diff --git a/.circleci/config.yml b/.circleci/config.yml index 9c118a4cff4..4143cad465b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -414,6 +414,7 @@ jobs: test-browser: docker: - image: mcr.microsoft.com/playwright:v1.60.0-noble + resource_class: 2xlarge.gen2 working_directory: /home/circleci/react-spectrum steps: - restore_cache: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 85fa50515bf..3c0ae90bdb2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,4 @@ + Closes @@ -8,6 +9,8 @@ Closes - [ ] Filled out test instructions. - [ ] Updated documentation (if it already exists for this component). - [ ] Looked at the Accessibility Practices for this feature - [Aria Practices](https://www.w3.org/WAI/ARIA/apg/) +- [ ] I understand every change in this PR and can explain why it's there. +- [ ] If AI-assisted, I followed our [AI contribution guidance](https://github.com/adobe/react-spectrum/blob/main/CONTRIBUTING.md#ai-assisted-contributions) and pointed my assistant at [CLAUDE.md](https://github.com/adobe/react-spectrum/blob/main/CLAUDE.md). ## 📝 Test Instructions: diff --git a/.gitignore b/.gitignore index ac8c322e491..a96bd943803 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ starters/tailwind/registry starters/docs/yarn.lock starters/tailwind/yarn.lock .scout/ +.codex/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..2649d884eaa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# AGENTS.md + +Guidance for working in the react-spectrum monorepo. + +## Repo layout + +The repo is layered. Changes flow up from the lowest level: + +- **`@internationalized/*` and `@react-stately/*`** — the two lowest levels (i18n utilities and state management). +- **`@react-aria/*`** — behavior and accessibility hooks built on the above. +- **`react-aria-components` (RAC)** and some **React Spectrum v3 (RSP)** — component layer built on the hooks. +- **RSP S2 (`@react-spectrum/s2`)** — the Spectrum 2 design system, built on RAC, the highest level. + +## Toolchain guardrails + +This repo does **not** use the conventional JS toolchain — use these, don't swap in defaults: + +- Format with `yarn format` (oxfmt), **not** Prettier. Lint with `yarn lint` (oxlint), **not** ESLint. Type-check with `yarn check-types` (tsgo), **not** tsc. Build with `yarn build` (Parcel), **not** rollup/tsc. +- **Don't run `yarn chromatic` / `yarn chromatic:forced-colors`** — maintainers run the VRT suites. +- All commonly used commands live in the root `package.json` scripts. + +## Contributing + +- **Match the surrounding code** — follow the naming, structure, and patterns of neighboring files. +- **Commit format** — use conventional-commit prefixes (`fix:`, `feat:`, `chore:`, `docs:`) as seen in the git history. + +## Task-specific workflows + +Read the relevant file before starting that kind of work (other agents: read the file directly; Claude will surface it): + +- Writing or running tests → [`docs/contributing/testing.md`](docs/contributing/testing.md) +- Tooling details (format/lint/type-check/build, Storybook, workspaces) → [`docs/contributing/tooling.md`](docs/contributing/tooling.md) +- Styling S2 components → [`docs/contributing/s2-styling.md`](docs/contributing/s2-styling.md) +- Adding user-facing strings → [`docs/contributing/i18n-strings.md`](docs/contributing/i18n-strings.md) +- Touching generated code (icons) → [`docs/contributing/codegen.md`](docs/contributing/codegen.md) +- Comments and opening a PR → [`docs/contributing/pull-requests.md`](docs/contributing/pull-requests.md) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..ca5718bf6e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +Read AGENTS.md for project architecture and conventions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff52e57539a..d6542dc5420 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,52 @@ Read [GitHub's pull request documentation](https://help.github.com/articles/abou Lastly, please follow the pull request template when submitting a pull request! + +## AI-assisted contributions +Setting expectations: the AI doesn't contribute to React Spectrum, you do. The AI is a tool, but you are still the author, and you own every line, every decision, and every explanation. + +If you use an AI assistant, point it at our [AGENTS.md](AGENTS.md), which captures the repo conventions we expect it to follow. The detailed conventions are split across [`docs/contributing/`](docs/contributing/) — see testing, tooling, s2-styling, i18n-strings, codegen, and pull-requests. + +### Aligning on a solution + +Open an issue or discussion, or at minimum, describe the problem and intended solution in the PR description. Otherwise, we have to reverse-engineer both the problem and the intended solution before we can even begin reviewing. An issue with: "here's what I'm seeing, here's what I plan to do, does that sound right?" really helps. + +### Give us the intent, not just the fix + +Tell us what you want and why, separately from how you did it. Then if we need to make a change to the PR, we can be confident that we're satisfying the goal you had. This framing helps when prompting the AI as well. + +### Tell us what you tested + +* mouse / touch / keyboard / screen reader +* LTR / RTL +* light / dark / high-contrast +* disabled / loading / error / empty +* narrow / wide / truncated / very long / wrapping text +* component sizes and zoom levels + +Even if you haven't tested all of these, it's hugely helpful to us to know where to focus efforts. + +### Beware false confidence + +One of the biggest issues we face with the rise of AI contributions is the wrong root cause but with a lot of details asserting why it is, in fact, the issue. Press the AI, and ask both it and yourself "is this the root cause, or a symptom?" and "what else could cause this?" before committing to a solution. + +### Keep a human in the loop of the conversation + +When you iterate between reviews, be sure you can say what changed and why, one sentence is enough. A PR that transforms completely between every review without explanation is exhausting to follow and makes us feel like we're arguing with a machine instead of collaborating with a person. + + +#### Requirements of front end code + +These can be useful constraints or reminders as AI is not inherently good at these things. For the toolchain that enforces some of this, see [`docs/contributing/tooling.md`](docs/contributing/tooling.md). + +* **Small** — everything you add ships across the network to the client, so more code means slower load times. +* **Fast** — must run well on constrained CPU and memory, not just the latest MacBook Pro. Many users are on years-old, low-end Android devices. +* **Mindful of the shared environment** — keep the global namespace clean, don't hog resources or throw uncaught errors, avoid CSS that leaks across boundaries, and keep ids unique. +* **Stable** — RSP and Quarry are libraries with many downstream dependents who upgrade on their own schedule, so avoid breaking them. +* **Accessible** — accessibility is still a relatively new web requirement, so strong examples are scarce and bad ones are common. Use other examples in the repo or the APG examples first. +* **Cross-environment** — works across browsers, assistive technologies, and devices. + + ### Contributor License Agreement All third-party contributions to this project must be accompanied by a signed contributor license agreement. This gives Adobe permission to redistribute your contributions as part of the project. [Sign our CLA](https://opensource.adobe.com/cla.html). You only need to submit an Adobe CLA one time, so if you have submitted one previously, you are good to go! diff --git a/docs/contributing/codegen.md b/docs/contributing/codegen.md new file mode 100644 index 00000000000..4b140d3230e --- /dev/null +++ b/docs/contributing/codegen.md @@ -0,0 +1,3 @@ +# Generated code + +@adobe/react-spectrum v3 icon components are generated (`yarn build:icons`) and @react-spectrum/s2 icons are handled through a parcel transformer, not hand-written, and `postinstall` runs `patch-package`, so run install on a fresh clone. diff --git a/docs/contributing/i18n-strings.md b/docs/contributing/i18n-strings.md new file mode 100644 index 00000000000..bea91a05b9a --- /dev/null +++ b/docs/contributing/i18n-strings.md @@ -0,0 +1,3 @@ +# User-facing strings (i18n) + +Add the key to the package's `intl/en-US.json` (ICU MessageFormat) and read it via the localized string hook. Never hardcode UI text, and don't hand-edit the other locale files (translators own those). diff --git a/docs/contributing/pull-requests.md b/docs/contributing/pull-requests.md new file mode 100644 index 00000000000..e2f01a9a622 --- /dev/null +++ b/docs/contributing/pull-requests.md @@ -0,0 +1,11 @@ +# Pull requests + +## Commenting + +Comments while developing are fine. Before presenting code for review, trim verbose comments so the diff reads cleanly. When a genuinely complex section still warrants a comment, prefer a higher-level explanation of the whole section over annotating individual lines. Specific line comments are warranted when it's addressing a browser bug or the code deviates from other patterns. + +## Opening a PR + +Start from `.github/PULL_REQUEST_TEMPLATE.md` (e.g. `gh pr create --body-file .github/PULL_REQUEST_TEMPLATE.md`) rather than writing a body from scratch: fill in every section, complete the checklist honestly, and disclose AI use. Above the checklist, add a holistic summary of how the changes work and why this approach was chosen — give the intent separately from the implementation. + +See also `CONTRIBUTING.md` (AI-assisted contributions). diff --git a/docs/contributing/s2-styling.md b/docs/contributing/s2-styling.md new file mode 100644 index 00000000000..0132a742eb1 --- /dev/null +++ b/docs/contributing/s2-styling.md @@ -0,0 +1,3 @@ +# S2 styling + +Style with the `style` [macro](https://react-spectrum.adobe.com/styling) (`import {style} from '../style' with {type: 'macro'};` — the `with {type: 'macro'}` attribute is required). Pass typed style objects to it; don't write CSS files or hand-rolled className strings for S2. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 00000000000..c5c16b7cbb0 --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,26 @@ +# Testing + +Test suites are split by type: + +- **Jest tests** — `yarn test` +- **SSR tests** — `yarn test:ssr` +- **Browser tests** — `yarn test:browser` +- **Visual regression tests (VRT)** — `yarn chromatic` +- **High-contrast-mode VRT** — `yarn chromatic:forced-colors` + +Maintainers run the Chromatic VRT suites themselves — don't run `yarn chromatic` / `yarn chromatic:forced-colors`. You can still start the VRT Storybooks locally to verify visual state: `yarn start:chromatic` and `yarn start:chromatic-fc`. + +Tests are **not** co-located with source — each package keeps them in a sibling `test/` directory. The file suffix routes the test to a runner: `*.ssr.test.*` → `yarn test:ssr`, `*.browser.test.*` → `yarn test:browser`, plain `*.test.*` → `yarn test` (Jest). Shared test helpers live in `@react-aria/test-utils` / `@react-spectrum/test-utils` (the `User` event abstraction and per-component testers). + +## Writing tests + +- **Run the full suite before committing.** Do not write PR descriptions that list a subset of specific passing tests — run `yarn test`, `yarn test:ssr` and (when relevant) `yarn test:browser` — do not run the Chromatic VRT suites (see above). +- **Run lint and formatting before committing** (`yarn lint`, `yarn format`). +- **Test at the right level.** For any change at the RAC level or below (including hooks), write the test at the RAC level ideally. If the change lives at a higher level, test at that level. +- **Move to browser tests when needed.** If a test requires mocking specific browser behavior, consider moving it to the browser run (`yarn test:browser`). +- **Cover the reported issue.** When fixing a reported issue, add a test that reproduces the specific example given in the issue. +- **Check whether the test already exists.** Find a home for it near other similar tests. +- **Check code coverage** to help decide whether a new test adds value — this is subjective. +- **In unit tests, prefer** fake timers, our test utils, and user event. Aside from those, prefer not mocking other modules, instead, move the test to a higher level. +- **Combine tests** that share the same setup before an assertion. +- **Ground test titles in the goal**, not the implementation — double-check they are accurate. diff --git a/docs/contributing/tooling.md b/docs/contributing/tooling.md new file mode 100644 index 00000000000..d9f84b8c214 --- /dev/null +++ b/docs/contributing/tooling.md @@ -0,0 +1,13 @@ +# Tooling + +This repo does **not** use the conventional JS toolchain — reach for these, and don't hand-format code or swap in defaults: + +- **Format** — `oxfmt` (`yarn format`), not Prettier. The style is opinionated (single quotes, no bracket spacing → `{foo}`, no trailing commas). Always run the tool rather than formatting by hand. +- **Lint** — `oxlint` (`oxlint packages --fix`) plus repo-local rules, not ESLint. `yarn lint` bundles format-check, type-check, `oxlint`, and Yarn `constraints` (`yarn constraints --fix`) (which enforce cross-package dependency versions). +- **Type-check** — `tsgo` (`yarn check-types`), the native TypeScript compiler — not `tsc`. +- **Build** — Parcel driven by `make` (`yarn build`), not plain `tsc`/rollup. +- **Yarn 4 workspaces** monorepo; use `yarn workspaces foreach` for cross-package operations. + +## Storybook + +Storybook is the main way to develop and view components: `yarn start` (v3/RAC) and `yarn start:s2` (S2). diff --git a/examples/s2-next-macros/src/app/page.tsx b/examples/s2-next-macros/src/app/page.tsx index e1cef34b79a..16125aac36e 100644 --- a/examples/s2-next-macros/src/app/page.tsx +++ b/examples/s2-next-macros/src/app/page.tsx @@ -38,6 +38,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -53,9 +57,12 @@ import { useListData, useTreeData } from '@react-spectrum/s2'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import Section from './components/Section'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CardViewExample} from './components/CardViewExample'; @@ -222,6 +229,89 @@ function ReorderableTreeView() { ); } +interface SideNavItemData { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: SideNavItemData[]; +} + +let sideNavItems: SideNavItemData[] = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item: SideNavItemData) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState({ @@ -355,6 +445,7 @@ function App() { + {!isLazyLoaded && ( diff --git a/examples/s2-parcel-example/src/App.js b/examples/s2-parcel-example/src/App.js index 67fbe0f7414..2602a87a3a1 100644 --- a/examples/s2-parcel-example/src/App.js +++ b/examples/s2-parcel-example/src/App.js @@ -36,6 +36,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -51,9 +55,12 @@ import { useListData, useTreeData } from '@react-spectrum/s2'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import Section from './components/Section'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CardViewExample} from './components/CardViewExample'; @@ -218,6 +225,81 @@ function ReorderableTreeView() { ); } +let sideNavItems = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState({ @@ -349,6 +431,7 @@ function App() { + {!isLazyLoaded && ( diff --git a/examples/s2-vite-project/src/App.tsx b/examples/s2-vite-project/src/App.tsx index 9c317800944..a2418c4ea23 100644 --- a/examples/s2-vite-project/src/App.tsx +++ b/examples/s2-vite-project/src/App.tsx @@ -34,6 +34,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -48,9 +52,12 @@ import { useListData, useTreeData } from '@react-spectrum/s2'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import Section from './components/Section'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CardViewExample} from './components/CardViewExample'; @@ -217,6 +224,89 @@ function ReorderableTreeView() { ); } +interface SideNavItemData { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: SideNavItemData[]; +} + +let sideNavItems: SideNavItemData[] = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item: SideNavItemData) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState({ @@ -344,6 +434,7 @@ function App() { + {!isLazyLoaded && ( diff --git a/examples/s2-webpack-5-example/src/App.js b/examples/s2-webpack-5-example/src/App.js index 3cfecfc5669..5027d8c8a71 100644 --- a/examples/s2-webpack-5-example/src/App.js +++ b/examples/s2-webpack-5-example/src/App.js @@ -36,6 +36,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -51,9 +55,12 @@ import { useListData, useTreeData } from '@react-spectrum/s2'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import Section from './components/Section'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CardViewExample} from './components/CardViewExample'; @@ -218,6 +225,81 @@ function ReorderableTreeView() { ); } +let sideNavItems = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState({ @@ -349,6 +431,7 @@ function App() { + {!isLazyLoaded && ( diff --git a/examples/s2-webpack-5-typescript-example/src/App.tsx b/examples/s2-webpack-5-typescript-example/src/App.tsx index f06a7314668..7f2944443c9 100644 --- a/examples/s2-webpack-5-typescript-example/src/App.tsx +++ b/examples/s2-webpack-5-typescript-example/src/App.tsx @@ -19,6 +19,7 @@ import { Button, ButtonGroup, Cell, + Collection, Column, Divider, Heading, @@ -31,6 +32,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -44,9 +49,12 @@ import { } from '@react-spectrum/s2'; import {CardViewExample} from './components/CardViewExample'; import {CollectionCardsExample} from './components/CollectionCardsExample'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import {LoadingState} from '@react-types/shared'; import React, {useState} from 'react'; import Section from './components/Section'; @@ -54,6 +62,89 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; const Lazy = React.lazy(() => import('./Lazy')); +interface SideNavItemData { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: SideNavItemData[]; +} + +let sideNavItems: SideNavItemData[] = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item: SideNavItemData) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState<{ @@ -263,6 +354,7 @@ function App() { + {!isLazyLoaded && ( diff --git a/packages/@internationalized/date/src/queries.ts b/packages/@internationalized/date/src/queries.ts index 5c2876c0074..92638187708 100644 --- a/packages/@internationalized/date/src/queries.ts +++ b/packages/@internationalized/date/src/queries.ts @@ -348,6 +348,11 @@ export function getWeeksInMonth( } /** Returns the lesser of the two provider dates. */ +export function minDate(a: A, b: B): A | B; +export function minDate( + a?: A | null, + b?: B | null +): A | B | null | undefined; export function minDate( a?: A | null, b?: B | null diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 873d5def2d5..7fd5afd63de 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -47,6 +47,7 @@ export type { PromptFieldAttachmentListProps, PromptTokenFieldPopoverProps, PromptFieldToolbarProps, + PromptFieldTokenValue, InsertMenuItemProps, PromptFieldVoiceButtonProps, InsertTokenMenuItemProps, diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index da3e9cb4f49..cd36b61eb62 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -20,23 +20,27 @@ import { } from '@react-types/shared'; import { baseColor, + color, focusRing, iconStyle, + lightDark, + space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; -import {BasicHorizontalCard} from './HorizontalCard'; import {Button} from 'react-aria-components/Button'; import {CardProps} from '@react-spectrum/s2/Card'; +import {ContentContext} from '@react-spectrum/s2/Content'; import Cross from '../ui-icons/Cross'; +import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; import {forwardRef, ReactNode, useContext, useRef} from 'react'; -import {IconContext} from '@react-spectrum/s2/Icon'; +import {IllustrationContext} from '@react-spectrum/s2/Icon'; import {ImageContext} from '@react-spectrum/s2/Image'; +import {ImageCoordinator} from '@react-spectrum/s2/ImageCoordinator'; // @ts-ignore import intlMessages from '../intl/*.json'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; -import {Provider} from 'react-aria-components/slots'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { Tag, @@ -46,6 +50,7 @@ import { TagListProps, TagProps } from 'react-aria-components/TagGroup'; +import {TextContext} from '@react-spectrum/s2/Text'; import {useDOMRef} from './useDOMRef'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; @@ -111,6 +116,183 @@ const styles = style<{ disableTapHighlight: true }); +const onlyPreview = ':not(:has([data-slot=content])):not(:has([data-slot=preview]))'; +const noDescription = ':not(:has([slot=description]))'; + +const attachmentCard = style({ + display: 'flex', + flexDirection: 'row', + position: 'relative', + borderRadius: 'default', + backgroundColor: { + default: lightDark('transparent-white-300', 'transparent-black-300'), + forcedColors: 'ButtonFace' + }, + boxShadow: { + default: `[inset 0 0 0 1px light-dark(${color('transparent-black-300')}, ${color('transparent-white-300')})]`, + isInvalid: `[inset 0 0 0 1px ${color('negative-900')}]` + }, + forcedColorAdjust: 'none', + transition: 'default', + fontFamily: 'sans', + overflow: 'clip', + contain: 'layout', + disableTapHighlight: true, + height: { + default: 68, + size: { + XS: 52, + S: 60, + M: 68, + L: 76, + XL: 80 + } + }, + width: { + default: 'full', + [onlyPreview]: 'auto' + }, + aspectRatio: { + [onlyPreview]: '1/1' + }, + '--card-spacing': { + type: 'paddingTop', + value: { + size: { + XS: 8, + S: 12, + M: 16, + L: 20, + XL: 24 + }, + [onlyPreview]: 0 + } + }, + alignItems: 'center', + + '--card-padding-y': { + type: 'paddingTop', + value: {default: '--card-spacing'} + }, + '--card-padding-x': { + type: 'paddingStart', + value: {default: '--card-spacing'} + }, + paddingY: '--card-padding-y', + paddingX: '--card-padding-x', + boxSizing: 'border-box', + justifyContent: { + [onlyPreview]: 'center' + }, + '--basic-thumb-size': { + type: 'height', + value: { + size: { + XS: 24, + S: 26, + M: 32, + L: 36, + XL: 40 + }, + [onlyPreview]: 'full' + } + }, + '--illust-thumb-size': { + type: 'height', + value: { + size: { + XS: 48, + S: 44, + M: 48, + L: 52, + XL: 56 + }, + [onlyPreview]: 'full' + } + }, + '--illust-margin-x': { + type: 'marginStart', + value: { + size: { + XS: -8, + S: -8, + M: -12, + L: -12, + XL: -12 + } + } + } +}); + +const illustThumbnailStyles = style({ + position: 'relative', + alignSelf: 'center', + flexShrink: 0, + pointerEvents: 'none', + userSelect: 'none', + size: '--illust-thumb-size', + marginX: '--illust-margin-x' +}); + +const attachmentTitle = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'}>({ + font: 'title', + fontSize: { + size: { + XS: 'title-xs', + S: 'title-xs', + M: 'title-sm', + L: 'title', + XL: 'title-lg' + } + }, + lineClamp: 1, + gridArea: 'title' +}); + +const attachmentDescription = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'}>({ + font: 'body', + fontSize: { + size: { + XS: 'body-2xs', + S: 'body-2xs', + M: 'body-xs', + L: 'body-sm', + XL: 'body' + } + }, + lineClamp: 1, + gridArea: 'description' +}); + +const attachmentContent = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'}>({ + display: 'grid', + gridTemplateColumns: ['minmax(0, 1fr)'], + gridTemplateAreas: ['title', 'description'], + columnGap: 4, + flexGrow: 1, + minWidth: 0, + alignItems: 'baseline', + alignContent: 'start', + rowGap: { + size: { + XS: 4, + S: 4, + M: space(6), + L: space(6), + XL: 8 + }, + [noDescription]: 0 + }, + paddingStart: { + default: '--card-spacing', + ':first-child': 0 + }, + paddingEnd: { + default: 'calc(var(--card-spacing) * 1.5 / 2)', + ':last-child': 0 + } +}); + const CloseButton = function CloseButton(props) { let ref = useRef(null); // oxlint-disable react/react-compiler @@ -208,32 +390,63 @@ const tagStyles = style({ borderRadius: 'default' }); +// this is checking that there isn't content in the attachment +// similar to onlyPreview, but specifically checking siblings before the alert icon (aka looking for Content) +const onlyPreviewFromError = ':not([data-slot=content] ~ *)'; const attachmentErrorStyles = style({ display: 'flex', flexShrink: 0, alignItems: 'center', - paddingStart: 8, + paddingStart: { + default: 8, + [onlyPreviewFromError]: 0 + }, + position: { + [onlyPreviewFromError]: 'absolute' + }, + top: { + [onlyPreviewFromError]: '50%' + }, + insetStart: { + [onlyPreviewFromError]: '50%' + }, + transform: { + [onlyPreviewFromError]: 'translate(-50%, -50%)' + }, '--iconPrimary': { type: 'color', value: 'negative' } }); +// this is also checking that there isn't content in the attachment +// similar to onlyPreview, but specifically checking siblings after the thumbnail (aka looking for Content) +const onlyPreviewFromThumbnail = ':not(:has(~ [data-slot=content]))'; function AttachmentContextProvider({ children, - isUploading + isUploading, + isInvalid }: { children: ReactNode; isUploading: boolean; + isInvalid?: boolean; }) { let imageCtx = useContext(ImageContext); - let iconCtx = useContext(IconContext); + let illustrationCtx = useContext(IllustrationContext); const opacityStyles = style({ - opacity: {default: 1, isUploading: 0.15}, + opacity: { + default: 1, + isUploading: 0.15, + isInvalid: { + default: 1, + [onlyPreviewFromThumbnail]: 0.15 + } + }, transition: 'default' - })({isUploading}); + })({isUploading, isInvalid}); const imageSlots = imageCtx && 'slots' in imageCtx ? imageCtx.slots : undefined; - const iconSlots = iconCtx && 'slots' in iconCtx ? iconCtx.slots : undefined; + const illustrationSlots = + illustrationCtx && 'slots' in illustrationCtx ? illustrationCtx.slots : undefined; return ( + + {children} + + + ); +} + export const Attachment = forwardRef(function Attachment( props: AttachmentProps, ref: DOMRef @@ -282,8 +571,7 @@ export const Attachment = forwardRef(function Attachment( styles, isInvalid, children, - size = 'M', - ...otherProps + size = 'M' } = props; let domRef = useDOMRef(ref); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); @@ -296,7 +584,7 @@ export const Attachment = forwardRef(function Attachment( aria-describedby={ariaDescribedby} ref={domRef} className={renderProps => mergeStyles(tagStyles({...renderProps}), styles)}> - + {props.uploadProgress != null && props.uploadProgress < 100 && (
@@ -319,6 +611,7 @@ export const Attachment = forwardRef(function Attachment(
)} {typeof children === 'function' ? children({size}) : children} @@ -327,7 +620,7 @@ export const Attachment = forwardRef(function Attachment( )} -
+ {/** Definitely not a close button, though looks like one. */}
{ - /** The children of the Card. */ - children: ReactNode | ((renderProps: HorizontalCardRenderProps) => ReactNode); - /** - * The size of the Card. - * - * @default 'M' - */ - size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; - /** - * The amount of internal padding within the Card. - * - * @default 'regular' - */ - density?: 'compact' | 'regular' | 'spacious'; - /** - * The visual style of the Card. - * - * @default 'primary' - */ - variant?: 'primary' | 'secondary' | 'tertiary'; - /** - * Spectrum-defined styles, returned by the `style()` macro. - */ - styles?: StyleString; -} - -export interface BasicCardProps extends Omit { - /** - * The visual style of the Card. - * - * @default 'primary' - */ - variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; - /** Whether the card is in an error state. */ - isInvalid?: boolean; -} - -const borderRadius = { - default: 'lg', - size: { - XS: 'default', - S: 'default' - }, - isBasic: 'default' -} as const; - -// Figma missing a lot of combinations of variant, tshirt, density -// Quiet Basic cards? -// Does Basic not participate in selection? (It does, but it's denoted by the border...) -// Why is there a flipped horizontal card? -// Max width on contents for horizontal cards? Doesn't appear to be one that includes the preview because the preview can have any ratio and that -// causes the width grow. -// (Max) height on cards? Maybe that makes more sense. - -const onlyPreview = ':not(:has([data-slot=content])):not(:has([data-slot=preview]))'; - -let card = style({ - display: 'flex', - flexDirection: 'row', - position: 'relative', - borderRadius, - backgroundColor: { - default: lightDark('transparent-white-300', 'transparent-black-300'), - forcedColors: 'ButtonFace' - }, - // TODO: waiting for design to investigate thumbnail card/attachement error state - boxShadow: { - default: `[inset 0 0 0 1px light-dark(${color('transparent-black-300')}, ${color('transparent-white-300')})]`, - isInvalid: `[inset 0 0 0 1px ${color('negative-900')}]` - }, - forcedColorAdjust: 'none', - transition: 'default', - fontFamily: 'sans', - textDecoration: 'none', - overflow: { - default: 'clip', - variant: { - quiet: 'visible' - } - }, - contain: 'layout', - disableTapHighlight: true, - userSelect: { - isCardView: 'none' - }, - cursor: { - isLink: 'pointer' - }, - height: { - default: { - size: { - XS: 160, - S: 180, - M: 200, - L: 220, - XL: 240 - } - }, - isBasic: { - default: 68, - size: { - XS: 52, - S: 60, - M: 68, - L: 76, - XL: 80 - } - }, - isCardView: 'full' - }, - width: { - default: 'full', - [onlyPreview]: 'auto' - }, - aspectRatio: { - [onlyPreview]: '1/1' - }, - '--card-spacing': { - type: 'paddingTop', - value: { - density: { - compact: { - size: { - XS: '[6px]', - S: 8, - M: 12, - L: 16, - XL: 20 - } - }, - regular: { - size: { - XS: 8, - S: 12, - M: 16, - L: 20, - XL: 24 - } - }, - spacious: { - size: { - XS: 12, - S: 16, - M: 20, - L: 24, - XL: 28 - } - } - }, - [onlyPreview]: 0 - } - }, - alignItems: { - isBasic: 'center' - }, - '--card-padding-y': { - type: 'paddingTop', - value: { - default: '--card-spacing' - } - }, - '--card-padding-x': { - type: 'paddingStart', - value: { - default: '--card-spacing' - } - }, - paddingY: '--card-padding-y', - paddingX: '--card-padding-x', - boxSizing: 'border-box', - ...focusRing(), - outlineStyle: { - default: 'none', - isFocusVisible: 'solid', - // Focus ring moves to preview when quiet. - variant: { - quiet: 'none' - } - }, - '--basic-thumb-size': { - type: 'height', - value: { - default: 68, - size: { - XS: 24, - S: 26, - M: 32, - L: 36, - XL: 40 - }, - [onlyPreview]: 'full' - } - } -}); - -let selectionIndicator = style({ - position: 'absolute', - inset: 0, - zIndex: 2, - borderRadius, - pointerEvents: 'none', - borderWidth: 2, - borderStyle: 'solid', - borderColor: 'gray-1000', - transition: 'default', - opacity: { - default: 0, - isSelected: 1 - }, - // Quiet cards with no checkbox have an extra inner stroke - // to distinguish the selection indicator from the preview. - outlineColor: lightDark('transparent-white-600', 'transparent-black-600'), - outlineOffset: -4, - outlineStyle: { - default: 'none', - isStrokeInner: 'solid' - }, - outlineWidth: 2 -}); - -let preview = style({ - position: 'relative', - transition: 'default', - overflow: 'clip', - marginY: 'calc(var(--card-padding-y) * -1)', - marginStart: 'calc(var(--card-padding-x) * -1)', - marginEnd: { - ':last-child': 'calc(var(--card-padding-x) * -1)' - }, - borderRadius: { - isQuiet: borderRadius - }, - boxShadow: { - isQuiet: { - isHovered: 'elevated', - isFocusVisible: 'elevated', - isSelected: 'elevated' - } - }, - ...focusRing(), - outlineStyle: { - default: 'none', - isQuiet: { - isFocusVisible: 'solid' - } - } -}); - -const image = style({ - height: 'full', - aspectRatio: '1/1', - objectFit: 'cover', - userSelect: 'none', - pointerEvents: 'none' -}); - -let title = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'; isBasic?: boolean}>({ - font: 'title', - fontSize: { - size: { - XS: 'title-xs', - S: 'title-xs', - M: 'title-sm', - L: 'title', - XL: 'title-lg' - } - }, - lineClamp: { - default: 3, - isBasic: 1 - }, - gridArea: 'title' -}); - -let description = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'; isBasic?: boolean}>({ - font: 'body', - fontSize: { - size: { - XS: 'body-2xs', - S: 'body-2xs', - M: 'body-xs', - L: 'body-sm', - XL: 'body' - } - }, - lineClamp: { - default: 3, - isBasic: 1 - }, - gridArea: 'description' -}); - -let content = style({ - display: 'grid', - // By default, all elements are displayed in a stack. - // If an action menu is present, place it next to the title. - gridTemplateColumns: { - default: ['minmax(0, 1fr)'], - ':has([data-slot=menu])': ['minmax(0, 1fr)', 'auto'] - }, - gridTemplateAreas: { - default: ['title', 'description'], - ':has([data-slot=menu])': ['title menu', 'description description'] - }, - columnGap: 4, - flexGrow: 1, - minWidth: 0, - alignItems: 'baseline', - alignContent: 'start', - rowGap: { - size: { - XS: 4, - S: 4, - M: space(6), - L: space(6), - XL: 8 - } - }, - paddingStart: { - default: '--card-spacing', - ':first-child': 0 - }, - paddingEnd: { - default: 'calc(var(--card-spacing) * 1.5 / 2)', - ':last-child': 0 - } -}); - -let actionMenu = style({ - gridArea: 'menu', - // Don't cause the row to expand, preserve gap between title and description text. - // Would use -100% here but it doesn't work in Firefox. - marginY: 'calc(-1 * self(height))' -}); - -let footer = style({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 8 -}); - -export const InternalCardViewContext = createContext({ - ElementType: 'div' as 'div' | typeof GridListItem, - layout: 'grid' as 'grid' | 'waterfall' -}); - -interface InternalCardContextValue { - isQuiet: boolean; - size: 'XS' | 'S' | 'M' | 'L' | 'XL'; - isSelected: boolean; - isHovered: boolean; - isFocusVisible: boolean; - isPressed: boolean; - isCheckboxSelection: boolean; -} - -const InternalCardContext = createContext({ - isQuiet: false, - size: 'M', - isSelected: false, - isHovered: false, - isFocusVisible: false, - isPressed: false, - isCheckboxSelection: true -}); - -const actionButtonSize = { - XS: 'XS', - S: 'XS', - M: 'S', - L: 'M', - XL: 'L' -} as const; - -const Card = forwardRef(function Card( - props: Omit & { - isBasic?: boolean; - isInvalid?: boolean; - variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; - }, - ref: DOMRef -) { - let {ElementType} = useContext(InternalCardViewContext); - let domRef = useDOMRef(ref); - let { - isBasic = false, - isInvalid = false, - density = 'regular', - size = 'M', - variant = 'primary', - styles, - id, - ...otherProps - } = props; - let isQuiet = variant === 'quiet'; - let isSkeleton = useIsSkeleton(); - let children = ( - - - {typeof props.children === 'function' ? props.children({size}) : props.children} - - - ); - - // oxlint-disable-next-line react/react-compiler - let press = pressScale(domRef); - if (ElementType === 'div' && !isSkeleton && props.href) { - // Standalone Card that has an href should be rendered as a Link. - // NOTE: In this case, the card must not contain interactive elements. - return ( - - mergeStyles( - card({ - ...renderProps, - size, - density, - variant, - isBasic, - isInvalid, - isCardView: false, - isLink: true - }), - styles - ) - } - style={renderProps => - // Only the preview in quiet cards scales down on press - variant === 'quiet' ? undefined : press(renderProps) - }> - {renderProps => ( - - {children} - - )} - - ); - } - - if (ElementType === 'div' || isSkeleton) { - return ( -
- - {children} - -
- ); - } - - return ( - - mergeStyles( - card({ - ...renderProps, - isCardView: true, - isLink: !!props.href, - size, - density, - variant, - isBasic, - isInvalid - }), - styles - ) - } - style={renderProps => - // Only the preview in quiet cards scales down on press - variant === 'quiet' ? undefined : press(renderProps) - }> - {({selectionMode, selectionBehavior, isHovered, isFocusVisible, isSelected, isPressed}) => ( - - {/* Selection indicator and checkbox move inside the preview for quiet cards */} - {!isQuiet && } - {!isQuiet && selectionMode !== 'none' && selectionBehavior === 'toggle' && ( - - )} - {/* this makes the :first-child selector work even with the checkbox */} -
{children}
-
- )} -
- ); -}); - -function SelectionIndicator() { - let {size, isSelected, isQuiet, isCheckboxSelection} = useContext(InternalCardContext); - return ( -
- ); -} - -function CardCheckbox() { - let {size} = useContext(InternalCardContext); - return ( -
- -
- ); -} - -export interface CardPreviewProps extends DOMProps { - children: ReactNode; - /** - * Spectrum-defined styles, returned by the `style()` macro. - */ - styles?: StyleString; -} - -// TODO: this should be the same component as the one in @react-spectrum/s2/Card -export const CardPreview = forwardRef(function CardPreview( - props: CardPreviewProps, - ref: DOMRef -) { - let {size, isQuiet, isHovered, isFocusVisible, isSelected, isPressed, isCheckboxSelection} = - useContext(InternalCardContext); - let domRef = useDOMRef(ref); - // oxlint-disable react/react-compiler - return ( -
- {isQuiet && } - {isQuiet && isCheckboxSelection && } -
- {props.children} -
-
- ); - // oxlint-enable react/react-compiler -}); - -const collection = style({ - display: 'grid', - gridTemplateColumns: 'repeat(3, 1fr)', - gap: { - default: 4, - size: { - XS: 2, - S: 2 - } - } -}); - -const collectionImage = style({ - width: 'full', - gridColumnEnd: { - ':nth-last-child(4):first-child': 'span 3' - }, - objectFit: 'cover', - pointerEvents: 'none', - userSelect: 'none' -}); - -export const CollectionCardPreview = forwardRef(function CollectionCardPreview( - props: CardPreviewProps, - ref: DOMRef -) { - let {size} = useContext(InternalCardContext)!; - return ( - -
- - {props.children} - -
-
- ); -}); - -const buttonSize = { - XS: 'S', - S: 'S', - M: 'M', - L: 'L', - XL: 'XL' -} as const; - -export const HorizontalCard = forwardRef(function HorizontalCard( - props: HorizontalCardProps, - ref: DOMRef -) { - let {size = 'M'} = props; - return ( - - {composeRenderProps(props.children, children => ( - - {children} - - ))} - - ); -}); - -const iconThumbnailStyles = style({ - position: 'relative', - alignSelf: 'center', - flexShrink: 0, - pointerEvents: 'none', - userSelect: 'none', - size: '--basic-thumb-size' -}); - -export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard( - props: BasicCardProps, - ref: DOMRef -) { - let {size = 'M'} = props; - return ( - - {composeRenderProps(props.children, children => ( - - {children} - - ))} - - ); -}); diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 44cad4ad784..83958e2f52a 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -14,22 +14,16 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; import Attach from '@react-spectrum/s2/icons/Attach'; import {Attachment, AttachmentList, AttachmentListProps} from './AttachmentList'; import {Autocomplete} from 'react-aria-components/Autocomplete'; -import { - baseColor, - color, - css, - iconStyle, - style, - StyleString -} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Button} from '@react-spectrum/s2/Button'; import {Cell} from './loader/data'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; +import {color, css, space, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { createContext, createRef, forwardRef, use, + useCallback, useContext, useDeferredValue, useEffect, @@ -38,6 +32,7 @@ import { useState } from 'react'; import {FocusableRef} from '@react-types/shared'; +import {getInteractionModality} from 'react-aria/private/interactions/useFocusVisible'; import {IconContext} from '@react-spectrum/s2'; import {Image, Text} from '@react-spectrum/s2/Card'; // @ts-ignore @@ -51,6 +46,7 @@ import Plus from '@react-spectrum/s2/icons/Add'; import {Popover, PopoverProps} from '@react-spectrum/s2/Popover'; import { Position, + SelectedRange, TokenFieldSegment, TokenFieldValue, TokenSegment @@ -73,6 +69,7 @@ import {useControlledState} from 'react-stately/useControlledState'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusableRef} from './useDOMRef'; import {useFocusWithin} from 'react-aria/useFocusWithin'; +import {useKeyboard} from 'react-aria/useKeyboard'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; @@ -86,9 +83,9 @@ export interface PromptFieldAttachment { export interface PromptFieldProps { children: React.ReactNode; acceptedAttachmentTypes?: string[]; - value?: TokenFieldValue; - defaultValue?: TokenFieldValue; - onChange?: (value: TokenFieldValue) => void; + value?: PromptFieldValue; + defaultValue?: PromptFieldValue; + onChange?: (value: PromptFieldValue) => void; attachments?: PromptFieldAttachment[]; defaultAttachments?: PromptFieldAttachment[]; onAttachmentsChange?: (attachments: PromptFieldAttachment[]) => void; @@ -106,8 +103,8 @@ interface PromptFieldState { attachments: PromptFieldAttachment[]; setAttachments: React.Dispatch>; acceptedAttachmentTypes?: string[]; - prompt: TokenFieldValue; - setPrompt: React.Dispatch>; + prompt: PromptFieldValue; + setPrompt: React.Dispatch>; inputRef: React.RefObject; onSubmit?: () => void; onStop?: () => void; @@ -145,10 +142,80 @@ function tokenizeURLs(text: string): TokenFieldSegment[] { return segments; } -export class PromptFieldValue extends TokenFieldValue { +interface UrlTokenValue { + type: 'url'; + url: string; +} + +interface PlaceholderTokenValue { + type: 'placeholder'; + placeholderType: 'token'; + /** Anchor character to insert when the user starts typing (e.g. '@'). */ + anchor: string; + /** Expected value type to filter completions by. */ + valueType: string | null; +} + +interface PlaceholderTextTokenValue { + type: 'placeholder'; + placeholderType: 'text'; +} + +interface AnchorTokenValue { + type: 'anchor'; + valueType: string; +} + +interface CustomTokenValue { + type: 'custom'; + /** Anchor character to insert when the user starts typing to replace the token (e.g. '@'). */ + anchor: string; + /** Type of the token value, used to filter replacement completions. */ + valueType: string; + /** Arbitrary token data. */ + data: any; +} + +export type PromptFieldTokenValue = + | UrlTokenValue + | PlaceholderTokenValue + | PlaceholderTextTokenValue + | AnchorTokenValue + | CustomTokenValue; + +export class PromptFieldValue extends TokenFieldValue { tokenize(text: string): TokenFieldSegment[] { return tokenizeURLs(text); } + + replaceRangeWithSegments( + start: Position, + end: Position, + segments: TokenFieldSegment[], + coalesce = true + ): this { + let slice = this.slice(start, end).segments; + let token = slice[0]; + if ( + slice.length === 1 && + token.type === 'token' && + ((token.value?.type === 'placeholder' && token.value.placeholderType === 'token') || + token.value?.type === 'custom') && + segments.length === 1 && + segments[0].type === 'text' && + !segments[0].text.startsWith(token.value.anchor) + ) { + segments = [ + { + type: 'token', + text: token.value.anchor, + value: {type: 'anchor', valueType: token.value.valueType} + }, + ...segments + ]; + } + return super.replaceRangeWithSegments(start, end, segments, coalesce); + } } const PromptFieldContext = createContext({ @@ -334,9 +401,10 @@ export function PromptFieldAttachmentList(props: PromptFieldAttachmentListProps) export interface PromptTokenFieldProps { completionTrigger?: RegExp; renderCompletions?: ( - filterValue: string + filterValue: string, + valueType: string | null ) => React.ReactNode[] | null | Promise; - children?: (segment: TokenSegment) => React.ReactElement; + children?: (segment: TokenSegment) => React.ReactElement; pixelLoader?: Cell[] | Cell[][]; placeholder?: string; onKeyDown?: (e: React.KeyboardEvent) => void; @@ -353,7 +421,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) { pixelLoader, placeholder, menuWidth, - onKeyDown + onKeyDown: onKeyDownProp } = props; let { prompt, @@ -369,24 +437,75 @@ export function PromptTokenField(props: PromptTokenFieldProps) { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let [isFocused, setFocused] = useState(false); - let [filterAnchor, filterValue] = useMemo(() => { + let [filterAnchor, filterValue, filterType] = useMemo(() => { + // If on a placeholder token, show suggestions. + let slice = prompt.slice(prompt.selectedRange.start, prompt.selectedRange.end); + let segment = slice.segments.length === 1 ? slice.segments[0] : null; + if ( + segment?.type === 'token' && + ((segment.value?.type === 'placeholder' && segment.value.placeholderType === 'token') || + segment.value?.type === 'custom') + ) { + return [prompt.selectedRange.start, segment.value.anchor, segment.value.valueType ?? null]; + } + if (completionTrigger) { + // Find a preceding anchor token. This tells us what kind of object to filter for. + let anchorTokenIndex = -1; + let filterType: string | null = null; + for ( + let index = Math.min(prompt.selectedRange.anchor.index, prompt.segments.length - 1); + index >= 0; + index-- + ) { + let segment = prompt.segments[index]; + if (segment.type === 'token' && segment.value?.type === 'anchor') { + anchorTokenIndex = index; + filterType = segment.value?.valueType; + break; + } + } + let filterAnchor = prompt.findText( prompt.caretPosition, TokenFieldValue.Direction.Backward, completionTrigger ); + + // If anchor token is after text anchor, use it. + if (anchorTokenIndex >= 0 && (!filterAnchor || anchorTokenIndex > filterAnchor.index)) { + filterAnchor = {index: anchorTokenIndex, offset: 0}; + } + + // Filter text is the text between the anchor and the caret position. if (filterAnchor != null) { let filterValue = prompt.slice(filterAnchor, prompt.caretPosition).toString(); - return [filterAnchor, filterValue]; + return [filterAnchor, filterValue, filterType]; } } - return [null, null]; + return [null, null, null]; }, [completionTrigger, prompt]); let items = useMemo(() => { - return filterValue != null ? renderCompletions?.(filterValue) : null; - }, [filterValue, renderCompletions]); + return filterValue != null ? renderCompletions?.(filterValue, filterType) : null; + }, [filterValue, filterType, renderCompletions]); + + let tab = (dir: number) => { + let nextPrompt = selectNextToken(prompt, dir); + if (nextPrompt) { + setPrompt(nextPrompt); + return true; + } + return false; + }; + + let {keyboardProps} = useKeyboard({ + onKeyDown: onKeyDownProp, + shortcuts: { + Tab: () => tab(1), + 'Shift+Tab': () => tab(-1) + } + }); return (
{ if (e.isTrusted) { setFocused(true); + + // If shift tabbing into the prompt field, select the last placeholder if any. + if ( + e.relatedTarget && + getInteractionModality() === 'keyboard' && + e.currentTarget.compareDocumentPosition(e.relatedTarget) & + Node.DOCUMENT_POSITION_FOLLOWING + ) { + let lastPlaceholder = prompt.segments.findLastIndex(s => s.type === 'token'); + if (lastPlaceholder >= 0) { + setPrompt(value => + value.withSelectedRange( + new TokenFieldValue.SelectedRange( + {index: lastPlaceholder, offset: 0}, + {index: lastPlaceholder, offset: 1} + ) + ) + ); + } + } } }} onBlur={e => { @@ -451,7 +591,6 @@ export function PromptTokenField(props: PromptTokenFieldProps) { setFocused(false); } }} - onKeyDown={onKeyDown} onPaste={ acceptedAttachmentTypes ? e => { @@ -477,12 +616,12 @@ export function PromptTokenField(props: PromptTokenFieldProps) { + className={ css('&:empty::before { content: attr(data-placeholder); }') + style({ font: 'body', color: { - default: baseColor('neutral'), + default: 'neutral', ':empty': { default: 'gray-600', forcedColors: 'GrayText' @@ -491,9 +630,22 @@ export function PromptTokenField(props: PromptTokenFieldProps) { width: 'full', outlineStyle: 'none', cursor: 'text' - })(renderProps) + }) }> - {children || (segment => {segment.text})} + {useCallback( + (token: TokenSegment) => { + if (token.value?.type === 'anchor') { + return {token.text}; + } else { + return children ? ( + children(token) + ) : ( + {token.text} + ); + } + }, + [children] + )} = 0 && i < prompt.segments.length; i += dir) { + let segment = prompt.segments[i]; + if (segment.type === 'token' && (!placeholder || segment.value?.type === 'placeholder')) { + return prompt.withSelectedRange( + new TokenFieldValue.SelectedRange( + {index: i, offset: 0}, + {index: i, offset: segment.text.length} + ) + ); + } + } + + return null; +} + export interface PromptTokenFieldPopoverProps extends Omit { filterAnchor?: Position | null; items?: React.ReactNode[] | null | Promise; @@ -517,7 +690,7 @@ export interface PromptTokenFieldPopoverProps extends Omit 0 && filterAnchor.offset === 0) { + filterAnchor = { + index: filterAnchor.index - 1, + offset: prompt.segments[filterAnchor.index - 1].text.length + }; + } + // Reposition the popover when the anchor changes. + key = `${filterAnchor.index}:${filterAnchor.offset}`; + } + return ( { return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> - + {menuItems} @@ -548,6 +735,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { } export interface PromptTokenProps extends Omit { + token: TokenSegment; children: React.ReactNode; } @@ -555,40 +743,50 @@ export function PromptToken(props: PromptTokenProps) { return ( + className={renderProps => + style({ + font: 'ui', + backgroundColor: { + default: 'transparent-overlay-1000/10', + isSelected: 'blue-800', + // Firefox ignores completely transparent selection colors, so we need to use a nearly transparent color instead + '::selection': '[#ffffff01]' + }, + color: { + default: 'body', + isSelected: 'white' + }, + outlineStyle: { + default: 'solid', + isPlaceholder: 'dashed' + }, + outlineWidth: 1, + outlineColor: { + default: 'transparent-overlay-1000/10', + isPlaceholder: 'transparent-overlay-1000/40' + }, + outlineOffset: -1, + borderRadius: 'pill', + boxShadow: `[inset 0 24px 32px 0 ${color('transparent-white-50')}, 0 8px 32px 0 ${color('transparent-black-50')}]`, + paddingX: 8, + // not using inline-flex here due to a text selection bug in WebKit. + paddingY: space(3), + lineHeight: '[1em]', + cursor: 'default', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } + })({...renderProps, isPlaceholder: props.token.value?.type === 'placeholder'}) + }> {icon} + styles: style({ + size: 14, + display: 'inline-block', + verticalAlign: '[-0.18em]', + marginEnd: 4 + }) }}> {props.children} @@ -674,7 +872,7 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { // to be inaccurate let finalPrompt = buildVoicePrompt(basePromptRef.current, transcript); inputRef.current.focus(); - setTokenFieldSelection(inputRef.current, finalPrompt.caretPosition, finalPrompt.caretPosition); + setTokenFieldSelection(inputRef.current, finalPrompt.selectedRange); setPrompt(finalPrompt); }); @@ -799,37 +997,50 @@ export function AttachFileMenuItem() { } // either replace the filter text (aka token replace) or insert value at current caret position (aka plain text inject) -function useInsertPromptSegment(buildSegments: (item: any) => TokenFieldSegment[]) { +function useInsertPromptSegment(segments: TokenFieldSegment[]) { let {setPrompt, inputRef} = useContext(PromptFieldContext); let anchor = useContext(PromptCompletionAnchorContext); - let pendingCaret = useRef(null); - return (item: any) => { + let pendingSelection = useRef(null); + return () => { setPrompt(value => { + // Add a space only if not already followed by one, but move the cursor past the space in any case. + let insert: TokenFieldSegment[] = [...segments]; + let endPosition = value.selectedRange.end; + if (insert.length) { + let space = value.findText(endPosition, TokenFieldValue.Direction.Forward, ' '); + let hasFollowingSpace = space && value.slice(endPosition, space).segments.length === 0; + insert.push({type: 'text', text: ' '}); + if (hasFollowingSpace && space) { + space.offset++; + endPosition = space; + } + } let newValue = value.replaceRangeWithSegments( - anchor ?? value.caretPosition, - value.caretPosition, - buildSegments(item), + anchor ?? value.selectedRange.start, + endPosition, + insert, false // Don't coalesce in undo/redo history. ); - pendingCaret.current = newValue.caretPosition; + newValue = selectNextToken(newValue, 1, true) || newValue; + pendingSelection.current = newValue.selectedRange; return newValue; }); if (anchor == null) { // Wait for popover animation, then restore cursor to after the inserted content. setTimeout(() => { - if (inputRef.current && pendingCaret.current) { - let position = pendingCaret.current; - pendingCaret.current = null; + if (inputRef.current && pendingSelection.current) { + let range = pendingSelection.current; + pendingSelection.current = null; inputRef.current.focus(); // we need to update the position manually since TokenField's update caret logic only happens if the field is focused // but this insert can happen from the + menu aka the field isn't focused until this gets called which is too late - setTokenFieldSelection(inputRef.current, position, position); + setTokenFieldSelection(inputRef.current, range); // the above focus and setCursor call can cause the internally tracked caret position to be reset incorrectly // seemingly due to TokenField's isProgrammaticSelectionChange being flipped to false by setCursor and thus reset to 0 by the .focus // fix this by resetting to proper position below // happens when injecting multiple tokens one after another via + menu - setPrompt(value => value.withCaretPosition(position)); + setPrompt(value => value.withSelectedRange(range)); } }, 400); } @@ -848,19 +1059,19 @@ export interface InsertTokenMenuItemProps extends Omit< | 'rel' | 'routerOptions' | 'target' -> {} + | 'value' +> { + token: TokenSegment; +} export function InsertTokenMenuItem(props: InsertTokenMenuItemProps) { - let insert = useInsertPromptSegment(item => [ - {type: 'token', text: 'command' in item ? item.command : item.title, value: item}, - {type: 'text', text: ' '} - ]); + let insert = useInsertPromptSegment([props.token]); return ( { - insert(props.value); + insert(); props.onAction?.(); }} /> @@ -879,18 +1090,19 @@ export interface InsertTextMenuItemProps extends Omit< | 'rel' | 'routerOptions' | 'target' -> {} + | 'value' +> { + text: string; +} export function InsertTextMenuItem(props: InsertTextMenuItemProps) { - let insert = useInsertPromptSegment(item => [ - {type: 'text', text: `${'command' in item ? item.command : item.title} `} - ]); + let insert = useInsertPromptSegment([{type: 'text', text: props.text}]); return ( { - insert(props.value); + insert(); props.onAction?.(); }} /> @@ -914,12 +1126,12 @@ export interface CommandMenuItemProps extends Omit< // since they dont end up inserting a token or text, we need to clear the partial text that the user used // to filter the menu export function CommandMenuItem(props: CommandMenuItemProps) { - let insert = useInsertPromptSegment(() => []); + let insert = useInsertPromptSegment([]); return ( { - insert(undefined); + insert(); props.onAction?.(); }} /> diff --git a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx index 57dc6287524..e4c9f865ec5 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx @@ -13,8 +13,9 @@ import {Attachment as AttachmentComponent, AttachmentList} from '../src/AttachmentList'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; import {Content} from '@react-spectrum/s2/Content'; -import File from '@react-spectrum/s2/icons/File'; -import FileText from '@react-spectrum/s2/icons/FileText'; +import FileTextIllustration from '../../s2/spectrum-illustrations/gradient/generic1/FileText'; +import FileVideo from '@react-spectrum/s2/illustrations/gradient/generic1/FileVideo'; +import FileZip from '@react-spectrum/s2/illustrations/gradient/generic1/FileZip'; import {Image} from '@react-spectrum/s2/Image'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -106,20 +107,19 @@ function NonImageAttachmentListRender(args) { let {isInvalid, size, uploadProgress, ...listArgs} = args; return ( - {/* TODO: what should the thumbnail only look like with icons */} - + - + notes.txt Plain text document @@ -130,7 +130,7 @@ function NonImageAttachmentListRender(args) { isInvalid={isInvalid} size={size} aria-label="data.csv"> - + data.csv @@ -165,3 +165,99 @@ export const LongContents: Story = { ) }; + +function MixedAttachments(args) { + let {isInvalid, size, uploadProgress, ...listArgs} = args; + + return ( +
+ + + + + + + + + + + + + + + + + + + banner.png + PNG image + + + + + + notes.txt + Plain text + + + + + + video.mp4 + MP4 + + + + + + debug.zip + ZIP + + + +
+ ); +} + +export const Mixed: Story = { + name: 'Mixed attachements', + render: args => +}; diff --git a/packages/@react-spectrum/ai/stories/BasicHorizontalCard.stories.tsx b/packages/@react-spectrum/ai/stories/BasicHorizontalCard.stories.tsx deleted file mode 100644 index 37290d5f691..00000000000 --- a/packages/@react-spectrum/ai/stories/BasicHorizontalCard.stories.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import {ActionButton} from '@react-spectrum/s2/ActionButton'; -import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; -import {type BasicCardProps, BasicHorizontalCard} from '../src/HorizontalCard'; -import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; -import ChevronRight from '@react-spectrum/s2/icons/ChevronRight'; -import {Content} from '@react-spectrum/s2/Content'; -import {Footer} from '@react-spectrum/s2/Footer'; -import {Image} from '@react-spectrum/s2/Image'; -import {MenuItem} from '@react-spectrum/s2/Menu'; -import type {Meta, StoryObj} from '@storybook/react'; -import {Skeleton} from '@react-spectrum/s2/Skeleton'; -import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Text} from '@react-spectrum/s2/Text'; - -const events = ['onAction']; - -const meta: Meta = { - component: BasicHorizontalCard, - parameters: { - layout: 'centered' - }, - tags: ['autodocs'], - args: { - isLoading: false, - ...getActionArgs(events) - }, - argTypes: { - ...categorizeArgTypes('Events', events), - href: {table: {disable: true}}, - download: {table: {disable: true}}, - hrefLang: {table: {disable: true}}, - referrerPolicy: {table: {disable: true}}, - rel: {table: {disable: true}}, - routerOptions: {table: {disable: true}}, - ping: {table: {disable: true}}, - target: {table: {disable: true}}, - value: {table: {disable: true}}, - textValue: {table: {disable: true}}, - onAction: {table: {disable: true, category: 'Events'}}, - isDisabled: {table: {disable: true}}, - children: {table: {disable: true}} - }, - decorators: (children, {args}) => ( - {children(args)} - ), - title: 'AI/BasicHorizontalCard' -}; - -export default meta; - -type BasicStory = StoryObj; - -export const Basic: BasicStory = { - render: args => ( -
- - - - Card title - Card description. - -
- - Test - -
-
- - - - Card title - Card description. - -
- - - -
-
- - - - Card title - Card description. - - - - - - Card title - Card description. - - - - - -
- ), - argTypes: { - variant: { - control: 'radio', - options: ['primary', 'secondary', 'tertiary', 'quiet'] - } - } -}; diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 715184cb6f9..344de025e87 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -215,7 +215,7 @@ export function VirtualizedStreamingChat() { let nextId = useRef(initialResponses.length); let [isGenerating, setGenerating] = useState(false); let timeouts = useRef([]); - let [promptValue, setPromptValue] = useState(new PromptFieldValue([])); + let [promptValue, setPromptValue] = useState(new PromptFieldValue([])); let followUpMessage = useRef(null); function handleSend(prompt: TokenFieldValue) { diff --git a/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx b/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx deleted file mode 100644 index 246f42c8188..00000000000 --- a/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import {CardPreview, HorizontalCard, type HorizontalCardProps} from '../src/HorizontalCard'; -import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; -import {Content} from '@react-spectrum/s2/Content'; -import {Image} from '@react-spectrum/s2/Image'; -import type {Meta, StoryObj} from '@storybook/react'; -import {Skeleton} from '@react-spectrum/s2/Skeleton'; -import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Text} from '@react-spectrum/s2/Text'; - -const events = ['onAction']; - -const meta: Meta = { - component: HorizontalCard, - parameters: { - layout: 'centered' - }, - tags: ['autodocs'], - args: { - isLoading: false, - ...getActionArgs(events) - }, - argTypes: { - ...categorizeArgTypes('Events', events), - href: {table: {disable: true}}, - download: {table: {disable: true}}, - hrefLang: {table: {disable: true}}, - referrerPolicy: {table: {disable: true}}, - rel: {table: {disable: true}}, - routerOptions: {table: {disable: true}}, - ping: {table: {disable: true}}, - target: {table: {disable: true}}, - value: {table: {disable: true}}, - textValue: {table: {disable: true}}, - onAction: {table: {disable: true, category: 'Events'}}, - isDisabled: {table: {disable: true}}, - children: {table: {disable: true}} - }, - decorators: (children, {args}) => ( - {children(args)} - ), - title: 'AI/HorizontalCard' -}; - -export default meta; - -type Story = StoryObj; - -export const Horizontal: Story = { - render: args => ( -
- - - - - - - Card title - - Card description. Give a concise overview of the context or functionality that's - mentioned in the card title. - - - - - - - - - - - Card title - - - Card description. Give a concise overview of the context or functionality that's - mentioned in the card title. - - - -
- ) -}; diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index e8c004ba22d..d6a58abae94 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -21,6 +21,7 @@ import { PromptFieldAttachment, PromptFieldAttachmentList, PromptFieldSubmitButton, + PromptFieldTokenValue, PromptFieldToolbar, PromptFieldValue, PromptFieldVoiceButton, @@ -30,6 +31,7 @@ import { import {Attachment} from '../src/AttachmentList'; import Brand from '@react-spectrum/s2/icons/Brand'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; +import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import { Collection, Header, @@ -53,6 +55,7 @@ import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; import SocialNetwork from '@react-spectrum/s2/icons/SocialNetwork'; import {TokenFieldValue} from 'react-aria-components'; +import {TokenSegment} from 'react-stately'; import {useRef, useState} from 'react'; import UserGroup from '@react-spectrum/s2/icons/UserGroup'; @@ -126,61 +129,78 @@ type Story = StoryObj; const slashCommands = [ { command: '/audience-explainer', - type: 'skill', + kind: 'skill', description: 'Explain an AEP audience in english' }, - {command: '/btw', type: 'command', description: 'Ask a side question'}, - {command: '/clear', type: 'command', description: 'Clear the context'}, - {command: '/compact', type: 'command', description: 'Summarize conversation history'}, - {command: '/dataset-usage', type: 'skill', description: 'Explain how to use a dataset'}, - {command: '/feedback', type: 'command', description: 'Submit feedback'}, - {command: '/plan', type: 'command', description: 'Create a plan before executing'}, - {command: '/visual-artifact', type: 'skill', description: 'Generate a chart or graph'} + {command: '/btw', kind: 'command', description: 'Ask a side question'}, + {command: '/clear', kind: 'command', description: 'Clear the context'}, + {command: '/compact', kind: 'command', description: 'Summarize conversation history'}, + {command: '/dataset-usage', kind: 'skill', description: 'Explain how to use a dataset'}, + {command: '/feedback', kind: 'command', description: 'Submit feedback'}, + {command: '/plan', kind: 'command', description: 'Create a plan before executing'}, + {command: '/visual-artifact', kind: 'skill', description: 'Generate a chart or graph'} ]; const icons = { - command: , - skill: , - audience: , - campaign: , - journey: , - url: + command: , + skill: , + audience: , + campaign: , + journey: , + url: } as const; +function getIcon(token: TokenSegment) { + switch (token.value?.type) { + case 'placeholder': + return token.value.placeholderType === 'token' && token.value.valueType + ? icons[token.value.valueType] + : null; + case 'url': + return icons.url; + case 'custom': + return icons[token.value.valueType]; + } +} + const objects = [ { section: 'Audiences', + type: 'audience', items: [ - {type: 'audience', title: 'New Customers'}, - {type: 'audience', title: 'Returning Customers'}, - {type: 'audience', title: 'Loyal Customers'}, - {type: 'audience', title: 'High-Value Customers'}, - {type: 'audience', title: 'Low-Value Customers'} + {kind: 'audience', title: 'New Customers'}, + {kind: 'audience', title: 'Returning Customers'}, + {kind: 'audience', title: 'Loyal Customers'}, + {kind: 'audience', title: 'High-Value Customers'}, + {kind: 'audience', title: 'Low-Value Customers'} ] }, { section: 'Campaigns', + type: 'campaign', items: [ - {type: 'campaign', title: 'Spring Launch 2026'}, - {type: 'campaign', title: 'Holiday Cheer'}, - {type: 'campaign', title: 'Back to School'}, - {type: 'campaign', title: 'Summer Adventure'}, - {type: 'campaign', title: 'Tech Trends Expo'} + {kind: 'campaign', title: 'Spring Launch 2026'}, + {kind: 'campaign', title: 'Holiday Cheer'}, + {kind: 'campaign', title: 'Back to School'}, + {kind: 'campaign', title: 'Summer Adventure'}, + {kind: 'campaign', title: 'Tech Trends Expo'} ] }, { section: 'Journeys', + type: 'journey', items: [ - {type: 'journey', title: 'Welcome Flow'}, - {type: 'journey', title: 'Abandoned Cart Recovery'}, - {type: 'journey', title: 'Post-Purchase Follow-up'}, - {type: 'journey', title: 'Re-engagement Campaign'}, - {type: 'journey', title: 'Birthday Surprise Journey'} + {kind: 'journey', title: 'Welcome Flow'}, + {kind: 'journey', title: 'Abandoned Cart Recovery'}, + {kind: 'journey', title: 'Post-Purchase Follow-up'}, + {kind: 'journey', title: 'Re-engagement Campaign'}, + {kind: 'journey', title: 'Birthday Surprise Journey'} ] } ]; interface CompletionCallbacks { + valueType?: string | null; onClear?: () => void; onCompact?: () => void; } @@ -188,7 +208,11 @@ interface CompletionCallbacks { function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) { if (filterValue.startsWith('/')) { return slashCommands - .filter(item => item.command.includes(filterValue.slice(1))) + .filter( + item => + item.command.includes(filterValue.slice(1)) && + (callbacks?.valueType ? item.kind === callbacks.valueType : true) + ) .map(item => item.command === '/clear' ? ( @@ -204,14 +228,21 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) ) : item.command === '/feedback' || item.command === '/btw' ? ( // coworker doesn't seem to have any text insertion commands anymore, so I added these for testing - + {item.command} {item.description} ) : ( - - {item.type === 'skill' ? : } + + {item.kind === 'skill' ? : } {item.command} {item.description} @@ -219,11 +250,19 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) ); } else if (filterValue.startsWith('@')) { return objects + .filter(section => (callbacks?.valueType ? section.type === callbacks.valueType : true)) .map(section => { let matchingItems = section.items .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) .map(item => ( - + {item.title} )); @@ -258,7 +297,11 @@ function atEnd(v: PromptFieldValue) { let prompt1 = new PromptFieldValue([ {type: 'text', text: 'Analyze '}, - {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, + { + type: 'token', + text: 'New Customers', + value: {type: 'custom', anchor: '@', valueType: 'audience', data: {title: 'New Customers'}} + }, {type: 'text', text: ' and suggest targeting strategies'} ]); @@ -267,23 +310,42 @@ let prompt2 = new PromptFieldValue([ { type: 'token', text: 'Spring Launch 2026', - value: {type: 'campaign', title: 'Spring Launch 2026'} + value: {type: 'custom', anchor: '@', valueType: 'campaign', data: {title: 'Spring Launch 2026'}} } ]); let prompt3Base = new PromptFieldValue([ {type: 'text', text: 'Summarize the '}, - {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}} + { + type: 'token', + text: 'Welcome Flow', + value: {type: 'custom', anchor: '@', valueType: 'journey', data: {title: 'Welcome Flow'}} + } ]); +let prompt4 = new PromptFieldValue( + [ + {type: 'text', text: 'Detect audiences in '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + }, + {type: 'text', text: ' that changed significantly in the past '}, + {type: 'token', text: 'date', value: {type: 'placeholder', placeholderType: 'text'}} + ] + // {selectedRange: new TokenFieldValue.SelectedRange({index: 1, offset: 0}, {index: 1, offset: 1})} +); + let prompts = [ - prompt1.withCaretPosition(atEnd(prompt1)), - prompt2.withCaretPosition(atEnd(prompt2)), + prompt1.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt1))), + prompt2.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt2))), prompt3Base.replaceRange( atEnd(prompt3Base), atEnd(prompt3Base), - ' journey performance from test.com /' - ) + ' journey performance from test.com ' + ), + prompt4 ]; function EverythingRender(args) { @@ -315,46 +377,6 @@ function EverythingRender(args) { }); }; - let isFieldEmpty = (prompt: TokenFieldValue) => { - let text = prompt.toString(); - return text === '' && !text.includes('\n'); - }; - - // logic for using up/down arrow keys to fill field with previous prompts - let onKeyDown = (e: React.KeyboardEvent) => { - let canNavigate = historyIndexRef.current !== -1 || isFieldEmpty(value); - let history = historyRef.current; - if (!canNavigate || history.length === 0) { - return; - } - - if (e.key === 'ArrowUp') { - e.preventDefault(); - let nextIndex = - historyIndexRef.current === -1 - ? history.length - 1 - : Math.max(0, historyIndexRef.current - 1); - historyIndexRef.current = nextIndex; - isHistoryNavigating.current = true; - setValue(history[nextIndex]); - } else if (e.key === 'ArrowDown') { - if (historyIndexRef.current === -1) { - return; - } - e.preventDefault(); - let nextIndex = historyIndexRef.current + 1; - if (nextIndex >= history.length) { - historyIndexRef.current = -1; - isHistoryNavigating.current = true; - setValue(new PromptFieldValue([])); - } else { - historyIndexRef.current = nextIndex; - isHistoryNavigating.current = true; - setValue(history[nextIndex]); - } - } - }; - let handleChange = (newValue: TokenFieldValue) => { if (!isHistoryNavigating.current) { // if user edits the field, then we want to reset the index so up arrow starts from latest prompt again @@ -374,7 +396,37 @@ function EverythingRender(args) { setValue(prompt); promptFieldRef.current?.focus(); }}> - {prompt.toString()} + {prompt.segments.map((s, i) => + s.type === 'token' ? ( + + {getIcon(s) && {getIcon(s)}} + {s.text} + + ) : ( + s.text + ) + )} ))} @@ -435,23 +487,23 @@ function EverythingRender(args) { - renderCompletions(filterValue, { + renderCompletions={(filterValue, valueType) => { + return renderCompletions(filterValue, { + valueType, onClear: () => { setValue(new PromptFieldValue([])); setAttachments([]); }, onCompact: action('onCompact') - }) - } + }); + }} pixelLoader={data[args.pixelLoader]} placeholder={placeholder} - menuWidth={menuWidth} - onKeyDown={onKeyDown}> - {segment => ( - - {icons[segment.value?.type]} - {segment.text} + menuWidth={menuWidth}> + {token => ( + + {getIcon(token)} + {token.text} )} @@ -463,7 +515,7 @@ function EverythingRender(args) { Commands - item.type === 'command')}> + item.kind === 'command')}> {item => item.command === '/clear' ? ( {item.description} ) : item.command === '/feedback' || item.command === '/btw' ? ( - + {item.command} {item.description} ) : ( - + {item.command} {item.description} @@ -499,9 +557,15 @@ function EverythingRender(args) { Skills - item.type === 'skill')}> + item.kind === 'skill')}> {item => ( - + {item.command} {item.description} @@ -521,7 +585,15 @@ function EverythingRender(args) { {item => ( - {item.title} + + {item.title} + )} @@ -568,10 +640,10 @@ export const AsyncCompletions = () => ( await new Promise(resolve => setTimeout(resolve, 500)); return renderCompletions(filterValue); }}> - {segment => ( - - {icons[segment.value?.type]} - {segment.text} + {token => ( + + {getIcon(token)} + {token.text} )} diff --git a/packages/@react-spectrum/ai/test/PromptField.test.tsx b/packages/@react-spectrum/ai/test/PromptField.test.tsx index 84cbabd317e..6729af91ab9 100644 --- a/packages/@react-spectrum/ai/test/PromptField.test.tsx +++ b/packages/@react-spectrum/ai/test/PromptField.test.tsx @@ -10,12 +10,43 @@ * governing permissions and limitations under the License. */ +import {act, screen, waitFor} from '@react-spectrum/test-utils-internal'; +import { + imageAttachment, + installRangePolyfill, + PromptFieldValue, + renderPromptField, + tokenTexts +} from './utils/promptFieldTestUtils'; import {PromptField, PromptTokenField} from '../src/PromptField'; import React from 'react'; import {render} from '@react-spectrum/test-utils-internal'; import userEvent from '@testing-library/user-event'; +// Suite requires React 19 (matches the TokenField browser coverage this ports from). const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; + +let findMenuItem = (name: string | RegExp) => screen.findByRole('menuitem', {name}); +let getMenuItem = (name: string | RegExp) => screen.getByRole('menuitem', {name}); +let queryMenuItem = (name: string | RegExp) => screen.queryByRole('menuitem', {name}); + +let selectedText = (v: PromptFieldValue) => + v.slice(v.selectedRange.start, v.selectedRange.end).toString(); + +// A prompt containing both a fillable object placeholder (Journey) and a free-text placeholder (Date). +function placeholderPrompt(): PromptFieldValue { + return new PromptFieldValue([ + {type: 'text', text: 'Detect audiences in '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + }, + {type: 'text', text: ' that changed in the past '}, + {type: 'token', text: 'Date', value: {type: 'placeholder', placeholderType: 'text'}} + ]); +} + describeOrSkip('PromptField', () => { let user; @@ -23,6 +54,396 @@ describeOrSkip('PromptField', () => { user = userEvent.setup({delay: null}); }); + beforeAll(() => { + installRangePolyfill(); + }); + + describe('placeholder text', () => { + it('shows the placeholder when empty and hides it after typing', async () => { + let {user, textbox, getValue} = renderPromptField({placeholder: 'Ask me anything'}); + expect(textbox).toHaveAttribute('data-placeholder', 'Ask me anything'); + + await user.click(textbox); + await user.keyboard('hi'); + expect(getValue().toString()).toBe('hi'); + }); + }); + + describe('autocomplete trigger: @', () => { + it('opens object completions and inserts a token', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('@'); + + // All object sections are shown for a bare @ trigger. + expect(await findMenuItem('New Customers')).toBeInTheDocument(); + expect(getMenuItem('Spring Launch 2026')).toBeInTheDocument(); + expect(getMenuItem('Welcome Flow')).toBeInTheDocument(); + + await user.click(getMenuItem('New Customers')); + // Token replaces the typed filter and a trailing space is added. + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['New Customers'])); + expect(getValue().toString()).toBe('New Customers '); + }); + + it('filters completions as the user types', async () => { + let {user, textbox} = renderPromptField(); + await user.click(textbox); + await user.keyboard('@New'); + + expect(await findMenuItem('New Customers')).toBeInTheDocument(); + expect(queryMenuItem('Welcome Flow')).not.toBeInTheDocument(); + }); + + it('does not trigger when @ is not preceded by whitespace or start', async () => { + let {user, textbox} = renderPromptField(); + await user.click(textbox); + await user.keyboard('hello@'); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + }); + + describe('autocomplete trigger: /', () => { + it('inserts a token via InsertTokenMenuItem', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/'); + + expect(await findMenuItem('/audience-explainer')).toBeInTheDocument(); + expect(getMenuItem('/clear')).toBeInTheDocument(); + expect(getMenuItem('/compact')).toBeInTheDocument(); + expect(getMenuItem('/feedback')).toBeInTheDocument(); + + await user.click(getMenuItem('/audience-explainer')); + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['/audience-explainer'])); + }); + + it('inserts plain text via InsertTextMenuItem', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/feedback'); + + await user.click(await findMenuItem('/feedback')); + await waitFor(() => expect(getValue().toString()).toBe('/feedback ')); + expect(tokenTexts(getValue())).toEqual([]); + }); + + it('runs a callback and clears the filter via CommandMenuItem', async () => { + let {user, textbox, getValue, onCompact} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/compact'); + + await user.click(await findMenuItem('/compact')); + await waitFor(() => expect(onCompact).toHaveBeenCalledTimes(1)); + // Nothing inserted, and the filter text is cleared. + expect(tokenTexts(getValue())).toEqual([]); + expect(getValue().toString()).not.toContain('/compact'); + }); + + it('runs a callback via a plain MenuItem', async () => { + let {user, textbox, onClear} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/clear'); + + await user.click(await findMenuItem('/clear')); + await waitFor(() => expect(onClear).toHaveBeenCalledTimes(1)); + }); + }); + + describe('URL tokenization', () => { + it('auto-tokenizes a URL as it is typed', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('visit test.com now'); + + await waitFor(() => + expect(getValue().segments.some(s => s.type === 'token' && s.value?.type === 'url')).toBe( + true + ) + ); + let urlToken = getValue().segments.find(s => s.type === 'token' && s.value?.type === 'url'); + expect(urlToken?.text).toBe('test.com'); + }); + }); + + describe('replacing an existing token', () => { + it('opens same-type completions when a custom token is selected', async () => { + let initialValue = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + { + type: 'token', + text: 'New Customers', + value: { + type: 'custom', + anchor: '@', + valueType: 'audience', + data: {kind: 'audience', title: 'New Customers'} + } + } + ]); + let {user, textbox, getValue, setValue} = renderPromptField({initialValue}); + + // Select the token via the controlled value (jsdom can't click-select a token), then the + // completions open filtered to the same type (audiences). + await user.click(textbox); + act(() => + setValue( + v => + v.withSelectedRange( + new PromptFieldValue.SelectedRange( + {index: 1, offset: 0}, + {index: 1, offset: 'New Customers'.length} + ) + ) as PromptFieldValue + ) + ); + expect(await findMenuItem('Returning Customers')).toBeInTheDocument(); + expect(queryMenuItem('Welcome Flow')).not.toBeInTheDocument(); + expect(queryMenuItem('Spring Launch 2026')).not.toBeInTheDocument(); + + // Choosing a different audience replaces the selected token. + await user.click(getMenuItem('Returning Customers')); + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['Returning Customers'])); + }); + }); + + describe('insert (+) menu', () => { + it('inserts an object token from the Reference submenu', async () => { + let {user, textbox, getValue} = renderPromptField(); + // Type first so the field has a live caret to insert at (typical usage). + await user.click(textbox); + await user.keyboard('Use '); + await user.click(screen.getByRole('button', {name: 'Add'})); + + expect(await findMenuItem('Attach a file')).toBeInTheDocument(); + let referenceItem = getMenuItem('Reference an object'); + + // Open the submenu. + await user.hover(referenceItem); + await user.click(await findMenuItem('Welcome Flow')); + + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['Welcome Flow'])); + expect(getValue().toString()).toBe('Use Welcome Flow '); + }); + + it('does not insert a double space when the caret is already followed by one', async () => { + let {user, getValue, setValue} = renderPromptField(); + // Place the caret between 'x' and the space (jsdom can't move the caret via arrow keys). + act(() => + setValue( + new PromptFieldValue([{type: 'text', text: 'x y'}]).withSelectedRange( + new PromptFieldValue.SelectedRange({index: 0, offset: 1}) + ) as PromptFieldValue + ) + ); + + await user.click(screen.getByRole('button', {name: 'Add'})); + await user.hover(getMenuItem('Reference an object')); + await user.click(await findMenuItem('Welcome Flow')); + + // Reuses the existing trailing space rather than adding a second one. + await waitFor(() => expect(getValue().toString()).toBe('xWelcome Flow y')); + }); + }); + + describe('placeholders', () => { + it('moves between placeholders with Tab and Shift+Tab', async () => { + let {user, textbox, getValue} = renderPromptField({initialValue: placeholderPrompt()}); + await user.click(textbox); + await user.keyboard('{Home}'); + + // Tab selects the first placeholder (Journey, index 1). + await user.keyboard('{Tab}'); + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 1, offset: 0})); + expect(getValue().selectedRange.end).toEqual({index: 1, offset: 'Journey'.length}); + + // Tab again selects the next placeholder (Date, index 3). + await user.keyboard('{Tab}'); + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 3, offset: 0})); + expect(getValue().selectedRange.end).toEqual({index: 3, offset: 'Date'.length}); + + // Shift+Tab goes back to the Journey placeholder. + await user.keyboard('{Shift>}{Tab}{/Shift}'); + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 1, offset: 0})); + }); + + it('re-opens completions filtered to the placeholder value type when typed over', async () => { + let {user, textbox} = renderPromptField({initialValue: placeholderPrompt()}); + await user.click(textbox); + await user.keyboard('{Home}'); + await user.keyboard('{Tab}'); // select the Journey placeholder + await user.keyboard('W'); + + // Completions re-open, filtered to journeys only. + expect(await findMenuItem('Welcome Flow')).toBeInTheDocument(); + expect(queryMenuItem('New Customers')).not.toBeInTheDocument(); + }); + + it('selects the last token when Shift+Tab-ing into the field', async () => { + let {user, textbox, getValue, setValue} = renderPromptField({ + initialValue: placeholderPrompt() + }); + await user.click(textbox); + // Put the caret past the last placeholder so Tab leaves the field instead of jumping + // placeholders (jsdom can't move the caret to the end via {End}). + act(() => + setValue( + v => + v.withSelectedRange( + new PromptFieldValue.SelectedRange({index: 3, offset: 'Date'.length}) + ) as PromptFieldValue + ) + ); + await user.keyboard('{Tab}'); + // Re-enter from a following element; the last placeholder (Date, index 3) is auto-selected. + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 3, offset: 0})); + // The selection covers the last placeholder (the Date token). + expect(selectedText(getValue())).toBe('Date'); + }); + + it('tabs through all tokens, not just placeholders', async () => { + let initialValue = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + { + type: 'token', + text: 'New Customers', + value: { + type: 'custom', + anchor: '@', + valueType: 'audience', + data: {kind: 'audience', title: 'New Customers'} + } + }, + {type: 'text', text: ' and '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + } + ]); + let {user, textbox, getValue} = renderPromptField({initialValue}); + await user.click(textbox); + await user.keyboard('{Home}'); + + // Tab selects the first token (a non-placeholder custom token). + await user.keyboard('{Tab}'); + await waitFor(() => expect(selectedText(getValue())).toBe('New Customers')); + + // Tab again selects the next token (the placeholder). + await user.keyboard('{Tab}'); + await waitFor(() => expect(selectedText(getValue())).toBe('Journey')); + + await user.keyboard('{Tab}'); + await user.keyboard('{Shift>}{Tab}{/Shift}'); + expect(selectedText(getValue())).toBe('Journey'); + }); + + it('advances the selection to the next placeholder after filling a placeholder', async () => { + let {user, textbox, getValue} = renderPromptField({initialValue: placeholderPrompt()}); + await user.click(textbox); + await user.keyboard('{Home}'); + await user.keyboard('{Tab}'); // select the Journey placeholder + + await user.click(await findMenuItem('Welcome Flow')); + + // Filling the placeholder auto-advances the selection to the next placeholder (Date). + await waitFor(() => expect(selectedText(getValue())).toBe('Date')); + }); + + it('does not advance the selection onto a following non-placeholder token', async () => { + let initialValue = new PromptFieldValue([ + {type: 'text', text: 'in '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + }, + {type: 'text', text: ' for '}, + { + type: 'token', + text: 'New Customers', + value: { + type: 'custom', + anchor: '@', + valueType: 'audience', + data: {kind: 'audience', title: 'New Customers'} + } + } + ]); + let {user, textbox, getValue} = renderPromptField({initialValue}); + await user.click(textbox); + await user.keyboard('{Home}'); + await user.keyboard('{Tab}'); // select the Journey placeholder + + await user.click(await findMenuItem('Welcome Flow')); + + // No placeholder follows, so the selection collapses to a caret rather than selecting the + // non-placeholder token (auto-advance only targets placeholders). + await waitFor(() => expect(getValue().selectedRange.isCollapsed).toBe(true)); + expect(selectedText(getValue())).toBe(''); + expect(tokenTexts(getValue())).toEqual(['Welcome Flow', 'New Customers']); + }); + }); + + describe('submit / generate state', () => { + it('disables submit when empty and enables it with content', async () => { + let {user, textbox, getValue, onSubmit} = renderPromptField(); + let submit = screen.getByRole('button', {name: 'Send'}); + expect(submit).toBeDisabled(); + + await user.click(textbox); + await user.keyboard('hello'); + await waitFor(() => expect(getValue().toString()).toBe('hello')); + expect(submit).toBeEnabled(); + + await user.click(submit); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].toString()).toBe('hello'); + }); + + it('shows a Stop button while generating and calls onStop', async () => { + let {user, onStop} = renderPromptField({isGenerating: true}); + let stop = screen.getByRole('button', {name: 'Stop'}); + expect(stop).toBeEnabled(); + + await user.click(stop); + expect(onStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('attachments', () => { + it('renders attachments and removes them', async () => { + let attachment = imageAttachment('a1'); + let {user, container, getAttachments, onRemoveAttachments} = renderPromptField({ + attachments: [attachment] + }); + + expect(screen.getByLabelText('Attachments')).toBeInTheDocument(); + let removeButton = container.querySelector('[slot="remove"]') as HTMLElement; + expect(removeButton).toBeInTheDocument(); + + await user.click(removeButton); + expect(onRemoveAttachments).toHaveBeenCalledTimes(1); + expect(onRemoveAttachments.mock.calls[0][0][0].id).toBe('a1'); + await waitFor(() => expect(getAttachments().length).toBe(0)); + }); + + it('shows upload progress while uploading', () => { + renderPromptField({attachments: [imageAttachment('a1')], uploadProgress: 50}); + expect(screen.getByRole('progressbar', {name: 'Uploading'})).toBeInTheDocument(); + }); + + it('renders an attachment in the invalid state', () => { + let {container} = renderPromptField({attachments: [imageAttachment('a1')], invalid: true}); + expect(screen.getByLabelText('Attachments')).toBeInTheDocument(); + // The invalid state renders a decorative alert icon. + expect(container.querySelector('[aria-hidden="true"] svg')).toBeTruthy(); + }); + }); + it('fires onKeyDown when a key is pressed in the token field', async () => { let onKeyDown = jest.fn(); let {getByRole} = render( diff --git a/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx new file mode 100644 index 00000000000..fd0b7852fcb --- /dev/null +++ b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx @@ -0,0 +1,368 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + AttachFileMenuItem, + CommandMenuItem, + InsertMenuButton, + InsertTextMenuItem, + InsertTokenMenuItem, + PromptField, + PromptFieldAttachment, + PromptFieldAttachmentList, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldValue, + PromptToken, + PromptTokenField +} from '../../src/PromptField'; +import {Attachment} from '../../src/AttachmentList'; +import { + Collection, + Header, + Heading, + Menu, + MenuItem, + MenuSection, + SubmenuTrigger, + Text +} from '@react-spectrum/s2/Menu'; +import {Image} from '@react-spectrum/s2/Image'; +import {pointerMap, render} from '@react-spectrum/test-utils-internal'; +import React, {useEffect, useState} from 'react'; +import {TokenFieldValue} from 'react-aria-components'; +import userEvent from '@testing-library/user-event'; + +// Tiny transparent PNG so resolves without a network fetch. +export const TINY_PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +/** + * Install DOM stubs that jsdom is missing but TokenField/the completion popover rely on. Call + * in beforeAll. + * - Range.getBoundingClientRect / getClientRects: the popover uses these for positioning. + * - InputEvent.getTargetRanges: TokenField reads it on beforeinput to find the edited range. + * Returning [] makes it fall back to the current selection (its pre-existing code path). + */ +export function installRangePolyfill(): void { + let proto = Range.prototype as any; + if (!proto.getBoundingClientRect) { + proto.getBoundingClientRect = () => ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON() {} + }); + } + if (!proto.getClientRects) { + proto.getClientRects = () => ({ + length: 0, + item: () => null, + [Symbol.iterator]: function* () {} + }); + } + if (typeof InputEvent !== 'undefined' && !(InputEvent.prototype as any).getTargetRanges) { + (InputEvent.prototype as any).getTargetRanges = () => []; + } +} + +// Completion data, trimmed from PromptField.stories.tsx. +export const slashCommands = [ + {command: '/audience-explainer', kind: 'skill', description: 'Explain an AEP audience'}, + {command: '/clear', kind: 'command', description: 'Clear the context'}, + {command: '/compact', kind: 'command', description: 'Summarize conversation history'}, + {command: '/feedback', kind: 'command', description: 'Submit feedback'} +]; + +export const objects = [ + { + section: 'Audiences', + type: 'audience', + items: [ + {kind: 'audience', title: 'New Customers'}, + {kind: 'audience', title: 'Returning Customers'} + ] + }, + { + section: 'Campaigns', + type: 'campaign', + items: [{kind: 'campaign', title: 'Spring Launch 2026'}] + }, + { + section: 'Journeys', + type: 'journey', + items: [ + {kind: 'journey', title: 'Welcome Flow'}, + {kind: 'journey', title: 'Abandoned Cart Recovery'} + ] + } +]; + +interface CompletionCallbacks { + valueType?: string | null; + onClear?: () => void; + onCompact?: () => void; +} + +export function renderCompletions( + filterValue: string, + callbacks?: CompletionCallbacks +): React.ReactNode[] | null { + if (filterValue.startsWith('/')) { + return slashCommands + .filter( + item => + item.command.includes(filterValue.slice(1)) && + (callbacks?.valueType ? item.kind === callbacks.valueType : true) + ) + .map(item => + item.command === '/clear' ? ( + + {item.command} + + ) : item.command === '/compact' ? ( + + {item.command} + + ) : item.command === '/feedback' ? ( + + {item.command} + + ) : ( + + {item.command} + + ) + ); + } else if (filterValue.startsWith('@')) { + return objects + .filter(section => (callbacks?.valueType ? section.type === callbacks.valueType : true)) + .map(section => { + let matchingItems = section.items + .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) + .map(item => ( + + {item.title} + + )); + return matchingItems.length > 0 ? ( + +
+ {section.section} +
+ {matchingItems} +
+ ) : null; + }) + .filter((v): v is React.ReactElement => v != null); + } + return null; +} + +export interface HarnessOptions { + initialValue?: PromptFieldValue; + attachments?: PromptFieldAttachment[]; + isGenerating?: boolean; + placeholder?: string; + acceptedAttachmentTypes?: string[]; + /** Applied to every rendered attachment (for exercising the upload progress state). */ + uploadProgress?: number; + /** Renders every attachment in the invalid state. */ + invalid?: boolean; +} + +export interface HarnessSpies { + onSubmit: jest.Mock; + onStop: jest.Mock; + onClear: jest.Mock; + onCompact: jest.Mock; + onRemoveAttachments: jest.Mock; +} + +interface ControlledPromptFieldProps extends HarnessOptions { + valueRef: React.MutableRefObject; + attachmentsRef: React.MutableRefObject; + setValueRef: React.MutableRefObject>>; + spies: HarnessSpies; +} + +function ControlledPromptField(props: ControlledPromptFieldProps) { + let { + initialValue = new PromptFieldValue([]), + attachments: initialAttachments = [], + isGenerating, + placeholder, + acceptedAttachmentTypes = ['image/*'], + uploadProgress, + invalid, + valueRef, + attachmentsRef, + setValueRef, + spies + } = props; + let [value, setValue] = useState(initialValue); + let [attachments, setAttachments] = useState(initialAttachments); + useEffect(() => { + setValueRef.current = setValue; + }, [setValue, setValueRef]); + useEffect(() => { + valueRef.current = value; + }, [value, valueRef]); + useEffect(() => { + attachmentsRef.current = attachments; + }, [attachments, attachmentsRef]); + + return ( + setValue(v as PromptFieldValue)} + attachments={attachments} + onAttachmentsChange={setAttachments} + isGenerating={isGenerating} + onStop={spies.onStop} + onSubmit={spies.onSubmit} + acceptedAttachmentTypes={acceptedAttachmentTypes} + onRemoveAttachments={spies.onRemoveAttachments}> + + {attachment => ( + + {attachment.image && } + + )} + + + renderCompletions(filterValue, { + valueType, + onClear: spies.onClear, + onCompact: spies.onCompact + }) + }> + {token => {token.text}} + + + + + + + Reference an object + + + {(item: (typeof objects)[number]) => ( + +
+ {item.section} +
+ + {(obj: {kind: string; title: string}) => ( + + {obj.title} + + )} + +
+ )} +
+
+
+ +
+
+ ); +} + +export interface PromptFieldHarness extends HarnessSpies { + user: ReturnType; + getValue: () => PromptFieldValue; + getAttachments: () => PromptFieldAttachment[]; + /** + * The controlled value setter. jsdom can't drive caret/token selection through the + * contenteditable (that needs Selection.modify / hit-testing, covered by TokenField's own + * browser tests), so tests position the caret/selection through the controlled value instead. + */ + setValue: React.Dispatch>; + textbox: HTMLElement; + container: HTMLElement; +} + +export function renderPromptField(options: HarnessOptions = {}): PromptFieldHarness { + let user = userEvent.setup({delay: null, pointerMap}); + let valueRef = {current: options.initialValue ?? new PromptFieldValue([])}; + let attachmentsRef = {current: options.attachments ?? []}; + let setValueRef = {current: (() => {}) as React.Dispatch>}; + let spies: HarnessSpies = { + onSubmit: jest.fn(), + onStop: jest.fn(), + onClear: jest.fn(), + onCompact: jest.fn(), + onRemoveAttachments: jest.fn() + }; + let tree = render( + + ); + return { + ...spies, + user, + getValue: () => valueRef.current, + getAttachments: () => attachmentsRef.current, + setValue: (...args) => setValueRef.current(...args), + textbox: tree.getByRole('textbox', {name: 'Prompt'}), + container: tree.container + }; +} + +/** Build an image attachment fixture backed by a real File. */ +export function imageAttachment(id: string, name = 'photo.png'): PromptFieldAttachment { + return {id, file: new File(['x'], name, {type: 'image/png'}), image: TINY_PNG}; +} + +export function tokenTexts(value: PromptFieldValue): string[] { + return value.segments.filter(s => s.type === 'token').map(s => s.text); +} + +export {PromptFieldValue, TokenFieldValue}; diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index 45404c4eba8..fd2298af0d5 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -39,7 +39,7 @@ import {HoverEvents} from '@react-types/shared'; import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {TextContext} from './Text'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; @@ -89,7 +89,7 @@ export interface CheckboxProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface CheckboxFieldProps @@ -109,7 +109,7 @@ export interface CheckboxFieldProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface CheckboxButtonProps @@ -429,7 +429,7 @@ export const CheckboxField = /*#__PURE__*/ (forwardRef as forwardRefType)(functi function useCheckboxAria( props: CheckboxProps | CheckboxFieldProps, - userProvidedInputRef: RefObject | null + userProvidedInputRef: Ref | null ): [CheckboxAria, RefObject] { let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index 4f9f59dfe8f..caf52c62906 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -40,7 +40,7 @@ import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import {RadioGroupState, useRadioGroupState} from 'react-stately/useRadioGroupState'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TextContext} from './Text'; @@ -89,7 +89,7 @@ export interface RadioProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface RadioFieldProps @@ -108,7 +108,7 @@ export interface RadioFieldProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface RadioButtonProps diff --git a/packages/react-aria-components/src/Switch.tsx b/packages/react-aria-components/src/Switch.tsx index b81cd69c0b3..067458b5858 100644 --- a/packages/react-aria-components/src/Switch.tsx +++ b/packages/react-aria-components/src/Switch.tsx @@ -32,7 +32,7 @@ import {forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/share import {HoverEvents} from '@react-types/shared'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; -import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {TextContext} from './Text'; import {ToggleState, useToggleState} from 'react-stately/useToggleState'; import {useFocusRing} from 'react-aria/useFocusRing'; @@ -65,7 +65,7 @@ export interface SwitchProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface SwitchFieldProps @@ -85,7 +85,7 @@ export interface SwitchFieldProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface SwitchButtonProps @@ -225,7 +225,10 @@ export const Switch = /*#__PURE__*/ (forwardRef as forwardRefType)(function Swit let {inputRef: userProvidedInputRef = null, ...otherProps} = props; [props, ref] = useContextProps(otherProps, ref, SwitchContext); let inputRef = useObjectRef( - mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null) + useMemo( + () => mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null), + [userProvidedInputRef, props.inputRef] + ) ); let state = useToggleState(props); let aria = useSwitch( @@ -276,7 +279,10 @@ export const SwitchField = /*#__PURE__*/ (forwardRef as forwardRefType)(function let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; let inputRef = useObjectRef( - mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null) + useMemo( + () => mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null), + [userProvidedInputRef, props.inputRef] + ) ); let state = useToggleState(props); let aria = useSwitch( diff --git a/packages/react-aria-components/test/Calendar.test.js b/packages/react-aria-components/test/Calendar.test.js index b1aa4e93c35..da3d4880591 100644 --- a/packages/react-aria-components/test/Calendar.test.js +++ b/packages/react-aria-components/test/Calendar.test.js @@ -501,6 +501,128 @@ describe('Calendar', () => { expect(cell).not.toHaveClass('selected'); }); + describe('selectDate', () => { + // Use a fixed date so the tests are deterministic regardless of the current date. + let focusedDate = new CalendarDate(2026, 4, 15); + + let SelectDateExample = () => { + let state = useContext(CalendarStateContext); + return ( + + + + + {state.value ? state.value.toString() : 'none'} + + ); + }; + + it('selects a date before the visible range when isDateUnavailable is provided', async () => { + let {getByRole, getAllByRole, getByTestId} = render( + false}> +
+ + + +
+ {date => } + +
+ ); + + // Navigate to the next month so the focused date is before the visible range. + await user.click(getAllByRole('button', {name: 'Next'})[0]); + + // Selecting a date before the visible range should still work. + await user.click(getByRole('button', {name: 'Select focused'})); + expect(getByTestId('selected-value')).toHaveTextContent(focusedDate.toString()); + + await user.click(getByRole('button', {name: 'Select one month before'})); + expect(getByTestId('selected-value')).toHaveTextContent( + focusedDate.subtract({months: 1}).toString() + ); + }); + + it('selects a date after the visible range when isDateUnavailable is provided', async () => { + let {getByRole, getByTestId} = render( + false}> +
+ + + +
+ {date => } + +
+ ); + + // Navigate to the previous month so the focused date is after the visible range. + await user.click(getByRole('button', {name: 'Previous'})); + + await user.click(getByRole('button', {name: 'Select focused'})); + expect(getByTestId('selected-value')).toHaveTextContent(focusedDate.toString()); + + await user.click(getByRole('button', {name: 'Select one month after'})); + expect(getByTestId('selected-value')).toHaveTextContent( + focusedDate.add({months: 1}).toString() + ); + }); + + it('selects the nearest earlier available date when the focused date is unavailable', async () => { + let {getByRole, getByTestId} = render( + d.day === focusedDate.day}> +
+ + + +
+ {date => } + +
+ ); + + // The focused date is unavailable, but the day before it is available. + await user.click(getByRole('button', {name: 'Select focused'})); + expect(getByTestId('selected-value')).toHaveTextContent( + focusedDate.subtract({days: 1}).toString() + ); + }); + + it('does not select anything when the focused date is unavailable and no earlier date in the visible range is available', async () => { + let {getByRole, getByTestId} = render( + d.day <= focusedDate.day}> +
+ + + +
+ {date => } + +
+ ); + + // Every day from the start of the visible month through the focused date is unavailable. + await user.click(getByRole('button', {name: 'Select focused'})); + expect(getByTestId('selected-value')).toHaveTextContent('none'); + }); + }); + it('should not modify selection when trying to select an unavailable date by keyboard', async () => { let calendar = renderCalendar({isDateUnavailable: d => d.day === 15}); let day16 = calendar.getByText('16'); diff --git a/packages/react-aria-components/test/Checkbox.test.js b/packages/react-aria-components/test/Checkbox.test.js index f87ce806420..7a44370b3e5 100644 --- a/packages/react-aria-components/test/Checkbox.test.js +++ b/packages/react-aria-components/test/Checkbox.test.js @@ -423,6 +423,15 @@ describe.each(['Checkbox', 'CheckboxField'])('%s', comp => { expect(inputRef.current).toBe(getByRole('checkbox')); }); + it('should support callback ref', () => { + let cleanup = jest.fn(); + let onRef = jest.fn(() => cleanup); + let {getByRole, unmount} = render(Test); + expect(onRef).toHaveBeenCalledWith(getByRole('checkbox')); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('should support and merge input ref on context', () => { let inputRef = React.createRef(); let contextInputRef = React.createRef(); diff --git a/packages/react-aria-components/test/ColorField.test.js b/packages/react-aria-components/test/ColorField.test.js index 99ea90fbe13..bdd4b2e6895 100644 --- a/packages/react-aria-components/test/ColorField.test.js +++ b/packages/react-aria-components/test/ColorField.test.js @@ -130,6 +130,44 @@ describe('ColorField', () => { expect(outerEl[0]).toHaveClass('react-aria-ColorField'); }); + it('should commit the typed value on Enter', async () => { + let onChange = jest.fn(); + let {getByRole} = render(); + let input = getByRole('textbox'); + + await user.tab(); + await user.clear(input); + await user.keyboard('#0000ff'); + expect(onChange).not.toHaveBeenCalled(); + + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(parseColor('#0000ff')); + expect(input).toHaveValue('#0000FF'); + }); + + it('should restore the previous value on Enter if the typed value cannot be parsed', async () => { + let onChange = jest.fn(); + let {getByRole} = render(); + let input = getByRole('textbox'); + + await user.tab(); + await user.clear(input); + await user.keyboard('ab'); + await user.keyboard('{Enter}'); + expect(onChange).not.toHaveBeenCalled(); + expect(input).toHaveValue('#FF0000'); + }); + + it('should call a user onKeyDown handler exactly once per key press', async () => { + let onKeyDown = jest.fn(); + render(); + + await user.tab(); + await user.keyboard('a'); + expect(onKeyDown).toHaveBeenCalledTimes(1); + }); + it('supports validation errors', async () => { let {getByRole, getByTestId} = render(
diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index 596497a88a3..2a95a682654 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -755,6 +755,23 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(inputRef.current).toBe(radio); }); + it('should support callback ref', () => { + let cleanup = jest.fn(); + let onRef = jest.fn(() => cleanup); + let {getByRole, unmount} = render( + + + + A + + + ); + let radio = getByRole('radio'); + expect(onRef).toHaveBeenCalledWith(radio); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('should support and merge input ref on context', () => { let inputRef = React.createRef(); let contextInputRef = React.createRef(); diff --git a/packages/react-aria-components/test/Switch.test.js b/packages/react-aria-components/test/Switch.test.js index 5433f99d85d..9f7d6d7f1ae 100644 --- a/packages/react-aria-components/test/Switch.test.js +++ b/packages/react-aria-components/test/Switch.test.js @@ -342,6 +342,15 @@ describe.each(['Switch', 'SwitchField'])('%s', comp => { expect(inputRef.current).toBe(getByRole('switch')); }); + it('should support callback ref', () => { + let cleanup = jest.fn(); + let onRef = jest.fn(() => cleanup); + let {getByRole, unmount} = render(Test); + expect(onRef).toHaveBeenCalledWith(getByRole('switch')); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('should support and merge input ref on context', () => { let inputRef = React.createRef(); let contextInputRef = React.createRef(); diff --git a/packages/react-aria-components/test/TokenField.browser.test.tsx b/packages/react-aria-components/test/TokenField.browser.test.tsx index 062a786665d..a5e0d6b7f7b 100644 --- a/packages/react-aria-components/test/TokenField.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.browser.test.tsx @@ -1135,5 +1135,23 @@ describeOrSkip('TokenField browser interactions', () => { } await waitForFieldText(getValue, 'b'); }); + + it('restores the selection when undoing a replacement', async () => { + let list = segments(text('abcde')); + let {textbox, getValue} = await renderControlledTokenField(list); + let el = textbox.element(); + await focusField(textbox); + // Select "bcd". + setFieldSelection(el, {index: 0, offset: 1}, {index: 0, offset: 4}); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 4}); + // Replace the selection by typing. + await userEvent.keyboard('X'); + await waitForFieldText(getValue, 'aXe'); + // Undo restores both the text and the selection that was replaced. + let mod = modKey(); + await userEvent.keyboard(`{${mod}>}z{/${mod}}`); + await waitForFieldText(getValue, 'abcde'); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 4}); + }); }); }); diff --git a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index e9a56002477..d066980ddf9 100644 --- a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx @@ -102,7 +102,7 @@ export async function focusField(locator: Locator) { } export function setFieldSelection(textboxEl: Element, start: Position, end: Position): void { - setTokenFieldSelection(textboxEl, start, end); + setTokenFieldSelection(textboxEl, new TokenFieldValue.SelectedRange(start, end)); } /** diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 84ae4850e3a..2e0d996c660 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -77,6 +77,7 @@ export {useNumberFormatter} from '../src/i18n/useNumberFormatter'; export {useListFormatter} from '../src/i18n/useListFormatter'; export {useFocus} from '../src/interactions/useFocus'; export {useFocusVisible} from '../src/interactions/useFocusVisible'; +export {useShowFocusIndicator} from '../src/interactions/useFocusVisible'; export {useFocusWithin} from '../src/interactions/useFocusWithin'; export {useHover} from '../src/interactions/useHover'; export {useInteractOutside} from '../src/interactions/useInteractOutside'; diff --git a/packages/react-aria/exports/useShowFocusIndicator.ts b/packages/react-aria/exports/useShowFocusIndicator.ts new file mode 100644 index 00000000000..50e431f11a8 --- /dev/null +++ b/packages/react-aria/exports/useShowFocusIndicator.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export {useShowFocusIndicator} from '../src/interactions/useFocusVisible'; diff --git a/packages/react-aria/src/color/useColorField.ts b/packages/react-aria/src/color/useColorField.ts index 5fb408349a6..d5d5c0170ec 100644 --- a/packages/react-aria/src/color/useColorField.ts +++ b/packages/react-aria/src/color/useColorField.ts @@ -19,6 +19,7 @@ import { ValidationResult } from '@react-types/shared'; import {ColorFieldProps, ColorFieldState} from 'react-stately/useColorFieldState'; +import {flushSync} from 'react-dom'; import {InputHTMLAttributes, LabelHTMLAttributes, RefObject, useCallback, useState} from 'react'; import {mergeProps} from '../utils/mergeProps'; import {privateValidationStateProp} from 'react-stately/private/form/useFormValidationState'; @@ -26,6 +27,7 @@ import {useFocusWithin} from '../interactions/useFocusWithin'; import {useFormattedTextField} from '../textfield/useFormattedTextField'; import {useFormReset} from '../utils/useFormReset'; import {useId} from '../utils/useId'; +import {useKeyboard} from '../interactions/useKeyboard'; import {useScrollWheel} from '../interactions/useScrollWheel'; import {useSpinButton} from '../spinbutton/useSpinButton'; @@ -70,10 +72,26 @@ export function useColorField( state: ColorFieldState, ref: RefObject ): ColorFieldAria { - let {isDisabled, isReadOnly, isRequired, isWheelDisabled, validationBehavior = 'aria'} = props; + let { + isDisabled, + isReadOnly, + isRequired, + isWheelDisabled, + validationBehavior = 'aria', + onKeyDown, + onKeyUp + } = props; - let {colorValue, inputValue, increment, decrement, incrementToMax, decrementToMin, commit} = - state; + let { + colorValue, + inputValue, + increment, + decrement, + incrementToMax, + decrementToMin, + commit, + commitValidation + } = state; let inputId = useId(); let {spinButtonProps} = useSpinButton({ @@ -110,6 +128,21 @@ export function useColorField( let scrollingDisabled = isWheelDisabled || isDisabled || isReadOnly || !focusWithin; useScrollWheel({onScroll: onWheel, isDisabled: scrollingDisabled}, ref); + let {keyboardProps} = useKeyboard({ + isDisabled: isDisabled || isReadOnly, + shortcuts: { + Enter: () => { + flushSync(() => { + commit(); + }); + commitValidation(); + return {shouldPreventDefault: false}; + } + }, + onKeyDown, + onKeyUp + }); + let onChange = value => { if (state.validate(value)) { state.setInputValue(value); @@ -128,7 +161,9 @@ export function useColorField( [privateValidationStateProp]: state, type: 'text', autoComplete: 'off', - onChange + onChange, + onKeyDown: undefined, + onKeyUp: undefined }, state, ref @@ -136,7 +171,7 @@ export function useColorField( useFormReset(ref, state.defaultColorValue, state.setColorValue); - inputProps = mergeProps(inputProps, spinButtonProps, focusWithinProps, { + inputProps = mergeProps(keyboardProps, inputProps, spinButtonProps, focusWithinProps, { role: 'textbox', 'aria-valuemax': null, 'aria-valuemin': null, diff --git a/packages/react-aria/src/interactions/useFocusVisible.ts b/packages/react-aria/src/interactions/useFocusVisible.ts index 9e992f5334f..72b1d22fd25 100644 --- a/packages/react-aria/src/interactions/useFocusVisible.ts +++ b/packages/react-aria/src/interactions/useFocusVisible.ts @@ -23,7 +23,7 @@ import {isMac} from '../utils/platform'; import {isVirtualClick} from '../utils/isVirtualEvent'; import {openLink} from '../utils/openLink'; import {PointerType} from '@react-types/shared'; -import {useEffect, useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import {useIsSSR} from '../ssr/SSRProvider'; export type Modality = 'keyboard' | 'pointer' | 'virtual'; @@ -303,6 +303,13 @@ export function setInteractionModality(modality: Modality): void { triggerChangeHandlers(modality, null); } +/** + * Returns a callback that makes the focus indicator visible. + */ +export function useShowFocusIndicator(): () => void { + return useCallback(() => setInteractionModality('keyboard'), []); +} + /** @private */ export function getPointerType(): PointerType { return currentPointerType; diff --git a/packages/react-aria/src/tokenfield/useToken.ts b/packages/react-aria/src/tokenfield/useToken.ts index 9f51ed2ec75..15c9547b5b8 100644 --- a/packages/react-aria/src/tokenfield/useToken.ts +++ b/packages/react-aria/src/tokenfield/useToken.ts @@ -36,12 +36,12 @@ export function useToken( useEvent(useRef(typeof document !== 'undefined' ? document : null), 'selectionchange', () => { let selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || !ref.current) { + if (!selection || !ref.current) { return; } - let range = selection.getRangeAt(0); - if (!range.collapsed && range.intersectsNode(ref.current)) { + let range = selection.rangeCount === 0 ? null : selection.getRangeAt(0); + if (!range?.collapsed && range?.intersectsNode(ref.current)) { setSelected(true); } else { setSelected(false); diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index aef7c40d9f2..ee050095218 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -20,12 +20,13 @@ import { useMemo, useRef } from 'react'; -import {getActiveElement} from '../utils/shadowdom/DOMFunctions'; +import {getActiveElement, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getOwnerDocument} from '../utils/domHelpers'; import {isMac} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; import { Position, + SelectedRange, TokenFieldProps, TokenFieldSegment, TokenFieldState, @@ -175,19 +176,15 @@ export function useTokenField( nextValue.current = value; }); - let caretPosition = useRef(null); + let selectedRange = useRef(null); useLayoutEffect(() => { - if ( - ref.current && - value.caretPosition && - !state.isComposing && - value.caretPosition !== caretPosition.current - ) { + if (ref.current && !state.isComposing && value.selectedRange !== selectedRange.current) { // Only move the caret when the field is already focused. if (ref.current === getActiveElement(getOwnerDocument(ref.current))) { - setCursor(ref.current, value.caretPosition); + setTokenFieldSelection(ref.current, value.selectedRange); + announceToken(value); } - caretPosition.current = value.caretPosition; + selectedRange.current = value.selectedRange; } }); @@ -198,11 +195,14 @@ export function useTokenField( stopComposition(); } - let selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return; + let range = e.getTargetRanges()[0]; + if (!range) { + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + range = selection.getRangeAt(0); } - let range = selection.getRangeAt(0); let [start, end] = rangeToPositions(ref.current!, range); // https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes @@ -277,7 +277,7 @@ export function useTokenField( case 'deleteContent': case 'deleteByCut': case 'deleteCompositionText': { - if (!range.collapsed) { + if (!range.collapsed && !isSamePosition(start, end)) { apply(tokens => tokens.replaceRange(start, end, '')); break; } @@ -393,20 +393,29 @@ export function useTokenField( // When the cursor moves next to a token, announce it. // Otherwise the screen reader will only announce the first/last character. - if (window.getSelection()?.isCollapsed) { - let [start, end] = getSelection(ref.current!)!; - if (start.offset === 0) { - let segment = value.segments[start.index]; - if (segment?.type !== 'token') { - segment = value.segments[start.index - 1]; - } - if (segment?.type === 'token') { - announce(segment.text, 'assertive'); - } + let range = getSelectedRange(ref.current!); + if (!range) { + return; + } - // Update the caret position in the value. - state.setValue(value => value.withCaretPosition(end)); - } + announceToken(value, range); + + // Update the selected range in the value. + state.setValue(value => value.withSelectedRange(range)); + }); + + // Clear selection on blur. + useEvent(ref, 'blur', e => { + if (!e.isTrusted) { + return; + } + + let selection = window.getSelection(); + if (ref.current && selection && selection.containsNode(ref.current, true)) { + selection.removeAllRanges(); + state.setValue(value => + value.withSelectedRange(new TokenFieldValue.SelectedRange(value.caretPosition)) + ); } }); @@ -423,7 +432,7 @@ export function useTokenField( let end = value.findLineBoundary(selection[1], TokenFieldValue.Direction.Forward); if (start && end) { e.preventDefault(); - setTokenFieldSelection(ref.current!, start, end, true); + setTokenFieldSelection(ref.current!, new TokenFieldValue.SelectedRange(start, end), true); } } }); @@ -613,13 +622,34 @@ export function getSelection(container: Element): [Position, Position] | null { return rangeToPositions(container, range); } +export function getSelectedRange(container: Element) { + let selection = window.getSelection(); + if ( + !selection || + !selection.anchorNode || + !selection.focusNode || + !nodeContains(container, selection.anchorNode) || + !nodeContains(container, selection.focusNode) + ) { + return null; + } + let anchor = getPosition(container, selection.anchorNode, selection.anchorOffset, false); + let current = getPosition( + container, + selection.focusNode, + selection.focusOffset, + !selection.isCollapsed + ); + return new TokenFieldValue.SelectedRange(anchor, current); +} + function rangeToPositions(container: Element, range: Range | StaticRange): [Position, Position] { - let start = getPosition(container, range.startContainer, range.startOffset); - let end = getPosition(container, range.endContainer, range.endOffset); + let start = getPosition(container, range.startContainer, range.startOffset, false); + let end = getPosition(container, range.endContainer, range.endOffset, !range.collapsed); return [start, end]; } -function getPosition(container: Element, node: Node, offset: number): Position { +function getPosition(container: Element, node: Node, offset: number, isRangeEnd = false): Position { if (node === container) { return {index: offset, offset: 0}; } @@ -636,7 +666,7 @@ function getPosition(container: Element, node: Node, offset: number): Position { let endOffset = 0; if (originalNode === tokenNode) { // Cursor is inside the token. - atEnd = offset > 0; + atEnd = isRangeEnd || offset > 0; } else if (originalNode === node) { // Cursor is inside the wrapper element. atEnd = offset > 1; @@ -666,57 +696,84 @@ function getPosition(container: Element, node: Node, offset: number): Position { let isProgrammaticSelectionChange = Symbol('isProgrammaticSelectionChange'); function setCursor(root: Element, pos: Position, fireEvent = false) { - setTokenFieldSelection(root, pos, pos, fireEvent); + setTokenFieldSelection(root, new TokenFieldValue.SelectedRange(pos), fireEvent); } export function setTokenFieldSelection( root: Element, - start: Position, - end: Position, + selectedRange: SelectedRange, fireEvent = false ) { let selection = window.getSelection(); if (selection) { - let range = createDOMRange(root, start, end); + // Use setBaseAndExtent to preserve the selection direction. A plain Range + + // addRange always produces a forward selection and collapses when the + // anchor comes after the current position (backward selections). + let [anchorNode, anchorOffset] = getDOMPosition(root, selectedRange.anchor); + let [focusNode, focusOffset] = getDOMPosition(root, selectedRange.current); root[isProgrammaticSelectionChange] = !fireEvent; - selection.removeAllRanges(); - selection.addRange(range); + + // Only set selection if it has changed, because this can clobber the browser's selection direction. + if ( + selection.anchorNode !== anchorNode || + selection.anchorOffset !== anchorOffset || + selection.focusNode !== focusNode || + selection.focusOffset !== focusOffset + ) { + selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); + } } } export function tokenFieldPositionToDOMRange(root: Element, pos: Position): Range { - return createDOMRange(root, pos, pos); + // Unlike createDOMRange (used for caret/selection placement), this range is only + // measured via getBoundingClientRect to position things like an autocomplete popover. + // Place the endpoints inside the token's zero width space wrappers so the range has a + // valid rect at the token, rather than a collapsed root-level position. + let range = document.createRange(); + let [startContainer, startOffset] = getDOMRectPosition(root, pos); + range.setStart(startContainer, startOffset); + range.setEnd(startContainer, startOffset); + return range; } -function createDOMRange(root: Element, start: Position, end: Position): Range { - let range = document.createRange(); - let startChild = root.childNodes[start.index]; - if (!startChild) { - range.setStart(root, Math.min(root.childNodes.length, start.index)); - } else if (startChild.nodeType === Node.ELEMENT_NODE) { - // Place the cursor outside the token wrapper element. - if (start.offset > 0) { - range.setStartAfter(startChild); +function getDOMRectPosition(root: Element, pos: Position): [Node, number] { + let child = root.childNodes[pos.index]; + if (child && child.nodeType === Node.ELEMENT_NODE) { + // Place the position inside the zero width space wrappers around the token. + if (pos.offset > 0) { + return [child.lastChild!, 1]; } else { - range.setStartBefore(startChild); + return [child.firstChild!, 0]; } - } else { - range.setStart(startChild, start.offset); } + return getDOMPosition(root, pos); +} - let endChild = root.childNodes[end.index]; - if (!endChild) { - range.setEnd(root, Math.min(root.childNodes.length, end.index)); - } else if (endChild.nodeType === Node.ELEMENT_NODE) { - if (end.offset > 0) { - range.setEndAfter(endChild); +function createDOMRange(root: Element, start: Position, end: Position): Range { + let range = document.createRange(); + let [startContainer, startOffset] = getDOMPosition(root, start); + let [endContainer, endOffset] = getDOMPosition(root, end); + range.setStart(startContainer, startOffset); + range.setEnd(endContainer, endOffset); + return range; +} + +function getDOMPosition(root: Element, pos: Position): [Node, number] { + let child = root.childNodes[pos.index]; + if (!child) { + return [root, Math.min(root.childNodes.length, pos.index)]; + } else if (child.nodeType === Node.ELEMENT_NODE) { + // Place the cursor outside the token wrapper element. + // This is necessary for composition events. + if (pos.offset > 0) { + return [root, pos.index + 1]; } else { - range.setEndBefore(endChild); + return [root, pos.index]; } } else { - range.setEnd(endChild, end.offset); + return [child, pos.offset]; } - return range; } function isSamePosition(a: Position, b: Position): boolean { @@ -832,3 +889,26 @@ function trackMutations(element: Element) { } }; } + +function announceToken(value: TokenFieldValue, range = value.selectedRange) { + if (range.isCollapsed) { + // Announce adjacent tokens. + let segment = value.segments[range.current.index]; + if (segment && segment.type !== 'token') { + if (range.current.offset === 0) { + segment = value.segments[range.current.index - 1]; + } else if (range.current.offset === segment.text.length) { + segment = value.segments[range.current.index + 1]; + } + } + if (segment?.type === 'token') { + announce(segment.text, 'assertive'); + } + } else { + // Announce token if it is the only thing selected. + let selected = value.slice(range.start, range.end).segments; + if (selected.length === 1 && selected[0].type === 'token') { + announce(selected[0].text, 'assertive'); + } + } +} diff --git a/packages/react-aria/src/utils/getMetaValue.ts b/packages/react-aria/src/utils/getMetaValue.ts new file mode 100644 index 00000000000..54115f79ab6 --- /dev/null +++ b/packages/react-aria/src/utils/getMetaValue.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {getOwnerDocument, getOwnerWindow} from './domHelpers'; + +declare global { + interface Window { + __webpack_nonce__?: string; + } + var __webpack_nonce__: string | undefined; +} + +export function getMetaValue(key: string, doc?: Document): string | undefined { + let ownerWindow = getOwnerWindow(doc); + let ownerDocument = getOwnerDocument(doc); + + if (ownerDocument == null || ownerWindow == null) { + return; + } + + let content: string | undefined = undefined; + let selector = `meta[name="${CSS.escape(key)}"], meta[property="${CSS.escape(key)}"]`; + let meta = ownerDocument.querySelector(selector); + + if (meta && meta instanceof ownerWindow.HTMLMetaElement) { + if (key === 'csp-nonce' && meta.nonce) { + content ??= meta.nonce || undefined; + } + + if (meta.content) { + content ??= meta.content || undefined; + } + } + + if (key === 'csp-nonce') { + content ??= ownerWindow.__webpack_nonce__ || globalThis.__webpack_nonce__ || undefined; + } + + return content; +} diff --git a/packages/react-aria/src/utils/getNonce.ts b/packages/react-aria/src/utils/getNonce.ts index 54ff2a0d08e..b03216cfe67 100644 --- a/packages/react-aria/src/utils/getNonce.ts +++ b/packages/react-aria/src/utils/getNonce.ts @@ -10,17 +10,8 @@ * governing permissions and limitations under the License. */ -import {getOwnerWindow} from './domHelpers'; - -type NonceWindow = Window & - typeof globalThis & { - __webpack_nonce__?: string; - }; - -function getWebpackNonce(doc?: Document): string | undefined { - let ownerWindow = doc?.defaultView as NonceWindow | null | undefined; - return ownerWindow?.__webpack_nonce__ || globalThis['__webpack_nonce__'] || undefined; -} +import {getMetaValue} from './getMetaValue'; +import {getOwnerDocument} from './domHelpers'; let nonceCache = new WeakMap(); @@ -35,25 +26,13 @@ export function resetNonceCache(): void { * Security Policy. */ export function getNonce(doc?: Document): string | undefined { - let d = doc ?? (typeof document !== 'undefined' ? document : undefined); - if (!d) { - return getWebpackNonce(d); - } - - if (nonceCache.has(d)) { - return nonceCache.get(d); - } + let ownerDocument = getOwnerDocument(doc); - let meta = d.querySelector('meta[property="csp-nonce"]'); - let nonce = - (meta && - meta instanceof getOwnerWindow(meta).HTMLMetaElement && - (meta.nonce || meta.content)) || - getWebpackNonce(d) || - undefined; + let nonce = nonceCache.get(ownerDocument); + nonce ??= getMetaValue('csp-nonce', ownerDocument); if (nonce !== undefined) { - nonceCache.set(d, nonce); + nonceCache.set(ownerDocument, nonce); } return nonce; } diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index d469996ee35..72c798438d0 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -21,7 +21,8 @@ import { import { addWindowFocusTracking, useFocusVisible, - useFocusVisibleListener + useFocusVisibleListener, + useShowFocusIndicator } from '../../src/interactions/useFocusVisible'; import {changeHandlers, hasSetupGlobalListeners} from '../../src/interactions/useFocusVisible'; import {mergeProps} from '../../src/utils/mergeProps'; @@ -375,6 +376,40 @@ describe('useFocusVisible', function () { }); }); +describe('useShowFocusIndicator', function () { + // A form library that moves focus to the first invalid field does so by calling + // element.focus() from the submit handler. Clicking submit leaves the modality on + // 'pointer', so the programmatic focus lands without a visible indicator. + function FormExample() { + let {focusProps, isFocusVisible} = useFocusRing(); + let showFocusIndicator = useShowFocusIndicator(); + let ref = React.useRef(null); + + let onSubmit = e => { + e.preventDefault(); + showFocusIndicator(); + ref.current.focus(); + }; + + return ( + + + + + ); + } + + it('shows the focus indicator on programmatic focus after a pointer interaction', async function () { + let user = userEvent.setup({delay: null, pointerMap}); + render(); + await user.click(screen.getByRole('button', {name: 'Submit'})); + + let input = screen.getByRole('textbox'); + expect(input).toHaveFocus(); + expect(input).toHaveAttribute('data-focus-visible'); + }); +}); + describe('useFocusVisibleListener', function () { it('emits on modality change (non-text input)', function () { let fnMock = jest.fn(); diff --git a/packages/react-aria/test/utils/getMetaValue.test.js b/packages/react-aria/test/utils/getMetaValue.test.js new file mode 100644 index 00000000000..5bd292215d2 --- /dev/null +++ b/packages/react-aria/test/utils/getMetaValue.test.js @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {getMetaValue} from '../../src/utils/getMetaValue'; + +describe('getMetaValue', () => { + afterEach(() => { + document.querySelectorAll('meta').forEach(el => el.remove()); + delete globalThis['__webpack_nonce__']; + }); + + it('returns undefined when no matching meta tag exists', () => { + expect(getMetaValue('theme-color')).toBeUndefined(); + }); + + it('reads a value from the name attribute for an arbitrary key', () => { + let meta = document.createElement('meta'); + meta.setAttribute('name', 'theme-color'); + meta.setAttribute('content', '#ff0000'); + document.head.appendChild(meta); + + expect(getMetaValue('theme-color')).toBe('#ff0000'); + }); + + it('reads a value from the property attribute for an arbitrary key', () => { + let meta = document.createElement('meta'); + meta.setAttribute('property', 'og:title'); + meta.setAttribute('content', 'Hello'); + document.head.appendChild(meta); + + expect(getMetaValue('og:title')).toBe('Hello'); + }); + + it('does not fall back to __webpack_nonce__ for non-nonce keys', () => { + globalThis['__webpack_nonce__'] = 'webpack-nonce'; + + expect(getMetaValue('theme-color')).toBeUndefined(); + }); + + it('escapes special characters in the key when building the selector', () => { + let meta = document.createElement('meta'); + meta.setAttribute('name', 'my:weird.key'); + meta.setAttribute('content', 'escaped'); + document.head.appendChild(meta); + + expect(getMetaValue('my:weird.key')).toBe('escaped'); + }); +}); diff --git a/packages/react-stately/exports/useTokenFieldState.ts b/packages/react-stately/exports/useTokenFieldState.ts index e6ad50ac607..98e3a375c20 100644 --- a/packages/react-stately/exports/useTokenFieldState.ts +++ b/packages/react-stately/exports/useTokenFieldState.ts @@ -19,5 +19,6 @@ export type { TokenSegment, TextSegment, Position, + SelectedRange, TokenFieldValueOptions } from '../src/tokenfield/TokenFieldValue'; diff --git a/packages/react-stately/src/calendar/useCalendarState.ts b/packages/react-stately/src/calendar/useCalendarState.ts index c5df20dbe91..3150bb785ed 100644 --- a/packages/react-stately/src/calendar/useCalendarState.ts +++ b/packages/react-stately/src/calendar/useCalendarState.ts @@ -33,6 +33,7 @@ import { GregorianCalendar, isEqualCalendar, isSameDay, + minDate, startOfMonth, startOfWeek, toCalendar, @@ -230,7 +231,8 @@ export function useCalendarState< function normalizeValue(newValue: CalendarDate) { let constrained = constrainValue(newValue, minValue, maxValue); - let prev = previousAvailableDate(constrained, startDate, isDateUnavailable); + let lowerBound = minValue ?? minDate(constrained, startDate); + let prev = previousAvailableDate(constrained, lowerBound, isDateUnavailable); if (!prev) { return null; } diff --git a/packages/react-stately/src/tokenfield/TokenFieldValue.ts b/packages/react-stately/src/tokenfield/TokenFieldValue.ts index dd8ddd1c3c2..96ac3caddeb 100644 --- a/packages/react-stately/src/tokenfield/TokenFieldValue.ts +++ b/packages/react-stately/src/tokenfield/TokenFieldValue.ts @@ -31,13 +31,66 @@ export interface Position { offset: number; } +/** Represents a text selection in a TokenField. */ +export class SelectedRange { + /** The anchor position. */ + anchor: Position; + /** The current (i.e. caret) position. */ + current: Position; + + /** + * Creates a new selection range. If only a single position is provided, the selection is + * collapsed. + */ + constructor(anchor: Position, current: Position = anchor) { + this.anchor = anchor; + this.current = current; + } + + /** Whether the selection is collapsed to a single caret position. */ + get isCollapsed() { + return this.anchor.index === this.current.index && this.anchor.offset === this.current.offset; + } + + /** The side of the selection closest to the start of the value. */ + get start() { + return compare(this.anchor, this.current) < 0 ? this.anchor : this.current; + } + + /** The side of the selection closest to the end of the value. */ + get end() { + return compare(this.anchor, this.current) < 0 ? this.current : this.anchor; + } + + /** Returns whether this selection is equal to another. */ + isEqual(other: SelectedRange) { + if (this === other) { + return true; + } + return ( + this.anchor.index === other.anchor.index && + this.anchor.offset === other.anchor.offset && + this.current.index === other.current.index && + this.current.offset === other.current.offset + ); + } +} + +function compare(a: Position, b: Position) { + if (a.index === b.index) { + return a.offset - b.offset; + } + + return a.index - b.index; +} + enum Direction { Forward = 1, Backward = -1 } export interface TokenFieldValueOptions { - caretPosition?: Position | null; + selectedRange?: SelectedRange | null; } /** @@ -45,11 +98,12 @@ export interface TokenFieldValueOptions { */ export class TokenFieldValue { static readonly Direction = Direction; + static readonly SelectedRange = SelectedRange; /** The text and token segments in the list. */ readonly segments: readonly TokenFieldSegment[]; - /** The caret position. */ - caretPosition: Position = {index: 0, offset: 0}; + /** The selected range. */ + selectedRange: SelectedRange; // Linked list representing the undo/redo history. private previous: this | null = null; private next: this | null = null; @@ -58,7 +112,7 @@ export class TokenFieldValue { /** Create a new list with the given segments. */ constructor(tokens: readonly TokenFieldSegment[], options?: TokenFieldValueOptions) { this.segments = tokens; - this.caretPosition = options?.caretPosition ?? {index: 0, offset: 0}; + this.selectedRange = options?.selectedRange ?? new SelectedRange({index: 0, offset: 0}); } protected createFieldValue(segments: readonly TokenFieldSegment[]): this { @@ -69,23 +123,28 @@ export class TokenFieldValue { return new Constructor(segments); } + get caretPosition(): Position { + return this.selectedRange.current; + } + /** Create a new list with the caret position set to the given position. */ - withCaretPosition(caretPosition: Position): this { - if ( - this.caretPosition.index === caretPosition.index && - this.caretPosition.offset === caretPosition.offset - ) { + withSelectedRange(selectedRange: SelectedRange): this { + if (this.selectedRange.isEqual(selectedRange)) { return this; } let result = this.createFieldValue(this.segments); - result.caretPosition = caretPosition; + result.selectedRange = selectedRange; result.previous = this.previous; result.next = this.next; result.isCoalescing = this.isCoalescing; return result; } + withCaretPosition(position: Position): this { + return this.withSelectedRange(new SelectedRange(position)); + } + private splitSegment( segment: TokenFieldSegment | undefined, offset: number @@ -174,14 +233,14 @@ export class TokenFieldValue { appendSegments(newSegments, this.segments.slice(end.index + 1)); let segments = this.createFieldValue(newSegments); - segments.caretPosition = caret; + segments.selectedRange = new SelectedRange(caret); segments.isCoalescing = coalesce; if (this.isCoalescing && coalesce && this.previous) { segments.previous = this.previous; segments.previous.next = segments; } else { segments.previous = this; - this.caretPosition = end; + this.selectedRange = new SelectedRange(start, end); this.next = segments; } return segments; @@ -304,8 +363,7 @@ export class TokenFieldValue { ); } - this.caretPosition = position; - return this; + return this.withSelectedRange(new SelectedRange(position)); } /** Delete text to the next or previous line break. */ @@ -324,7 +382,7 @@ export class TokenFieldValue { ); } - return this; + return this.withSelectedRange(new SelectedRange(position)); } /** Create a new list containing a subset of the segments. */ diff --git a/packages/react-stately/test/calendar/useCalendarState.test.ts b/packages/react-stately/test/calendar/useCalendarState.test.ts new file mode 100644 index 00000000000..ca5a365e4ab --- /dev/null +++ b/packages/react-stately/test/calendar/useCalendarState.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {actHook as act, renderHook} from '@react-spectrum/test-utils-internal'; +import {CalendarDate, createCalendar} from '@internationalized/date'; +import {useCalendarState} from '../../src/calendar/useCalendarState'; + +describe('useCalendarState', () => { + describe('selectDate', () => { + // https://github.com/adobe/react-spectrum/issues/7779 + let selectedDate = new CalendarDate(2026, 4, 15); + + it('selects a date before the visible range when isDateUnavailable is provided', () => { + let {result} = renderHook(() => + useCalendarState({ + locale: 'en-US', + createCalendar, + isDateUnavailable: () => false, + defaultFocusedValue: selectedDate + }) + ); + + // Navigate to the next month so the selected date is before the visible range. + act(() => { + result.current.focusNextPage(); + }); + expect(result.current.visibleRange.start.compare(selectedDate)).toBeGreaterThan(0); + + // Selecting a date before the visible range should still work. + act(() => { + result.current.selectDate(selectedDate); + }); + + expect(result.current.value).not.toBeNull(); + expect(result.current.value!.compare(selectedDate)).toBe(0); + }); + + it('selects a date after the visible range when isDateUnavailable is provided', () => { + let {result} = renderHook(() => + useCalendarState({ + locale: 'en-US', + createCalendar, + isDateUnavailable: () => false, + defaultFocusedValue: selectedDate + }) + ); + + // Navigate to the previous month so the selected date is after the visible range. + act(() => { + result.current.focusPreviousPage(); + }); + expect(result.current.visibleRange.end.compare(selectedDate)).toBeLessThan(0); + + act(() => { + result.current.selectDate(selectedDate); + }); + + expect(result.current.value).not.toBeNull(); + expect(result.current.value!.compare(selectedDate)).toBe(0); + }); + + it('selects the nearest earlier available date when the selected date is unavailable', () => { + let {result} = renderHook(() => + useCalendarState({ + locale: 'en-US', + createCalendar, + isDateUnavailable: date => date.day === 15, + defaultFocusedValue: new CalendarDate(2026, 4, 20) + }) + ); + + // Day 15 is in the visible range, but unavailable. Day 14 is available. + act(() => { + result.current.selectDate(new CalendarDate(2026, 4, 15)); + }); + + expect(result.current.value).not.toBeNull(); + expect(result.current.value!.compare(new CalendarDate(2026, 4, 14))).toBe(0); + }); + + it('returns null when there is no available date between the selected date and the start of the visible range', () => { + let {result} = renderHook(() => + useCalendarState({ + locale: 'en-US', + createCalendar, + isDateUnavailable: date => date.day <= 15, + defaultFocusedValue: new CalendarDate(2026, 4, 20) + }) + ); + + // Every day from the start of the visible month through day 15 is unavailable. + act(() => { + result.current.selectDate(new CalendarDate(2026, 4, 15)); + }); + + expect(result.current.value).toBeNull(); + }); + }); +}); diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index d35139ef412..c302f5df80a 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -136,8 +136,11 @@ function iconWrapperPlugin(): Plugin { name: 'icon-wrapper', enforce: 'pre', resolveId(source) { - if (source.startsWith('@react-spectrum/s2/icons/')) { - const iconName = source.replace('@react-spectrum/s2/icons/', ''); + // Match both the bare specifier and the form produced after the + // `@react-spectrum/s2` -> exports alias rewrites it to `.../exports/icons/`. + const match = source.match(/(?:@react-spectrum\/s2|[\\/]exports)[\\/]icons[\\/](.+)$/); + if (match) { + const iconName = match[1]; if (iconMap.has(iconName)) { return VIRTUAL_PREFIX + iconName; }