snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
All checks were successful
Release / Create Version PR or Publish Stable Release (push) Has been skipped
Release / Publish Prerelease to NPM (push) Has been skipped
Monitor Registries / Monitor registry health (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 22:44:43 +05:00
commit 1416db152c
5727 changed files with 630238 additions and 0 deletions

2
packages/react/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
.turbo

View File

@@ -0,0 +1,29 @@
# @shadcn/react
## 0.3.1
### Patch Changes
- [#11720](https://github.com/shadcn-ui/ui/pull/11720) [`503a3a57aec9a3817e37f90aa0817b1fabd284d0`](https://github.com/shadcn-ui/ui/commit/503a3a57aec9a3817e37f90aa0817b1fabd284d0) Thanks [@shadcn](https://github.com/shadcn)! - Set `data-pending-scroll` on the MessageScroller root and viewport until `defaultScrollPosition` (`"end"` or `"last-anchor"`) is applied, so the viewport can be hidden and a server-rendered transcript does not flash the top of the thread on reload.
## 0.3.0
### Minor Changes
- [#11414](https://github.com/shadcn-ui/ui/pull/11414) [`3e54530e31020e2277df03490a30a08e3bc1792b`](https://github.com/shadcn-ui/ui/commit/3e54530e31020e2277df03490a30a08e3bc1792b) Thanks [@shadcn](https://github.com/shadcn)! - Add the Questionnaire primitive for multi-step questions, freeform answers, validation, and keyboard navigation.
## 0.2.1
### Patch Changes
- [#11106](https://github.com/shadcn-ui/ui/pull/11106) [`f6a26478547c7057aeda82516666dc3473dc39bd`](https://github.com/shadcn-ui/ui/commit/f6a26478547c7057aeda82516666dc3473dc39bd) Thanks [@shadcn](https://github.com/shadcn)! - Fix MessageScroller autoScroll around anchored turns: the anchor hold now hands off to follow-output once the streamed reply fills the viewport, content growth outrunning the frame-coalesced resize handler no longer releases follow-output, and the scroll button no longer flickers while follow-output closes the gap a streamed chunk just opened.
- [#11100](https://github.com/shadcn-ui/ui/pull/11100) [`dd6ce77cf1606eee2839368a97cc35417f763232`](https://github.com/shadcn-ui/ui/commit/dd6ce77cf1606eee2839368a97cc35417f763232) Thanks [@shadcn](https://github.com/shadcn)! - Fix MessageScroller abandoning a newly appended scrollAnchor placement when autoScroll is enabled, so the anchored turn holds at the reading line while the reply streams.
- [#11085](https://github.com/shadcn-ui/ui/pull/11085) [`628e427231ca4bb7f586a69f99fe7809749a53e2`](https://github.com/shadcn-ui/ui/commit/628e427231ca4bb7f586a69f99fe7809749a53e2) Thanks [@devnomic](https://github.com/devnomic)! - Fix MessageScroller firing "ResizeObserver loop completed with undelivered notifications" during streamed content growth by coalescing ResizeObserver callbacks into requestAnimationFrame.
## 0.2.0
### Minor Changes
- [#11022](https://github.com/shadcn-ui/ui/pull/11022) [`18fcf0f766857a7249cc0daac3c1609610edd158`](https://github.com/shadcn-ui/ui/commit/18fcf0f766857a7249cc0daac3c1609610edd158) Thanks [@shadcn](https://github.com/shadcn)! - initial release of @shadcn/react

21
packages/react/README.md Normal file
View File

@@ -0,0 +1,21 @@
# @shadcn/react
Unstyled components for React.
## Installation
```bash
npm install @shadcn/react
```
## Documentation
Visit https://ui.shadcn.com/docs to view the documentation.
## Contributing
Please read the [contributing guide](https://github.com/shadcn-ui/ui/blob/main/CONTRIBUTING.md).
## License
Licensed under the [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).

View File

@@ -0,0 +1,79 @@
{
"name": "@shadcn/react",
"version": "0.3.1",
"description": "Unstyled components for React.",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"author": {
"name": "shadcn",
"url": "https://twitter.com/shadcn"
},
"repository": {
"type": "git",
"url": "https://github.com/shadcn-ui/ui.git",
"directory": "packages/react"
},
"homepage": "https://ui.shadcn.com",
"bugs": {
"url": "https://github.com/shadcn-ui/ui/issues"
},
"files": [
"dist"
],
"keywords": [
"react",
"headless",
"unstyled",
"primitives",
"accessible",
"ui",
"shadcn"
],
"type": "module",
"sideEffects": false,
"exports": {
"./message-scroller": {
"types": "./dist/message-scroller/index.d.ts",
"default": "./dist/message-scroller/index.js"
},
"./questionnaire": {
"types": "./dist/questionnaire/index.d.ts",
"default": "./dist/questionnaire/index.js"
}
},
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist",
"test": "vitest run",
"test:browser": "vitest run -c vitest.browser.config.ts"
},
"peerDependencies": {
"@types/react": ">=19",
"react": ">=19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
},
"devDependencies": {
"@types/react-dom": "19.2.2",
"@vitest/browser": "^3.2.6",
"jsdom": "^28.1.0",
"playwright": "^1.61.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"rimraf": "^6.0.1",
"tsup": "^8.5.0",
"typescript": "^5.9.2",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^3.2.6"
}
}

View File

@@ -0,0 +1,176 @@
# MessageScroller — performance
MessageScroller is **not virtualized** by default. It renders every message as a real DOM
node and tracks scroll position with geometry reads on the scroll, resize, and
content-change paths. The question this doc answers — with a runnable benchmark,
not assertions of faith — is whether that design holds up for long, markdown-rich
transcripts.
**Verdict: yes, with wide headroom.** A scroll commit on 1000 messages blocks the
main thread for under 1ms; the same on 600 heavy-markdown items (64k DOM nodes)
is well under 1ms with zero dropped frames; and while a heavy reply streams in,
the scroller's own per-token work is ~1–2ms — a fraction of a frame on top of the
markdown render. The limiting factor at extreme scale is the un-virtualized row
count itself, not the scroller's work.
## Running it
```bash
# from packages/react
pnpm test:browser src/message-scroller/message-scroller.perf.browser.test.tsx
```
It runs in real Chromium via Playwright (`vitest.browser.config.ts`) and prints a
numbers table per layer. **It must run in a real browser** — the entire cost here
is forced layout, and jsdom stubs `getBoundingClientRect` to a free property
read, so a jsdom "benchmark" would measure nothing real. The file matches the
`*.browser.test.tsx` glob, so it also runs in CI (`browser-tests.yml`) and
doubles as a regression guard.
**The suite runs outside React's act environment.** Both browser test files set
`IS_REACT_ACT_ENVIRONMENT = false`. This component is driven by the event loop —
`requestAnimationFrame`, `ResizeObserver`, `IntersectionObserver` — which `act()`
cannot wrap, so the tests flush the renders they time with `flushSync` and settle
async scroll work on real frames. Do not reintroduce `act()` here: it re-adds the
spurious "update not wrapped in act(...)" warnings from the rAF-driven visibility
subscriber without changing what is measured.
## Why this is the right thing to measure
The scroller's hot-path cost is not the `getBoundingClientRect` _call_ — it is the
**forced synchronous reflow** that call triggers when layout is dirty. The
benchmark is built around two facts about when layout is and isn't dirty:
- **Scrolling does not dirty layout.** Changing `scrollTop` is a scroll-offset
change, not a layout invalidation, so during steady scroll `getBoundingClientRect`
reads already-computed boxes. Cost tracks the _number of items iterated_ (one
rect read per top-level row in `getContentBottom`), and is nearly blind to how
deep each row's subtree is. This is why heavy markdown barely changes the scroll
numbers.
- **Growth dirties layout.** When a streaming reply gets taller, the next geometry
read pays for a real reflow. That is the only path where DOM weight can bite, so
it gets its own layer and an A/B isolation against a plain scroll div.
## The three layers
### Layer 1 — algorithmic scaling (isolated)
Calls `getMessageScrollerScrollable` (which drives the O(n) `getContentBottom` scan) in
a tight loop against a real laid-out DOM, toggling `scrollTop` each iteration to
force the reflow that real scrolling forces. Answers: how does one scroll commit
scale with row count?
### Layer 2 — real scroll under load (the integration proof)
Mounts a large transcript with scroll-state and visibility both subscribed (a real
app), drives a top-to-bottom scroll, and times the **synchronous scroll handler**
(`handleScroll → syncAfterScroll → commitScrollState → getContentBottom` runs
inside `dispatchEvent`, so timing the dispatch is timing the exact main-thread
block a user scroll causes). Counts long tasks. Run both with trivial rows and
with heavy markdown rows.
### Layer 3 — streaming (the real-chat worst case)
Streams an assistant reply token-by-token at the bottom of a long transcript of
**memoized** heavy turns (so only the growing reply re-renders, like a real app).
Runs the identical growth inside MessageScroller (auto-follow) and inside a plain
scroll `<div>` (manual follow); the **delta** isolates the scroller's per-token
cost from the markdown's own render cost.
## Indicative numbers
> Measured on Apple Silicon, headless Chromium (Playwright), 2026-06. These are
> **indicative, not guarantees** — absolute timings drift with hardware and CI.
> The durable contract is the budget assertions below; the test is the source of
> truth. Re-run to get numbers for your machine.
**Layer 1 — per-commit cost vs. row count (trivial rows):**
| messages | median | p95 | max |
| -------- | ------ | ----- | ----- |
| 100 | 0.1ms | 0.1ms | 0.7ms |
| 500 | 0.3ms | 0.3ms | 1.2ms |
| 1000 | 0.5ms | 0.6ms | 1.9ms |
| 2000 | 1.1ms | 1.2ms | 4.6ms |
Linear in row count with a tiny constant. Extrapolated, a single commit alone
would not reach one 16ms frame until ~30,000 rendered rows.
**Layer 1 — per-commit cost vs. DOM weight (heavy markdown):**
| turns | items | DOM nodes | median | p95 |
| ----- | ----- | --------- | ------ | ----- |
| 50 | 100 | 10,509 | 0.1ms | 0.2ms |
| 150 | 300 | 31,996 | 0.2ms | 0.3ms |
| 300 | 600 | 64,135 | 0.4ms | 0.5ms |
64k DOM nodes barely moves the cost — it tracks item count, not subtree depth,
because scrolling doesn't dirty layout (see above).
**Layer 2 — real top-to-bottom scroll:**
| scenario | handler median | handler p95 | handler max | long tasks |
| --------------------------- | -------------- | ----------- | ----------- | ---------- |
| 1000 trivial messages | 0.9ms | 1.2ms | 2.1ms | 0 |
| 300 heavy turns (64k nodes) | 0.6ms | 0.8ms | 0.9ms | 0 |
**Layer 3 — streaming a heavy reply (per-token commit, `flushSync`-timed):**
| tree | median | p95 | max | long tasks |
| ---------------------- | ---------- | ----- | ----- | ---------- |
| plain `<div>` baseline | 1.2ms | 2.2ms | 2.3ms | 0 |
| MessageScroller | 3.2ms | 5.7ms | 8.3ms | 0 |
| **scroller overhead** | **~1–2ms** | | | |
Each token re-renders the growing reply; `flushSync` times that commit plus the
scroller's `handleContentChange` layout effect (re-scan items, follow-bottom).
The overhead over a plain `<div>` is the scroller's synchronous content-change
work — a consistent **~1–2ms per token**, a fraction of a frame, with zero long
tasks. (An earlier `act()`-wrapped version of this layer timed the whole async
flush and spuriously reported ~0/negative overhead; the `flushSync` timing is the
honest one.) The per-token total is still dominated by the markdown render, which
is the message component's cost, not the scroller's.
## Preventing regressions
The suite runs in CI on every PR that touches `packages/react/**`
(`.github/workflows/browser-tests.yml`). It guards against regressions two
ways, in order of strength:
### 1. Deterministic guard (the real one)
`getBoundingClientRect` is spied during a single scroll commit and the call count
is asserted to be **O(items), not O(items²)** — currently ~1.0 reads per item
(2030 reads for 2000 items). This count is **machine-independent and has zero
variance**, so it can be a tight bound: the classic perf regression (a nested rect
loop, an accidental per-item `getComputedStyle`) makes the per-item ratio scale
with item count and blows past the bound deterministically, where a timing test
would just shrug. This is the assertion to trust.
### 2. Timing budgets (coarse backstop)
Wall-clock assertions catch a catastrophic slowdown, but they are
hardware-dependent, so they carry generous slack and only flag multi-× blowups:
- Layer 1: per-commit median at the largest row count `< 16ms` (one frame).
- Layer 2: scroll-handler median `< 16ms`, p95 `< 32ms`.
- Layer 3: scroller per-token median `< 16ms`, and overhead over baseline `< 16ms`.
Do not tighten the timing budgets toward the indicative numbers — CI runner noise
will make them flake. If you want an earlier warning on gradual creep, prefer
adding more invariants to the deterministic guard (more counts, scaling-ratio
checks between two row counts measured in the same run) over shrinking the ms
thresholds.
## Boundaries and non-goals
- **No virtualization.** Cost is O(rendered rows). Realistic transcripts (hundreds
to low thousands of turns) have enormous headroom; tens of thousands of _live_
rows would eventually make `getContentBottom`'s all-items scan matter. The fix is
already scoped if ever needed: the last non-spacer child carries the max bottom,
so the scan can collapse to O(1).
- **Markdown render cost is out of scope.** Layer 3 shows per-token cost is the
content renderer's, not the scroller's. Mitigations (memoizing history, batching
tokens, virtualizing code blocks) belong to the message components.
- **Timings are machine-relative.** Don't copy these numbers into user-facing docs
as promises; cite the methodology and re-run.

View File

@@ -0,0 +1,73 @@
# MessageScroller
Headless scroll container for chat transcripts. Owns scroll position, anchoring,
auto-follow, and visibility tracking so message components can stay presentational.
## Usage
```tsx
import {
MessageScroller,
useMessageScroller,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
;<MessageScroller.Provider autoScroll>
<MessageScroller.Root>
<MessageScroller.Viewport>
<MessageScroller.Content>
<MessageScroller.Item messageId="m1" scrollAnchor>
…
</MessageScroller.Item>
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller.Root>
</MessageScroller.Provider>
```
## Exports
All from `@shadcn/react/message-scroller`.
### Parts
| Part | Role | Notable props |
| -------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `MessageScroller.Provider` | Headless root; owns scroll state, anchoring, auto-follow, and visibility | `autoScroll`, `defaultScrollPosition`, `scrollPreviousItemPeek`, `scrollMargin`, `scrollEdgeThreshold` |
| `MessageScroller.Root` | Styled frame; lays out the viewport, content, and controls in the provider | `data-pending-scroll` until the opening position applies |
| `MessageScroller.Viewport` | Scrollable frame | `preserveScrollOnPrepend`; `data-pending-scroll` until the opening position applies |
| `MessageScroller.Content` | Message list; defaults `role="log"` + `aria-relevant="additions"` | — |
| `MessageScroller.Item` | One message wrapper | `messageId`, `scrollAnchor` |
| `MessageScroller.Button` | Scroll-to-end/start affordance; auto-hides when caught up | `direction` |
### Hooks (flat siblings)
| Hook | Returns |
| -------------------------------- | --------------------------------------------------------------------------- |
| `useMessageScroller()` | `{ scrollToMessage, scrollToStart, scrollToEnd }` |
| `useMessageScrollerScrollable()` | `MessageScrollerScrollable` — `{ start, end }`, the edges the viewport can scroll toward |
| `useMessageScrollerVisibility()` | `MessageScrollerVisibilityState` — `currentAnchorId`, `visibleMessageIds` |
### Types
| Type | Meaning |
| -------------------------------------- | --------------------------------------------------------------------- |
| `MessageScrollerScrollable` | result of `useMessageScrollerScrollable()` |
| `MessageScrollerVisibilityState` | result of `useMessageScrollerVisibility()` |
| `MessageScrollerScrollOptions` | options for the scroll commands (`align`, `behavior`, `scrollMargin`) |
| `MessageScrollerScrollAlign` | `"start" \| "center" \| "end" \| "nearest"` |
| `MessageScrollerDefaultScrollPosition` | `"start" \| "end" \| "last-anchor"` |
## Tests
| File | Environment | Covers |
| ---------------------------------------- | ----------- | -------------------------------------------------------------------------- |
| `geometry.test.ts` | jsdom | Geometry math with stubbed rects. |
| `message-scroller.browser.test.tsx` | chromium | Behavior jsdom can't model (native scroll anchoring, prepend, visibility). |
| `message-scroller.perf.browser.test.tsx` | chromium | Performance benchmark + regression guard. |
```bash
pnpm test # unit (jsdom)
pnpm test:browser # behavior + performance (chromium)
```

View File

@@ -0,0 +1,446 @@
import * as React from "react"
import { composeRefs, mergeProps, useRender } from "../use-render"
import { USER_SCROLL_KEYS } from "./types"
import type {
MessageScrollerButtonProps,
MessageScrollerContentProps,
MessageScrollerContextValue,
MessageScrollerItemProps,
MessageScrollerProps,
MessageScrollerProviderProps,
MessageScrollerRegisterMessage,
MessageScrollerViewportProps,
} from "./types"
import { useMessageScrollerController } from "./use-message-scroller-controller"
import { useLatest } from "./utils"
const MessageScrollerContext =
React.createContext<MessageScrollerContextValue | null>(null)
const MessageScrollerItemContext =
React.createContext<MessageScrollerRegisterMessage | null>(null)
function useMessageScrollerContext() {
const context = React.useContext(MessageScrollerContext)
if (!context) {
throw new Error("useMessageScroller must be used within a MessageScroller.")
}
return context
}
function useMessageScrollerItemContext() {
const context = React.useContext(MessageScrollerItemContext)
if (!context) {
throw new Error(
"MessageScrollerItem must be used within a MessageScroller."
)
}
return context
}
function useMessageScroller() {
const { scrollToEnd, scrollToMessage, scrollToStart } =
useMessageScrollerContext()
return React.useMemo(
() => ({
scrollToEnd,
scrollToMessage,
scrollToStart,
}),
[scrollToEnd, scrollToMessage, scrollToStart]
)
}
function useMessageScrollerScrollable() {
const { stateStore } = useMessageScrollerContext()
return React.useSyncExternalStore(
stateStore.subscribe,
stateStore.getSnapshot,
stateStore.getSnapshot
)
}
function useMessageScrollerVisibility() {
const { observeVisibility, unobserveVisibility, visibilityStore } =
useMessageScrollerContext()
const subscribe = React.useCallback(
(listener: () => void) =>
visibilityStore.subscribe(
listener,
observeVisibility,
unobserveVisibility
),
[observeVisibility, unobserveVisibility, visibilityStore]
)
return React.useSyncExternalStore(
subscribe,
visibilityStore.getSnapshot,
visibilityStore.getSnapshot
)
}
function MessageScrollerProvider({
autoScroll = false,
children,
defaultScrollPosition = "end",
scrollEdgeThreshold,
scrollPreviousItemPeek,
scrollMargin,
}: MessageScrollerProviderProps) {
const { context, registerMessage } = useMessageScrollerController({
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollPreviousItemPeek,
scrollMargin,
})
return (
<MessageScrollerContext.Provider value={context}>
<MessageScrollerItemContext.Provider value={registerMessage}>
{children}
</MessageScrollerItemContext.Provider>
</MessageScrollerContext.Provider>
)
}
function usePendingDefaultScroll() {
const { pendingDefaultScrollStore } = useMessageScrollerContext()
return React.useSyncExternalStore(
pendingDefaultScrollStore.subscribe,
pendingDefaultScrollStore.getSnapshot,
pendingDefaultScrollStore.getSnapshot
)
}
function MessageScroller({ children, ...props }: MessageScrollerProps) {
const { setRootElement } = useMessageScrollerContext()
const pendingDefaultScroll = usePendingDefaultScroll()
return (
<div
ref={setRootElement}
{...props}
{...(pendingDefaultScroll ? { "data-pending-scroll": "" } : null)}
>
{children}
</div>
)
}
function MessageScrollerViewport({
"aria-label": ariaLabel,
children,
onKeyDown,
onScroll,
onTouchMove,
onWheel,
preserveScrollOnPrepend = true,
ref,
role,
tabIndex,
...props
}: MessageScrollerViewportProps) {
const {
handleResize,
preserveScrollOnPrependRef,
setViewportElement,
syncAfterScroll,
userScrollIntent,
viewportRef,
} = useMessageScrollerContext()
const pendingDefaultScroll = usePendingDefaultScroll()
preserveScrollOnPrependRef.current = preserveScrollOnPrepend
const setViewportRef = React.useCallback(
(element: HTMLDivElement | null) => {
setViewportElement(element)
composeRefs(ref)?.(element)
},
[ref, setViewportElement]
)
function handleScroll(event: React.UIEvent<HTMLDivElement>) {
syncAfterScroll()
onScroll?.(event)
}
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
userScrollIntent()
onWheel?.(event)
}
function handleTouchMove(event: React.TouchEvent<HTMLDivElement>) {
userScrollIntent()
onTouchMove?.(event)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (USER_SCROLL_KEYS.has(event.key)) {
userScrollIntent()
}
onKeyDown?.(event)
}
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || typeof ResizeObserver === "undefined") {
return
}
// Coalesce into rAF: handleResize mutates the spacer inside the observed
// content, and resizing an observed element during delivery fires
// "ResizeObserver loop completed with undelivered notifications".
let frame = 0
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(handleResize)
})
observer.observe(viewport)
return () => {
window.cancelAnimationFrame(frame)
observer.disconnect()
}
}, [handleResize, viewportRef])
return (
<div
ref={setViewportRef}
role={role ?? "region"}
aria-label={ariaLabel ?? "Messages"}
tabIndex={tabIndex ?? 0}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
onTouchMove={handleTouchMove}
onWheel={handleWheel}
{...props}
{...(pendingDefaultScroll ? { "data-pending-scroll": "" } : null)}
>
{children}
</div>
)
}
function MessageScrollerContent({
"aria-relevant": ariaRelevant,
children,
ref,
role,
spacerClassName,
...props
}: MessageScrollerContentProps) {
const {
handleContentChange,
handleResize,
setContentElement,
setSpacerElement,
} = useMessageScrollerContext()
const contentRef = React.useRef<HTMLDivElement | null>(null)
const setContentRef = React.useCallback(
(element: HTMLDivElement | null) => {
contentRef.current = element
setContentElement(element)
composeRefs(ref)?.(element)
},
[ref, setContentElement]
)
React.useLayoutEffect(() => {
const content = contentRef.current
if (!content) {
return
}
handleContentChange()
if (typeof MutationObserver === "undefined") {
return
}
const observer = new MutationObserver(() => {
handleContentChange()
})
observer.observe(content, { childList: true })
return () => observer.disconnect()
}, [handleContentChange])
React.useEffect(() => {
const content = contentRef.current
if (!content || typeof ResizeObserver === "undefined") {
return
}
// Coalesce into rAF: handleResize mutates the spacer inside this observed
// element, and resizing an observed element during delivery fires
// "ResizeObserver loop completed with undelivered notifications".
let frame = 0
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(handleResize)
})
observer.observe(content)
return () => {
window.cancelAnimationFrame(frame)
observer.disconnect()
}
}, [handleResize])
return (
<div
ref={setContentRef}
role={role ?? "log"}
aria-relevant={ariaRelevant ?? "additions"}
{...props}
>
{children}
<div
ref={setSpacerElement}
aria-hidden="true"
data-message-scroller-spacer=""
hidden
className={spacerClassName}
/>
</div>
)
}
function MessageScrollerItem({
messageId,
ref,
scrollAnchor = false,
...props
}: MessageScrollerItemProps) {
const registerMessage = useMessageScrollerItemContext()
const elementRef = React.useRef<HTMLDivElement | null>(null)
const setItemRef = React.useCallback(
(element: HTMLDivElement | null) => {
const previousElement = elementRef.current
elementRef.current = element
if (messageId) {
registerMessage(messageId, element, previousElement)
}
composeRefs(ref)?.(element)
},
[messageId, ref, registerMessage]
)
return (
<div
ref={setItemRef}
data-message-id={messageId}
data-scroll-anchor={scrollAnchor ? "true" : "false"}
{...props}
/>
)
}
function MessageScrollerButton({
behavior = "smooth",
children,
direction = "end",
onClick,
render,
tabIndex,
type = "button",
...props
}: MessageScrollerButtonProps) {
const { scrollToEnd, scrollToStart, stateStore } = useMessageScrollerContext()
const onClickRef = useLatest(onClick)
const subscribe = React.useCallback(
(listener: () => void) => stateStore.subscribe(listener),
[stateStore]
)
const getSnapshot = React.useCallback(() => {
const state = stateStore.getSnapshot()
return direction === "start" ? state.start : state.end
}, [direction, stateStore])
const isActive = React.useSyncExternalStore(
subscribe,
getSnapshot,
getSnapshot
)
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (!isActive) {
return
}
onClickRef.current?.(event)
if (!event.defaultPrevented) {
event.currentTarget.blur()
if (direction === "start") {
scrollToStart({ behavior })
} else {
scrollToEnd({ behavior })
}
}
},
[behavior, direction, isActive, onClickRef, scrollToEnd, scrollToStart]
)
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type,
inert: !isActive,
tabIndex: isActive ? tabIndex : -1,
children: children ?? <span>Scroll to {direction}</span>,
onClick: handleClick,
},
props
),
render,
state: {
active: isActive,
direction,
},
stateAttributesMapping: {
active: (value) => ({
"data-active": value ? "true" : "false",
}),
},
})
}
export {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}

View File

@@ -0,0 +1,451 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest"
import {
getContentBottom,
getElementScrollTop,
getLastScrollAnchor,
getMessageScrollerScrollable,
getNewScrollAnchor,
getUnanchoredScrollAnchor,
} from "./geometry"
// jsdom does not compute layout, so each element's rect, scroll position, and
// dimensions are stubbed directly. Rects are expressed in viewport (client)
// coordinates and shift with the owning viewport's scrollTop.
type RectInput = {
top: number
height: number
}
const cleanups: Array<() => void> = []
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.()
}
})
// Override window.getComputedStyle once per test so padding stubs read from
// data attributes set on the element.
function stubComputedStyle() {
const original = window.getComputedStyle
window.getComputedStyle = ((element: Element) => {
const style = original(element)
if (element instanceof HTMLElement && element.dataset.paddingStart) {
return {
...style,
gap: "0px",
rowGap: "0px",
paddingBlockStart: `${element.dataset.paddingStart}px`,
paddingTop: `${element.dataset.paddingStart}px`,
paddingBlockEnd: `${element.dataset.paddingEnd ?? "0"}px`,
paddingBottom: `${element.dataset.paddingEnd ?? "0"}px`,
} as CSSStyleDeclaration
}
return style
}) as typeof window.getComputedStyle
cleanups.push(() => {
window.getComputedStyle = original
})
}
function setRect(element: HTMLElement, rect: RectInput) {
element.getBoundingClientRect = () =>
({
top: rect.top,
bottom: rect.top + rect.height,
height: rect.height,
left: 0,
right: 0,
width: 0,
x: 0,
y: rect.top,
toJSON: () => ({}),
}) as DOMRect
}
function setClientHeight(element: HTMLElement, height: number) {
Object.defineProperty(element, "clientHeight", {
configurable: true,
get: () => height,
})
}
// Build a viewport/content/items fixture. The viewport rect is anchored at
// `viewportTop` in client space; item rects are given in viewport-relative
// terms and offset by viewportTop so getElementTop math lines up with scrollTop.
function createFixture(options: {
viewportTop?: number
viewportHeight: number
scrollTop?: number
scrollHeight?: number
contentPaddingStart?: number
contentPaddingEnd?: number
items: Array<{
messageId?: string
scrollAnchor?: boolean
top: number
height: number
}>
}) {
const viewportTop = options.viewportTop ?? 0
const scrollTop = options.scrollTop ?? 0
const viewport = document.createElement("div")
const content = document.createElement("div")
const spacer = document.createElement("div")
viewport.appendChild(content)
content.appendChild(spacer)
setRect(viewport, { top: viewportTop, height: options.viewportHeight })
setClientHeight(viewport, options.viewportHeight)
viewport.scrollTop = scrollTop
if (typeof options.scrollHeight === "number") {
Object.defineProperty(viewport, "scrollHeight", {
configurable: true,
get: () => options.scrollHeight as number,
})
}
if (typeof options.contentPaddingStart === "number") {
content.dataset.paddingStart = String(options.contentPaddingStart)
content.dataset.paddingEnd = String(options.contentPaddingEnd ?? 0)
}
const items = options.items.map((item) => {
const element = document.createElement("div")
if (item.messageId) {
element.dataset.messageId = item.messageId
}
element.dataset.scrollAnchor = item.scrollAnchor ? "true" : "false"
// Item rects are in client coordinates: viewport-relative top shifted by the
// viewport's own top so getElementTop adds back scrollTop correctly.
setRect(element, { top: viewportTop + item.top, height: item.height })
content.insertBefore(element, spacer)
return element
})
return { viewport, content, spacer, items }
}
function createItems(
specs: Array<{ id: string; anchor?: boolean }>
): HTMLElement[] {
return specs.map((spec) => {
const element = document.createElement("div")
element.dataset.messageId = spec.id
element.dataset.scrollAnchor = spec.anchor ? "true" : "false"
return element
})
}
describe("getElementScrollTop", () => {
it("aligns to start by subtracting content padding and margin", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 10,
contentPaddingEnd: 10,
items: [{ messageId: "a", top: 100, height: 50 }],
})
// elementTop = 100; minus padding.start (10) minus scrollMargin (5).
expect(
getElementScrollTop({
align: "start",
element: items[0],
scrollMargin: 5,
spacer,
viewport,
})
).toBe(85)
})
it("centers the element within the padded inset", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 20,
contentPaddingEnd: 20,
items: [{ messageId: "a", top: 300, height: 40 }],
})
// insetHeight = 200 - 20 - 20 = 160; offset = (160 - 40) / 2 = 60.
// elementTop (300) - padding.start (20) - 60 - scrollMargin (0) = 220.
expect(
getElementScrollTop({
align: "center",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(220)
})
it("aligns to end against the viewport bottom", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 0,
contentPaddingEnd: 8,
items: [{ messageId: "a", top: 300, height: 50 }],
})
// elementTop (300) - clientHeight (200) + height (50) + padding.end (8)
// + scrollMargin (0) = 158.
expect(
getElementScrollTop({
align: "end",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(158)
})
it("returns the current scrollTop when nearest and already visible", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
scrollTop: 50,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// Element top relative to viewport is 60, so in content space it sits at
// scrollTop (50) + 60 = 110, fully inside [50, 250].
items: [{ messageId: "a", top: 60, height: 40 }],
})
expect(
getElementScrollTop({
align: "nearest",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(50)
})
it("scrolls up to reveal an element above the viewport when nearest", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
scrollTop: 300,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// Relative top -100 means content-space top is 300 + (-100) = 200, which
// is above viewportTop (scrollTop 300), so it must scroll up to 200.
items: [{ messageId: "a", top: -100, height: 40 }],
})
expect(
getElementScrollTop({
align: "nearest",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(200)
})
})
describe("getContentBottom", () => {
it("returns padding sum when there are no items", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 12,
contentPaddingEnd: 8,
items: [],
})
expect(getContentBottom({ content, spacer, viewport })).toBe(20)
})
it("measures from the lowest item bottom plus end padding", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
scrollTop: 30,
contentPaddingStart: 0,
contentPaddingEnd: 16,
items: [
{ messageId: "a", top: 0, height: 100 },
{ messageId: "b", top: 100, height: 150 },
],
})
// Lowest item bottom in client space is 250; minus viewportRect.top (0)
// plus scrollTop (30) plus padding.end (16) = 296.
expect(getContentBottom({ content, spacer, viewport })).toBe(296)
})
it("excludes the spacer from the measurement", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 0,
contentPaddingEnd: 0,
items: [{ messageId: "a", top: 0, height: 80 }],
})
// Give the spacer a tall rect; it must not raise contentBottom past 80.
setRect(spacer, { top: 0, height: 9999 })
expect(getContentBottom({ content, spacer, viewport })).toBe(80)
})
})
describe("getNewScrollAnchor / getLastScrollAnchor", () => {
it("finds the first anchor at or after the previous item count", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b" },
{ id: "c", anchor: true },
{ id: "d", anchor: true },
])
// Starting from index 1 skips the anchor at index 0.
expect(getNewScrollAnchor(items, 1)).toBe(items[2])
})
it("returns null when no anchor exists after the boundary", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b" },
{ id: "c" },
])
expect(getNewScrollAnchor(items, 1)).toBeNull()
})
it("finds the last anchor scanning from the end", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b", anchor: true },
{ id: "c" },
])
expect(getLastScrollAnchor(items)).toBe(items[1])
})
it("returns null when there are no anchors", () => {
const items = createItems([{ id: "a" }, { id: "b" }])
expect(getLastScrollAnchor(items)).toBeNull()
})
})
describe("getUnanchoredScrollAnchor", () => {
it("returns the first anchor that has not been handled yet", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b", anchor: true },
])
const handled = new WeakSet<HTMLElement>()
expect(getUnanchoredScrollAnchor(items, handled)).toBe(items[0])
handled.add(items[0]!)
expect(getUnanchoredScrollAnchor(items, handled)).toBe(items[1])
})
it("returns null when every anchor has already been handled", () => {
const items = createItems([{ id: "a", anchor: true }])
const handled = new WeakSet<HTMLElement>([items[0]!])
expect(getUnanchoredScrollAnchor(items, handled)).toBeNull()
})
})
describe("getMessageScrollerScrollable", () => {
it("cannot scroll toward either edge when viewport or content is missing", () => {
const scrollable = getMessageScrollerScrollable({
content: null,
scrollEdgeThreshold: 8,
spacer: null,
viewport: null,
})
expect(scrollable).toMatchObject({
start: false,
end: false,
})
})
it("treats sub-threshold gaps at both edges as not scrollable", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
// scrollTop 5 <= threshold 8, so it cannot scroll toward the start.
scrollTop: 5,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// contentBottom 204; 204 - 5 - 200 = -1 <= 8, so it cannot scroll toward the end.
items: [{ messageId: "a", top: 199, height: 0 }],
})
const scrollable = getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
expect(scrollable).toMatchObject({
start: false,
end: false,
})
})
it("can scroll toward both edges past the threshold", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
// scrollTop 100 > threshold 8, so it can scroll toward the start.
scrollTop: 100,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// contentBottom 1000; 1000 - 100 - 200 = 700 > 8, so it can scroll toward the end.
items: [{ messageId: "a", top: 900, height: 100 }],
})
const scrollable = getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
expect(scrollable).toMatchObject({
start: true,
end: true,
})
})
})

View File

@@ -0,0 +1,387 @@
import {
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
SCROLL_POSITION_EPSILON,
} from "./types"
import type {
MessageScrollerScrollable,
MessageScrollerScrollAlign,
MessageScrollerVisibilityState,
} from "./types"
function getMessageScrollerScrollable({
content,
scrollEdgeThreshold,
spacer,
viewport,
}: {
content: HTMLElement | null
scrollEdgeThreshold: number
spacer: HTMLElement | null
viewport: HTMLElement | null
}): MessageScrollerScrollable {
if (!viewport || !content) {
return EMPTY_MESSAGE_SCROLLER_SCROLLABLE
}
const contentBottom = getContentBottom({ content, spacer, viewport })
return {
start: viewport.scrollTop > scrollEdgeThreshold,
end:
contentBottom - viewport.scrollTop - viewport.clientHeight >
scrollEdgeThreshold,
}
}
function getMessageScrollerVisibilityState({
content,
scrollMargin,
scrollPreviousItemPeek,
spacer,
viewport,
visibleMessageIds,
}: {
content: HTMLElement | null
scrollMargin: number
scrollPreviousItemPeek: number
spacer: HTMLElement | null
viewport: HTMLElement | null
visibleMessageIds: Set<string>
}): MessageScrollerVisibilityState {
if (!content || !viewport) {
return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
}
const viewportRect = viewport.getBoundingClientRect()
// The reading line sits scrollPreviousItemPeek below scrollMargin: anchored
// turns land there with the previous turn peeking above. A row only peeking in
// that band has not been read down to yet, so it counts as neither visible nor
// current.
const lineTop = viewportRect.top + scrollMargin + scrollPreviousItemPeek
const trackByLayout = typeof IntersectionObserver === "undefined"
const visible: string[] = []
let currentAnchorId: string | null = null
// Walk rows in document order so visible ids come out top-to-bottom.
for (const item of getMessageScrollerItems(content, spacer)) {
const messageId = item.dataset.messageId
if (!messageId) {
continue
}
const isAnchor = item.dataset.scrollAnchor === "true"
// Anchors need a rect to place the current line; non-anchors lean on the
// observer set (or a rect in the no-observer fallback).
const rect = isAnchor || trackByLayout ? item.getBoundingClientRect() : null
const isVisible =
trackByLayout && rect
? rect.bottom > lineTop && rect.top < viewportRect.bottom
: visibleMessageIds.has(messageId)
if (isVisible) {
visible.push(messageId)
}
// Current is the last anchor to have reached the reading line: the turn you
// scrolled to (placed at the line) wins over newer turns lower down, the
// previous turn peeking above the line has been passed, and it stays current
// even after its header scrolls above the viewport.
if (isAnchor && rect && rect.top <= lineTop + SCROLL_POSITION_EPSILON) {
currentAnchorId = messageId
}
}
if (visible.length === 0 && currentAnchorId === null) {
return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
}
return {
currentAnchorId,
visibleMessageIds: visible,
}
}
function getMessageScrollerItems(
content: HTMLElement,
spacer: HTMLElement | null
) {
return Array.from(content.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement && child !== spacer
)
}
function getNewScrollAnchor(items: HTMLElement[], previousItemCount: number) {
for (let index = previousItemCount; index < items.length; index++) {
const item = items[index]
if (item?.dataset.scrollAnchor === "true") {
return item
}
}
return null
}
function getUnanchoredScrollAnchor(
items: HTMLElement[],
handledAnchors: { has(element: HTMLElement): boolean }
) {
for (const item of items) {
if (item.dataset.scrollAnchor === "true" && !handledAnchors.has(item)) {
return item
}
}
return null
}
function hasMultipleNewScrollAnchors(
items: HTMLElement[],
previousItemCount: number
) {
let count = 0
for (let index = previousItemCount; index < items.length; index++) {
const item = items[index]
if (item?.dataset.scrollAnchor !== "true") {
continue
}
count += 1
if (count > 1) {
return true
}
}
return false
}
function getLastScrollAnchor(items: HTMLElement[]) {
for (let index = items.length - 1; index >= 0; index--) {
const item = items[index]
if (item?.dataset.scrollAnchor === "true") {
return item
}
}
return null
}
function getFirstVisibleMessageItem({
content,
spacer,
viewport,
}: {
content: HTMLElement
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const viewportRect = viewport.getBoundingClientRect()
for (const item of getMessageScrollerItems(content, spacer)) {
if (!item.dataset.messageId) {
continue
}
const rect = item.getBoundingClientRect()
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) {
return item
}
}
return null
}
function getElementScrollTop({
align,
element,
scrollMargin,
spacer,
viewport,
}: {
align: MessageScrollerScrollAlign
element: HTMLElement
scrollMargin: number
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const elementTop = getElementTop(element, viewport)
const elementHeight = element.getBoundingClientRect().height
const contentPadding = getContentBlockPadding(spacer)
if (align === "center") {
const insetHeight = Math.max(
0,
viewport.clientHeight - contentPadding.start - contentPadding.end
)
return (
elementTop -
contentPadding.start -
(insetHeight - elementHeight) / 2 -
scrollMargin
)
}
if (align === "end") {
return (
elementTop -
viewport.clientHeight +
elementHeight +
contentPadding.end +
scrollMargin
)
}
if (align === "nearest") {
const elementBottom = elementTop + elementHeight
const viewportTop = viewport.scrollTop + contentPadding.start
const viewportBottom =
viewport.scrollTop + viewport.clientHeight - contentPadding.end
if (elementTop >= viewportTop && elementBottom <= viewportBottom) {
return viewport.scrollTop
}
if (elementTop < viewportTop) {
return elementTop - contentPadding.start - scrollMargin
}
return (
elementBottom - viewport.clientHeight + contentPadding.end + scrollMargin
)
}
return elementTop - contentPadding.start - scrollMargin
}
function getElementTop(element: HTMLElement, viewport: HTMLElement) {
const elementRect = element.getBoundingClientRect()
const viewportRect = viewport.getBoundingClientRect()
return elementRect.top - viewportRect.top + viewport.scrollTop
}
function getElementViewportTop(element: HTMLElement, viewport: HTMLElement) {
return (
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
)
}
function getTailSpacerHeight({
content,
scrollTop,
spacer,
viewport,
}: {
content: HTMLElement
scrollTop: number
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const contentBottom = getContentBottom({ content, spacer, viewport })
return scrollTop + viewport.clientHeight - contentBottom
}
function getContentBottom({
content,
spacer,
viewport,
}: {
content: HTMLElement
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const items = getMessageScrollerItems(content, spacer)
const padding = getBlockPadding(content)
const viewportRect = viewport.getBoundingClientRect()
const scrollTop = viewport.scrollTop
let contentBottom = padding.start + padding.end
for (const item of items) {
const rect = item.getBoundingClientRect()
contentBottom = Math.max(
contentBottom,
rect.bottom - viewportRect.top + scrollTop + padding.end
)
}
return contentBottom
}
function getMaxScrollTop(viewport: HTMLElement) {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight)
}
function getBlockPadding(element: HTMLElement) {
const style = window.getComputedStyle(element)
return {
end: readCssPixel(style.paddingBlockEnd || style.paddingBottom),
start: readCssPixel(style.paddingBlockStart || style.paddingTop),
}
}
function getContentBlockPadding(spacer: HTMLElement | null) {
const content = spacer?.parentElement
if (!content) {
return {
end: 0,
start: 0,
}
}
return getBlockPadding(content)
}
function getFlexGap(element: HTMLElement | null) {
if (!element) {
return 0
}
const style = window.getComputedStyle(element)
const gap = style.rowGap === "normal" ? style.gap : style.rowGap
return readCssPixel(gap)
}
function readCssPixel(value: string | undefined) {
if (!value) {
return 0
}
const number = Number.parseFloat(value)
return Number.isFinite(number) ? number : 0
}
export {
getContentBlockPadding,
getContentBottom,
getElementScrollTop,
getElementTop,
getElementViewportTop,
getFirstVisibleMessageItem,
getFlexGap,
getLastScrollAnchor,
getMaxScrollTop,
getMessageScrollerItems,
getMessageScrollerScrollable,
getMessageScrollerVisibilityState,
getNewScrollAnchor,
getTailSpacerHeight,
getUnanchoredScrollAnchor,
hasMultipleNewScrollAnchors,
}

View File

@@ -0,0 +1,31 @@
import {
MessageScrollerButton as Button,
MessageScrollerContent as Content,
MessageScrollerItem as Item,
MessageScrollerProvider as Provider,
MessageScroller as Root,
MessageScrollerViewport as Viewport,
} from "./components"
export const MessageScroller = {
Provider,
Root,
Viewport,
Content,
Item,
Button,
}
export {
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "./components"
export type {
MessageScrollerDefaultScrollPosition,
MessageScrollerScrollAlign,
MessageScrollerScrollOptions,
MessageScrollerScrollable,
MessageScrollerVisibilityState,
} from "./types"

View File

@@ -0,0 +1,698 @@
import * as React from "react"
import { flushSync } from "react-dom"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, expect, test } from "vitest"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerVisibility,
} from "."
const MessageScrollerProvider = MessageScrollerPrimitive.Provider
const MessageScroller = MessageScrollerPrimitive.Root
const MessageScrollerViewport = MessageScrollerPrimitive.Viewport
const MessageScrollerContent = MessageScrollerPrimitive.Content
const MessageScrollerItem = MessageScrollerPrimitive.Item
const MessageScrollerButton = MessageScrollerPrimitive.Button
// Real-browser regression for the prepend double-compensation bug. In Chromium
// native scroll anchoring shifts scrollTop on prepend; the component's restore
// must NOT compensate again. This is invisible to jsdom: with no native
// anchoring there, the viewport-relative and content-relative measurements
// produce the same number, so the bug and the fix look identical. Only a real
// engine with scroll anchoring can tell them apart.
// This component is driven by the event loop — requestAnimationFrame,
// ResizeObserver, IntersectionObserver — none of which act() can wrap. The suite
// flushes renders synchronously with flushSync and settles the async scroll work
// on real frames via settle(), so it runs OUTSIDE the act environment that
// vitest.browser.setup.ts enables globally (vitest isolates files, so other
// suites keep it on). Without this, the rAF-driven visibility subscriber logs
// spurious "not wrapped in act" warnings even though the assertions, which read
// only after settle(), are correct.
;(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = false
const ITEM_HEIGHT = 80
const VIEWPORT_HEIGHT = 200
type TestItem = {
height?: number
id: string
scrollAnchor?: boolean
}
let root: Root | null = null
let container: HTMLDivElement | null = null
afterEach(() => {
root?.unmount()
container?.remove()
root = null
container = null
})
function Thread({
autoScroll,
defaultScrollPosition,
items,
scrollPreviousItemPeek,
showButton = false,
showJumpButton = false,
showVisibility = false,
}: {
autoScroll?: boolean
defaultScrollPosition?: React.ComponentProps<
typeof MessageScrollerProvider
>["defaultScrollPosition"]
items: TestItem[]
scrollPreviousItemPeek?: number
showButton?: boolean
showJumpButton?: boolean
showVisibility?: boolean
}) {
return (
<MessageScrollerProvider
autoScroll={autoScroll}
defaultScrollPosition={defaultScrollPosition}
scrollPreviousItemPeek={scrollPreviousItemPeek}
>
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{items.map((item) => (
<MessageScrollerItem
key={item.id}
messageId={item.id}
scrollAnchor={item.scrollAnchor}
style={{
height: item.height ?? ITEM_HEIGHT,
flex: "none",
}}
>
{item.id}
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
{showButton ? (
<MessageScrollerButton behavior="auto">
Scroll to end
</MessageScrollerButton>
) : null}
{showJumpButton ? <JumpButton messageId="m5" /> : null}
{showVisibility ? <VisibilityProbe /> : null}
</MessageScroller>
</MessageScrollerProvider>
)
}
function VisibilityProbe() {
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
return (
<div
data-testid="visibility"
data-current-anchor={currentAnchorId ?? ""}
data-visible={visibleMessageIds.join(",")}
/>
)
}
const MemoMessageItem = React.memo(function MemoMessageItem({
id,
anchor,
}: {
id: string
anchor: boolean
}) {
return (
<MessageScrollerItem
messageId={id}
scrollAnchor={anchor}
style={{ height: ITEM_HEIGHT, flex: "none" }}
>
{id}
</MessageScrollerItem>
)
})
function JumpButton({ messageId }: { messageId: string }) {
const { scrollToMessage } = useMessageScroller()
return (
<button
type="button"
onClick={() => {
scrollToMessage(messageId, { align: "start", behavior: "auto" })
}}
>
Jump to message
</button>
)
}
// Resolve after a few real animation frames so the component's rAF-scheduled
// scroll work and the browser's native anchoring both settle.
function settle(frames = 4) {
return new Promise<void>((resolve) => {
let remaining = frames
const tick = () =>
remaining-- <= 0 ? resolve() : requestAnimationFrame(tick)
requestAnimationFrame(tick)
})
}
function viewportOffsetOf(messageId: string, viewport: HTMLElement) {
const item = document.querySelector(
`[data-message-id="${messageId}"]`
) as HTMLElement
return Math.round(
item.getBoundingClientRect().top - viewport.getBoundingClientRect().top
)
}
function getViewport() {
return document.querySelector('[aria-label="viewport"]') as HTMLElement
}
function getDistanceToBottom(viewport: HTMLElement) {
return Math.round(
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
)
}
function getScrollTop(viewport: HTMLElement) {
return Math.round(viewport.scrollTop)
}
function getCurrentAnchor() {
return document
.querySelector('[data-testid="visibility"]')!
.getAttribute("data-current-anchor")
}
function getVisibleIds() {
const value =
document
.querySelector('[data-testid="visibility"]')!
.getAttribute("data-visible") ?? ""
return value ? value.split(",") : []
}
async function renderThread(props: React.ComponentProps<typeof Thread>) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(<Thread {...props} />)
})
await settle()
}
function createItems(count: number) {
return Array.from({ length: count }, (_, index) => ({
id: `m${index}`,
}))
}
test("keeps the visible message in place when older messages are prepended", async () => {
const initial = createItems(8)
await renderThread({ items: initial })
const viewport = getViewport()
// Scroll into the middle so there is content above and below; m3 lands at the
// top of the viewport and becomes the preserved anchor.
viewport.scrollTop = 3 * ITEM_HEIGHT
await settle()
const offsetBefore = viewportOffsetOf("m3", viewport)
// Prepend three older rows above the current scroll position.
flushSync(() => {
root!.render(
<Thread items={[{ id: "o0" }, { id: "o1" }, { id: "o2" }, ...initial]} />
)
})
await settle()
const offsetAfter = viewportOffsetOf("m3", viewport)
// The tracked message must not move within the viewport. With the old
// content-relative restore it jumps up by the prepended height (~240px).
expect(Math.abs(offsetAfter - offsetBefore)).toBeLessThanOrEqual(1)
})
test("opens at the bottom by default", async () => {
await renderThread({ items: createItems(8) })
expect(getDistanceToBottom(getViewport())).toBeLessThanOrEqual(1)
})
test("keeps auto-scroll pinned when the final message grows", async () => {
const initial = createItems(6)
await renderThread({ autoScroll: true, items: initial })
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(
<Thread
autoScroll
items={initial.map((item, index) =>
index === initial.length - 1 ? { ...item, height: 240 } : item
)}
/>
)
})
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
})
test("keeps the end pinned when bulk appending anchored turns with autoScroll", async () => {
const initial = createItems(6)
await renderThread({ autoScroll: true, items: initial })
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(
<Thread
autoScroll
items={[
...initial,
{ id: "new-user-1", scrollAnchor: true },
{ id: "new-assistant-1" },
{ id: "new-user-2", scrollAnchor: true },
{ id: "new-assistant-2" },
]}
/>
)
})
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
})
test("does not keep the end pinned when appending after the default bottom open", async () => {
const initial = createItems(6)
await renderThread({ items: initial })
const viewport = getViewport()
const scrollTop = getScrollTop(viewport)
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(<Thread items={[...initial, { id: "new" }]} />)
})
await settle()
expect(getScrollTop(viewport)).toBe(scrollTop)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
})
test("scroll button moves the viewport to the end", async () => {
await renderThread({
defaultScrollPosition: "start",
items: createItems(8),
showButton: true,
})
const viewport = getViewport()
expect(getScrollTop(viewport)).toBe(0)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
const button = document.querySelector("button") as HTMLButtonElement
button.click()
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
})
test("restores the last scroll anchor when the final turn overflows", async () => {
await renderThread({
defaultScrollPosition: "last-anchor",
items: [
{ id: "m0" },
{ id: "m1" },
{ id: "last-user", scrollAnchor: true },
{ id: "last-assistant", height: 360 },
],
scrollPreviousItemPeek: 0,
})
const viewport = getViewport()
expect(viewportOffsetOf("last-user", viewport)).toBeLessThanOrEqual(1)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
})
test("falls back to the end when the last anchored turn fits", async () => {
await renderThread({
defaultScrollPosition: "last-anchor",
items: [
...createItems(5),
{ id: "last-user", scrollAnchor: true },
{ id: "last-assistant" },
],
scrollPreviousItemPeek: 0,
})
expect(getDistanceToBottom(getViewport())).toBeLessThanOrEqual(1)
})
test("scrolls to a mounted message by id", async () => {
await renderThread({
defaultScrollPosition: "start",
items: createItems(8),
showJumpButton: true,
})
const viewport = getViewport()
const button = document.querySelector("button") as HTMLButtonElement
button.click()
await settle()
expect(viewportOffsetOf("m5", viewport)).toBeLessThanOrEqual(1)
})
test("preserves a scrolled-to turn across a prepend (command-path anchor)", async () => {
// Characterization lock for the Phase-5 anchor-ref boundary: scrollToMessage
// sets the preserve anchor OUTSIDE a content change (the command path), unlike
// every other prepend test which scrolls directly. Jumping then prepending
// must keep the jumped-to turn fixed, not lose its anchor.
const initial = createItems(12)
await renderThread({
defaultScrollPosition: "start",
items: initial,
showJumpButton: true,
})
const viewport = getViewport()
;(document.querySelector("button") as HTMLButtonElement).click()
await settle()
const offsetBefore = viewportOffsetOf("m5", viewport)
expect(offsetBefore).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(
<Thread
defaultScrollPosition="start"
items={[{ id: "o0" }, { id: "o1" }, { id: "o2" }, ...initial]}
showJumpButton
/>
)
})
await settle()
expect(
Math.abs(viewportOffsetOf("m5", viewport) - offsetBefore)
).toBeLessThanOrEqual(1)
})
test("user scroll intent cancels follow-bottom", async () => {
const initial = createItems(8)
await renderThread({ autoScroll: true, items: initial })
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
viewport.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: -ITEM_HEIGHT })
)
viewport.scrollTop = 0
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
await settle()
expect(getScrollTop(viewport)).toBe(0)
flushSync(() => {
root!.render(<Thread autoScroll items={[...initial, { id: "new" }]} />)
})
await settle()
expect(getScrollTop(viewport)).toBe(0)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
})
test("tracks the current anchor as it scrolls above the viewport", async () => {
// Every tenth row is a turn-start anchor: m0, m10, m20.
const items = Array.from({ length: 30 }, (_, index) => ({
id: `m${index}`,
scrollAnchor: index % 10 === 0,
}))
await renderThread({
defaultScrollPosition: "start",
items,
showVisibility: true,
})
const viewport = getViewport()
// Opened at the top → the first anchor is current. The subscribed probe
// settles on a post-mount rAF, so the value is only correct after the settle()
// that renderThread awaits.
expect(getCurrentAnchor()).toBe("m0")
// Scroll until m10 is fully above the viewport. It must stay current even
// though it is no longer visible — the behavior the old activeMessageId (and
// jsdom) could not express.
viewport.scrollTop = 900
await settle()
expect(getCurrentAnchor()).toBe("m10")
expect(getVisibleIds()).not.toContain("m10")
// Scrolling past the next anchor advances the cursor.
viewport.scrollTop = 1700
await settle()
expect(getCurrentAnchor()).toBe("m20")
})
test("keeps the anchor at the reading line current over lower visible anchors", async () => {
// Every row is a turn-start anchor, so several are on screen at once — the
// scrollToMessage case. Jumping to a turn must keep IT current even though
// newer anchors sit below it and are still visible (the regression where the
// lowest visible anchor wrongly stole "current").
const items = Array.from({ length: 12 }, (_, index) => ({
id: `m${index}`,
scrollAnchor: true,
}))
await renderThread({
defaultScrollPosition: "start",
items,
showVisibility: true,
})
const viewport = getViewport()
// Bring m5 up to the reading line; m6 and m7 sit below it, also visible.
viewport.scrollTop = 5 * ITEM_HEIGHT
await settle()
expect(getCurrentAnchor()).toBe("m5")
// m6 is on screen but below the line — it must not steal current.
expect(getVisibleIds()).toContain("m6")
})
test("visibility populates under StrictMode (frame ref + lifecycle survive remount)", async () => {
const ids = Array.from({ length: 8 }, (_, index) => `m${index}`)
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
// StrictMode double-invokes mount → unmount → remount on the same refs, which
// is what wedges the rAF scheduler and tears the subscription in Next dev.
flushSync(() => {
root!.render(
<React.StrictMode>
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{ids.map((id, index) => (
<MemoMessageItem key={id} id={id} anchor={index % 2 === 0} />
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<VisibilityProbe />
</MessageScroller>
</MessageScrollerProvider>
</React.StrictMode>
)
})
await settle()
// Force a recompute after StrictMode's mount churn.
getViewport().scrollTop = 0
getViewport().dispatchEvent(new Event("scroll", { bubbles: true }))
await settle()
expect(getCurrentAnchor()).toBe("m0")
expect(getVisibleIds().length).toBeGreaterThan(0)
})
test("tracks visibility through memoized item components", async () => {
const ids = Array.from({ length: 8 }, (_, index) => `m${index}`)
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{ids.map((id, index) => (
<MemoMessageItem key={id} id={id} anchor={index % 2 === 0} />
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<VisibilityProbe />
</MessageScroller>
</MessageScrollerProvider>
)
})
await settle()
// currentAnchorId scans the DOM (memo-independent). visibleMessageIds rides
// registration + the real IntersectionObserver — the path memo could break.
expect(getCurrentAnchor()).toBe("m0")
expect(getVisibleIds().length).toBeGreaterThan(0)
expect(getVisibleIds()).toContain("m0")
})
test("an anchored turn holds at the top when content below it collapses", async () => {
const peek = 32
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
const base = [
{ id: "m0", height: 300 },
{ id: "m1", height: 300 },
{ id: "m2", height: 300 },
]
flushSync(() => {
root!.render(<Thread items={base} scrollPreviousItemPeek={peek} />)
})
await settle()
// A new turn (anchor) arrives with a transient marker below it — like the
// demo's "Thinking..." placeholder — so it has content below to reach the top.
flushSync(() => {
root!.render(
<Thread
scrollPreviousItemPeek={peek}
items={[
...base,
{ id: "turn", height: 80, scrollAnchor: true },
{ id: "marker", height: 100 },
]}
/>
)
})
await settle()
expect(viewportOffsetOf("turn", getViewport())).toBeLessThanOrEqual(peek + 4)
// The marker is replaced by an empty reply that will stream in: the content
// below the turn collapses. The turn must stay pinned at the top, not drop as
// the browser clamps scrollTop to the shorter content.
flushSync(() => {
root!.render(
<Thread
scrollPreviousItemPeek={peek}
items={[
...base,
{ id: "turn", height: 80, scrollAnchor: true },
{ id: "reply", height: 0 },
]}
/>
)
})
await settle()
await settle()
expect(viewportOffsetOf("turn", getViewport())).toBeLessThanOrEqual(peek + 4)
})
test("auto-scroll and content updates survive a StrictMode remount", async () => {
// StrictMode mounts, unmounts, then remounts on the same refs. The existing
// StrictMode test covers the visibility frame; this one covers the rest of the
// machinery — the state + scroll frame refs must reset on the remount so
// follow-bottom keeps working, not just on first mount.
const items = createItems(6)
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(
<React.StrictMode>
<Thread autoScroll items={items} showVisibility />
</React.StrictMode>
)
})
await settle()
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
// A new message after the remount still drives the content-change + scroll path.
flushSync(() => {
root!.render(
<React.StrictMode>
<Thread autoScroll items={[...items, { id: "new" }]} showVisibility />
</React.StrictMode>
)
})
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
expect(getVisibleIds().length).toBeGreaterThan(0)
})

View File

@@ -0,0 +1,841 @@
import * as React from "react"
import { flushSync } from "react-dom"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, expect, test } from "vitest"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScrollerVisibility,
} from "."
import { getMessageScrollerScrollable } from "./geometry"
const MessageScrollerProvider = MessageScrollerPrimitive.Provider
const MessageScroller = MessageScrollerPrimitive.Root
const MessageScrollerViewport = MessageScrollerPrimitive.Viewport
const MessageScrollerContent = MessageScrollerPrimitive.Content
const MessageScrollerItem = MessageScrollerPrimitive.Item
// Performance proof for MessageScroller at scale. Two layers:
//
// Layer 1 isolates the algorithm: getMessageScrollerScrollable() drives the O(n)
// getContentBottom() scan that runs on every scroll commit. We measure its cost
// against a real laid-out DOM as message count grows, forcing a reflow each
// iteration the way real scrolling does.
//
// Layer 2 is the integration proof: a real top-to-bottom scroll on a large
// transcript, timing the synchronous scroll handler (where the geometry runs)
// and the resulting frame cadence, with both scroll state and visibility
// subscribed like a real chat app.
//
// jsdom cannot run either: it stubs getBoundingClientRect to a free property
// read, so the forced layout that IS the cost here would not exist.
const ITEM_HEIGHT = 48
const VIEWPORT_HEIGHT = 480
const FRAME_BUDGET = 16 // One 60fps frame in ms.
let root: Root | null = null
let container: HTMLDivElement | null = null
// Performance benchmarks are not React act() tests: they drive real
// requestAnimationFrame / IntersectionObserver and measure wall-clock, and never
// assert on rendered output. Opt this file out of the act environment that
// vitest.browser.setup.ts enables globally — vitest isolates files, so the
// behavior tests keep it on. Renders we time are flushed synchronously with
// flushSync (which runs layout effects in-band); everything else settles on real
// frames. Without this flag, the live visibility subscriber's rAF/IO-driven store
// updates — which act() cannot wrap — would log spurious "not wrapped in act"
// warnings.
;(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = false
afterEach(() => {
root?.unmount()
container?.remove()
root = null
container = null
})
function VisibilityProbe() {
// Subscribing turns on the visibility recompute path, so Layer 2 reflects a
// real app that reads currentAnchorId, not just the scroll-state path.
const { currentAnchorId } = useMessageScrollerVisibility()
return (
<div data-testid="visibility" data-current-anchor={currentAnchorId ?? ""} />
)
}
function Thread({
count,
showVisibility = false,
}: {
count: number
showVisibility?: boolean
}) {
return (
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{Array.from({ length: count }, (_, index) => (
<MessageScrollerItem
key={index}
messageId={`m${index}`}
// Every eighth row is a turn-start anchor, like real transcripts.
scrollAnchor={index % 8 === 0}
style={{ height: ITEM_HEIGHT, flex: "none" }}
>
{`m${index}`}
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
{showVisibility ? <VisibilityProbe /> : null}
</MessageScroller>
</MessageScrollerProvider>
)
}
function nextFrame() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
async function mountThread(count: number, showVisibility = false) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(<Thread count={count} showVisibility={showVisibility} />)
})
// Let the component's mount-time scroll work settle before measuring.
await nextFrame()
await nextFrame()
await nextFrame()
const viewport = document.querySelector(
'[aria-label="viewport"]'
) as HTMLElement
const content = viewport.querySelector('[role="log"]') as HTMLElement
const spacer = content.querySelector(
"[data-message-scroller-spacer]"
) as HTMLElement
return { content, spacer, viewport }
}
function median(values: number[]) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
function percentile(values: number[], p: number) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[
Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))
]
}
function round(value: number) {
return Math.round(value * 1000) / 1000
}
test("Layer 1 — getContentBottom scan stays sub-frame as message count grows", async () => {
const counts = [100, 500, 1000, 2000]
const iterations = 200
const rows: Array<{
n: number
medianMs: number
p95Ms: number
maxMs: number
}> = []
for (const n of counts) {
const { content, spacer, viewport } = await mountThread(n)
const samples: number[] = []
// Warm up so JIT and first-layout costs do not pollute the samples.
for (let i = 0; i < 20; i++) {
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
}
for (let i = 0; i < iterations; i++) {
// Toggle scrollTop to dirty layout, so the getBoundingClientRect calls
// inside getMessageScrollerScrollable force a real reflow — exactly what
// happens frame-to-frame during a real scroll. Without this the browser
// would serve cached rects and the benchmark would measure nothing.
viewport.scrollTop = 1000 + (i % 2)
const start = performance.now()
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
samples.push(performance.now() - start)
}
rows.push({
n,
medianMs: round(median(samples)),
p95Ms: round(percentile(samples, 95)),
maxMs: round(Math.max(...samples)),
})
root?.unmount()
container?.remove()
root = null
container = null
}
// eslint-disable-next-line no-console
console.log(
"\n[Layer 1] getMessageScrollerScrollable cost per scroll commit (real reflow):\n" +
rows
.map(
(r) =>
` n=${String(r.n).padStart(4)} median ${String(
r.medianMs
).padStart(
6
)}ms p95 ${String(r.p95Ms).padStart(6)}ms max ${String(
r.maxMs
).padStart(6)}ms`
)
.join("\n") +
"\n"
)
// Proof: even at 2000 rendered rows a single commit stays under one frame,
// with generous CI slack. The logged median is the real headline number.
const worst = rows[rows.length - 1]
expect(worst.medianMs).toBeLessThan(FRAME_BUDGET)
})
test("Layer 2 — real top-to-bottom scroll on 1000 messages holds frame budget", async () => {
const COUNT = 1000
const STEPS = 120
const { viewport } = await mountThread(COUNT, true)
// Capture any main-thread long task (>50ms by spec) during the scroll.
const longTasks: number[] = []
let observer: PerformanceObserver | null = null
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ["longtask"] })
} catch {
// longtask not supported — frame timing below is still the primary proof.
}
const max = viewport.scrollHeight - viewport.clientHeight
const handlerCosts: number[] = []
const frameDeltas: number[] = []
let last = performance.now()
for (let i = 1; i <= STEPS; i++) {
viewport.scrollTop = Math.round((max * i) / STEPS)
// The scroll handler runs synchronously inside dispatch:
// syncAfterScroll -> commitScrollState -> getContentBottom. Timing the
// dispatch is timing the exact main-thread block a user scroll causes.
const start = performance.now()
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
handlerCosts.push(performance.now() - start)
await nextFrame()
const now = performance.now()
frameDeltas.push(now - last)
last = now
}
observer?.disconnect()
const stats = {
handlerMedianMs: round(median(handlerCosts)),
handlerP95Ms: round(percentile(handlerCosts, 95)),
handlerMaxMs: round(Math.max(...handlerCosts)),
frameMedianMs: round(median(frameDeltas)),
frameP95Ms: round(percentile(frameDeltas, 95)),
frameMaxMs: round(Math.max(...frameDeltas)),
longTasks: longTasks.length,
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
}
// eslint-disable-next-line no-console
console.log(
`\n[Layer 2] scrolling ${COUNT} messages top-to-bottom over ${STEPS} steps` +
" (scroll state + visibility live):\n" +
` scroll handler median ${stats.handlerMedianMs}ms p95 ${stats.handlerP95Ms}ms max ${stats.handlerMaxMs}ms\n` +
` frame interval median ${stats.frameMedianMs}ms p95 ${stats.frameP95Ms}ms max ${stats.frameMaxMs}ms\n` +
` long tasks ${stats.longTasks}${
stats.longTasks ? ` (worst ${stats.longTaskMaxMs}ms)` : ""
}\n`
)
// Proof: the synchronous scroll handler never spends a whole frame, so it
// cannot drop one. Median is the headline; p95 carries generous CI slack.
expect(stats.handlerMedianMs).toBeLessThan(FRAME_BUDGET)
expect(stats.handlerP95Ms).toBeLessThan(FRAME_BUDGET * 2)
})
test("Regression guard — one scroll commit does O(items) layout reads, not O(items^2)", async () => {
// Timing budgets are a coarse, hardware-dependent backstop. THIS is the real
// regression guard: the number of forced layout reads per scroll commit is
// deterministic and machine-independent. getContentBottom reads one rect per
// item (plus a small constant), so the ratio stays ~O(1) per item. An O(n^2)
// regression — e.g. a nested rect loop — would push it far past the bound.
const ITEMS = 2000
const { viewport } = await mountThread(ITEMS)
const originalRect = Element.prototype.getBoundingClientRect
let calls = 0
Element.prototype.getBoundingClientRect = function getBoundingClientRectSpy(
this: Element
) {
calls++
return originalRect.call(this)
}
try {
// One synchronous scroll commit (handleScroll runs inside dispatch).
viewport.scrollTop = 1234
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
} finally {
Element.prototype.getBoundingClientRect = originalRect
}
const perItem = round(calls / ITEMS)
// eslint-disable-next-line no-console
console.log(
`\n[Regression guard] ${calls} getBoundingClientRect calls for ${ITEMS} items` +
` (${perItem}/item) in one scroll commit\n`
)
// A handful of rect reads per item is fine; anything that scales with item
// count (O(n) per item -> O(n^2) total) blows past this at 2000 items.
expect(perItem).toBeLessThan(4)
})
// ===========================================================================
// Layer 3 — streaming. The one path where heavy DOM should actually bite: an
// assistant reply GROWS token-by-token at the bottom of a long transcript.
// Growth dirties layout, so handleContentChange + the ResizeObserver-driven
// handleResize run while a full reflow is genuinely required — unlike steady
// scroll, which never dirties layout.
//
// To isolate the SCROLLER's cost from the markdown's own render cost (they
// interleave in one React commit), we run the identical growth twice: once
// inside MessageScroller (auto-follow) and once inside a plain scroll div
// (manual follow). Prior history is memoized in both, like a real app, so only
// the growing reply re-renders. The delta is what the scroller adds per token.
// ===========================================================================
const PRIOR_TURNS = 150
const STREAM_CHUNKS = 60
function StreamingReplyBody({ chunks }: { chunks: number }) {
// Each token batch appends a block, so the reply grows and dirties layout.
return (
<div style={{ padding: "12px 16px", fontSize: 14, lineHeight: 1.6 }}>
{Array.from({ length: chunks }, (_, index) =>
index % 5 === 4 ? (
<CodeBlock key={index} lines={6} />
) : (
<p key={index} style={{ margin: "0 0 8px" }}>
{`Streaming sentence ${index} that adds height to the reply as it grows. `.repeat(
2
)}
</p>
)
)}
</div>
)
}
// Memoized prior history — stable across token renders, so streaming only
// re-renders the growing reply, exactly like a real memoized message list.
const PriorTurnsScroller = React.memo(function PriorTurnsScroller() {
return (
<>
{Array.from({ length: PRIOR_TURNS }, (_, index) => (
<React.Fragment key={index}>
<MessageScrollerItem
messageId={`u${index}`}
scrollAnchor
style={{
flex: "none",
padding: "12px 16px",
background: "#fafafa",
}}
>
{`Question ${index}`}
</MessageScrollerItem>
<MessageScrollerItem messageId={`a${index}`} style={{ flex: "none" }}>
<HeavyAssistantMessage seed={index} />
</MessageScrollerItem>
</React.Fragment>
))}
</>
)
})
const PriorTurnsPlain = React.memo(function PriorTurnsPlain() {
return (
<>
{Array.from({ length: PRIOR_TURNS }, (_, index) => (
<React.Fragment key={index}>
<div style={{ padding: "12px 16px", background: "#fafafa" }}>
{`Question ${index}`}
</div>
<div>
<HeavyAssistantMessage seed={index} />
</div>
</React.Fragment>
))}
</>
)
})
function ScrollerStream({ chunks }: { chunks: number }) {
return (
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
<PriorTurnsScroller />
<MessageScrollerItem messageId="streaming" style={{ flex: "none" }}>
<StreamingReplyBody chunks={chunks} />
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
)
}
function PlainStream({ chunks }: { chunks: number }) {
return (
<div
data-testid="plain-viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<PriorTurnsPlain />
<div>
<StreamingReplyBody chunks={chunks} />
</div>
</div>
</div>
)
}
async function runStream(
renderTree: (chunks: number) => React.ReactElement,
follow?: (viewport: HTMLElement) => void
) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(renderTree(1))
})
await nextFrame()
await nextFrame()
await nextFrame()
const viewport = (container.querySelector('[aria-label="viewport"]') ??
container.querySelector('[data-testid="plain-viewport"]')) as HTMLElement
const longTasks: number[] = []
let observer: PerformanceObserver | null = null
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ["longtask"] })
} catch {
// longtask unsupported.
}
const commits: number[] = []
for (let chunk = 2; chunk <= STREAM_CHUNKS; chunk++) {
// Time the synchronous React commit: render + layout effects, where the
// scroller's handleContentChange runs. The reply is one chunk taller, so
// layout is dirty going in.
const start = performance.now()
flushSync(() => {
root!.render(renderTree(chunk))
})
commits.push(performance.now() - start)
follow?.(viewport)
await nextFrame()
}
observer?.disconnect()
root?.unmount()
container?.remove()
root = null
container = null
return {
commitMedianMs: round(median(commits)),
commitP95Ms: round(percentile(commits, 95)),
commitMaxMs: round(Math.max(...commits)),
longTasks: longTasks.length,
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
}
}
test("Layer 3 — streaming a heavy reply: scroller overhead vs plain baseline", async () => {
// Baseline: same growth in a plain scroll div, manually following the bottom.
const baseline = await runStream(
(chunks) => <PlainStream chunks={chunks} />,
(viewport) => {
viewport.scrollTop = viewport.scrollHeight
}
)
// Scroller: identical growth, auto-follow does the work.
const scroller = await runStream((chunks) => (
<ScrollerStream chunks={chunks} />
))
const overheadMedian = round(
scroller.commitMedianMs - baseline.commitMedianMs
)
// eslint-disable-next-line no-console
console.log(
`\n[Layer 3] streaming a reply to ${STREAM_CHUNKS} chunks below ${PRIOR_TURNS} memoized heavy turns,` +
" per-token commit (render + scroller content-change handling):\n" +
` plain div baseline median ${baseline.commitMedianMs}ms p95 ${baseline.commitP95Ms}ms max ${baseline.commitMaxMs}ms longTasks ${baseline.longTasks}\n` +
` MessageScroller median ${scroller.commitMedianMs}ms p95 ${scroller.commitP95Ms}ms max ${scroller.commitMaxMs}ms longTasks ${scroller.longTasks}\n` +
` scroller overhead median ${overheadMedian}ms per token\n`
)
// Proof: the scroller's per-token streaming work fits in a frame, and the
// overhead it adds over a plain scroll div is a small fraction of one.
expect(scroller.commitMedianMs).toBeLessThan(FRAME_BUDGET)
expect(overheadMedian).toBeLessThan(FRAME_BUDGET)
})
// ---------------------------------------------------------------------------
// Heavy-content variant. Real chat turns are not one text node — they are full
// markdown subtrees: headings, paragraphs, tokenized code blocks, tables and
// lists, hundreds of DOM nodes each. The scroller's forced reflow recomputes
// layout over that whole subtree, so this is the honest stress for the geometry
// hot path. (This does NOT measure the markdown library's own render cost —
// that is the content's concern, not the scroller's.)
// ---------------------------------------------------------------------------
function CodeBlock({ lines }: { lines: number }) {
return (
<pre
style={{
whiteSpace: "pre",
overflowX: "auto",
background: "#f5f5f5",
padding: 12,
margin: "8px 0",
fontFamily: "monospace",
fontSize: 13,
}}
>
<code>
{Array.from({ length: lines }, (_, line) => (
<div key={line}>
{Array.from({ length: 8 }, (_, token) => (
<span key={token} style={{ color: token % 2 ? "#07a" : "#905" }}>
{`tok${token} `}
</span>
))}
</div>
))}
</code>
</pre>
)
}
function MarkdownTable({ rows }: { rows: number }) {
return (
<table
style={{ borderCollapse: "collapse", margin: "8px 0", width: "100%" }}
>
<thead>
<tr>
{["Name", "Type", "Default", "Description"].map((header) => (
<th key={header} style={{ border: "1px solid #ddd", padding: 6 }}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }, (_, row) => (
<tr key={row}>
{Array.from({ length: 4 }, (_, cell) => (
<td key={cell} style={{ border: "1px solid #ddd", padding: 6 }}>
{`r${row}c${cell}`}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
function HeavyAssistantMessage({ seed }: { seed: number }) {
// Vary the sizes by turn so heights are non-uniform like real replies.
const paragraphs = 2 + (seed % 3)
const codeLines = 12 + (seed % 14)
const tableRows = 3 + (seed % 5)
return (
<div style={{ padding: "12px 16px", fontSize: 14, lineHeight: 1.6 }}>
<h2 style={{ fontSize: 18, margin: "0 0 8px" }}>{`Reply ${seed}`}</h2>
{Array.from({ length: paragraphs }, (_, index) => (
<p key={index} style={{ margin: "0 0 8px" }}>
{`This is paragraph ${index} of a fairly long assistant reply that wraps across multiple lines so the layout engine has real flow work to do when the surrounding scroll position changes during a scroll. `.repeat(
2
)}
</p>
))}
<ul style={{ margin: "0 0 8px", paddingLeft: 20 }}>
{Array.from({ length: 5 }, (_, index) => (
<li key={index}>{`List item ${index} with some inline text.`}</li>
))}
</ul>
<CodeBlock lines={codeLines} />
<MarkdownTable rows={tableRows} />
</div>
)
}
function HeavyThread({ turns }: { turns: number }) {
return (
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{Array.from({ length: turns }, (_, index) => (
<React.Fragment key={index}>
{/* User turn: short, and the scroll anchor. */}
<MessageScrollerItem
messageId={`u${index}`}
scrollAnchor
style={{
flex: "none",
padding: "12px 16px",
background: "#fafafa",
}}
>
{`Question ${index}: how does this work?`}
</MessageScrollerItem>
{/* Assistant turn: heavy markdown subtree. */}
<MessageScrollerItem
messageId={`a${index}`}
style={{ flex: "none" }}
>
<HeavyAssistantMessage seed={index} />
</MessageScrollerItem>
</React.Fragment>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<VisibilityProbe />
</MessageScroller>
</MessageScrollerProvider>
)
}
async function mountHeavyThread(turns: number) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(<HeavyThread turns={turns} />)
})
await nextFrame()
await nextFrame()
await nextFrame()
const viewport = document.querySelector(
'[aria-label="viewport"]'
) as HTMLElement
const content = viewport.querySelector('[role="log"]') as HTMLElement
const spacer = content.querySelector(
"[data-message-scroller-spacer]"
) as HTMLElement
const nodeCount = content.querySelectorAll("*").length
return { content, nodeCount, spacer, viewport }
}
test("Layer 1 (heavy markdown) — commit cost over full markdown subtrees", async () => {
const turnCounts = [50, 150, 300]
const iterations = 150
const rows: Array<{
turns: number
items: number
nodes: number
medianMs: number
p95Ms: number
maxMs: number
}> = []
for (const turns of turnCounts) {
const { content, nodeCount, spacer, viewport } =
await mountHeavyThread(turns)
const samples: number[] = []
for (let i = 0; i < 20; i++) {
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
}
for (let i = 0; i < iterations; i++) {
viewport.scrollTop = 1000 + (i % 2)
const start = performance.now()
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
samples.push(performance.now() - start)
}
rows.push({
turns,
items: turns * 2,
nodes: nodeCount,
medianMs: round(median(samples)),
p95Ms: round(percentile(samples, 95)),
maxMs: round(Math.max(...samples)),
})
root?.unmount()
container?.remove()
root = null
container = null
}
// eslint-disable-next-line no-console
console.log(
"\n[Layer 1 · heavy markdown] getMessageScrollerScrollable cost per commit:\n" +
rows
.map(
(r) =>
` turns=${String(r.turns).padStart(3)} items=${String(
r.items
).padStart(3)} domNodes=${String(r.nodes).padStart(
6
)} median ${String(r.medianMs).padStart(6)}ms p95 ${String(
r.p95Ms
).padStart(6)}ms max ${String(r.maxMs).padStart(6)}ms`
)
.join("\n") +
"\n"
)
const worst = rows[rows.length - 1]
expect(worst.medianMs).toBeLessThan(FRAME_BUDGET)
})
test("Layer 2 (heavy markdown) — real scroll over 300 markdown turns", async () => {
const TURNS = 300
const STEPS = 120
const { nodeCount, viewport } = await mountHeavyThread(TURNS)
const longTasks: number[] = []
let observer: PerformanceObserver | null = null
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ["longtask"] })
} catch {
// longtask unsupported — handler timing below is still the proof.
}
const max = viewport.scrollHeight - viewport.clientHeight
const handlerCosts: number[] = []
for (let i = 1; i <= STEPS; i++) {
viewport.scrollTop = Math.round((max * i) / STEPS)
const start = performance.now()
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
handlerCosts.push(performance.now() - start)
await nextFrame()
}
observer?.disconnect()
const stats = {
handlerMedianMs: round(median(handlerCosts)),
handlerP95Ms: round(percentile(handlerCosts, 95)),
handlerMaxMs: round(Math.max(...handlerCosts)),
longTasks: longTasks.length,
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
}
// eslint-disable-next-line no-console
console.log(
`\n[Layer 2 · heavy markdown] scrolling ${TURNS} turns (${
TURNS * 2
} messages, ${nodeCount} DOM nodes) top-to-bottom:\n` +
` scroll handler median ${stats.handlerMedianMs}ms p95 ${stats.handlerP95Ms}ms max ${stats.handlerMaxMs}ms\n` +
` long tasks ${stats.longTasks}${
stats.longTasks ? ` (worst ${stats.longTaskMaxMs}ms)` : ""
}\n`
)
expect(stats.handlerMedianMs).toBeLessThan(FRAME_BUDGET)
expect(stats.handlerP95Ms).toBeLessThan(FRAME_BUDGET * 2)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
import { EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE } from "./types"
import type {
MessageScrollerScrollable,
MessageScrollerStore,
MessageScrollerVisibilityState,
MessageScrollerVisibilityStore,
} from "./types"
// Generic useSyncExternalStore backing: a stable snapshot (referentially equal
// while isEqual holds, so subscribers only re-render on real transitions) plus
// optional reference-counting via the subscribe-time lifecycle callbacks.
function createExternalStore<T>(
initialSnapshot: T,
isEqual: (a: T, b: T) => boolean
) {
let snapshot = initialSnapshot
const listeners = new Set<() => void>()
return {
getSnapshot: () => snapshot,
hasListeners: () => listeners.size > 0,
setSnapshot: (nextSnapshot: T) => {
if (isEqual(snapshot, nextSnapshot)) {
return
}
snapshot = nextSnapshot
listeners.forEach((listener) => listener())
},
subscribe: (
listener: () => void,
onFirstSubscribe?: () => void,
onLastUnsubscribe?: () => void
) => {
const wasEmpty = listeners.size === 0
listeners.add(listener)
if (wasEmpty) {
onFirstSubscribe?.()
}
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
onLastUnsubscribe?.()
}
}
},
}
}
function createMessageScrollerStore<T>(
initialSnapshot: T,
isEqual: (a: T, b: T) => boolean
): MessageScrollerStore<T> {
return createExternalStore(initialSnapshot, isEqual)
}
function createMessageScrollerVisibilityStore(): MessageScrollerVisibilityStore {
return createExternalStore(
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
areVisibilityStatesEqual
)
}
function areScrollStatesEqual(
current: MessageScrollerScrollable,
next: MessageScrollerScrollable
) {
return current.start === next.start && current.end === next.end
}
function areVisibilityStatesEqual(
current: MessageScrollerVisibilityState,
next: MessageScrollerVisibilityState
) {
if (current.currentAnchorId !== next.currentAnchorId) {
return false
}
if (current.visibleMessageIds.length !== next.visibleMessageIds.length) {
return false
}
return current.visibleMessageIds.every(
(messageId, index) => messageId === next.visibleMessageIds[index]
)
}
export {
areScrollStatesEqual,
areVisibilityStatesEqual,
createMessageScrollerStore,
createMessageScrollerVisibilityStore,
}

View File

@@ -0,0 +1,240 @@
import * as React from "react"
import type { UseRenderComponentProps } from "../use-render"
// Default scrollEdgeThreshold. Sub-pixel tolerance so edge detection does not
// flicker across engines that round scrollTop differently.
const DEFAULT_SCROLL_EDGE_THRESHOLD = 8
// Default scrollPreviousItemPeek. Pixels of the previous item kept visible above
// a newly anchored row.
const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64
// Default scrollMargin for scrollToMessage and programmatic targets.
const DEFAULT_SCROLL_MARGIN = 0
// Two fractional scrollTop values within this range are treated as equal, to
// absorb zoom and HiDPI rounding drift.
const SCROLL_POSITION_EPSILON = 0.5
// How long (ms) data-autoscrolling stays set during a programmatic smooth scroll
// before clearing.
const AUTOSCROLLING_CLEAR_DELAY = 180
// Viewport keys that count as deliberate scroll intent and release follow-bottom.
const USER_SCROLL_KEYS = new Set([
"ArrowDown",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
" ", // Space key.
])
// Internal scroll mode. Derived from intent and commands; decides how the
// viewport reacts to content and resize.
type MessageScrollerMode =
| "following-bottom" // autoScroll on, pinned to the latest message.
| "free-scrolling" // reader scrolled away; position left alone (prepends still preserved).
| "anchored-to-message" // holding a turn at the reading line while it streams.
| "settling-jump" // a programmatic jump is animating; intent detection suppressed until it settles.
// Where a saved transcript opens on the first non-empty render.
type MessageScrollerDefaultScrollPosition = "start" | "end" | "last-anchor"
// Which transcript edge MessageScrollerButton scrolls toward.
type MessageScrollerButtonDirection = "start" | "end"
// Viewport alignment for scrollToMessage and programmatic jumps.
type MessageScrollerScrollAlign = "start" | "center" | "end" | "nearest"
// Options for scrollToMessage, scrollToEnd, and scrollToStart.
type MessageScrollerScrollOptions = {
// Viewport edge or center to align the target to.
align?: MessageScrollerScrollAlign
// Native scroll behavior.
behavior?: ScrollBehavior
// Margin on the aligned edge, in pixels. Defaults to the provider scrollMargin.
scrollMargin?: number
}
// Scroll snapshot from useMessageScrollerScrollable: which edges the viewport can
// still scroll toward.
type MessageScrollerScrollable = {
// The viewport can scroll toward the start (content is hidden above).
start: boolean
// The viewport can scroll toward the end (content is hidden below).
end: boolean
}
// Visibility snapshot from useMessageScrollerVisibility.
type MessageScrollerVisibilityState = {
// The anchored turn the reader is in, or null. Stays set after the anchor
// scrolls above the viewport.
currentAnchorId: string | null
// messageId values intersecting the viewport, in document order.
visibleMessageIds: string[]
}
// Headless provider for a chat transcript scroller. Owns scroll behavior and
// state; renders no DOM.
type MessageScrollerProviderProps = {
children?: React.ReactNode
// Follow new content at the bottom while the viewport is already at the end.
autoScroll?: boolean
// Opening position on the first non-empty render, applied once.
defaultScrollPosition?: MessageScrollerDefaultScrollPosition
// Distance from an edge that still counts as at-top/at-bottom. Defaults to 8.
scrollEdgeThreshold?: number
// Extra top margin for a newly anchored row, added to scrollMargin. Defaults to 64.
scrollPreviousItemPeek?: number
// Default margin on the aligned edge for commands and visibility. Defaults to 0.
scrollMargin?: number
}
// Frame container for a chat transcript scroller. Must render inside a
// MessageScrollerProvider.
type MessageScrollerProps = React.ComponentProps<"div">
// Scrollable viewport. Owns native scroll events and prepend preservation.
type MessageScrollerViewportProps = React.ComponentProps<"div"> & {
// Keep the first visible messageId row stable on prepend. Defaults to true.
preserveScrollOnPrepend?: boolean
}
// Transcript row container. Every direct child should be a MessageScrollerItem.
type MessageScrollerContentProps = React.ComponentProps<"div"> & {
// Class name for the internal tail spacer used when anchoring turns near the top.
spacerClassName?: string
}
// One transcript row: a message, marker, typing row, separator, or load-more row.
type MessageScrollerItemProps = React.ComponentProps<"div"> & {
// Stable row id for scrollToMessage, visibility, and prepend preservation.
messageId?: string
// Marks a turn boundary that newly appended anchors and last-anchor restore use.
scrollAnchor?: boolean
}
// State passed to MessageScrollerButton's render prop.
type MessageScrollerButtonRenderState = {
// Whether overflow exists toward this button's direction.
active: boolean
direction: MessageScrollerButtonDirection
}
// Scroll control for the start or end of the transcript.
type MessageScrollerButtonProps = UseRenderComponentProps<
"button",
MessageScrollerButtonRenderState
> & {
// Native scroll behavior when clicked. Defaults to "smooth".
behavior?: ScrollBehavior
// Transcript edge to scroll toward. Defaults to "end".
direction?: MessageScrollerButtonDirection
}
// Minimal external store backing useMessageScrollerScrollable, consumed via
// useSyncExternalStore.
type MessageScrollerStore<T> = {
getSnapshot: () => T
setSnapshot: (nextSnapshot: T) => void
subscribe: (listener: () => void) => () => void
}
// External store backing useMessageScrollerVisibility. Adds reference-counting so
// tracking stays lazy.
type MessageScrollerVisibilityStore = {
getSnapshot: () => MessageScrollerVisibilityState
hasListeners: () => boolean
setSnapshot: (nextSnapshot: MessageScrollerVisibilityState) => void
subscribe: (
listener: () => void,
onFirstSubscribe: () => void,
onLastUnsubscribe: () => void
) => () => void
}
// Registers (or, with removedElement, unregisters) a MessageScrollerItem node by
// messageId.
type MessageScrollerRegisterMessage = (
messageId: string,
element: HTMLElement | null,
removedElement?: HTMLElement | null
) => void
// Internal context wiring the parts together. Not part of the public API.
type MessageScrollerContextValue = {
handleContentChange: () => void
handleResize: () => void
observeVisibility: () => void
preserveScrollOnPrependRef: React.RefObject<boolean>
scrollToEnd: (options?: MessageScrollerScrollOptions) => boolean
scrollToMessage: (
messageId: string,
options?: MessageScrollerScrollOptions
) => boolean
scrollToStart: (options?: MessageScrollerScrollOptions) => boolean
setContentElement: (element: HTMLDivElement | null) => void
setRootElement: (element: HTMLDivElement | null) => void
setSpacerElement: (element: HTMLDivElement | null) => void
setViewportElement: (element: HTMLDivElement | null) => void
pendingDefaultScrollStore: MessageScrollerStore<boolean>
stateStore: MessageScrollerStore<MessageScrollerScrollable>
syncAfterScroll: () => void
unobserveVisibility: () => void
userScrollIntent: () => void
viewportRef: React.RefObject<HTMLDivElement | null>
visibilityStore: MessageScrollerVisibilityStore
}
// Initial MessageScrollerScrollable before measurement. Stable reference for the
// server and first-render snapshot.
const EMPTY_MESSAGE_SCROLLER_SCROLLABLE: MessageScrollerScrollable = {
start: false,
end: false,
}
// Shared empty array so empty visibility snapshots stay referentially stable.
const EMPTY_VISIBLE_MESSAGE_IDS: string[] = []
// Initial MessageScrollerVisibilityState. Nothing tracked, no current anchor.
const EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE: MessageScrollerVisibilityState =
{
currentAnchorId: null,
visibleMessageIds: EMPTY_VISIBLE_MESSAGE_IDS,
}
export {
AUTOSCROLLING_CLEAR_DELAY,
DEFAULT_SCROLL_EDGE_THRESHOLD,
DEFAULT_SCROLL_MARGIN,
DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
EMPTY_VISIBLE_MESSAGE_IDS,
SCROLL_POSITION_EPSILON,
USER_SCROLL_KEYS,
}
export type {
MessageScrollerButtonDirection,
MessageScrollerButtonProps,
MessageScrollerButtonRenderState,
MessageScrollerContentProps,
MessageScrollerContextValue,
MessageScrollerDefaultScrollPosition,
MessageScrollerItemProps,
MessageScrollerMode,
MessageScrollerProps,
MessageScrollerProviderProps,
MessageScrollerRegisterMessage,
MessageScrollerScrollAlign,
MessageScrollerScrollOptions,
MessageScrollerScrollable,
MessageScrollerStore,
MessageScrollerViewportProps,
MessageScrollerVisibilityState,
MessageScrollerVisibilityStore,
}

View File

@@ -0,0 +1,328 @@
import * as React from "react"
import {
getElementScrollTop,
getElementViewportTop,
getMaxScrollTop,
getTailSpacerHeight,
} from "./geometry"
import { AUTOSCROLLING_CLEAR_DELAY, SCROLL_POSITION_EPSILON } from "./types"
import type { MessageScrollerScrollOptions } from "./types"
import {
markDefaultScrollPositionApplied,
type MessageScrollerRefs,
} from "./use-message-scroller-refs"
// Imperative scroll primitives, split from the controller so the move mechanics
// live apart from the policy that decides when to run them. Each command resolves
// a target scrollTop and returns false when the viewport is not mounted yet.
function useMessageScrollerCommands({
refs,
commitScrollState,
scheduleStateCommit,
scheduleVisibilitySync,
}: {
refs: MessageScrollerRefs
commitScrollState: () => void
scheduleStateCommit: () => void
scheduleVisibilitySync: () => void
}) {
const {
streamingTurnRef,
autoScrollRef,
autoscrollingRef,
autoscrollingTimeoutRef,
contentRef,
itemCountRef,
messageElementsRef,
modeRef,
pendingScrollToMessageRef,
prependRestoreRef,
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
viewportRef,
} = refs
const setAutoScrolling = React.useCallback(
(autoscrolling: boolean) => {
if (autoscrollingTimeoutRef.current !== null) {
window.clearTimeout(autoscrollingTimeoutRef.current)
autoscrollingTimeoutRef.current = null
}
if (autoscrollingRef.current !== autoscrolling) {
autoscrollingRef.current = autoscrolling
commitScrollState()
}
if (autoscrolling) {
autoscrollingTimeoutRef.current = window.setTimeout(() => {
autoscrollingTimeoutRef.current = null
autoscrollingRef.current = false
commitScrollState()
}, AUTOSCROLLING_CLEAR_DELAY)
}
},
[commitScrollState]
)
const setTailSpacerHeight = React.useCallback((height: number) => {
const spacer = spacerRef.current
if (!spacer) {
return
}
const nextHeight = Math.max(0, Math.ceil(height))
if (spacerHeightRef.current === nextHeight) {
return
}
spacerHeightRef.current = nextHeight
spacer.hidden = nextHeight === 0
spacer.style.height = `${nextHeight}px`
spacer.style.marginTop = nextHeight > 0 ? `${-spacerGapRef.current}px` : ""
}, [])
const scrollToPosition = React.useCallback(
(
scrollTop: number,
{
behavior = "auto",
autoscrolling = false,
}: {
behavior?: ScrollBehavior
autoscrolling?: boolean
} = {}
) => {
const viewport = viewportRef.current
if (!viewport) {
return
}
const nextScrollTop = Math.max(0, scrollTop)
if (
Math.abs(viewport.scrollTop - nextScrollTop) <= SCROLL_POSITION_EPSILON
) {
viewport.scrollTop = nextScrollTop
commitScrollState()
return
}
if (autoscrolling) {
setAutoScrolling(true)
}
viewport.scrollTo({
top: nextScrollTop,
behavior,
})
scheduleStateCommit()
},
[commitScrollState, scheduleStateCommit, setAutoScrolling]
)
const scrollToStart = React.useCallback(
({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => {
if (!viewportRef.current) {
return false
}
setTailSpacerHeight(0)
streamingTurnRef.current = null
modeRef.current = "free-scrolling"
scrollToPosition(0, { behavior })
scheduleVisibilitySync()
return true
},
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
)
const scrollToEnd = React.useCallback(
({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => {
const viewport = viewportRef.current
if (!viewport) {
return false
}
setTailSpacerHeight(0)
streamingTurnRef.current = null
modeRef.current = autoScrollRef.current
? "following-bottom"
: "free-scrolling"
scrollToPosition(getMaxScrollTop(viewport), {
autoscrolling: true,
behavior,
})
scheduleVisibilitySync()
return true
},
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
)
const scrollToElement = React.useCallback(
(
element: HTMLElement,
{
align = "start",
behavior = "auto",
scrollMargin = scrollMarginRef.current,
}: MessageScrollerScrollOptions = {},
{
keepPreviousPeek = false,
}: {
keepPreviousPeek?: boolean
} = {}
) => {
const content = contentRef.current
const viewport = viewportRef.current
if (!content || !viewport || !content.contains(element)) {
return false
}
const scrollTop = getElementScrollTop({
align,
element,
scrollMargin: keepPreviousPeek
? scrollMargin + scrollPreviousItemPeekRef.current
: scrollMargin,
spacer: spacerRef.current,
viewport,
})
const nextSpacerHeight = getTailSpacerHeight({
content,
scrollTop,
spacer: spacerRef.current,
viewport,
})
setTailSpacerHeight(nextSpacerHeight)
// Seed the prepend anchor with the jump target so a prepend that lands
// before this scroll settles still preserves the jumped-to row; once it
// settles, syncAfterScroll's capturePrependAnchor re-captures it from the
// first visible row.
prependRestoreRef.current = {
element,
viewportTop: getElementViewportTop(element, viewport),
}
modeRef.current = keepPreviousPeek
? "anchored-to-message"
: "settling-jump"
streamingTurnRef.current = keepPreviousPeek ? element : null
scrollToPosition(scrollTop, { behavior })
scheduleVisibilitySync()
return true
},
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
)
const reanchorToAnchoredMessage = React.useCallback(() => {
const element = streamingTurnRef.current
if (
!element ||
!element.isConnected ||
modeRef.current !== "anchored-to-message"
) {
return false
}
// Re-run the placement so the tail spacer is recomputed for the new content
// height and the turn is held at the reading line.
return scrollToElement(
element,
{ align: "start" },
{ keepPreviousPeek: true }
)
}, [scrollToElement])
// The target row may not be mounted yet (e.g. an async-loaded transcript).
// When it is missing the request is queued in pendingScrollToMessageRef and
// flushed later — on registerMessage for that id, or on the next content
// change. An explicit jump also marks the mount default as applied, so
// defaultScrollPosition does not override it.
const scrollToMessage = React.useCallback(
(messageId: string, options?: MessageScrollerScrollOptions) => {
const element = messageElementsRef.current.get(messageId)
if (!element) {
if (itemCountRef.current === 0) {
pendingScrollToMessageRef.current = {
messageId,
options,
}
markDefaultScrollPositionApplied(refs)
return true
}
return false
}
markDefaultScrollPositionApplied(refs)
if (scrollToElement(element, options)) {
pendingScrollToMessageRef.current = null
return true
}
pendingScrollToMessageRef.current = {
messageId,
options,
}
return true
},
[scrollToElement]
)
const flushPendingScrollToMessage = React.useCallback(() => {
const pending = pendingScrollToMessageRef.current
if (!pending) {
return false
}
const element = messageElementsRef.current.get(pending.messageId)
if (!element) {
return false
}
const handled = scrollToElement(element, pending.options)
if (!handled) {
return false
}
pendingScrollToMessageRef.current = null
markDefaultScrollPositionApplied(refs)
return true
}, [scrollToElement])
return {
flushPendingScrollToMessage,
reanchorToAnchoredMessage,
scrollToElement,
scrollToEnd,
scrollToMessage,
scrollToStart,
}
}
export { useMessageScrollerCommands }

View File

@@ -0,0 +1,774 @@
import * as React from "react"
import {
getContentBottom,
getElementTop,
getElementViewportTop,
getFirstVisibleMessageItem,
getFlexGap,
getLastScrollAnchor,
getMessageScrollerItems,
getMessageScrollerScrollable,
getMessageScrollerVisibilityState,
getNewScrollAnchor,
getUnanchoredScrollAnchor,
hasMultipleNewScrollAnchors,
} from "./geometry"
import {
DEFAULT_SCROLL_EDGE_THRESHOLD,
DEFAULT_SCROLL_MARGIN,
DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
SCROLL_POSITION_EPSILON,
} from "./types"
import type {
MessageScrollerContextValue,
MessageScrollerProviderProps,
MessageScrollerRegisterMessage,
MessageScrollerScrollable,
} from "./types"
import { useMessageScrollerCommands } from "./use-message-scroller-commands"
import {
clearPendingDefaultScroll,
markDefaultScrollPositionApplied,
useMessageScrollerRefs,
} from "./use-message-scroller-refs"
// Builds a ref callback that stores the node and runs onMount once it attaches.
function useElementRef(
elementRef: React.RefObject<HTMLDivElement | null>,
onMount: () => void
) {
return React.useCallback(
(element: HTMLDivElement | null) => {
elementRef.current = element
if (element) {
onMount()
}
},
[elementRef, onMount]
)
}
// Orchestrator hook. Decides when to scroll and delegates the moves to
// useMessageScrollerCommands; state and visibility commits are coalesced on a
// requestAnimationFrame and torn down on cleanup for StrictMode safety.
function useMessageScrollerController({
autoScroll = false,
defaultScrollPosition = "end",
scrollEdgeThreshold = DEFAULT_SCROLL_EDGE_THRESHOLD,
scrollPreviousItemPeek = DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
scrollMargin = DEFAULT_SCROLL_MARGIN,
}: MessageScrollerProviderProps) {
const refs = useMessageScrollerRefs({
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollMargin,
scrollPreviousItemPeek,
})
const {
streamingTurnRef,
autoScrollRef,
autoscrollingRef,
autoscrollingTimeoutRef,
contentRef,
defaultScrollPositionAppliedRef,
firstItemRef,
itemCountRef,
lastScrollTopRef,
messageElementsRef,
modeRef,
pendingScrollFrameRef,
pendingScrollToMessageRef,
prependRestoreRef,
preserveScrollOnPrependRef,
pendingDefaultScrollStore,
rootRef,
scrollEdgeThresholdRef,
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
stateFrameRef,
stateStore,
viewportRef,
visibilityFrameRef,
visibilityObserverRef,
visibilityStore,
visibleMessageIdsRef,
handledScrollAnchorsRef,
} = refs
const previousDefaultScrollPositionRef = React.useRef(defaultScrollPosition)
if (previousDefaultScrollPositionRef.current !== defaultScrollPosition) {
previousDefaultScrollPositionRef.current = defaultScrollPosition
defaultScrollPositionAppliedRef.current = false
// The pending-scroll hold is mount-only. A live prop change re-applies the
// opening position in layout; it does not hide the viewport again.
}
const writeStateAttributes = React.useCallback(
(state: MessageScrollerScrollable) => {
const root = rootRef.current
const viewport = viewportRef.current
const scrollable = [state.start && "start", state.end && "end"]
.filter(Boolean)
.join(" ")
const autoScrolling = autoscrollingRef.current
for (const element of [root, viewport]) {
if (!element) {
continue
}
if (scrollable) {
element.setAttribute("data-scrollable", scrollable)
} else {
element.removeAttribute("data-scrollable")
}
element.toggleAttribute("data-autoscrolling", autoScrolling)
}
},
[]
)
// Owns the one follow-bottom transition: arm at the bottom, release on any
// scroll away (including a scrollbar drag), suppressed during a programmatic
// scroll so the auto-scroll animation cannot release itself. Arming also
// skips the anchored-to-message hold: the tail spacer makes a freshly
// anchored turn read as "at the end", and re-arming there would let the
// first streamed chunk yank the reader off the anchor. The hold hands back
// to following in handleResize, once the reply consumes the tail spacer.
const reconcileFollowMode = React.useCallback(
(scrollable: MessageScrollerScrollable) => {
const scrollTop = viewportRef.current?.scrollTop ?? 0
// Content growing past the live edge also reads as "not at the end", but
// only a scrollbar drag moves scrollTop up. Growth must not release
// follow-output: the resize handler is coalesced onto a frame, so a state
// commit can observe the grown content before follow catches up.
const scrolledUp =
scrollTop < lastScrollTopRef.current - SCROLL_POSITION_EPSILON
lastScrollTopRef.current = scrollTop
if (
autoScrollRef.current &&
!scrollable.end &&
modeRef.current !== "settling-jump" &&
modeRef.current !== "anchored-to-message"
) {
modeRef.current = "following-bottom"
} else if (
modeRef.current === "following-bottom" &&
scrollable.end &&
scrolledUp &&
!autoscrollingRef.current
) {
modeRef.current = "free-scrolling"
}
},
[]
)
const commitScrollState = React.useCallback(() => {
const nextState = getMessageScrollerScrollable({
content: contentRef.current,
scrollEdgeThreshold: scrollEdgeThresholdRef.current,
spacer: spacerRef.current,
viewport: viewportRef.current,
})
reconcileFollowMode(nextState)
// While follow-output is engaged the scroller is already closing any gap a
// streamed chunk just opened, so publishing it as scrollable toward the
// end would strobe the scroll button once per chunk. Reconcile runs on the
// raw geometry first, so a commit that releases follow still publishes the
// gap it released over.
const publishedState =
modeRef.current === "following-bottom"
? { ...nextState, end: false }
: nextState
writeStateAttributes(publishedState)
stateStore.setSnapshot(publishedState)
}, [reconcileFollowMode, stateStore, writeStateAttributes])
const scheduleStateCommit = React.useCallback(() => {
if (stateFrameRef.current !== null) {
return
}
stateFrameRef.current = window.requestAnimationFrame(() => {
stateFrameRef.current = null
commitScrollState()
})
}, [commitScrollState])
const scheduleVisibilitySync = React.useCallback(() => {
if (!visibilityStore.hasListeners()) {
return
}
if (visibilityFrameRef.current !== null) {
return
}
visibilityFrameRef.current = window.requestAnimationFrame(() => {
visibilityFrameRef.current = null
// A frame can outlive the last unsubscribe. Recomputing here would
// overwrite the EMPTY snapshot that teardown just wrote, leaving a stale
// value for the next subscriber to read.
if (!visibilityStore.hasListeners()) {
return
}
visibilityStore.setSnapshot(
getMessageScrollerVisibilityState({
content: contentRef.current,
scrollMargin: scrollMarginRef.current,
scrollPreviousItemPeek: scrollPreviousItemPeekRef.current,
spacer: spacerRef.current,
viewport: viewportRef.current,
visibleMessageIds: visibleMessageIdsRef.current,
})
)
})
}, [visibilityStore])
const {
flushPendingScrollToMessage,
reanchorToAnchoredMessage,
scrollToElement,
scrollToEnd,
scrollToMessage,
scrollToStart,
} = useMessageScrollerCommands({
refs,
commitScrollState,
scheduleStateCommit,
scheduleVisibilitySync,
})
const restorePrependedAnchor = React.useCallback(() => {
const anchor = prependRestoreRef.current
const viewport = viewportRef.current
if (!anchor || !viewport || !anchor.element.isConnected) {
return false
}
// Compare the anchor relative to the viewport, not to the content. Native
// scroll anchoring leaves the viewport-relative position unchanged, so this
// is a no-op where the browser already handled the prepend and only corrects
// the scroll where it did not (e.g. Safari) — without trusting a capability
// flag, which some engines report incorrectly.
const nextViewportTop = getElementViewportTop(anchor.element, viewport)
const delta = nextViewportTop - anchor.viewportTop
if (Math.abs(delta) <= SCROLL_POSITION_EPSILON) {
return false
}
viewport.scrollTop += delta
anchor.viewportTop = getElementViewportTop(anchor.element, viewport)
scheduleStateCommit()
scheduleVisibilitySync()
return true
}, [scheduleStateCommit, scheduleVisibilitySync])
const capturePrependAnchor = React.useCallback(() => {
const content = contentRef.current
const viewport = viewportRef.current
if (!content || !viewport) {
prependRestoreRef.current = null
return
}
const anchor = getFirstVisibleMessageItem({
content,
spacer: spacerRef.current,
viewport,
})
prependRestoreRef.current = anchor
? {
element: anchor,
viewportTop: getElementViewportTop(anchor, viewport),
}
: null
}, [])
const schedulePendingScrollToMessageFlush = React.useCallback(() => {
if (pendingScrollFrameRef.current !== null) {
return
}
pendingScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingScrollFrameRef.current = null
if (flushPendingScrollToMessage()) {
capturePrependAnchor()
}
})
}, [capturePrependAnchor, flushPendingScrollToMessage])
const applyDefaultScrollPosition = React.useCallback(() => {
if (
!defaultScrollPosition ||
defaultScrollPositionAppliedRef.current ||
itemCountRef.current === 0
) {
return false
}
let handled = false
if (defaultScrollPosition === "last-anchor") {
const content = contentRef.current
const viewport = viewportRef.current
const anchor =
content && viewport
? getLastScrollAnchor(
getMessageScrollerItems(content, spacerRef.current)
)
: null
if (!content || !viewport || !anchor) {
handled = scrollToEnd({ behavior: "auto" })
} else {
const anchorTop = getElementTop(anchor, viewport)
const contentBottom = getContentBottom({
content,
spacer: spacerRef.current,
viewport,
})
// A short last turn already fits below the anchor, so opening at the end
// shows the whole turn without leaving a blank gap beneath it.
const lastTurnFits = contentBottom - anchorTop <= viewport.clientHeight
handled = lastTurnFits
? scrollToEnd({ behavior: "auto" })
: scrollToElement(
anchor,
{ align: "start" },
{ keepPreviousPeek: true }
)
}
} else {
handled =
defaultScrollPosition === "end"
? scrollToEnd({ behavior: "auto" })
: scrollToStart({ behavior: "auto" })
}
if (!handled) {
return false
}
markDefaultScrollPositionApplied(refs)
return true
}, [defaultScrollPosition, scrollToElement, scrollToEnd, scrollToStart])
const handleContentChange = React.useCallback(() => {
const content = contentRef.current
if (!content) {
return
}
const items = getMessageScrollerItems(content, spacerRef.current)
const previousItemCount = itemCountRef.current
const previousFirstItem = firstItemRef.current
itemCountRef.current = items.length
firstItemRef.current = items[0] ?? null
// Reconcile the scroll position with the new content. Every path re-captures
// the prepend anchor afterward, so each branch just returns.
//
// Branch order is load-bearing: first-content, prepended, appended, updated.
const reconcileScrollPosition = () => {
if (flushPendingScrollToMessage()) {
return
}
if (previousItemCount === 0) {
if (applyDefaultScrollPosition()) {
return
}
if (
items.length > 0 &&
autoScrollRef.current &&
scrollToEnd({ behavior: "auto" })
) {
return
}
commitScrollState()
scheduleVisibilitySync()
return
}
const previousFirstItemIndex = previousFirstItem
? items.indexOf(previousFirstItem)
: -1
const didPrepend =
preserveScrollOnPrependRef.current && previousFirstItemIndex > 0
if (didPrepend) {
// Prepended rows are not new appends. Restore the prior scroll position.
// The restore is a no-op where native scroll anchoring already did it.
restorePrependedAnchor()
return
}
if (items.length > previousItemCount) {
const anchor = getNewScrollAnchor(items, previousItemCount)
if (anchor) {
// While the reader is following the live end, a batch of several
// anchored turns arriving at once should keep following the end — not
// yank back to anchor the first turn of the batch. A single new anchor
// still moves to the top as usual.
if (
autoScrollRef.current &&
modeRef.current === "following-bottom" &&
hasMultipleNewScrollAnchors(items, previousItemCount)
) {
scrollToEnd({ behavior: "auto" })
return
}
scrollToElement(
anchor,
{ align: "start" },
{ keepPreviousPeek: true }
)
handledScrollAnchorsRef.current.add(anchor)
return
}
}
if (items.length === previousItemCount) {
const anchor = getUnanchoredScrollAnchor(
items,
handledScrollAnchorsRef.current
)
if (anchor) {
scrollToElement(
anchor,
{ align: "start" },
{ keepPreviousPeek: true }
)
handledScrollAnchorsRef.current.add(anchor)
return
}
}
// Appends with no new anchor (and content-only updates) fall through here:
// keep following the end if we still are, otherwise just recommit state.
if (modeRef.current === "following-bottom" && autoScrollRef.current) {
scrollToEnd({ behavior: "auto" })
} else {
commitScrollState()
scheduleVisibilitySync()
}
}
reconcileScrollPosition()
capturePrependAnchor()
}, [
applyDefaultScrollPosition,
capturePrependAnchor,
commitScrollState,
flushPendingScrollToMessage,
restorePrependedAnchor,
scheduleVisibilitySync,
scrollToElement,
scrollToEnd,
])
const handleResize = React.useCallback(() => {
if (modeRef.current === "following-bottom" && autoScrollRef.current) {
scrollToEnd({ behavior: "auto" })
return
}
// Hold the anchored turn in place as content below it resizes (a reply
// streaming in, or a transient marker collapsing) — otherwise the shrinking
// content lets the browser clamp scrollTop and the turn drops.
const previousSpacerHeight = spacerHeightRef.current
if (reanchorToAnchoredMessage()) {
// The reply streaming below the anchor consumes the tail spacer as it
// grows. Once the last of it is gone the reply has filled the viewport
// and the reader is genuinely at the live edge, so autoScroll hands off
// from the anchor hold to following the bottom. Requiring the >0 → 0
// transition keeps a turn taller than the viewport (placed with no
// spacer) held instead of yanked to the end.
if (
autoScrollRef.current &&
previousSpacerHeight > 0 &&
spacerHeightRef.current === 0
) {
scrollToEnd({ behavior: "auto" })
}
return
}
scheduleStateCommit()
scheduleVisibilitySync()
}, [
reanchorToAnchoredMessage,
scheduleStateCommit,
scheduleVisibilitySync,
scrollToEnd,
])
const observeVisibility = React.useCallback(() => {
const viewport = viewportRef.current
if (!viewport || !visibilityStore.hasListeners()) {
return
}
if (typeof IntersectionObserver === "undefined") {
scheduleVisibilitySync()
return
}
if (!visibilityObserverRef.current) {
visibilityObserverRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const messageId = (entry.target as HTMLElement).dataset.messageId
if (!messageId) {
continue
}
if (entry.isIntersecting) {
visibleMessageIdsRef.current.add(messageId)
} else {
visibleMessageIdsRef.current.delete(messageId)
}
}
scheduleVisibilitySync()
},
{
root: viewport,
// Shrink the root's top edge to the anchoring line so a previous turn
// peeking in the scrollMargin + peek band is not reported as visible,
// keeping visibleMessageIds consistent with currentAnchorId. Captured
// at observe time; a prop change rebuilds the observer on resubscribe.
rootMargin: `${-(
scrollMarginRef.current + scrollPreviousItemPeekRef.current
)}px 0px 0px 0px`,
threshold: [0, 0.01, 0.5, 1],
}
)
}
messageElementsRef.current.forEach((element) => {
visibilityObserverRef.current?.observe(element)
})
scheduleVisibilitySync()
}, [scheduleVisibilitySync, visibilityStore])
const unobserveVisibility = React.useCallback(() => {
if (visibilityFrameRef.current !== null) {
window.cancelAnimationFrame(visibilityFrameRef.current)
visibilityFrameRef.current = null
}
visibilityObserverRef.current?.disconnect()
visibilityObserverRef.current = null
visibleMessageIdsRef.current.clear()
visibilityStore.setSnapshot(EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE)
}, [visibilityStore])
const registerMessage = React.useCallback<MessageScrollerRegisterMessage>(
(messageId, element, removedElement) => {
if (element) {
messageElementsRef.current.set(messageId, element)
visibilityObserverRef.current?.observe(element)
scheduleVisibilitySync()
if (pendingScrollToMessageRef.current?.messageId === messageId) {
schedulePendingScrollToMessageFlush()
}
return
}
if (
removedElement &&
messageElementsRef.current.get(messageId) === removedElement
) {
messageElementsRef.current.delete(messageId)
visibleMessageIdsRef.current.delete(messageId)
visibilityObserverRef.current?.unobserve(removedElement)
scheduleVisibilitySync()
}
},
[schedulePendingScrollToMessageFlush, scheduleVisibilitySync]
)
const userScrollIntent = React.useCallback(() => {
if (
modeRef.current === "following-bottom" ||
modeRef.current === "anchored-to-message" ||
modeRef.current === "settling-jump"
) {
// A deliberate gesture releases auto-follow, turn-anchoring, and an in-flight
// programmatic jump so re-pinning (and re-arming) never fights the reader.
streamingTurnRef.current = null
modeRef.current = "free-scrolling"
}
}, [])
const mirrorStateAttributes = React.useCallback(
() => writeStateAttributes(stateStore.getSnapshot()),
[stateStore, writeStateAttributes]
)
const setRootElement = useElementRef(rootRef, mirrorStateAttributes)
const setViewportElement = useElementRef(viewportRef, mirrorStateAttributes)
const setContentElement = React.useCallback(
(element: HTMLDivElement | null) => {
contentRef.current = element
},
[]
)
const setSpacerElement = React.useCallback(
(element: HTMLDivElement | null) => {
spacerRef.current = element
spacerGapRef.current = getFlexGap(element?.parentElement ?? null)
},
[]
)
const syncAfterScroll = React.useCallback(() => {
commitScrollState()
scheduleVisibilitySync()
capturePrependAnchor()
}, [capturePrependAnchor, commitScrollState, scheduleVisibilitySync])
const context = React.useMemo<MessageScrollerContextValue>(
() => ({
handleContentChange,
handleResize,
observeVisibility,
pendingDefaultScrollStore,
preserveScrollOnPrependRef,
scrollToEnd,
scrollToMessage,
scrollToStart,
setContentElement,
setRootElement,
setSpacerElement,
setViewportElement,
stateStore,
syncAfterScroll,
unobserveVisibility,
userScrollIntent,
viewportRef,
visibilityStore,
}),
[
handleContentChange,
handleResize,
observeVisibility,
pendingDefaultScrollStore,
scrollToEnd,
scrollToMessage,
scrollToStart,
setContentElement,
setRootElement,
setSpacerElement,
setViewportElement,
stateStore,
syncAfterScroll,
unobserveVisibility,
userScrollIntent,
visibilityStore,
]
)
React.useLayoutEffect(() => {
if (applyDefaultScrollPosition()) {
return
}
if (itemCountRef.current === 0) {
clearPendingDefaultScroll(refs)
}
}, [applyDefaultScrollPosition])
React.useEffect(() => {
return () => {
// Reset every ref after cancelling. StrictMode replays effects on the same
// refs (unmount then remount), so a frame id left non-null here makes the
// scheduler on remount think a frame is still pending and never reschedule.
if (stateFrameRef.current !== null) {
window.cancelAnimationFrame(stateFrameRef.current)
stateFrameRef.current = null
}
if (visibilityFrameRef.current !== null) {
window.cancelAnimationFrame(visibilityFrameRef.current)
visibilityFrameRef.current = null
}
if (autoscrollingTimeoutRef.current !== null) {
window.clearTimeout(autoscrollingTimeoutRef.current)
autoscrollingTimeoutRef.current = null
}
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current)
pendingScrollFrameRef.current = null
}
visibilityObserverRef.current?.disconnect()
visibilityObserverRef.current = null
}
}, [])
React.useLayoutEffect(() => {
if (
autoScroll &&
modeRef.current === "following-bottom" &&
itemCountRef.current > 0
) {
scrollToEnd({ behavior: "auto" })
return
}
commitScrollState()
}, [autoScroll, commitScrollState, scrollToEnd])
return {
context,
registerMessage,
}
}
export { useMessageScrollerController }

View File

@@ -0,0 +1,200 @@
import * as React from "react"
import {
areScrollStatesEqual,
createMessageScrollerStore,
createMessageScrollerVisibilityStore,
} from "./stores"
import { EMPTY_MESSAGE_SCROLLER_SCROLLABLE } from "./types"
import type {
MessageScrollerDefaultScrollPosition,
MessageScrollerMode,
MessageScrollerScrollable,
MessageScrollerScrollOptions,
MessageScrollerStore,
MessageScrollerVisibilityStore,
} from "./types"
// Shared mutable ref bag for one MessageScroller, closed over by both the
// controller and the commands so writes are visible across them without prop
// threading. stateStore and visibilityStore fan out via useSyncExternalStore.
type MessageScrollerRefs = {
autoScrollRef: React.RefObject<boolean>
autoscrollingRef: React.RefObject<boolean>
autoscrollingTimeoutRef: React.RefObject<number | null>
streamingTurnRef: React.RefObject<HTMLElement | null>
contentRef: React.RefObject<HTMLDivElement | null>
defaultScrollPositionAppliedRef: React.RefObject<boolean>
firstItemRef: React.RefObject<HTMLElement | null>
itemCountRef: React.RefObject<number>
lastScrollTopRef: React.RefObject<number>
messageElementsRef: React.RefObject<Map<string, HTMLElement>>
modeRef: React.RefObject<MessageScrollerMode>
pendingScrollFrameRef: React.RefObject<number | null>
pendingScrollToMessageRef: React.RefObject<{
messageId: string
options?: MessageScrollerScrollOptions
} | null>
prependRestoreRef: React.RefObject<{
element: HTMLElement
viewportTop: number
} | null>
preserveScrollOnPrependRef: React.RefObject<boolean>
pendingDefaultScrollStore: MessageScrollerStore<boolean>
rootRef: React.RefObject<HTMLDivElement | null>
scrollEdgeThresholdRef: React.RefObject<number>
scrollMarginRef: React.RefObject<number>
scrollPreviousItemPeekRef: React.RefObject<number>
spacerGapRef: React.RefObject<number>
spacerHeightRef: React.RefObject<number>
spacerRef: React.RefObject<HTMLDivElement | null>
stateFrameRef: React.RefObject<number | null>
stateStore: MessageScrollerStore<MessageScrollerScrollable>
viewportRef: React.RefObject<HTMLDivElement | null>
visibilityFrameRef: React.RefObject<number | null>
visibilityObserverRef: React.RefObject<IntersectionObserver | null>
visibilityStore: MessageScrollerVisibilityStore
visibleMessageIdsRef: React.RefObject<Set<string>>
handledScrollAnchorsRef: React.RefObject<WeakSet<HTMLElement>>
}
// Builds the per-instance ref bag: the two external stores constructed once, and
// the latest prop values mirrored onto refs each render so callbacks stay stable.
function useMessageScrollerRefs({
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollMargin,
scrollPreviousItemPeek,
}: {
autoScroll: boolean
defaultScrollPosition: MessageScrollerDefaultScrollPosition
scrollEdgeThreshold: number
scrollMargin: number
scrollPreviousItemPeek: number
}): MessageScrollerRefs {
const autoScrollRef = React.useRef(autoScroll)
const autoscrollingRef = React.useRef(false)
const contentRef = React.useRef<HTMLDivElement | null>(null)
const defaultScrollPositionAppliedRef = React.useRef(false)
const scrollEdgeThresholdRef = React.useRef(scrollEdgeThreshold)
const itemCountRef = React.useRef(0)
const firstItemRef = React.useRef<HTMLElement | null>(null)
// The scrollTop seen by the previous state commit, so follow-release can tell
// a reader scrolling up from content growing past the live edge.
const lastScrollTopRef = React.useRef(0)
const modeRef = React.useRef<MessageScrollerMode>(
autoScroll ? "following-bottom" : "free-scrolling"
)
const messageElementsRef = React.useRef(new Map<string, HTMLElement>())
const pendingScrollToMessageRef = React.useRef<{
messageId: string
options?: MessageScrollerScrollOptions
} | null>(null)
// The row to hold steady on the next prepend: the first visible row, or a jump
// target seeded by scrollToElement. restorePrependedAnchor reads only this.
const prependRestoreRef = React.useRef<{
element: HTMLElement
viewportTop: number
} | null>(null)
// The turn held at the reading line so a reply streaming in below it can re-pin
// it instead of letting scrollTop clamp it loose.
const streamingTurnRef = React.useRef<HTMLElement | null>(null)
const scrollPreviousItemPeekRef = React.useRef(scrollPreviousItemPeek)
const preserveScrollOnPrependRef = React.useRef(true)
const rootRef = React.useRef<HTMLDivElement | null>(null)
const scrollMarginRef = React.useRef(scrollMargin)
const pendingScrollFrameRef = React.useRef<number | null>(null)
const spacerGapRef = React.useRef(0)
const spacerHeightRef = React.useRef(0)
const spacerRef = React.useRef<HTMLDivElement | null>(null)
const stateFrameRef = React.useRef<number | null>(null)
const pendingDefaultScrollStoreRef =
React.useRef<MessageScrollerStore<boolean> | null>(null)
const stateStoreRef =
React.useRef<MessageScrollerStore<MessageScrollerScrollable> | null>(null)
const autoscrollingTimeoutRef = React.useRef<number | null>(null)
const viewportRef = React.useRef<HTMLDivElement | null>(null)
const visibilityFrameRef = React.useRef<number | null>(null)
const visibilityObserverRef = React.useRef<IntersectionObserver | null>(null)
const visibilityStoreRef =
React.useRef<MessageScrollerVisibilityStore | null>(null)
const visibleMessageIdsRef = React.useRef(new Set<string>())
const handledScrollAnchorsRef = React.useRef(new WeakSet<HTMLElement>())
if (pendingDefaultScrollStoreRef.current === null) {
pendingDefaultScrollStoreRef.current = createMessageScrollerStore(
defaultScrollPosition === "end" ||
defaultScrollPosition === "last-anchor",
(current, next) => current === next
)
}
if (stateStoreRef.current === null) {
stateStoreRef.current = createMessageScrollerStore(
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
areScrollStatesEqual
)
}
if (visibilityStoreRef.current === null) {
visibilityStoreRef.current = createMessageScrollerVisibilityStore()
}
// Track the latest prop values on every render so callbacks read fresh values
// without being recreated (the useLatest pattern).
autoScrollRef.current = autoScroll
scrollEdgeThresholdRef.current = scrollEdgeThreshold
scrollMarginRef.current = scrollMargin
scrollPreviousItemPeekRef.current = scrollPreviousItemPeek
return {
autoScrollRef,
autoscrollingRef,
autoscrollingTimeoutRef,
streamingTurnRef,
contentRef,
defaultScrollPositionAppliedRef,
firstItemRef,
itemCountRef,
lastScrollTopRef,
messageElementsRef,
modeRef,
pendingScrollFrameRef,
pendingScrollToMessageRef,
prependRestoreRef,
preserveScrollOnPrependRef,
pendingDefaultScrollStore: pendingDefaultScrollStoreRef.current,
rootRef,
scrollEdgeThresholdRef,
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
stateFrameRef,
stateStore: stateStoreRef.current,
viewportRef,
visibilityFrameRef,
visibilityObserverRef,
visibilityStore: visibilityStoreRef.current,
visibleMessageIdsRef,
handledScrollAnchorsRef,
}
}
function clearPendingDefaultScroll(refs: MessageScrollerRefs) {
refs.pendingDefaultScrollStore.setSnapshot(false)
}
function markDefaultScrollPositionApplied(refs: MessageScrollerRefs) {
refs.defaultScrollPositionAppliedRef.current = true
clearPendingDefaultScroll(refs)
}
export {
clearPendingDefaultScroll,
markDefaultScrollPositionApplied,
useMessageScrollerRefs,
}
export type { MessageScrollerRefs }

View File

@@ -0,0 +1,11 @@
import * as React from "react"
function useLatest<T>(value: T) {
const ref = React.useRef(value)
ref.current = value
return ref
}
export { useLatest }

View File

@@ -0,0 +1,98 @@
# Questionnaire
An unstyled, multi-step questionnaire for React. `Root` renders a native form,
and answers are read with `FormData`.
Questionnaire supports server rendering. Pass `Root.items` to include complete
collection-derived state in the initial HTML.
## Usage
```tsx
const items = [
{
name: "prototype",
required: true,
prompt: "What should we prototype next?",
description: "Choose a direction or write your own.",
choices: [
{
value: "delegation",
label: "Delegation",
description: "Show how work moves to a specialist.",
},
{ value: "questions", label: "Question prompts" },
],
input: { label: "Another answer", placeholder: "Type another answer…" },
},
{
name: "detail",
required: false,
prompt: "How much detail?",
description: "Skip this if you are not sure yet.",
choices: [
{ value: "focused", label: "Focused" },
{ value: "complete", label: "Complete flow" },
],
},
] as const
```
```tsx
import { Questionnaire } from "@shadcn/react/questionnaire"
export function ProjectQuestionnaire() {
return (
<Questionnaire.Root
items={items}
onSubmit={(event) => {
event.preventDefault()
const answers = new FormData(event.currentTarget)
}}
>
<Questionnaire.Progress />
{items.map((question) => (
<Questionnaire.Item
key={question.name}
name={question.name}
required={question.required}
>
<Questionnaire.Title>{question.prompt}</Questionnaire.Title>
<Questionnaire.Description>
{question.description}
</Questionnaire.Description>
<Questionnaire.Choices>
{question.choices.map((choice) => (
<Questionnaire.Choice key={choice.value} value={choice.value}>
<Questionnaire.ChoiceInput />
<Questionnaire.ChoiceLabel>
<span>{choice.label}</span>
{"description" in choice ? (
<span>{choice.description}</span>
) : null}
</Questionnaire.ChoiceLabel>
<Questionnaire.ChoiceShortcut />
</Questionnaire.Choice>
))}
{"input" in question ? (
<Questionnaire.Input
aria-label={question.input.label}
placeholder={question.input.placeholder}
/>
) : null}
</Questionnaire.Choices>
<Questionnaire.Error />
</Questionnaire.Item>
))}
<Questionnaire.Previous />
<Questionnaire.Skip />
<Questionnaire.Next />
<Questionnaire.Submit />
</Questionnaire.Root>
)
}
```
## Documentation
Read the full docs at [ui.shadcn.com/docs/react/questionnaire](https://ui.shadcn.com/docs/react/questionnaire).

View File

@@ -0,0 +1,234 @@
import type {
ItemRegistration,
QuestionnaireItemDefinition,
QuestionnaireShortcutMode,
} from "./types"
import { getShortcutKeys } from "./utils"
type QuestionnaireCollection = {
enabledItems: readonly QuestionnaireItemDefinition[]
itemByName: ReadonlyMap<string, QuestionnaireItemDefinition>
items: readonly QuestionnaireItemDefinition[]
}
function createQuestionnaireCollection(
items: readonly QuestionnaireItemDefinition[] | undefined
): QuestionnaireCollection | null {
if (items === undefined) {
return null
}
return {
enabledItems: items.filter((item) => !item.disabled),
itemByName: new Map(items.map((item) => [item.name, item])),
items,
}
}
function getInitialItemName(
collection: QuestionnaireCollection | null,
defaultItem: string | undefined
) {
if (!collection) {
return defaultItem ?? null
}
const defaultDefinition = defaultItem
? collection.itemByName.get(defaultItem)
: undefined
if (defaultDefinition && !defaultDefinition.disabled) {
return defaultDefinition.name
}
return collection.enabledItems[0]?.name ?? null
}
function getShortcutByChoiceValue(
item: QuestionnaireItemDefinition | undefined,
shortcuts: QuestionnaireShortcutMode | null
) {
const shortcutByChoiceValue = new Map<string, string>()
if (!item || !shortcuts) {
return shortcutByChoiceValue
}
const keys = getShortcutKeys(shortcuts)
let shortcutIndex = 0
for (const choice of item.choices ?? []) {
if (choice.disabled) {
continue
}
const shortcut = keys[shortcutIndex]
if (!shortcut) {
break
}
shortcutByChoiceValue.set(choice.value, shortcut)
shortcutIndex += 1
}
return shortcutByChoiceValue
}
function getCollectionDefinitionWarnings(
collection: QuestionnaireCollection,
defaultItem: string | undefined
) {
const warnings: string[] = []
const itemNames = new Set<string>()
for (const item of collection.items) {
if (itemNames.has(item.name)) {
warnings.push(`Item name "${item.name}" is defined more than once.`)
}
itemNames.add(item.name)
const choiceValues = new Set<string>()
for (const choice of item.choices ?? []) {
if (choiceValues.has(choice.value)) {
warnings.push(
`Choice value "${choice.value}" is defined more than once in item "${item.name}".`
)
}
choiceValues.add(choice.value)
}
}
if (defaultItem) {
const defaultDefinition = collection.itemByName.get(defaultItem)
if (!defaultDefinition || defaultDefinition.disabled) {
warnings.push(
`defaultItem "${defaultItem}" does not identify an enabled item. The first enabled item will be used instead.`
)
}
}
return warnings
}
function getCollectionRegistrationWarnings(
collection: QuestionnaireCollection,
registrations: readonly ItemRegistration[],
shortcuts: QuestionnaireShortcutMode | null
) {
const warnings: string[] = []
const registrationByName = new Map(
registrations.map((registration) => [registration.name, registration])
)
for (const definition of collection.items) {
const registration = registrationByName.get(definition.name)
if (!registration) {
if (!definition.disabled) {
warnings.push(
`Item "${definition.name}" is defined but has no rendered Questionnaire.Item.`
)
}
continue
}
if (registration.disabled !== Boolean(definition.disabled)) {
warnings.push(
`Item "${definition.name}" has different disabled values in Root.items and Questionnaire.Item.`
)
}
if (registration.required !== Boolean(definition.required)) {
warnings.push(
`Item "${definition.name}" has different required values in Root.items and Questionnaire.Item.`
)
}
const definedChoices = definition.choices ?? []
const definedChoiceByValue = new Map(
definedChoices.map((choice) => [choice.value, choice])
)
const registeredChoiceByValue = new Map(
registration.choices.map((choice) => [choice.value, choice])
)
for (const choice of definedChoices) {
const registeredChoice = registeredChoiceByValue.get(choice.value)
if (!registeredChoice) {
warnings.push(
`Choice "${choice.value}" is defined for item "${definition.name}" but has no rendered Questionnaire.Choice.`
)
continue
}
if (registeredChoice.disabled !== Boolean(choice.disabled)) {
warnings.push(
`Choice "${choice.value}" in item "${definition.name}" has different disabled values in Root.items and Questionnaire.Choice.`
)
}
}
if (shortcuts) {
for (const choice of registration.choices) {
if (!definedChoiceByValue.has(choice.value)) {
warnings.push(
`Rendered choice "${choice.value}" in item "${definition.name}" is missing from Root.items and will not receive a shortcut.`
)
}
}
const definedOrder = definedChoices
.filter((choice) => !choice.disabled)
.map((choice) => choice.value)
const registeredOrder = registration.choices
.filter((choice) => !choice.disabled)
.map((choice) => choice.value)
const sameChoices =
definedOrder.length > 0 &&
definedOrder.length === registeredOrder.length &&
definedOrder.every((choiceValue) =>
registeredOrder.includes(choiceValue)
)
if (
sameChoices &&
definedOrder.some(
(choiceValue, index) => choiceValue !== registeredOrder[index]
)
) {
warnings.push(
`Choice order for item "${definition.name}" differs between Root.items and the rendered Questionnaire.Choice elements.`
)
}
}
}
for (const registration of registrations) {
if (
!registration.disabled &&
!collection.itemByName.has(registration.name)
) {
warnings.push(
`Rendered item "${registration.name}" is missing from Root.items and is excluded from the questionnaire collection.`
)
}
}
return warnings
}
export {
createQuestionnaireCollection,
getCollectionDefinitionWarnings,
getCollectionRegistrationWarnings,
getInitialItemName,
getShortcutByChoiceValue,
}

View File

@@ -0,0 +1,551 @@
import * as React from "react"
import { mergeProps, useRender } from "../use-render"
import {
QuestionnaireChoiceContext,
QuestionnaireContext,
QuestionnaireItemContext,
useQuestionnaireChoiceContext,
useQuestionnaireContext,
useQuestionnaireItemContext,
} from "./context"
import type {
QuestionnaireChoiceInputProps,
QuestionnaireChoiceLabelProps,
QuestionnaireChoiceProps,
QuestionnaireChoiceShortcutProps,
QuestionnaireChoicesProps,
QuestionnaireDescriptionProps,
QuestionnaireErrorProps,
QuestionnaireInputProps,
QuestionnaireItemProps,
QuestionnaireNavigationState,
QuestionnaireNextProps,
QuestionnairePreviousProps,
QuestionnaireProgressProps,
QuestionnaireRootProps,
QuestionnaireSkipProps,
QuestionnaireSubmitProps,
QuestionnaireTitleProps,
} from "./types"
import { useQuestionnaireChoice } from "./use-questionnaire-choice"
import { useQuestionnaireInput } from "./use-questionnaire-input"
import { useQuestionnaireItem } from "./use-questionnaire-item"
import { useQuestionnaireRoot } from "./use-questionnaire-root"
function QuestionnaireRoot({
defaultItem,
item,
items,
noValidate = true,
onItemChange,
onReset,
onSubmit,
ref,
shortcuts,
...props
}: QuestionnaireRootProps) {
const { context, rootProps, state } = useQuestionnaireRoot({
defaultItem,
item,
items,
noValidate,
onItemChange,
onReset,
onSubmit,
ref,
shortcuts,
})
const element = useRender({
defaultTagName: "form",
props: mergeProps<"form">({ ...rootProps, noValidate }, props),
state,
})
return (
<QuestionnaireContext.Provider value={context}>
{element}
</QuestionnaireContext.Provider>
)
}
function QuestionnaireProgress({
children,
render,
...props
}: QuestionnaireProgressProps) {
const { current, first, last, total } = useQuestionnaireContext(
"Questionnaire.Progress"
)
const label = total ? `Question ${current} of ${total}` : undefined
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
"aria-label": "Questionnaire progress",
"aria-live": "polite",
"aria-valuemax": total || undefined,
"aria-valuemin": total ? 1 : undefined,
"aria-valuenow": total ? current : undefined,
"aria-valuetext": label,
children: children ?? label,
role: "progressbar",
},
props
),
render,
state: { current, first, last, total },
})
}
function QuestionnaireItem({
"aria-describedby": ariaDescribedBy,
"aria-keyshortcuts": ariaKeyShortcuts,
children,
disabled = false,
invalid = false,
multiple = false,
name,
onStatusChange,
ref,
required = false,
...props
}: QuestionnaireItemProps) {
const { context, itemProps, state } = useQuestionnaireItem({
"aria-describedby": ariaDescribedBy,
"aria-keyshortcuts": ariaKeyShortcuts,
disabled,
invalid,
multiple,
name,
onStatusChange,
ref,
required,
})
const element = useRender({
defaultTagName: "fieldset",
props: mergeProps<"fieldset">({ ...itemProps, children }, props),
state,
stateAttributesMapping: {
active: (isActive) => ({
"data-active": isActive ? "" : undefined,
}),
},
})
return (
<QuestionnaireItemContext.Provider value={context}>
{element}
</QuestionnaireItemContext.Provider>
)
}
function QuestionnaireTitle({ render, ...props }: QuestionnaireTitleProps) {
useQuestionnaireItemContext("Questionnaire.Title")
return useRender({
defaultTagName: "legend",
props,
render,
})
}
function QuestionnaireDescription({
id,
render,
...props
}: QuestionnaireDescriptionProps) {
const { registerDescription } = useQuestionnaireItemContext(
"Questionnaire.Description"
)
const generatedId = React.useId()
const descriptionId = id ?? generatedId
React.useLayoutEffect(
() => registerDescription(descriptionId),
[descriptionId, registerDescription]
)
return useRender({
defaultTagName: "p",
props: mergeProps<"p">({ id: descriptionId }, props),
render,
})
}
function QuestionnaireChoices({ render, ...props }: QuestionnaireChoicesProps) {
const { shortcuts } = useQuestionnaireItemContext("Questionnaire.Choices")
return useRender({
defaultTagName: "div",
props,
render,
state: { shortcuts },
})
}
function QuestionnaireChoice({
checked,
children,
defaultChecked = false,
disabled = false,
onChange,
render,
value,
...props
}: QuestionnaireChoiceProps) {
const { inputProps, state } = useQuestionnaireChoice({
checked,
defaultChecked,
disabled,
onChange,
value,
})
const element = useRender({
defaultTagName: "label",
props: mergeProps<"label">({ children }, props),
render,
state,
stateAttributesMapping: {
checked: (isChecked) => ({
"data-checked": isChecked ? "" : undefined,
"data-unchecked": isChecked ? undefined : "",
}),
},
})
return (
<QuestionnaireChoiceContext.Provider value={{ inputProps, state }}>
{element}
</QuestionnaireChoiceContext.Provider>
)
}
function QuestionnaireChoiceInput({
render,
...props
}: QuestionnaireChoiceInputProps) {
const { inputProps, state } = useQuestionnaireChoiceContext(
"Questionnaire.ChoiceInput"
)
return useRender({
defaultTagName: "input",
props: mergeProps<"input">(inputProps, props),
render,
state,
stateAttributesMapping: {
checked: (isChecked) => ({
"data-checked": isChecked ? "" : undefined,
"data-unchecked": isChecked ? undefined : "",
}),
},
})
}
function QuestionnaireChoiceLabel({
render,
...props
}: QuestionnaireChoiceLabelProps) {
useQuestionnaireChoiceContext("Questionnaire.ChoiceLabel")
return useRender({
defaultTagName: "span",
props,
render,
})
}
function QuestionnaireChoiceShortcut({
children,
render,
...props
}: QuestionnaireChoiceShortcutProps) {
const { state } = useQuestionnaireChoiceContext(
"Questionnaire.ChoiceShortcut"
)
const shortcutState = { shortcut: state.shortcut }
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
"aria-hidden": true,
children: children ?? state.shortcut,
hidden: state.shortcut === null,
},
props
),
render,
state: shortcutState,
})
}
function QuestionnaireInput({
defaultValue,
disabled = false,
onChange,
ref,
render,
type = "text",
value,
...props
}: QuestionnaireInputProps) {
const { inputProps, state } = useQuestionnaireInput({
defaultValue,
disabled,
onChange,
ref,
type,
value,
})
return useRender({
defaultTagName: "input",
props: mergeProps<"input">(inputProps, props),
render,
state,
stateAttributesMapping: {
filled: (isFilled) => ({
"data-empty": isFilled ? undefined : "",
"data-filled": isFilled ? "" : undefined,
}),
},
})
}
function QuestionnaireError({
children,
id,
render,
...props
}: QuestionnaireErrorProps) {
const { invalid, registerError, required } = useQuestionnaireItemContext(
"Questionnaire.Error"
)
const generatedId = React.useId()
const errorId = id ?? generatedId
React.useLayoutEffect(() => registerError(errorId), [errorId, registerError])
return useRender({
defaultTagName: "p",
props: mergeProps<"p">(
{
children:
children ??
(required
? "Choose an answer to continue."
: "Choose an answer or skip this question."),
hidden: !invalid,
id: errorId,
role: invalid ? "alert" : undefined,
},
props
),
render,
state: { invalid },
})
}
function QuestionnairePrevious({
children,
disabled: disabledProp = false,
onClick,
render,
tabIndex,
type = "button",
...props
}: QuestionnairePreviousProps) {
const context = useQuestionnaireContext("Questionnaire.Previous")
const visible = context.total > 1 && !context.first
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event)
if (!event.defaultPrevented) {
context.goPrevious()
}
}
return useRenderNavigationButton({
children: children ?? "Previous",
disabled: disabledProp,
onClick: handleClick,
props,
render,
status: context.activeItemStatus,
tabIndex,
type,
visible,
})
}
function QuestionnaireSkip({
children,
disabled: disabledProp = false,
onClick,
render,
tabIndex,
type = "button",
...props
}: QuestionnaireSkipProps) {
const context = useQuestionnaireContext("Questionnaire.Skip")
const visible = context.activeItemRequired === false
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event)
if (!event.defaultPrevented) {
context.skipCurrent()
}
}
return useRenderNavigationButton({
children: children ?? "Skip",
disabled: disabledProp,
onClick: handleClick,
props,
render,
status: context.activeItemStatus,
tabIndex,
type,
visible,
})
}
function QuestionnaireNext({
children,
disabled: disabledProp = false,
onClick,
render,
tabIndex,
type = "button",
...props
}: QuestionnaireNextProps) {
const context = useQuestionnaireContext("Questionnaire.Next")
const visible = context.total > 1 && !context.last
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event)
if (!event.defaultPrevented) {
context.goNext()
}
}
return useRenderNavigationButton({
children: children ?? "Next",
disabled: disabledProp,
onClick: handleClick,
props,
render,
shortcut: "Enter",
status: context.activeItemStatus,
tabIndex,
type,
visible,
})
}
function QuestionnaireSubmit({
children,
disabled: disabledProp = false,
render,
tabIndex,
type = "submit",
...props
}: QuestionnaireSubmitProps) {
const context = useQuestionnaireContext("Questionnaire.Submit")
const visible = context.total > 0 && context.last
return useRenderNavigationButton({
children: children ?? "Submit",
disabled: disabledProp,
props,
render,
shortcut: "Enter",
status: context.activeItemStatus,
tabIndex,
type,
visible,
})
}
function useRenderNavigationButton({
children,
disabled,
onClick,
props,
render,
shortcut,
status,
tabIndex,
type,
visible,
}: {
children: React.ReactNode
disabled: boolean
onClick?: React.MouseEventHandler<HTMLButtonElement>
props: React.ComponentPropsWithRef<"button">
render: QuestionnaireNextProps["render"]
shortcut?: "Enter"
status: QuestionnaireNavigationState["status"]
tabIndex: number | undefined
type: "button" | "reset" | "submit"
visible: boolean
}) {
const activeShortcut = visible && !disabled ? (shortcut ?? null) : null
const state: QuestionnaireNavigationState = {
disabled,
shortcut: activeShortcut,
status,
visible,
}
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
"aria-hidden": !visible || undefined,
"aria-keyshortcuts": activeShortcut ?? undefined,
children,
disabled,
hidden: !visible,
inert: !visible,
onClick,
tabIndex: visible ? tabIndex : -1,
type,
},
props
),
render,
state,
stateAttributesMapping: {
visible: (isVisible) => ({
"data-hidden": isVisible ? undefined : "",
"data-visible": isVisible ? "" : undefined,
}),
},
})
}
export {
QuestionnaireChoice,
QuestionnaireChoiceInput,
QuestionnaireChoiceLabel,
QuestionnaireChoiceShortcut,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireRoot,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import type {
QuestionnaireChoiceContextValue,
QuestionnaireContextValue,
QuestionnaireItemContextValue,
} from "./types"
const QuestionnaireChoiceContext =
React.createContext<QuestionnaireChoiceContextValue | null>(null)
const QuestionnaireContext =
React.createContext<QuestionnaireContextValue | null>(null)
const QuestionnaireItemContext =
React.createContext<QuestionnaireItemContextValue | null>(null)
function useQuestionnaireContext(component: string) {
const context = React.useContext(QuestionnaireContext)
if (!context) {
throw new Error(
`${component} must be used within a Questionnaire.Root component.`
)
}
return context
}
function useQuestionnaireChoiceContext(component: string) {
const context = React.useContext(QuestionnaireChoiceContext)
if (!context) {
throw new Error(
`${component} must be used within a Questionnaire.Choice component.`
)
}
return context
}
function useQuestionnaireItemContext(component: string) {
const context = React.useContext(QuestionnaireItemContext)
if (!context) {
throw new Error(
`${component} must be used within a Questionnaire.Item component.`
)
}
return context
}
export {
QuestionnaireChoiceContext,
QuestionnaireContext,
QuestionnaireItemContext,
useQuestionnaireChoiceContext,
useQuestionnaireContext,
useQuestionnaireItemContext,
}

View File

@@ -0,0 +1,45 @@
import {
QuestionnaireChoice as Choice,
QuestionnaireChoiceInput as ChoiceInput,
QuestionnaireChoiceLabel as ChoiceLabel,
QuestionnaireChoices as Choices,
QuestionnaireChoiceShortcut as ChoiceShortcut,
QuestionnaireDescription as Description,
QuestionnaireError as Error,
QuestionnaireInput as Input,
QuestionnaireItem as Item,
QuestionnaireNext as Next,
QuestionnairePrevious as Previous,
QuestionnaireProgress as Progress,
QuestionnaireRoot as Root,
QuestionnaireSkip as Skip,
QuestionnaireSubmit as Submit,
QuestionnaireTitle as Title,
} from "./components"
export const Questionnaire = {
Root,
Progress,
Item,
Title,
Description,
Choices,
Choice,
ChoiceInput,
ChoiceLabel,
ChoiceShortcut,
Input,
Error,
Previous,
Skip,
Next,
Submit,
}
export type {
QuestionnaireChoiceDefinition,
QuestionnaireInputType,
QuestionnaireItemDefinition,
QuestionnaireItemStatus,
QuestionnaireShortcutMode,
} from "./types"

View File

@@ -0,0 +1,724 @@
// @vitest-environment jsdom
import * as React from "react"
import { act } from "react"
import { hydrateRoot, type Root } from "react-dom/client"
import { renderToString } from "react-dom/server"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { Questionnaire } from "."
import type { QuestionnaireItemDefinition } from "./types"
const items = [
{
choices: [
{ value: "delegation" },
{ disabled: true, value: "automatic" },
{ value: "questions" },
],
name: "scope",
required: true,
},
{
choices: [{ value: "ignored" }],
disabled: true,
name: "disabled",
},
{
choices: [{ value: "focused" }, { value: "complete" }],
name: "detail",
},
] as const satisfies readonly QuestionnaireItemDefinition[]
let container: HTMLDivElement
let root: Root | null
beforeEach(() => {
;(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement("div")
document.body.appendChild(container)
root = null
})
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount()
})
}
container.remove()
vi.restoreAllMocks()
})
describe("Questionnaire server rendering", () => {
it("renders collection progress, active item, actions, and shortcuts", () => {
renderServerQuestionnaire()
expect(form().dataset.current).toBe("1")
expect(form().dataset.total).toBe("2")
expect(form().hasAttribute("data-first")).toBe(true)
expect(progress().textContent).toBe("Question 1 of 2")
expect(progress().getAttribute("aria-valuenow")).toBe("1")
expect(progress().getAttribute("aria-valuemax")).toBe("2")
expect(item("scope").hasAttribute("data-active")).toBe(true)
expect(item("scope").hidden).toBe(false)
expect(item("disabled").hidden).toBe(true)
expect(item("detail").hidden).toBe(true)
expect(action("previous").hidden).toBe(true)
expect(action("skip").hidden).toBe(true)
expect(action("next").hidden).toBe(false)
expect(action("next").dataset.status).toBe("unanswered")
expect(action("submit").hidden).toBe(true)
expect(choice("delegation").dataset.shortcut).toBe("A")
expect(choiceInput("delegation").getAttribute("aria-keyshortcuts")).toBe(
"A"
)
expect(choice("automatic").hasAttribute("data-shortcut")).toBe(false)
expect(shortcut("automatic").hidden).toBe(true)
expect(choice("questions").dataset.shortcut).toBe("B")
})
it("renders a requested optional item and its applicable actions", () => {
renderServerQuestionnaire({ defaultItem: "detail" })
expect(form().dataset.current).toBe("2")
expect(form().hasAttribute("data-last")).toBe(true)
expect(progress().textContent).toBe("Question 2 of 2")
expect(item("scope").hidden).toBe(true)
expect(item("detail").hasAttribute("data-active")).toBe(true)
expect(action("previous").hidden).toBe(false)
expect(action("skip").hidden).toBe(false)
expect(action("next").hidden).toBe(true)
expect(action("submit").hidden).toBe(false)
})
it("renders a controlled item from the collection", () => {
renderMarkup(<TestQuestionnaire item="detail" />)
expect(progress().textContent).toBe("Question 2 of 2")
expect(item("scope").hidden).toBe(true)
expect(item("detail").hasAttribute("data-active")).toBe(true)
})
it.each(["missing", "disabled"])(
"falls back from an invalid default item %s during render",
(defaultItem) => {
renderServerQuestionnaire({ defaultItem })
expect(progress().textContent).toBe("Question 1 of 2")
expect(item("scope").hasAttribute("data-active")).toBe(true)
expect(item("detail").hidden).toBe(true)
}
)
it("renders numeric shortcuts and leaves overflow choices unassigned", () => {
const choices = Array.from({ length: 10 }, (_, index) => ({
value: `choice-${index + 1}`,
}))
const numericItems = [{ choices, name: "numeric" }]
renderMarkup(
<Questionnaire.Root items={numericItems} shortcuts="numbers">
<Questionnaire.Item name="numeric">
<Questionnaire.Title>Choose a number</Questionnaire.Title>
{choices.map((choice) => (
<TestChoice key={choice.value} value={choice.value} />
))}
</Questionnaire.Item>
</Questionnaire.Root>
)
expect(choice("choice-1").dataset.shortcut).toBe("1")
expect(choice("choice-9").dataset.shortcut).toBe("9")
expect(choice("choice-10").hasAttribute("data-shortcut")).toBe(false)
})
it("renders an input-only item without fixed-choice definitions", () => {
renderMarkup(
<Questionnaire.Root items={[{ name: "input" }]}>
<Questionnaire.Progress data-testid="progress" />
<Questionnaire.Item data-testid="input" name="input">
<Questionnaire.Title>Describe the result</Questionnaire.Title>
<Questionnaire.Input
data-testid="input-control"
aria-label="Result"
/>
</Questionnaire.Item>
<Questionnaire.Submit data-testid="submit" />
</Questionnaire.Root>
)
expect(progress().textContent).toBe("Question 1 of 1")
expect(item("input").hasAttribute("data-active")).toBe(true)
expect(query<HTMLInputElement>("input-control").type).toBe("text")
expect(action("submit").hidden).toBe(false)
})
it("renders both navigation directions for a middle item", () => {
const middleItems = [
{ name: "first" },
{ name: "middle" },
{ name: "last" },
]
renderMarkup(
<Questionnaire.Root defaultItem="middle" items={middleItems}>
<Questionnaire.Progress data-testid="progress" />
{middleItems.map((definition) => (
<Questionnaire.Item
key={definition.name}
data-testid={definition.name}
name={definition.name}
>
<Questionnaire.Title>{definition.name}</Questionnaire.Title>
</Questionnaire.Item>
))}
<Questionnaire.Previous data-testid="previous" />
<Questionnaire.Next data-testid="next" />
<Questionnaire.Submit data-testid="submit" />
</Questionnaire.Root>
)
expect(progress().textContent).toBe("Question 2 of 3")
expect(action("previous").hidden).toBe(false)
expect(action("next").hidden).toBe(false)
expect(action("submit").hidden).toBe(true)
})
it("pins the current defaultChecked server-rendering limitation", () => {
const defaultItems = [{ choices: [{ value: "default" }], name: "answer" }]
renderMarkup(
<Questionnaire.Root items={defaultItems}>
<Questionnaire.Item name="answer">
<TestChoice defaultChecked value="default" />
</Questionnaire.Item>
</Questionnaire.Root>
)
expect(choiceInput("default").checked).toBe(false)
expect(choice("default").hasAttribute("data-checked")).toBe(false)
})
})
describe("Questionnaire hydration", () => {
it("hydrates without changing collection-derived output", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const onRecoverableError = vi.fn()
const questionnaire = <TestQuestionnaire />
renderMarkup(questionnaire)
const initialMarkup = {
actions: actionsMarkup(),
progress: progress().outerHTML,
root: form().outerHTML.match(/<form[^>]*>/)?.[0],
}
await act(async () => {
root = hydrateRoot(container, questionnaire, { onRecoverableError })
})
expect(onRecoverableError).not.toHaveBeenCalled()
expect(consoleWarn).not.toHaveBeenCalled()
expect(progress().outerHTML).toBe(initialMarkup.progress)
expect(actionsMarkup()).toBe(initialMarkup.actions)
expect(form().outerHTML.match(/<form[^>]*>/)?.[0]).toBe(initialMarkup.root)
})
it("warns after falling back from an invalid default item", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const questionnaire = <TestQuestionnaire defaultItem="missing" />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(progress().textContent).toBe("Question 1 of 2")
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'defaultItem "missing" does not identify an enabled item'
)
)
})
it("joins logical navigation to matching runtime items", async () => {
const questionnaire = <TestQuestionnaire />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
await act(async () => {
choiceInput("delegation").click()
})
await act(async () => {
action("next").click()
})
expect(progress().textContent).toBe("Question 2 of 2")
expect(item("scope").hidden).toBe(true)
expect(item("detail").hasAttribute("data-active")).toBe(true)
})
it("keeps definition shortcut order authoritative after hydration", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const orderedItems = [
{
choices: [{ value: "second" }, { value: "first" }],
name: "order",
},
]
const questionnaire = (
<Questionnaire.Root items={orderedItems} shortcuts="letters">
<Questionnaire.Item data-testid="order" name="order">
<TestChoice value="first" />
<TestChoice value="second" />
</Questionnaire.Item>
</Questionnaire.Root>
)
renderMarkup(questionnaire)
expect(choice("first").dataset.shortcut).toBe("B")
expect(choice("second").dataset.shortcut).toBe("A")
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
await keydown(item("order"), "A")
expect(choiceInput("second").checked).toBe(true)
expect(choiceInput("first").checked).toBe(false)
expect(choiceInput("second").getAttribute("aria-keyshortcuts")).toContain(
"A"
)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Choice order for item "order" differs between Root.items'
)
)
})
it("warns when rendered metadata differs from its definitions", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const questionnaire = (
<Questionnaire.Root
items={[
{
choices: [{ disabled: true, value: "fixed" }],
name: "answer",
required: true,
},
]}
>
<Questionnaire.Item name="answer">
<TestChoice value="fixed" />
</Questionnaire.Item>
</Questionnaire.Root>
)
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(consoleWarn).toHaveBeenCalledTimes(2)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Item "answer" has different required values in Root.items'
)
)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Choice "fixed" in item "answer" has different disabled values'
)
)
})
it("warns about duplicate item names and choice values", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const questionnaire = (
<Questionnaire.Root
items={[
{
choices: [{ value: "fixed" }, { value: "fixed" }],
name: "answer",
},
{ disabled: true, name: "answer" },
]}
>
<Questionnaire.Item name="answer">
<TestChoice value="fixed" />
</Questionnaire.Item>
</Questionnaire.Root>
)
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining('Item name "answer" is defined more than once')
)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Choice value "fixed" is defined more than once in item "answer"'
)
)
})
it("does not assign post-hydration shortcuts to omitted definitions", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const questionnaire = (
<Questionnaire.Root items={[{ name: "answer" }]} shortcuts="letters">
<Questionnaire.Item name="answer">
<TestChoice value="fixed" />
<Questionnaire.Input aria-label="Another answer" />
</Questionnaire.Item>
</Questionnaire.Root>
)
renderMarkup(questionnaire)
expect(shortcut("fixed").hidden).toBe(true)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(shortcut("fixed").hidden).toBe(true)
expect(choiceInput("fixed").hasAttribute("aria-keyshortcuts")).toBe(false)
expect(consoleWarn).toHaveBeenCalledTimes(1)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Rendered choice "fixed" in item "answer" is missing from Root.items'
)
)
})
it("updates authoritative order and falls back when the active item is disabled", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const onItemChange = vi.fn()
const initialItems = [
{ name: "first" },
{ name: "second" },
{ name: "third" },
]
function DynamicQuestionnaire({
definitions,
}: {
definitions: readonly QuestionnaireItemDefinition[]
}) {
return (
<Questionnaire.Root
data-testid="root"
defaultItem="second"
items={definitions}
onItemChange={onItemChange}
>
<Questionnaire.Progress data-testid="progress" />
{definitions.map((definition) => (
<Questionnaire.Item
key={definition.name}
data-testid={definition.name}
disabled={definition.disabled}
name={definition.name}
>
<Questionnaire.Title>{definition.name}</Questionnaire.Title>
</Questionnaire.Item>
))}
<Questionnaire.Previous data-testid="previous" />
<Questionnaire.Next data-testid="next" />
<Questionnaire.Submit data-testid="submit" />
</Questionnaire.Root>
)
}
const questionnaire = <DynamicQuestionnaire definitions={initialItems} />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(progress().textContent).toBe("Question 2 of 3")
const reorderedItems = [
{ name: "second" },
{ name: "first" },
{ name: "third" },
]
await act(async () => {
root?.render(<DynamicQuestionnaire definitions={reorderedItems} />)
})
expect(progress().textContent).toBe("Question 1 of 3")
expect(action("previous").hidden).toBe(true)
const disabledItems = [
{ disabled: true, name: "second" },
{ name: "first" },
{ name: "third" },
]
await act(async () => {
root?.render(<DynamicQuestionnaire definitions={disabledItems} />)
})
expect(progress().textContent).toBe("Question 1 of 2")
expect(item("first").hasAttribute("data-active")).toBe(true)
expect(onItemChange).toHaveBeenLastCalledWith("first")
expect(consoleWarn).toHaveBeenCalledTimes(1)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'defaultItem "second" does not identify an enabled item'
)
)
const expandedItems = [
{ name: "first" },
{ name: "third" },
{ name: "fourth" },
]
await act(async () => {
root?.render(<DynamicQuestionnaire definitions={expandedItems} />)
})
expect(progress().textContent).toBe("Question 1 of 3")
expect(action("next").hidden).toBe(false)
expect(item("fourth").hidden).toBe(true)
})
it("keeps a controlled item aligned with collection updates", async () => {
function ControlledQuestionnaire({ item: activeItem }: { item: string }) {
return (
<Questionnaire.Root item={activeItem} items={items}>
<Questionnaire.Progress data-testid="progress" />
<Questionnaire.Item data-testid="scope" name="scope" required>
<Questionnaire.Title>Scope</Questionnaire.Title>
<TestChoice value="delegation" />
<TestChoice disabled value="automatic" />
<TestChoice value="questions" />
</Questionnaire.Item>
<Questionnaire.Item data-testid="detail" name="detail">
<Questionnaire.Title>Detail</Questionnaire.Title>
<TestChoice value="focused" />
<TestChoice value="complete" />
</Questionnaire.Item>
</Questionnaire.Root>
)
}
const questionnaire = <ControlledQuestionnaire item="scope" />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
await act(async () => {
root?.render(<ControlledQuestionnaire item="detail" />)
})
expect(progress().textContent).toBe("Question 2 of 2")
expect(item("scope").hidden).toBe(true)
expect(item("detail").hasAttribute("data-active")).toBe(true)
})
it("reports a mismatch again after it resolves and recurs", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
function WarningQuestionnaire({
includeChoice,
}: {
includeChoice: boolean
}) {
const definitions = [
{
choices: includeChoice ? [{ value: "fixed" }] : undefined,
name: "answer",
},
]
return (
<Questionnaire.Root items={definitions} shortcuts="letters">
<Questionnaire.Item name="answer">
<TestChoice value="fixed" />
</Questionnaire.Item>
</Questionnaire.Root>
)
}
const questionnaire = <WarningQuestionnaire includeChoice />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(consoleWarn).not.toHaveBeenCalled()
await act(async () => {
root?.render(<WarningQuestionnaire includeChoice={false} />)
})
expect(consoleWarn).toHaveBeenCalledTimes(1)
await act(async () => {
root?.render(<WarningQuestionnaire includeChoice />)
})
await act(async () => {
root?.render(<WarningQuestionnaire includeChoice={false} />)
})
expect(consoleWarn).toHaveBeenCalledTimes(2)
})
})
function TestQuestionnaire({
defaultItem,
item: controlledItem,
}: {
defaultItem?: string
item?: string
}) {
return (
<Questionnaire.Root
data-testid="root"
defaultItem={defaultItem}
item={controlledItem}
items={items}
shortcuts="letters"
>
<Questionnaire.Progress data-testid="progress" />
<Questionnaire.Item data-testid="scope" name="scope" required>
<Questionnaire.Title>Choose the scope</Questionnaire.Title>
<TestChoice value="delegation" />
<TestChoice disabled value="automatic" />
<TestChoice value="questions" />
</Questionnaire.Item>
<Questionnaire.Item data-testid="disabled" disabled name="disabled">
<Questionnaire.Title>Disabled question</Questionnaire.Title>
<TestChoice value="ignored" />
</Questionnaire.Item>
<Questionnaire.Item data-testid="detail" name="detail">
<Questionnaire.Title>Choose the detail</Questionnaire.Title>
<TestChoice value="focused" />
<TestChoice value="complete" />
</Questionnaire.Item>
<Questionnaire.Previous data-testid="previous" />
<Questionnaire.Skip data-testid="skip" />
<Questionnaire.Next data-testid="next" />
<Questionnaire.Submit data-testid="submit" />
</Questionnaire.Root>
)
}
function TestChoice({
value,
...props
}: Omit<React.ComponentProps<typeof Questionnaire.Choice>, "children">) {
return (
<Questionnaire.Choice
data-testid={`choice-${value}`}
value={value}
{...props}
>
<Questionnaire.ChoiceInput data-testid={`input-${value}`} />
<Questionnaire.ChoiceLabel>{value}</Questionnaire.ChoiceLabel>
<Questionnaire.ChoiceShortcut data-testid={`shortcut-${value}`} />
</Questionnaire.Choice>
)
}
function renderServerQuestionnaire({
defaultItem,
}: {
defaultItem?: string
} = {}) {
renderMarkup(<TestQuestionnaire defaultItem={defaultItem} />)
}
function renderMarkup(element: React.ReactNode) {
container.innerHTML = renderToString(element)
}
function form() {
return query<HTMLFormElement>("root")
}
function progress() {
return query<HTMLElement>("progress")
}
function item(name: string) {
return query<HTMLFieldSetElement>(name)
}
function choice(value: string) {
return query<HTMLLabelElement>(`choice-${value}`)
}
function choiceInput(value: string) {
return query<HTMLInputElement>(`input-${value}`)
}
function shortcut(value: string) {
return query<HTMLElement>(`shortcut-${value}`)
}
function action(name: "next" | "previous" | "skip" | "submit") {
return query<HTMLButtonElement>(name)
}
function actionsMarkup() {
return ["previous", "skip", "next", "submit"]
.map(
(name) =>
action(name as "next" | "previous" | "skip" | "submit").outerHTML
)
.join("")
}
function query<ElementType extends Element>(testId: string) {
const element = container.querySelector<ElementType>(
`[data-testid="${testId}"]`
)
if (!element) {
throw new Error(`Missing test element: ${testId}`)
}
return element
}
async function keydown(element: Element, key: string) {
await act(async () => {
element.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key })
)
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,305 @@
import * as React from "react"
import type { UseRenderComponentProps } from "../use-render"
type QuestionnaireItemStatus = "unanswered" | "answered" | "skipped"
type QuestionnaireShortcutMode = "letters" | "numbers"
type QuestionnaireChoiceDefinition = {
disabled?: boolean
value: string
}
type QuestionnaireItemDefinition = {
choices?: readonly QuestionnaireChoiceDefinition[]
disabled?: boolean
name: string
required?: boolean
}
type QuestionnaireRootState = {
current: number
first: boolean
last: boolean
total: number
}
type QuestionnaireRootProps = Omit<
React.ComponentPropsWithRef<"form">,
"defaultValue" | "value"
> & {
defaultItem?: string
item?: string
items?: readonly QuestionnaireItemDefinition[]
onItemChange?: (item: string) => void
shortcuts?: QuestionnaireShortcutMode
}
type QuestionnaireProgressState = QuestionnaireRootState
type QuestionnaireProgressProps = UseRenderComponentProps<
"div",
QuestionnaireProgressState
>
type QuestionnaireItemState = {
active: boolean
disabled: boolean
invalid: boolean
multiple: boolean
required: boolean
status: QuestionnaireItemStatus
}
type QuestionnaireItemProps = Omit<
React.ComponentPropsWithRef<"fieldset">,
"name" | "value"
> & {
invalid?: boolean
name: string
multiple?: boolean
onStatusChange?: (status: QuestionnaireItemStatus) => void
required?: boolean
}
type QuestionnaireTitleProps = UseRenderComponentProps<"legend">
type QuestionnaireDescriptionProps = UseRenderComponentProps<"p">
type QuestionnaireChoicesState = {
shortcuts: QuestionnaireShortcutMode | null
}
type QuestionnaireChoicesProps = UseRenderComponentProps<
"div",
QuestionnaireChoicesState
>
type QuestionnaireErrorProps = UseRenderComponentProps<
"p",
Pick<QuestionnaireItemState, "invalid">
>
type QuestionnaireChoiceState = {
checked: boolean
disabled: boolean
invalid: boolean
shortcut: string | null
type: "checkbox" | "radio"
}
type QuestionnaireChoiceContextValue = {
inputProps: React.ComponentPropsWithRef<"input">
state: QuestionnaireChoiceState
}
type QuestionnaireChoiceProps = Omit<
UseRenderComponentProps<"label", QuestionnaireChoiceState>,
"onChange"
> & {
checked?: boolean
defaultChecked?: boolean
disabled?: boolean
onChange?: React.ChangeEventHandler<HTMLInputElement>
value: string
}
type QuestionnaireChoiceInputProps = Omit<
UseRenderComponentProps<"input", QuestionnaireChoiceState>,
| "checked"
| "defaultChecked"
| "disabled"
| "name"
| "onChange"
| "required"
| "type"
| "value"
>
type QuestionnaireChoiceLabelProps = UseRenderComponentProps<"span">
type QuestionnaireChoiceShortcutState = Pick<
QuestionnaireChoiceState,
"shortcut"
>
type QuestionnaireChoiceShortcutProps = UseRenderComponentProps<
"span",
QuestionnaireChoiceShortcutState
>
type QuestionnaireInputState = {
disabled: boolean
filled: boolean
invalid: boolean
}
type QuestionnaireInputType =
| "date"
| "datetime-local"
| "email"
| "month"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "time"
| "url"
| "week"
type QuestionnaireInputProps = Omit<
UseRenderComponentProps<"input", QuestionnaireInputState>,
"form" | "name" | "type"
> & {
type?: QuestionnaireInputType
}
type QuestionnaireNavigationState = {
disabled: boolean
shortcut: "Enter" | null
status: QuestionnaireItemStatus | null
visible: boolean
}
type QuestionnairePreviousProps = UseRenderComponentProps<
"button",
QuestionnaireNavigationState
>
type QuestionnaireSkipProps = UseRenderComponentProps<
"button",
QuestionnaireNavigationState
>
type QuestionnaireNextProps = UseRenderComponentProps<
"button",
QuestionnaireNavigationState
>
type QuestionnaireSubmitProps = UseRenderComponentProps<
"button",
QuestionnaireNavigationState
>
type AnswerControlRegistration = {
disabled: boolean
element: HTMLInputElement
id: string
} & (
| {
ownDisabled: boolean
type: "choice"
value: string
}
| {
type: "input"
}
)
type ChoiceRegistration = {
disabled: boolean
value: string
}
type ItemRegistration = {
choices: readonly ChoiceRegistration[]
disabled: boolean
element: HTMLFieldSetElement
focus: () => void
focusInvalid: () => void
getAnswerByElement: (element: Element) => AnswerControlRegistration | null
getAnswerByShortcut: (shortcut: string) => AnswerControlRegistration | null
moveAnswerFocus: (element: Element, direction: "next" | "previous") => boolean
name: string
required: boolean
reset: () => void
skip: () => void
status: QuestionnaireItemStatus
validate: () => boolean
}
type PendingFocus = {
name: string
target: "invalid" | "item"
}
type QuestionnaireContextValue = QuestionnaireRootState & {
activeItem: ItemRegistration | null
activeItemName: string | null
activeItemRequired: boolean | null
activeItemStatus: QuestionnaireItemStatus | null
domVersion: number
goNext: () => void
goPrevious: () => void
nativeValidation: boolean
itemDefinitionByName: ReadonlyMap<string, QuestionnaireItemDefinition> | null
registerItem: (registration: ItemRegistration) => () => void
shortcuts: QuestionnaireShortcutMode | null
skipCurrent: () => void
}
type QuestionnaireItemContextValue = {
active: boolean
disabled: boolean
hasInputAnswer: boolean
invalid: boolean
multiple: boolean
name: string
registerAnswerControl: (registration: AnswerControlRegistration) => () => void
registerAnswerSelection: (
answerId: string,
defaultSelected: boolean
) => () => void
registerDescription: (descriptionId: string) => () => void
registerError: (errorId: string) => () => void
required: boolean
resetVersion: number
selectedAnswerIds: string[]
setAnswerDefault: (answerId: string, defaultSelected: boolean) => void
setAnswerSelectionFromInteraction: (
answerId: string,
selected: boolean
) => void
shortcutByAnswerId: ReadonlyMap<string, string>
shortcutByChoiceValue: ReadonlyMap<string, string> | null
shortcuts: QuestionnaireShortcutMode | null
status: QuestionnaireItemStatus
syncControlledAnswerSelection: (answerId: string, selected: boolean) => void
}
export type {
AnswerControlRegistration,
ChoiceRegistration,
ItemRegistration,
PendingFocus,
QuestionnaireChoiceContextValue,
QuestionnaireChoiceDefinition,
QuestionnaireChoiceInputProps,
QuestionnaireChoiceLabelProps,
QuestionnaireChoiceProps,
QuestionnaireChoiceShortcutProps,
QuestionnaireChoiceShortcutState,
QuestionnaireChoiceState,
QuestionnaireChoicesProps,
QuestionnaireChoicesState,
QuestionnaireContextValue,
QuestionnaireDescriptionProps,
QuestionnaireErrorProps,
QuestionnaireInputProps,
QuestionnaireInputState,
QuestionnaireInputType,
QuestionnaireItemContextValue,
QuestionnaireItemDefinition,
QuestionnaireItemProps,
QuestionnaireItemState,
QuestionnaireItemStatus,
QuestionnaireNavigationState,
QuestionnaireNextProps,
QuestionnairePreviousProps,
QuestionnaireProgressProps,
QuestionnaireProgressState,
QuestionnaireRootProps,
QuestionnaireRootState,
QuestionnaireShortcutMode,
QuestionnaireSkipProps,
QuestionnaireSubmitProps,
QuestionnaireTitleProps,
}

View File

@@ -0,0 +1,170 @@
import * as React from "react"
import { useQuestionnaireItemContext } from "./context"
import type {
QuestionnaireChoiceProps,
QuestionnaireChoiceState,
} from "./types"
import { getAnswerKeyShortcuts } from "./utils"
type UseQuestionnaireChoiceParameters = Pick<
QuestionnaireChoiceProps,
"checked" | "defaultChecked" | "disabled" | "onChange" | "value"
>
function useQuestionnaireChoice({
checked: controlledChecked,
defaultChecked = false,
disabled: choiceDisabled = false,
onChange,
value,
}: UseQuestionnaireChoiceParameters) {
const {
disabled: itemDisabled,
hasInputAnswer,
invalid,
multiple,
name: itemName,
registerAnswerControl,
registerAnswerSelection,
required,
resetVersion,
selectedAnswerIds,
setAnswerDefault,
setAnswerSelectionFromInteraction,
shortcutByAnswerId,
shortcutByChoiceValue,
status,
syncControlledAnswerSelection,
} = useQuestionnaireItemContext("Questionnaire.Choice")
const answerId = React.useId()
const [inputElement, setInputElement] =
React.useState<HTMLInputElement | null>(null)
const initialDefaultCheckedRef = React.useRef(defaultChecked)
const controlled = controlledChecked !== undefined
const disabled = itemDisabled || choiceDisabled
const selected = selectedAnswerIds.includes(answerId)
const checked = controlled
? status === "skipped"
? false
: controlledChecked
: selected
const type = multiple ? "checkbox" : "radio"
const shortcut =
shortcutByChoiceValue?.get(value) ??
shortcutByAnswerId.get(answerId) ??
null
React.useLayoutEffect(
() => registerAnswerSelection(answerId, initialDefaultCheckedRef.current),
[answerId, registerAnswerSelection]
)
React.useLayoutEffect(
() => setAnswerDefault(answerId, defaultChecked),
[answerId, defaultChecked, setAnswerDefault]
)
React.useLayoutEffect(() => {
if (!inputElement) {
return
}
return registerAnswerControl({
disabled,
element: inputElement,
id: answerId,
ownDisabled: choiceDisabled,
type: "choice",
value,
})
}, [
answerId,
choiceDisabled,
disabled,
inputElement,
registerAnswerControl,
value,
])
React.useLayoutEffect(() => {
if (controlled) {
syncControlledAnswerSelection(answerId, controlledChecked)
}
}, [
answerId,
controlled,
controlledChecked,
resetVersion,
syncControlledAnswerSelection,
])
React.useLayoutEffect(() => {
if (!inputElement) {
return
}
// Keep the native reset target aligned with Questionnaire's owned default,
// including controlled choices whose checked prop remains authoritative.
inputElement.defaultChecked = controlled
? controlledChecked
: defaultChecked
if (resetVersion > 0) {
inputElement.checked = checked
}
}, [
checked,
controlled,
controlledChecked,
defaultChecked,
inputElement,
resetVersion,
])
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange?.(event)
if (event.defaultPrevented) {
return
}
if (!controlled) {
setAnswerSelectionFromInteraction(answerId, event.target.checked)
return
}
if (status === "skipped" && controlledChecked === event.target.checked) {
setAnswerSelectionFromInteraction(answerId, controlledChecked)
}
}
const state: QuestionnaireChoiceState = {
checked,
disabled,
invalid,
shortcut,
type,
}
return {
inputProps: {
ref: setInputElement,
"aria-invalid": invalid || undefined,
"aria-keyshortcuts": getAnswerKeyShortcuts(
shortcut,
!disabled && checked
),
checked,
disabled,
id: answerId,
name: status === "skipped" ? undefined : itemName,
onChange: handleChange,
required: required && !multiple && !hasInputAnswer,
type,
value,
},
state,
}
}
export { useQuestionnaireChoice }

View File

@@ -0,0 +1,149 @@
import * as React from "react"
import { composeRefs } from "../use-render"
import { useQuestionnaireItemContext } from "./context"
import type { QuestionnaireInputProps, QuestionnaireInputState } from "./types"
import { getAnswerKeyShortcuts, hasInputValue } from "./utils"
type UseQuestionnaireInputParameters = Pick<
QuestionnaireInputProps,
"defaultValue" | "disabled" | "onChange" | "ref" | "type" | "value"
>
function useQuestionnaireInput({
defaultValue,
disabled: inputDisabled = false,
onChange,
ref,
type = "text",
value: controlledValue,
}: UseQuestionnaireInputParameters) {
const {
disabled: itemDisabled,
invalid,
name: itemName,
registerAnswerControl,
registerAnswerSelection,
resetVersion,
selectedAnswerIds,
setAnswerDefault,
setAnswerSelectionFromInteraction,
syncControlledAnswerSelection,
} = useQuestionnaireItemContext("Questionnaire.Input")
const answerId = React.useId()
const inputRef = React.useRef<HTMLInputElement | null>(null)
const initialDefaultFilledRef = React.useRef(hasInputValue(defaultValue))
const controlled = controlledValue !== undefined
const defaultFilled = hasInputValue(defaultValue)
const controlledFilled = hasInputValue(controlledValue)
const [uncontrolledFilled, setUncontrolledFilled] =
React.useState(defaultFilled)
const disabled = itemDisabled || inputDisabled
const filled = controlled ? controlledFilled : uncontrolledFilled
const selected = selectedAnswerIds.includes(answerId)
React.useLayoutEffect(
() => registerAnswerSelection(answerId, initialDefaultFilledRef.current),
[answerId, registerAnswerSelection]
)
React.useLayoutEffect(
() => setAnswerDefault(answerId, defaultFilled),
[defaultFilled, answerId, setAnswerDefault]
)
React.useLayoutEffect(() => {
const input = inputRef.current
if (!input) {
return
}
return registerAnswerControl({
disabled,
element: input,
id: answerId,
type: "input",
})
}, [disabled, answerId, registerAnswerControl])
React.useLayoutEffect(() => {
if (controlled) {
syncControlledAnswerSelection(answerId, controlledFilled)
return
}
if (resetVersion > 0) {
setUncontrolledFilled(defaultFilled)
}
}, [
controlled,
controlledFilled,
controlledValue,
defaultFilled,
answerId,
resetVersion,
syncControlledAnswerSelection,
])
React.useLayoutEffect(() => {
const input = inputRef.current
if (!input || !controlled) {
return
}
input.defaultValue = String(controlledValue)
}, [controlled, controlledValue])
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange?.(event)
if (event.defaultPrevented) {
return
}
const nextFilled = event.target.value.trim().length > 0
if (controlled) {
return
}
setUncontrolledFilled(nextFilled)
setAnswerSelectionFromInteraction(answerId, nextFilled)
}
const state: QuestionnaireInputState = {
disabled,
filled,
invalid,
}
const setInputRef = React.useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element
composeRefs(ref)?.(element)
},
[ref]
)
return {
inputProps: {
"aria-invalid": invalid || undefined,
"aria-keyshortcuts": getAnswerKeyShortcuts(
null,
!disabled && filled && selected
),
defaultValue: controlled ? undefined : defaultValue,
disabled,
form: selected ? undefined : "",
id: answerId,
name: selected ? itemName : undefined,
onChange: handleChange,
ref: setInputRef,
type,
value: controlled ? controlledValue : undefined,
},
state,
}
}
export { useQuestionnaireInput }

View File

@@ -0,0 +1,571 @@
import * as React from "react"
import { composeRefs } from "../use-render"
import { getShortcutByChoiceValue } from "./collection"
import { useQuestionnaireContext } from "./context"
import type {
AnswerControlRegistration,
QuestionnaireItemContextValue,
QuestionnaireItemProps,
QuestionnaireItemState,
QuestionnaireItemStatus,
} from "./types"
import {
compareAnswerOrder,
getShortcutKeys,
isAnswerFilled,
isEmptyNavigableInput,
isRadioTarget,
isTextEntryTarget,
} from "./utils"
type UseQuestionnaireItemParameters = Pick<
QuestionnaireItemProps,
| "aria-describedby"
| "aria-keyshortcuts"
| "disabled"
| "invalid"
| "multiple"
| "name"
| "onStatusChange"
| "ref"
| "required"
>
function useQuestionnaireItem({
"aria-describedby": ariaDescribedBy,
"aria-keyshortcuts": ariaKeyShortcuts,
disabled = false,
invalid: externallyInvalid = false,
multiple = false,
name,
onStatusChange,
ref,
required = false,
}: UseQuestionnaireItemParameters) {
const {
activeItemName,
domVersion,
first,
itemDefinitionByName,
last,
nativeValidation,
registerItem,
shortcuts,
} = useQuestionnaireContext("Questionnaire.Item")
const [element, setElement] = React.useState<HTMLFieldSetElement | null>(null)
const [answerControlRegistrations, setAnswerControlRegistrations] =
React.useState<AnswerControlRegistration[]>([])
const [validationAttempted, setValidationAttempted] = React.useState(false)
const [selectedAnswerIds, setSelectedAnswerIds] = React.useState<string[]>([])
const [skipped, setSkipped] = React.useState(false)
const [resetVersion, setResetVersion] = React.useState(0)
const [descriptionIds, setDescriptionIds] = React.useState<string[]>([])
const [errorIds, setErrorIds] = React.useState<string[]>([])
const defaultSelectedAnswerIdsRef = React.useRef<string[]>([])
const multipleRef = React.useRef(multiple)
const previousMultipleRef = React.useRef(multiple)
multipleRef.current = multiple
const active = !disabled && activeItemName === name
const answerControls = React.useMemo(
() => [...answerControlRegistrations].sort(compareAnswerOrder),
[answerControlRegistrations, domVersion]
)
const answers = React.useMemo(
() => answerControls.filter((registration) => !registration.disabled),
[answerControls]
)
const answered = answers.some((answer) =>
selectedAnswerIds.includes(answer.id)
)
const status: QuestionnaireItemStatus = skipped
? "skipped"
: answered
? "answered"
: "unanswered"
const intentionallySkipped = status === "skipped" && !required
const valid =
disabled ||
intentionallySkipped ||
(!externallyInvalid && status === "answered")
const invalid =
!disabled &&
!intentionallySkipped &&
(externallyInvalid || (validationAttempted && !valid))
const hasInputAnswer = answers.some((answer) => answer.type === "input")
const previousStatusRef = React.useRef(status)
const itemDefinition = itemDefinitionByName?.get(name)
const shortcutByChoiceValue = React.useMemo(
() =>
itemDefinitionByName
? getShortcutByChoiceValue(itemDefinition, shortcuts)
: null,
[itemDefinition, itemDefinitionByName, shortcuts]
)
const shortcutByAnswerId = React.useMemo(() => {
if (shortcutByChoiceValue) {
return new Map<string, string>()
}
const keys = getShortcutKeys(shortcuts)
const shortcutAnswers = answers.filter((answer) => answer.type === "choice")
return new Map(
shortcutAnswers
.slice(0, keys.length)
.map((answer, index) => [answer.id, keys[index]])
)
}, [answers, shortcutByChoiceValue, shortcuts])
const registerAnswerControl = React.useCallback(
(registration: AnswerControlRegistration) => {
setAnswerControlRegistrations((currentRegistrations) => [
...currentRegistrations.filter(
(currentRegistration) =>
currentRegistration.element !== registration.element &&
currentRegistration.id !== registration.id
),
registration,
])
return () => {
setAnswerControlRegistrations((currentRegistrations) =>
currentRegistrations.filter(
(currentRegistration) => currentRegistration !== registration
)
)
}
},
[]
)
React.useLayoutEffect(() => {
if (previousStatusRef.current === status) {
return
}
previousStatusRef.current = status
onStatusChange?.(status)
}, [onStatusChange, status])
const updateAnswerSelected = React.useCallback(
(answerId: string, selected: boolean) => {
setSelectedAnswerIds((currentAnswerIds) => {
if (!selected) {
return currentAnswerIds.filter(
(currentAnswerId) => currentAnswerId !== answerId
)
}
if (!multiple) {
return [answerId]
}
return currentAnswerIds.includes(answerId)
? currentAnswerIds
: [...currentAnswerIds, answerId]
})
},
[multiple]
)
const setAnswerSelectionFromInteraction = React.useCallback(
(answerId: string, selected: boolean) => {
setSkipped(false)
updateAnswerSelected(answerId, selected)
},
[updateAnswerSelected]
)
const syncControlledAnswerSelection = React.useCallback(
(answerId: string, selected: boolean) => {
if (selected) {
setSkipped(false)
}
updateAnswerSelected(answerId, selected)
},
[updateAnswerSelected]
)
const registerAnswerSelection = React.useCallback(
(answerId: string, defaultSelected: boolean) => {
if (defaultSelected) {
defaultSelectedAnswerIdsRef.current = [
...defaultSelectedAnswerIdsRef.current.filter(
(currentAnswerId) => currentAnswerId !== answerId
),
answerId,
]
setSelectedAnswerIds((currentAnswerIds) => {
if (!multipleRef.current) {
return currentAnswerIds.length ? currentAnswerIds : [answerId]
}
return currentAnswerIds.includes(answerId)
? currentAnswerIds
: [...currentAnswerIds, answerId]
})
}
return () => {
defaultSelectedAnswerIdsRef.current =
defaultSelectedAnswerIdsRef.current.filter(
(currentAnswerId) => currentAnswerId !== answerId
)
setSelectedAnswerIds((currentAnswerIds) =>
currentAnswerIds.filter(
(currentAnswerId) => currentAnswerId !== answerId
)
)
}
},
[]
)
const setAnswerDefault = React.useCallback(
(answerId: string, defaultSelected: boolean) => {
if (defaultSelected) {
defaultSelectedAnswerIdsRef.current =
defaultSelectedAnswerIdsRef.current.includes(answerId)
? defaultSelectedAnswerIdsRef.current
: [...defaultSelectedAnswerIdsRef.current, answerId]
return
}
defaultSelectedAnswerIdsRef.current =
defaultSelectedAnswerIdsRef.current.filter(
(currentAnswerId) => currentAnswerId !== answerId
)
},
[]
)
const registerDescription = React.useCallback(
(registeredDescriptionId: string) => {
setDescriptionIds((currentDescriptionIds) =>
currentDescriptionIds.includes(registeredDescriptionId)
? currentDescriptionIds
: [...currentDescriptionIds, registeredDescriptionId]
)
return () => {
setDescriptionIds((currentDescriptionIds) =>
currentDescriptionIds.filter(
(currentDescriptionId) =>
currentDescriptionId !== registeredDescriptionId
)
)
}
},
[]
)
const registerError = React.useCallback((registeredErrorId: string) => {
setErrorIds((currentErrorIds) =>
currentErrorIds.includes(registeredErrorId)
? currentErrorIds
: [...currentErrorIds, registeredErrorId]
)
return () => {
setErrorIds((currentErrorIds) =>
currentErrorIds.filter(
(currentErrorId) => currentErrorId !== registeredErrorId
)
)
}
}, [])
const validate = React.useCallback(() => {
setValidationAttempted(true)
if (!valid) {
return false
}
if (!nativeValidation) {
return true
}
const invalidAnswer = answers.find(
(answer) =>
isAnswerFilled(answer) &&
answer.element.willValidate &&
!answer.element.validity.valid
)
if (!invalidAnswer) {
return true
}
invalidAnswer.element.focus()
invalidAnswer.element.reportValidity()
return false
}, [answers, nativeValidation, valid])
const focus = React.useCallback(() => {
element?.focus()
}, [element])
const focusInvalid = React.useCallback(() => {
const selectedInput = element?.querySelector<HTMLInputElement>(
"input[data-filled][name]:not(:disabled)"
)
const firstControl = element?.querySelector<HTMLElement>(
"input:not([type=hidden]):not(:disabled), textarea:not(:disabled)"
)
;(selectedInput ?? firstControl ?? element)?.focus()
}, [element])
const reset = React.useCallback(() => {
setValidationAttempted(false)
setSkipped(false)
setSelectedAnswerIds(
multiple
? [...defaultSelectedAnswerIdsRef.current]
: defaultSelectedAnswerIdsRef.current.slice(0, 1)
)
setResetVersion((version) => version + 1)
}, [multiple])
const skip = React.useCallback(() => {
if (required) {
return
}
setSelectedAnswerIds([])
setSkipped(true)
}, [required])
React.useLayoutEffect(() => {
const wasMultiple = previousMultipleRef.current
previousMultipleRef.current = multiple
if (!wasMultiple || multiple) {
return
}
setSelectedAnswerIds((currentAnswerIds) => {
const selectedAnswer = answers.find((answer) =>
currentAnswerIds.includes(answer.id)
)
return selectedAnswer ? [selectedAnswer.id] : []
})
}, [answers, multiple])
const getAnswerByElement = React.useCallback(
(answerElement: Element) =>
answers.find((answer) => answer.element === answerElement) ?? null,
[answers]
)
const getAnswerByShortcut = React.useCallback(
(shortcut: string) => {
if (shortcutByChoiceValue) {
const choiceValue = Array.from(shortcutByChoiceValue.entries()).find(
([, choiceShortcut]) => choiceShortcut === shortcut
)?.[0]
return (
answers.find(
(answer) => answer.type === "choice" && answer.value === choiceValue
) ?? null
)
}
const answerId = Array.from(shortcutByAnswerId.entries()).find(
([, answerShortcut]) => answerShortcut === shortcut
)?.[0]
return answers.find((answer) => answer.id === answerId) ?? null
},
[answers, shortcutByAnswerId, shortcutByChoiceValue]
)
const moveAnswerFocus = React.useCallback(
(currentElement: Element, direction: "next" | "previous") => {
const currentIndex = answers.findIndex(
(answer) => answer.element === currentElement
)
const currentAnswer =
currentIndex < 0 ? null : (answers[currentIndex] ?? null)
if (
!answers.length ||
(isTextEntryTarget(currentElement) &&
!isEmptyNavigableInput(currentAnswer)) ||
(currentIndex < 0 && currentElement !== element)
) {
return false
}
const nextAnswer =
currentIndex < 0
? (answers.find(isAnswerFilled) ??
(direction === "next" ? answers[0] : answers[answers.length - 1]))
: answers[
(currentIndex +
(direction === "next" ? 1 : -1) +
answers.length) %
answers.length
]
if (!nextAnswer || nextAnswer.element === currentElement) {
return false
}
if (
currentIndex >= 0 &&
isRadioTarget(currentElement) &&
isRadioTarget(nextAnswer.element)
) {
return false
}
nextAnswer.element.focus()
if (nextAnswer.type === "choice" && isRadioTarget(nextAnswer.element)) {
nextAnswer.element.click()
}
return true
},
[answers, element]
)
React.useLayoutEffect(() => {
if (!element) {
return
}
return registerItem({
choices: answerControls.flatMap((answer) =>
answer.type === "choice"
? [{ disabled: answer.ownDisabled, value: answer.value }]
: []
),
disabled,
element,
focus,
focusInvalid,
getAnswerByElement,
getAnswerByShortcut,
moveAnswerFocus,
name,
required,
reset,
skip,
status,
validate,
})
}, [
answerControls,
disabled,
element,
focus,
focusInvalid,
getAnswerByElement,
getAnswerByShortcut,
moveAnswerFocus,
name,
registerItem,
required,
reset,
skip,
status,
validate,
])
const context = React.useMemo<QuestionnaireItemContextValue>(
() => ({
active,
disabled,
hasInputAnswer,
invalid,
multiple,
name,
registerAnswerControl,
registerAnswerSelection,
registerDescription,
registerError,
required,
resetVersion,
selectedAnswerIds,
setAnswerDefault,
setAnswerSelectionFromInteraction,
shortcutByAnswerId,
shortcutByChoiceValue,
shortcuts,
status,
syncControlledAnswerSelection,
}),
[
active,
disabled,
hasInputAnswer,
invalid,
multiple,
name,
registerAnswerControl,
registerAnswerSelection,
registerDescription,
registerError,
required,
resetVersion,
selectedAnswerIds,
setAnswerDefault,
setAnswerSelectionFromInteraction,
shortcutByAnswerId,
shortcutByChoiceValue,
shortcuts,
status,
syncControlledAnswerSelection,
]
)
const setItemRef = React.useCallback(
(nextElement: HTMLFieldSetElement | null) => {
setElement(nextElement)
composeRefs(ref)?.(nextElement)
},
[ref]
)
const describedBy =
[...descriptionIds, ...(invalid ? errorIds : []), ariaDescribedBy]
.filter(Boolean)
.join(" ") || undefined
const keyShortcuts =
[
ariaKeyShortcuts,
active ? "Meta+Enter Control+Enter" : undefined,
active && answers.length ? "ArrowUp ArrowDown" : undefined,
active && !first ? "ArrowLeft" : undefined,
active && !last && status !== "unanswered" ? "ArrowRight" : undefined,
]
.filter(Boolean)
.join(" ") || undefined
const state: QuestionnaireItemState = {
active,
disabled,
invalid,
multiple,
required,
status,
}
return {
context,
itemProps: {
"aria-describedby": describedBy,
"aria-invalid": invalid || undefined,
"aria-keyshortcuts": keyShortcuts,
disabled,
hidden: !active,
inert: !active,
ref: setItemRef,
tabIndex: -1,
},
state,
}
}
export { useQuestionnaireItem }

View File

@@ -0,0 +1,525 @@
import * as React from "react"
import { composeRefs } from "../use-render"
import {
createQuestionnaireCollection,
getCollectionDefinitionWarnings,
getCollectionRegistrationWarnings,
getInitialItemName,
} from "./collection"
import type {
ItemRegistration,
PendingFocus,
QuestionnaireContextValue,
QuestionnaireRootProps,
QuestionnaireRootState,
} from "./types"
import {
compareItemOrder,
getShortcutFromKey,
isAnswerFilled,
isRadioTarget,
isTextEntryTarget,
} from "./utils"
type UseQuestionnaireRootParameters = Pick<
QuestionnaireRootProps,
| "defaultItem"
| "item"
| "items"
| "noValidate"
| "onItemChange"
| "onReset"
| "onSubmit"
| "ref"
| "shortcuts"
>
function useQuestionnaireRoot({
defaultItem,
item: controlledItem,
items: itemDefinitions,
noValidate,
onItemChange,
onReset,
onSubmit,
ref,
shortcuts: shortcutMode,
}: UseQuestionnaireRootParameters) {
const collection = React.useMemo(
() => createQuestionnaireCollection(itemDefinitions),
[itemDefinitions]
)
const [registrations, setRegistrations] = React.useState<ItemRegistration[]>(
[]
)
const [uncontrolledItem, setUncontrolledItem] = React.useState<string | null>(
() => getInitialItemName(collection, defaultItem)
)
const [rootElement, setRootElement] = React.useState<HTMLFormElement | null>(
null
)
const [domVersion, setDomVersion] = React.useState(0)
const pendingFocusRef = React.useRef<PendingFocus | null>(null)
const controlled = controlledItem !== undefined
const activeItemName = controlled ? controlledItem : uncontrolledItem
const previousActiveItemNameRef = React.useRef(activeItemName)
const nativeValidation = noValidate === false
const shortcuts = shortcutMode ?? null
const activeWarningsRef = React.useRef(new Set<string>())
React.useLayoutEffect(() => {
if (process.env.NODE_ENV === "production") {
return
}
if (!collection || !rootElement) {
activeWarningsRef.current.clear()
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) {
return
}
const warnings = [
...getCollectionDefinitionWarnings(collection, defaultItem),
...getCollectionRegistrationWarnings(
collection,
registrations,
shortcuts
),
]
const activeWarnings = new Set(warnings)
activeWarnings.forEach((warning) => {
if (!activeWarningsRef.current.has(warning)) {
console.warn(`[Questionnaire] ${warning}`)
}
})
activeWarningsRef.current = activeWarnings
})
return () => {
cancelled = true
}
}, [collection, defaultItem, registrations, rootElement, shortcuts])
React.useLayoutEffect(() => {
if (!rootElement || typeof MutationObserver === "undefined") {
return
}
const observer = new MutationObserver(() => {
setDomVersion((version) => version + 1)
})
observer.observe(rootElement, { childList: true, subtree: true })
return () => observer.disconnect()
}, [rootElement])
const runtimeItems = React.useMemo(
() =>
registrations
.filter((registration) => !registration.disabled)
.sort(compareItemOrder),
[domVersion, registrations]
)
const runtimeItemByName = React.useMemo(
() =>
new Map(
runtimeItems.map((runtimeItem) => [runtimeItem.name, runtimeItem])
),
[runtimeItems]
)
const logicalItems = collection?.enabledItems ?? runtimeItems
const currentIndex = logicalItems.findIndex(
(logicalItem) => logicalItem.name === activeItemName
)
const activeItem =
currentIndex < 0 || !activeItemName
? null
: (runtimeItemByName.get(activeItemName) ?? null)
const activeDefinition = activeItemName
? collection?.itemByName.get(activeItemName)
: undefined
const activeItemRequired =
currentIndex < 0
? null
: activeDefinition
? Boolean(activeDefinition.required)
: (activeItem?.required ?? false)
const activeItemStatus =
currentIndex < 0
? null
: (activeItem?.status ?? (activeItemName ? "unanswered" : null))
const orderedRegistrations = React.useMemo(
() =>
collection
? collection.enabledItems.flatMap((definition) => {
const registration = runtimeItemByName.get(definition.name)
return registration ? [registration] : []
})
: runtimeItems,
[collection, runtimeItemByName, runtimeItems]
)
const total = logicalItems.length
const current = currentIndex < 0 ? 0 : currentIndex + 1
const first = total > 0 && currentIndex === 0
const last = total > 0 && currentIndex === total - 1
const setItem = React.useCallback(
(nextItem: string, focusTarget: PendingFocus["target"] = "item") => {
if (nextItem === activeItemName) {
return
}
pendingFocusRef.current = {
name: nextItem,
target: focusTarget,
}
if (!controlled) {
setUncontrolledItem(nextItem)
}
onItemChange?.(nextItem)
},
[activeItemName, controlled, onItemChange]
)
React.useLayoutEffect(() => {
if (total === 0) {
return
}
if (currentIndex < 0) {
if (!controlled && activeItemName === null) {
setUncontrolledItem(logicalItems[0].name)
return
}
setItem(logicalItems[0].name)
return
}
const pendingFocus = pendingFocusRef.current
const activeItemChanged =
previousActiveItemNameRef.current !== activeItemName
previousActiveItemNameRef.current = activeItemName
if (!pendingFocus || pendingFocus.name !== activeItemName) {
if (controlled && activeItemChanged) {
pendingFocusRef.current = null
activeItem?.focus()
}
return
}
if (pendingFocus.target === "invalid") {
activeItem?.focusInvalid()
} else {
activeItem?.focus()
}
pendingFocusRef.current = null
}, [
activeItem,
activeItemName,
controlled,
currentIndex,
logicalItems,
setItem,
total,
])
const registerItem = React.useCallback((registration: ItemRegistration) => {
setRegistrations((currentRegistrations) => [
...currentRegistrations.filter(
(currentRegistration) =>
currentRegistration.element !== registration.element &&
currentRegistration.name !== registration.name
),
registration,
])
return () => {
setRegistrations((currentRegistrations) =>
currentRegistrations.filter(
(currentRegistration) => currentRegistration !== registration
)
)
}
}, [])
const goPrevious = React.useCallback(() => {
if (currentIndex <= 0) {
return
}
setItem(logicalItems[currentIndex - 1].name)
}, [currentIndex, logicalItems, setItem])
const goNext = React.useCallback(() => {
if (!activeItem || currentIndex >= total - 1) {
return
}
if (!activeItem.validate()) {
activeItem.focusInvalid()
return
}
setItem(logicalItems[currentIndex + 1].name)
}, [activeItem, currentIndex, logicalItems, setItem, total])
const confirmCurrent = React.useCallback(() => {
if (!activeItem) {
return
}
if (!activeItem.validate()) {
activeItem.focusInvalid()
return
}
if (last) {
rootElement?.requestSubmit()
return
}
setItem(logicalItems[currentIndex + 1].name)
}, [activeItem, currentIndex, last, logicalItems, rootElement, setItem])
const skipCurrent = React.useCallback(() => {
if (!activeItem || activeItem.required) {
return
}
activeItem.skip()
if (!last) {
setItem(logicalItems[currentIndex + 1].name)
return
}
queueMicrotask(() => {
rootElement?.requestSubmit()
})
}, [activeItem, currentIndex, last, logicalItems, rootElement, setItem])
function handleReset(event: React.FormEvent<HTMLFormElement>) {
onReset?.(event)
if (event.defaultPrevented) {
return
}
for (const registration of registrations) {
registration.reset()
}
const resetItemName = collection
? getInitialItemName(collection, defaultItem)
: (runtimeItems.find((registration) => registration.name === defaultItem)
?.name ?? runtimeItems[0]?.name)
if (resetItemName) {
setItem(resetItemName)
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
const firstInvalidItem = orderedRegistrations.find(
(registration) => !registration.validate()
)
if (firstInvalidItem) {
event.preventDefault()
setItem(firstInvalidItem.name, "invalid")
if (firstInvalidItem.name === activeItemName) {
firstInvalidItem.focusInvalid()
pendingFocusRef.current = null
}
return
}
onSubmit?.(event)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLFormElement>) {
if (
event.defaultPrevented ||
event.nativeEvent.isComposing ||
event.keyCode === 229 ||
!activeItem ||
!(event.target instanceof Element)
) {
return
}
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey
) {
event.preventDefault()
if (!event.repeat) {
confirmCurrent()
}
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
const moved = activeItem.moveAnswerFocus(
event.target,
event.key === "ArrowDown" ? "next" : "previous"
)
if (moved) {
event.preventDefault()
return
}
}
if (
(event.key === "ArrowLeft" || event.key === "ArrowRight") &&
!isTextEntryTarget(event.target) &&
!isRadioTarget(event.target)
) {
event.preventDefault()
if (event.repeat) {
return
}
if (event.key === "ArrowLeft") {
goPrevious()
} else if (activeItem.status !== "unanswered") {
goNext()
}
return
}
if (event.key === "Enter") {
const answer = activeItem.getAnswerByElement(event.target)
if (!answer) {
return
}
event.preventDefault()
if (!event.repeat && isAnswerFilled(answer)) {
confirmCurrent()
}
return
}
if (!shortcuts || isTextEntryTarget(event.target)) {
return
}
const shortcut = getShortcutFromKey(event.key, shortcuts)
const answer = shortcut ? activeItem.getAnswerByShortcut(shortcut) : null
if (!answer) {
return
}
event.preventDefault()
if (event.repeat) {
return
}
answer.element.focus()
if (answer.type === "choice") {
answer.element.click()
}
}
const state: QuestionnaireRootState = {
current,
first,
last,
total,
}
const context = React.useMemo<QuestionnaireContextValue>(
() => ({
...state,
activeItem,
activeItemName,
activeItemRequired,
activeItemStatus,
domVersion,
goNext,
goPrevious,
itemDefinitionByName: collection?.itemByName ?? null,
nativeValidation,
registerItem,
shortcuts,
skipCurrent,
}),
[
activeItem,
activeItemName,
activeItemRequired,
activeItemStatus,
collection,
current,
domVersion,
first,
goNext,
goPrevious,
last,
nativeValidation,
registerItem,
shortcuts,
skipCurrent,
total,
]
)
const setRootRef = React.useCallback(
(element: HTMLFormElement | null) => {
setRootElement(element)
composeRefs(ref)?.(element)
},
[ref]
)
return {
context,
rootProps: {
"data-shortcuts": shortcuts ?? undefined,
onKeyDown: handleKeyDown,
onReset: handleReset,
onSubmit: handleSubmit,
ref: setRootRef,
},
state,
}
}
export { useQuestionnaireRoot }

View File

@@ -0,0 +1,141 @@
import type {
AnswerControlRegistration,
ItemRegistration,
QuestionnaireShortcutMode,
} from "./types"
function hasInputValue(value: unknown) {
if (Array.isArray(value)) {
return value.some((item) => String(item).trim().length > 0)
}
return (
value !== undefined && value !== null && String(value).trim().length > 0
)
}
function getShortcutKeys(shortcuts: QuestionnaireShortcutMode | null) {
if (shortcuts === "letters") {
return Array.from({ length: 26 }, (_, index) =>
String.fromCharCode(65 + index)
)
}
if (shortcuts === "numbers") {
return Array.from({ length: 9 }, (_, index) => String(index + 1))
}
return []
}
function getShortcutFromKey(key: string, shortcuts: QuestionnaireShortcutMode) {
const normalizedKey = shortcuts === "letters" ? key.toUpperCase() : key
return getShortcutKeys(shortcuts).includes(normalizedKey)
? normalizedKey
: null
}
function getAnswerKeyShortcuts(shortcut: string | null, filled: boolean) {
return (
[shortcut, filled ? "Enter" : null].filter(Boolean).join(" ") || undefined
)
}
function isAnswerFilled(answer: AnswerControlRegistration) {
if (answer.type === "choice") {
return answer.element.checked
}
return (
answer.element.hasAttribute("name") && hasInputValue(answer.element.value)
)
}
function isEmptyNavigableInput(answer: AnswerControlRegistration | null) {
return (
answer?.type === "input" &&
["email", "password", "search", "tel", "text", "url"].includes(
answer.element.type
) &&
!hasInputValue(answer.element.value)
)
}
function isTextEntryTarget(element: Element) {
if (
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
) {
return true
}
if (element instanceof HTMLInputElement) {
return !["button", "checkbox", "radio", "reset", "submit"].includes(
element.type
)
}
return element instanceof HTMLElement && element.isContentEditable
}
function isRadioTarget(element: Element) {
return element instanceof HTMLInputElement && element.type === "radio"
}
function compareItemOrder(
firstItem: ItemRegistration,
secondItem: ItemRegistration
) {
if (firstItem.element === secondItem.element) {
return 0
}
const position = firstItem.element.compareDocumentPosition(secondItem.element)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1
}
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
return 1
}
return 0
}
function compareAnswerOrder(
firstAnswer: AnswerControlRegistration,
secondAnswer: AnswerControlRegistration
) {
if (firstAnswer.element === secondAnswer.element) {
return 0
}
const position = firstAnswer.element.compareDocumentPosition(
secondAnswer.element
)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1
}
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
return 1
}
return 0
}
export {
compareAnswerOrder,
compareItemOrder,
getAnswerKeyShortcuts,
getShortcutFromKey,
getShortcutKeys,
hasInputValue,
isAnswerFilled,
isEmptyNavigableInput,
isRadioTarget,
isTextEntryTarget,
}

View File

@@ -0,0 +1,16 @@
# use-render
Internal polymorphic-render helper (`useRender`, `mergeProps`) that powers the
`render` prop on `@shadcn/react` primitives. It lets a component render as a
custom element while merging the primitive's props, refs, and state attributes
onto it.
## Attribution
This code is adapted from [Base UI](https://github.com/mui/base-ui)'s
`useRender` implementation (`@base-ui/react/use-render`), MIT licensed,
© MUI. We keep a local copy so the primitives carry no runtime dependency on
Base UI.
If Base UI publishes `useRender` as an independent, standalone package, we will
switch to it and remove this copy.

View File

@@ -0,0 +1,208 @@
// A poor man's version of base-ui useRender.
// see https://base-ui.com/react/utils/use-render.
// TODO: Replace if base-ui useRender is published as a package.
import * as React from "react"
type RenderState = Record<string, unknown>
type RenderFunction<TState extends RenderState> = (
props: Record<string, unknown>,
state: TState
) => React.ReactElement | null
type RenderProp<TState extends RenderState> =
| React.ReactElement
| RenderFunction<TState>
type StateAttributesMapping<TState extends RenderState> = Partial<{
[K in keyof TState]: (
value: TState[K]
) => Record<string, string | undefined> | null | undefined
}>
type UseRenderComponentProps<
TElement extends React.ElementType,
TState extends RenderState = RenderState,
> = React.ComponentPropsWithRef<TElement> & {
render?: RenderProp<TState>
}
type UseRenderOptions<
TElement extends React.ElementType,
TState extends RenderState,
> = {
defaultTagName: TElement
props?: React.ComponentPropsWithRef<TElement>
render?: RenderProp<TState>
state?: TState
stateAttributesMapping?: StateAttributesMapping<TState>
}
function useRender<
TElement extends React.ElementType,
TState extends RenderState = RenderState,
>({
defaultTagName,
props,
render,
state = {} as TState,
stateAttributesMapping,
}: UseRenderOptions<TElement, TState>) {
const elementProps = mergeProps<TElement>(
getStateAttributes(state, stateAttributesMapping),
props
)
if (!render) {
return React.createElement(defaultTagName, elementProps)
}
if (typeof render === "function") {
return render(elementProps, state)
}
if (!React.isValidElement(render)) {
return null
}
const renderProps = render.props as Record<string, unknown>
const propsWithRenderProps = mergeProps<TElement>(elementProps, renderProps)
const propsWithRef = {
...propsWithRenderProps,
ref: composeRefs(
elementProps.ref as React.Ref<unknown> | undefined,
renderProps.ref as React.Ref<unknown> | undefined
),
}
return React.cloneElement(
render,
propsWithRef as React.Attributes & Record<string, unknown>
)
}
function mergeProps<TElement extends React.ElementType>(
...sources: Array<
React.ComponentPropsWithRef<TElement> | Record<string, unknown> | undefined
>
) {
const result: Record<string, unknown> = {}
for (const source of sources) {
if (!source) {
continue
}
const sourceProps = source as Record<string, unknown>
for (const key of Object.keys(sourceProps)) {
const value = sourceProps[key]
if (value === undefined) {
continue
}
const current = result[key]
if (key === "className") {
result[key] = [current, value].filter(Boolean).join(" ")
} else if (key === "style") {
result[key] = {
...(current as React.CSSProperties | undefined),
...(value as React.CSSProperties | undefined),
}
} else if (key === "ref") {
result[key] = composeRefs(
current as React.Ref<unknown> | undefined,
value as React.Ref<unknown> | undefined
)
} else if (
isEventHandler(key) &&
typeof current === "function" &&
typeof value === "function"
) {
result[key] = composeEventHandlers(
value as EventHandler,
current as EventHandler
)
} else {
result[key] = value
}
}
}
return result as React.ComponentPropsWithRef<TElement>
}
function getStateAttributes<TState extends RenderState>(
state: TState,
mapping?: StateAttributesMapping<TState>
) {
const props: Record<string, unknown> = {}
for (const key of Object.keys(state) as Array<keyof TState>) {
const value = state[key]
const attributes = mapping?.[key]?.(value)
if (attributes) {
Object.assign(props, attributes)
continue
}
if (key === "slot") {
props["data-slot"] = value
continue
}
const attribute = `data-${String(key).replace(
/[A-Z]/g,
(letter) => `-${letter.toLowerCase()}`
)}`
if (typeof value === "boolean") {
props[attribute] = value ? "" : undefined
} else if (value !== null && value !== undefined) {
props[attribute] = String(value)
}
}
return props
}
type EventHandler = (event: React.SyntheticEvent) => void
function composeEventHandlers(theirs: EventHandler, ours: EventHandler) {
return function handleEvent(event: React.SyntheticEvent) {
theirs(event)
if (!event.defaultPrevented) {
ours(event)
}
}
}
function isEventHandler(key: string) {
return /^on[A-Z]/.test(key)
}
function composeRefs<T>(
...refs: Array<React.Ref<T> | undefined>
): React.RefCallback<T> | undefined {
const validRefs = refs.filter(Boolean)
if (validRefs.length === 0) {
return undefined
}
return (value) => {
for (const ref of validRefs) {
if (typeof ref === "function") {
ref(value)
} else if (ref) {
ref.current = value
}
}
}
}
export { composeRefs, mergeProps, useRender, type UseRenderComponentProps }

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"isolatedModules": false,
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,47 @@
import { readFileSync, writeFileSync } from "fs"
import { defineConfig } from "tsup"
const CLIENT_UI_ENTRIES = [
"dist/message-scroller/index.js",
"dist/questionnaire/index.js",
]
export default defineConfig((options) => ({
clean: !options.watch,
dts: true,
entry: {
"message-scroller/index": "src/message-scroller/index.ts",
"questionnaire/index": "src/questionnaire/index.ts",
},
format: ["esm"],
sourcemap: false,
minify: true,
target: "esnext",
outDir: "dist",
treeshake: true,
plugins: [useClientDirectivePlugin(CLIENT_UI_ENTRIES)],
}))
// Prepends `"use client"` to listed dist entry files after the build. Bundlers
// strip the directive from source, but RSC apps need it on the published module
// so imports like `@shadcn/react/message-scroller` resolve as client boundaries.
function useClientDirectivePlugin(entries: string[]) {
return {
name: "use-client-directive",
buildEnd({ writtenFiles }: { writtenFiles: { name: string }[] }) {
const uiFiles = writtenFiles.filter((file) =>
entries.some((entry) => file.name.replace(/\\/g, "/").endsWith(entry))
)
for (const file of uiFiles) {
const code = readFileSync(file.name, "utf8")
if (/^["']use client["'];?/.test(code)) {
continue
}
writeFileSync(file.name, `"use client";\n${code}`)
}
},
}
}

View File

@@ -0,0 +1,25 @@
import tsconfigPaths from "vite-tsconfig-paths"
import { defineConfig } from "vitest/config"
// Browser tests to cover what jsdom cannot.
// for example native scroll anchoring on prepend.
export default defineConfig({
test: {
include: ["src/**/*.browser.test.{ts,tsx}"],
setupFiles: ["./vitest.browser.setup.ts"],
browser: {
enabled: true,
provider: "playwright",
headless: true,
instances: [{ browser: "chromium" }],
},
},
optimizeDeps: {
include: ["react", "react-dom/client", "react/jsx-dev-runtime"],
},
plugins: [
tsconfigPaths({
ignoreConfigErrors: true,
}),
],
})

View File

@@ -0,0 +1,4 @@
// React's act() needs this flag outside of a testing-library environment.
;(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true

View File

@@ -0,0 +1,19 @@
import tsconfigPaths from "vite-tsconfig-paths"
import { configDefaults, defineConfig } from "vitest/config"
export default defineConfig({
test: {
exclude: [
...configDefaults.exclude,
"**/node_modules/**",
// Browser-mode tests run via vitest.browser.config.ts, not in jsdom.
"**/*.browser.test.*",
],
testTimeout: 8000,
},
plugins: [
tsconfigPaths({
ignoreConfigErrors: true,
}),
],
})