diff options
45 files changed, 3110 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9299ae6 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +## Routing + +Routing uses [React Router Data Mode](https://reactrouter.com/start/data/installation). +`src/route-config.ts` is the single source for page paths, components, titles, +icons, menu groups, and default expanded groups. It generates the page routes and +menu data. `src/router.tsx` creates the router with `createBrowserRouter`, and +`src/main.tsx` renders it with `RouterProvider`. `src/App.tsx` configures the +Spectrum Provider and renders the route `Outlet` inside `BasicLayout`. +`src/components/layout-basic.tsx` owns the sidebar, top bar, collapse state, and content area, +with styles in `src/components/layout-basic.module.css`. It accepts page content through `children`. +`src/components/sidebar-menu.tsx` provides the shared menu list, and +`src/components/collapsed-navigation.tsx` renders the same data in icon mode. +The top bar reads the title from the matched route's metadata. On screens up to +640px wide, `src/components/mobile-navigation.tsx` opens it in a full-screen modal +from the top bar. Selecting a page or closing the modal returns to the content. + +- `/`: the homepage in `src/page-home.tsx`. +- `/about`: the about page in `src/page-about.tsx`. +- `/form`: the Spectrum form showcase in `src/page-form.tsx`. +- `/ui-kit`: the Spectrum component showcase in `src/page-ui-kit.tsx`. +- Unmatched paths: the 404 page in `src/page-not-found.tsx`. + +Place page components directly in `src/` and name their files `page-*.tsx`. +Place shared components and their styles in `src/components/`. +Use colocated `*.module.css` files for page and component styles. Keep global +theme variables, document styles, and resets in `src/index.css`. Shared menu icon +styles live in `src/components/menu-icon.module.css`, and the basic page spacing +is shared through `src/page.module.css`. + +The top-right settings button opens `src/components/global-settings.tsx` with +light and dark appearance options. `src/theme.ts` provides the shared theme +context; `App` synchronizes it with Spectrum's Provider and the document theme. +The initial theme uses the saved preference, or the system preference on first +visit. Selections persist in local storage when it is available. + +## UI Kit + +`src/components/ui-kit/` contains lazily loaded examples of buttons, badges, +status lights, alerts, progress indicators, dialogs, popovers, tooltips, toasts, +breadcrumbs, tabs, accordions, removable tags, cards, and a sortable table with +row selection. Examples respond to the global light/dark appearance setting. + +## Form showcase + +`src/components/form-examples/` contains lazily loaded examples of text, password, +email, URL, telephone, search, multiline, number, checkbox, radio, switch, picker, +combo box, select box, segmented control, toggle buttons, slider, range slider, +date/time fields, date pickers, calendars, color controls, file selection, and drag +and drop. Layout controls demonstrate sizes, label positions, and disabled states. +Each field has its own named label/input grid. Side labels automatically fall +back to top labels when the form container is narrower than 560px, and restore +when sufficient space is available. + +Submission uses native validation plus a custom username rule. The username +`admin` demonstrates a local business validation error. A successful submission +shows a data preview with the password masked; no data or files are uploaded. +Reset remounts the examples to clear both native inputs and controlled widget state. + +Add pages to `routeConfig` in `src/route-config.ts` with a unique `id`, `title`, +absolute `path`, `Component`, and optional `Icon`. Use `children` for menu groups +and `defaultExpanded` to control their initial expansion. Menu groups do not +change page URLs. Set `hidden: true` to keep a route out of navigation, as with +the 404 page. Desktop, collapsed, and mobile menus all use this configuration. +When deploying, configure your host to serve `index.html` for application URLs +so direct visits and refreshes on nested routes work. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/index.html b/index.html new file mode 100644 index 0000000..c812ecd --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>template-react-spectrum</title> + </head> + <body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> + </body> +</html> diff --git a/package.json b/package.json new file mode 100644 index 0000000..9bacee5 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "template-react-spectrum", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@react-aria/optimize-locales-plugin": "^2.0.2", + "@react-spectrum/s2": "^1.7.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.1" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "unplugin-parcel-macros": "^0.2.0", + "vite": "^8.2.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..72fdc7f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1279 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@react-aria/optimize-locales-plugin': + specifier: ^2.0.2 + version: 2.0.2 + '@react-spectrum/s2': + specifier: ^1.7.1 + version: 1.7.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + react-router: + specifier: ^8.3.1 + version: 8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.1.0 + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)) + oxlint: + specifier: ^1.79.0 + version: 1.81.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + unplugin-parcel-macros: + specifier: ^0.2.0 + version: 0.2.0(@swc/helpers@0.5.23) + vite: + specifier: ^8.2.2 + version: 8.2.2(@types/node@24.13.3) + +packages: + + '@internationalized/date@3.12.4': + resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} + + '@internationalized/number@3.6.8': + resolution: {integrity: sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==} + + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + + '@oxlint/binding-android-arm-eabi@1.81.0': + resolution: {integrity: sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.81.0': + resolution: {integrity: sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.81.0': + resolution: {integrity: sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.81.0': + resolution: {integrity: sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.81.0': + resolution: {integrity: sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.81.0': + resolution: {integrity: sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.81.0': + resolution: {integrity: sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.81.0': + resolution: {integrity: sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.81.0': + resolution: {integrity: sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.81.0': + resolution: {integrity: sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.81.0': + resolution: {integrity: sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.81.0': + resolution: {integrity: sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.81.0': + resolution: {integrity: sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.81.0': + resolution: {integrity: sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.81.0': + resolution: {integrity: sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.81.0': + resolution: {integrity: sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.81.0': + resolution: {integrity: sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.81.0': + resolution: {integrity: sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.81.0': + resolution: {integrity: sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@parcel/macros@2.16.4': + resolution: {integrity: sha512-Uijt2AYhsMtxnRlwxQwYgZ7TTZVgi0mfk0EKh83NoCEJjrwArJRLNBv/xP6e/5KuTEDSNr/vqqcPk0BgmBvTKw==} + engines: {node: '>= 16.0.0', parcel: ^2.16.4} + + '@parcel/source-map@2.1.1': + resolution: {integrity: sha512-Ejx1P/mj+kMjQb8/y5XxDUn4reGdr+WyKYloBljpppUy8gs42T+BNoEOuRYqDVdgPc6NxduzIDoJS9pOFfV5Ew==} + engines: {node: ^12.18.3 || >=14} + + '@react-aria/optimize-locales-plugin@2.0.2': + resolution: {integrity: sha512-YxRwGC76G/aOozUEzl93irjhGuLsC63clF5abOX+lNAlg7EGns7250Ud8Ice6dy5oLv9mSXQx8LEf0b+56Cclg==} + + '@react-spectrum/s2@1.7.1': + resolution: {integrity: sha512-MMuEtebNabzzglLiGGSyXkg/yEig1cLqmq/JSKlYS+9cKzQoxwUIWtbdZLFFE9Ju0MPH9zNfpHXma1q9+hC+Yg==} + peerDependencies: + react: ^19.0.0-rc.1 + react-dom: ^19.0.0-rc.1 + + '@react-types/shared@3.36.1': + resolution: {integrity: sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@swc/core-darwin-arm64@1.16.2': + resolution: {integrity: sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.16.2': + resolution: {integrity: sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.16.2': + resolution: {integrity: sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.16.2': + resolution: {integrity: sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.16.2': + resolution: {integrity: sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.16.2': + resolution: {integrity: sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.16.2': + resolution: {integrity: sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.16.2': + resolution: {integrity: sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.16.2': + resolution: {integrity: sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.16.2': + resolution: {integrity: sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.16.2': + resolution: {integrity: sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.16.2': + resolution: {integrity: sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.16.2': + resolution: {integrity: sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-wasm@1.1.3: + resolution: {integrity: sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg==} + + oxlint@1.81.0: + resolution: {integrity: sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + react-aria-components@1.21.1: + resolution: {integrity: sha512-J88WflYY0z+EhLX0ce+GjFlQ3ag3ubIgfUTjpKSrBQBu92Xtl4Ub9o118HItUddtbk1Ido0jzyPy94/klZy1hg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-aria@3.52.1: + resolution: {integrity: sha512-fdZZruC9/x/joCg0mhKGs5aHpwrXLCSZ4GOJmhhYyiE0ffyEsk9MLFt9LCOCF9tn8ErTD+UDQP4oDUSlZkmpCg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-router@8.3.1: + resolution: {integrity: sha512-TEOpiO2g0TJHEOJeRVv4amUFun9v1npCKszvcquNvzETUtJ8udV86ah5eFoHT7g26bsBvT6EiIhqulR8eDF++A==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + + react-stately@3.50.0: + resolution: {integrity: sha512-TnckvpDQGU0672wEaLZqdzvhuSeXWjgW6vijMWxiKOxc8CosQUfPGISdcZyfHqjX3XMjEtRxj4w9HumvX4h4mw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unplugin-parcel-macros-darwin-arm64@0.2.0: + resolution: {integrity: sha512-gwCMdklmYmUHlF+TLa76R/Z/HBfklE2GIVtljMgTdJhNGseigbz+jWLWNKX+D95XHa7J2JPVfCUeiAQ0qYSvfw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + unplugin-parcel-macros-darwin-x64@0.2.0: + resolution: {integrity: sha512-4YW0YH7rhthQIWiOapCLvVQdTIRQSBX/mWZ+TeLjgRPZbxCq38FSiyVehG6uTFKK/9tRG9yR8hOcFulv1EOP7g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + unplugin-parcel-macros-linux-arm64-gnu@0.2.0: + resolution: {integrity: sha512-X8X8gjxOdX5b60gVUaU7t2HoDt1aOXNcG9aiQmPQF+BZVv2AiEf9XjaiMFwCTG9A6dVJLAVSxCNv8D14mm4W2w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + unplugin-parcel-macros-linux-arm64-musl@0.2.0: + resolution: {integrity: sha512-d21oC4k248xuRObcqHelB9Rh9gTnr+1bCpPwG88cREj/jOT+RnsMj/ht1N5a0KZI8OF0dGCiLaIa9Qo9QT+Liw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + unplugin-parcel-macros-linux-x64-gnu@0.2.0: + resolution: {integrity: sha512-ziGoLVVidgnHAYfcbt99xKmA+nSuglxuj1nleecH9U3S7gZ6i/41rtTLi8wn2klyWlbnGozL6/leWnMSJe+PVA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + unplugin-parcel-macros-linux-x64-musl@0.2.0: + resolution: {integrity: sha512-miRdxxNIFtlyCYfy/pyCjdgju2T/j/IpsNKcjnP0rw3zYXKVnA8sZy5XtlU4xxC7SpDeIbOhwLYXrrABLlobAA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + unplugin-parcel-macros-win32-x64-msvc@0.2.0: + resolution: {integrity: sha512-rWEKltnOpqb64UQg9Os4atU8ogaaCEsI5SDFn+/x4nLXK8RhPR66yfGoEwitjfFpwxMdQ/h0EmztBMJxCWEXCA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + unplugin-parcel-macros@0.2.0: + resolution: {integrity: sha512-Cwb3C9UMRcXYQYSkxh9BJ0swGrZgEShX7nar3qKAaYHmiwUzLbOUyMCQV4oyCbIO2aE6Sip0i2L1Ch5opXp/gw==} + engines: {node: '>= 18'} + + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + +snapshots: + + '@internationalized/date@3.12.4': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.8': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/string@3.2.10': + dependencies: + '@swc/helpers': 0.5.23 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.148.0': {} + + '@oxlint/binding-android-arm-eabi@1.81.0': + optional: true + + '@oxlint/binding-android-arm64@1.81.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.81.0': + optional: true + + '@oxlint/binding-darwin-x64@1.81.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.81.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.81.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.81.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.81.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.81.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.81.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.81.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.81.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.81.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.81.0': + optional: true + + '@parcel/macros@2.16.4': {} + + '@parcel/source-map@2.1.1': + dependencies: + detect-libc: 1.0.3 + + '@react-aria/optimize-locales-plugin@2.0.2': + dependencies: + unplugin: 2.3.11 + + '@react-spectrum/s2@1.7.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.8 + '@parcel/macros': 2.16.4 + '@react-types/shared': 3.36.1(react@19.2.8) + react: 19.2.8 + react-aria: 3.52.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-aria-components: 1.21.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-dom: 19.2.8(react@19.2.8) + react-stately: 3.50.0(react@19.2.8) + + '@react-types/shared@3.36.1(react@19.2.8)': + dependencies: + react: 19.2.8 + + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true + + '@rolldown/binding-android-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@swc/core-darwin-arm64@1.16.2': + optional: true + + '@swc/core-darwin-x64@1.16.2': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.16.2': + optional: true + + '@swc/core-linux-arm64-gnu@1.16.2': + optional: true + + '@swc/core-linux-arm64-musl@1.16.2': + optional: true + + '@swc/core-linux-ppc64-gnu@1.16.2': + optional: true + + '@swc/core-linux-s390x-gnu@1.16.2': + optional: true + + '@swc/core-linux-x64-gnu@1.16.2': + optional: true + + '@swc/core-linux-x64-musl@1.16.2': + optional: true + + '@swc/core-win32-arm64-msvc@1.16.2': + optional: true + + '@swc/core-win32-ia32-msvc@1.16.2': + optional: true + + '@swc/core-win32-x64-msvc@1.16.2': + optional: true + + '@swc/core@1.16.2(@swc/helpers@0.5.23)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.16.2 + '@swc/core-darwin-x64': 1.16.2 + '@swc/core-linux-arm-gnueabihf': 1.16.2 + '@swc/core-linux-arm64-gnu': 1.16.2 + '@swc/core-linux-arm64-musl': 1.16.2 + '@swc/core-linux-ppc64-gnu': 1.16.2 + '@swc/core-linux-s390x-gnu': 1.16.2 + '@swc/core-linux-x64-gnu': 1.16.2 + '@swc/core-linux-x64-musl': 1.16.2 + '@swc/core-win32-arm64-msvc': 1.16.2 + '@swc/core-win32-ia32-msvc': 1.16.2 + '@swc/core-win32-x64-msvc': 1.16.2 + '@swc/helpers': 0.5.23 + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2(@types/node@24.13.3) + + acorn@8.18.0: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + cookie-es@3.1.1: {} + + csstype@3.2.3: {} + + detect-libc@1.0.3: {} + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + nanoid@3.3.18: {} + + napi-wasm@1.1.3: {} + + oxlint@1.81.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.81.0 + '@oxlint/binding-android-arm64': 1.81.0 + '@oxlint/binding-darwin-arm64': 1.81.0 + '@oxlint/binding-darwin-x64': 1.81.0 + '@oxlint/binding-freebsd-x64': 1.81.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.81.0 + '@oxlint/binding-linux-arm-musleabihf': 1.81.0 + '@oxlint/binding-linux-arm64-gnu': 1.81.0 + '@oxlint/binding-linux-arm64-musl': 1.81.0 + '@oxlint/binding-linux-ppc64-gnu': 1.81.0 + '@oxlint/binding-linux-riscv64-gnu': 1.81.0 + '@oxlint/binding-linux-riscv64-musl': 1.81.0 + '@oxlint/binding-linux-s390x-gnu': 1.81.0 + '@oxlint/binding-linux-x64-gnu': 1.81.0 + '@oxlint/binding-linux-x64-musl': 1.81.0 + '@oxlint/binding-openharmony-arm64': 1.81.0 + '@oxlint/binding-win32-arm64-msvc': 1.81.0 + '@oxlint/binding-win32-ia32-msvc': 1.81.0 + '@oxlint/binding-win32-x64-msvc': 1.81.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-aria-components@1.21.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.2.8) + '@swc/helpers': 0.5.23 + client-only: 0.0.1 + react: 19.2.8 + react-aria: 3.52.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-dom: 19.2.8(react@19.2.8) + react-stately: 3.50.0(react@19.2.8) + + react-aria@3.52.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.8 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.2.8) + '@swc/helpers': 0.5.23 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-stately: 3.50.0(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie-es: 3.1.1 + react: 19.2.8 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react-stately@3.50.0(react@19.2.8): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.8 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.2.8) + '@swc/helpers': 0.5.23 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + + react@19.2.8: {} + + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + + scheduler@0.27.0: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tslib@2.8.1: {} + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + unplugin-parcel-macros-darwin-arm64@0.2.0: + optional: true + + unplugin-parcel-macros-darwin-x64@0.2.0: + optional: true + + unplugin-parcel-macros-linux-arm64-gnu@0.2.0: + optional: true + + unplugin-parcel-macros-linux-arm64-musl@0.2.0: + optional: true + + unplugin-parcel-macros-linux-x64-gnu@0.2.0: + optional: true + + unplugin-parcel-macros-linux-x64-musl@0.2.0: + optional: true + + unplugin-parcel-macros-win32-x64-msvc@0.2.0: + optional: true + + unplugin-parcel-macros@0.2.0(@swc/helpers@0.5.23): + dependencies: + '@parcel/source-map': 2.1.1 + '@swc/core': 1.16.2(@swc/helpers@0.5.23) + napi-wasm: 1.1.3 + unplugin: 1.16.1 + optionalDependencies: + unplugin-parcel-macros-darwin-arm64: 0.2.0 + unplugin-parcel-macros-darwin-x64: 0.2.0 + unplugin-parcel-macros-linux-arm64-gnu: 0.2.0 + unplugin-parcel-macros-linux-arm64-musl: 0.2.0 + unplugin-parcel-macros-linux-x64-gnu: 0.2.0 + unplugin-parcel-macros-linux-x64-musl: 0.2.0 + unplugin-parcel-macros-win32-x64-msvc: 0.2.0 + transitivePeerDependencies: + - '@swc/helpers' + + unplugin@1.16.1: + dependencies: + acorn: 8.18.0 + webpack-virtual-modules: 0.6.2 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.18.0 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + vite@8.2.2(@types/node@24.13.3): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.7 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + + webpack-virtual-modules@0.6.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..91ac346 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + '@swc/core': true diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..716d5ad --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,38 @@ +import { useLayoutEffect, useState } from 'react' +import { Outlet, useHref, useNavigate, type NavigateOptions } from 'react-router' +import { Provider } from '@react-spectrum/s2/Provider' +import BasicLayout from './components/layout-basic' +import { getInitialColorScheme, ThemeContext, themeStorageKey } from './theme' + +// Configure the type of the `routerOptions` prop on all React Spectrum components. +declare module '@react-spectrum/s2/Provider' { + interface RouterConfig { + routerOptions: NavigateOptions + } +} + +function App() { + const navigate = useNavigate() + const [colorScheme, setColorScheme] = useState(getInitialColorScheme) + + useLayoutEffect(() => { + document.documentElement.dataset.theme = colorScheme + try { + localStorage.setItem(themeStorageKey, colorScheme) + } catch { + // Keep applying the theme even when browser storage is unavailable. + } + }, [colorScheme]) + + return ( + <ThemeContext value={{ colorScheme, setColorScheme }}> + <Provider background="base" colorScheme={colorScheme} router={{ navigate, useHref }}> + <BasicLayout> + <Outlet /> + </BasicLayout> + </Provider> + </ThemeContext> + ) +} + +export default App diff --git a/src/components/collapsed-navigation.tsx b/src/components/collapsed-navigation.tsx new file mode 100644 index 0000000..1748317 --- /dev/null +++ b/src/components/collapsed-navigation.tsx @@ -0,0 +1,54 @@ +import { NavLink } from 'react-router' +import { ActionButton } from '@react-spectrum/s2/ActionButton' +import { Menu, MenuItem, MenuTrigger, SubmenuTrigger, Text } from '@react-spectrum/s2/Menu' +import { Tooltip, TooltipTrigger } from '@react-spectrum/s2/Tooltip' +import { menuItems, type NavigationItem } from '../route-config' +import iconStyles from './menu-icon.module.css' +import styles from './layout-basic.module.css' + +function containsPage(item: NavigationItem, pageId?: string): boolean { + return item.id === pageId || !!item.children?.some((child) => containsPage(child, pageId)) +} + +function renderMenuItem(item: NavigationItem) { + const Icon = item.Icon + const content = <>{Icon && <Icon />}<Text>{item.title}</Text></> + + return item.children ? ( + <SubmenuTrigger key={item.id}> + <MenuItem id={item.id} textValue={item.title}>{content}</MenuItem> + <Menu aria-label={item.title}>{item.children.map(renderMenuItem)}</Menu> + </SubmenuTrigger> + ) : ( + <MenuItem key={item.id} id={item.id} href={item.path} textValue={item.title}>{content}</MenuItem> + ) +} + +function CollapsedNavigation({ activePageId }: { activePageId?: string }) { + return ( + <div className={styles.iconNavigation}> + {menuItems.map((item) => { + const Icon = item.Icon + const icon = Icon ? <Icon UNSAFE_className={iconStyles.icon} /> : item.title.slice(0, 1) + + return item.children ? ( + <div key={item.id} className={`${styles.iconNavigationGroup}${containsPage(item, activePageId) ? ` ${styles.isActive}` : ''}`}> + <MenuTrigger direction="right" align="start"> + <TooltipTrigger placement="right"> + <ActionButton aria-label={item.title} isQuiet>{icon}</ActionButton> + <Tooltip>{item.title}</Tooltip> + </TooltipTrigger> + <Menu aria-label={item.title}>{item.children.map(renderMenuItem)}</Menu> + </MenuTrigger> + </div> + ) : ( + <NavLink key={item.id} className={styles.iconNavigationLink} to={item.path} end aria-label={item.title} title={item.title}> + {icon} + </NavLink> + ) + })} + </div> + ) +} + +export default CollapsedNavigation diff --git a/src/components/form-examples/color-file-fields.tsx b/src/components/form-examples/color-file-fields.tsx new file mode 100644 index 0000000..e9d0e74 --- /dev/null +++ b/src/components/form-examples/color-file-fields.tsx @@ -0,0 +1,69 @@ +import FieldGrid from './field-grid' +import { useState } from 'react' +import { ColorArea, parseColor } from '@react-spectrum/s2/ColorArea' +import { ColorField } from '@react-spectrum/s2/ColorField' +import { ColorSlider } from '@react-spectrum/s2/ColorSlider' +import { ColorWheel } from '@react-spectrum/s2/ColorWheel' +import { ColorSwatch, ColorSwatchPicker } from '@react-spectrum/s2/ColorSwatchPicker' +import { DropZone } from '@react-spectrum/s2/DropZone' +import { FileTrigger } from '@react-spectrum/s2/FileTrigger' +import { Button } from '@react-spectrum/s2/Button' +import styles from './form-examples.module.css' + +export default function ColorFileFields({ disabled }: { disabled: boolean }) { + const [color, setColor] = useState(() => parseColor('hsl(265, 75%, 60%)')) + const [files, setFiles] = useState<File[]>([]) + const [fileError, setFileError] = useState('') + return ( + <> + <section className={styles.section} aria-labelledby="color-fields-title"> + <h2 id="color-fields-title">颜色选择</h2> + <p>输入框、色板、色轮和滑块共享同一个颜色值。</p> + <FieldGrid> + <ColorField label="颜色值" value={color} onChange={(next) => { if (next) setColor(next) }} /> + <ColorSlider isDisabled={disabled} label="色相" channel="hue" colorSpace="hsl" value={color} onChange={setColor} /> + <ColorSlider isDisabled={disabled} label="透明度" channel="alpha" value={color} onChange={setColor} /> + <div className={styles.stack}> + <span>预设色板</span> + <ColorSwatchPicker value={color} onChange={setColor}> + <ColorSwatch color="#8b5cf6" /><ColorSwatch color="#3b82f6" /><ColorSwatch color="#22c55e" /><ColorSwatch color="#f97316" /> + </ColorSwatchPicker> + </div> + </FieldGrid> + <div className={styles.calendars}> + <div className={styles.stack}><span id="color-area-label">二维颜色面板</span><ColorArea isDisabled={disabled} aria-labelledby="color-area-label" colorSpace="hsl" xChannel="saturation" yChannel="lightness" value={color} onChange={setColor} /></div> + <div className={styles.stack}><span id="color-wheel-label">色轮</span><ColorWheel isDisabled={disabled} aria-labelledby="color-wheel-label" value={color} onChange={setColor} /></div> + </div> + <input type="hidden" name="color" value={color.toString('hexa')} /> + <output className={styles.output}>{color.toString('hexa')}</output> + </section> + <section className={styles.section} aria-labelledby="file-fields-title"> + <h2 id="file-fields-title">文件选择与拖放</h2> + <DropZone aria-label="附件拖放区域" getDropOperation={(types) => !disabled && types.has('Files') ? 'copy' : 'cancel'} + onDrop={async (event) => { + try { + const next = await Promise.all(event.items.filter((item) => item.kind === 'file').map((item) => item.getFile())) + setFiles(next) + setFileError('') + } catch { + setFileError('无法读取文件,请重新选择。') + } + }}> + <div className={styles.dropContent}> + <p>将文件拖放到这里,或从设备中选择。</p> + <FileTrigger allowsMultiple onSelect={(selected) => { if (selected) { setFiles(Array.from(selected)); setFileError('') } }}> + <Button type="button" variant="secondary">选择文件</Button> + </FileTrigger> + <p>仅在本页预览文件信息,不上传。</p> + </div> + </DropZone> + {fileError && <p role="alert">{fileError}</p>} + <div aria-live="polite"> + {files.length ? <ul>{files.map((file, index) => <li key={`${file.name}-${index}`}>{file.name} · {Math.ceil(file.size / 1024)} KB</li>)}</ul> : <p>尚未选择文件。</p>} + </div> + {files.length > 0 && <Button type="button" variant="secondary" onPress={() => setFiles([])}>清空文件</Button>} + <input type="hidden" name="files" value={JSON.stringify(files.map(({ name, size, type }) => ({ name, size, type })))} /> + </section> + </> + ) +} diff --git a/src/components/form-examples/date-number-fields.tsx b/src/components/form-examples/date-number-fields.tsx new file mode 100644 index 0000000..f2c489f --- /dev/null +++ b/src/components/form-examples/date-number-fields.tsx @@ -0,0 +1,45 @@ +import FieldGrid from './field-grid' +import { useState } from 'react' +import { NumberField } from '@react-spectrum/s2/NumberField' +import { Slider } from '@react-spectrum/s2/Slider' +import { RangeSlider } from '@react-spectrum/s2/RangeSlider' +import { DateField } from '@react-spectrum/s2/DateField' +import { TimeField } from '@react-spectrum/s2/TimeField' +import { DatePicker } from '@react-spectrum/s2/DatePicker' +import { DateRangePicker } from '@react-spectrum/s2/DateRangePicker' +import { Calendar } from '@react-spectrum/s2/Calendar' +import { RangeCalendar, type DateRange } from '@react-spectrum/s2/RangeCalendar' +import styles from './form-examples.module.css' + +export default function DateNumberFields({ disabled }: { disabled: boolean }) { + const [day, setDay] = useState('') + const [range, setRange] = useState<DateRange | null>(null) + return ( + <> + <section className={styles.section} aria-labelledby="number-fields-title"> + <h2 id="number-fields-title">数字与滑块</h2> + <FieldGrid> + <NumberField name="quantity" label="数量" defaultValue={2} minValue={1} maxValue={20} step={1} description="范围 1–20,支持步进按钮。" /> + <NumberField name="budget" label="预算" defaultValue={1000} minValue={0} step={100} formatOptions={{ style: 'currency', currency: 'CNY' }} /> + <NumberField name="ratio" label="百分比" defaultValue={0.5} minValue={0} maxValue={1} step={0.05} formatOptions={{ style: 'percent' }} /> + <Slider name="volume" label="音量" defaultValue={40} minValue={0} maxValue={100} /> + <RangeSlider startName="priceMin" endName="priceMax" label="价格区间" defaultValue={{ start: 20, end: 80 }} minValue={0} maxValue={100} /> + </FieldGrid> + </section> + <section className={styles.section} aria-labelledby="date-fields-title"> + <h2 id="date-fields-title">日期与时间</h2> + <FieldGrid> + <DateField name="date" label="分段日期输入" /> + <TimeField name="time" label="时间输入" hourCycle={24} granularity="minute" /> + <DatePicker name="appointment" label="日期选择器" /> + <DatePicker name="appointmentTime" label="日期和时间" granularity="minute" hourCycle={24} /> + <DateRangePicker startName="tripStart" endName="tripEnd" label="日期范围" /> + </FieldGrid> + <div className={styles.calendars}> + <div className={styles.stack}><span id="calendar-label">独立日历</span><Calendar isDisabled={disabled} aria-labelledby="calendar-label" onChange={(value) => setDay(value?.toString() ?? '')} /><input type="hidden" name="calendar" value={day} /></div> + <div className={styles.stack}><span id="range-calendar-label">独立范围日历</span><RangeCalendar isDisabled={disabled} aria-labelledby="range-calendar-label" value={range} onChange={setRange} /><input type="hidden" name="calendarStart" value={range?.start.toString() ?? ''} /><input type="hidden" name="calendarEnd" value={range?.end.toString() ?? ''} /></div> + </div> + </section> + </> + ) +} diff --git a/src/components/form-examples/field-grid.tsx b/src/components/form-examples/field-grid.tsx new file mode 100644 index 0000000..7dbb83d --- /dev/null +++ b/src/components/form-examples/field-grid.tsx @@ -0,0 +1,10 @@ +import { Children, type ReactNode } from 'react' +import styles from './form-examples.module.css' + +export default function FieldGrid({ children }: { children: ReactNode }) { + return ( + <div className={styles.grid}> + {Children.map(children, (child) => <div className={styles.field}>{child}</div>)} + </div> + ) +} diff --git a/src/components/form-examples/form-examples.module.css b/src/components/form-examples/form-examples.module.css new file mode 100644 index 0000000..d63db91 --- /dev/null +++ b/src/components/form-examples/form-examples.module.css @@ -0,0 +1,136 @@ +.controls { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 20px; + padding: 20px; + border: 1px solid var(--border); + border-radius: 12px; + margin-bottom: 24px; +} + +.form.form { + display: block; +} + +.fields { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.section, +.result { + display: grid; + gap: 24px; + min-width: 0; + padding: 24px; + margin-bottom: 24px; + border: 1px solid var(--border); + border-radius: 12px; +} + +.section h2, +.result h2 { + font-size: 20px; + margin: 0; +} + +.section p, +.result p { + font-size: 14px; + line-height: 1.6; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + align-items: start; + gap: 28px; + min-width: 0; +} + +.field { + display: grid; + grid-template-columns: [field] minmax(0, 1fr); + align-content: start; + min-width: 0; +} + +.grid .field > * { + grid-column: 1 / -1; + min-width: 0; +} + +.sideLabels .grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 480px), 1fr)); +} + +.sideLabels .field { + grid-template-columns: [label] minmax(0, 10rem) [field] minmax(0, 1fr); + column-gap: 16px; +} + +.layoutHint { + margin-bottom: 20px; + font-size: 14px; +} + +.fields:disabled { + opacity: 0.6; +} + +.stack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + min-width: 0; + font-size: 14px; +} + +.calendars { + display: flex; + flex-wrap: wrap; + gap: 32px; + min-width: 0; + overflow-x: auto; +} + +.dropContent { + padding: 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + text-align: center; +} + +.output, +.result pre { + margin: 0; + padding: 16px; + border-radius: 8px; + background: var(--social-bg); + font-family: monospace; + font-size: 13px; + overflow: auto; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +@media (max-width: 640px) { + .section, + .result { + padding: 16px; + } +} diff --git a/src/components/form-examples/form-examples.tsx b/src/components/form-examples/form-examples.tsx new file mode 100644 index 0000000..e1dd6c8 --- /dev/null +++ b/src/components/form-examples/form-examples.tsx @@ -0,0 +1,91 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { Form } from '@react-spectrum/s2/Form' +import { Button, ButtonGroup } from '@react-spectrum/s2/ButtonGroup' +import { Picker, PickerItem } from '@react-spectrum/s2/Picker' +import { Switch } from '@react-spectrum/s2/Switch' +import { Checkbox } from '@react-spectrum/s2/Checkbox' +import TextFields from './text-fields' +import SelectionFields from './selection-fields' +import DateNumberFields from './date-number-fields' +import ColorFileFields from './color-file-fields' +import styles from './form-examples.module.css' + +function DemoForm({ onReset }: { onReset: () => void }) { + const containerRef = useRef<HTMLDivElement>(null) + const [isCompact, setIsCompact] = useState(false) + const [size, setSize] = useState<'S' | 'M' | 'L' | 'XL'>('M') + const [sideLabels, setSideLabels] = useState(false) + const [disabled, setDisabled] = useState(false) + const [errors, setErrors] = useState<Record<string, string>>({}) + const [result, setResult] = useState<Record<string, string | string[]> | null>(null) + + useLayoutEffect(() => { + const container = containerRef.current + if (!container) return + const update = () => setIsCompact(container.clientWidth < 560) + update() + const observer = new ResizeObserver(update) + observer.observe(container) + return () => observer.disconnect() + }, []) + + const useSideLabels = sideLabels && !isCompact + + return ( + <div ref={containerRef}> + <div className={styles.controls}> + <Picker label="控件尺寸" selectedKey={size} onSelectionChange={(key) => { if (key === 'S' || key === 'M' || key === 'L' || key === 'XL') setSize(key) }}> + <PickerItem id="S">S</PickerItem><PickerItem id="M">M</PickerItem><PickerItem id="L">L</PickerItem><PickerItem id="XL">XL</PickerItem> + </Picker> + <Switch isSelected={sideLabels} onChange={setSideLabels}>标签放在侧边</Switch> + <Switch isSelected={disabled} onChange={setDisabled}>禁用表单</Switch> + </div> + {sideLabels && isCompact && <p className={styles.layoutHint}>当前宽度较窄,已自动使用顶部标签。</p>} + <Form UNSAFE_className={`${styles.form}${useSideLabels ? ` ${styles.sideLabels}` : ''}`} size={size} isDisabled={disabled} labelPosition={useSideLabels ? 'side' : 'top'} necessityIndicator="label" + validationBehavior="native" validationErrors={errors} + onReset={(event) => { event.preventDefault(); onReset() }} + onSubmit={(event) => { + event.preventDefault() + const data = new FormData(event.currentTarget) + if (data.get('username') === 'admin') { + setErrors({ username: '该用户名已被占用,请换一个。(本地模拟业务校验)' }) + setResult(null) + return + } + setErrors({}) + const entries = [...new Set(data.keys())].map((key) => { + const values = data.getAll(key).map((value) => typeof value === 'string' ? value : value.name) + return [key, key === 'password' && values[0] ? '••••••••' : values.length === 1 ? values[0] : values] + }) + setResult(Object.fromEntries(entries)) + }}> + <fieldset className={styles.fields} disabled={disabled} inert={disabled}> + <legend className={styles.srOnly}>Spectrum 表单示例</legend> + <TextFields /> + <SelectionFields disabled={disabled} /> + <DateNumberFields disabled={disabled} /> + <ColorFileFields disabled={disabled} /> + <section className={styles.section} aria-labelledby="submit-title"> + <h2 id="submit-title">校验与提交</h2> + <p>用户名和邮箱为必填项。可以直接提交,体验错误提示和焦点定位。</p> + <Checkbox name="agreement" value="accepted" isRequired>我已了解这是本地表单示例</Checkbox> + <ButtonGroup> + <Button type="submit" variant="primary">提交并预览</Button> + <Button type="reset" variant="secondary">重置全部</Button> + </ButtonGroup> + </section> + </fieldset> + </Form> + <section className={styles.result} aria-labelledby="result-title"> + <h2 id="result-title">提交结果</h2> + <p role="status">{result ? '校验通过,以下为本次提交的数据。' : '填写表单并提交后,在这里查看结果。'}</p> + {result && <pre tabIndex={0}>{JSON.stringify(result, null, 2)}</pre>} + </section> + </div> + ) +} + +export default function FormExamples() { + const [version, setVersion] = useState(0) + return <DemoForm key={version} onReset={() => setVersion((value) => value + 1)} /> +} diff --git a/src/components/form-examples/selection-fields.tsx b/src/components/form-examples/selection-fields.tsx new file mode 100644 index 0000000..84e1ec1 --- /dev/null +++ b/src/components/form-examples/selection-fields.tsx @@ -0,0 +1,68 @@ +import FieldGrid from './field-grid' +import { useState } from 'react' +import { Checkbox, CheckboxGroup } from '@react-spectrum/s2/CheckboxGroup' +import { Radio, RadioGroup } from '@react-spectrum/s2/RadioGroup' +import { Switch } from '@react-spectrum/s2/Switch' +import { Picker, PickerItem } from '@react-spectrum/s2/Picker' +import { ComboBox, ComboBoxItem } from '@react-spectrum/s2/ComboBox' +import { SelectBox, SelectBoxGroup, Text, type Selection } from '@react-spectrum/s2/SelectBoxGroup' +import { SegmentedControl, SegmentedControlItem, type Key } from '@react-spectrum/s2/SegmentedControl' +import { ToggleButton, ToggleButtonGroup } from '@react-spectrum/s2/ToggleButtonGroup' +import styles from './form-examples.module.css' + +export default function SelectionFields({ disabled }: { disabled: boolean }) { + const [plan, setPlan] = useState<Selection>(new Set(['personal'])) + const [view, setView] = useState<Key>('list') + const [format, setFormat] = useState<Selection>(new Set()) + const [favorite, setFavorite] = useState(false) + const [partial, setPartial] = useState(true) + const [partialSelected, setPartialSelected] = useState(false) + return ( + <section className={styles.section} aria-labelledby="selection-fields-title"> + <h2 id="selection-fields-title">选择控件</h2> + <FieldGrid> + <Picker name="region" label="下拉单选" defaultSelectedKey="shanghai"> + <PickerItem id="shanghai">上海</PickerItem><PickerItem id="beijing">北京</PickerItem><PickerItem id="shenzhen">深圳</PickerItem> + </Picker> + <ComboBox name="language" label="可搜索下拉框" defaultItems={[{ id: 'ts', name: 'TypeScript' }, { id: 'js', name: 'JavaScript' }, { id: 'py', name: 'Python' }]}> + {(item) => <ComboBoxItem id={item.id}>{item.name}</ComboBoxItem>} + </ComboBox> + <RadioGroup name="contact" label="单选组" defaultValue="email" orientation="horizontal"> + <Radio value="email">邮件</Radio><Radio value="phone">电话</Radio> + </RadioGroup> + <CheckboxGroup name="interests" label="复选框组" defaultValue={['design']}> + <Checkbox value="design">设计</Checkbox><Checkbox value="development">开发</Checkbox><Checkbox value="research">研究</Checkbox> + </CheckboxGroup> + <div className={styles.stack}> + <Checkbox name="newsletter" value="yes">订阅邮件</Checkbox> + <Checkbox name="partialSelection" value="yes" isIndeterminate={partial} isSelected={partialSelected} onChange={(value) => { setPartial(false); setPartialSelected(value) }}>部分选中状态</Checkbox> + <Switch name="notifications" value="on" defaultSelected>开启通知</Switch> + </div> + <div className={styles.stack}> + <span id="view-label">分段选择</span> + <SegmentedControl aria-labelledby="view-label" selectedKey={view} onSelectionChange={setView}> + <SegmentedControlItem isDisabled={disabled} id="list">列表</SegmentedControlItem><SegmentedControlItem isDisabled={disabled} id="grid">网格</SegmentedControlItem> + </SegmentedControl> + <input type="hidden" name="view" value={String(view)} /> + </div> + <div className={styles.stack}> + <span id="format-label">切换按钮组</span> + <ToggleButtonGroup aria-labelledby="format-label" selectionMode="multiple" selectedKeys={format} onSelectionChange={setFormat}> + <ToggleButton id="bold">粗体</ToggleButton><ToggleButton id="italic">斜体</ToggleButton> + </ToggleButtonGroup> + <input type="hidden" name="format" value={JSON.stringify(format === 'all' ? ['bold', 'italic'] : [...format])} /> + <ToggleButton isSelected={favorite} onChange={setFavorite}>收藏</ToggleButton> + <input type="hidden" name="favorite" value={String(favorite)} /> + </div> + </FieldGrid> + <div className={styles.stack}> + <span id="plan-label">卡片选择</span> + <SelectBoxGroup isDisabled={disabled} aria-labelledby="plan-label" selectedKeys={plan} onSelectionChange={setPlan}> + <SelectBox id="personal" textValue="个人版"><Text slot="label">个人版</Text><Text slot="description">个人工作空间</Text></SelectBox> + <SelectBox id="team" textValue="团队版"><Text slot="label">团队版</Text><Text slot="description">多人协作空间</Text></SelectBox> + </SelectBoxGroup> + <input type="hidden" name="plan" value={plan === 'all' ? 'all' : [...plan].join(',')} /> + </div> + </section> + ) +} diff --git a/src/components/form-examples/text-fields.tsx b/src/components/form-examples/text-fields.tsx new file mode 100644 index 0000000..59cdd28 --- /dev/null +++ b/src/components/form-examples/text-fields.tsx @@ -0,0 +1,28 @@ +import FieldGrid from './field-grid' +import { TextField } from '@react-spectrum/s2/TextField' +import { TextArea } from '@react-spectrum/s2/TextArea' +import { SearchField } from '@react-spectrum/s2/SearchField' +import { ContextualHelp, Heading, Content } from '@react-spectrum/s2/ContextualHelp' +import styles from './form-examples.module.css' + +export default function TextFields() { + return ( + <section className={styles.section} aria-labelledby="text-fields-title"> + <h2 id="text-fields-title">文本输入</h2> + <FieldGrid> + <TextField name="username" label="用户名" isRequired minLength={3} maxLength={20} + description="3–20 个字符;输入 admin 可体验提交后的业务校验。" + contextualHelp={<ContextualHelp><Heading>用户名</Heading><Content>可使用字母、数字和下划线。</Content></ContextualHelp>} + validate={(value) => value && !/^[a-zA-Z0-9_]+$/.test(value) ? '只能包含字母、数字和下划线。' : null} /> + <TextField name="email" label="邮箱" type="email" isRequired placeholder="name@example.com" /> + <TextField name="password" label="密码" type="password" minLength={8} description="选填,至少 8 位;提交预览中隐藏密码。" /> + <TextField name="website" label="网址" type="url" placeholder="https://example.com" /> + <TextField name="phone" label="电话" type="tel" placeholder="输入联系电话" /> + <SearchField name="keyword" label="搜索关键词" description="支持输入与一键清空。" /> + <TextArea name="bio" label="多行简介" maxLength={200} description="最多 200 个字符。" /> + <TextField name="reference" label="只读字段" value="FORM-DEMO-001" isReadOnly /> + <TextField label="禁用字段" defaultValue="不可编辑,也不参与提交" isDisabled /> + </FieldGrid> + </section> + ) +} diff --git a/src/components/global-settings.module.css b/src/components/global-settings.module.css new file mode 100644 index 0000000..49b8f27 --- /dev/null +++ b/src/components/global-settings.module.css @@ -0,0 +1,15 @@ +.settings { + box-sizing: border-box; + width: min(260px, calc(100vw - 48px)); + padding: 16px; + display: grid; + gap: 20px; + text-align: start; +} + +.settings .title { + margin: 0; + font-size: 18px; + line-height: 1.4; + color: var(--text-h); +} diff --git a/src/components/global-settings.tsx b/src/components/global-settings.tsx new file mode 100644 index 0000000..4e9c507 --- /dev/null +++ b/src/components/global-settings.tsx @@ -0,0 +1,41 @@ +import { useId } from 'react' +import { ActionButton } from '@react-spectrum/s2/ActionButton' +import { DialogTrigger, Popover } from '@react-spectrum/s2/Popover' +import { Radio, RadioGroup } from '@react-spectrum/s2/RadioGroup' +import { Tooltip, TooltipTrigger } from '@react-spectrum/s2/Tooltip' +import SettingsIcon from '@react-spectrum/s2/icons/Settings' +import { useTheme } from '../theme' +import styles from './global-settings.module.css' + +function GlobalSettings() { + const titleId = useId() + const { colorScheme, setColorScheme } = useTheme() + + return ( + <DialogTrigger> + <TooltipTrigger> + <ActionButton aria-label="全局设置" isQuiet> + <SettingsIcon /> + </ActionButton> + <Tooltip>全局设置</Tooltip> + </TooltipTrigger> + <Popover placement="bottom end" aria-labelledby={titleId}> + <div className={styles.settings}> + <h2 id={titleId} className={styles.title}>全局设置</h2> + <RadioGroup + label="外观模式" + value={colorScheme} + onChange={(value) => { + if (value === 'light' || value === 'dark') setColorScheme(value) + }} + > + <Radio value="light">亮色</Radio> + <Radio value="dark">暗色</Radio> + </RadioGroup> + </div> + </Popover> + </DialogTrigger> + ) +} + +export default GlobalSettings diff --git a/src/components/layout-basic.module.css b/src/components/layout-basic.module.css new file mode 100644 index 0000000..cc29f98 --- /dev/null +++ b/src/components/layout-basic.module.css @@ -0,0 +1,156 @@ +.appLayout { + --navigation-inset: 12px; + /* SideNav's leading gutter (12) plus half the shared icon size (20). */ + --navigation-icon-center: calc(var(--navigation-inset) + 1.375rem * var(--s2-scale, 1)); + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + height: 100dvh; + overflow: hidden; + color: var(--text); + background: var(--bg); +} + +.sidebar { + display: flex; + flex-direction: column; + min-height: 0; + border-inline-end: 1px solid var(--border); + background: var(--social-bg); +} + +.sidebarBrand, +.topbar { + display: flex; + align-items: center; + box-sizing: border-box; + height: 64px; + flex-shrink: 0; + padding: 0 24px; + border-bottom: 1px solid var(--border); + color: var(--text-h); + font-size: 16px; + font-weight: 600; +} + +.sidebarNavigation { + flex: 1; + min-height: 0; + padding: 16px var(--navigation-inset); +} + +.expandedNavigation { + height: 100%; +} + +.topbar { + gap: 12px; +} + +.topbarActions { + margin-inline-start: auto; + display: flex; + align-items: center; + flex-shrink: 0; +} + +.appLayout.isCollapsed { + /* Include the sidebar border so both modes share the same icon center. */ + grid-template-columns: calc(2 * var(--navigation-icon-center) + 1px) minmax(0, 1fr); +} + +.isCollapsed .sidebarBrand { + justify-content: center; + padding-inline: 0; +} + +.isCollapsed .sidebarNavigation { + overflow-y: auto; +} + +.iconNavigation { + display: flex; + flex-direction: column; + align-items: center; + /* Match SideNav's row spacing (space(6)). */ + gap: 0.375rem; +} + +.iconNavigationLink, +.iconNavigationGroup { + display: flex; + justify-content: center; + align-items: center; + width: 40px; + /* Match SideNav's 32-unit row height, including Spectrum's touch scale. */ + height: calc(2rem * var(--s2-scale, 1)); + flex-shrink: 0; + border-radius: 8px; + color: var(--text); +} + +.iconNavigationLink:hover, +.iconNavigationLink[aria-current="page"], +.iconNavigationGroup.isActive { + background: var(--border); + color: var(--text-h); +} + +.iconNavigationLink:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.contentLayout { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-width: 0; + min-height: 0; +} + +.skipLink { + position: fixed; + top: 8px; + left: 8px; + z-index: 10; + padding: 8px 16px; + background: var(--bg); + color: var(--accent); + transform: translateY(calc(-100% - 16px)); + + &:focus { + transform: translateY(0); + } +} + +@media (max-width: 640px) { + .appLayout, + .appLayout.isCollapsed { + --navigation-inset: 8px; + grid-template-columns: minmax(0, 1fr); + } + + .sidebar, + .desktopMenuToggle { + display: none; + } + + .sidebarBrand, + .topbar { + padding-inline: 12px; + } +} + +.pageContent { + /* Keep absolutely positioned accessibility helpers inside this scroll area. */ + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: auto; + text-align: center; +} + +.pageContent > * { + flex-shrink: 0; +} diff --git a/src/components/layout-basic.tsx b/src/components/layout-basic.tsx new file mode 100644 index 0000000..05f3eef --- /dev/null +++ b/src/components/layout-basic.tsx @@ -0,0 +1,67 @@ +import { useState, type ReactNode } from 'react' +import { useMatches } from 'react-router' +import type { PageMetadata } from '../route-config' +import CollapsedNavigation from './collapsed-navigation' +import { ActionButton } from '@react-spectrum/s2/ActionButton' +import { Tooltip, TooltipTrigger } from '@react-spectrum/s2/Tooltip' +import SidebarMenu from './sidebar-menu' +import MobileNavigation from './mobile-navigation' +import GlobalSettings from './global-settings' +import HomeIcon from '@react-spectrum/s2/icons/Home' +import ChevronLeftIcon from '@react-spectrum/s2/icons/ChevronLeft' +import ChevronRightIcon from '@react-spectrum/s2/icons/ChevronRight' +import styles from './layout-basic.module.css' + +interface BasicLayoutProps { + children: ReactNode +} + +function BasicLayout({ children }: BasicLayoutProps) { + const [isCollapsed, setIsCollapsed] = useState(false) + const currentPage = useMatches().at(-1)?.handle as PageMetadata | undefined + + return ( + <div className={`${styles.appLayout}${isCollapsed ? ` ${styles.isCollapsed}` : ''}`}> + <a className={styles.skipLink} href="#main-content">Skip to content</a> + <aside id="sidebar" className={styles.sidebar} aria-label="Sidebar"> + <div className={styles.sidebarBrand} aria-label="React Spectrum"> + {isCollapsed ? <HomeIcon /> : 'React Spectrum'} + </div> + <nav className={styles.sidebarNavigation} aria-label="Main navigation"> + {isCollapsed && <CollapsedNavigation activePageId={currentPage?.id} />} + <div className={styles.expandedNavigation} hidden={isCollapsed}> + <SidebarMenu /> + </div> + </nav> + </aside> + <div className={styles.contentLayout}> + <header className={styles.topbar}> + <MobileNavigation /> + <div className={styles.desktopMenuToggle}> + <TooltipTrigger> + <ActionButton + aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'} + aria-expanded={!isCollapsed} + aria-controls="sidebar" + isQuiet + onPress={() => setIsCollapsed((collapsed) => !collapsed)} + > + {isCollapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />} + </ActionButton> + <Tooltip>{isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}</Tooltip> + </TooltipTrigger> + </div> + <span>{currentPage?.title}</span> + <div className={styles.topbarActions}> + <GlobalSettings /> + </div> + </header> + <main id="main-content" className={styles.pageContent} tabIndex={-1}> + {children} + </main> + </div> + </div> + ) +} + +export default BasicLayout diff --git a/src/components/menu-icon.module.css b/src/components/menu-icon.module.css new file mode 100644 index 0000000..39bfa21 --- /dev/null +++ b/src/components/menu-icon.module.css @@ -0,0 +1,6 @@ +/* Match Spectrum override specificity without depending on an ancestor. */ +.icon.icon { + width: calc(1.25rem * var(--s2-scale, 1)); + height: calc(1.25rem * var(--s2-scale, 1)); + flex-shrink: 0; +} diff --git a/src/components/mobile-navigation.module.css b/src/components/mobile-navigation.module.css new file mode 100644 index 0000000..4fe082d --- /dev/null +++ b/src/components/mobile-navigation.module.css @@ -0,0 +1,53 @@ +.mobileMenuToggle { + display: none; +} + +.mobileNavigation { + position: fixed; + inset: 0; + width: 100%; + height: 100dvh; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + box-sizing: border-box; + background: var(--bg); + color: var(--text); + overflow: hidden; +} + +.mobileNavigation[open] { + display: flex; + flex-direction: column; +} + +.mobileNavigation::backdrop { + background: var(--bg); +} + +.mobileNavigationHeader { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + min-height: 64px; + padding: env(safe-area-inset-top) 16px 0; + border-bottom: 1px solid var(--border); + font-size: 16px; + font-weight: 600; + color: var(--text-h); +} + +.mobileNavigationContent { + flex: 1; + min-height: 0; + padding: 16px max(12px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); +} + +@media (max-width: 640px) { + .mobileMenuToggle { + display: block; + } +} diff --git a/src/components/mobile-navigation.tsx b/src/components/mobile-navigation.tsx new file mode 100644 index 0000000..abf4f24 --- /dev/null +++ b/src/components/mobile-navigation.tsx @@ -0,0 +1,64 @@ +import styles from './mobile-navigation.module.css' +import { useEffect, useRef, useState } from 'react' +import { useLocation } from 'react-router' +import { ActionButton } from '@react-spectrum/s2/ActionButton' +import CloseIcon from '@react-spectrum/s2/icons/Close' +import ViewListIcon from '@react-spectrum/s2/icons/ViewList' +import SidebarMenu from './sidebar-menu' + +function MobileNavigation() { + const dialogRef = useRef<HTMLDialogElement>(null) + const [isOpen, setIsOpen] = useState(false) + const { key } = useLocation() + + // Close after all route changes, including selecting the current page. + useEffect(() => { + dialogRef.current?.close() + }, [key]) + + useEffect(() => { + const media = window.matchMedia('(max-width: 640px)') + const closeOnDesktop = () => { + if (!media.matches) dialogRef.current?.close() + } + media.addEventListener('change', closeOnDesktop) + return () => media.removeEventListener('change', closeOnDesktop) + }, []) + + return ( + <div className={styles.mobileMenuToggle}> + <ActionButton + aria-label="打开菜单" + aria-haspopup="dialog" + aria-expanded={isOpen} + aria-controls="mobile-navigation" + isQuiet + onPress={() => { + dialogRef.current?.showModal() + setIsOpen(true) + }} + > + <ViewListIcon /> + </ActionButton> + <dialog + ref={dialogRef} + id="mobile-navigation" + className={styles.mobileNavigation} + aria-labelledby="mobile-navigation-title" + onClose={() => setIsOpen(false)} + > + <header className={styles.mobileNavigationHeader}> + <span id="mobile-navigation-title">菜单</span> + <ActionButton aria-label="关闭菜单" isQuiet onPress={() => dialogRef.current?.close()}> + <CloseIcon /> + </ActionButton> + </header> + <nav className={styles.mobileNavigationContent} aria-label="页面导航"> + <SidebarMenu /> + </nav> + </dialog> + </div> + ) +} + +export default MobileNavigation diff --git a/src/components/sidebar-menu.tsx b/src/components/sidebar-menu.tsx new file mode 100644 index 0000000..604c002 --- /dev/null +++ b/src/components/sidebar-menu.tsx @@ -0,0 +1,35 @@ +import iconStyles from './menu-icon.module.css' +import { useMatches } from 'react-router' +import { SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, Text } from '@react-spectrum/s2/SideNav' +import { defaultExpandedKeys, menuItems, type NavigationItem, type PageMetadata } from '../route-config' + +function renderItem(item: NavigationItem, level = 0) { + const Icon = item.Icon + const content = ( + <> + {Icon && <Icon UNSAFE_className={level === 0 ? iconStyles.icon : undefined} />} + <Text>{item.title}</Text> + </> + ) + + return ( + <SideNavItem key={item.id} id={item.id} href={item.path} textValue={item.title}> + <SideNavItemContent> + {item.path ? <SideNavItemLink>{content}</SideNavItemLink> : content} + </SideNavItemContent> + {item.children?.map((child) => renderItem(child, level + 1))} + </SideNavItem> + ) +} + +function SidebarMenu() { + const currentPage = useMatches().at(-1)?.handle as PageMetadata | undefined + + return ( + <SideNav aria-label="Pages" selectedRoute={currentPage?.path ?? null} defaultExpandedKeys={defaultExpandedKeys}> + {menuItems.map((item) => renderItem(item))} + </SideNav> + ) +} + +export default SidebarMenu diff --git a/src/components/ui-kit/actions-feedback.tsx b/src/components/ui-kit/actions-feedback.tsx new file mode 100644 index 0000000..ade8d48 --- /dev/null +++ b/src/components/ui-kit/actions-feedback.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react' +import { Button, Text } from '@react-spectrum/s2/Button' +import { ActionButton } from '@react-spectrum/s2/ActionButton' +import { ToggleButton } from '@react-spectrum/s2/ToggleButton' +import { ActionMenu, MenuItem } from '@react-spectrum/s2/ActionMenu' +import { Badge } from '@react-spectrum/s2/Badge' +import { StatusLight } from '@react-spectrum/s2/StatusLight' +import { ProgressBar } from '@react-spectrum/s2/ProgressBar' +import { ProgressCircle } from '@react-spectrum/s2/ProgressCircle' +import { Meter } from '@react-spectrum/s2/Meter' +import { InlineAlert, Heading, Content } from '@react-spectrum/s2/InlineAlert' +import { DialogTrigger, Dialog, ButtonGroup } from '@react-spectrum/s2/Dialog' +import { Popover } from '@react-spectrum/s2/Popover' +import { Tooltip, TooltipTrigger } from '@react-spectrum/s2/Tooltip' +import { ToastContainer, ToastQueue } from '@react-spectrum/s2/Toast' +import AddIcon from '@react-spectrum/s2/icons/Add' +import EditIcon from '@react-spectrum/s2/icons/Edit' +import InfoIcon from '@react-spectrum/s2/icons/InfoCircle' +import styles from './ui-kit.module.css' + +export default function ActionsFeedback() { + const [message, setMessage] = useState('点击按钮体验交互。') + const [selected, setSelected] = useState(false) + const [progress, setProgress] = useState(40) + const toastClosers = useRef<Array<() => void>>([]) + useEffect(() => () => { toastClosers.current.forEach((close) => close()) }, []) + + return ( + <> + <section className={styles.section} aria-labelledby="kit-buttons"> + <h2 id="kit-buttons">按钮与操作</h2> + <div className={styles.row}> + <Button variant="primary" onPress={() => setMessage('已点击主要按钮。')}>主要按钮</Button> + <Button variant="secondary" onPress={() => setMessage('已点击次要按钮。')}>次要按钮</Button> + <Button variant="accent" onPress={() => setMessage('已点击强调按钮。')}><AddIcon /><Text>新建</Text></Button> + <Button variant="negative" onPress={() => setMessage('这是危险操作的样式示例。')}>危险操作</Button> + <Button isDisabled>禁用按钮</Button> + </div> + <div className={styles.row}> + {(['S', 'M', 'L', 'XL'] as const).map((size) => <Button key={size} size={size} variant="secondary" onPress={() => setMessage(`已点击 ${size} 尺寸按钮。`)}>{size}</Button>)} + <TooltipTrigger><ActionButton aria-label="编辑示例" onPress={() => setMessage('已点击图标按钮。')}><EditIcon /></ActionButton><Tooltip>编辑示例</Tooltip></TooltipTrigger> + <ToggleButton isSelected={selected} onChange={setSelected}>{selected ? '已收藏' : '收藏'}</ToggleButton> + <ActionMenu aria-label="更多示例操作" onAction={(key) => setMessage(key === 'copy' ? '已选择复制示例。' : '已选择重命名示例。')}> + <MenuItem id="copy">复制</MenuItem><MenuItem id="rename">重命名</MenuItem> + </ActionMenu> + </div> + <p role="status" className={styles.caption}>{message}</p> + </section> + + <section className={styles.section} aria-labelledby="kit-status"> + <h2 id="kit-status">徽标与状态</h2> + <div className={styles.row}> + <Badge variant="neutral">默认</Badge><Badge variant="informative">信息</Badge><Badge variant="positive">成功</Badge><Badge variant="notice">提醒</Badge><Badge variant="negative">错误</Badge><Badge variant="accent">强调</Badge> + </div> + <div className={styles.row}> + <StatusLight variant="positive">已完成</StatusLight><StatusLight variant="notice">待处理</StatusLight><StatusLight variant="negative">失败</StatusLight><StatusLight variant="neutral">草稿</StatusLight> + </div> + <div className={styles.grid}> + <InlineAlert variant="informative"><Heading>信息提示</Heading><Content>用于展示与当前操作相关的说明。</Content></InlineAlert> + <InlineAlert variant="positive"><Heading>成功提示</Heading><Content>这是一条成功状态示例。</Content></InlineAlert> + <InlineAlert variant="notice"><Heading>注意事项</Heading><Content>操作前需要留意的提示信息。</Content></InlineAlert> + <InlineAlert variant="negative"><Heading>错误提示</Heading><Content>展示问题原因及后续处理方式。</Content></InlineAlert> + </div> + </section> + + <section className={styles.section} aria-labelledby="kit-progress"> + <h2 id="kit-progress">进度与容量</h2> + <div className={styles.grid}> + <ProgressBar label="示例进度" value={progress} /> + <Meter label="已用容量" value={65} variant="notice" /> + <ProgressBar label="不确定进度" isIndeterminate /> + <div className={styles.row}><ProgressCircle aria-label="正在加载" isIndeterminate /><span>加载中</span></div> + </div> + <div className={styles.row}> + <Button variant="secondary" isDisabled={progress === 100} onPress={() => setProgress((value) => Math.min(value + 20, 100))}>增加进度</Button> + <Button variant="secondary" onPress={() => setProgress(0)}>重置进度</Button> + </div> + </section> + + <section className={styles.section} aria-labelledby="kit-overlays"> + <h2 id="kit-overlays">弹层与通知</h2> + <div className={styles.row}> + <DialogTrigger> + <Button variant="secondary">打开对话框</Button> + <Dialog size="S"> + {({ close }) => <><Heading>对话框示例</Heading><Content>对话框用于需要用户确认的操作。</Content><ButtonGroup><Button variant="secondary" onPress={close}>取消</Button><Button variant="primary" onPress={() => { setMessage('已确认对话框。'); close() }}>确认</Button></ButtonGroup></>} + </Dialog> + </DialogTrigger> + <DialogTrigger> + <Button variant="secondary">打开 Popover</Button> + <Popover aria-label="Popover 示例"><div className={styles.popover}><InfoIcon /><p>Popover 用于在操作附近展示补充内容,点击外部或按 Esc 关闭。</p></div></Popover> + </DialogTrigger> + <TooltipTrigger><Button variant="secondary">悬停查看提示</Button><Tooltip>这是一条 Tooltip 提示。</Tooltip></TooltipTrigger> + <Button variant="secondary" onPress={() => toastClosers.current.push(ToastQueue.positive('这是一条成功通知。', { timeout: 5000 }))}>成功通知</Button> + <Button variant="secondary" onPress={() => toastClosers.current.push(ToastQueue.negative('这是一条错误通知示例。', { timeout: 5000 }))}>错误通知</Button> + </div> + </section> + <ToastContainer /> + </> + ) +} diff --git a/src/components/ui-kit/navigation-data.tsx b/src/components/ui-kit/navigation-data.tsx new file mode 100644 index 0000000..5ac2605 --- /dev/null +++ b/src/components/ui-kit/navigation-data.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' +import { Tabs, TabList, Tab, TabPanel } from '@react-spectrum/s2/Tabs' +import { Accordion, AccordionItem, AccordionItemTitle, AccordionItemPanel } from '@react-spectrum/s2/Accordion' +import { Breadcrumbs, Breadcrumb } from '@react-spectrum/s2/Breadcrumbs' +import { TagGroup, Tag } from '@react-spectrum/s2/TagGroup' +import { Button } from '@react-spectrum/s2/Button' +import { Card, CardPreview, Content, Text, Footer } from '@react-spectrum/s2/Card' +import { TableView, TableHeader, Column, TableBody, Row, Cell, type Selection, type SortDescriptor } from '@react-spectrum/s2/TableView' +import { StatusLight } from '@react-spectrum/s2/StatusLight' +import EditIcon from '@react-spectrum/s2/icons/Edit' +import styles from './ui-kit.module.css' + +const initialTags = ['设计', '开发', '文档'] +const rows = [ + { id: 'buttons', name: '按钮', category: '基础操作', status: '已完成' }, + { id: 'navigation', name: '导航', category: '页面组织', status: '已完成' }, + { id: 'form', name: '表单', category: '数据输入', status: '示例' }, +] + +export default function NavigationData() { + const [tags, setTags] = useState(initialTags) + const [selection, setSelection] = useState<Selection>(new Set()) + const [sort, setSort] = useState<SortDescriptor>({ column: 'name', direction: 'ascending' }) + const sortedRows = [...rows].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN') * (sort.direction === 'descending' ? -1 : 1)) + + return ( + <> + <section className={styles.section} aria-labelledby="kit-navigation"> + <h2 id="kit-navigation">导航与内容组织</h2> + <Breadcrumbs><Breadcrumb href="/">Home</Breadcrumb><Breadcrumb>UI Kit</Breadcrumb></Breadcrumbs> + <Tabs aria-label="组件内容示例" defaultSelectedKey="overview"> + <TabList aria-label="组件内容示例"><Tab id="overview">概览</Tab><Tab id="details">详情</Tab><Tab id="disabled" isDisabled>禁用</Tab></TabList> + <TabPanel id="overview"><p className={styles.tabContent}>这是概览面板。点击“详情”切换内容。</p></TabPanel> + <TabPanel id="details"><p className={styles.tabContent}>这是详情面板。也可以使用键盘方向键切换标签。</p></TabPanel> + </Tabs> + <Accordion allowsMultipleExpanded defaultExpandedKeys={['usage']}> + <AccordionItem id="usage"><AccordionItemTitle>组件如何交互?</AccordionItemTitle><AccordionItemPanel>点击、聚焦或使用键盘体验组件的不同状态。</AccordionItemPanel></AccordionItem> + <AccordionItem id="theme"><AccordionItemTitle>如何查看暗色效果?</AccordionItemTitle><AccordionItemPanel>使用顶栏右上角的设置按钮切换亮色和暗色模式。</AccordionItemPanel></AccordionItem> + </Accordion> + </section> + + <section className={styles.section} aria-labelledby="kit-tags"> + <h2 id="kit-tags">标签与卡片</h2> + <TagGroup aria-label="可移除的示例标签" onRemove={(keys) => setTags((current) => current.filter((tag) => !keys.has(tag)))}> + {tags.map((tag) => <Tag key={tag} id={tag}>{tag}</Tag>)} + </TagGroup> + {tags.length === 0 && <p className={styles.caption}>标签已清空。</p>} + <div><Button variant="secondary" onPress={() => setTags(initialTags)}>恢复标签</Button></div> + <div className={styles.cards}> + <Card href="/form" aria-label="查看表单示例"> + <CardPreview><div className={styles.cardPreview}><EditIcon /></div></CardPreview> + <Content><Text slot="title">表单组件</Text><Text slot="description">文本输入、选择、日期、颜色与文件控件。</Text></Content> + <Footer><StatusLight variant="informative">点击查看表单页面</StatusLight></Footer> + </Card> + <Card variant="secondary" aria-label="组件展示卡片"> + <Content><Text slot="title">内容卡片</Text><Text slot="description">使用标题、描述和底部状态组织信息。</Text></Content> + <Footer><StatusLight variant="positive">静态展示</StatusLight></Footer> + </Card> + </div> + </section> + + <section className={styles.section} aria-labelledby="kit-table"> + <h2 id="kit-table">数据表格</h2> + <p className={styles.caption}>支持多选,点击“名称”可切换排序。</p> + <div className={styles.tableContainer}> + <TableView aria-label="组件示例列表" UNSAFE_className={styles.table} selectionMode="multiple" selectedKeys={selection} onSelectionChange={setSelection} sortDescriptor={sort} onSortChange={setSort}> + <TableHeader><Column id="name" isRowHeader allowsSorting>名称</Column><Column id="category">分类</Column><Column id="status">状态</Column></TableHeader> + <TableBody items={sortedRows}>{(row) => <Row id={row.id}><Cell>{row.name}</Cell><Cell>{row.category}</Cell><Cell>{row.status}</Cell></Row>}</TableBody> + </TableView> + </div> + <p role="status" className={styles.caption}>已选择 {selection === 'all' ? rows.length : selection.size} 项</p> + </section> + </> + ) +} diff --git a/src/components/ui-kit/ui-kit.module.css b/src/components/ui-kit/ui-kit.module.css new file mode 100644 index 0000000..d590bb4 --- /dev/null +++ b/src/components/ui-kit/ui-kit.module.css @@ -0,0 +1,80 @@ +.section { + min-width: 0; + display: grid; + gap: 24px; + padding: 24px; + margin-bottom: 24px; + border: 1px solid var(--border); + border-radius: 12px; +} + +.section > h2 { + margin: 0; + font-size: 20px; +} + +.row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +.grid, +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 20px; + min-width: 0; +} + +.grid > *, +.cards > * { + min-width: 0; +} + +.caption, +.tabContent, +.popover p { + font-size: 14px; + line-height: 1.6; + overflow-wrap: anywhere; +} + +.tabContent { + padding: 20px 0; +} + +.popover { + display: grid; + gap: 12px; + padding: 16px; + width: min(260px, calc(100vw - 64px)); + box-sizing: border-box; +} + +.cardPreview { + display: grid; + place-items: center; + width: 100%; + height: 100px; + color: var(--accent); + background: var(--social-bg); +} + +.tableContainer { + min-width: 0; + overflow: auto; +} + +.table.table { + height: 280px; + min-width: 440px; + width: 100%; +} + +@media (max-width: 640px) { + .section { + padding: 16px; + } +} diff --git a/src/components/ui-kit/ui-kit.tsx b/src/components/ui-kit/ui-kit.tsx new file mode 100644 index 0000000..e12012d --- /dev/null +++ b/src/components/ui-kit/ui-kit.tsx @@ -0,0 +1,6 @@ +import ActionsFeedback from './actions-feedback' +import NavigationData from './navigation-data' + +export default function UIKit() { + return <><ActionsFeedback /><NavigationData /></> +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..10f75be --- /dev/null +++ b/src/index.css @@ -0,0 +1,74 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --accent: #aa3bff; + --social-bg: rgba(244, 243, 236, 0.5); + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +:root[data-theme='dark'] { + color-scheme: dark; + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --accent: #c084fc; + --social-bg: rgba(47, 48, 58, 0.5); +} + +#root { + width: 100%; + min-height: 100svh; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..098eec5 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import { RouterProvider } from 'react-router/dom' +import { router } from './router' +import '@react-spectrum/s2/page.css' + +createRoot(document.getElementById('root')!).render( + <StrictMode> + <RouterProvider router={router} /> + </StrictMode> +) diff --git a/src/page-about.tsx b/src/page-about.tsx new file mode 100644 index 0000000..1672795 --- /dev/null +++ b/src/page-about.tsx @@ -0,0 +1,16 @@ +import styles from './page.module.css' +import { Button } from '@react-spectrum/s2/Button'; + +function About() { + return ( + <section className={styles.page}> + <h1>About</h1> + <p>A React application built with Vite.</p> + + + <Button>Save</Button> + </section> + ) +} + +export default About diff --git a/src/page-form.module.css b/src/page-form.module.css new file mode 100644 index 0000000..f5ba93f --- /dev/null +++ b/src/page-form.module.css @@ -0,0 +1,23 @@ +.page { + width: 100%; + max-width: 1280px; + box-sizing: border-box; + margin-inline: auto; + padding: clamp(16px, 3vw, 32px); + text-align: start; +} + +.header { + margin-bottom: 28px; +} + +.header h1 { + font-size: 32px; + line-height: 1.4; + margin: 0 0 8px; +} + +.header p { + font-size: 14px; + line-height: 1.6; +} diff --git a/src/page-form.tsx b/src/page-form.tsx new file mode 100644 index 0000000..76e134b --- /dev/null +++ b/src/page-form.tsx @@ -0,0 +1,18 @@ +import { lazy, Suspense } from 'react' +import styles from './page-form.module.css' + +const FormExamples = lazy(() => import('./components/form-examples/form-examples')) + +export default function FormPage() { + return ( + <div className={styles.page}> + <header className={styles.header}> + <h1>表单</h1> + <p>Spectrum 表单控件、交互状态与数据校验示例。</p> + </header> + <Suspense fallback={<p role="status">正在加载表单…</p>}> + <FormExamples /> + </Suspense> + </div> + ) +} diff --git a/src/page-home.tsx b/src/page-home.tsx new file mode 100644 index 0000000..08d3952 --- /dev/null +++ b/src/page-home.tsx @@ -0,0 +1,11 @@ +import styles from './page.module.css' + +function Home() { + return ( + <section className={styles.page}> + <h1>Home</h1> + </section> + ) +} + +export default Home diff --git a/src/page-not-found.tsx b/src/page-not-found.tsx new file mode 100644 index 0000000..793c5c4 --- /dev/null +++ b/src/page-not-found.tsx @@ -0,0 +1,14 @@ +import styles from './page.module.css' +import { Link } from 'react-router' + +function NotFound() { + return ( + <section className={styles.page}> + <h1>404</h1> + <p>Page not found.</p> + <Link to="/">Back to home</Link> + </section> + ) +} + +export default NotFound diff --git a/src/page-ui-kit.module.css b/src/page-ui-kit.module.css new file mode 100644 index 0000000..a0c3481 --- /dev/null +++ b/src/page-ui-kit.module.css @@ -0,0 +1,23 @@ +.page { + width: 100%; + max-width: 1280px; + box-sizing: border-box; + margin-inline: auto; + padding: clamp(16px, 3vw, 32px); + text-align: start; +} + +.header { + margin-bottom: 28px; +} + +.header h1 { + margin: 0 0 8px; + font-size: 32px; + line-height: 1.4; +} + +.header p { + font-size: 14px; + line-height: 1.6; +} diff --git a/src/page-ui-kit.tsx b/src/page-ui-kit.tsx new file mode 100644 index 0000000..9b1bb5d --- /dev/null +++ b/src/page-ui-kit.tsx @@ -0,0 +1,13 @@ +import { lazy, Suspense } from 'react' +import styles from './page-ui-kit.module.css' + +const UIKit = lazy(() => import('./components/ui-kit/ui-kit')) + +export default function UIKitPage() { + return ( + <div className={styles.page}> + <header className={styles.header}><h1>UI Kit</h1><p>Spectrum 组件的外观与交互示例。</p></header> + <Suspense fallback={<p role="status">正在加载组件…</p>}><UIKit /></Suspense> + </div> + ) +} diff --git a/src/page.module.css b/src/page.module.css new file mode 100644 index 0000000..3f0c725 --- /dev/null +++ b/src/page.module.css @@ -0,0 +1,9 @@ +.page { + padding: 32px 20px; + + a { + display: inline-block; + margin-top: 24px; + color: var(--accent); + } +} diff --git a/src/route-config.ts b/src/route-config.ts new file mode 100644 index 0000000..9d308d4 --- /dev/null +++ b/src/route-config.ts @@ -0,0 +1,82 @@ +import type { ComponentType } from 'react' +import type { RouteObject } from 'react-router' +import HomeIcon from '@react-spectrum/s2/icons/Home' +import InfoCircleIcon from '@react-spectrum/s2/icons/InfoCircle' +import HelpCircleIcon from '@react-spectrum/s2/icons/HelpCircle' +import EditIcon from '@react-spectrum/s2/icons/Edit' +import AppsIcon from '@react-spectrum/s2/icons/Apps' +import Home from './page-home' +import About from './page-about' +import NotFound from './page-not-found' +import FormPage from './page-form' +import UIKitPage from './page-ui-kit' + +interface ItemMetadata { + id: string + title: string + Icon?: typeof HomeIcon + hidden?: boolean +} + +interface PageConfig extends ItemMetadata { + path: string + Component: ComponentType + children?: never +} + +interface GroupConfig extends ItemMetadata { + children: NavigationItem[] + defaultExpanded?: boolean + path?: never + Component?: never +} + +export type NavigationItem = PageConfig | GroupConfig + +export interface PageMetadata { + id: string + title: string + path: string +} + +// Menu groups organize navigation; page paths are absolute and independent of groups. +export const routeConfig: NavigationItem[] = [ + { id: 'home', title: 'Home', path: '/', Icon: HomeIcon, Component: Home }, + { id: 'form', title: '表单', path: '/form', Icon: EditIcon, Component: FormPage }, + { id: 'ui-kit', title: 'UI Kit', path: '/ui-kit', Icon: AppsIcon, Component: UIKitPage }, + { + id: 'help', + title: 'Help', + Icon: HelpCircleIcon, + defaultExpanded: true, + children: [ + { id: 'about', title: 'About', path: '/about', Icon: InfoCircleIcon, Component: About }, + ], + }, + { id: 'not-found', title: 'Page not found', path: '*', Component: NotFound, hidden: true }, +] + +function createPageRoutes(items: NavigationItem[]): RouteObject[] { + return items.flatMap((item): RouteObject[] => { + if (item.children) return createPageRoutes(item.children) + const handle: PageMetadata = { id: item.id, title: item.title, path: item.path } + return [{ id: item.id, path: item.path, Component: item.Component, handle }] + }) +} + +function createMenuItems(items: NavigationItem[]): NavigationItem[] { + return items.filter((item) => !item.hidden).map((item) => + item.children ? { ...item, children: createMenuItems(item.children) } : item, + ) +} + +function getExpandedKeys(items: NavigationItem[]): string[] { + return items.flatMap((item) => item.children + ? [...(item.defaultExpanded ? [item.id] : []), ...getExpandedKeys(item.children)] + : [], + ) +} + +export const pageRoutes = createPageRoutes(routeConfig) +export const menuItems = createMenuItems(routeConfig) +export const defaultExpandedKeys = getExpandedKeys(menuItems) diff --git a/src/router.tsx b/src/router.tsx new file mode 100644 index 0000000..8128f45 --- /dev/null +++ b/src/router.tsx @@ -0,0 +1,11 @@ +import { createBrowserRouter } from 'react-router' +import App from './App' +import { pageRoutes } from './route-config' + +export const router = createBrowserRouter([ + { + path: '/', + Component: App, + children: pageRoutes, + }, +]) diff --git a/src/theme.ts b/src/theme.ts new file mode 100644 index 0000000..c05b24d --- /dev/null +++ b/src/theme.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react' + +export type ColorScheme = 'light' | 'dark' +export const themeStorageKey = 'react-spectrum-color-scheme' + +export const ThemeContext = createContext<{ + colorScheme: ColorScheme + setColorScheme: (colorScheme: ColorScheme) => void +} | null>(null) + +export function getInitialColorScheme(): ColorScheme { + try { + const saved = localStorage.getItem(themeStorageKey) + if (saved === 'light' || saved === 'dark') return saved + } catch { + // Storage may be unavailable; theme switching still works for this session. + } + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +export function useTheme() { + const theme = useContext(ThemeContext) + if (!theme) throw new Error('useTheme must be used within ThemeContext') + return theme +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c012b56 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,33 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import macros from 'unplugin-parcel-macros'; +import optimizeLocales from '@react-aria/optimize-locales-plugin'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + macros.vite(), // Must be first! + react(), + { + ...optimizeLocales.vite({ locales: ['en-US', 'zh-CN'] }), + enforce: 'pre' + } + ], + build: { + target: ['es2022'], + // Lightning CSS produces a much smaller CSS bundle than the default minifier. + cssMinify: 'lightningcss', + rollupOptions: { + output: { + // Bundle all S2 and style-macro generated CSS into a single bundle instead of code splitting. + // Because atomic CSS has so much overlap between components, loading all CSS up front results in + // smaller bundles instead of producing duplication between pages. + manualChunks(id) { + if (/macro-(.*)\.css$/.test(id) || /@react-spectrum\/s2\/.*\.css$/.test(id)) { + return 's2-styles'; + } + } + } + } + } +}) |
