aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore24
-rw-r--r--.oxlintrc.json8
-rw-r--r--README.md98
-rw-r--r--index.html12
-rw-r--r--package.json29
-rw-r--r--pnpm-lock.yaml1279
-rw-r--r--pnpm-workspace.yaml2
-rw-r--r--src/App.tsx38
-rw-r--r--src/components/collapsed-navigation.tsx54
-rw-r--r--src/components/form-examples/color-file-fields.tsx69
-rw-r--r--src/components/form-examples/date-number-fields.tsx45
-rw-r--r--src/components/form-examples/field-grid.tsx10
-rw-r--r--src/components/form-examples/form-examples.module.css136
-rw-r--r--src/components/form-examples/form-examples.tsx91
-rw-r--r--src/components/form-examples/selection-fields.tsx68
-rw-r--r--src/components/form-examples/text-fields.tsx28
-rw-r--r--src/components/global-settings.module.css15
-rw-r--r--src/components/global-settings.tsx41
-rw-r--r--src/components/layout-basic.module.css156
-rw-r--r--src/components/layout-basic.tsx67
-rw-r--r--src/components/menu-icon.module.css6
-rw-r--r--src/components/mobile-navigation.module.css53
-rw-r--r--src/components/mobile-navigation.tsx64
-rw-r--r--src/components/sidebar-menu.tsx35
-rw-r--r--src/components/ui-kit/actions-feedback.tsx101
-rw-r--r--src/components/ui-kit/navigation-data.tsx75
-rw-r--r--src/components/ui-kit/ui-kit.module.css80
-rw-r--r--src/components/ui-kit/ui-kit.tsx6
-rw-r--r--src/index.css74
-rw-r--r--src/main.tsx12
-rw-r--r--src/page-about.tsx16
-rw-r--r--src/page-form.module.css23
-rw-r--r--src/page-form.tsx18
-rw-r--r--src/page-home.tsx11
-rw-r--r--src/page-not-found.tsx14
-rw-r--r--src/page-ui-kit.module.css23
-rw-r--r--src/page-ui-kit.tsx13
-rw-r--r--src/page.module.css9
-rw-r--r--src/route-config.ts82
-rw-r--r--src/router.tsx11
-rw-r--r--src/theme.ts25
-rw-r--r--tsconfig.app.json26
-rw-r--r--tsconfig.json7
-rw-r--r--tsconfig.node.json23
-rw-r--r--vite.config.ts33
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';
+ }
+ }
+ }
+ }
+ }
+})