Compare commits

..

3 Commits

Author SHA1 Message Date
jules
996ba8716f a11y: real modal — focus trap, Tab cycle, Escape, focus restore
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:45:53 +10:00
jules
1ecef406e2 chore(pkg): add package.json (name/version/peerDeps/exports/sideEffects)
Libs shipped as bare source with no manifest — consumable only via per-app
vite/tsconfig alias surgery, no version contract, no tree-shaking signal.
Add a minimal package.json matching the @crema/content-ui template: entry +
exports map, declared peerDependencies, sideEffects:false. Mechanical, no
code change. Frontend audit 2026-06-20, rank 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:36:49 +10:00
jules
f897648b5c feat: optional notice slot under composer + fix starter-suggestion crash
Add an optional `notice` prop (React.ReactNode) to AgentDockProps, rendered
as quiet fine-print under the composer. Lets a host app surface a privacy /
data-handling notice next to the input without the dock owning the copy.
Default undefined — no change for apps that don't pass it.

Also fix a latent crash: a "Try saying" starter suggestion called
sendMessage(s) with a single argument, but sendMessage(display, wire)
immediately does wire.trim() — clicking a suggestion threw on undefined.
Pass (s, s) since a suggestion's display and wire text are identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:51:29 +10:00
2 changed files with 120 additions and 1 deletions

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@crema/agent-dock-ui",
"version": "0.0.1",
"private": true,
"description": "Agent Dock components for the Crema design system. Builds on @crema/agent-ui.",
"type": "module",
"main": "./src/index.tsx",
"types": "./src/index.tsx",
"exports": {
".": "./src/index.tsx"
},
"files": [
"src"
],
"sideEffects": false,
"peerDependencies": {
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}

View File

@@ -113,6 +113,11 @@ export interface AgentDockProps {
hidden?: boolean; hidden?: boolean;
/** localStorage namespace for the open/closed state. */ /** localStorage namespace for the open/closed state. */
storageKey?: string; storageKey?: string;
/** Optional fine-print rendered under the composer — e.g. a privacy /
* data-handling notice. App-supplied content (the dock stays generic);
* omit for no notice. Kept visually quiet so it doesn't compete with the
* input. */
notice?: React.ReactNode;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -153,6 +158,7 @@ export function AgentDock({
onExpand, onExpand,
hidden = false, hidden = false,
storageKey = DEFAULT_STORAGE_KEY, storageKey = DEFAULT_STORAGE_KEY,
notice,
}: AgentDockProps) { }: AgentDockProps) {
const [open, setOpen] = useState<boolean>(() => { const [open, setOpen] = useState<boolean>(() => {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
@@ -170,6 +176,9 @@ export function AgentDock({
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
// The slide-over panel — target for the focus trap while `open`.
const panelRef = useRef<HTMLDivElement | null>(null);
useFocusTrap(open, panelRef, () => setOpen(false));
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -330,6 +339,7 @@ export function AgentDock({
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
/> />
<div <div
ref={panelRef}
data-slot="sheet-content" data-slot="sheet-content"
className="absolute right-0 top-0 flex h-full w-full flex-col bg-[var(--background)] shadow-2xl sm:w-[480px] md:w-[560px]" className="absolute right-0 top-0 flex h-full w-full flex-col bg-[var(--background)] shadow-2xl sm:w-[480px] md:w-[560px]"
> >
@@ -418,7 +428,7 @@ export function AgentDock({
<li key={s}> <li key={s}>
<button <button
type="button" type="button"
onClick={() => void sendMessage(s)} onClick={() => void sendMessage(s, s)}
disabled={sending} disabled={sending}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--chat-assistant-bg)] px-3 py-2 text-left text-sm leading-snug text-[var(--chat-assistant-fg)] transition-colors hover:border-[var(--primary)] disabled:opacity-60" className="w-full rounded-lg border border-[var(--border)] bg-[var(--chat-assistant-bg)] px-3 py-2 text-left text-sm leading-snug text-[var(--chat-assistant-fg)] transition-colors hover:border-[var(--primary)] disabled:opacity-60"
data-action="assistant-dock-starter" data-action="assistant-dock-starter"
@@ -504,6 +514,11 @@ export function AgentDock({
)} )}
</button> </button>
</div> </div>
{notice ? (
<div className="mt-2 px-0.5 text-[11px] leading-snug text-[var(--foreground)]/50">
{notice}
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -512,6 +527,89 @@ export function AgentDock({
); );
} }
/* ------------------------------------------------------------------ */
/* Focus management */
/* ------------------------------------------------------------------ */
/* Descendants that can receive keyboard focus. Kept in sync with the
* focus-trap's Tab cycling below. */
const FOCUSABLE_SELECTOR =
'button, [href], input, textarea, select, [tabindex]:not([tabindex="-1"])';
/** Small self-contained focus trap for a modal container — no external
* dependency, so the dock stays generic. While `active`, it moves focus
* into `containerRef`, keeps Tab / Shift+Tab cycling inside it, routes
* Escape to `onEscape`, and restores focus to whatever was focused before
* activation once the panel closes/unmounts. */
function useFocusTrap(
active: boolean,
containerRef: React.RefObject<HTMLElement | null>,
onEscape: () => void,
) {
// Track the latest onEscape without re-running the effect each render.
const onEscapeRef = useRef(onEscape);
onEscapeRef.current = onEscape;
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
// Remember where focus was so we can hand it back on close.
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusables = () =>
Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((el) => !el.hasAttribute("disabled"));
// Move focus into the panel — first focusable, or the container itself.
const first = focusables()[0];
if (first) {
first.focus();
} else {
container.tabIndex = -1;
container.focus();
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscapeRef.current();
return;
}
if (e.key !== "Tab") return;
const items = focusables();
if (items.length === 0) {
// Nothing to cycle — keep focus pinned inside the panel.
e.preventDefault();
return;
}
const firstEl = items[0];
const lastEl = items[items.length - 1];
const activeEl = document.activeElement;
if (e.shiftKey) {
// Shift+Tab at the first (or focus escaped the panel) → wrap to last.
if (activeEl === firstEl || !container.contains(activeEl)) {
e.preventDefault();
lastEl.focus();
}
} else if (activeEl === lastEl || !container.contains(activeEl)) {
// Tab at the last (or focus escaped the panel) → wrap to first.
e.preventDefault();
firstEl.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
// Restore focus to the trigger that opened the panel.
previouslyFocused?.focus?.();
};
}, [active, containerRef]);
}
function IconButton({ function IconButton({
label, label,
dataAction, dataAction,