@djangocfg/widget-chat 0.1.13 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/package.json +10 -10
- package/src/composer/controls/voice.tsx +6 -1
- package/src/composer/field/input.tsx +22 -2
- package/src/composer/focus-field.ts +2 -1
- package/src/core/index.ts +1 -0
- package/src/core/scroll/index.ts +2 -0
- package/src/core/scroll/pin.ts +34 -0
- package/src/core/viewport/back-stack.ts +28 -0
- package/src/core/viewport/box.ts +57 -0
- package/src/core/viewport/index.ts +8 -0
- package/src/i18n/index.ts +15 -50
- package/src/i18n/types.ts +3 -16
- package/src/primitives/index.ts +5 -0
- package/src/primitives/layer-registry.ts +49 -0
- package/src/primitives/transcript.tsx +5 -2
- package/src/primitives/use-hold-bottom.ts +88 -0
- package/src/primitives/use-layer.ts +113 -0
- package/src/primitives/use-stick-to-bottom.ts +13 -1
- package/src/primitives/use-viewport-box.ts +79 -0
- package/src/styles/composer.css +41 -0
- package/src/styles/index.css +1 -0
- package/src/styles/sheet.css +71 -0
- package/src/ui/chat-sheet.tsx +107 -0
- package/src/ui/index.ts +2 -0
- package/src/i18n/plural.ts +0 -62
package/README.md
CHANGED
|
@@ -100,6 +100,33 @@ allowlist in `core/media/allowed-src.ts` — and the first reads it lazily for
|
|
|
100
100
|
exactly this reason. Setting it later leaves already-written links addressing
|
|
101
101
|
the old scheme, which then render as plain text.
|
|
102
102
|
|
|
103
|
+
## Phones: the package owns the browser problems
|
|
104
|
+
|
|
105
|
+
A host mounts `<ChatSheet>` around `<Chat>` and passes nothing else. Everything below is built in and on by default.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import { Chat, ChatSheet } from "@djangocfg/widget-chat";
|
|
109
|
+
import "@djangocfg/widget-chat/styles"; // includes styles/sheet.css
|
|
110
|
+
|
|
111
|
+
<ChatSheet open={open} onOpenChange={setOpen} title="Assistant" closeLabel="Close">
|
|
112
|
+
<Chat adapter={adapter} body={{ kind: "packaged" }} className="flex min-h-0 flex-1 flex-col" />
|
|
113
|
+
</ChatSheet>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Problem | Root cause | Built-in answer |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| Panel does not fill the screen with the keyboard up; the page shows through | iOS keeps the layout viewport tall and pans the visual one; a `100dvh` layer is taller than what is visible and a rAF-late follower leaves a strip | `ChatSheet` is sized from `visualViewport` (`--vvh/--vvw/--vv-top/--vv-left/--kb`, written inside the event), with `dvh`/`svh`/`%` fallbacks, safe-area padding, an opaque backdrop behind it |
|
|
119
|
+
| History jumps when the input is focused | `use-stick-to-bottom` watches the CONTENT, not the scroller. The keyboard shrinks the scroller, nothing grew, nothing followed; the browser's focus scroll was then read as the reader scrolling up and released the lock | `useHoldBottom` (inside `Transcript`): if the reader was within `PIN_THRESHOLD` (80px) of the bottom, a scroller resize, a viewport change or a field focus/blur re-pins instantly and ignores the browser's scrolls for `SETTLE_MS`. A reader scrolled up on purpose is left alone, and `JumpToLatest` is offered |
|
|
120
|
+
| Page behind scrolls, is focusable, is read out | none of it was locked | `useLayer`: `inert` on the page root (`[data-app-root]`, else every other child of `<body>`), `overflow:hidden` on `<html>` with scrollbar compensation (never `position:fixed` on body, so no jump), `overscroll-behavior: contain`, Back closes (one history entry per layer, the page's own history state is kept), Escape via the dialog |
|
|
121
|
+
|
|
122
|
+
Opt-outs: `behaviour={{ viewport, back, inert, appRoot, lockScroll, autoFocus }}`. Not on a phone sheet? `useViewportBox`, `useLayer` and `useHoldBottom` are exported from `primitives` for a host with its own layer; the pure parts (`readViewportBox`, `boxVars`, `nextPinned`, `distanceFromBottom`) from `core`. `focusComposerField` now focuses with `preventScroll`.
|
|
123
|
+
|
|
124
|
+
The sheet's close label defaults to English "Close"; pass `closeLabel` for other languages.
|
|
125
|
+
|
|
126
|
+
### What a real phone must confirm
|
|
127
|
+
|
|
128
|
+
The maths and the pin decision are unit-tested; the following needs an iPhone (Safari and Chrome): the strip above the keyboard and the accessory bar, the iOS pan while typing, rotation, Back gesture vs. the app's own router history, the settle window length against the real keyboard animation, and `inert` with VoiceOver.
|
|
129
|
+
|
|
103
130
|
## Documentation
|
|
104
131
|
|
|
105
132
|
This file is the contract. [`@docs/`](./@docs/README.md) is the long form —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/widget-chat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "A transport-agnostic chat surface: a pure core, a store, primitives, a composer, and the assembled components a host mounts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chat",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"test:watch": "vitest"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@djangocfg/widget-kit": "^0.1.
|
|
59
|
-
"@djangocfg/widget-media": "^0.1.
|
|
58
|
+
"@djangocfg/widget-kit": "^0.1.16",
|
|
59
|
+
"@djangocfg/widget-media": "^0.1.12",
|
|
60
60
|
"@streamdown/code": "^1.1.1",
|
|
61
61
|
"@streamdown/math": "^1.0.2",
|
|
62
62
|
"mdast-util-to-string": "^4.0.0",
|
|
@@ -66,19 +66,19 @@
|
|
|
66
66
|
"use-stick-to-bottom": "^1.1.6"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
|
-
"@djangocfg/api": "^2.1.
|
|
70
|
-
"@djangocfg/i18n": "^2.1.
|
|
71
|
-
"@djangocfg/ui-core": "^2.1.
|
|
69
|
+
"@djangocfg/api": "^2.1.586",
|
|
70
|
+
"@djangocfg/i18n": "^2.1.586",
|
|
71
|
+
"@djangocfg/ui-core": "^2.1.586",
|
|
72
72
|
"lucide-react": "^0.545.0",
|
|
73
73
|
"react": "^19.0.0",
|
|
74
74
|
"remark-gfm": "^4.0.0",
|
|
75
75
|
"streamdown": "^2.6.0"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@djangocfg/api": "^2.1.
|
|
79
|
-
"@djangocfg/i18n": "^2.1.
|
|
80
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
81
|
-
"@djangocfg/ui-core": "^2.1.
|
|
78
|
+
"@djangocfg/api": "^2.1.586",
|
|
79
|
+
"@djangocfg/i18n": "^2.1.586",
|
|
80
|
+
"@djangocfg/typescript-config": "^2.1.586",
|
|
81
|
+
"@djangocfg/ui-core": "^2.1.586",
|
|
82
82
|
"@storybook/react-vite": "^10.5.0",
|
|
83
83
|
"@types/node": "^25.9.5",
|
|
84
84
|
"@types/react": "19.2.15",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
3
3
|
|
|
4
|
+
import { useIsTouch } from "@djangocfg/ui-core/hooks";
|
|
5
|
+
|
|
4
6
|
import { useChatCopy } from "../../i18n";
|
|
5
7
|
import { useDismiss } from "../../primitives";
|
|
6
8
|
import { useCapabilities, useVoice } from "../../store";
|
|
@@ -57,6 +59,9 @@ export function ComposerVoice({ className, children, language }: ComposerVoicePr
|
|
|
57
59
|
language?.value,
|
|
58
60
|
voiceSounds?.play,
|
|
59
61
|
);
|
|
62
|
+
// A touch device has its own keyboard dictation: a second microphone (and its language picker) is a duplicate tile,
|
|
63
|
+
// whatever the host passes. A desktop keeps both.
|
|
64
|
+
const touch = useIsTouch();
|
|
60
65
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
61
66
|
// Which view the ONE panel is showing. A view switch rather than a second
|
|
62
67
|
// popover: nesting one floating surface inside another makes both fight over
|
|
@@ -143,7 +148,7 @@ export function ComposerVoice({ className, children, language }: ComposerVoicePr
|
|
|
143
148
|
if (!recording && text !== "") armed.current = false;
|
|
144
149
|
}, [recording, text, setText]);
|
|
145
150
|
|
|
146
|
-
if (!available) return null;
|
|
151
|
+
if (!available || touch) return null;
|
|
147
152
|
|
|
148
153
|
const micLabel = recording ? copy.stop : copy.start;
|
|
149
154
|
const mic = (
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type Ref,
|
|
9
9
|
} from "react";
|
|
10
10
|
|
|
11
|
+
import { useTouchSafeStyle } from "@djangocfg/ui-core/hooks";
|
|
11
12
|
import { FIELD_SIZING, useAutosize } from "./use-autosize";
|
|
12
13
|
import { useComposer } from "../context";
|
|
13
14
|
|
|
@@ -89,6 +90,16 @@ export function ComposerInput({
|
|
|
89
90
|
const { draft, setText, handlePaste, send, canSend } = useComposer();
|
|
90
91
|
const ref = useRef<HTMLTextAreaElement | null>(null);
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* iOS WebKit auto-zoom prevention (iOS-only targeting).
|
|
95
|
+
*
|
|
96
|
+
* Returns max(16px, 1em) for iOS devices to prevent viewport zoom on focus.
|
|
97
|
+
* Android and desktop return undefined (keep optimal font-size).
|
|
98
|
+
*
|
|
99
|
+
* @see {@link @researches/2026-10-25__ios-webkit-auto-zoom}
|
|
100
|
+
*/
|
|
101
|
+
const touchSafeFontSize = useTouchSafeStyle();
|
|
102
|
+
|
|
92
103
|
/**
|
|
93
104
|
* Fill the internal ref (autosize measures through it) and the host's, if
|
|
94
105
|
* one was passed. A callback rather than handing the host `ref` directly:
|
|
@@ -195,14 +206,23 @@ export function ComposerInput({
|
|
|
195
206
|
*
|
|
196
207
|
* Empty on browsers without support, where the effect above sizes the box
|
|
197
208
|
* instead. Never both — a written `height` would override this.
|
|
209
|
+
*
|
|
210
|
+
* iOS auto-zoom prevention: merge touchSafeFontSize if present (iOS-only).
|
|
211
|
+
* On Android/desktop, touchSafeFontSize is undefined and style remains unchanged.
|
|
198
212
|
*/
|
|
199
213
|
// `fieldSizing` is newer than the pinned csstype, so the cast is the
|
|
200
214
|
// property being real and the types being behind — not a shape being
|
|
201
215
|
// forced. Remove it once csstype carries the property.
|
|
202
216
|
style={
|
|
203
217
|
FIELD_SIZING
|
|
204
|
-
? ({
|
|
205
|
-
|
|
218
|
+
? ({
|
|
219
|
+
fieldSizing: "content",
|
|
220
|
+
maxHeight: `${maxRows}lh`,
|
|
221
|
+
...(touchSafeFontSize && { fontSize: touchSafeFontSize }),
|
|
222
|
+
} as CSSProperties)
|
|
223
|
+
: touchSafeFontSize
|
|
224
|
+
? ({ fontSize: touchSafeFontSize } as CSSProperties)
|
|
225
|
+
: undefined
|
|
206
226
|
}
|
|
207
227
|
onChange={onChange}
|
|
208
228
|
onPaste={onPaste}
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
export function focusComposerField(root: HTMLElement | null | undefined): void {
|
|
22
22
|
const field = root?.querySelector("textarea");
|
|
23
23
|
if (!field || field === document.activeElement) return;
|
|
24
|
-
|
|
24
|
+
// `preventScroll`: the browser's own scroll-into-view moves ancestors and the page; the transcript pins itself.
|
|
25
|
+
field.focus({ preventScroll: true });
|
|
25
26
|
const end = field.value.length;
|
|
26
27
|
field.setSelectionRange(end, end);
|
|
27
28
|
}
|
package/src/core/index.ts
CHANGED
package/src/core/scroll/index.ts
CHANGED
|
@@ -15,3 +15,5 @@ export { MissedCount, missedRows } from "./missed";
|
|
|
15
15
|
export type { MissedRole } from "./missed";
|
|
16
16
|
export { isOwnSend } from "./sent";
|
|
17
17
|
export type { SentRole } from "./sent";
|
|
18
|
+
export { PIN_THRESHOLD, SETTLE_MS, distanceFromBottom, nextPinned } from "./pin";
|
|
19
|
+
export type { ScrollMetrics } from "./pin";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Whether the reader is "at the bottom", and whether the view should follow it.
|
|
2
|
+
//
|
|
3
|
+
// The follow itself is `use-stick-to-bottom`'s (see `primitives/use-stick-to-bottom`). What the library cannot
|
|
4
|
+
// know is that the SCROLLER can change height without the content changing: an on-screen keyboard, a growing
|
|
5
|
+
// composer, a rotation. The bottom then moves away under a reader who never scrolled. These two functions
|
|
6
|
+
// decide, without a DOM, whether that reader was at the bottom and so must be taken back to it.
|
|
7
|
+
|
|
8
|
+
/** Distance from the newest message that still counts as "at the bottom" (px). */
|
|
9
|
+
export const PIN_THRESHOLD = 80;
|
|
10
|
+
|
|
11
|
+
/** How long after a focus or a viewport change the browser's own scrolling is not read as the reader leaving (ms). The iOS keyboard animates for about 400. */
|
|
12
|
+
export const SETTLE_MS = 700;
|
|
13
|
+
|
|
14
|
+
export interface ScrollMetrics {
|
|
15
|
+
readonly scrollHeight: number;
|
|
16
|
+
readonly scrollTop: number;
|
|
17
|
+
readonly clientHeight: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function distanceFromBottom(m: ScrollMetrics): number {
|
|
21
|
+
return Math.max(0, m.scrollHeight - m.scrollTop - m.clientHeight);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The reader's intent, updated on each scroll event.
|
|
26
|
+
*
|
|
27
|
+
* While `settling`, a scroll event is the BROWSER's (it scrolls a focused field into view, clamps a shrinking box),
|
|
28
|
+
* not the reader's, so the previous intent stands. Outside it, being within `threshold` of the bottom is being pinned
|
|
29
|
+
* and anything further is a reader who scrolled up on purpose.
|
|
30
|
+
*/
|
|
31
|
+
export function nextPinned(previous: boolean, m: ScrollMetrics, settling: boolean, threshold: number = PIN_THRESHOLD): boolean {
|
|
32
|
+
if (settling) return previous;
|
|
33
|
+
return distanceFromBottom(m) <= threshold;
|
|
34
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** One browser-history entry pushed by an open layer. `depth` is stored in the entry's state so a popstate tells how many entries are gone. */
|
|
2
|
+
export interface Entry {
|
|
3
|
+
id: string;
|
|
4
|
+
depth: number;
|
|
5
|
+
/** The layer is still open. A layer closed from inside leaves a dead entry until `settle` removes it. */
|
|
6
|
+
live: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function push(entries: Entry[], id: string): Entry[] {
|
|
10
|
+
return [...entries, { id, depth: entries.length + 1, live: true }];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The layer closed by itself (button, Escape, overlay). */
|
|
14
|
+
export function release(entries: Entry[], id: string): Entry[] {
|
|
15
|
+
return entries.map((entry) => (entry.id === id ? { ...entry, live: false } : entry));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** History moved to `depth` (0 = the page): entries above it are gone; the live ones among them must close. */
|
|
19
|
+
export function reconcile(entries: Entry[], depth: number): { entries: Entry[]; closed: string[] } {
|
|
20
|
+
return { entries: entries.filter((e) => e.depth <= depth), closed: entries.filter((e) => e.depth > depth && e.live).map((e) => e.id) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Dead entries on top of the stack are popped with one `history.go(-pop)`; dead ones under a live layer wait for it. */
|
|
24
|
+
export function settle(entries: Entry[]): { entries: Entry[]; pop: number } {
|
|
25
|
+
let end = entries.length;
|
|
26
|
+
while (end > 0 && !entries[end - 1]?.live) end -= 1;
|
|
27
|
+
return { entries: entries.slice(0, end), pop: entries.length - end };
|
|
28
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The visible area of a phone, as numbers. No DOM: `visualViewport` is read by
|
|
2
|
+
// the caller (`primitives/use-viewport-box`) and handed in as plain values, so
|
|
3
|
+
// the arithmetic is testable headless.
|
|
4
|
+
|
|
5
|
+
/** Smallest shrink of the visible area that counts as an on-screen keyboard (a collapsing URL bar is about 100px). */
|
|
6
|
+
export const KEYBOARD_MIN = 120;
|
|
7
|
+
|
|
8
|
+
/** What `window.visualViewport` reports, as plain numbers. */
|
|
9
|
+
export interface ViewportSample {
|
|
10
|
+
readonly width: number;
|
|
11
|
+
readonly height: number;
|
|
12
|
+
readonly offsetTop: number;
|
|
13
|
+
readonly offsetLeft: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ViewportBox {
|
|
17
|
+
/** Visible width in px. */
|
|
18
|
+
readonly width: number;
|
|
19
|
+
/** Visible height in px. */
|
|
20
|
+
readonly height: number;
|
|
21
|
+
/** Distance of the visible area from the top of the layout viewport (iOS pans it when the keyboard opens). */
|
|
22
|
+
readonly top: number;
|
|
23
|
+
readonly left: number;
|
|
24
|
+
/** Keyboard height: the part of the layout viewport the visible area does not cover. */
|
|
25
|
+
readonly kb: number;
|
|
26
|
+
readonly keyboard: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pure reading of `visualViewport`.
|
|
31
|
+
*
|
|
32
|
+
* iOS keeps the layout viewport tall (`kb` > 0) and pans the visual one; Android with
|
|
33
|
+
* `interactiveWidget: resizes-content` shrinks the layout viewport (`kb` stays 0), so the keyboard is
|
|
34
|
+
* also recognised by the visible height falling below the tallest one seen (`baseline`).
|
|
35
|
+
*/
|
|
36
|
+
export function readViewportBox(vv: ViewportSample, innerHeight: number, baseline: number): ViewportBox {
|
|
37
|
+
const kb = Math.max(0, Math.round(innerHeight - vv.height - vv.offsetTop));
|
|
38
|
+
return {
|
|
39
|
+
width: vv.width,
|
|
40
|
+
height: vv.height,
|
|
41
|
+
top: Math.max(0, vv.offsetTop),
|
|
42
|
+
left: Math.max(0, vv.offsetLeft),
|
|
43
|
+
kb,
|
|
44
|
+
keyboard: kb > KEYBOARD_MIN || baseline - vv.height > KEYBOARD_MIN,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The custom properties a layer carries so CSS can size itself from the box (`styles/sheet.css` reads them with fallbacks). */
|
|
49
|
+
export function boxVars(box: ViewportBox): Record<string, string> {
|
|
50
|
+
return {
|
|
51
|
+
"--vvh": `${box.height}px`,
|
|
52
|
+
"--vvw": `${box.width}px`,
|
|
53
|
+
"--vv-top": `${box.top}px`,
|
|
54
|
+
"--vv-left": `${box.left}px`,
|
|
55
|
+
"--kb": `${box.kb}px`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The phone's visible area and the browser-history stack of open layers: numbers and lists, no DOM.
|
|
2
|
+
//
|
|
3
|
+
// May import: utils.
|
|
4
|
+
|
|
5
|
+
export { KEYBOARD_MIN, boxVars, readViewportBox } from "./box";
|
|
6
|
+
export type { ViewportBox, ViewportSample } from "./box";
|
|
7
|
+
export { push as pushLayer, reconcile as reconcileLayers, release as releaseLayer, settle as settleLayers } from "./back-stack";
|
|
8
|
+
export type { Entry as LayerEntry } from "./back-stack";
|
package/src/i18n/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useLocaleOptional } from "@djangocfg/i18n";
|
|
1
|
+
import { createCopy, type Locale } from "@djangocfg/i18n/copy";
|
|
3
2
|
|
|
4
3
|
import { ar } from "./locales/ar";
|
|
5
4
|
import { da } from "./locales/da";
|
|
@@ -30,58 +29,24 @@ export type {
|
|
|
30
29
|
TranscriptCopy,
|
|
31
30
|
VoiceCopy,
|
|
32
31
|
} from "./types";
|
|
33
|
-
export { fill, plural } from "
|
|
32
|
+
export { fill, plural } from "@djangocfg/i18n/copy";
|
|
34
33
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* copy of the list: a second list is a second thing to forget, and what it
|
|
40
|
-
* hides is a locale that silently never loads.
|
|
41
|
-
*/
|
|
42
|
-
export const CHAT_DICTIONARIES: Readonly<Record<string, ChatCopy>> = {
|
|
43
|
-
en, ru, de, fr, es, it, nl, pl, sv, no, da, tr, ja, ko, zh, ar,
|
|
44
|
-
// The KEY is the language code the app reports (`pt-BR`), which is not a
|
|
45
|
-
// valid JS identifier — so the binding is `ptBR` and the key is quoted. Get
|
|
46
|
-
// this wrong and the lookup misses: every Brazilian user falls back to
|
|
47
|
-
// English with no error anywhere.
|
|
34
|
+
/** Every bundled dictionary, typed complete over the shared `Locale` union. */
|
|
35
|
+
export const CHAT_DICTIONARIES: Readonly<Record<Locale, ChatCopy>> = {
|
|
36
|
+
en, ru, ko, ja, de, fr, zh, it, es, nl, ar, tr, pl, sv, no, da,
|
|
37
|
+
// The key is `pt-BR`, which is not a valid JS identifier, so the binding is `ptBR`.
|
|
48
38
|
"pt-BR": ptBR,
|
|
49
39
|
};
|
|
50
40
|
|
|
51
|
-
|
|
52
|
-
* The chat dictionary for the active language, English for anything not yet
|
|
53
|
-
* translated.
|
|
54
|
-
*
|
|
55
|
-
* The package owns its copy rather than taking it as props. Labels-in
|
|
56
|
-
* looked right — it kept this package free of i18next — but it made adding one
|
|
57
|
-
* string a four-file change and let a host assemble a translated string into a
|
|
58
|
-
* DOM selector, which broke the composer's caret in a single locale with types
|
|
59
|
-
* and tests green.
|
|
60
|
-
*
|
|
61
|
-
* The whole typed object, not a `t(key)` resolver: a dot-path string
|
|
62
|
-
* reintroduces exactly the runtime miss that typing the schema removes.
|
|
63
|
-
* `@djangocfg/i18n` types a CLOSED resources map, so a `chat` namespace does
|
|
64
|
-
* not typecheck there, and opening one would point the dependency arrow from
|
|
65
|
-
* `i18n` at this widget.
|
|
66
|
-
*
|
|
67
|
-
* `useLocaleOptional` returns null with no provider mounted, which is what lets
|
|
68
|
-
* every surface render in Storybook and in a test without one.
|
|
69
|
-
*/
|
|
70
|
-
export function useChatCopy(): ChatCopy {
|
|
71
|
-
const locale = useLocaleOptional();
|
|
72
|
-
return useMemo(() => (locale ? CHAT_DICTIONARIES[locale] ?? en : en), [locale]);
|
|
73
|
-
}
|
|
41
|
+
const kit = createCopy(CHAT_DICTIONARIES);
|
|
74
42
|
|
|
75
43
|
/**
|
|
76
|
-
* The active language
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* still apply, and selecting `few` from a dictionary that has none is what the
|
|
81
|
-
* `other` fallback in `plural()` exists to absorb.
|
|
44
|
+
* The chat dictionary for the active language: the `locale` prop, else the
|
|
45
|
+
* `@djangocfg/i18n` provider, else English. The package owns its copy rather
|
|
46
|
+
* than taking labels as props, because a host assembling a translated string
|
|
47
|
+
* into a DOM selector once broke the composer caret in a single locale.
|
|
82
48
|
*/
|
|
83
|
-
export
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
49
|
+
export const useChatCopy = kit.useCopy;
|
|
50
|
+
|
|
51
|
+
/** The bundled locale in use, for `plural()`; a plural category is needed on every render. */
|
|
52
|
+
export const useChatLanguage = kit.useLocale;
|
package/src/i18n/types.ts
CHANGED
|
@@ -11,22 +11,9 @@
|
|
|
11
11
|
* onboarding one.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* `other` is required and the rest are not, because a language HAS the
|
|
18
|
-
* categories it has: English resolves only `one` and `other`, Russian adds
|
|
19
|
-
* `few` and `many`, Japanese uses `other` alone. Requiring all six would force
|
|
20
|
-
* an English catalogue to invent a `few` that no input can ever select.
|
|
21
|
-
*/
|
|
22
|
-
export interface PluralCopy {
|
|
23
|
-
readonly zero?: string;
|
|
24
|
-
readonly one?: string;
|
|
25
|
-
readonly two?: string;
|
|
26
|
-
readonly few?: string;
|
|
27
|
-
readonly many?: string;
|
|
28
|
-
readonly other: string;
|
|
29
|
-
}
|
|
14
|
+
import type { PluralCopy } from "@djangocfg/i18n/copy";
|
|
15
|
+
|
|
16
|
+
export type { PluralCopy };
|
|
30
17
|
|
|
31
18
|
/** Copy for the transcript: the log, its empty state, and the wait. */
|
|
32
19
|
export interface TranscriptCopy {
|
package/src/primitives/index.ts
CHANGED
|
@@ -41,3 +41,8 @@ export { useTurnActivity } from "./use-turn-activity";
|
|
|
41
41
|
export type { TurnActivity } from "./use-turn-activity";
|
|
42
42
|
export { ChatTooltip } from "./tooltip";
|
|
43
43
|
export type { ChatTooltipProps } from "./tooltip";
|
|
44
|
+
export { useHoldBottom } from "./use-hold-bottom";
|
|
45
|
+
export type { HoldBottomOptions } from "./use-hold-bottom";
|
|
46
|
+
export { useViewportBox, trackViewport } from "./use-viewport-box";
|
|
47
|
+
export { useLayer } from "./use-layer";
|
|
48
|
+
export type { UseLayerOptions } from "./use-layer";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { pushLayer, reconcileLayers, releaseLayer, settleLayers, type LayerEntry } from "../core/viewport";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Open layers as browser-history entries, so the phone's Back gesture closes the top layer instead of leaving the page.
|
|
5
|
+
* One entry per layer; `popstate` closes whatever the history left behind; a layer closed from inside has its entry
|
|
6
|
+
* removed after a short wait (a layer that opens right after keeps the stack consistent).
|
|
7
|
+
*/
|
|
8
|
+
let entries: LayerEntry[] = [];
|
|
9
|
+
const closers = new Map<string, () => void>();
|
|
10
|
+
let listening = false;
|
|
11
|
+
let timer = 0;
|
|
12
|
+
|
|
13
|
+
const SETTLE_MS = 60;
|
|
14
|
+
const KEY = "__layer";
|
|
15
|
+
const DEPTH = "__depth";
|
|
16
|
+
|
|
17
|
+
function onPop() {
|
|
18
|
+
const state = window.history.state as Record<string, unknown> | null;
|
|
19
|
+
const depth = typeof state?.[DEPTH] === "number" ? (state[DEPTH] as number) : 0;
|
|
20
|
+
const next = reconcileLayers(entries, depth);
|
|
21
|
+
entries = next.entries;
|
|
22
|
+
next.closed.reverse().forEach((id) => closers.get(id)?.());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function flush() {
|
|
26
|
+
timer = 0;
|
|
27
|
+
const next = settleLayers(entries);
|
|
28
|
+
entries = next.entries;
|
|
29
|
+
if (next.pop > 0) window.history.go(-next.pop);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Registers an open layer; returns the call to make when it closes. `onBack` runs when the history leaves it. */
|
|
33
|
+
export function registerLayer(id: string, onBack: () => void): () => void {
|
|
34
|
+
if (!listening) {
|
|
35
|
+
window.addEventListener("popstate", onPop);
|
|
36
|
+
listening = true;
|
|
37
|
+
}
|
|
38
|
+
entries = pushLayer(entries, id);
|
|
39
|
+
closers.set(id, onBack);
|
|
40
|
+
// Keep the page's own history state: a router that stored something there must still find it.
|
|
41
|
+
const own = (window.history.state ?? {}) as Record<string, unknown>;
|
|
42
|
+
window.history.pushState({ ...own, [KEY]: id, [DEPTH]: entries.length }, "");
|
|
43
|
+
return () => {
|
|
44
|
+
closers.delete(id);
|
|
45
|
+
entries = releaseLayer(entries, id);
|
|
46
|
+
window.clearTimeout(timer);
|
|
47
|
+
timer = window.setTimeout(flush, SETTLE_MS);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { useMissedCount } from "./use-missed-count";
|
|
11
11
|
import { useReturnOnSend } from "./use-return-on-send";
|
|
12
12
|
import { useRovingFocus } from "./use-roving-focus";
|
|
13
|
+
import { useHoldBottom } from "./use-hold-bottom";
|
|
13
14
|
import { useStickToBottom } from "./use-stick-to-bottom";
|
|
14
15
|
import { useTurnActivity, type TurnActivity } from "./use-turn-activity";
|
|
15
16
|
|
|
@@ -102,7 +103,8 @@ export function Transcript({
|
|
|
102
103
|
const ids = useMessageIds();
|
|
103
104
|
const historyLoaded = useHistoryLoaded();
|
|
104
105
|
const activity = useTurnActivity();
|
|
105
|
-
const { ref, contentRef, onScroll, isPinned, scrollToBottom } = useStickToBottom(ids.length);
|
|
106
|
+
const { ref, contentRef, onScroll, isPinned, scrollToBottom, holdBottom } = useStickToBottom(ids.length);
|
|
107
|
+
const hold = useHoldBottom({ hold: holdBottom });
|
|
106
108
|
const missed = useMissedCount(ids, isPinned);
|
|
107
109
|
useReturnOnSend(ids, scrollToBottom);
|
|
108
110
|
const roving = useRovingFocus(ids.length);
|
|
@@ -115,9 +117,10 @@ export function Transcript({
|
|
|
115
117
|
const attach = useCallback(
|
|
116
118
|
(node: HTMLDivElement | null) => {
|
|
117
119
|
ref(node);
|
|
120
|
+
hold(node);
|
|
118
121
|
surfaces?.registerTranscript(node);
|
|
119
122
|
},
|
|
120
|
-
[ref, surfaces],
|
|
123
|
+
[ref, hold, surfaces],
|
|
121
124
|
);
|
|
122
125
|
|
|
123
126
|
// Two things suppress the empty state, both because it would be a LIE.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the reader at the bottom when the SCROLLER changes height and the content does not.
|
|
3
|
+
*
|
|
4
|
+
* Root cause of "the history jumps when the input gets focus". `use-stick-to-bottom` observes the CONTENT element, so
|
|
5
|
+
* it follows text that grows; it does not observe the scroller. When the keyboard opens (or the composer grows, or the
|
|
6
|
+
* phone turns) the scroller gets shorter, `scrollTop` stays, and the newest messages are now below the fold: nothing
|
|
7
|
+
* grew, so nothing followed. Worse, the browser scrolls the focused field into view in the same moment; the scroller
|
|
8
|
+
* moves up, the library reads that as the reader scrolling up, "escapes" the lock, and from then on neither follows
|
|
9
|
+
* nor shows the bottom.
|
|
10
|
+
*
|
|
11
|
+
* What this does, with the decisions in `core/scroll/pin.ts`:
|
|
12
|
+
* - remembers whether the reader is within `PIN_THRESHOLD` of the bottom, from scroll events, EXCEPT those inside a
|
|
13
|
+
* settle window opened by a field gaining or losing focus or by the visual viewport changing (those are the browser's);
|
|
14
|
+
* - when the scroller resizes or the viewport moves and the reader was pinned, re-pins instantly and tells the library
|
|
15
|
+
* to ignore escapes for the window. A reader who scrolled up on purpose is not pinned, so nothing happens to them.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback, useRef } from "react";
|
|
19
|
+
|
|
20
|
+
import { PIN_THRESHOLD, SETTLE_MS, nextPinned } from "../core/scroll";
|
|
21
|
+
|
|
22
|
+
export interface HoldBottomOptions {
|
|
23
|
+
/** Re-pin for `ms` (the stick-to-bottom engine's `holdBottom`). */
|
|
24
|
+
readonly hold: (ms: number) => void;
|
|
25
|
+
/** Distance from the bottom that still counts as pinned. */
|
|
26
|
+
readonly threshold?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isField = (target: EventTarget | null): boolean =>
|
|
30
|
+
target instanceof HTMLElement && (target.matches("textarea, input, select") || target.isContentEditable);
|
|
31
|
+
|
|
32
|
+
function attach(node: HTMLElement, hold: (ms: number) => void, threshold: number): () => void {
|
|
33
|
+
let pinned = true;
|
|
34
|
+
let settleUntil = 0;
|
|
35
|
+
const settling = () => performance.now() < settleUntil;
|
|
36
|
+
const metrics = () => ({ scrollHeight: node.scrollHeight, scrollTop: node.scrollTop, clientHeight: node.clientHeight });
|
|
37
|
+
|
|
38
|
+
const open = () => {
|
|
39
|
+
settleUntil = performance.now() + SETTLE_MS;
|
|
40
|
+
};
|
|
41
|
+
const follow = () => {
|
|
42
|
+
if (pinned) hold(Math.max(settleUntil - performance.now(), 120));
|
|
43
|
+
};
|
|
44
|
+
const onScroll = () => {
|
|
45
|
+
pinned = nextPinned(pinned, metrics(), settling(), threshold);
|
|
46
|
+
};
|
|
47
|
+
const onFocus = (event: FocusEvent) => {
|
|
48
|
+
if (!isField(event.target)) return;
|
|
49
|
+
open();
|
|
50
|
+
follow();
|
|
51
|
+
};
|
|
52
|
+
const onViewport = () => {
|
|
53
|
+
open();
|
|
54
|
+
follow();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Seed from the real position, in case the transcript opened already scrolled.
|
|
58
|
+
pinned = nextPinned(true, metrics(), false, threshold);
|
|
59
|
+
node.addEventListener("scroll", onScroll, { passive: true });
|
|
60
|
+
document.addEventListener("focusin", onFocus);
|
|
61
|
+
document.addEventListener("focusout", onFocus);
|
|
62
|
+
const vv = window.visualViewport;
|
|
63
|
+
vv?.addEventListener("resize", onViewport);
|
|
64
|
+
const observer = new ResizeObserver(follow);
|
|
65
|
+
observer.observe(node);
|
|
66
|
+
return () => {
|
|
67
|
+
node.removeEventListener("scroll", onScroll);
|
|
68
|
+
document.removeEventListener("focusin", onFocus);
|
|
69
|
+
document.removeEventListener("focusout", onFocus);
|
|
70
|
+
vv?.removeEventListener("resize", onViewport);
|
|
71
|
+
observer.disconnect();
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A ref callback for the SCROLLER; compose it with the one the stick-to-bottom engine needs. */
|
|
76
|
+
export function useHoldBottom({ hold, threshold = PIN_THRESHOLD }: HoldBottomOptions): (node: HTMLElement | null) => void {
|
|
77
|
+
const stop = useRef<(() => void) | null>(null);
|
|
78
|
+
const latest = useRef(hold);
|
|
79
|
+
latest.current = hold;
|
|
80
|
+
return useCallback(
|
|
81
|
+
(node: HTMLElement | null) => {
|
|
82
|
+
stop.current?.();
|
|
83
|
+
stop.current = null;
|
|
84
|
+
if (node && typeof ResizeObserver !== "undefined") stop.current = attach(node, (ms) => latest.current(ms), threshold);
|
|
85
|
+
},
|
|
86
|
+
[threshold],
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { registerLayer } from "./layer-registry";
|
|
4
|
+
|
|
5
|
+
export interface UseLayerOptions {
|
|
6
|
+
/** Back closes the layer (a history entry per open layer). Default true. */
|
|
7
|
+
readonly back?: boolean;
|
|
8
|
+
/** Make the page behind inert while open. Default true. */
|
|
9
|
+
readonly inert?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* The page's root, when the host can name it; absent, every direct child of `<body>` that does not
|
|
12
|
+
* contain `layer` is made inert. A selector, an element, or a function returning elements.
|
|
13
|
+
*/
|
|
14
|
+
readonly appRoot?: string | Element | (() => Iterable<Element>);
|
|
15
|
+
/** Lock page scroll without shifting the layout while open. Default true. */
|
|
16
|
+
readonly lockScroll?: boolean;
|
|
17
|
+
/** The layer's own element. It, and anything holding it, is never made inert. */
|
|
18
|
+
readonly layer?: () => Element | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let open = 0;
|
|
22
|
+
const inerted = new Map<Element, boolean>();
|
|
23
|
+
let saved: { html: string; body: string; gutter: string } | null = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Never inert: scripts, the dialog's own portal (it is mounted a tick after the effect runs, so the layer ref can
|
|
27
|
+
* still be empty), focus guards, and toast regions the reader must still be able to dismiss.
|
|
28
|
+
*/
|
|
29
|
+
function isLayerHost(el: Element): boolean {
|
|
30
|
+
return (
|
|
31
|
+
el.tagName === "SCRIPT" ||
|
|
32
|
+
el.hasAttribute("data-radix-portal") ||
|
|
33
|
+
el.hasAttribute("data-radix-focus-guard") ||
|
|
34
|
+
el.hasAttribute("data-sonner-toaster") ||
|
|
35
|
+
el.matches('[role="dialog"], [aria-modal="true"]') ||
|
|
36
|
+
el.querySelector('[role="dialog"][data-state="open"], [aria-modal="true"]') !== null
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function targets(options: UseLayerOptions): Element[] {
|
|
41
|
+
const layer = options.layer?.() ?? null;
|
|
42
|
+
const root = options.appRoot;
|
|
43
|
+
let list: Element[];
|
|
44
|
+
if (typeof root === "function") list = [...root()];
|
|
45
|
+
else if (root instanceof Element) list = [root];
|
|
46
|
+
else if (typeof root === "string") list = [...document.querySelectorAll(root)];
|
|
47
|
+
else {
|
|
48
|
+
const marked = [...document.querySelectorAll("[data-app-root]")];
|
|
49
|
+
list = marked.length > 0 ? marked : [...document.body.children];
|
|
50
|
+
}
|
|
51
|
+
return list.filter((el) => !(layer && (el === layer || el.contains(layer))) && !isLayerHost(el));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* No layout shift on lock: `overflow: hidden` on `<html>` removes a desktop scrollbar, so its width is replaced by
|
|
56
|
+
* padding; on a phone the scrollbar overlays and nothing moves. The body is never `position: fixed` (that resets the
|
|
57
|
+
* scroll position and jumps the page on close).
|
|
58
|
+
*/
|
|
59
|
+
function lock(on: boolean, options: UseLayerOptions) {
|
|
60
|
+
const html = document.documentElement;
|
|
61
|
+
if (on) {
|
|
62
|
+
if (open === 0) {
|
|
63
|
+
saved = { html: html.style.overflow, body: document.body.style.paddingRight, gutter: html.style.overscrollBehaviorY };
|
|
64
|
+
if (options.lockScroll !== false) {
|
|
65
|
+
const bar = window.innerWidth - html.clientWidth;
|
|
66
|
+
html.style.overflow = "hidden";
|
|
67
|
+
if (bar > 0) document.body.style.paddingRight = `${bar}px`;
|
|
68
|
+
}
|
|
69
|
+
html.style.overscrollBehaviorY = "contain";
|
|
70
|
+
}
|
|
71
|
+
open += 1;
|
|
72
|
+
if (options.inert !== false) {
|
|
73
|
+
for (const el of targets(options)) {
|
|
74
|
+
if (!inerted.has(el)) inerted.set(el, el.hasAttribute("inert"));
|
|
75
|
+
el.setAttribute("inert", "");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
open -= 1;
|
|
81
|
+
if (open > 0) return;
|
|
82
|
+
for (const [el, was] of inerted) if (!was) el.removeAttribute("inert");
|
|
83
|
+
inerted.clear();
|
|
84
|
+
if (saved) {
|
|
85
|
+
html.style.overflow = saved.html;
|
|
86
|
+
document.body.style.paddingRight = saved.body;
|
|
87
|
+
html.style.overscrollBehaviorY = saved.gutter;
|
|
88
|
+
saved = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What an open full-screen layer needs besides the dialog itself: the page behind inert (no focus, no taps, no screen
|
|
94
|
+
* reader, no keyboard accessory fields), page scroll locked without a shift, no pull-to-refresh behind it, and the
|
|
95
|
+
* phone's Back closing it. Escape is the dialog's own.
|
|
96
|
+
*/
|
|
97
|
+
export function useLayer(isOpen: boolean, close: () => void, options: UseLayerOptions = {}): void {
|
|
98
|
+
const id = useRef(`layer-${Math.random().toString(36).slice(2)}`);
|
|
99
|
+
const closeRef = useRef(close);
|
|
100
|
+
closeRef.current = close;
|
|
101
|
+
const optionsRef = useRef(options);
|
|
102
|
+
optionsRef.current = options;
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!isOpen) return;
|
|
105
|
+
const opts = optionsRef.current;
|
|
106
|
+
lock(true, opts);
|
|
107
|
+
const release = opts.back === false ? () => {} : registerLayer(id.current, () => closeRef.current());
|
|
108
|
+
return () => {
|
|
109
|
+
release();
|
|
110
|
+
lock(false, opts);
|
|
111
|
+
};
|
|
112
|
+
}, [isOpen]);
|
|
113
|
+
}
|
|
@@ -33,6 +33,11 @@ export interface StickToBottom {
|
|
|
33
33
|
/** False while the user is reading history — render a "jump to latest" control. */
|
|
34
34
|
readonly isPinned: boolean;
|
|
35
35
|
readonly scrollToBottom: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* Re-pin to the bottom for `ms`, instantly, and treat any scroll the BROWSER makes in that time (focus scrolling,
|
|
38
|
+
* keyboard clamping) as not the reader's. Used by `useHoldBottom`.
|
|
39
|
+
*/
|
|
40
|
+
readonly holdBottom: (ms: number) => void;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/**
|
|
@@ -79,5 +84,12 @@ export function useStickToBottom(_revision: number): StickToBottom {
|
|
|
79
84
|
void scrollToBottom();
|
|
80
85
|
}, [scrollToBottom]);
|
|
81
86
|
|
|
82
|
-
|
|
87
|
+
const hold = useCallback(
|
|
88
|
+
(ms: number) => {
|
|
89
|
+
void scrollToBottom({ animation: "instant", ignoreEscapes: true, duration: ms });
|
|
90
|
+
},
|
|
91
|
+
[scrollToBottom],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { ref, contentRef: content, onScroll, isPinned: isAtBottom, scrollToBottom: jump, holdBottom: hold };
|
|
83
95
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useCallback, useRef, type RefCallback } from "react";
|
|
2
|
+
|
|
3
|
+
import { boxVars, readViewportBox } from "../core/viewport";
|
|
4
|
+
|
|
5
|
+
interface TrackOptions {
|
|
6
|
+
/** The keyboard opened or closed. */
|
|
7
|
+
readonly onKeyboard?: (open: boolean) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Keeps `--vvh`, `--vvw`, `--vv-top`, `--vv-left`, `--kb` and `data-keyboard` of `node` equal to the visible area while the keyboard is up; with it down only `--kb: 0px` stays and CSS sizes the layer from `dvh`.
|
|
12
|
+
*
|
|
13
|
+
* Reads once at once (from the ref callback, before the first paint), then on `visualViewport` resize and scroll and on
|
|
14
|
+
* rotation. The write happens INSIDE the event, not a frame later: iOS pans the visual viewport during the keyboard
|
|
15
|
+
* animation, and a layer that follows one frame late shows the page behind it as a strip. A rotation is read again
|
|
16
|
+
* after it settles, because iOS reports the old size mid-turn. Styles are written directly, so there is no React render
|
|
17
|
+
* per event.
|
|
18
|
+
*/
|
|
19
|
+
export function trackViewport(node: HTMLElement, { onKeyboard }: TrackOptions = {}): () => void {
|
|
20
|
+
const vv = window.visualViewport;
|
|
21
|
+
if (!vv) return () => {};
|
|
22
|
+
let baseline = vv.height;
|
|
23
|
+
let keyboard = false;
|
|
24
|
+
let settle = 0;
|
|
25
|
+
|
|
26
|
+
const apply = () => {
|
|
27
|
+
const box = readViewportBox(vv, window.innerHeight, baseline);
|
|
28
|
+
baseline = Math.max(baseline, box.height);
|
|
29
|
+
// Only a raised keyboard needs the measured box. Without one the CSS fallbacks (`dvh`, top 0) are exact, and the
|
|
30
|
+
// measured numbers are not trustworthy: iOS 26 leaves `offsetTop` stale after the keyboard closes, which pushed the sheet down.
|
|
31
|
+
for (const [name, value] of Object.entries(boxVars(box))) {
|
|
32
|
+
if (box.keyboard || name === "--kb") node.style.setProperty(name, value);
|
|
33
|
+
else node.style.removeProperty(name);
|
|
34
|
+
}
|
|
35
|
+
if (box.keyboard !== keyboard) {
|
|
36
|
+
keyboard = box.keyboard;
|
|
37
|
+
node.toggleAttribute("data-keyboard", keyboard);
|
|
38
|
+
onKeyboard?.(keyboard);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const rotated = () => {
|
|
42
|
+
baseline = 0;
|
|
43
|
+
apply();
|
|
44
|
+
window.clearTimeout(settle);
|
|
45
|
+
settle = window.setTimeout(() => {
|
|
46
|
+
baseline = vv.height;
|
|
47
|
+
apply();
|
|
48
|
+
}, 250);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
apply();
|
|
52
|
+
vv.addEventListener("resize", apply);
|
|
53
|
+
vv.addEventListener("scroll", apply);
|
|
54
|
+
window.addEventListener("orientationchange", rotated);
|
|
55
|
+
return () => {
|
|
56
|
+
vv.removeEventListener("resize", apply);
|
|
57
|
+
vv.removeEventListener("scroll", apply);
|
|
58
|
+
window.removeEventListener("orientationchange", rotated);
|
|
59
|
+
window.clearTimeout(settle);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A ref for the layer element: while it is mounted and `enabled`, it carries the box (see `trackViewport`). */
|
|
64
|
+
export function useViewportBox<T extends HTMLElement>(
|
|
65
|
+
enabled: boolean,
|
|
66
|
+
onKeyboard?: (open: boolean) => void,
|
|
67
|
+
): RefCallback<T> {
|
|
68
|
+
const stop = useRef<(() => void) | null>(null);
|
|
69
|
+
const keyboard = useRef(onKeyboard);
|
|
70
|
+
keyboard.current = onKeyboard;
|
|
71
|
+
return useCallback(
|
|
72
|
+
(node: T | null) => {
|
|
73
|
+
stop.current?.();
|
|
74
|
+
stop.current = null;
|
|
75
|
+
if (node && enabled) stop.current = trackViewport(node, { onKeyboard: (open) => keyboard.current?.(open) });
|
|
76
|
+
},
|
|
77
|
+
[enabled],
|
|
78
|
+
);
|
|
79
|
+
}
|
package/src/styles/composer.css
CHANGED
|
@@ -447,3 +447,44 @@
|
|
|
447
447
|
transition: none;
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
|
+
|
|
451
|
+
/* ============================================================================
|
|
452
|
+
iOS WebKit Auto-Zoom Prevention (CSS Baseline)
|
|
453
|
+
========================================================================= */
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Prevents iOS Safari/Chrome from zooming viewport when textarea gets focus.
|
|
457
|
+
*
|
|
458
|
+
* **Problem**: iOS WebKit zooms when font-size < 16px, causing conversation
|
|
459
|
+
* to "fly away" and creating critical UX failure in chat interfaces.
|
|
460
|
+
*
|
|
461
|
+
* **Solution**: Multi-layer defense:
|
|
462
|
+
* 1. CSS baseline (works without JS) — @supports iOS detection + mobile media
|
|
463
|
+
* 2. React hook (useTouchSafeStyle) — precise iOS-only targeting via device detect
|
|
464
|
+
*
|
|
465
|
+
* **WCAG Compliance**: ✅ WCAG 2.1 AA (SC 1.4.4, 1.4.10)
|
|
466
|
+
* - No viewport restrictions (user-scalable=no would violate WCAG)
|
|
467
|
+
* - Text zoom still works via browser accessibility settings
|
|
468
|
+
* - Maintains 200% text zoom capability
|
|
469
|
+
*
|
|
470
|
+
* @see {@link @researches/2026-10-25__ios-webkit-auto-zoom} Research
|
|
471
|
+
* @see {@link @djangocfg/ui-core/hooks/device/useTouchSafeStyle} React hook
|
|
472
|
+
*/
|
|
473
|
+
|
|
474
|
+
/* iOS-specific via @supports (CSS feature detection) */
|
|
475
|
+
@supports (-webkit-touch-callout: none) {
|
|
476
|
+
.composer-box textarea,
|
|
477
|
+
.composer-box input,
|
|
478
|
+
.composer-box select {
|
|
479
|
+
font-size: max(1rem, 16px);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/* Mobile baseline (all devices, fallback for browsers without @supports) */
|
|
484
|
+
@media (max-width: 768px) {
|
|
485
|
+
.composer-box textarea,
|
|
486
|
+
.composer-box input,
|
|
487
|
+
.composer-box select {
|
|
488
|
+
font-size: max(1rem, 16px);
|
|
489
|
+
}
|
|
490
|
+
}
|
package/src/styles/index.css
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The chat sheet: one layer exactly as big as the VISIBLE area of a phone.
|
|
3
|
+
*
|
|
4
|
+
* `useViewportBox` writes --vvh / --vvw (visible size), --vv-top / --vv-left (offset from the layout viewport, which
|
|
5
|
+
* iOS pans when the keyboard opens) and --kb (keyboard height) on it. The fallbacks make the first frame right before
|
|
6
|
+
* any script has run: `dvh` where it exists, `svh`, then 100%. Unlayered on purpose: it wins over the dialog's own
|
|
7
|
+
* utility classes. No transition on top or height, only a fade on open.
|
|
8
|
+
*
|
|
9
|
+
* Two things keep the page from showing through. The sheet is opaque and sized from the visual viewport; and a
|
|
10
|
+
* backdrop the size of the whole layout viewport sits behind it, so a frame in which the sheet lags the keyboard
|
|
11
|
+
* animation shows the sheet's colour, not the page.
|
|
12
|
+
*/
|
|
13
|
+
.chat-sheet {
|
|
14
|
+
position: fixed;
|
|
15
|
+
left: var(--vv-left, 0px);
|
|
16
|
+
top: var(--vv-top, 0px);
|
|
17
|
+
right: auto;
|
|
18
|
+
bottom: auto;
|
|
19
|
+
width: var(--vvw, 100%);
|
|
20
|
+
height: 100%;
|
|
21
|
+
height: 100svh;
|
|
22
|
+
height: var(--vvh, 100dvh);
|
|
23
|
+
max-width: none;
|
|
24
|
+
max-height: none;
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
background: var(--background, canvas);
|
|
29
|
+
overscroll-behavior: contain;
|
|
30
|
+
touch-action: manipulation;
|
|
31
|
+
transform: none;
|
|
32
|
+
/* Safe areas: the notch and the home bar are inside the visible area only while no keyboard covers them. */
|
|
33
|
+
padding-top: env(safe-area-inset-top);
|
|
34
|
+
padding-left: env(safe-area-inset-left);
|
|
35
|
+
padding-right: env(safe-area-inset-right);
|
|
36
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
37
|
+
transition: none;
|
|
38
|
+
animation: none;
|
|
39
|
+
}
|
|
40
|
+
/* With the keyboard up the home bar is under it. */
|
|
41
|
+
.chat-sheet[data-keyboard] {
|
|
42
|
+
padding-bottom: 0;
|
|
43
|
+
}
|
|
44
|
+
/* The backing is a shadow, not a pseudo-element: a fixed child is clipped by the sheet's `overflow: hidden` as soon as the dialog's animation or transform makes the sheet its containing block. A box-shadow paints outside the box and is never clipped by it. */
|
|
45
|
+
.chat-sheet {
|
|
46
|
+
box-shadow: 0 0 0 100vmax var(--background, canvas);
|
|
47
|
+
}
|
|
48
|
+
.chat-sheet[data-state="open"] {
|
|
49
|
+
animation: chat-sheet-in 140ms ease-out;
|
|
50
|
+
}
|
|
51
|
+
@keyframes chat-sheet-in {
|
|
52
|
+
from { opacity: 0; }
|
|
53
|
+
}
|
|
54
|
+
@media (prefers-reduced-motion: reduce) {
|
|
55
|
+
.chat-sheet[data-state="open"] { animation: none; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* The conversation inside a sheet (or any host column): the transcript takes the free height and scrolls, the composer
|
|
60
|
+
* sits right under it. `min-height: 0` is what lets the scroller shrink with the keyboard instead of pushing the composer out.
|
|
61
|
+
*/
|
|
62
|
+
.chat-sheet-body {
|
|
63
|
+
display: flex;
|
|
64
|
+
flex: 1 1 0;
|
|
65
|
+
min-height: 0;
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
}
|
|
68
|
+
.chat-sheet-body > .chat-surface {
|
|
69
|
+
flex: 1 1 0;
|
|
70
|
+
min-height: 0;
|
|
71
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { useCallback, useRef, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogClose,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from "@djangocfg/ui-core/components";
|
|
12
|
+
|
|
13
|
+
import { useLayer, useViewportBox, type UseLayerOptions } from "../primitives";
|
|
14
|
+
|
|
15
|
+
export interface ChatSheetProps {
|
|
16
|
+
readonly open: boolean;
|
|
17
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
18
|
+
/** The heading, and the dialog's accessible name. */
|
|
19
|
+
readonly title: string;
|
|
20
|
+
/** What the thread is about, under the title. */
|
|
21
|
+
readonly description?: string;
|
|
22
|
+
/** Accessible name of the close button. Default "Close". */
|
|
23
|
+
readonly closeLabel?: string;
|
|
24
|
+
/** Controls in the header, before the close button (a "more" menu, for one). */
|
|
25
|
+
readonly actions?: ReactNode;
|
|
26
|
+
/** The `<Chat>` (or the host's own column). */
|
|
27
|
+
readonly children: ReactNode;
|
|
28
|
+
/**
|
|
29
|
+
* Opt-outs. Everything is ON by default; the host passes nothing.
|
|
30
|
+
* `viewport`: size the layer from `visualViewport` (keyboard, pan, safe areas).
|
|
31
|
+
* `back`: the phone's Back closes it.
|
|
32
|
+
* `inert`: the page behind is inert. `appRoot` names the page root (selector, element or function); absent, the `[data-app-root]` element, else every other child of `<body>`.
|
|
33
|
+
* `lockScroll`: page scroll is locked without a layout shift.
|
|
34
|
+
* `autoFocus`: focus the field on open (off on phones: it would raise the keyboard over the answer).
|
|
35
|
+
*/
|
|
36
|
+
readonly behaviour?: {
|
|
37
|
+
readonly viewport?: boolean;
|
|
38
|
+
readonly back?: boolean;
|
|
39
|
+
readonly inert?: boolean;
|
|
40
|
+
readonly appRoot?: UseLayerOptions["appRoot"];
|
|
41
|
+
readonly lockScroll?: boolean;
|
|
42
|
+
readonly autoFocus?: boolean;
|
|
43
|
+
};
|
|
44
|
+
readonly className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The chat on a phone: a full-screen dialog exactly the size of the visible area, with header, transcript and composer
|
|
49
|
+
* in one column. The package owns the browser problems of that surface: the layer follows `visualViewport` (no gap above
|
|
50
|
+
* the keyboard, no page showing through), the page behind is inert and not scrolled or shifted, Back and Escape close
|
|
51
|
+
* it, and the transcript stays pinned to the newest message while the keyboard opens (`Transcript`).
|
|
52
|
+
*/
|
|
53
|
+
export function ChatSheet({ open, onOpenChange, title, description, closeLabel = "Close", actions, children, behaviour, className }: ChatSheetProps) {
|
|
54
|
+
const layer = useRef<HTMLDivElement | null>(null);
|
|
55
|
+
const heading = useRef<HTMLHeadingElement>(null);
|
|
56
|
+
const sized = useViewportBox<HTMLDivElement>(open && behaviour?.viewport !== false);
|
|
57
|
+
const attach = useCallback(
|
|
58
|
+
(node: HTMLDivElement | null) => {
|
|
59
|
+
layer.current = node;
|
|
60
|
+
sized(node);
|
|
61
|
+
},
|
|
62
|
+
[sized],
|
|
63
|
+
);
|
|
64
|
+
useLayer(open, useCallback(() => onOpenChange(false), [onOpenChange]), {
|
|
65
|
+
back: behaviour?.back,
|
|
66
|
+
inert: behaviour?.inert,
|
|
67
|
+
appRoot: behaviour?.appRoot,
|
|
68
|
+
lockScroll: behaviour?.lockScroll,
|
|
69
|
+
layer: () => layer.current,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
74
|
+
<DialogContent
|
|
75
|
+
ref={attach}
|
|
76
|
+
fullscreen
|
|
77
|
+
closeButton={false}
|
|
78
|
+
className={`chat-sheet ${className ?? ""}`}
|
|
79
|
+
onOpenAutoFocus={(event) => {
|
|
80
|
+
if (behaviour?.autoFocus) return;
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
heading.current?.focus({ preventScroll: true });
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-2">
|
|
86
|
+
<div className="min-w-0">
|
|
87
|
+
<DialogTitle ref={heading} tabIndex={-1} className="truncate text-base font-semibold outline-none">
|
|
88
|
+
{title}
|
|
89
|
+
</DialogTitle>
|
|
90
|
+
{description ? (
|
|
91
|
+
<DialogDescription className="truncate text-xs text-muted-foreground">{description}</DialogDescription>
|
|
92
|
+
) : null}
|
|
93
|
+
</div>
|
|
94
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
95
|
+
{actions}
|
|
96
|
+
<DialogClose asChild>
|
|
97
|
+
<Button variant="ghost" size="icon" aria-label={closeLabel}>
|
|
98
|
+
<X className="size-5" aria-hidden="true" />
|
|
99
|
+
</Button>
|
|
100
|
+
</DialogClose>
|
|
101
|
+
</div>
|
|
102
|
+
</header>
|
|
103
|
+
<div className="chat-sheet-body">{children}</div>
|
|
104
|
+
</DialogContent>
|
|
105
|
+
</Dialog>
|
|
106
|
+
);
|
|
107
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -52,3 +52,5 @@ export { CollapseTrigger, useCollapse } from "./collapsible-body";
|
|
|
52
52
|
export type { Collapse, CollapseState } from "./collapsible-body";
|
|
53
53
|
export { LinkChip } from "./link-chip";
|
|
54
54
|
export type { LinkChipProps } from "./link-chip";
|
|
55
|
+
export { ChatSheet } from "./chat-sheet";
|
|
56
|
+
export type { ChatSheetProps } from "./chat-sheet";
|
package/src/i18n/plural.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { PluralCopy } from "./types";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Fill `{{name}}` placeholders.
|
|
5
|
-
*
|
|
6
|
-
* The dictionary is plain strings, so there is no interpolator behind it. A
|
|
7
|
-
* template literal at each call site would put copy assembly back into JSX,
|
|
8
|
-
* which is the seam this package's i18n exists to close.
|
|
9
|
-
*/
|
|
10
|
-
export function fill(
|
|
11
|
-
template: string,
|
|
12
|
-
values: Readonly<Record<string, string | number>>,
|
|
13
|
-
): string {
|
|
14
|
-
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
|
|
15
|
-
const value = values[key];
|
|
16
|
-
return value === undefined ? match : String(value);
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* One `Intl.PluralRules` per language, kept for the life of the tab.
|
|
22
|
-
*
|
|
23
|
-
* Constructing one costs a locale-data lookup, and the composer's queue summary
|
|
24
|
-
* re-renders on every keystroke while a turn streams. The cache is unbounded on
|
|
25
|
-
* purpose: its key space is the language list, seventeen entries.
|
|
26
|
-
*/
|
|
27
|
-
const rules = new Map<string, Intl.PluralRules>();
|
|
28
|
-
|
|
29
|
-
function rulesFor(language: string): Intl.PluralRules {
|
|
30
|
-
const cached = rules.get(language);
|
|
31
|
-
if (cached !== undefined) return cached;
|
|
32
|
-
// An unknown or malformed tag throws rather than falling back, and a bad
|
|
33
|
-
// language must not take the composer down — English plurals on an unknown
|
|
34
|
-
// tag are wrong in a way the user can read past.
|
|
35
|
-
let made: Intl.PluralRules;
|
|
36
|
-
try {
|
|
37
|
-
made = new Intl.PluralRules(language);
|
|
38
|
-
} catch {
|
|
39
|
-
made = new Intl.PluralRules("en");
|
|
40
|
-
}
|
|
41
|
-
rules.set(language, made);
|
|
42
|
-
return made;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Choose a plural form and fill `{{count}}`.
|
|
47
|
-
*
|
|
48
|
-
* Falls back to `other` when the category a language selects is absent from the
|
|
49
|
-
* dictionary. That is not a defect to fail on: `other` is required by the type,
|
|
50
|
-
* so a fallback always exists, and rendering the wrong-but-readable form beats
|
|
51
|
-
* rendering nothing at the moment a user is counting their own queued prompts.
|
|
52
|
-
*/
|
|
53
|
-
export function plural(
|
|
54
|
-
copy: PluralCopy,
|
|
55
|
-
count: number,
|
|
56
|
-
language: string,
|
|
57
|
-
values: Readonly<Record<string, string | number>> = {},
|
|
58
|
-
): string {
|
|
59
|
-
const category = rulesFor(language).select(count);
|
|
60
|
-
const template = copy[category] ?? copy.other;
|
|
61
|
-
return fill(template, { ...values, count });
|
|
62
|
-
}
|