Merge branch 'develop' into wip/akirathan/10157-lang-server-module

# Conflicts:
#	build.sbt
#	engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala
#	engine/runtime-instrument-repl-debugger/src/main/java/org/enso/interpreter/instrument/repl/debugger/ReplDebuggerInstrument.java
#	project/JPMSPlugin.scala
#	project/JPMSUtils.scala
This commit is contained in:
Pavel Marek 2024-09-04 08:30:52 +02:00
commit 595028cf40
652 changed files with 18560 additions and 12900 deletions

View File

@ -58,51 +58,6 @@ jobs:
env:
GRAAL_EDITION: GraalVM CE
timeout-minutes: 240
benchmark-engine-oracle-graal-vm:
name: Benchmark Engine (Oracle GraalVM)
runs-on:
- benchmark
steps:
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
name: Installing wasm-pack
uses: jetli/wasm-pack-action@v0.4.0
with:
version: v0.10.2
- name: Expose Artifact API and context information.
uses: actions/github-script@v7
with:
script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n "
- name: Checking out the repository
uses: actions/checkout@v4
with:
clean: false
submodules: recursive
- name: Build Script Setup
run: ./run --help || (git clean -ffdx && ./run --help)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- if: (always())
name: Clean before
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: ./run backend benchmark runtime
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- if: failure() && runner.os == 'Windows'
name: List files if failed (Windows)
run: Get-ChildItem -Force -Recurse
- if: failure() && runner.os != 'Windows'
name: List files if failed (non-Windows)
run: ls -lAR
- if: (always())
name: Clean after
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
GRAAL_EDITION: Oracle GraalVM
timeout-minutes: 240
env:
ENSO_BUILD_MINIMAL_RUN: ${{ true == inputs.just-check }}
ENSO_BUILD_SKIP_VERSION_CHECK: "true"

View File

@ -13,8 +13,8 @@ on:
type: boolean
default: false
jobs:
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-linux-x86_64:
name: Engine (GraalVM CE) (linux, x86_64)
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-linux-amd64:
name: Engine (GraalVM CE) (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -104,8 +104,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
GRAAL_EDITION: GraalVM CE
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-x86_64:
name: Engine (GraalVM CE) (macos, x86_64)
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-amd64:
name: Engine (GraalVM CE) (macos, amd64)
runs-on:
- macos-12
steps:
@ -148,8 +148,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
GRAAL_EDITION: GraalVM CE
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-windows-x86_64:
name: Engine (GraalVM CE) (windows, x86_64)
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-windows-amd64:
name: Engine (GraalVM CE) (windows, amd64)
runs-on:
- self-hosted
- Windows
@ -193,8 +193,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
GRAAL_EDITION: GraalVM CE
enso-build-ci-gen-job-ci-check-backend-oracle-graal-vm-linux-x86_64:
name: Engine (Oracle GraalVM) (linux, x86_64)
enso-build-ci-gen-job-ci-check-backend-oracle-graal-vm-linux-amd64:
name: Engine (Oracle GraalVM) (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -238,8 +238,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
GRAAL_EDITION: Oracle GraalVM
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-linux-x86_64:
name: JVM Tests (GraalVM CE) (linux, x86_64)
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-linux-amd64:
name: JVM Tests (GraalVM CE) (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -275,7 +275,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Engine Tests Report (GraalVM CE, linux, x86_64)
name: Engine Tests Report (GraalVM CE, linux, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -351,8 +351,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-x86_64:
name: JVM Tests (GraalVM CE) (macos, x86_64)
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-amd64:
name: JVM Tests (GraalVM CE) (macos, amd64)
runs-on:
- macos-12
steps:
@ -387,7 +387,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Engine Tests Report (GraalVM CE, macos, x86_64)
name: Engine Tests Report (GraalVM CE, macos, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -406,8 +406,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-windows-x86_64:
name: JVM Tests (GraalVM CE) (windows, x86_64)
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-windows-amd64:
name: JVM Tests (GraalVM CE) (windows, amd64)
runs-on:
- self-hosted
- Windows
@ -443,7 +443,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Engine Tests Report (GraalVM CE, windows, x86_64)
name: Engine Tests Report (GraalVM CE, windows, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -462,8 +462,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-jvm-tests-oracle-graal-vm-linux-x86_64:
name: JVM Tests (Oracle GraalVM) (linux, x86_64)
enso-build-ci-gen-job-jvm-tests-oracle-graal-vm-linux-amd64:
name: JVM Tests (Oracle GraalVM) (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -499,7 +499,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Engine Tests Report (Oracle GraalVM, linux, x86_64)
name: Engine Tests Report (Oracle GraalVM, linux, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -518,8 +518,8 @@ jobs:
GRAAL_EDITION: Oracle GraalVM
permissions:
checks: write
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-linux-x86_64:
name: Standard Library Tests (GraalVM CE) (linux, x86_64)
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-linux-amd64:
name: Standard Library Tests (GraalVM CE) (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -558,7 +558,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Standard Library Tests Report (GraalVM CE, linux, x86_64)
name: Standard Library Tests Report (GraalVM CE, linux, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -637,8 +637,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-x86_64:
name: Standard Library Tests (GraalVM CE) (macos, x86_64)
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-amd64:
name: Standard Library Tests (GraalVM CE) (macos, amd64)
runs-on:
- macos-12
steps:
@ -676,7 +676,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Standard Library Tests Report (GraalVM CE, macos, x86_64)
name: Standard Library Tests Report (GraalVM CE, macos, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -695,8 +695,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-windows-x86_64:
name: Standard Library Tests (GraalVM CE) (windows, x86_64)
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-windows-amd64:
name: Standard Library Tests (GraalVM CE) (windows, amd64)
runs-on:
- self-hosted
- Windows
@ -735,7 +735,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Standard Library Tests Report (GraalVM CE, windows, x86_64)
name: Standard Library Tests Report (GraalVM CE, windows, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -754,8 +754,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-standard-library-tests-oracle-graal-vm-linux-x86_64:
name: Standard Library Tests (Oracle GraalVM) (linux, x86_64)
enso-build-ci-gen-job-standard-library-tests-oracle-graal-vm-linux-amd64:
name: Standard Library Tests (Oracle GraalVM) (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -794,7 +794,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Standard Library Tests Report (Oracle GraalVM, linux, x86_64)
name: Standard Library Tests Report (Oracle GraalVM, linux, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
path-replace-backslashes: true
reporter: java-junit

View File

@ -13,8 +13,8 @@ on:
type: boolean
default: false
jobs:
enso-build-ci-gen-job-snowflake-tests-linux-x86_64:
name: Snowflake Tests (linux, x86_64)
enso-build-ci-gen-job-snowflake-tests-linux-amd64:
name: Snowflake Tests (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -56,7 +56,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Extra Library Tests Report (GraalVM CE, linux, x86_64)
name: Extra Library Tests Report (GraalVM CE, linux, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
path-replace-backslashes: true
reporter: java-junit

View File

@ -15,7 +15,7 @@ on:
type: boolean
default: false
jobs:
enso-build-ci-gen-job-cancel-workflow-linux-x86_64:
enso-build-ci-gen-job-cancel-workflow-linux-amd64:
name: Cancel Previous Runs
if: github.ref != 'refs/heads/develop'
runs-on:
@ -27,8 +27,8 @@ jobs:
access_token: ${{ github.token }}
permissions:
actions: write
enso-build-ci-gen-job-gui-check-linux-x86_64:
name: GUI tests (linux, x86_64)
enso-build-ci-gen-job-gui-check-linux-amd64:
name: GUI tests (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -70,8 +70,8 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-lint-linux-x86_64:
name: Lint (linux, x86_64)
enso-build-ci-gen-job-lint-linux-amd64:
name: Lint (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -113,8 +113,8 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-native-test-linux-x86_64:
name: Native Rust tests (linux, x86_64)
enso-build-ci-gen-job-native-test-linux-amd64:
name: Native Rust tests (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -156,8 +156,8 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-wasm-test-linux-x86_64:
name: WASM tests (linux, x86_64)
enso-build-ci-gen-job-wasm-test-linux-amd64:
name: WASM tests (linux, amd64)
runs-on:
- self-hosted
- Linux

View File

@ -15,8 +15,8 @@ on:
type: boolean
default: false
jobs:
enso-build-ci-gen-job-build-backend-linux-x86_64:
name: Build Backend (linux, x86_64)
enso-build-ci-gen-job-build-backend-linux-amd64:
name: Build Backend (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -58,8 +58,8 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-build-backend-macos-x86_64:
name: Build Backend (macos, x86_64)
enso-build-ci-gen-job-build-backend-macos-amd64:
name: Build Backend (macos, amd64)
runs-on:
- macos-12
steps:
@ -100,8 +100,8 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-build-backend-windows-x86_64:
name: Build Backend (windows, x86_64)
enso-build-ci-gen-job-build-backend-windows-amd64:
name: Build Backend (windows, amd64)
runs-on:
- self-hosted
- Windows
@ -143,7 +143,7 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-cancel-workflow-linux-x86_64:
enso-build-ci-gen-job-cancel-workflow-linux-amd64:
name: Cancel Previous Runs
if: github.ref != 'refs/heads/develop'
runs-on:
@ -155,8 +155,8 @@ jobs:
access_token: ${{ github.token }}
permissions:
actions: write
enso-build-ci-gen-job-gui-build-linux-x86_64:
name: GUI build (linux, x86_64)
enso-build-ci-gen-job-gui-build-linux-amd64:
name: GUI build (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -210,8 +210,8 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-gui-build-macos-x86_64:
name: GUI build (macos, x86_64)
enso-build-ci-gen-job-gui-build-macos-amd64:
name: GUI build (macos, amd64)
runs-on:
- macos-12
steps:
@ -264,8 +264,8 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-gui-build-windows-x86_64:
name: GUI build (windows, x86_64)
enso-build-ci-gen-job-gui-build-windows-amd64:
name: GUI build (windows, amd64)
runs-on:
- self-hosted
- Windows
@ -319,10 +319,10 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-package-ide-linux-x86_64:
name: Package New IDE (linux, x86_64)
enso-build-ci-gen-job-package-ide-linux-amd64:
name: Package New IDE (linux, amd64)
needs:
- enso-build-ci-gen-job-build-backend-linux-x86_64
- enso-build-ci-gen-job-build-backend-linux-amd64
runs-on:
- self-hosted
- Linux
@ -376,10 +376,10 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-package-ide-macos-x86_64:
name: Package New IDE (macos, x86_64)
enso-build-ci-gen-job-package-ide-macos-amd64:
name: Package New IDE (macos, amd64)
needs:
- enso-build-ci-gen-job-build-backend-macos-x86_64
- enso-build-ci-gen-job-build-backend-macos-amd64
runs-on:
- macos-12
steps:
@ -439,10 +439,10 @@ jobs:
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enso-build-ci-gen-job-package-ide-windows-x86_64:
name: Package New IDE (windows, x86_64)
enso-build-ci-gen-job-package-ide-windows-amd64:
name: Package New IDE (windows, amd64)
needs:
- enso-build-ci-gen-job-build-backend-windows-x86_64
- enso-build-ci-gen-job-build-backend-windows-amd64
runs-on:
- self-hosted
- Windows

View File

@ -21,8 +21,8 @@ on:
required: true
type: string
jobs:
enso-build-ci-gen-promote-release-job-linux-x86_64:
name: Promote release (linux, x86_64)
enso-build-ci-gen-promote-release-job-linux-amd64:
name: Promote release (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -72,10 +72,10 @@ jobs:
release:
name: Release
needs:
- enso-build-ci-gen-promote-release-job-linux-x86_64
- enso-build-ci-gen-promote-release-job-linux-amd64
uses: ./.github/workflows/release.yml
with:
version: ${{ needs.enso-build-ci-gen-promote-release-job-linux-x86_64.outputs.ENSO_VERSION }}
version: ${{ needs.enso-build-ci-gen-promote-release-job-linux-amd64.outputs.ENSO_VERSION }}
secrets: inherit
env:
ENSO_BUILD_SKIP_VERSION_CHECK: "true"

View File

@ -16,7 +16,7 @@ on:
required: true
type: string
jobs:
enso-build-ci-gen-draft-release-linux-x86_64:
enso-build-ci-gen-draft-release-linux-amd64:
name: Create a release draft.
runs-on:
- self-hosted
@ -47,11 +47,11 @@ jobs:
outputs:
ENSO_RELEASE_ID: ${{ steps.prepare.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ steps.prepare.outputs.ENSO_VERSION }}
enso-build-ci-gen-job-deploy-runtime-linux-x86_64:
name: Upload Runtime to ECR (linux, x86_64)
enso-build-ci-gen-job-deploy-runtime-linux-amd64:
name: Upload Runtime to ECR (linux, amd64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-job-upload-backend-linux-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
- enso-build-ci-gen-job-upload-backend-linux-amd64
runs-on:
- self-hosted
- Linux
@ -98,12 +98,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
enso-build-ci-gen-job-upload-backend-linux-x86_64:
name: Upload Backend (linux, x86_64)
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
enso-build-ci-gen-job-upload-backend-linux-amd64:
name: Upload Backend (linux, amd64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
runs-on:
- self-hosted
- Linux
@ -146,12 +146,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
enso-build-ci-gen-job-upload-backend-macos-aarch64:
name: Upload Backend (macos, aarch64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
runs-on:
- self-hosted
- macOS
@ -195,12 +195,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
enso-build-ci-gen-job-upload-backend-macos-x86_64:
name: Upload Backend (macos, x86_64)
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
enso-build-ci-gen-job-upload-backend-macos-amd64:
name: Upload Backend (macos, amd64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
runs-on:
- macos-12
steps:
@ -242,12 +242,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
enso-build-ci-gen-job-upload-backend-windows-x86_64:
name: Upload Backend (windows, x86_64)
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
enso-build-ci-gen-job-upload-backend-windows-amd64:
name: Upload Backend (windows, amd64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
runs-on:
- self-hosted
- Windows
@ -290,17 +290,17 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
enso-build-ci-gen-publish-release-linux-x86_64:
name: Publish release (linux, x86_64)
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
enso-build-ci-gen-publish-release-linux-amd64:
name: Publish release (linux, amd64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-job-deploy-runtime-linux-x86_64
- enso-build-ci-gen-upload-ide-linux-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
- enso-build-ci-gen-job-deploy-runtime-linux-amd64
- enso-build-ci-gen-upload-ide-linux-amd64
- enso-build-ci-gen-upload-ide-macos-aarch64
- enso-build-ci-gen-upload-ide-macos-x86_64
- enso-build-ci-gen-upload-ide-windows-x86_64
- enso-build-ci-gen-upload-ide-macos-amd64
- enso-build-ci-gen-upload-ide-windows-amd64
runs-on:
- self-hosted
- Linux
@ -346,13 +346,13 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_S3_ACCESS_KEY_ID }}
AWS_REGION: us-west-1
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_S3_SECRET_ACCESS_KEY }}
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
enso-build-ci-gen-upload-ide-linux-x86_64:
name: Build IDE (linux, x86_64)
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
enso-build-ci-gen-upload-ide-linux-amd64:
name: Build IDE (linux, amd64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-job-upload-backend-linux-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
- enso-build-ci-gen-job-upload-backend-linux-amd64
runs-on:
- self-hosted
- Linux
@ -407,12 +407,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
enso-build-ci-gen-upload-ide-macos-aarch64:
name: Build IDE (macos, aarch64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
- enso-build-ci-gen-job-upload-backend-macos-aarch64
runs-on:
- self-hosted
@ -476,13 +476,13 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
enso-build-ci-gen-upload-ide-macos-x86_64:
name: Build IDE (macos, x86_64)
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
enso-build-ci-gen-upload-ide-macos-amd64:
name: Build IDE (macos, amd64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-job-upload-backend-macos-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
- enso-build-ci-gen-job-upload-backend-macos-amd64
runs-on:
- macos-12
steps:
@ -543,13 +543,13 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
enso-build-ci-gen-upload-ide-windows-x86_64:
name: Build IDE (windows, x86_64)
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
enso-build-ci-gen-upload-ide-windows-amd64:
name: Build IDE (windows, amd64)
needs:
- enso-build-ci-gen-draft-release-linux-x86_64
- enso-build-ci-gen-job-upload-backend-windows-x86_64
- enso-build-ci-gen-draft-release-linux-amd64
- enso-build-ci-gen-job-upload-backend-windows-amd64
runs-on:
- self-hosted
- Windows
@ -606,8 +606,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
env:
ENSO_BUILD_SKIP_VERSION_CHECK: "true"
ENSO_EDITION: ${{ inputs.version }}

View File

@ -15,7 +15,7 @@ on:
type: boolean
default: false
jobs:
enso-build-ci-gen-job-cancel-workflow-linux-x86_64:
enso-build-ci-gen-job-cancel-workflow-linux-amd64:
name: Cancel Previous Runs
if: github.ref != 'refs/heads/develop'
runs-on:
@ -27,8 +27,8 @@ jobs:
access_token: ${{ github.token }}
permissions:
actions: write
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-linux-x86_64:
name: Engine (GraalVM CE) (linux, x86_64)
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-linux-amd64:
name: Engine (GraalVM CE) (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -72,8 +72,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
GRAAL_EDITION: GraalVM CE
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-x86_64:
name: Engine (GraalVM CE) (macos, x86_64)
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-amd64:
name: Engine (GraalVM CE) (macos, amd64)
runs-on:
- macos-12
steps:
@ -116,8 +116,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
GRAAL_EDITION: GraalVM CE
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-windows-x86_64:
name: Engine (GraalVM CE) (windows, x86_64)
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-windows-amd64:
name: Engine (GraalVM CE) (windows, amd64)
runs-on:
- self-hosted
- Windows
@ -161,8 +161,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
GRAAL_EDITION: GraalVM CE
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-linux-x86_64:
name: JVM Tests (GraalVM CE) (linux, x86_64)
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-linux-amd64:
name: JVM Tests (GraalVM CE) (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -198,7 +198,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Engine Tests Report (GraalVM CE, linux, x86_64)
name: Engine Tests Report (GraalVM CE, linux, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -217,8 +217,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-x86_64:
name: JVM Tests (GraalVM CE) (macos, x86_64)
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-amd64:
name: JVM Tests (GraalVM CE) (macos, amd64)
runs-on:
- macos-12
steps:
@ -253,7 +253,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Engine Tests Report (GraalVM CE, macos, x86_64)
name: Engine Tests Report (GraalVM CE, macos, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -272,8 +272,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-windows-x86_64:
name: JVM Tests (GraalVM CE) (windows, x86_64)
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-windows-amd64:
name: JVM Tests (GraalVM CE) (windows, amd64)
runs-on:
- self-hosted
- Windows
@ -309,7 +309,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Engine Tests Report (GraalVM CE, windows, x86_64)
name: Engine Tests Report (GraalVM CE, windows, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -328,8 +328,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-linux-x86_64:
name: Standard Library Tests (GraalVM CE) (linux, x86_64)
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-linux-amd64:
name: Standard Library Tests (GraalVM CE) (linux, amd64)
runs-on:
- self-hosted
- Linux
@ -368,7 +368,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Standard Library Tests Report (GraalVM CE, linux, x86_64)
name: Standard Library Tests Report (GraalVM CE, linux, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -387,8 +387,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-x86_64:
name: Standard Library Tests (GraalVM CE) (macos, x86_64)
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-amd64:
name: Standard Library Tests (GraalVM CE) (macos, amd64)
runs-on:
- macos-12
steps:
@ -426,7 +426,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Standard Library Tests Report (GraalVM CE, macos, x86_64)
name: Standard Library Tests Report (GraalVM CE, macos, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -445,8 +445,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-windows-x86_64:
name: Standard Library Tests (GraalVM CE) (windows, x86_64)
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-windows-amd64:
name: Standard Library Tests (GraalVM CE) (windows, amd64)
runs-on:
- self-hosted
- Windows
@ -485,7 +485,7 @@ jobs:
uses: dorny/test-reporter@v1
with:
max-annotations: 50
name: Standard Library Tests Report (GraalVM CE, windows, x86_64)
name: Standard Library Tests Report (GraalVM CE, windows, amd64)
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
path-replace-backslashes: true
reporter: java-junit
@ -504,8 +504,8 @@ jobs:
GRAAL_EDITION: GraalVM CE
permissions:
checks: write
enso-build-ci-gen-job-verify-license-packages-linux-x86_64:
name: Verify License Packages (linux, x86_64)
enso-build-ci-gen-job-verify-license-packages-linux-amd64:
name: Verify License Packages (linux, amd64)
runs-on:
- self-hosted
- Linux

View File

@ -58,51 +58,6 @@ jobs:
env:
GRAAL_EDITION: GraalVM CE
timeout-minutes: 240
benchmark-standard-libraries-oracle-graal-vm:
name: Benchmark Standard Libraries (Oracle GraalVM)
runs-on:
- benchmark
steps:
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
name: Installing wasm-pack
uses: jetli/wasm-pack-action@v0.4.0
with:
version: v0.10.2
- name: Expose Artifact API and context information.
uses: actions/github-script@v7
with:
script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n "
- name: Checking out the repository
uses: actions/checkout@v4
with:
clean: false
submodules: recursive
- name: Build Script Setup
run: ./run --help || (git clean -ffdx && ./run --help)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- if: (always())
name: Clean before
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: ./run backend benchmark enso-jmh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- if: failure() && runner.os == 'Windows'
name: List files if failed (Windows)
run: Get-ChildItem -Force -Recurse
- if: failure() && runner.os != 'Windows'
name: List files if failed (non-Windows)
run: ls -lAR
- if: (always())
name: Clean after
run: ./run git-clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
env:
GRAAL_EDITION: Oracle GraalVM
timeout-minutes: 240
env:
ENSO_BUILD_MINIMAL_RUN: ${{ true == inputs.just-check }}
ENSO_BUILD_SKIP_VERSION_CHECK: "true"

View File

@ -3,8 +3,21 @@
#### Enso IDE
- [Table Editor Widget][10774] displayed in `Table.new` component.
- [New design of Component Browser][10814] - the component list is under the
input and shown only in the initial "component browsing" mode - in this mode
the entire input is a filtering pattern (it is not interpreted as parts of
code). After picking any suggestion with Tab or new button the mode is
switched to "code editing", where visualization preview is displayed instead.
- [Drilldown for XML][10824]
- [Fixed issue where switching edited widget with <kbd>tab</kbd> key did not
updated actual code][10857]
- [Added fullscreen modes to documentation editor and code editor][10876]
[10774]: https://github.com/enso-org/enso/pull/10774
[10814]: https://github.com/enso-org/enso/pull/10814
[10824]: https://github.com/enso-org/enso/pull/10824
[10857]: https://github.com/enso-org/enso/pull/10857
[10876]: https://github.com/enso-org/enso/pull/10876
#### Enso Standard Library
@ -17,6 +30,10 @@
- [Mixed Decimal/Float arithmetic now throws an error; mixed comparisons now
attach warnings.][10725]
- [Support for creating Atoms in expressions.][10820]
- [IO.print without new line][10858]
- [Add `Text.to_decimal`.][10874]
- [Added .floor, .ceil, .trunc to the in-memory `Decimal` column.][10887]
- [Added vectorized .round to the in-memory `Decimal` column.][10912]
[10614]: https://github.com/enso-org/enso/pull/10614
[10660]: https://github.com/enso-org/enso/pull/10660
@ -24,8 +41,18 @@
[10733]: https://github.com/enso-org/enso/pull/10733
[10725]: https://github.com/enso-org/enso/pull/10725
[10820]: https://github.com/enso-org/enso/pull/10820
[10858]: https://github.com/enso-org/enso/pull/10858
[10874]: https://github.com/enso-org/enso/pull/10874
[10887]: https://github.com/enso-org/enso/pull/10887
[10912]: https://github.com/enso-org/enso/pull/10912
# Enso 2023.3
#### Enso Language & Runtime
- [Print out warnings associated with local variables][10842]
[10842]: https://github.com/enso-org/enso/pull/10842
# Enso 2024.3
#### Enso Language & Runtime

4
Cargo.lock generated
View File

@ -1205,6 +1205,7 @@ dependencies = [
"bytes",
"chrono",
"clap 4.5.15",
"convert_case",
"derive-where",
"derive_more",
"dirs",
@ -1216,7 +1217,6 @@ dependencies = [
"futures",
"glob",
"handlebars",
"heck 0.4.1",
"ide-ci",
"mime",
"new_mime_guess",
@ -2186,6 +2186,7 @@ dependencies = [
"bytes",
"chrono",
"clap 4.5.15",
"convert_case",
"data-encoding",
"dependency_runner",
"derive-where",
@ -2200,7 +2201,6 @@ dependencies = [
"futures-util",
"glob",
"headers",
"heck 0.4.1",
"http-serde",
"indicatif",
"itertools",

View File

@ -92,7 +92,7 @@ blocks_in_conditions = "allow" # Until the issue is fixed: https://github.com/ru
# We are tryingto maintain minimum set of dependencies. Before adding a new dependency, consult it
# with the core development team. Thank you!
chrono = { version = "0.4.31", features = ["serde"] }
clap = { version = "4.5.4", features = ["derive", "env", "wrap_help", "string"] }
clap = { version = "4.5.15", features = ["derive", "env", "wrap_help", "string"] }
derive-where = "1.2.7"
directories = { version = "5.0.1" }
dirs = { version = "5.0.1" }

View File

@ -5,88 +5,88 @@
"type": "node",
"request": "launch",
"name": "Dashboard",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "dev"]
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-dashboard", "dev"]
},
{
"type": "node",
"request": "launch",
"name": "GUI",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "dev"]
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev:gui"]
},
{
"type": "node",
"request": "launch",
"name": "GUI (Storybook)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "story:dev"]
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-gui2", "story:dev"]
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (Build)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "build:cloud"]
"runtimeExecutable": "pnpm",
"runtimeArgs": ["build:gui"]
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (E2E UI)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test:e2e:debug"]
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e:debug"]
},
{
"type": "node",
"request": "launch",
"name": "GUI (E2E UI)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "test:e2e", "--", "--ui"]
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e", "--", "--ui"]
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (All tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test"]
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test"]
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (E2E tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test:e2e"],
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (Unit tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test:unit"],
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:unit"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "GUI (All tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "test"]
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-gui2", "test"]
},
{
"type": "node",
"request": "launch",
"name": "GUI (E2E tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "test:e2e"],
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "GUI (Unit tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "test:unit", "--", "run"],
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:unit", "--", "run"],
"outputCapture": "std"
}
]

View File

@ -65,12 +65,12 @@ export function locateNewLabelModalColorButtons(page: test.Page) {
/** Find a "name" input for an "upsert secret" modal (if any) on the current page. */
export function locateSecretNameInput(page: test.Page) {
return locateUpsertSecretModal(page).getByPlaceholder('Enter the name of the secret')
return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretNamePlaceholder)
}
/** Find a "value" input for an "upsert secret" modal (if any) on the current page. */
export function locateSecretValueInput(page: test.Page) {
return locateUpsertSecretModal(page).getByPlaceholder('Enter the value of the secret')
return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder)
}
/** Find a search bar input (if any) on the current page. */
@ -100,16 +100,6 @@ export function locateSetUsernameButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Set Username' }).getByText('Set Username')
}
/** Find a "delete" button (if any) on the current page. */
export function locateDeleteButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Delete' }).getByText('Delete')
}
/** Find a button to delete something (if any) on the current page. */
export function locateDeleteIcon(page: test.Locator | test.Page) {
return page.getByAltText('Delete')
}
/** Find a "create" button (if any) on the current page. */
export function locateCreateButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Create' }).getByText('Create')
@ -765,7 +755,7 @@ export async function login(
await locateLoginButton(page).click()
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
if (first) {
await passTermsAndConditionsDialog({ page, setupAPI })
await passAgreementsDialog({ page, setupAPI })
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
}
})
@ -840,11 +830,12 @@ async function mockDate({ page }: MockParams) {
})
}
/** Pass the Terms and conditions dialog. */
export async function passTermsAndConditionsDialog({ page }: MockParams) {
/** Pass the Agreements dialog. */
export async function passAgreementsDialog({ page }: MockParams) {
await test.test.step('Accept Terms and Conditions', async () => {
await page.waitForSelector('#terms-of-service-modal')
await page.getByRole('checkbox').click()
await page.waitForSelector('#agreements-modal')
await page.getByRole('checkbox').and(page.getByTestId('terms-of-service-checkbox')).click()
await page.getByRole('checkbox').and(page.getByTestId('privacy-policy-checkbox')).click()
await page.getByRole('button', { name: 'Accept' }).click()
})
}

View File

@ -174,7 +174,7 @@ export default class DrivePageActions extends PageActions {
await test.expect(locateAssetRows(page)).toHaveCount(0)
const nonAssetRows = locateNonAssetRows(page)
await test.expect(nonAssetRows).toHaveCount(1)
await test.expect(nonAssetRows).toHaveText(/You have no files/)
await test.expect(nonAssetRows).toHaveText(/This folder is empty/)
})
},
/** A test assertion to confirm that there is only one row visible, and that row is the

View File

@ -1,6 +1,7 @@
/** @file Actions for a "new Data Link" modal. */
import type * as test from 'playwright/test'
import { TEXT } from '../actions'
import type * as baseActions from './BaseActions'
import BaseActions from './BaseActions'
import DrivePageActions from './DrivePageActions'
@ -11,7 +12,7 @@ import DrivePageActions from './DrivePageActions'
/** Locate the "new data link" modal. */
function locateNewDataLinkModal(page: test.Page) {
return page.getByRole('heading').and(page.getByText('Create Datalink')).locator('..')
return page.getByRole('dialog').filter({ has: page.getByText('Create Datalink') })
}
// ===============================
@ -30,7 +31,7 @@ export default class NewDataLinkModalActions extends BaseActions {
/** Interact with the "name" input - for example, to set the name using `.fill("")`. */
withNameInput(callback: baseActions.LocatorCallback) {
return this.step('Interact with "name" input', async (page) => {
const locator = locateNewDataLinkModal(page).getByLabel('Name')
const locator = locateNewDataLinkModal(page).getByPlaceholder(TEXT.datalinkNamePlaceholder)
await callback(locator)
})
}

View File

@ -83,6 +83,14 @@ export default class RegisterPageActions extends BaseActions {
await this.page.getByPlaceholder(TEXT.emailPlaceholder).fill(email)
await this.page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password)
await this.page.getByPlaceholder(TEXT.confirmPasswordPlaceholder).fill(confirmPassword)
await this.page
.getByRole('checkbox')
.and(this.page.getByTestId('terms-of-service-checkbox'))
.click()
await this.page
.getByRole('checkbox')
.and(this.page.getByTestId('privacy-policy-checkbox'))
.click()
await this.page
.getByRole('button', { name: TEXT.register, exact: true })
.getByText(TEXT.register)

View File

@ -80,7 +80,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
isEnabled: true,
rootDirectoryId: defaultDirectoryId,
userGroups: null,
plan: backend.Plan.enterprise,
plan: backend.Plan.solo,
isOrganizationAdmin: true,
}
const defaultOrganization: backend.OrganizationInfo = {
@ -530,6 +530,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
return
}
})
await get(remoteBackendPaths.LIST_USER_GROUPS_PATH + '*', async (route) => {
await route.fulfill({ json: [] })
})
await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => ({
versions: [
{
@ -928,6 +931,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
goOnline: () => {
isOnline = true
},
setPlan: (plan: backend.Plan) => {
if (currentUser) {
object.unsafeMutable(currentUser).plan = plan
}
},
currentUser: () => currentUser,
setCurrentUser: (user: backend.User | null) => {
currentUser = user

View File

@ -65,7 +65,7 @@ test.test('asset panel contents', ({ page }) =>
})
.login()
.do(async (thePage) => {
await actions.passTermsAndConditionsDialog({ page: thePage })
await actions.passAgreementsDialog({ page: thePage })
})
.driveTable.clickRow(0)
.toggleAssetPanel()

View File

@ -1,51 +1,64 @@
/** @file Test the labels sidebar panel. */
import * as test from '@playwright/test'
import * as actions from './actions'
import {
locateCreateButton,
locateLabelsPanel,
locateLabelsPanelLabels,
locateModalBackground,
locateNewLabelButton,
locateNewLabelModal,
locateNewLabelModalColorButtons,
locateNewLabelModalNameInput,
mockAllAndLogin,
TEXT,
} from './actions'
test.test.beforeEach(({ page }) => actions.mockAllAndLogin({ page }))
test.test.beforeEach(({ page }) => mockAllAndLogin({ page }))
test.test('labels', async ({ page }) => {
// Empty labels panel
await test.expect(actions.locateLabelsPanel(page)).toBeVisible()
await test.expect(locateLabelsPanel(page)).toBeVisible()
// "Create label" modal
await actions.locateNewLabelButton(page).click()
await test.expect(actions.locateNewLabelModal(page)).toBeVisible()
await locateNewLabelButton(page).click()
await test.expect(locateNewLabelModal(page)).toBeVisible()
await page.press('body', 'Escape')
await test.expect(actions.locateNewLabelModal(page)).not.toBeVisible()
await actions.locateNewLabelButton(page).click()
await actions.locateModalBackground(page).click()
await test.expect(actions.locateNewLabelModal(page)).not.toBeVisible()
await actions.locateNewLabelButton(page).click()
await test.expect(locateNewLabelModal(page)).not.toBeVisible()
await locateNewLabelButton(page).click()
await locateModalBackground(page).click()
await test.expect(locateNewLabelModal(page)).not.toBeVisible()
await locateNewLabelButton(page).click()
// "Create label" modal with name set
await actions.locateNewLabelModalNameInput(page).fill('New Label')
await test.expect(actions.locateNewLabelModal(page)).toHaveText(/^New Label/)
await locateNewLabelModalNameInput(page).fill('New Label')
await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
await page.press('body', 'Escape')
// "Create label" modal with color set
// The exact number is allowed to vary; but to click the fourth color, there must be at least
// four colors.
await actions.locateNewLabelButton(page).click()
test.expect(await actions.locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4)
await locateNewLabelButton(page).click()
test.expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4)
// `force: true` is required because the `label` needs to handle the click event, not the
// `button`.
await actions.locateNewLabelModalColorButtons(page).nth(4).click({ force: true })
await test.expect(actions.locateNewLabelModal(page)).toBeVisible()
await locateNewLabelModalColorButtons(page).nth(4).click({ force: true })
await test.expect(locateNewLabelModal(page)).toBeVisible()
// "Create label" modal with name and color set
await actions.locateNewLabelModalNameInput(page).fill('New Label')
await test.expect(actions.locateNewLabelModal(page)).toHaveText(/^New Label/)
await locateNewLabelModalNameInput(page).fill('New Label')
await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
// Labels panel with one entry
await actions.locateCreateButton(actions.locateNewLabelModal(page)).click()
await test.expect(actions.locateLabelsPanel(page)).toBeVisible()
await locateCreateButton(locateNewLabelModal(page)).click()
await test.expect(locateLabelsPanel(page)).toBeVisible()
// Empty labels panel again, after deleting the only entry
await actions.locateLabelsPanelLabels(page).first().hover()
await actions.locateDeleteIcon(actions.locateLabelsPanel(page)).first().click()
await actions.locateDeleteButton(page).click()
test.expect(await actions.locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1)
await locateLabelsPanelLabels(page).first().hover()
const labelsPanel = locateLabelsPanel(page)
await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click()
await page.getByRole('button', { name: 'Delete' }).getByText('Delete').click()
test.expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1)
})

View File

@ -12,7 +12,7 @@ test.test('login and logout', ({ page }) =>
.mockAll({ page })
.login()
.do(async (thePage) => {
await actions.passTermsAndConditionsDialog({ page: thePage })
await actions.passAgreementsDialog({ page: thePage })
await test.expect(actions.locateDriveView(thePage)).toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()
})

View File

@ -4,7 +4,7 @@ import * as test from '@playwright/test'
import {
INVALID_PASSWORD,
mockAll,
passTermsAndConditionsDialog,
passAgreementsDialog,
TEXT,
VALID_EMAIL,
VALID_PASSWORD,
@ -26,7 +26,7 @@ test.test('login screen', ({ page }) =>
// Technically it should not be allowed, but
.login(VALID_EMAIL, INVALID_PASSWORD)
.do(async (thePage) => {
await passTermsAndConditionsDialog({ page: thePage })
await passAgreementsDialog({ page: thePage })
})
.withDriveView(async (driveView) => {
await test.expect(driveView).toBeVisible()

View File

@ -1,15 +1,21 @@
/** @file Test the user settings tab. */
import * as test from '@playwright/test'
import * as backend from '#/services/Backend'
import * as actions from './actions'
test.test('members settings', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const api = await actions.mockAllAndLoginAndExposeAPI({
page,
setupAPI: (theApi) => {
theApi.setPlan(backend.Plan.enterprise)
// Setup
theApi.setCurrentOrganization(theApi.defaultOrganization)
},
})
const localActions = actions.settings.members
// Setup
api.setCurrentOrganization(api.defaultOrganization)
await localActions.go(page)
await test
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
@ -17,7 +23,6 @@ test.test('members settings', async ({ page }) => {
const otherUserName = 'second.user_'
const otherUser = api.addUser(otherUserName)
// await actions.closeModal(page)
await actions.relog({ page })
await localActions.go(page)
await test
@ -25,7 +30,6 @@ test.test('members settings', async ({ page }) => {
.toHaveText([api.currentUser()?.name ?? '', otherUserName])
api.deleteUser(otherUser.userId)
// await actions.closeModal(page)
await actions.relog({ page })
await localActions.go(page)
await test

View File

@ -31,8 +31,8 @@ test.test('organization settings', async ({ page }) => {
await test.test.step('Unset name (should fail)', async () => {
await nameInput.fill('')
await nameInput.press('Enter')
await test.expect(nameInput).toHaveValue('')
test.expect(api.currentOrganization()?.name).toBe(newName)
await test.expect(nameInput).toHaveValue(newName)
})
const invalidEmail = 'invalid@email'

View File

@ -31,6 +31,7 @@
"@aws-amplify/auth": "5.6.5",
"@aws-amplify/core": "5.8.5",
"@hookform/resolvers": "^3.4.0",
"@internationalized/date": "^3.5.5",
"@monaco-editor/react": "4.6.0",
"@sentry/react": "^7.74.0",
"@stripe/react-stripe-js": "^2.7.1",
@ -40,17 +41,18 @@
"ajv": "^8.12.0",
"clsx": "^2.1.1",
"enso-common": "workspace:*",
"framer-motion": "11.3.0",
"is-network-error": "^1.0.1",
"monaco-editor": "0.48.0",
"react": "^18.3.1",
"react-aria": "^3.33.0",
"react-aria-components": "^1.2.0",
"react-aria": "^3.34.3",
"react-aria-components": "^1.3.3",
"react-dom": "^18.3.1",
"react-error-boundary": "4.0.13",
"react-hook-form": "^7.51.4",
"react-router": "^6.23.1",
"react-router-dom": "^6.23.1",
"react-stately": "^3.31.0",
"react-stately": "^3.32.2",
"react-toastify": "^9.1.3",
"tailwind-merge": "^2.3.0",
"tailwind-variants": "0.2.1",
@ -58,8 +60,7 @@
"ts-results": "^3.3.0",
"validator": "^13.12.0",
"zod": "^3.23.8",
"zustand": "^4.5.4",
"framer-motion": "11.3.0"
"zustand": "^4.5.4"
},
"devDependencies": {
"@fast-check/vitest": "^0.0.8",

View File

@ -47,7 +47,7 @@ import * as appUtils from '#/appUtils'
import * as inputBindingsModule from '#/configurations/inputBindings'
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider from '#/providers/BackendProvider'
import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider'
import DriveProvider from '#/providers/DriveProvider'
import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider'
import { useHttpClient } from '#/providers/HttpClientProvider'
@ -80,8 +80,8 @@ import * as errorBoundary from '#/components/ErrorBoundary'
import * as suspense from '#/components/Suspense'
import AboutModal from '#/modals/AboutModal'
import { AgreementsModal } from '#/modals/AgreementsModal'
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
import LocalBackend from '#/services/LocalBackend'
import ProjectManager, * as projectManager from '#/services/ProjectManager'
@ -93,6 +93,8 @@ import LocalStorage from '#/utilities/LocalStorage'
import * as object from '#/utilities/object'
import { useInitAuthService } from '#/authentication/service'
import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'
import { Path } from '#/utilities/path'
// ============================
// === Global configuration ===
@ -102,6 +104,7 @@ declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly inputBindings: Readonly<Record<string, readonly string[]>>
readonly localRootDirectory: string
}
}
@ -118,6 +121,8 @@ LocalStorage.registerKey('inputBindings', {
),
})
LocalStorage.registerKey('localRootDirectory', { schema: z.string() })
// ======================
// === getMainPageUrl ===
// ======================
@ -347,6 +352,12 @@ function AppRouter(props: AppRouterProps) {
}
}, [localStorage, inputBindingsRaw])
const mainPageUrl = getMainPageUrl()
// Subscribe to `localStorage` updates to trigger a rerender when the terms of service
// or privacy policy have been accepted.
localStorageProvider.useLocalStorageState('termsOfService')
localStorageProvider.useLocalStorageState('privacyPolicy')
const authService = useInitAuthService(props)
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
const refreshUserSession =
@ -420,24 +431,26 @@ function AppRouter(props: AppRouterProps) {
{/* Protected pages are visible to authenticated users. */}
<router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route element={<authProvider.ProtectedLayout />}>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
<router.Route element={<AgreementsModal />}>
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
<router.Route
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
<router.Route element={<InvitedToOrganizationModal />}>
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
<router.Route
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
<router.Route
path={appUtils.SUBSCRIBE_PATH}
element={
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<subscribe.Subscribe />
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
<router.Route
path={appUtils.SUBSCRIBE_PATH}
element={
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<subscribe.Subscribe />
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
</router.Route>
</router.Route>
</router.Route>
</router.Route>
@ -455,7 +468,7 @@ function AppRouter(props: AppRouterProps) {
</router.Route>
</router.Route>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
<router.Route element={<AgreementsModal />}>
<router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route path={appUtils.SETUP_PATH} element={<setup.Setup />} />
</router.Route>
@ -499,6 +512,7 @@ function AppRouter(props: AppRouterProps) {
* due to modals being in `TheModal`. */}
<DriveProvider>
<errorBoundary.ErrorBoundary>
<LocalBackendPathSynchronizer />
<VersionChecker />
{routes}
{detect.IS_DEV_MODE && (
@ -516,3 +530,21 @@ function AppRouter(props: AppRouterProps) {
</DevtoolsProvider>
)
}
// ====================================
// === LocalBackendPathSynchronizer ===
// ====================================
/** Keep `localBackend.rootPath` in sync with the saved root path state. */
function LocalBackendPathSynchronizer() {
const [localRootDirectory] = localStorageProvider.useLocalStorageState('localRootDirectory')
const localBackend = useLocalBackend()
if (localBackend) {
if (localRootDirectory != null) {
localBackend.rootPath = Path(localRootDirectory)
} else {
localBackend.resetRootPath()
}
}
return null
}

View File

@ -37,6 +37,10 @@ export const ALL_PATHS_REGEX = new RegExp(
// === Constants related to URLs ===
export const SEARCH_PARAMS_PREFIX = 'cloud-ide_'
/** Return the email address for contacting support. */
export const SUPPORT_EMAIL = 'cloud@enso.org'
/** Return the `mailto:` URL for contacting support. */
export const SUPPORT_EMAIL_URL = `mailto:${SUPPORT_EMAIL}`
/**
* Build a Subscription URL for a given plan.

View File

@ -1,8 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M2.08579 3.75735L6.32843 7.99999L2.08579 12.2426L3.5 13.6568L7.7426 9.41421L9.1569 7.99999L7.7426 6.58578L3.5 2.34314L2.08579 3.75735Z"
fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.08579 3.75735L11.32843 7.99999L7.08579 12.2426L8.5 13.6568L12.7426 9.41421L14.1569 7.99999L12.7426 6.58578L8.5 2.34314L7.08579 3.75735Z"
fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 548 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="m2 6.5h12v3h-12" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 185 B

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 11C3 9.34315 4.34315 8 6 8H10C11.6569 8 13 9.34315 13 11V14C13 14.5523 12.5523 15 12 15H4C3.44772 15 3 14.5523 3 14V11Z" fill="black"/>
<circle cx="8" cy="4" r="3" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -1,4 +1,3 @@
<svg width="6" height="6" viewBox="0 0 6 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 0H2V2L0 2V4H2V6H4V4H6V2L4 2V0Z" fill="black"
fill-opacity="0.3" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 0H2V2L0 2V4H2V6H4V4H6V2L4 2V0Z" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 202 B

View File

@ -1,25 +1,26 @@
/** @file Alert component. */
import * as React from 'react'
import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as twv from '#/utilities/tailwindVariants'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
// =================
// === Constants ===
// =================
export const ALERT_STYLES = twv.tv({
export const ALERT_STYLES = tv({
base: 'flex flex-col items-stretch',
variants: {
fullWidth: { true: 'w-full' },
variant: {
custom: '',
outline: 'border border-2 bg-transparent border-primary/30 text-primary',
neutral: 'border border-2 bg-gray-100 border-gray-800 text-primary',
error: 'border border-2 bg-red-100 border-danger text-primary',
info: 'border border-2 bg-blue-100 border-blue-800 text-blue-800',
success: 'border border-2 bg-green-100 border-green-800 text-green-800',
warning: 'border border-2 bg-yellow-100 border-yellow-800 text-yellow-800',
outline: 'border border-0.5 bg-transparent border-primary/20 text-primary',
neutral: 'border border-0.5 bg-gray-100 border-gray-800 text-primary',
error: 'border border-0.5 bg-red-100 border-danger text-primary',
info: 'border border-0.5 bg-blue-100 border-blue-800 text-blue-800',
success: 'border border-0.5 bg-green-100 border-green-800 text-green-800',
warning: 'border border-0.5 bg-yellow-100 border-yellow-800 text-yellow-800',
},
rounded: {
none: 'rounded-none',
@ -51,14 +52,14 @@ export const ALERT_STYLES = twv.tv({
/** Props for an {@link Alert}. */
export interface AlertProps
extends React.PropsWithChildren,
twv.VariantProps<typeof ALERT_STYLES>,
React.HTMLAttributes<HTMLDivElement> {}
extends PropsWithChildren,
VariantProps<typeof ALERT_STYLES>,
HTMLAttributes<HTMLDivElement> {}
/** Alert component. */
export const Alert = React.forwardRef(function Alert(
export const Alert = forwardRef(function Alert(
props: AlertProps,
ref: React.ForwardedRef<HTMLDivElement>,
ref: ForwardedRef<HTMLDivElement>,
) {
const { children, className, variant, size, rounded, fullWidth, ...containerProps } = props
@ -70,7 +71,7 @@ export const Alert = React.forwardRef(function Alert(
return (
<div
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })}
ref={mergeRefs.mergeRefs(ref, (e) => {
ref={mergeRefs(ref, (e) => {
if (variant === 'error') {
e?.focus()
}

View File

@ -10,7 +10,8 @@ import SvgMask from '#/components/SvgMask'
import * as twv from '#/utilities/tailwindVariants'
import * as text from '../Text'
import { forwardRef } from '#/utilities/react'
import { TEXT_STYLE } from '../Text'
// ==============
// === Button ===
@ -88,7 +89,9 @@ export const BUTTON_STYLES = twv.tv({
'transition-[opacity,outline-offset,background,border-color] duration-150 ease-in-out',
],
variants: {
isDisabled: { true: 'disabled:opacity-50 disabled:cursor-not-allowed' },
isDisabled: {
true: 'disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed',
},
isFocused: {
true: 'focus:outline-none focus-visible:outline-2 focus-visible:outline-black focus-visible:outline-offset-[-2px]',
},
@ -104,7 +107,7 @@ export const BUTTON_STYLES = twv.tv({
custom: { base: '', extraClickZone: '', icon: 'h-full' },
hero: { base: 'px-8 py-4 text-lg font-bold', content: 'gap-[0.75em]' },
large: {
base: text.TEXT_STYLE({
base: TEXT_STYLE({
variant: 'body',
color: 'custom',
weight: 'semibold',
@ -115,7 +118,7 @@ export const BUTTON_STYLES = twv.tv({
extraClickZone: 'after:inset-[-6px]',
},
medium: {
base: text.TEXT_STYLE({
base: TEXT_STYLE({
variant: 'body',
color: 'custom',
weight: 'semibold',
@ -126,7 +129,7 @@ export const BUTTON_STYLES = twv.tv({
extraClickZone: 'after:inset-[-8px]',
},
small: {
base: text.TEXT_STYLE({
base: TEXT_STYLE({
variant: 'body',
color: 'custom',
weight: 'medium',
@ -137,7 +140,7 @@ export const BUTTON_STYLES = twv.tv({
extraClickZone: 'after:inset-[-10px]',
},
xsmall: {
base: text.TEXT_STYLE({
base: TEXT_STYLE({
variant: 'body',
color: 'custom',
weight: 'medium',
@ -149,7 +152,7 @@ export const BUTTON_STYLES = twv.tv({
extraClickZone: 'after:inset-[-12px]',
},
xxsmall: {
base: text.TEXT_STYLE({
base: TEXT_STYLE({
variant: 'body',
color: 'custom',
className: 'flex px-[3px] pt-[0.5px] pb-[2.5px] leading-[16px]',
@ -164,7 +167,7 @@ export const BUTTON_STYLES = twv.tv({
},
iconOnly: {
true: {
base: text.TEXT_STYLE({
base: TEXT_STYLE({
disableLineHeightCompensation: true,
className: 'border-0 outline-offset-[5px]',
}),
@ -189,8 +192,7 @@ export const BUTTON_STYLES = twv.tv({
icon: 'h-[1.25cap] w-[1.25cap] mt-[0.25cap]',
},
primary: 'bg-primary text-white hover:bg-primary/70',
tertiary: 'bg-accent text-white hover:bg-accent-dark',
cancel: 'bg-white/50 hover:bg-white',
accent: 'bg-accent text-white hover:bg-accent-dark',
delete:
'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger',
icon: {
@ -201,9 +203,11 @@ export const BUTTON_STYLES = twv.tv({
},
ghost:
'text-primary hover:text-primary/80 hover:bg-white focus-visible:text-primary/80 focus-visible:bg-white',
// eslint-disable-next-line @typescript-eslint/naming-convention
'ghost-fading':
'text-primary opacity-80 hover:opacity-100 hover:bg-white focus-visible:bg-white',
submit: 'bg-invite text-white opacity-80 hover:opacity-100',
outline: 'border-primary/40 text-primary hover:border-primary hover:bg-primary/5',
bar: 'border-primary/20 hover:bg-primary/5',
outline: 'border-primary/20 text-primary hover:border-primary hover:bg-primary/5',
},
iconPosition: {
start: { content: '' },
@ -214,7 +218,8 @@ export const BUTTON_STYLES = twv.tv({
},
extraClickZone: {
true: {
extraClickZone: 'flex relative after:absolute after:cursor-pointer',
extraClickZone:
'flex relative after:absolute after:cursor-pointer group-disabled:after:cursor-not-allowed',
},
false: {
extraClickZone: 'after:inset-0',
@ -240,7 +245,8 @@ export const BUTTON_STYLES = twv.tv({
},
},
slots: {
extraClickZone: 'flex relative after:absolute after:cursor-pointer',
extraClickZone:
'flex relative after:absolute after:cursor-pointer group-disabled:after:cursor-not-allowed',
wrapper: 'relative block',
loader: 'absolute inset-0 flex items-center justify-center',
content: 'flex items-center gap-[0.5em]',
@ -278,7 +284,7 @@ export const BUTTON_STYLES = twv.tv({
})
/** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */
export const Button = React.forwardRef(function Button(
export const Button = forwardRef(function Button(
props: ButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>,
) {
@ -331,7 +337,7 @@ export const Button = React.forwardRef(function Button(
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
const isLoading = loading || implicitlyLoading
const isDisabled = props.isDisabled == null ? isLoading : props.isDisabled
const isDisabled = props.isDisabled ?? isLoading
React.useLayoutEffect(() => {
const delay = 350
@ -382,7 +388,7 @@ export const Button = React.forwardRef(function Button(
icon: iconClasses,
text: textClasses,
} = BUTTON_STYLES({
isDisabled: isDisabled,
isDisabled,
isActive,
loading: isLoading,
fullWidth,
@ -443,7 +449,11 @@ export const Button = React.forwardRef(function Button(
isDisabled,
// we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger
// onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered
onPressEnd: handlePress,
onPressEnd: (e) => {
if (!isDisabled) {
handlePress(e)
}
},
className: aria.composeRenderProps(className, (classNames, states) =>
base({ className: classNames, ...states }),
),

View File

@ -51,11 +51,14 @@ const STYLES = twv.tv({
/** Props for a {@link ButtonGroup}. */
interface ButtonGroupProps extends React.PropsWithChildren, twv.VariantProps<typeof STYLES> {
readonly className?: string
readonly className?: string | undefined
}
/** A group of buttons. */
export function ButtonGroup(props: ButtonGroupProps) {
export const ButtonGroup = React.forwardRef(function ButtonGroup(
props: ButtonGroupProps,
ref: React.ForwardedRef<HTMLDivElement>,
) {
const {
children,
className,
@ -69,6 +72,7 @@ export function ButtonGroup(props: ButtonGroupProps) {
return (
<div
ref={ref}
className={STYLES({
gap,
wrap,
@ -82,4 +86,4 @@ export function ButtonGroup(props: ButtonGroupProps) {
{children}
</div>
)
}
})

View File

@ -57,6 +57,12 @@ const DIALOG_STYLES = twv.tv({
header: 'px-4 pt-[5px] pb-1.5 min-h-12',
},
},
fitContent: {
true: {
base: 'min-w-max',
content: 'min-w-max',
},
},
hideCloseButton: { true: { closeButton: 'hidden' } },
closeButton: {
normal: { base: '', closeButton: '' },
@ -127,6 +133,7 @@ export function Dialog(props: DialogProps) {
testId = 'dialog',
size,
rounded,
fitContent,
...ariaDialogProps
} = props
@ -148,7 +155,7 @@ export function Dialog(props: DialogProps) {
const overlayState = React.useRef<aria.OverlayTriggerState | null>(null)
const root = portal.useStrictPortalContext()
const dialogSlots = DIALOG_STYLES({
const styles = DIALOG_STYLES({
className,
type,
rounded,
@ -156,6 +163,7 @@ export function Dialog(props: DialogProps) {
closeButton,
scrolledToTop: isScrolledToTop,
size,
fitContent,
})
utlities.useInteractOutside({
@ -216,17 +224,15 @@ export function Dialog(props: DialogProps) {
element.dataset.testId = testId
}
})}
className={dialogSlots.base()}
className={styles.base()}
{...ariaDialogProps}
>
{(opts) => {
return (
<dialogProvider.DialogProvider value={{ close: opts.close, dialogId }}>
<aria.Header
className={dialogSlots.header({ scrolledToTop: isScrolledToTop })}
>
<aria.Header className={styles.header({ scrolledToTop: isScrolledToTop })}>
<ariaComponents.CloseButton
className={dialogSlots.closeButton()}
className={styles.closeButton()}
onPress={opts.close}
/>
@ -234,7 +240,7 @@ export function Dialog(props: DialogProps) {
<ariaComponents.Text.Heading
slot="title"
level={2}
className={dialogSlots.heading()}
className={styles.heading()}
weight="semibold"
>
{title}
@ -248,7 +254,7 @@ export function Dialog(props: DialogProps) {
handleScroll(ref.scrollTop)
}
}}
className={dialogSlots.content()}
className={styles.content()}
onScroll={(event) => {
handleScroll(event.currentTarget.scrollTop)
}}

View File

@ -40,6 +40,7 @@ export const POPOVER_STYLES = twv.tv({
true: 'animate-out fade-out placement-bottom:slide-out-to-top-1 placement-top:slide-out-to-bottom-1 placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 ease-in duration-150',
},
size: {
auto: { base: 'w-[unset]', dialog: 'p-2.5' },
xsmall: { base: 'max-w-xs', dialog: 'p-2.5' },
small: { base: 'max-w-sm', dialog: 'p-3.5' },
medium: { base: 'max-w-md', dialog: 'p-3.5' },

View File

@ -13,6 +13,7 @@ import * as aria from '#/components/aria'
import * as errorUtils from '#/utilities/error'
import { forwardRef } from '#/utilities/react'
import type { Mutable } from 'enso-common/src/utilities/data/object'
import * as dialog from '../Dialog'
import * as components from './components'
@ -35,14 +36,12 @@ function mapValueOnEvent(value: unknown) {
* Provides better error handling and form state management and better UX out of the box. */
// There is no way to avoid type casting here
// eslint-disable-next-line no-restricted-syntax
export const Form = React.forwardRef(function Form<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
>(
props: types.FormProps<Schema, TFieldValues, TTransformedValues>,
export const Form = forwardRef(function Form<Schema extends components.TSchema>(
props: types.FormProps<Schema>,
ref: React.Ref<HTMLFormElement>,
) {
/** Input values for this form. */
type FieldValues = components.FieldValues<Schema>
const formId = React.useId()
const {
@ -78,6 +77,7 @@ export const Form = React.forwardRef(function Form<
schema,
...formOptions,
},
defaultValues,
)
const dialogContext = dialog.useDialogContext()
@ -90,7 +90,7 @@ export const Form = React.forwardRef(function Form<
// the result, and the variables(form fields).
// In general, prefer using object literals for the mutation key.
mutationKey: ['Form submission', `testId: ${testId}`, `id: ${id}`],
mutationFn: async (fieldValues: TFieldValues) => {
mutationFn: async (fieldValues: FieldValues) => {
try {
await onSubmit?.(fieldValues, innerForm)
@ -153,33 +153,32 @@ export const Form = React.forwardRef(function Form<
control,
} = innerForm
const formStateRenderProps: types.FormStateRenderProps<Schema, TFieldValues, TTransformedValues> =
{
formState,
register: (name, options) => {
const registered = register(name, options)
const formStateRenderProps: types.FormStateRenderProps<Schema> = {
formState,
register: (name, options) => {
const registered = register(name, options)
const result: types.UseFormRegisterReturn<Schema, TFieldValues, typeof name> = {
...registered,
isDisabled: registered.disabled ?? false,
isRequired: registered.required ?? false,
isInvalid: Boolean(formState.errors[name]),
onChange: (value) => registered.onChange(mapValueOnEvent(value)),
onBlur: (value) => registered.onBlur(mapValueOnEvent(value)),
}
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
...registered,
isDisabled: registered.disabled ?? false,
isRequired: registered.required ?? false,
isInvalid: Boolean(formState.errors[name]),
onChange: (value) => registered.onChange(mapValueOnEvent(value)),
onBlur: (value) => registered.onBlur(mapValueOnEvent(value)),
}
return result
},
unregister,
setError,
clearErrors,
getValues,
setValue,
setFocus,
reset,
control,
form: innerForm,
}
return result
},
unregister,
setError,
clearErrors,
getValues,
setValue,
setFocus,
reset,
control,
form: innerForm,
}
const base = styles.FORM_STYLES({
className: typeof className === 'function' ? className(formStateRenderProps) : className,
@ -192,7 +191,7 @@ export const Form = React.forwardRef(function Form<
const message = error?.message ?? getText('arbitraryFormErrorMessage')
return [key, message]
}),
) as Record<keyof TFieldValues, string>
) as Record<keyof FieldValues, string>
return (
<form
@ -235,14 +234,8 @@ export const Form = React.forwardRef(function Form<
| 'useFormSchema'
>
> &
(<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
>(
props: React.RefAttributes<HTMLFormElement> &
types.FormProps<Schema, TFieldValues, TTransformedValues>,
// eslint-disable-next-line no-restricted-syntax
(<Schema extends components.TSchema>(
props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema>,
) => React.JSX.Element)
Form.schema = components.schema

View File

@ -3,13 +3,14 @@
*
* Field component
*/
import * as React from 'react'
import * as aria from '#/components/aria'
import { useText } from '#/providers/TextProvider'
import { forwardRef } from '#/utilities/react'
import { type ExtractFunction, tv, type VariantProps } from '#/utilities/tailwindVariants'
import type { Path } from 'react-hook-form'
import * as text from '../../Text'
import type * as types from './types'
import * as formContext from './useFormContext'
@ -17,11 +18,12 @@ import * as formContext from './useFormContext'
/**
* Props for Field component
*/
export interface FieldComponentProps extends VariantProps<typeof FIELD_STYLES>, types.FieldProps {
export interface FieldComponentProps<Schema extends types.TSchema>
extends VariantProps<typeof FIELD_STYLES>,
types.FieldProps {
readonly 'data-testid'?: string | undefined
readonly name: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any, any, any>
readonly name: Path<types.FieldValues<Schema>>
readonly form?: types.FormInstance<Schema>
readonly isInvalid?: boolean | undefined
readonly className?: string | undefined
readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode)
@ -62,12 +64,14 @@ export const FIELD_STYLES = tv({
/**
* Field component
*/
export const Field = React.forwardRef(function Field(
props: FieldComponentProps,
// eslint-disable-next-line no-restricted-syntax
export const Field = forwardRef(function Field<Schema extends types.TSchema>(
props: FieldComponentProps<Schema>,
ref: React.ForwardedRef<HTMLFieldSetElement>,
) {
const {
form = formContext.useFormContext(),
// eslint-disable-next-line no-restricted-syntax
form = formContext.useFormContext() as unknown as types.FormInstance<Schema>,
isInvalid,
children,
className,

View File

@ -17,8 +17,9 @@ import * as formContext from './useFormContext'
* Props for the FormError component.
*/
export interface FormErrorProps extends Omit<reactAriaComponents.AlertProps, 'children'> {
// We do not need to know the form fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any, any>
readonly form?: types.FormInstance<any>
}
/**

View File

@ -20,9 +20,9 @@ export interface ResetProps extends Omit<ariaComponents.ButtonProps, 'loading'>
*
* This field is helpful when you need to use the submit button outside of the form.
*/
// For this component, we don't need to know the form fields
// We do not need to know the form fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any, any>
readonly form?: types.FormInstance<any>
}
/**

View File

@ -24,9 +24,9 @@ interface SubmitButtonBaseProps {
*
* This field is helpful when you need to use the submit button outside of the form.
*/
// For this component, we don't need to know the form fields
// We do not need to know the form fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any, any>
readonly form?: types.FormInstance<any>
/**
* Prop that allows to close the parent dialog without submitting the form.
*

View File

@ -9,33 +9,34 @@ import type * as z from 'zod'
import type * as schemaModule from './schema'
/**
* Field values type.
*/
// eslint-disable-next-line no-restricted-syntax
/** The type of the inputs to the form, used for UI inputs. */
export type FieldValues<Schema extends TSchema | undefined> =
Schema extends TSchema ? z.infer<Schema> : reactHookForm.FieldValues
Schema extends TSchema ? z.input<Schema> : reactHookForm.FieldValues
/** The type of the outputs of the form, used for the callback. */
export type TransformedValues<Schema extends TSchema | undefined> =
Schema extends TSchema ? z.output<Schema> : reactHookForm.FieldValues
/**
* Field path type.
* @alias reactHookForm.FieldPath
*/
export type FieldPath<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
> = reactHookForm.FieldPath<TFieldValues>
export type FieldPath<Schema extends TSchema> = reactHookForm.FieldPath<FieldValues<Schema>>
/**
* Schema type
*/
export type TSchema = z.AnyZodObject | z.ZodEffects<z.AnyZodObject>
export type TSchema =
| z.AnyZodObject
| z.ZodEffects<z.AnyZodObject>
| z.ZodEffects<z.ZodEffects<z.AnyZodObject>>
/**
* Props for the useForm hook.
*/
export interface UseFormProps<Schema extends TSchema, TFieldValues extends FieldValues<Schema>>
export interface UseFormProps<Schema extends TSchema>
extends Omit<
reactHookForm.UseFormProps<TFieldValues>,
reactHookForm.UseFormProps<FieldValues<Schema>>,
'handleSubmit' | 'resetOptions' | 'resolver'
> {
readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema)
@ -45,30 +46,20 @@ export interface UseFormProps<Schema extends TSchema, TFieldValues extends Field
* Return type of the useForm hook.
* @alias reactHookForm.UseFormReturn
*/
export interface UseFormReturn<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TTransformedValues extends Record<string, unknown> | undefined = undefined,
> extends reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedValues> {}
export interface UseFormReturn<Schema extends TSchema>
extends reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>> {}
/**
* Form state type.
* @alias reactHookForm.FormState
*/
export type FormState<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
> = reactHookForm.FormState<TFieldValues>
export type FormState<Schema extends TSchema> = reactHookForm.FormState<FieldValues<Schema>>
/**
* Form instance type
* @alias UseFormReturn
*/
export type FormInstance<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema> = FieldValues<Schema>,
TTransformedValues extends Record<string, unknown> | undefined = undefined,
> = UseFormReturn<Schema, TFieldValues, TTransformedValues>
export type FormInstance<Schema extends TSchema> = UseFormReturn<Schema>
/**
* Form type interface that check if FieldValues type is compatible with the value type from component
@ -76,21 +67,20 @@ export type FormInstance<
export interface FormWithValueValidation<
BaseValueType,
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends FieldPath<Schema, TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
TFieldName extends FieldPath<Schema>,
// It is not ideal to have this as a parameter as it can be edited, but this is the simplest way
// to avoid distributive conditional types to affect the error message. We want distributivity
// to happen, just not for the error message itself.
ErrorType = [
'Type mismatch: Expected',
TFieldValues[TFieldName],
FieldValues<Schema>[TFieldName],
'got',
BaseValueType,
'instead.',
],
> {
readonly form?:
| (BaseValueType extends TFieldValues[TFieldName] ?
FormInstance<Schema, TFieldValues, TTransformedValues>
: ErrorType)
| (BaseValueType extends FieldValues<Schema>[TFieldName] ? FormInstance<Schema> : ErrorType)
| undefined
}

View File

@ -14,21 +14,11 @@ import * as formContext from './useFormContext'
export interface UseFieldOptions<
BaseValueType,
Schema extends types.TSchema,
TFieldValues extends types.FieldValues<Schema>,
TFieldName extends types.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
> extends types.FormWithValueValidation<
BaseValueType,
Schema,
TFieldValues,
TFieldName,
TTransformedValues
> {
TFieldName extends types.FieldPath<Schema>,
> extends types.FormWithValueValidation<BaseValueType, Schema, TFieldName> {
readonly name: TFieldName
readonly isDisabled?: boolean | undefined
// eslint-disable-next-line no-restricted-syntax
readonly defaultValue?: TFieldValues[TFieldName] | undefined
readonly defaultValue?: types.FieldValues<Schema>[TFieldName] | undefined
}
/**
@ -37,18 +27,15 @@ export interface UseFieldOptions<
export function useField<
BaseValueType,
Schema extends types.TSchema,
TFieldValues extends types.FieldValues<Schema>,
TFieldName extends types.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
>(options: UseFieldOptions<BaseValueType, Schema, TFieldValues, TFieldName, TTransformedValues>) {
TFieldName extends types.FieldPath<Schema>,
>(options: UseFieldOptions<BaseValueType, Schema, TFieldName>) {
const { form = formContext.useFormContext(), name, defaultValue, isDisabled = false } = options
// This is safe, because the form is always passed either via the options or via the context.
// The assertion is needed because we use additional type validation for form instance and throw
// ts error if form does not pass the validation.
// eslint-disable-next-line no-restricted-syntax
const formInstance = form as types.FormInstance<Schema, TFieldValues, TTransformedValues>
const formInstance = form as types.FormInstance<Schema>
const { field, fieldState, formState } = reactHookForm.useController({
name,

View File

@ -26,15 +26,12 @@ import type * as types from './types'
* But be careful, You should not switch between the two types of arguments.
* Otherwise you'll be fired
*/
export function useForm<
Schema extends types.TSchema,
TFieldValues extends types.FieldValues<Schema> = types.FieldValues<Schema>,
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
>(
optionsOrFormInstance:
| types.UseFormProps<Schema, TFieldValues>
| types.UseFormReturn<Schema, TFieldValues, TTransformedValues>,
): types.UseFormReturn<Schema, TFieldValues, TTransformedValues> {
export function useForm<Schema extends types.TSchema>(
optionsOrFormInstance: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
defaultValues?:
| reactHookForm.DefaultValues<types.FieldValues<Schema>>
| ((payload?: unknown) => Promise<types.FieldValues<Schema>>),
): types.UseFormReturn<Schema> {
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
const argsType = getArgsType(optionsOrFormInstance)
@ -47,31 +44,45 @@ export function useForm<
`,
)
if ('formState' in optionsOrFormInstance) {
return optionsOrFormInstance
} else {
const { schema, ...options } = optionsOrFormInstance
const form =
'formState' in optionsOrFormInstance ? optionsOrFormInstance : (
(() => {
const { schema, ...options } = optionsOrFormInstance
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
return reactHookForm.useForm<TFieldValues, unknown, TTransformedValues>({
...options,
resolver: zodResolver.zodResolver(computedSchema, { async: true }),
})
}
return reactHookForm.useForm<
types.FieldValues<Schema>,
unknown,
types.TransformedValues<Schema>
>({
...options,
resolver: zodResolver.zodResolver(computedSchema, { async: true }),
})
})()
)
const initialDefaultValues = React.useRef(defaultValues)
React.useEffect(() => {
// Expose default values to controlled inputs like `Selector` and `MultiSelector`.
// Using `defaultValues` is not sufficient as the value needs to be manually set at least once.
const defaults = initialDefaultValues.current
if (defaults) {
if (typeof defaults !== 'function') {
form.reset(defaults)
}
}
}, [form])
return form
}
/**
* Get the type of arguments passed to the useForm hook
*/
function getArgsType<
Schema extends types.TSchema,
TFieldValues extends types.FieldValues<Schema>,
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
>(
args:
| types.UseFormProps<Schema, TFieldValues>
| types.UseFormReturn<Schema, TFieldValues, TTransformedValues>,
function getArgsType<Schema extends types.TSchema>(
args: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
) {
return 'formState' in args ? 'formInstance' : 'formOptions'
}

View File

@ -15,26 +15,14 @@ export type * from './components'
/**
* Props for the Form component
*/
export type FormProps<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> = BaseFormProps<Schema, TFieldValues, TTransformedValues> &
(
| FormPropsWithOptions<Schema, TFieldValues>
| FormPropsWithParentForm<Schema, TFieldValues, TTransformedValues>
)
export type FormProps<Schema extends components.TSchema> = BaseFormProps<Schema> &
(FormPropsWithOptions<Schema> | FormPropsWithParentForm<Schema>)
/**
* Base props for the Form component.
*/
interface BaseFormProps<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> extends Omit<
interface BaseFormProps<Schema extends components.TSchema>
extends Omit<
React.HTMLProps<HTMLFormElement>,
'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style'
>,
@ -47,26 +35,18 @@ interface BaseFormProps<
* Otherwise Typescript fails to infer the correct type for the form values.
* This is a known limitation and we are working on a solution.
*/
readonly defaultValues?: components.UseFormProps<Schema, TFieldValues>['defaultValues']
readonly defaultValues?: components.UseFormProps<Schema>['defaultValues']
readonly onSubmit?: (
values: TFieldValues,
form: components.UseFormReturn<Schema, TFieldValues, TTransformedValues>,
values: components.TransformedValues<Schema>,
form: components.UseFormReturn<Schema>,
) => unknown
readonly style?:
| React.CSSProperties
| ((
props: FormStateRenderProps<Schema, TFieldValues, TTransformedValues>,
) => React.CSSProperties)
readonly children:
| React.ReactNode
| ((props: FormStateRenderProps<Schema, TFieldValues, TTransformedValues>) => React.ReactNode)
readonly formRef?: React.MutableRefObject<
components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
>
| ((props: FormStateRenderProps<Schema>) => React.CSSProperties)
readonly children: React.ReactNode | ((props: FormStateRenderProps<Schema>) => React.ReactNode)
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>
readonly className?:
| string
| ((props: FormStateRenderProps<Schema, TFieldValues, TTransformedValues>) => string)
readonly className?: string | ((props: FormStateRenderProps<Schema>) => string)
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
readonly onSubmitSuccess?: () => Promise<void> | void
@ -86,13 +66,8 @@ interface BaseFormProps<
* Props for the Form component with parent form
* or if form is passed as a prop.
*/
interface FormPropsWithParentForm<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> {
readonly form: components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
interface FormPropsWithParentForm<Schema extends components.TSchema> {
readonly form: components.UseFormReturn<Schema>
readonly schema?: never
readonly formOptions?: never
}
@ -101,42 +76,28 @@ interface FormPropsWithParentForm<
* Props for the Form component with schema and form options.
* Creates a new form instance. This is the default way to use the form.
*/
interface FormPropsWithOptions<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
> {
interface FormPropsWithOptions<Schema extends components.TSchema> {
readonly schema: Schema | ((schema: typeof components.schema) => Schema)
readonly form?: never
readonly formOptions?: Omit<components.UseFormProps<Schema, TFieldValues>, 'resolver' | 'schema'>
readonly formOptions?: Omit<components.UseFormProps<Schema>, 'resolver' | 'schema'>
}
/**
* Register function for a form field.
*/
export type UseFormRegister<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
> = <
TFieldName extends components.FieldPath<Schema, TFieldValues> = components.FieldPath<
Schema,
TFieldValues
>,
export type UseFormRegister<Schema extends components.TSchema> = <
TFieldName extends components.FieldPath<Schema> = components.FieldPath<Schema>,
>(
name: TFieldName,
options?: reactHookForm.RegisterOptions<TFieldValues, TFieldName>,
// eslint-disable-next-line no-restricted-syntax
) => UseFormRegisterReturn<Schema, TFieldValues, TFieldName>
options?: reactHookForm.RegisterOptions<components.FieldValues<Schema>, TFieldName>,
) => UseFormRegisterReturn<Schema, TFieldName>
/**
* UseFormRegister return type.
*/
export interface UseFormRegisterReturn<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TFieldName extends components.FieldPath<Schema, TFieldValues> = components.FieldPath<
Schema,
TFieldValues
>,
TFieldName extends components.FieldPath<Schema> = components.FieldPath<Schema>,
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onBlur' | 'onChange'> {
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onChange: <Value>(value: Value) => Promise<boolean | void> | void
@ -150,13 +111,8 @@ export interface UseFormRegisterReturn<
/**
* Form Render Props.
*/
export type FormStateRenderProps<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> = Pick<
components.FormInstance<Schema, TFieldValues, TTransformedValues>,
export type FormStateRenderProps<Schema extends components.TSchema> = Pick<
components.FormInstance<Schema>,
| 'clearErrors'
| 'control'
| 'formState'
@ -166,17 +122,11 @@ export type FormStateRenderProps<
| 'setFocus'
| 'setValue'
| 'unregister'
// eslint-disable-next-line no-restricted-syntax
> & {
/**
* The form register function.
* Adds a field to the form state.
*/
readonly register: UseFormRegister<Schema, TFieldValues>
/**
* Form Instance
*/
readonly form: components.FormInstance<Schema, TFieldValues, TTransformedValues>
/** The form register function. Adds a field to the form state. */
readonly register: UseFormRegister<Schema>
/** The form instance. */
readonly form: components.FormInstance<Schema>
}
/**
@ -186,20 +136,13 @@ export type FormStateRenderProps<
interface FormFieldProps<
BaseValueType,
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TFieldName extends components.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> extends components.FormWithValueValidation<
BaseValueType,
Schema,
TFieldValues,
TFieldName,
TTransformedValues
> {
TFieldName extends components.FieldPath<Schema>,
> extends components.FormWithValueValidation<BaseValueType, Schema, TFieldName> {
readonly name: TFieldName
readonly value?: BaseValueType extends TFieldValues[TFieldName] ? TFieldValues[TFieldName] : never
readonly defaultValue?: TFieldValues[TFieldName]
readonly value?: BaseValueType extends components.FieldValues<Schema>[TFieldName] ?
components.FieldValues<Schema>[TFieldName]
: never
readonly defaultValue?: components.FieldValues<Schema>[TFieldName] | undefined
readonly isDisabled?: boolean
readonly isRequired?: boolean
readonly isInvalid?: boolean
@ -212,14 +155,11 @@ export type FieldStateProps<
// eslint-disable-next-line no-restricted-syntax
BaseProps extends { value?: unknown },
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TFieldName extends components.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> = FormFieldProps<BaseProps['value'], Schema, TFieldValues, TFieldName, TTransformedValues> & {
TFieldName extends components.FieldPath<Schema>,
> = FormFieldProps<BaseProps['value'], Schema, TFieldName> & {
// to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps
[K in keyof Omit<
BaseProps,
keyof FormFieldProps<BaseProps['value'], Schema, TFieldValues, TFieldName, TTransformedValues>
keyof FormFieldProps<BaseProps['value'], Schema, TFieldName>
>]: BaseProps[K]
}

View File

@ -0,0 +1,219 @@
/** @file A date picker. */
import { useContext, type ForwardedRef } from 'react'
import type { DateSegment as DateSegmentType } from 'react-stately'
import CrossIcon from '#/assets/cross.svg'
import ArrowIcon from '#/assets/folder_arrow.svg'
import {
DatePicker as AriaDatePicker,
Calendar,
CalendarCell,
CalendarGrid,
CalendarGridBody,
CalendarGridHeader,
CalendarHeaderCell,
DateInput,
DatePickerStateContext,
DateSegment,
Dialog,
Group,
Heading,
Label,
type DatePickerProps as AriaDatePickerProps,
type DateValue,
} from '#/components/aria'
import {
Button,
Form,
Popover,
Text,
type FieldComponentProps,
type FieldPath,
type FieldProps,
type FieldStateProps,
type FieldValues,
type TSchema,
} from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import { forwardRef } from '#/utilities/react'
import { Controller } from 'react-hook-form'
import { tv, type VariantProps } from 'tailwind-variants'
const DATE_PICKER_STYLES = tv({
base: '',
variants: {
size: {
small: {
inputGroup: 'h-6 px-2',
},
medium: {
inputGroup: 'h-8 px-4',
},
},
},
slots: {
inputGroup: 'flex items-center gap-2 rounded-full border-0.5 border-primary/20',
dateInput: 'flex justify-center grow',
dateSegment: 'rounded placeholder-shown:text-primary/30 focus:bg-primary/10 px-[0.5px]',
resetButton: '',
calendarPopover: 'w-0',
calendarDialog: 'text-primary text-xs',
calendarContainer: '',
calendarHeader: 'flex items-center mb-2',
calendarHeading: 'grow text-center',
calendarGrid: '',
calendarGridHeader: 'flex',
calendarGridHeaderCell: '',
calendarGridBody: '',
calendarGridCell:
'text-center px-1 rounded border border-transparent hover:bg-primary/10 outside-visible-range:text-primary/30 disabled:text-primary/30 selected:border-primary/40',
},
defaultVariants: {
size: 'medium',
},
})
/** Props for a {@link DatePicker}. */
export interface DatePickerProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
extends FieldStateProps<
Omit<
AriaDatePickerProps<Extract<FieldValues<Schema>[TFieldName], DateValue>>,
'children' | 'className' | 'style'
>,
Schema,
TFieldName
>,
FieldProps,
Pick<FieldComponentProps<Schema>, 'className' | 'style'>,
VariantProps<typeof DATE_PICKER_STYLES> {
readonly noResetButton?: boolean
readonly noCalendarHeader?: boolean
readonly segments?: Partial<Record<DateSegmentType['type'], boolean>>
}
/** A date picker. */
export const DatePicker = forwardRef(function DatePicker<
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
>(props: DatePickerProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
const {
noResetButton = false,
noCalendarHeader = false,
segments = {},
name,
isDisabled,
form,
defaultValue,
label,
isRequired,
className,
size,
} = props
const { fieldState, formInstance } = Form.useField({
name,
isDisabled,
form,
defaultValue,
})
const styles = DATE_PICKER_STYLES({ size })
return (
<Form.Field
form={formInstance}
name={name}
fullWidth
label={label}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
aria-describedby={props['aria-describedby']}
isRequired={isRequired}
isInvalid={fieldState.invalid}
aria-details={props['aria-details']}
ref={ref}
style={props.style}
>
<Controller
control={formInstance.control}
name={name}
render={(renderProps) => {
return (
<AriaDatePicker className={styles.base({ className })} {...renderProps.field}>
<Label />
<Group className={styles.inputGroup()}>
<Button variant="icon" icon={ArrowIcon} className="rotate-90" />
<DateInput className={styles.dateInput()}>
{(segment) =>
segments[segment.type] === false ?
<></>
: <DateSegment segment={segment} className={styles.dateSegment()} />
}
</DateInput>
{!noResetButton && <DatePickerResetButton className={styles.resetButton()} />}
</Group>
{props.description != null && <Text slot="description" />}
<Popover size="auto" className={styles.calendarPopover()}>
<Dialog className={styles.calendarDialog()}>
<Calendar className={styles.calendarContainer()}>
<header className={styles.calendarHeader()}>
<Button
variant="icon"
slot="previous"
icon={ArrowIcon}
className="rotate-180"
/>
<Heading className={styles.calendarHeading()} />
<Button variant="icon" slot="next" icon={ArrowIcon} />
</header>
<CalendarGrid className={styles.calendarGrid()}>
{noCalendarHeader ?
<></>
: <CalendarGridHeader className={styles.calendarGridHeader()}>
{() => <CalendarHeaderCell className={styles.calendarGridHeaderCell()} />}
</CalendarGridHeader>
}
<CalendarGridBody className={styles.calendarGridBody()}>
{(date) => (
<CalendarCell date={date} className={styles.calendarGridCell()} />
)}
</CalendarGridBody>
</CalendarGrid>
<Text slot="errorMessage" />
</Calendar>
</Dialog>
</Popover>
</AriaDatePicker>
)
}}
/>
</Form.Field>
)
})
/** Props for a {@link DatePickerResetButton}. */
interface DatePickerResetButtonProps {
readonly className?: string
}
/** A reset button for a {@link DatePicker}. */
function DatePickerResetButton(props: DatePickerResetButtonProps) {
const { className } = props
const state = useContext(DatePickerStateContext)
const { getText } = useText()
return (
<Button
// Do not inherit default Button behavior from DatePicker.
slot={null}
variant="icon"
aria-label={getText('reset')}
icon={CrossIcon}
className={className ?? ''}
onPress={() => {
state.setValue(null)
}}
/>
)
}

View File

@ -0,0 +1,2 @@
/** @file Barrel file for the DatePicker component. */
export * from './DatePicker'

View File

@ -0,0 +1,257 @@
/** @file A styled dropdown. */
import { useEffect, useMemo, useRef, useState, type ForwardedRef, type ReactNode } from 'react'
import CheckMarkIcon from '#/assets/check_mark.svg'
import FolderArrowIcon from '#/assets/folder_arrow.svg'
import {
ListBox,
ListBoxItem,
mergeProps,
useFocusVisible,
useFocusWithin,
} from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import { useSyncRef } from '#/hooks/syncRefHooks'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants'
const DROPDOWN_STYLES = tv({
base: 'focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy',
variants: {
isFocused: {
true: {
container: 'z-1',
options: 'before:h-full before:shadow-soft',
optionsContainer: 'grid-rows-1fr',
input: 'z-1',
},
false: {
container: 'overflow-hidden',
options: 'before:h-6 group-hover:before:bg-hover-bg',
optionsContainer: 'grid-rows-0fr',
},
},
isReadOnly: {
true: {
input: 'read-only',
},
},
multiple: {
true: {
optionsItem: 'hover:font-semibold',
},
},
},
slots: {
container: 'absolute left-0 h-full w-full min-w-max',
options:
'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:backdrop-blur-default before:transition-colors',
optionsSpacing: 'padding relative h-6',
optionsContainer:
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
optionsList: 'overflow-hidden',
optionsItem:
'flex h-6 items-center gap-dropdown-arrow rounded-input px-input-x transition-colors focus:cursor-default focus:bg-frame focus:font-bold focus:focus-ring not-focus:hover:bg-hover-bg not-selected:hover:bg-hover-bg',
input: 'relative flex h-6 items-center gap-dropdown-arrow px-input-x',
inputDisplay: 'grow select-none',
hiddenOptions: 'flex h-0 flex-col overflow-hidden',
hiddenOption: 'flex gap-dropdown-arrow px-input-x font-bold',
},
})
// ================
// === Dropdown ===
// ================
/** Props for a list item child. */
interface InternalChildProps<T> {
readonly item: T
}
/** Props for the display of the currently selected item, when the dropdown supports multiple children. */
interface InternalChildrenProps<T> {
readonly items: readonly T[]
/** This is the value passed as {@link DropdownProps.children}. */
readonly children: (props: InternalChildProps<T>) => ReactNode
}
/** Props for a {@link Dropdown} shared between all variants. */
interface InternalBaseDropdownProps<T> extends InternalChildrenProps<T> {
readonly readOnly?: boolean
readonly className?: string
}
/** Props for a {@link Dropdown}, when `multiple` is `false` or absent. */
interface InternalSingleDropdownProps<T> extends InternalBaseDropdownProps<T> {
readonly multiple?: false
readonly selectedIndex: number | null
readonly onChange: (item: T, index: number) => void
}
/** Props for a {@link Dropdown}, when `multiple` is `true`. */
interface InternalMultipleDropdownProps<T> extends InternalBaseDropdownProps<T> {
readonly multiple: true
readonly selectedIndices: readonly number[]
readonly renderMultiple: (props: InternalChildrenProps<T>) => ReactNode
readonly onChange: (items: readonly T[], indices: readonly number[]) => void
}
/** Props for a {@link Dropdown}. */
export type DropdownProps<T> = InternalMultipleDropdownProps<T> | InternalSingleDropdownProps<T>
/** A styled dropdown. */
export const Dropdown = forwardRef(function Dropdown<T>(
props: DropdownProps<T>,
ref: ForwardedRef<HTMLDivElement>,
) {
const { readOnly = false, className, items, children: Child } = props
const listBoxItems = useMemo(() => items.map((item, i) => ({ item, i })), [items])
const [tempSelectedIndex, setTempSelectedIndex] = useState<number | null>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
const [isFocusWithin, setIsFocusWithin] = useState(false)
const [isMouseFocused, setIsMouseFocused] = useState(false)
const { isFocusVisible } = useFocusVisible()
const isFocusedRef = useSyncRef(isFocusWithin)
const isSelfMouseDownRef = useRef(false)
const delayedIsFocused = useRef(false)
const { focusWithinProps } = useFocusWithin({
onFocusWithinChange: setIsFocusWithin,
})
const multiple = props.multiple === true
const selectedIndex = 'selectedIndex' in props ? props.selectedIndex : null
const selectedIndices =
'selectedIndices' in props ? props.selectedIndices
: selectedIndex != null ? [selectedIndex]
: []
const selectedItems = selectedIndices.flatMap((index) => {
const item = items[index]
return item != null ? [item] : []
})
const visuallySelectedIndex = tempSelectedIndex ?? selectedIndex
const visuallySelectedItem = visuallySelectedIndex == null ? null : items[visuallySelectedIndex]
const isFocused = isFocusVisible ? isFocusWithin : isMouseFocused
const styles = DROPDOWN_STYLES({ isFocused, isReadOnly: readOnly })
useEffect(() => {
setTempSelectedIndex(selectedIndex)
}, [selectedIndex])
useEffect(() => {
const onDocumentMouseDown = () => {
if (!isSelfMouseDownRef.current) {
setIsMouseFocused(false)
if (document.activeElement === rootRef.current) {
rootRef.current?.blur()
}
isSelfMouseDownRef.current = false
}
}
document.addEventListener('mousedown', onDocumentMouseDown)
return () => {
document.removeEventListener('mousedown', onDocumentMouseDown)
}
}, [isFocusedRef])
useEffect(() => {
const handle = requestAnimationFrame(() => {
delayedIsFocused.current = isFocused
})
return () => {
cancelAnimationFrame(handle)
}
}, [isFocused])
return (
<FocusRing placement="outset">
<div
ref={mergeRefs(ref, rootRef)}
onMouseDown={() => {
isSelfMouseDownRef.current = true
// `isFocused` cannot be used as `isFocusWithin` is set to `false` immediately before
// this event handler is called.
setIsMouseFocused(!delayedIsFocused.current)
}}
tabIndex={-1}
className={styles.base({ className })}
{...mergeProps<React.JSX.IntrinsicElements['div']>()(focusWithinProps, {
onBlur: (event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsMouseFocused(false)
}
},
})}
>
<div className={styles.container()}>
<div className={styles.options()}>
{/* Spacing. */}
<div className={styles.optionsSpacing()} />
<div className={styles.optionsContainer()}>
<ListBox
selectionMode={multiple ? 'multiple' : 'single'}
selectionBehavior={multiple ? 'toggle' : 'replace'}
items={listBoxItems}
dependencies={[selectedIndices]}
className={styles.optionsList()}
onSelectionChange={(keys) => {
if (multiple) {
const indices = Array.from(keys, (i) => Number(i))
props.onChange(
indices.flatMap((i) => {
const item = items[i]
return item === undefined ? [] : [item]
}),
indices,
)
} else {
const [key] = keys
if (key != null) {
const i = Number(key)
const item = items[i]
if (item !== undefined) {
props.onChange(item, i)
setIsMouseFocused(false)
rootRef.current?.blur()
}
}
}
}}
>
{({ item, i }) => (
<ListBoxItem key={i} id={i} className={styles.optionsItem()}>
<SvgMask
src={CheckMarkIcon}
className={selectedIndices.includes(i) ? '' : 'invisible'}
/>
<Child item={item} />
</ListBoxItem>
)}
</ListBox>
</div>
</div>
</div>
<div className={styles.input()}>
<SvgMask src={FolderArrowIcon} className="rotate-90" />
<div className={styles.inputDisplay()}>
{visuallySelectedItem != null ?
<Child item={visuallySelectedItem} />
: multiple && <props.renderMultiple items={selectedItems}>{Child}</props.renderMultiple>
}
</div>
</div>
{/* Hidden, but required to exist for the width of the parent element to be correct.
* Classes that do not affect width have been removed. */}
<div className={styles.hiddenOptions()}>
{items.map((item, i) => (
<div key={i} className={styles.hiddenOption()}>
<SvgMask src={CheckMarkIcon} />
<Child item={item} />
</div>
))}
</div>
</div>
</FocusRing>
)
})

View File

@ -0,0 +1,2 @@
/** @file Barrel file for the Dropdown component. */
export * from './Dropdown'

View File

@ -3,63 +3,60 @@
*
* Basic input component. Input component is a component that is used to get user input in a text field.
*/
import * as React from 'react'
import {
useRef,
type CSSProperties,
type ForwardedRef,
type ReactElement,
type ReactNode,
type Ref,
} from 'react'
import type * as twv from 'tailwind-variants'
import type { VariantProps } from 'tailwind-variants'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as mergeRefs from '#/utilities/mergeRefs'
import {
Form,
Text,
type FieldComponentProps,
type FieldPath,
type FieldProps,
type FieldStateProps,
type TSchema,
} from '#/components/AriaComponents'
import SvgMask from '#/components/SvgMask'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import type { ExtractFunction } from '#/utilities/tailwindVariants'
import { omit } from 'enso-common/src/utilities/data/object'
import { INPUT_STYLES } from '../variants'
/**
* Props for the Input component.
*/
export interface InputProps<
Schema extends ariaComponents.TSchema,
TFieldValues extends ariaComponents.FieldValues<Schema>,
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
> extends ariaComponents.FieldStateProps<
Omit<aria.InputProps, 'children' | 'size'>,
Schema,
TFieldValues,
TFieldName,
TTransformedValues
>,
ariaComponents.FieldProps,
Omit<twv.VariantProps<typeof INPUT_STYLES>, 'disabled' | 'invalid'> {
export interface InputProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
extends FieldStateProps<Omit<aria.InputProps, 'children' | 'size'>, Schema, TFieldName>,
FieldProps,
Omit<VariantProps<typeof INPUT_STYLES>, 'disabled' | 'invalid'> {
readonly 'data-testid'?: string | undefined
readonly className?: string
readonly style?: React.CSSProperties
readonly inputRef?: React.Ref<HTMLInputElement>
readonly addonStart?: React.ReactNode
readonly addonEnd?: React.ReactNode
readonly style?: CSSProperties
readonly inputRef?: Ref<HTMLInputElement>
readonly addonStart?: ReactNode
readonly addonEnd?: ReactNode
readonly placeholder?: string
/** The icon to display in the input. */
readonly icon?: React.ReactElement | string | null
readonly icon?: ReactElement | string | null
readonly variants?: ExtractFunction<typeof INPUT_STYLES> | undefined
readonly fieldVariants?: ariaComponents.FieldComponentProps['variants']
readonly fieldVariants?: FieldComponentProps<Schema>['variants']
}
/**
* Basic input component. Input component is a component that is used to get user input in a text field.
*/
// eslint-disable-next-line no-restricted-syntax
export const Input = React.forwardRef(function Input<
Schema extends ariaComponents.TSchema,
TFieldValues extends ariaComponents.FieldValues<Schema>,
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
>(
props: InputProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
ref: React.ForwardedRef<HTMLFieldSetElement>,
) {
export const Input = forwardRef(function Input<
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
>(props: InputProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
const {
name,
isDisabled = false,
@ -83,9 +80,9 @@ export const Input = React.forwardRef(function Input<
...inputProps
} = props
const privateInputRef = React.useRef<HTMLInputElement>(null)
const privateInputRef = useRef<HTMLInputElement>(null)
const { fieldState, formInstance } = ariaComponents.Form.useField({
const { fieldState, formInstance } = Form.useField({
name,
isDisabled,
form,
@ -127,7 +124,7 @@ export const Input = React.forwardRef(function Input<
})
return (
<ariaComponents.Form.Field
<Form.Field
data-testid={props['data-testid']}
form={formInstance}
name={name}
@ -156,11 +153,11 @@ export const Input = React.forwardRef(function Input<
<div className={classes.inputContainer()}>
<aria.Input
ref={mergeRefs.mergeRefs(inputRef, privateInputRef, fieldRef)}
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
{...aria.mergeProps<aria.InputProps>()(
{ className: classes.textArea(), type, name, min, max, isRequired, isDisabled },
{ className: classes.textArea(), type, name, min, max },
inputProps,
omit(field, 'required', 'disabled'),
field,
)}
/>
</div>
@ -169,19 +166,11 @@ export const Input = React.forwardRef(function Input<
</div>
{description != null && (
<ariaComponents.Text slot="description" className={classes.description()}>
<Text slot="description" className={classes.description()}>
{description}
</ariaComponents.Text>
</Text>
)}
</div>
</ariaComponents.Form.Field>
</Form.Field>
)
}) as <
Schema extends ariaComponents.TSchema,
TFieldValues extends ariaComponents.FieldValues<Schema>,
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
>(
props: InputProps<Schema, TFieldValues, TFieldName, TTransformedValues> &
React.RefAttributes<HTMLInputElement>,
) => React.ReactElement
})

View File

@ -0,0 +1,187 @@
/** @file A horizontal selector supporting multiple input. */
import { useRef, type CSSProperties, type ForwardedRef, type Ref } from 'react'
import type { VariantProps } from 'tailwind-variants'
import {
FieldError,
ListBox,
mergeProps,
type ListBoxItemProps,
type ListBoxProps,
} from '#/components/aria'
import {
Form,
type FieldPath,
type FieldProps,
type FieldStateProps,
type FieldValues,
type TSchema,
} from '#/components/AriaComponents'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants'
import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object'
import { Controller } from 'react-hook-form'
import { MultiSelectorOption } from './MultiSelectorOption'
/** * Props for the MultiSelector component. */
export interface MultiSelectorProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
extends FieldStateProps<
Omit<ListBoxItemProps, 'children' | 'value'> & { value: FieldValues<Schema>[TFieldName] },
Schema,
TFieldName
>,
FieldProps,
Omit<VariantProps<typeof MULTI_SELECTOR_STYLES>, 'disabled' | 'invalid'> {
readonly items: readonly Extract<FieldValues<Schema>[TFieldName], readonly unknown[]>[number][]
readonly itemToString?: (
item: Extract<FieldValues<Schema>[TFieldName], readonly unknown[]>[number],
) => string
readonly columns?: number
readonly className?: string
readonly style?: CSSProperties
readonly inputRef?: Ref<HTMLDivElement>
readonly placeholder?: string
}
export const MULTI_SELECTOR_STYLES = tv({
base: 'block w-full bg-transparent transition-[border-color,outline] duration-200',
variants: {
disabled: {
true: { base: 'cursor-default opacity-50', textArea: 'cursor-default' },
false: { base: 'cursor-text', textArea: 'cursor-text' },
},
readOnly: { true: 'cursor-default' },
size: {
medium: { base: '' },
},
rounded: {
none: 'rounded-none',
small: 'rounded-sm',
medium: 'rounded-md',
large: 'rounded-lg',
xlarge: 'rounded-xl',
xxlarge: 'rounded-2xl',
xxxlarge: 'rounded-3xl',
full: 'rounded-full',
},
variant: {
outline: {
base: 'border-[0.5px] border-primary/20',
},
},
},
defaultVariants: {
size: 'medium',
rounded: 'xxlarge',
variant: 'outline',
},
slots: {
listBox: 'grid',
},
})
/**
* A horizontal multi-selector.
*/
export const MultiSelector = forwardRef(function MultiSelector<
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
>(props: MultiSelectorProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
const {
name,
items,
itemToString = String,
isDisabled = false,
columns,
form,
defaultValue,
inputRef,
label,
size,
rounded,
isRequired = false,
...inputProps
} = props
const privateInputRef = useRef<HTMLDivElement>(null)
const { fieldState, formInstance } = Form.useField({
name,
isDisabled,
form,
defaultValue,
})
const classes = MULTI_SELECTOR_STYLES({
size,
rounded,
readOnly: inputProps.readOnly,
disabled: isDisabled || formInstance.formState.isSubmitting,
})
return (
<Form.Field
form={formInstance}
name={name}
fullWidth
label={label}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
aria-describedby={props['aria-describedby']}
isRequired={isRequired}
isInvalid={fieldState.invalid}
aria-details={props['aria-details']}
ref={ref}
style={props.style}
className={props.className}
>
<div
className={classes.base()}
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
>
<Controller
control={formInstance.control}
name={name}
render={(renderProps) => {
const { ref: fieldRef, value, onChange, ...field } = renderProps.field
return (
<ListBox
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
orientation="horizontal"
selectionMode="multiple"
{...(inputProps.id != null && { id: String(inputProps.id) })}
{...mergeProps<ListBoxProps<FieldValues<Schema>[TFieldName]>>()(
{
className: classes.listBox(),
style: { gridTemplateColumns: `repeat(${columns ?? items.length}, 1fr)` },
},
unsafeRemoveUndefined(omit(inputProps, 'id')),
field,
)}
// eslint-disable-next-line no-restricted-syntax
aria-label={props['aria-label'] ?? (typeof label === 'string' ? label : '')}
// This is SAFE, as there is a constraint on `items` that prevents using keys
// that do not correspond to array values.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
defaultSelectedKeys={value?.map((item: FieldValues<Schema>[TFieldName]) =>
items.indexOf(item),
)}
onSelectionChange={(selection) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
onChange([...selection].map((key) => items[Number(key)]))
}}
>
{items.map((item, i) => (
<MultiSelectorOption key={i} id={i} value={{ item }} label={itemToString(item)} />
))}
</ListBox>
)
}}
/>
</div>
<FieldError />
</Form.Field>
)
})

View File

@ -0,0 +1,69 @@
/** @file An option in a selector. */
import { ListBoxItem, type ListBoxItemProps } from '#/components/aria'
import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants'
import * as React from 'react'
import type { VariantProps } from 'tailwind-variants'
import { TEXT_STYLE } from '../../Text'
/** Props for a {@link MultiSelectorOption}. */
export interface MultiSelectorOptionProps
extends ListBoxItemProps,
VariantProps<typeof MULTI_SELECTOR_OPTION_STYLES> {
readonly label: string
}
export const MULTI_SELECTOR_OPTION_STYLES = tv({
base: TEXT_STYLE({
className:
'flex flex-1 items-center justify-center min-h-8 relative overflow-clip cursor-pointer transition-[background-color,color,outline-offset] duration-200',
variant: 'body',
}),
variants: {
rounded: {
none: 'rounded-none',
small: 'rounded-sm',
medium: 'rounded-md',
large: 'rounded-lg',
xlarge: 'rounded-xl',
xxlarge: 'rounded-2xl',
xxxlarge: 'rounded-3xl',
full: 'rounded-full',
},
size: {
medium: { base: 'px-[11px] pb-1.5 pt-2' },
small: { base: 'px-[11px] pb-0.5 pt-1' },
},
variant: {
primary:
'selected:bg-primary selected:text-white hover:bg-primary/5 pressed:bg-primary/10 outline outline-2 outline-transparent outline-offset-[-2px] focus-visible:outline-primary focus-visible:outline-offset-0',
},
},
defaultVariants: {
size: 'medium',
rounded: 'xxxlarge',
variant: 'primary',
},
})
export const MultiSelectorOption = forwardRef(function MultiSelectorOption(
props: MultiSelectorOptionProps,
ref: React.ForwardedRef<HTMLDivElement>,
) {
const { label, ...radioProps } = props
const { className } = props
return (
<ListBoxItem
ref={ref}
{...radioProps}
className={(renderProps) =>
MULTI_SELECTOR_OPTION_STYLES({
className: typeof className === 'function' ? className(renderProps) : className,
})
}
>
{label}
</ListBoxItem>
)
})

View File

@ -0,0 +1,2 @@
/** @file Barrel file for the MultiSelector component. */
export * from './MultiSelector'

View File

@ -17,20 +17,13 @@ import {
// ================
/** Props for a {@link Password}. */
export interface PasswordProps<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends Path<TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
> extends Omit<InputProps<Schema, TFieldValues, TFieldName, TTransformedValues>, 'type'> {}
export interface PasswordProps<Schema extends TSchema, TFieldName extends Path<FieldValues<Schema>>>
extends Omit<InputProps<Schema, TFieldName>, 'type'> {}
/** A component wrapping {@link Input} with the ability to show and hide password. */
export function Password<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends Path<TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
>(props: PasswordProps<Schema, TFieldValues, TFieldName, TTransformedValues>) {
export function Password<Schema extends TSchema, TFieldName extends Path<FieldValues<Schema>>>(
props: PasswordProps<Schema, TFieldName>,
) {
const [showPassword, setShowPassword] = useState(false)
return (

View File

@ -1,19 +1,24 @@
/**
* @file A resizable input that uses a content-editable div.
*/
import * as React from 'react'
import { useRef, type ClipboardEvent, type ForwardedRef, type HTMLAttributes } from 'react'
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
import {
Form,
Text,
type FieldPath,
type FieldProps,
type FieldStateProps,
type TSchema,
} from '#/components/AriaComponents'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
import { INPUT_STYLES } from '../variants'
import * as ariaComponents from '#/components/AriaComponents'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as twv from '#/utilities/tailwindVariants'
import * as variants from '../variants'
const CONTENT_EDITABLE_STYLES = twv.tv({
extend: variants.INPUT_STYLES,
const CONTENT_EDITABLE_STYLES = tv({
extend: INPUT_STYLES,
base: '',
slots: { placeholder: 'opacity-50 absolute inset-0 pointer-events-none' },
})
@ -22,19 +27,11 @@ const CONTENT_EDITABLE_STYLES = twv.tv({
* Props for a {@link ResizableContentEditableInput}.
*/
export interface ResizableContentEditableInputProps<
Schema extends ariaComponents.TSchema,
TFieldValues extends ariaComponents.FieldValues<Schema>,
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
> extends ariaComponents.FieldStateProps<
React.HTMLAttributes<HTMLDivElement> & { value: string },
Schema,
TFieldValues,
TFieldName,
TTransformedValues
>,
Omit<ariaComponents.FieldProps, 'variant'>,
Omit<twv.VariantProps<typeof variants.INPUT_STYLES>, 'disabled' | 'invalid'> {
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
> extends FieldStateProps<HTMLAttributes<HTMLDivElement> & { value: string }, Schema, TFieldName>,
Omit<FieldProps, 'variant'>,
Omit<VariantProps<typeof INPUT_STYLES>, 'disabled' | 'invalid'> {
/**
* onChange is called when the content of the input changes.
* There is no way to prevent the change, so the value is always the new value.
@ -49,117 +46,98 @@ export interface ResizableContentEditableInputProps<
* A resizable input that uses a content-editable div.
* This component might be useful for a text input that needs to have highlighted content inside of it.
*/
// eslint-disable-next-line no-restricted-syntax
export const ResizableContentEditableInput = React.forwardRef(
function ResizableContentEditableInput<
Schema extends ariaComponents.TSchema,
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
TFieldValues extends ariaComponents.FieldValues<Schema> = ariaComponents.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
>(
props: ResizableContentEditableInputProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
ref: React.ForwardedRef<HTMLDivElement>,
) {
const {
placeholder = '',
description = null,
name,
isDisabled = false,
form,
defaultValue,
size,
rounded,
variant,
...textFieldProps
} = props
const inputRef = React.useRef<HTMLDivElement>(null)
const onPaste = eventCallbackHooks.useEventCallback(
(event: React.ClipboardEvent<HTMLDivElement>) => {
// Prevent pasting styled text.
event.preventDefault()
// sanitize the pasted text
// replace all < with &lt; to prevent XSS
const text = event.clipboardData
.getData('text/plain')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
document.execCommand('insertHTML', false, text)
},
)
const { field, fieldState, formInstance } = ariaComponents.Form.useField({
name,
isDisabled,
form,
defaultValue,
})
const {
base,
description: descriptionClass,
inputContainer,
textArea,
placeholder: placeholderClass,
} = CONTENT_EDITABLE_STYLES({
variant,
invalid: fieldState.invalid,
disabled: isDisabled || formInstance.formState.isSubmitting,
rounded,
size,
})
return (
<ariaComponents.Form.Field form={formInstance} name={name} fullWidth {...textFieldProps}>
<div
className={base()}
onClick={() => {
inputRef.current?.focus({ preventScroll: true })
}}
>
<div className={inputContainer()}>
<div
className={textArea()}
ref={mergeRefs.mergeRefs(inputRef, ref, field.ref)}
contentEditable
suppressContentEditableWarning
role="textbox"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
aria-autocomplete="none"
onPaste={onPaste}
onBlur={field.onBlur}
onInput={(event) => {
field.onChange(event.currentTarget.textContent ?? '')
}}
/>
<ariaComponents.Text
className={placeholderClass({ class: field.value.length > 0 ? 'hidden' : '' })}
>
{placeholder}
</ariaComponents.Text>
</div>
{description != null && (
<ariaComponents.Text slot="description" className={descriptionClass()}>
{description}
</ariaComponents.Text>
)}
</div>
</ariaComponents.Form.Field>
)
},
) as <
Schema extends ariaComponents.TSchema,
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
TFieldValues extends ariaComponents.FieldValues<Schema> = ariaComponents.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
export const ResizableContentEditableInput = forwardRef(function ResizableContentEditableInput<
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
>(
props: React.RefAttributes<HTMLDivElement> &
ResizableContentEditableInputProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
) => React.JSX.Element
props: ResizableContentEditableInputProps<Schema, TFieldName>,
ref: ForwardedRef<HTMLDivElement>,
) {
const {
placeholder = '',
description = null,
name,
isDisabled = false,
form,
defaultValue,
size,
rounded,
variant,
...textFieldProps
} = props
const inputRef = useRef<HTMLDivElement>(null)
const onPaste = useEventCallback((event: ClipboardEvent<HTMLDivElement>) => {
// Prevent pasting styled text.
event.preventDefault()
// sanitize the pasted text
// replace all < with &lt; to prevent XSS
const text = event.clipboardData
.getData('text/plain')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
document.execCommand('insertHTML', false, text)
})
const { field, fieldState, formInstance } = Form.useField({
name,
isDisabled,
form,
defaultValue,
})
const {
base,
description: descriptionClass,
inputContainer,
textArea,
placeholder: placeholderClass,
} = CONTENT_EDITABLE_STYLES({
variant,
invalid: fieldState.invalid,
disabled: isDisabled || formInstance.formState.isSubmitting,
rounded,
size,
})
return (
<Form.Field form={formInstance} name={name} fullWidth {...textFieldProps}>
<div
className={base()}
onClick={() => {
inputRef.current?.focus({ preventScroll: true })
}}
>
<div className={inputContainer()}>
<div
className={textArea()}
ref={mergeRefs(inputRef, ref, field.ref)}
contentEditable
suppressContentEditableWarning
role="textbox"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
aria-autocomplete="none"
onPaste={onPaste}
onBlur={field.onBlur}
onInput={(event) => {
field.onChange(event.currentTarget.textContent ?? '')
}}
/>
<Text className={placeholderClass({ class: field.value.length > 0 ? 'hidden' : '' })}>
{placeholder}
</Text>
</div>
{description != null && (
<Text slot="description" className={descriptionClass()}>
{description}
</Text>
)}
</div>
</Form.Field>
)
})

View File

@ -9,6 +9,7 @@ import * as aria from '#/components/aria'
import * as mergeRefs from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import * as variants from '../variants'
/**
@ -22,7 +23,7 @@ export interface ResizableInputProps extends aria.TextFieldProps {
/**
* A resizable input field.
*/
export const ResizableInput = React.forwardRef(function ResizableInput(
export const ResizableInput = forwardRef(function ResizableInput(
props: ResizableInputProps,
ref: React.ForwardedRef<HTMLTextAreaElement>,
) {

View File

@ -12,36 +12,29 @@ import {
Form,
type TSchema,
} from '#/components/AriaComponents'
import { mergeRefs } from '#/utilities/mergeRefs'
import RadioGroup from '#/components/styled/RadioGroup'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants'
import { Controller } from 'react-hook-form'
import { SelectorOption } from './SelectorOption'
/** * Props for the Selector component. */
export interface SelectorProps<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends FieldPath<Schema, TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
> extends FieldStateProps<
Omit<RadioGroupProps, 'children' | 'value'> & { value: TFieldValues[TFieldName] },
export interface SelectorProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
extends FieldStateProps<
Omit<RadioGroupProps, 'children' | 'value'> & { value: FieldValues<Schema>[TFieldName] },
Schema,
TFieldValues,
TFieldName,
TTransformedValues
TFieldName
>,
FieldProps,
Omit<twv.VariantProps<typeof SELECTOR_STYLES>, 'disabled' | 'invalid'> {
readonly items: readonly TFieldValues[TFieldName][]
readonly itemToString?: (item: TFieldValues[TFieldName]) => string
readonly items: readonly FieldValues<Schema>[TFieldName][]
readonly children?: (item: FieldValues<Schema>[TFieldName]) => string
readonly columns?: number
readonly className?: string
readonly style?: React.CSSProperties
readonly inputRef?: React.Ref<HTMLDivElement>
readonly placeholder?: string
readonly readOnly?: boolean
}
export const SELECTOR_STYLES = tv({
@ -84,21 +77,16 @@ export const SELECTOR_STYLES = tv({
/**
* A horizontal selector.
*/
// eslint-disable-next-line no-restricted-syntax
export const Selector = React.forwardRef(function Selector<
export const Selector = forwardRef(function Selector<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends FieldPath<Schema, TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
>(
props: SelectorProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
ref: React.ForwardedRef<HTMLFieldSetElement>,
) {
TFieldName extends FieldPath<Schema>,
>(props: SelectorProps<Schema, TFieldName>, ref: React.ForwardedRef<HTMLFieldSetElement>) {
const {
name,
items,
itemToString = String,
children = String,
isDisabled = false,
columns,
form,
defaultValue,
inputRef,
@ -115,7 +103,7 @@ export const Selector = React.forwardRef(function Selector<
name,
isDisabled,
form,
defaultValue,
...(defaultValue != null ? { defaultValue } : {}),
})
const classes = SELECTOR_STYLES({
@ -154,7 +142,14 @@ export const Selector = React.forwardRef(function Selector<
<RadioGroup
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
{...mergeProps<RadioGroupProps>()(
{ className: classes.radioGroup(), name, isRequired, isDisabled },
{
className: classes.radioGroup(),
name,
isRequired,
isDisabled,
style:
columns != null ? { gridTemplateColumns: `repeat(${columns}, 1fr)` } : {},
},
inputProps,
field,
)}
@ -167,7 +162,7 @@ export const Selector = React.forwardRef(function Selector<
}}
>
{items.map((item, i) => (
<SelectorOption value={String(i)} label={itemToString(item)} />
<SelectorOption key={i} value={String(i)} label={children(item)} />
))}
</RadioGroup>
)
@ -176,12 +171,4 @@ export const Selector = React.forwardRef(function Selector<
</div>
</Form.Field>
)
}) as <
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends FieldPath<Schema, TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
>(
props: React.RefAttributes<HTMLDivElement> &
SelectorProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
) => React.ReactElement
})

View File

@ -1,5 +1,6 @@
/** @file An option in a selector. */
import { Radio, type RadioProps } from '#/components/aria'
import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants'
import * as React from 'react'
import type { VariantProps } from 'tailwind-variants'
@ -45,7 +46,7 @@ export const SELECTOR_OPTION_STYLES = tv({
},
})
export const SelectorOption = React.forwardRef(function SelectorOption(
export const SelectorOption = forwardRef(function SelectorOption(
props: SelectorOptionProps,
ref: React.ForwardedRef<HTMLLabelElement>,
) {

View File

@ -4,7 +4,10 @@
* Barrel export file for Inputs
*/
export * from './DatePicker'
export * from './Dropdown'
export * from './Input'
export * from './MultiSelector'
export * from './Password'
export * from './ResizableInput'
export * from './Selector'

View File

@ -11,6 +11,7 @@ import * as aria from '#/components/aria'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as twv from '#/utilities/tailwindVariants'
import { forwardRef } from '#/utilities/react'
import * as text from '../Text'
import * as radioGroup from './RadioGroup'
import * as radioGroupContext from './RadioGroupContext'
@ -56,7 +57,7 @@ export interface RadioProps extends aria.RadioProps {
* A radio button.
*/
// eslint-disable-next-line no-restricted-syntax
export const Radio = React.forwardRef(function Radio(
export const Radio = forwardRef(function Radio(
props: RadioProps,
ref: React.ForwardedRef<HTMLLabelElement>,
) {

View File

@ -10,6 +10,7 @@ import * as aria from '#/components/aria'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as twv from '#/utilities/tailwindVariants'
import { forwardRef } from '#/utilities/react'
import * as formComponent from '../Form'
import * as radioGroupContext from './RadioGroupContext'
@ -18,15 +19,11 @@ import * as radioGroupContext from './RadioGroupContext'
*/
export interface RadioGroupProps<
Schema extends formComponent.TSchema,
TFieldValues extends formComponent.FieldValues<Schema>,
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
TFieldName extends formComponent.FieldPath<Schema>,
> extends formComponent.FieldStateProps<
Omit<aria.AriaRadioGroupProps, 'description' | 'label'>,
Schema,
TFieldValues,
TFieldName,
TTransformedValues
TFieldName
>,
twv.VariantProps<typeof RADIO_GROUP_STYLES>,
formComponent.FieldProps {
@ -43,15 +40,10 @@ export const RADIO_GROUP_STYLES = twv.tv({
* A radio group component.
*/
// eslint-disable-next-line no-restricted-syntax
export const RadioGroup = React.forwardRef(function RadioGroup<
export const RadioGroup = forwardRef(function RadioGroup<
Schema extends formComponent.TSchema,
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
TFieldValues extends formComponent.FieldValues<Schema> = formComponent.FieldValues<Schema>,
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
>(
props: RadioGroupProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
ref: React.ForwardedRef<HTMLDivElement>,
) {
TFieldName extends formComponent.FieldPath<Schema>,
>(props: RadioGroupProps<Schema, TFieldName>, ref: React.ForwardedRef<HTMLDivElement>) {
const {
children,
isRequired = false,
@ -109,13 +101,4 @@ export const RadioGroup = React.forwardRef(function RadioGroup<
</radioGroupContext.RadioGroupProvider>
</aria.RadioGroup>
)
}) as <
Schema extends formComponent.TSchema,
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
TFieldValues extends formComponent.FieldValues<Schema> = formComponent.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
>(
props: RadioGroupProps<Schema, TFieldValues, TFieldName, TTransformedValues> &
React.RefAttributes<HTMLFormElement>,
) => React.JSX.Element
})

View File

@ -8,6 +8,7 @@ import * as aria from '#/components/aria'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as twv from '#/utilities/tailwindVariants'
import { forwardRef } from '#/utilities/react'
import * as textProvider from './TextProvider'
import * as visualTooltip from './useVisualTooltip'
@ -17,6 +18,7 @@ import * as visualTooltip from './useVisualTooltip'
export interface TextProps
extends Omit<aria.TextProps, 'color'>,
twv.VariantProps<typeof TEXT_STYLE> {
readonly elementType?: keyof HTMLElementTagNameMap
readonly lineClamp?: number
readonly tooltip?: React.ReactElement | string | false | null
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
@ -116,10 +118,7 @@ export const TEXT_STYLE = twv.tv({
* Text component that supports truncation and show a tooltip on hover when text is truncated
*/
// eslint-disable-next-line no-restricted-syntax
export const Text = React.forwardRef(function Text(
props: TextProps,
ref: React.Ref<HTMLSpanElement>,
) {
export const Text = forwardRef(function Text(props: TextProps, ref: React.Ref<HTMLSpanElement>) {
const {
className,
variant,
@ -185,8 +184,8 @@ export const Text = React.forwardRef(function Text(
return (
<textProvider.TextProvider value={{ isInsideTextComponent: true }}>
{/* @ts-expect-error We suppose that elementType is a valid HTML element */}
<ElementType
// @ts-expect-error This is caused by the type-safe `elementType` type.
ref={mergeRefs.mergeRefs(ref, textElementRef)}
className={textClasses}
{...aria.mergeProps<React.HTMLAttributes<HTMLElement>>()(
@ -207,7 +206,7 @@ export const Text = React.forwardRef(function Text(
// eslint-disable-next-line no-restricted-syntax
}) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & {
// eslint-disable-next-line @typescript-eslint/naming-convention
Heading: React.FC<HeadingProps>
Heading: typeof Heading
}
/**
@ -221,10 +220,12 @@ export interface HeadingProps extends Omit<TextProps, 'elementType'> {
/**
* Heading component
*/
Text.Heading = React.forwardRef(function Heading(
// eslint-disable-next-line no-restricted-syntax
const Heading = forwardRef(function Heading(
props: HeadingProps,
ref: React.Ref<HTMLHeadingElement>,
) {
const { level = 1, ...textProps } = props
return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} />
})
Text.Heading = Heading

View File

@ -35,6 +35,12 @@ export interface VisualTooltipProps
readonly testId?: string
}
/** The return value of the {@link useVisualTooltip} hook. */
export interface VisualTooltipReturn {
readonly targetProps: aria.DOMAttributes<aria.FocusableElement> & { readonly id: string }
readonly tooltip: JSX.Element | null
}
/**
* The display strategy for the tooltip.
*/
@ -50,7 +56,7 @@ const DEFAULT_DELAY = 250
* Common use case is to show a tooltip when the content of an element is overflowing,
* Or show a description of the element when hovered over.
*/
export function useVisualTooltip(props: VisualTooltipProps) {
export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn {
const {
children,
targetRef,
@ -137,11 +143,15 @@ export function useVisualTooltip(props: VisualTooltipProps) {
}),
// eslint-disable-next-line @typescript-eslint/naming-convention
'aria-hidden': true,
// Note that this is a `@ts-expect-error` so that an update to the outdated type
// definitions will notify that this `@ts-expect-error` can be safely removed.
// @ts-expect-error This is a new DOM property.
popover: '',
role: 'presentation',
'data-testid': testId,
// Remove z-index from the overlay style
// because it's not needed(we show latest element on top) and can cause issues with stacking context
// Remove z-index from the overlay style because it is not needed.
// We show the latest element on top, and z-index can cause issues with
// the stacking context.
style: { zIndex: '' },
},
)}

View File

@ -5,6 +5,7 @@
*/
import * as React from 'react'
import { forwardRef } from '#/utilities/react'
import * as twv from '#/utilities/tailwindVariants'
/**
@ -17,9 +18,11 @@ export const VISUALLY_HIDDEN_STYLES = twv.tv({ base: 'sr-only' })
/**
* A component visually hides its children from the screen, but keeps them accessible to screen readers.
*/
export const VisuallyHidden = React.forwardRef<HTMLSpanElement, VisuallyHiddenProps>(
function VisuallyHidden(props, ref) {
const { className } = props
return <span ref={ref} className={VISUALLY_HIDDEN_STYLES({ className })} {...props} />
},
)
// eslint-disable-next-line no-restricted-syntax
export const VisuallyHidden = forwardRef(function VisuallyHidden(
props: VisuallyHiddenProps,
ref: React.ForwardedRef<HTMLSpanElement>,
) {
const { className } = props
return <span ref={ref} className={VISUALLY_HIDDEN_STYLES({ className })} {...props} />
})

View File

@ -32,7 +32,7 @@ interface InternalBaseAutocompleteProps<T> {
/** This may change as the user types in the input. */
readonly items: readonly T[]
readonly itemToKey: (item: T) => string
readonly itemToString: (item: T) => string
readonly children: (item: T) => string
readonly itemsToString?: (items: T[]) => string
readonly matches: (item: T, text: string) => boolean
readonly text?: string | null
@ -80,7 +80,7 @@ export type AutocompleteProps<T> = (
/** A select menu with a dropdown. */
export default function Autocomplete<T>(props: AutocompleteProps<T>) {
const { multiple, type = 'text', inputRef: rawInputRef, placeholder, values, setValues } = props
const { text, setText, autoFocus = false, items, itemToKey, itemToString, itemsToString } = props
const { text, setText, autoFocus = false, items, itemToKey, children, itemsToString } = props
const { matches } = props
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
@ -233,7 +233,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
})
}}
>
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
{itemsToString?.(values) ?? (values[0] != null ? children(values[0]) : ZWSP)}
</div>
}
<Button
@ -275,7 +275,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
}}
>
<Text truncate="1" className="w-full" tooltipPlacement="left">
{itemToString(item)}
{children(item)}
</Text>
</div>
))}

View File

@ -12,6 +12,7 @@ import RadioGroup from '#/components/styled/RadioGroup'
import * as backend from '#/services/Backend'
import { forwardRef } from '#/utilities/react'
import * as tailwindMerge from '#/utilities/tailwindMerge'
/** Props for a {@link ColorPickerItem}. */
@ -55,6 +56,9 @@ export interface ColorPickerProps extends Readonly<aria.RadioGroupProps> {
readonly setColor: (color: backend.LChColor) => void
}
/** A color picker to select from a predetermined list of colors. */
export default forwardRef(ColorPicker)
/** A color picker to select from a predetermined list of colors. */
function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { pickerClassName = '', children, setColor, ...radioGroupProps } = props
@ -79,6 +83,3 @@ function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef<HTMLDivEle
</RadioGroup>
)
}
/** A color picker to select from a predetermined list of colors. */
export default React.forwardRef(ColorPicker)

View File

@ -5,6 +5,7 @@ import * as detect from 'enso-common/src/detect'
import Modal from '#/components/Modal'
import { forwardRef } from '#/utilities/react'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// =================
@ -28,6 +29,8 @@ export interface ContextMenusProps extends Readonly<React.PropsWithChildren> {
readonly event: Pick<React.MouseEvent, 'pageX' | 'pageY'>
}
export default forwardRef(ContextMenus)
/** A context menu that opens at the current mouse position. */
function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { hidden = false, children, event } = props
@ -55,5 +58,3 @@ function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivE
</div>
</Modal>
}
export default React.forwardRef(ContextMenus)

View File

@ -1,113 +0,0 @@
/** @file Styled input element. */
import * as React from 'react'
import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
// =================
// === Constants ===
// =================
/** The delay to wait before checking input validity. */
const DEBOUNCE_MS = 1000
// =======================
// === ControlledInput ===
// =======================
/** Props for a {@link ControlledInput}. */
export interface ControlledInputProps extends Readonly<aria.InputProps> {
readonly value: string
readonly error?: string
readonly validate?: boolean
readonly setValue: (value: string) => void
readonly shouldReportValidityRef?: React.MutableRefObject<boolean>
}
/** A component for authentication from inputs, with preset styles. */
export default function ControlledInput(props: ControlledInputProps) {
const {
setValue,
error,
validate = false,
shouldReportValidityRef,
onKeyDown,
onChange,
onBlur,
...inputProps
} = props
const [reportTimeoutHandle, setReportTimeoutHandle] = React.useState<number | null>(null)
const [hasReportedValidity, setHasReportedValidity] = React.useState(false)
const [wasJustBlurred, setWasJustBlurred] = React.useState(false)
const focusChildProps = focusHooks.useFocusChild()
return (
<FocusRing>
<aria.Input
{...aria.mergeProps<aria.InputProps>()(inputProps, focusChildProps, {
className:
'w-full rounded-full border py-auth-input-y pl-auth-icon-container-w pr-auth-input-r text-sm placeholder-gray-500 transition-all duration-auth hover:bg-gray-100 focus:bg-gray-100',
onKeyDown: (event) => {
if (!event.isPropagationStopped()) {
onKeyDown?.(event)
}
},
onChange: (event) => {
onChange?.(event)
setValue(event.target.value)
setWasJustBlurred(false)
if (validate) {
if (reportTimeoutHandle != null) {
window.clearTimeout(reportTimeoutHandle)
}
const currentTarget = event.currentTarget
if (error != null) {
currentTarget.setCustomValidity('')
currentTarget.setCustomValidity(
currentTarget.checkValidity() || shouldReportValidityRef?.current === false ?
''
: error,
)
}
if (hasReportedValidity) {
if (shouldReportValidityRef?.current === false || currentTarget.checkValidity()) {
setHasReportedValidity(false)
}
} else {
setReportTimeoutHandle(
window.setTimeout(() => {
if (
shouldReportValidityRef?.current !== false &&
!currentTarget.reportValidity()
) {
setHasReportedValidity(true)
}
}, DEBOUNCE_MS),
)
}
}
},
onBlur:
validate ?
(event) => {
onBlur?.(event)
if (wasJustBlurred) {
setHasReportedValidity(false)
} else {
const currentTarget = event.currentTarget
if (shouldReportValidityRef?.current !== false) {
if (!currentTarget.reportValidity()) {
event.preventDefault()
}
}
setWasJustBlurred(true)
}
}
: onBlur,
})}
/>
</FocusRing>
)
}

View File

@ -1,262 +0,0 @@
/** @file An input that outputs a {@link Date}. */
import * as React from 'react'
import CrossIcon from '#/assets/cross.svg'
import FolderArrowIcon from '#/assets/folder_arrow.svg'
import FolderArrowDoubleIcon from '#/assets/folder_arrow_double.svg'
import * as focusHooks from '#/hooks/focusHooks'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import * as dateTime from '#/utilities/dateTime'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// =================
// === Constants ===
// =================
/** The number of days in a week. */
const DAYS_IN_WEEK = 7
/** The month index of the last month i the year (December). */
const LAST_MONTH_INDEX = 11
// =============
// === Types ===
// =============
/** Information required to render a specific day. */
interface DayInfo {
readonly monthOffset: number
readonly date: number
}
// ======================
// === DateInputProps ===
// ======================
/** Props for a {@link DateInput}. */
export interface DateInputProps {
readonly date: Date | null
readonly onInput: (date: Date | null) => void
}
/** An input that outputs a {@link Date}. */
export default function DateInput(props: DateInputProps) {
const { date, onInput } = props
const { getText } = textProvider.useText()
const focusChildProps = focusHooks.useFocusChild()
const year = date?.getFullYear() ?? new Date().getFullYear()
const monthIndex = date?.getMonth() ?? new Date().getMonth()
const [isPickerVisible, setIsPickerVisible] = React.useState(false)
const [selectedYear, setSelectedYear] = React.useState(year)
const [selectedMonthIndex, setSelectedMonthIndex] = React.useState(monthIndex)
const month = React.useMemo<readonly (readonly DayInfo[])[]>(() => {
const currentDay = new Date(selectedYear, selectedMonthIndex, 1)
const dayOfWeek = (currentDay.getDay() + DAYS_IN_WEEK - 1) % DAYS_IN_WEEK
currentDay.setDate(currentDay.getDate() - dayOfWeek)
const result: DayInfo[][] = []
let lastMonthIndex = currentDay.getMonth()
let monthOffset = currentDay.getMonth() === selectedMonthIndex ? 0 : -1
do {
const week: DayInfo[] = []
for (let i = 0; i < DAYS_IN_WEEK; i += 1) {
week.push({ monthOffset, date: currentDay.getDate() })
currentDay.setDate(currentDay.getDate() + 1)
if (currentDay.getMonth() !== lastMonthIndex) {
monthOffset += 1
lastMonthIndex = currentDay.getMonth()
}
}
result.push(week)
// This MUST be a `do ... while ...` loop since the first day may not be in the same month.
} while (currentDay.getMonth() === selectedMonthIndex)
return result
}, [selectedYear, selectedMonthIndex])
React.useEffect(() => {
setSelectedYear(year)
setSelectedMonthIndex(monthIndex)
}, [year, monthIndex])
React.useEffect(() => {
const onClick = () => {
setIsPickerVisible(false)
}
document.addEventListener('click', onClick)
return () => {
document.removeEventListener('click', onClick)
}
})
return (
<div
className="relative flex flex-col"
onClick={(event) => {
event.stopPropagation()
}}
>
<FocusRing>
<div
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(focusChildProps, {
role: 'button',
tabIndex: 0,
className: tailwindMerge.twMerge(
'flex h-text w-date-picker items-center rounded-full border border-primary/10 px-date-input transition-colors hover:[&:not(:has(button:hover))]:bg-hover-bg',
date == null && 'placeholder',
),
onClick: (event) => {
event.stopPropagation()
setIsPickerVisible(!isPickerVisible)
},
onKeyDown: (event) => {
if (event.key === 'Enter' || event.key === 'Space') {
event.stopPropagation()
setIsPickerVisible(!isPickerVisible)
}
},
})}
>
<div className="flex grow flex-col items-center">
{date != null ? dateTime.formatDate(date) : getText('noDateSelected')}
</div>
{date != null && (
<ariaComponents.Button
size="custom"
variant="custom"
className="flex rounded-full transition-colors hover:bg-hover-bg"
onPress={() => {
onInput(null)
}}
>
<SvgMask src={CrossIcon} className="size-4" />
</ariaComponents.Button>
)}
</div>
</FocusRing>
{isPickerVisible && (
<div className="absolute left-1/2 top-text-h z-1 mt-date-input-gap">
<div
className={ariaComponents.DIALOG_BACKGROUND({
className:
'relative -translate-x-1/2 rounded-2xl border border-primary/10 p-date-input shadow-soft',
})}
>
<div className="relative mb-date-input-gap">
<div className="flex items-center">
<ariaComponents.Button
size="custom"
variant="custom"
className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg"
onPress={() => {
setSelectedYear(selectedYear - 1)
}}
>
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" />
</ariaComponents.Button>
<ariaComponents.Button
size="custom"
variant="custom"
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onPress={() => {
if (selectedMonthIndex === 0) {
setSelectedYear(selectedYear - 1)
setSelectedMonthIndex(LAST_MONTH_INDEX)
} else {
setSelectedMonthIndex(selectedMonthIndex - 1)
}
}}
>
<SvgMask src={FolderArrowIcon} className="rotate-180" />
</ariaComponents.Button>
<aria.Text className="grow text-center">
{dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
</aria.Text>
<ariaComponents.Button
size="custom"
variant="custom"
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onPress={() => {
if (selectedMonthIndex === LAST_MONTH_INDEX) {
setSelectedYear(selectedYear + 1)
setSelectedMonthIndex(0)
} else {
setSelectedMonthIndex(selectedMonthIndex + 1)
}
}}
>
<SvgMask src={FolderArrowIcon} />
</ariaComponents.Button>
<ariaComponents.Button
size="custom"
variant="custom"
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onPress={() => {
setSelectedYear(selectedYear + 1)
}}
>
<SvgMask src={FolderArrowDoubleIcon} />
</ariaComponents.Button>
</div>
</div>
<table className="relative w-full">
<thead>
<tr>
<th className="text-tight min-w-date-cell p">{getText('mondayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('tuesdayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('wednesdayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('thursdayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('fridayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('saturdayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('sundayAbbr')}</th>
</tr>
</thead>
<tbody>
{month.map((week, i) => (
<tr key={i}>
{week.map((day, j) => {
const currentDate = new Date(
selectedYear,
selectedMonthIndex + day.monthOffset,
day.date,
)
const isSelectedDate =
date != null &&
currentDate.getFullYear() === year &&
currentDate.getMonth() === monthIndex &&
currentDate.getDate() === date.getDate()
return (
<td key={j} className="text-tight p">
<ariaComponents.Button
size="custom"
variant="custom"
isDisabled={isSelectedDate}
className={tailwindMerge.twMerge(
'w-full rounded-small-rectangle-button text-center hover:bg-primary/10 disabled:bg-frame disabled:font-bold',
day.monthOffset !== 0 && 'opacity-unimportant',
)}
onPress={() => {
setIsPickerVisible(false)
onInput(currentDate)
}}
>
{day.date}
</ariaComponents.Button>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}

View File

@ -23,11 +23,24 @@ import {
} from '#/providers/EnsoDevtoolsProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import { Switch } from '#/components/aria'
import {
Button,
ButtonGroup,
DialogTrigger,
Form,
Popover,
Radio,
RadioGroup,
Separator,
Text,
} from '#/components/AriaComponents'
import Portal from '#/components/Portal'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import * as backend from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
/**
* Configuration for a paywall feature.
@ -64,6 +77,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
const { authQueryKey, session } = authProvider.useAuth()
const enableVersionChecker = useEnableVersionChecker()
const setEnableVersionChecker = useSetEnableVersionChecker()
const { localStorage } = useLocalStorage()
const [features, setFeatures] = React.useState<
Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
@ -92,8 +106,8 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
{children}
<Portal>
<ariaComponents.DialogTrigger>
<ariaComponents.Button
<DialogTrigger>
<Button
icon={DevtoolsLogo}
aria-label={getText('paywallDevtoolsButtonLabel')}
variant="icon"
@ -103,27 +117,25 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
data-ignore-click-outside
/>
<ariaComponents.Popover>
<ariaComponents.Text.Heading disableLineHeightCompensation>
<Popover>
<Text.Heading disableLineHeightCompensation>
{getText('paywallDevtoolsPopoverHeading')}
</ariaComponents.Text.Heading>
</Text.Heading>
<ariaComponents.Separator orientation="horizontal" className="my-3" />
<Separator orientation="horizontal" className="my-3" />
{session?.type === UserSessionType.full && (
<>
<ariaComponents.Text variant="subtitle">
{getText('paywallDevtoolsPlanSelectSubtitle')}
</ariaComponents.Text>
<Text variant="subtitle">{getText('paywallDevtoolsPlanSelectSubtitle')}</Text>
<ariaComponents.Form
<Form
gap="small"
schema={(schema) => schema.object({ plan: schema.string() })}
defaultValues={{ plan: session.user.plan ?? 'free' }}
>
{({ form }) => (
<>
<ariaComponents.RadioGroup
<RadioGroup
form={form}
name="plan"
onChange={(value) => {
@ -133,16 +145,13 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
})
}}
>
<ariaComponents.Radio label={getText('free')} value={'free'} />
<ariaComponents.Radio label={getText('solo')} value={backend.Plan.solo} />
<ariaComponents.Radio label={getText('team')} value={backend.Plan.team} />
<ariaComponents.Radio
label={getText('enterprise')}
value={backend.Plan.enterprise}
/>
</ariaComponents.RadioGroup>
<Radio label={getText('free')} value={'free'} />
<Radio label={getText('solo')} value={backend.Plan.solo} />
<Radio label={getText('team')} value={backend.Plan.team} />
<Radio label={getText('enterprise')} value={backend.Plan.enterprise} />
</RadioGroup>
<ariaComponents.Button
<Button
size="small"
variant="outline"
onPress={() =>
@ -152,27 +161,27 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
}
>
{getText('reset')}
</ariaComponents.Button>
</Button>
</>
)}
</ariaComponents.Form>
</Form>
<ariaComponents.Separator orientation="horizontal" className="my-3" />
<Separator orientation="horizontal" className="my-3" />
{/* eslint-disable-next-line no-restricted-syntax */}
<ariaComponents.Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
<Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
Open setup page
</ariaComponents.Button>
</Button>
<ariaComponents.Separator orientation="horizontal" className="my-3" />
<Separator orientation="horizontal" className="my-3" />
</>
)}
<ariaComponents.Text variant="subtitle" className="mb-2">
<Text variant="subtitle" className="mb-2">
{getText('productionOnlyFeatures')}
</ariaComponents.Text>
</Text>
<div className="flex flex-col">
<aria.Switch
<Switch
className="group flex items-center gap-1"
isSelected={enableVersionChecker ?? !IS_DEV_MODE}
onChange={setEnableVersionChecker}
@ -181,21 +190,45 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
</div>
<ariaComponents.Text className="flex-1">
{getText('enableVersionChecker')}
</ariaComponents.Text>
</aria.Switch>
<Text className="flex-1">{getText('enableVersionChecker')}</Text>
</Switch>
<ariaComponents.Text variant="body" color="disabled">
<Text variant="body" color="disabled">
{getText('enableVersionCheckerDescription')}
</ariaComponents.Text>
</Text>
</div>
<ariaComponents.Separator orientation="horizontal" className="my-3" />
<Separator orientation="horizontal" className="my-3" />
<ariaComponents.Text variant="subtitle" className="mb-2">
<Text variant="subtitle" className="mb-2">
{getText('localStorage')}
</Text>
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
<div className="flex gap-1">
<ButtonGroup className="grow-0">
<Button
size="small"
variant="outline"
onPress={() => {
localStorage.delete(key)
}}
>
{getText('delete')}
</Button>
</ButtonGroup>
<Text variant="body">
{key
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
.replace(/^./, (m) => m.toUpperCase())}
</Text>
</div>
))}
<Separator orientation="horizontal" className="my-3" />
<Text variant="subtitle" className="mb-2">
{getText('paywallDevtoolsPaywallFeaturesToggles')}
</ariaComponents.Text>
</Text>
<div className="flex flex-col gap-1">
{Object.entries(features).map(([feature, configuration]) => {
@ -205,7 +238,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
return (
<div key={feature} className="flex flex-col">
<aria.Switch
<Switch
className="group flex items-center gap-1"
isSelected={configuration.isForceEnabled ?? true}
onChange={(value) => {
@ -218,18 +251,18 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
</div>
<ariaComponents.Text className="flex-1">{getText(label)}</ariaComponents.Text>
</aria.Switch>
<Text className="flex-1">{getText(label)}</Text>
</Switch>
<ariaComponents.Text variant="body" color="disabled">
<Text variant="body" color="disabled">
{getText(descriptionTextId)}
</ariaComponents.Text>
</Text>
</div>
)
})}
</div>
</ariaComponents.Popover>
</ariaComponents.DialogTrigger>
</Popover>
</DialogTrigger>
</Portal>
</PaywallDevtoolsContext.Provider>
)

View File

@ -1,334 +0,0 @@
/** @file A styled dropdown. */
import * as React from 'react'
import CheckMarkIcon from '#/assets/check_mark.svg'
import FolderArrowIcon from '#/assets/folder_arrow.svg'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// ================
// === Dropdown ===
// ================
/** Props for a list item child. */
interface InternalChildProps<T> {
readonly item: T
}
/** Props for the display of the currently selected item, when the dropdown supports multiple children. */
interface InternalChildrenProps<T> {
readonly items: T[]
/** This is the value passed as {@link DropdownProps.render}. */
readonly render: (props: InternalChildProps<T>) => React.ReactNode
}
/** Props for a {@link Dropdown} shared between all variants. */
interface InternalBaseDropdownProps<T> {
readonly readOnly?: boolean
readonly className?: string
readonly items: readonly T[]
readonly render: (props: InternalChildProps<T>) => React.ReactNode
}
/** Props for a {@link Dropdown}, when `multiple` is `false` or absent. */
interface InternalSingleDropdownProps<T> extends InternalBaseDropdownProps<T> {
readonly multiple?: false
readonly selectedIndex: number | null
readonly onClick: (item: T, index: number) => void
}
/** Props for a {@link Dropdown}, when `multiple` is `true`. */
interface InternalMultipleDropdownProps<T> extends InternalBaseDropdownProps<T> {
readonly multiple: true
readonly selectedIndices: readonly number[]
readonly renderMultiple: (props: InternalChildrenProps<T>) => React.ReactNode
readonly onClick: (items: readonly T[], indices: readonly number[]) => void
}
/** Props for a {@link Dropdown}. */
export type DropdownProps<T> = InternalMultipleDropdownProps<T> | InternalSingleDropdownProps<T>
/** A styled dropdown. */
function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivElement>) {
const { readOnly = false, className, items, render: Child } = props
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
const [tempSelectedIndex, setTempSelectedIndex] = React.useState<number | null>(null)
const rootRef = React.useRef<HTMLDivElement | null>(null)
const justFocusedRef = React.useRef(false)
const justBlurredRef = React.useRef(false)
const isMouseDown = React.useRef(false)
const multiple = props.multiple === true
const selectedIndex = 'selectedIndex' in props ? props.selectedIndex : null
const selectedIndices = 'selectedIndices' in props ? props.selectedIndices : []
const selectedItems = selectedIndices.flatMap((index) => {
const item = items[index]
return item != null ? [item] : []
})
const visuallySelectedIndex = tempSelectedIndex ?? selectedIndex
const visuallySelectedItem = visuallySelectedIndex == null ? null : items[visuallySelectedIndex]
React.useEffect(() => {
setTempSelectedIndex(selectedIndex)
}, [selectedIndex])
React.useEffect(() => {
if (!isDropdownVisible) {
rootRef.current?.blur()
}
}, [isDropdownVisible])
React.useEffect(() => {
const onDocumentClick = () => {
setIsDropdownVisible(false)
justBlurredRef.current = true
}
document.addEventListener('click', onDocumentClick)
return () => {
document.removeEventListener('click', onDocumentClick)
}
}, [])
const onKeyDown = (event: React.KeyboardEvent) => {
if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
switch (event.key) {
case 'Escape': {
event.stopPropagation()
setIsDropdownVisible(false)
break
}
case 'Enter':
case 'Tab': {
event.stopPropagation()
if (event.key === 'Enter') {
setIsDropdownVisible(true)
}
if (tempSelectedIndex != null) {
const item = items[tempSelectedIndex]
if (item != null) {
if (multiple) {
const newIndices =
selectedIndices.includes(tempSelectedIndex) ?
selectedIndices.filter((index) => index !== tempSelectedIndex)
: [...selectedIndices, tempSelectedIndex]
props.onClick(
newIndices.flatMap((index) => {
const otherItem = items[index]
return otherItem != null ? [otherItem] : []
}),
newIndices,
)
} else {
props.onClick(item, tempSelectedIndex)
}
}
}
if (isDropdownVisible && (event.key !== 'Enter' || !multiple)) {
setIsDropdownVisible(false)
justBlurredRef.current = true
}
break
}
case 'ArrowUp': {
if (!isDropdownVisible) break
event.preventDefault()
setTempSelectedIndex(
(
tempSelectedIndex == null ||
tempSelectedIndex === 0 ||
tempSelectedIndex >= items.length
) ?
items.length - 1
: tempSelectedIndex - 1,
)
break
}
case 'ArrowDown': {
if (!isDropdownVisible) break
event.preventDefault()
setTempSelectedIndex(
tempSelectedIndex == null || tempSelectedIndex >= items.length - 1 ?
0
: tempSelectedIndex + 1,
)
break
}
}
}
}
return (
<FocusRing placement="outset">
<div
ref={(element) => {
if (typeof ref === 'function') {
ref(element)
} else if (ref != null) {
ref.current = element
}
rootRef.current = element
}}
tabIndex={0}
className={tailwindMerge.twMerge(
'focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy',
className,
)}
onFocus={(event) => {
if (!justBlurredRef.current && !readOnly && event.target === event.currentTarget) {
setIsDropdownVisible(true)
justFocusedRef.current = true
}
justBlurredRef.current = false
}}
onBlur={(event) => {
if (!readOnly && event.target === event.currentTarget) {
setIsDropdownVisible(false)
justBlurredRef.current = true
}
}}
onKeyDown={onKeyDown}
onKeyUp={() => {
justFocusedRef.current = false
}}
onClick={(event) => {
event.stopPropagation()
}}
>
<div
className={tailwindMerge.twMerge(
'absolute left-0 h-full w-full min-w-max',
isDropdownVisible ? 'z-1' : 'overflow-hidden',
)}
>
<div
className={tailwindMerge.twMerge(
'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:backdrop-blur-default before:transition-colors',
isDropdownVisible ?
'before:h-full before:shadow-soft'
: 'before:h-text group-hover:before:bg-hover-bg',
)}
>
{/* Spacing. */}
<div
className="padding relative h-text"
onClick={(event) => {
event.stopPropagation()
if (!justFocusedRef.current && !readOnly) {
setIsDropdownVisible(!isDropdownVisible)
}
justFocusedRef.current = false
}}
/>
<div
className={tailwindMerge.twMerge(
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
isDropdownVisible ? 'grid-rows-1fr' : 'grid-rows-0fr',
)}
>
<div className="overflow-hidden">
{items.map((item, i) => (
<div
tabIndex={-1}
className={tailwindMerge.twMerge(
'flex h-text items-center gap-dropdown-arrow rounded-input px-input-x transition-colors',
multiple && 'hover:font-semibold',
i === visuallySelectedIndex ?
'cursor-default bg-frame font-bold focus-ring'
: 'hover:bg-hover-bg',
)}
key={i}
onMouseDown={(event) => {
event.preventDefault()
isMouseDown.current = true
}}
onMouseUp={() => {
isMouseDown.current = false
}}
onClick={() => {
if (i !== visuallySelectedIndex) {
if (multiple) {
const newIndices =
selectedIndices.includes(i) ?
selectedIndices.filter((index) => index !== i)
: [...selectedIndices, i]
props.onClick(
newIndices.flatMap((index) => {
const otherItem = items[index]
return otherItem != null ? [otherItem] : []
}),
newIndices,
)
rootRef.current?.focus()
} else {
setIsDropdownVisible(false)
props.onClick(item, i)
justBlurredRef.current = true
}
}
}}
onFocus={() => {
if (!isMouseDown.current) {
// This is from keyboard navigation.
if (multiple) {
props.onClick([item], [i])
} else {
props.onClick(item, i)
}
}
}}
>
<SvgMask
src={CheckMarkIcon}
className={selectedIndices.includes(i) ? '' : 'invisible'}
/>
<Child item={item} />
</div>
))}
</div>
</div>
</div>
</div>
<div
className={tailwindMerge.twMerge(
'relative flex h-text items-center gap-dropdown-arrow px-input-x',
isDropdownVisible && 'z-1',
readOnly && 'read-only',
)}
onClick={(event) => {
event.stopPropagation()
if (!justFocusedRef.current && !readOnly) {
setIsDropdownVisible(!isDropdownVisible)
}
justFocusedRef.current = false
}}
>
<SvgMask src={FolderArrowIcon} className="rotate-90" />
<div className="grow">
{visuallySelectedItem != null ?
<Child item={visuallySelectedItem} />
: multiple && <props.renderMultiple items={selectedItems} render={Child} />}
</div>
</div>
{/* Hidden, but required to exist for the width of the parent element to be correct.
* Classes that do not affect width have been removed. */}
<div className="flex h flex-col overflow-hidden">
{items.map((item, i) => (
<div key={i} className="flex gap-dropdown-arrow px-input-x font-bold">
<SvgMask src={CheckMarkIcon} />
<Child item={item} />
</div>
))}
</div>
</div>
</FocusRing>
)
}
/** A styled dropdown. */
// This is REQUIRED, as `React.forwardRef` does not preserve types of generic functions.
// eslint-disable-next-line no-restricted-syntax
export default React.forwardRef(Dropdown) as <T>(
props: DropdownProps<T> & React.RefAttributes<HTMLDivElement>,
) => React.JSX.Element

View File

@ -102,7 +102,7 @@ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {
{detect.IS_DEV_MODE && stack != null && (
<ariaComponents.Alert
className="mx-auto mt-4 max-w-screen-lg overflow-x-auto"
className="mx-auto mt-4 max-h-[80vh] max-w-screen-lg overflow-auto"
variant="neutral"
>
<ariaComponents.Text

View File

@ -1,41 +0,0 @@
/** @file A styled input that includes an icon. */
import * as React from 'react'
import EyeIcon from '#/assets/eye.svg'
import EyeCrossedIcon from '#/assets/eye_crossed.svg'
import type * as controlledInput from '#/components/ControlledInput'
import ControlledInput from '#/components/ControlledInput'
import SvgIcon from '#/components/SvgIcon'
// =============
// === Input ===
// =============
/** Props for a {@link Input}. */
export interface InputProps extends controlledInput.ControlledInputProps {
readonly allowShowingPassword?: boolean
readonly icon: string
}
/** A styled input that includes an icon. */
export default function Input(props: InputProps) {
const { allowShowingPassword = false, icon, type, ...passthrough } = props
const [isShowingPassword, setIsShowingPassword] = React.useState(false)
return (
<div className="relative">
<SvgIcon src={icon} />
<ControlledInput {...passthrough} type={isShowingPassword ? 'text' : type} />
{type === 'password' && allowShowingPassword && (
<SvgIcon
src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
className="left-[unset] right-0 cursor-pointer rounded-full"
onClick={() => {
setIsShowingPassword((show) => !show)
}}
/>
)}
</div>
)
}

View File

@ -1,21 +1,17 @@
/** @file A dynamic wizard for creating an arbitrary type of Datalink. */
import * as React from 'react'
import { type Dispatch, Fragment, type JSX, type SetStateAction, useState } from 'react'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import { Input, Text } from '#/components/aria'
import { Button, Dropdown } from '#/components/AriaComponents'
import Autocomplete from '#/components/Autocomplete'
import Dropdown from '#/components/Dropdown'
import Checkbox from '#/components/styled/Checkbox'
import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing'
import { useBackendQuery } from '#/hooks/backendHooks'
import * as jsonSchema from '#/utilities/jsonSchema'
import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useRemoteBackendStrict } from '#/providers/BackendProvider'
import { useText } from '#/providers/TextProvider'
import { constantValueOfSchema, getSchemaName, lookupDef } from '#/utilities/jsonSchema'
import { asObject, singletonObjectOrNull } from '#/utilities/object'
import { twMerge } from '#/utilities/tailwindMerge'
// =======================
// === JSONSchemaInput ===
@ -30,21 +26,21 @@ export interface JSONSchemaInputProps {
readonly path: string
readonly getValidator: (path: string) => (value: unknown) => boolean
readonly value: NonNullable<unknown> | null
readonly setValue: React.Dispatch<React.SetStateAction<NonNullable<unknown> | null>>
readonly onChange: Dispatch<SetStateAction<NonNullable<unknown> | null>>
}
/** A dynamic wizard for creating an arbitrary type of Datalink. */
export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const { dropdownTitle, readOnly = false, defs, schema, path, getValidator } = props
const { value, setValue } = props
const { value, onChange } = props
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
// but it is more convenient to avoid having plugin infrastructure.
const remoteBackend = backendProvider.useRemoteBackendStrict()
const { getText } = textProvider.useText()
const [autocompleteText, setAutocompleteText] = React.useState(() =>
const remoteBackend = useRemoteBackendStrict()
const { getText } = useText()
const [autocompleteText, setAutocompleteText] = useState(() =>
typeof value === 'string' ? value : null,
)
const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(null)
const [selectedChildIndex, setSelectedChildIndex] = useState<number | null>(null)
const isSecret =
'type' in schema &&
schema.type === 'string' &&
@ -58,7 +54,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
// This value cannot change.
return null
} else {
const children: React.JSX.Element[] = []
const children: JSX.Element[] = []
if ('type' in schema) {
switch (schema.type) {
case 'string': {
@ -66,107 +62,93 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const isValid = typeof value === 'string' && value !== ''
children.push(
<div
className={tailwindMerge.twMerge(
className={twMerge(
'w-60 rounded-default border-0.5',
isValid ? 'border-primary/20' : 'border-red-700/60',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)}
>
<Autocomplete
items={autocompleteItems ?? []}
itemToKey={(item) => item}
itemToString={(item) => item}
placeholder={getText('enterSecretPath')}
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
values={isValid ? [value] : []}
setValues={(values) => {
setValue(values[0] ?? '')
onChange(values[0] ?? '')
}}
text={autocompleteText}
setText={setAutocompleteText}
/>
>
{(item) => item}
</Autocomplete>
</div>,
)
} else {
children.push(
<FocusArea direction="horizontal">
{(innerProps) => (
<FocusRing>
<aria.Input
type="text"
readOnly={readOnly}
value={typeof value === 'string' ? value : ''}
size={1}
className={tailwindMerge.twMerge(
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)}
placeholder={getText('enterText')}
onChange={(event) => {
const newValue: string = event.currentTarget.value
setValue(newValue)
}}
{...innerProps}
/>
</FocusRing>
)}
</FocusArea>,
<FocusRing>
<Input
type="text"
readOnly={readOnly}
value={typeof value === 'string' ? value : ''}
size={1}
className={twMerge(
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)}
placeholder={getText('enterText')}
onChange={(event) => {
const newValue: string = event.currentTarget.value
onChange(newValue)
}}
/>
</FocusRing>,
)
}
break
}
case 'number': {
children.push(
<FocusArea direction="horizontal">
{(innerProps) => (
<FocusRing>
<aria.Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={tailwindMerge.twMerge(
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)}
placeholder={getText('enterNumber')}
onChange={(event) => {
const newValue: number = event.currentTarget.valueAsNumber
if (Number.isFinite(newValue)) {
setValue(newValue)
}
}}
{...innerProps}
/>
</FocusRing>
)}
</FocusArea>,
<FocusRing>
<Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={twMerge(
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)}
placeholder={getText('enterNumber')}
onChange={(event) => {
const newValue: number = event.currentTarget.valueAsNumber
if (Number.isFinite(newValue)) {
onChange(newValue)
}
}}
/>
</FocusRing>,
)
break
}
case 'integer': {
children.push(
<FocusArea direction="horizontal">
{(innerProps) => (
<FocusRing>
<aria.Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={tailwindMerge.twMerge(
'focus-child min-6- text40 w-80 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)}
placeholder={getText('enterInteger')}
onChange={(event) => {
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
setValue(newValue)
}}
{...innerProps}
/>
</FocusRing>
)}
</FocusArea>,
<FocusRing>
<Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={twMerge(
'focus-child min-6- text40 w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)}
placeholder={getText('enterInteger')}
onChange={(event) => {
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
onChange(newValue)
}}
/>
</FocusRing>,
)
break
}
@ -175,79 +157,68 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
<Checkbox
isReadOnly={readOnly}
isSelected={typeof value === 'boolean' && value}
onChange={setValue}
onChange={onChange}
/>,
)
break
}
case 'object': {
const propertiesObject =
'properties' in schema ? object.asObject(schema.properties) ?? {} : {}
const propertiesObject = 'properties' in schema ? asObject(schema.properties) ?? {} : {}
const requiredProperties =
'required' in schema && Array.isArray(schema.required) ? schema.required : []
const propertyDefinitions = Object.entries(propertiesObject).flatMap(
(kv: [string, unknown]) => {
const [k, v] = kv
return object
.singletonObjectOrNull(v)
.map((childSchema) => ({ key: k, schema: childSchema }))
return singletonObjectOrNull(v).map((childSchema) => ({
key: k,
schema: childSchema,
}))
},
)
if (jsonSchema.constantValue(defs, schema).length !== 1) {
if (constantValueOfSchema(defs, schema).length !== 1) {
children.push(
<div className="grid items-center gap-json-schema rounded-default border-0.5 border-primary/20 p-json-schema-object-input">
{propertyDefinitions.map((definition) => {
const { key, schema: childSchema } = definition
const isOptional = !requiredProperties.includes(key)
return jsonSchema.constantValue(defs, childSchema).length === 1 ?
const isPresent = value != null && key in value
return constantValueOfSchema(defs, childSchema).length === 1 ?
null
: <>
<FocusArea active={isOptional} direction="horizontal">
{(innerProps) => {
const isPresent = value != null && key in value
return (
<ariaComponents.Button
size="custom"
variant="custom"
isDisabled={!isOptional}
isActive={!isOptional || isPresent}
className={tailwindMerge.twMerge(
'text col-start-1 inline-block whitespace-nowrap rounded-full px-button-x text-left',
isOptional && 'hover:bg-hover-bg',
)}
onPress={() => {
if (isOptional) {
setValue((oldValue) => {
if (oldValue != null && key in oldValue) {
// This is SAFE, as `value` is an untyped object.
// The removed key is intentionally unused.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
const { [key]: removed, ...newValue } = oldValue as Record<
string,
NonNullable<unknown> | null
>
return newValue
} else {
return {
...oldValue,
[key]: jsonSchema.constantValue(
defs,
childSchema,
true,
)[0],
}
}
})
: <Fragment key={key}>
<Button
size="custom"
variant="custom"
isDisabled={!isOptional}
isActive={!isOptional || isPresent}
className={twMerge(
'col-start-1 inline-block whitespace-nowrap rounded-full px-button-x',
isOptional && 'hover:bg-hover-bg',
)}
onPress={() => {
if (isOptional) {
onChange((oldValue) => {
if (oldValue != null && key in oldValue) {
// This is SAFE, as `value` is an untyped object.
// The removed key is intentionally unused.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
const { [key]: removed, ...newValue } = oldValue as Record<
string,
NonNullable<unknown> | null
>
return newValue
} else {
return {
...oldValue,
[key]: constantValueOfSchema(defs, childSchema, true)[0],
}
}}
{...innerProps}
>
{'title' in childSchema ? String(childSchema.title) : key}
</ariaComponents.Button>
)
}
})
}
}}
</FocusArea>
{value != null && key in value && (
>
{'title' in childSchema ? String(childSchema.title) : key}
</Button>
{isPresent && (
<div className="col-start-2">
<JSONSchemaInput
readOnly={readOnly}
@ -258,8 +229,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
// This is SAFE, as `value` is an untyped object.
// eslint-disable-next-line no-restricted-syntax
value={(value as Record<string, unknown>)[key] ?? null}
setValue={(newValue) => {
setValue((oldValue) => {
onChange={(newValue) => {
onChange((oldValue) => {
if (typeof newValue === 'function') {
const unsafeValue: unknown = newValue(
// This is SAFE; but there is no way to tell TypeScript that an object
@ -288,7 +259,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
/>
</div>
)}
</>
</Fragment>
})}
</div>,
)
@ -298,7 +269,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
}
}
if ('$ref' in schema && typeof schema.$ref === 'string') {
const referencedSchema = jsonSchema.lookupDef(defs, schema)
const referencedSchema = lookupDef(defs, schema)
if (referencedSchema != null) {
children.push(
<JSONSchemaInput
@ -311,12 +282,12 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
}
}
if ('anyOf' in schema && Array.isArray(schema.anyOf)) {
const childSchemas = schema.anyOf.flatMap(object.singletonObjectOrNull)
const childSchemas = schema.anyOf.flatMap(singletonObjectOrNull)
const selectedChildSchema =
selectedChildIndex == null ? null : childSchemas[selectedChildIndex]
const selectedChildPath = `${path}/anyOf/${selectedChildIndex ?? 0}`
const childValue =
selectedChildSchema == null ? [] : jsonSchema.constantValue(defs, selectedChildSchema)
selectedChildSchema == null ? [] : constantValueOfSchema(defs, selectedChildSchema)
if (
value != null &&
(selectedChildSchema == null || getValidator(selectedChildPath)(value) !== true)
@ -330,32 +301,23 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
}
}
const dropdown = (
<FocusArea direction="horizontal">
{(innerProps) => (
<Dropdown
readOnly={readOnly}
items={childSchemas}
selectedIndex={selectedChildIndex}
render={(childProps) => (
<aria.Text>{jsonSchema.getSchemaName(defs, childProps.item)}</aria.Text>
)}
className="self-start"
onClick={(childSchema, index) => {
setSelectedChildIndex(index)
const newConstantValue = jsonSchema.constantValue(defs, childSchema, true)
setValue(newConstantValue[0] ?? null)
}}
{...innerProps}
/>
)}
</FocusArea>
<Dropdown
readOnly={readOnly}
items={childSchemas}
selectedIndex={selectedChildIndex}
className="self-start"
onChange={(childSchema, index) => {
setSelectedChildIndex(index)
const newConstantValue = constantValueOfSchema(defs, childSchema, true)
onChange(newConstantValue[0] ?? null)
}}
>
{({ item }) => <Text slot="label">{getSchemaName(defs, item)}</Text>}
</Dropdown>
)
children.push(
<div
className={tailwindMerge.twMerge(
'flex flex-col gap-json-schema',
childValue.length === 0 && 'w-full',
)}
className={twMerge('flex flex-col gap-json-schema', childValue.length === 0 && 'w-full')}
>
{dropdownTitle != null ?
<div className="flex h-row items-center">
@ -372,14 +334,14 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
path={selectedChildPath}
getValidator={getValidator}
value={value}
setValue={setValue}
onChange={onChange}
/>
)}
</div>,
)
}
if ('allOf' in schema && Array.isArray(schema.allOf)) {
const childSchemas = schema.allOf.flatMap(object.singletonObjectOrNull)
const childSchemas = schema.allOf.flatMap(singletonObjectOrNull)
const newChildren = childSchemas.map((childSchema, i) => (
<JSONSchemaInput
key={i}
@ -389,7 +351,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
path={`${path}/allOf/${i}`}
getValidator={getValidator}
value={value}
setValue={setValue}
onChange={onChange}
/>
))
children.push(...newChildren)

View File

@ -7,6 +7,8 @@ import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import { useFocusChild } from '#/hooks/focusHooks'
import { useText } from '#/providers/TextProvider'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { twMerge } from 'tailwind-merge'
// ============
@ -15,25 +17,28 @@ import { twMerge } from 'tailwind-merge'
/** Props for a {@link Link}. */
export interface LinkProps {
readonly onPress?: () => void
readonly openInBrowser?: boolean
readonly to: string
readonly icon: string
readonly text: string
}
export default React.forwardRef(Link)
export default forwardRef(Link)
/** A styled colored link with an icon. */
function Link(props: LinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
const { openInBrowser = false, to, icon, text } = props
const { openInBrowser = false, to, icon, text, onPress } = props
const { getText } = useText()
const { className: focusChildClassName, ...focusChildProps } = useFocusChild()
const linkRef = React.useRef<HTMLAnchorElement>(null)
return (
<FocusRing>
<aria.Link
ref={ref}
ref={mergeRefs(linkRef, ref)}
href={to}
{...(openInBrowser && { target: '_blank' })}
rel="noopener noreferrer"
className={twMerge(
'flex items-center gap-auth-link rounded-full px-auth-link-x py-auth-link-y text-center text-xs font-bold text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700',
@ -43,6 +48,7 @@ function Link(props: LinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
if (openInBrowser) {
toastify.toast.success(getText('openedLinkInBrowser'))
}
onPress?.()
}}
{...focusChildProps}
>

View File

@ -14,21 +14,35 @@ import SvgMask from '#/components/SvgMask'
// === Constants ===
// =================
const INFO_ICON = (
// eslint-disable-next-line no-restricted-syntax
<ariaComponents.Text variant="custom" className="pb-0.5 text-xl leading-[0]" aria-hidden>
!
</ariaComponents.Text>
)
const STATUS_ICON_MAP: Readonly<Record<Status, StatusIcon>> = {
loading: {
icon: <loader.Loader minHeight="h8" />,
colorClassName: 'text-primary',
bgClassName: 'bg-transparent',
},
info: {
icon: INFO_ICON,
colorClassName: 'text-primary',
bgClassName: 'bg-primary/30',
},
error: { icon: Error, colorClassName: 'text-red-500', bgClassName: 'bg-red-500' },
success: { icon: Success, colorClassName: 'text-green-500', bgClassName: 'bg-green' },
info: {
icon: (
// eslint-disable-next-line no-restricted-syntax
<ariaComponents.Text variant="custom" className="pb-0.5 text-xl leading-[0]" aria-hidden>
!
</ariaComponents.Text>
),
// pending is the same as loading. Used for mutations.
pending: {
icon: <loader.Loader minHeight="h8" />,
colorClassName: 'text-primary',
bgClassName: 'bg-transparent',
},
// idle is the same as info. Used for mutations.
idle: {
icon: INFO_ICON,
colorClassName: 'text-primary',
bgClassName: 'bg-primary/30',
},
@ -60,7 +74,7 @@ const RESULT_STYLES = twv.tv({
// ==============
/** Possible statuses for a result. */
export type Status = 'error' | 'info' | 'loading' | 'success'
export type Status = 'error' | 'idle' | 'info' | 'loading' | 'pending' | 'success'
// ==================
// === StatusIcon ===

View File

@ -80,11 +80,11 @@ export interface StepperProps {
}
const STEPPER_STYLES = tvw.tv({
base: 'flex flex-col items-center w-full',
base: 'flex flex-col items-center w-full gap-4',
slots: {
steps: 'flex items-center justify-between w-full',
step: 'flex-1 last:flex-none',
content: 'relative w-full mt-4',
content: 'relative w-full',
},
})

View File

@ -18,7 +18,13 @@ type Direction = 'back-none' | 'back' | 'forward-none' | 'forward' | 'initial'
* Props for {@link useStepperState}
*/
export interface StepperStateProps {
/**
* The default step to start on (0-indexed)
*/
readonly defaultStep?: number
/**
* The number of steps in the stepper (amount of steps is 1-indexed)
*/
readonly steps: number
readonly onStepChange?: (step: number, direction: 'back' | 'forward') => void
readonly onCompleted?: () => void

View File

@ -1,7 +1,9 @@
/** @file Barrel re-export of `react-aria` and `react-aria-components`. */
import type { Mutable } from 'enso-common/src/utilities/data/object'
import * as aria from 'react-aria'
export type * from '@react-types/shared'
// @ts-expect-error The conflicting exports are props types ONLY.
export * from 'react-aria'
// @ts-expect-error The conflicting exports are props types ONLY.
export * from 'react-aria-components'
@ -18,6 +20,9 @@ export { useTooltipTriggerState, type OverlayTriggerState } from 'react-stately'
*
* The constraint is defaulted to `never` to make an explicit constraint mandatory. */
export function mergeProps<Constraint extends object = never>() {
return <T extends (Partial<Constraint> | null | undefined)[]>(...args: T) =>
aria.mergeProps(...args)
return <const T extends readonly (Partial<Constraint> | null | undefined)[]>(
...args: T & { [K in keyof T]: Pick<T[K], keyof Constraint & keyof T[K]> }
// This is SAFE, as `args` is an intersection of `T` and another type.
// eslint-disable-next-line no-restricted-syntax
) => aria.mergeProps<Mutable<T>>(...(args as T))
}

View File

@ -6,7 +6,7 @@ import { useStore } from 'zustand'
import BlankIcon from '#/assets/blank.svg'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
@ -23,7 +23,6 @@ import AssetListEventType from '#/events/AssetListEventType'
import AssetContextMenu from '#/layouts/AssetContextMenu'
import type * as assetsTable from '#/layouts/AssetsTable'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category from '#/layouts/CategorySwitcher/Category'
import * as aria from '#/components/aria'
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
@ -36,9 +35,9 @@ import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal'
import * as backendModule from '#/services/Backend'
import * as localBackend from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { isCloudCategory } from '#/layouts/CategorySwitcher/Category'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download'
@ -47,6 +46,7 @@ import * as eventModule from '#/utilities/event'
import * as fileInfo from '#/utilities/fileInfo'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
import * as path from '#/utilities/path'
import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge'
@ -98,7 +98,7 @@ export default function AssetRow(props: AssetRowProps) {
const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
const { visibilities } = state
const { visibilities, category } = state
const [item, setItem] = React.useState(rawItem)
const driveStore = useDriveStore()
@ -115,12 +115,14 @@ export default function AssetRow(props: AssetRowProps) {
({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected,
)
const draggableProps = dragAndDropHooks.useDraggable()
const { user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useFullUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const { data: users } = useBackendQuery(backend, 'listUsers', [])
const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', [])
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const rootRef = React.useRef<HTMLElement | null>(null)
const dragOverTimeoutHandle = React.useRef<number | null>(null)
@ -131,7 +133,11 @@ export default function AssetRow(props: AssetRowProps) {
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility }),
)
const isCloud = backend.type === backendModule.BackendType.remote
const nodeParentKeysRef = React.useRef<{
readonly nodeMap: WeakRef<ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>>
readonly parentKeys: Map<backendModule.AssetId, backendModule.DirectoryId>
} | null>(null)
const isCloud = isCloudCategory(category)
const outerVisibility = visibilities.get(item.key)
const visibility =
outerVisibility == null || outerVisibility === Visibility.visible ?
@ -163,7 +169,7 @@ export default function AssetRow(props: AssetRowProps) {
// This is SAFE, as `isOpened` is only true for projects.
// eslint-disable-next-line no-restricted-syntax
...createGetProjectDetailsQuery.createPassiveListener(item.item.id as backendModule.ProjectId),
select: (data) => data.state.type,
select: (data) => data?.state.type,
enabled: item.type === backendModule.AssetType.project,
})
@ -205,7 +211,13 @@ export default function AssetRow(props: AssetRowProps) {
object.merge(oldAsset, {
title: oldAsset.title + ' (copy)',
labels: [],
permissions: permissions.tryGetSingletonOwnerPermission(user),
permissions: permissions.tryCreateOwnerPermission(
`${item.path} (copy)`,
category,
user,
users ?? [],
userGroups ?? [],
),
modifiedAt: dateTime.toRfc3339(new Date()),
}),
)
@ -232,14 +244,19 @@ export default function AssetRow(props: AssetRowProps) {
}
},
[
user,
rootDirectoryId,
asset,
item.key,
toastAndLog,
copyAsset,
nodeMap,
setAsset,
rootDirectoryId,
copyAsset,
asset.id,
asset.title,
nodeMap,
item.path,
item.key,
category,
user,
users,
userGroups,
toastAndLog,
dispatchAssetListEvent,
],
)
@ -259,7 +276,7 @@ export default function AssetRow(props: AssetRowProps) {
let newId = asset.id
if (!isCloud) {
const oldPath = localBackend.extractTypeAndId(asset.id).id
const newPath = projectManager.joinPath(newParentPath, fileInfo.fileName(oldPath))
const newPath = path.joinPath(newParentPath, fileInfo.getFileName(oldPath))
switch (asset.type) {
case backendModule.AssetType.file: {
newId = localBackend.newFileId(newPath)
@ -425,7 +442,7 @@ export default function AssetRow(props: AssetRowProps) {
}, [setModal, asset.description, setAsset, backend, item.item.id, item.item.title])
eventListProvider.useAssetEventListener(async (event) => {
if (state.category === Category.trash) {
if (state.category.type === 'trash') {
switch (event.type) {
case AssetEventType.deleteForever: {
if (event.ids.has(item.key)) {
@ -712,15 +729,33 @@ export default function AssetRow(props: AssetRowProps) {
const directoryKey =
item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey
const payload = drag.ASSET_ROWS.lookup(event)
if (
(payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)) ||
event.dataTransfer.types.includes('Files')
) {
const isPayloadMatch =
payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)
const canPaste = (() => {
if (!isPayloadMatch) {
return true
} else {
if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) {
const parentKeys = new Map(
Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [
id,
otherAsset.directoryKey,
]),
)
nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys }
}
return !payload.some((payloadItem) => {
const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key)
const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
return !parent ? true : (
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path)
)
})
}
})()
if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) {
event.preventDefault()
if (
item.item.type === backendModule.AssetType.directory &&
state.category !== Category.trash
) {
if (item.item.type === backendModule.AssetType.directory && state.category.type !== 'trash') {
setIsDraggedOver(true)
}
}
@ -837,7 +872,7 @@ export default function AssetRow(props: AssetRowProps) {
onDragOver(event)
}}
onDragOver={(event) => {
if (state.category === Category.trash) {
if (state.category.type === 'trash') {
event.dataTransfer.dropEffect = 'none'
}
props.onDragOver?.(event)
@ -864,7 +899,7 @@ export default function AssetRow(props: AssetRowProps) {
props.onDragLeave?.(event)
}}
onDrop={(event) => {
if (state.category !== Category.trash) {
if (state.category.type !== 'trash') {
props.onDrop?.(event)
clearDragState()
const [directoryKey, directoryId, directoryTitle] =
@ -921,7 +956,7 @@ export default function AssetRow(props: AssetRowProps) {
state={state}
rowState={rowState}
setRowState={setRowState}
isEditable={state.category !== Category.trash}
isEditable={state.category.type !== 'trash'}
/>
</td>
)

View File

@ -7,7 +7,11 @@ import * as datalinkValidator from '#/data/datalinkValidator'
import type * as jsonSchemaInput from '#/components/JSONSchemaInput'
import JSONSchemaInput from '#/components/JSONSchemaInput'
import { FieldError } from '#/components/aria'
import type { FieldValues, FormInstance, TSchema } from '#/components/AriaComponents'
import { useFormContext } from '#/components/AriaComponents/Form/components/useFormContext'
import * as error from '#/utilities/error'
import { Controller, type FieldPath } from 'react-hook-form'
// =================
// === Constants ===
@ -45,3 +49,33 @@ export default function DatalinkInput(props: DatalinkInputProps) {
/>
)
}
/** Props for a {@link DatalinkFormInput}. */
export interface DatalinkFormInputProps<Schema extends TSchema>
extends Omit<DatalinkInputProps, 'onChange' | 'value'> {
readonly form?: FormInstance<Schema>
readonly name: FieldPath<FieldValues<Schema>>
}
/** A dynamic wizard for creating an arbitrary type of Datalink. */
export function DatalinkFormInput<Schema extends TSchema>(props: DatalinkFormInputProps<Schema>) {
const fallbackForm = useFormContext()
// eslint-disable-next-line no-restricted-syntax
const { form = fallbackForm as unknown as FormInstance<Schema>, name, ...inputProps } = props
return (
<Controller
control={form.control}
name={name}
render={({ field, fieldState }) => {
const { value, onChange } = field
return (
<>
<DatalinkInput {...inputProps} value={value} onChange={onChange} />
<FieldError>{fieldState.error?.message}</FieldError>
</>
)
}}
/>
)
}

View File

@ -1,6 +1,4 @@
/** @file The icon and name of a {@link backendModule.DirectoryAsset}. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import FolderIcon from '#/assets/folder.svg'

View File

@ -42,7 +42,7 @@ export interface PermissionProps {
readonly backend: Backend
readonly asset: Pick<backendModule.Asset, 'id' | 'permissions' | 'type'>
readonly self: backendModule.UserPermission
readonly self: backendModule.AssetPermission
readonly isOnlyOwner: boolean
readonly permission: backendModule.AssetPermission
readonly setPermission: (userPermissions: backendModule.AssetPermission) => void
@ -57,7 +57,7 @@ export default function Permission(props: PermissionProps) {
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [permission, setPermission] = React.useState(initialPermission)
const permissionId = backendModule.getAssetPermissionId(permission)
const isDisabled = isOnlyOwner && permissionId === self.user.userId
const isDisabled = isOnlyOwner && backendModule.getAssetPermissionId(self) === permissionId
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
const createPermission = useMutation(

View File

@ -71,16 +71,12 @@ export default function ProjectIcon(props: ProjectIconProps) {
const closeProject = projectHooks.useCloseProject()
const openProjectTab = projectHooks.useOpenEditor()
const { user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useFullUserSession()
const { getText } = textProvider.useText()
const {
data: projectState,
isLoading,
isError,
} = reactQuery.useQuery({
const { data: projectState, isError } = reactQuery.useQuery({
...projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.id),
select: (data) => data.state,
select: (data) => data?.state,
enabled: isOpened,
})
const status = projectState?.type
@ -95,12 +91,8 @@ export default function ProjectIcon(props: ProjectIconProps) {
// Project is closed, show open button
if (!isOpened) {
return backendModule.ProjectState.closed
} else if (!isLoading && status == null) {
// Project is opened, but not yet queried.
return backendModule.ProjectState.openInProgress
} else if (isLoading) {
return backendModule.ProjectState.openInProgress
} else if (status == null) {
// Project is opened, but not yet queried.
return backendModule.ProjectState.openInProgress
} else if (status === backendModule.ProjectState.closed) {
// Project is opened locally, but not on the backend yet.
@ -113,8 +105,6 @@ export default function ProjectIcon(props: ProjectIconProps) {
const spinnerState = (() => {
if (!isOpened) {
return spinner.SpinnerState.initial
} else if (isLoading) {
return spinner.SpinnerState.loadingSlow
} else if (isError) {
return spinner.SpinnerState.initial
} else if (status == null) {

View File

@ -63,7 +63,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const { backend, nodeMap } = state
const client = useQueryClient()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useFullUserSession()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()

View File

@ -10,8 +10,6 @@ import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import Category from '#/layouts/CategorySwitcher/Category'
import * as ariaComponents from '#/components/AriaComponents'
import ContextMenu from '#/components/ContextMenu'
import ContextMenus from '#/components/ContextMenus'
@ -37,20 +35,18 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
const { backend, category, setQuery } = state
const { temporarilyAddedLabels, temporarilyRemovedLabels } = rowState
const asset = item.item
const { user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useFullUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const labels = backendHooks.useListTags(backend)
const { data: labels } = backendHooks.useBackendQuery(backend, 'listTags', [])
const labelsByName = React.useMemo(() => {
return new Map(labels?.map((label) => [label.value, label]))
}, [labels])
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const self = asset.permissions?.find(
backendModule.isUserPermissionAnd((permission) => permission.user.userId === user.userId),
)
const self = permissions.tryFindSelfPermission(user, asset.permissions)
const managesThisAsset =
category !== Category.trash &&
category.type !== 'trash' &&
(self?.permission === permissions.PermissionAction.own ||
self?.permission === permissions.PermissionAction.admin)
const setAsset = React.useCallback(

View File

@ -11,7 +11,6 @@ import * as modalProvider from '#/providers/ModalProvider'
import AssetEventType from '#/events/AssetEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category from '#/layouts/CategorySwitcher/Category'
import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column'
@ -45,21 +44,16 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { item, setItem, state, isReadonly = false } = props
const { category, setQuery } = state
const asset = item.item
const { user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useFullUserSession()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
const { setModal } = modalProvider.useSetModal()
const self = asset.permissions?.find(
backendModule.isUserPermissionAnd((permission) => permission.user.userId === user.userId),
)
const self = permissions.tryFindSelfPermission(user, asset.permissions)
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const managesThisAsset =
!isReadonly &&
category !== Category.trash &&
category.type !== 'trash' &&
(self?.permission === permissions.PermissionAction.own ||
self?.permission === permissions.PermissionAction.admin)
const setAsset = React.useCallback(

View File

@ -1,33 +1,28 @@
/** @file A heading for the "Accessed by projects" column. */
import * as React from 'react'
import AccessedByProjectsIcon from '#/assets/accessed_by_projects.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import Button from '#/components/styled/Button'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Accessed by projects" column. */
export default function AccessedByProjectsColumnHeading(props: column.AssetColumnHeadingProps) {
export default function AccessedByProjectsColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
const { getText } = useText()
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
active
image={AccessedByProjectsIcon}
className="size-4"
alt={getText('accessedByProjectsColumnHide')}
variant="icon"
icon={AccessedByProjectsIcon}
aria-label={getText('accessedByProjectsColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(columnUtils.Column.accessedByProjects)
hideColumn(Column.accessedByProjects)
}}
/>
<aria.Text className="text-header">{getText('accessedByProjectsColumnName')}</aria.Text>
<Text className="text-sm font-semibold">{getText('accessedByProjectsColumnName')}</Text>
</div>
)
}

View File

@ -1,33 +1,28 @@
/** @file A heading for the "Accessed data" column. */
import * as React from 'react'
import AccessedDataIcon from '#/assets/accessed_data.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import Button from '#/components/styled/Button'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Accessed data" column. */
export default function AccessedDataColumnHeading(props: column.AssetColumnHeadingProps) {
export default function AccessedDataColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
const { getText } = useText()
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
active
image={AccessedDataIcon}
className="size-4"
alt={getText('accessedDataColumnHide')}
variant="icon"
icon={AccessedDataIcon}
aria-label={getText('accessedDataColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(columnUtils.Column.accessedData)
hideColumn(Column.accessedData)
}}
/>
<aria.Text className="text-header">{getText('accessedDataColumnName')}</aria.Text>
<Text className="text-sm font-semibold">{getText('accessedDataColumnName')}</Text>
</div>
)
}

View File

@ -1,33 +1,28 @@
/** @file A heading for the "Docs" column. */
import * as React from 'react'
import DocsIcon from '#/assets/docs.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import Button from '#/components/styled/Button'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Docs" column. */
export default function DocsColumnHeading(props: column.AssetColumnHeadingProps) {
export default function DocsColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
const { getText } = useText()
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
active
image={DocsIcon}
className="size-4"
alt={getText('docsColumnHide')}
variant="icon"
icon={DocsIcon}
aria-label={getText('docsColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(columnUtils.Column.docs)
hideColumn(Column.docs)
}}
/>
<aria.Text className="text-header">{getText('docsColumnName')}</aria.Text>
<Text className="text-sm font-semibold">{getText('docsColumnName')}</Text>
</div>
)
}

View File

@ -1,33 +1,28 @@
/** @file A heading for the "Labels" column. */
import * as React from 'react'
import TagIcon from '#/assets/tag.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import Button from '#/components/styled/Button'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Labels" column. */
export default function LabelsColumnHeading(props: column.AssetColumnHeadingProps) {
export default function LabelsColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
const { getText } = useText()
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
active
image={TagIcon}
className="size-4"
alt={getText('labelsColumnHide')}
variant="icon"
icon={TagIcon}
aria-label={getText('labelsColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(columnUtils.Column.labels)
hideColumn(Column.labels)
}}
/>
<aria.Text className="text-header">{getText('labelsColumnName')}</aria.Text>
<Text className="fond-semibold text-sm">{getText('labelsColumnName')}</Text>
</div>
)
}

View File

@ -1,29 +1,21 @@
/** @file A heading for the "Modified" column. */
import * as React from 'react'
import SortAscendingIcon from '#/assets/sort_ascending.svg'
import TimeIcon from '#/assets/time.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import Button from '#/components/styled/Button'
import * as sorting from '#/utilities/sorting'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { Text } from '#/components/aria'
import { Button } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useText } from '#/providers/TextProvider'
import { SortDirection, nextSortDirection } from '#/utilities/sorting'
import { twMerge } from '#/utilities/tailwindMerge'
/** A heading for the "Modified" column. */
export default function ModifiedColumnHeading(
props: column.AssetColumnHeadingProps,
): React.JSX.Element {
export default function ModifiedColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { sortInfo, setSortInfo, hideColumn } = state
const { getText } = textProvider.useText()
const isSortActive = sortInfo?.field === columnUtils.Column.modified
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
const { getText } = useText()
const isSortActive = sortInfo?.field === Column.modified
const isDescending = sortInfo?.direction === SortDirection.descending
return (
<div
@ -36,41 +28,39 @@ export default function ModifiedColumnHeading(
className="group flex h-table-row w-full cursor-pointer items-center gap-icon-with-text"
>
<Button
active
image={TimeIcon}
className="size-4"
alt={getText('modifiedColumnHide')}
variant="icon"
icon={TimeIcon}
aria-label={getText('modifiedColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(columnUtils.Column.modified)
hideColumn(Column.modified)
}}
/>
<ariaComponents.Button
<Button
size="custom"
variant="custom"
className="flex grow justify-start gap-icon-with-text"
onPress={() => {
const nextDirection =
isSortActive ?
sorting.nextSortDirection(sortInfo.direction)
: sorting.SortDirection.ascending
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: columnUtils.Column.modified, direction: nextDirection })
setSortInfo({ field: Column.modified, direction: nextDirection })
}
}}
>
<aria.Text className="text-header">{getText('modifiedColumnName')}</aria.Text>
<Text className="text-header">{getText('modifiedColumnName')}</Text>
<img
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
className={tailwindMerge.twMerge(
className={twMerge(
'transition-all duration-arrow',
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
isDescending && 'rotate-180',
)}
/>
</ariaComponents.Button>
</Button>
</div>
)
}

View File

@ -1,30 +1,22 @@
/** @file A heading for the "Name" column. */
import * as React from 'react'
import SortAscendingIcon from '#/assets/sort_ascending.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import * as sorting from '#/utilities/sorting'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useText } from '#/providers/TextProvider'
import { SortDirection, nextSortDirection } from '#/utilities/sorting'
import { twMerge } from '#/utilities/tailwindMerge'
/** A heading for the "Name" column. */
export default function NameColumnHeading(
props: column.AssetColumnHeadingProps,
): React.JSX.Element {
export default function NameColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { sortInfo, setSortInfo } = state
const { getText } = textProvider.useText()
const isSortActive = sortInfo?.field === columnUtils.Column.name
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
const { getText } = useText()
const isSortActive = sortInfo?.field === Column.name
const isDescending = sortInfo?.direction === SortDirection.descending
return (
<ariaComponents.Button
<Button
size="custom"
variant="custom"
aria-label={
@ -36,26 +28,24 @@ export default function NameColumnHeading(
className="group flex h-table-row w-full items-center justify-start gap-icon-with-text px-name-column-x"
onPress={() => {
const nextDirection =
isSortActive ?
sorting.nextSortDirection(sortInfo.direction)
: sorting.SortDirection.ascending
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: columnUtils.Column.name, direction: nextDirection })
setSortInfo({ field: Column.name, direction: nextDirection })
}
}}
>
<aria.Text className="text-header">{getText('nameColumnName')}</aria.Text>
<Text className="text-sm font-semibold">{getText('nameColumnName')}</Text>
<img
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
className={tailwindMerge.twMerge(
className={twMerge(
'transition-all duration-arrow',
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
isDescending && 'rotate-180',
)}
/>
</ariaComponents.Button>
</Button>
)
}

View File

@ -1,53 +1,42 @@
/** @file A heading for the "Shared with" column. */
import * as React from 'react'
import PeopleIcon from '#/assets/people.svg'
import * as billingHooks from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import * as paywall from '#/components/Paywall'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { PaywallDialogButton } from '#/components/Paywall'
import { usePaywall } from '#/hooks/billing'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Shared with" column. */
export default function SharedWithColumnHeading(props: column.AssetColumnHeadingProps) {
export default function SharedWithColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
const { getText } = useText()
const { user } = authProvider.useNonPartialUserSession()
const { user } = useFullUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<ariaComponents.Button
<Button
variant="icon"
size="medium"
icon={PeopleIcon}
aria-label={getText('sharedWithColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(columnUtils.Column.sharedWith)
hideColumn(Column.sharedWith)
}}
/>
<div className="flex items-center gap-1">
<aria.Text className="text-header">{getText('sharedWithColumnName')}</aria.Text>
<Text className="text-sm font-semibold">{getText('sharedWithColumnName')}</Text>
{isUnderPaywall && (
<paywall.PaywallDialogButton
feature="share"
variant="icon"
children={false}
size="medium"
/>
<PaywallDialogButton feature="share" variant="icon" children={false} size="medium" />
)}
</div>
</div>

View File

@ -8,6 +8,7 @@ import * as ariaComponents from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import { forwardRef } from '#/utilities/react'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// ==============
@ -39,6 +40,8 @@ export interface ButtonProps {
readonly onPress: (event: aria.PressEvent) => void
}
export default forwardRef(Button)
/** A styled button. */
function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
const {
@ -64,13 +67,17 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
const button = (
<FocusRing placement="after">
<aria.Button
{...aria.mergeProps<aria.ButtonProps>()(buttonProps, focusChildProps, {
ref,
className: tailwindMerge.twMerge(
'relative after:pointer-events-none after:absolute after:inset after:rounded-button-focus-ring transition-colors hover:enabled:bg-primary/10 rounded-button-focus-ring -m-1 p-1',
buttonClassName,
),
})}
{...aria.mergeProps<aria.ButtonProps & React.RefAttributes<HTMLButtonElement>>()(
buttonProps,
focusChildProps,
{
ref,
className: tailwindMerge.twMerge(
'relative after:pointer-events-none after:absolute after:inset after:rounded-button-focus-ring transition-colors hover:enabled:bg-primary/10 rounded-button-focus-ring -m-1 p-1',
buttonClassName,
),
},
)}
>
<div
className={tailwindMerge.twMerge(
@ -103,5 +110,3 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
</ariaComponents.TooltipTrigger>
)
}
export default React.forwardRef(Button)

View File

@ -62,7 +62,11 @@ function FocusRoot(props: FocusRootProps) {
}
}
},
...(active ? { onKeyDown: navigator2D.onKeyDown.bind(navigator2D) } : {}),
...(active && {
onKeyDown: (event) => {
navigator2D.onKeyDown(event)
},
}),
}),
[active, children, navigator2D],
)

View File

@ -1,27 +1,32 @@
/** @file An input that handles focus movement. */
import * as React from 'react'
import type { ForwardedRef, RefAttributes } from 'react'
import * as focusHooks from '#/hooks/focusHooks'
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
import * as aria from '#/components/aria'
import {
Input as AriaInput,
mergeProps,
type InputProps as AriaInputProps,
} from '#/components/aria'
import { useHandleFocusMove } from '#/hooks/focusHooks'
import { useFocusDirection } from '#/providers/FocusDirectionProvider'
import { forwardRef } from '#/utilities/react'
// =============
// === Input ===
// =============
/** Props for a {@link Input}. */
export interface InputProps extends Readonly<aria.InputProps> {}
export interface InputProps extends Readonly<AriaInputProps> {}
export default forwardRef(Input)
/** An input that handles focus movement. */
function Input(props: InputProps, ref: React.ForwardedRef<HTMLInputElement>) {
const focusDirection = focusDirectionProvider.useFocusDirection()
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
function Input(props: InputProps, ref: ForwardedRef<HTMLInputElement>) {
const focusDirection = useFocusDirection()
const handleFocusMove = useHandleFocusMove(focusDirection)
return (
<aria.Input
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(props, {
<AriaInput
{...mergeProps<AriaInputProps & RefAttributes<HTMLInputElement>>()(props, {
ref,
className: 'focus-child',
onKeyDown: handleFocusMove,
@ -29,5 +34,3 @@ function Input(props: InputProps, ref: React.ForwardedRef<HTMLInputElement>) {
/>
)
}
export default React.forwardRef(Input)

View File

@ -19,6 +19,7 @@ import * as React from 'react'
import * as reactStately from 'react-stately'
import * as aria from '#/components/aria'
import { forwardRef } from '#/utilities/react'
/** Options for {@link useRenderProps}. */
interface RenderPropsHookOptions<T> extends aria.DOMProps, aria.AriaLabelingProps {
@ -101,6 +102,9 @@ function useSlot(): [React.RefCallback<Element>, boolean] {
// eslint-disable-next-line no-restricted-syntax
const UNDEFINED = undefined
/** A radio group allows a user to select a single item from a list of mutually exclusive options. */
export default forwardRef(RadioGroup)
/** A radio group allows a user to select a single item from a list of mutually exclusive options. */
function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef<HTMLDivElement>) {
;[props, ref] = aria.useContextProps(props, ref, aria.RadioGroupContext)
@ -171,6 +175,3 @@ function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef<HTMLDiv
</div>
)
}
/** A radio group allows a user to select a single item from a list of mutually exclusive options. */
export default React.forwardRef(RadioGroup)

View File

@ -11,6 +11,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import Button from '#/components/styled/Button'
import FocusRing from '#/components/styled/FocusRing'
import { forwardRef } from '#/utilities/react'
import { twMerge } from '#/utilities/tailwindMerge'
// =====================
@ -27,6 +28,8 @@ export interface SettingsInputProps {
readonly onSubmit?: (event: React.SyntheticEvent<HTMLInputElement>) => void
}
export default forwardRef(SettingsInput)
/** A styled input specific to settings pages. */
function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLInputElement>) {
const { isDisabled = false, type, placeholder, autoComplete, onChange, onSubmit } = props
@ -98,5 +101,3 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
</div>
)
}
export default React.forwardRef(SettingsInput)

View File

@ -1,7 +1,13 @@
/** @file A styled button representing a tab on a sidebar. */
import * as React from 'react'
import { Button, type ButtonProps } from '#/components/AriaComponents'
import { tv } from '#/utilities/tailwindVariants'
import * as ariaComponent from '#/components/AriaComponents'
const SIDEBAR_TAB_BUTTON_STYLES = tv({
base: 'font-medium',
variants: {
isActive: { true: 'bg-white opacity-100' },
},
})
// ========================
// === SidebarTabButton ===
@ -13,28 +19,30 @@ export interface SidebarTabButtonProps {
readonly isDisabled?: boolean
readonly autoFocus?: boolean
/** When `true`, the button is not faded out even when not hovered. */
readonly active?: boolean
readonly isActive?: boolean
readonly icon: string
readonly label: string
readonly onPress: ariaComponent.ButtonProps['onPress']
readonly onPress: ButtonProps['onPress']
}
/** A styled button representing a tab on a sidebar. */
export default function SidebarTabButton(props: SidebarTabButtonProps) {
const { isDisabled = false, active = false, icon, label, onPress } = props
const { isDisabled = false, isActive = false, icon, label, onPress } = props
const styles = SIDEBAR_TAB_BUTTON_STYLES({ isActive })
return (
<ariaComponent.Button
onPress={onPress}
<Button
icon={icon}
variant="ghost"
variant="ghost-fading"
loaderPosition="icon"
size="medium"
isDisabled={isDisabled}
rounded="full"
className={active ? 'bg-white opacity-100' : ''}
className={styles}
onPress={onPress}
>
{label}
</ariaComponent.Button>
</Button>
)
}

Some files were not shown because too many files have changed in this diff Show More