diff --git a/package.json b/package.json index ca3e3d0..ef15ff5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@isimplifyme/ui", - "version": "1.3.0", + "version": "1.4.0", "description": "React/Next.js UI library for iSimplifyMe properties \u2014 design system, article layouts, SEO helpers, bot middleware, and the iSM Concierge widget.", "homepage": "https://isimplifyme.com", "type": "module", diff --git a/src/concierge.test.tsx b/src/concierge.test.tsx new file mode 100644 index 0000000..bf70594 --- /dev/null +++ b/src/concierge.test.tsx @@ -0,0 +1,116 @@ +/** + * The `leading` slot — and, more importantly, its absence. + * + * This prop ships to every site consuming @isimplifyme/ui on a `^` range, so + * the load-bearing claim is not "the slot works" but "a bar that passes no + * slot is unchanged." That claim is what these tests pin. A prop whose + * default path is untested is a fleet-wide change disguised as an addition. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import ConciergeWidget from './concierge'; + +let container: HTMLDivElement; +let root: Root; + +// The widget bootstraps a session on mount. Nothing here exercises the +// network, so a stub keeps the render deterministic — a real fetch would make +// these tests flaky for reasons that have nothing to do with the slot. +beforeEach(() => { + // jsdom ships no matchMedia; the widget reads it to decide the mobile + // footer collision. Reporting "no match" gives the desktop bar, which is + // the layout these assertions are about. + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + onchange: null, + dispatchEvent: () => false, + })); + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }), + ), + ); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +function render(props: Record = {}) { + act(() => { + root.render(); + }); + const input = container.querySelector('input[type="text"]') as HTMLInputElement; + // The command bar is the input's flex parent — found by structure rather + // than by a class, because the widget styles entirely inline and a test + // that invented a hook would be pinning the test's own fiction. + return { input, bar: input.parentElement as HTMLElement }; +} + +describe('no leading node — the path every other site takes', () => { + it('keeps the original 24px optical inset', () => { + const { bar } = render(); + expect(bar.style.padding).toBe('10px 12px 10px 24px'); + }); + + it('renders nothing before the input', () => { + const { input, bar } = render(); + expect(bar.firstElementChild).toBe(input); + }); + + it('adds no aria-hidden element to the bar', () => { + const { bar } = render(); + expect(bar.querySelectorAll('[aria-hidden="true"]').length).toBe(0); + }); +}); + +describe('with a leading node', () => { + const sprite = ; + + it('renders it ahead of the input', () => { + const { input, bar } = render({ leading: sprite }); + const slot = bar.firstElementChild as HTMLElement; + expect(slot.querySelector('[data-testid="sprite"]')).not.toBeNull(); + expect(slot.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + }); + + it('drops the inset to 12px so the text starts where it always did', () => { + const { bar } = render({ leading: sprite }); + expect(bar.style.padding).toBe('10px 12px'); + }); + + it('hides it from assistive tech', () => { + const { bar } = render({ leading: sprite }); + expect((bar.firstElementChild as HTMLElement).getAttribute('aria-hidden')).toBe('true'); + }); + + it('leaves the input the only accessible name in the bar', () => { + // A second announced element here would read as a separate control. The + // input's own label is the one the user is meant to hear. + const { input, bar } = render({ leading: sprite }); + expect(input.getAttribute('aria-label')).toBe('Chat with concierge'); + const named = [...bar.querySelectorAll('[aria-label]')]; + expect(named.filter((el) => el !== input && !el.closest('[aria-hidden="true"]'))) + .toHaveLength(1); // the send button, which was always there + }); + + it('takes no tab stop', () => { + const { bar } = render({ leading: }); + const slot = bar.firstElementChild as HTMLElement; + expect(slot.getAttribute('aria-hidden')).toBe('true'); + expect(slot.style.pointerEvents).toBe('none'); + }); +}); diff --git a/src/concierge.tsx b/src/concierge.tsx index 3989181..f200b12 100644 --- a/src/concierge.tsx +++ b/src/concierge.tsx @@ -23,7 +23,7 @@ * apex-portal /api/leads/submit. */ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'; // ── Types ────────────────────────────────────────────────────────────── @@ -93,6 +93,19 @@ export interface ConciergeWidgetProps { * (e.g. ['911', '988']). Pure presentation — no behavior. Only * applied when `disclaimerOpener` is set. */ disclaimerHotlines?: string[]; + /** Decorative node rendered at the head of the command bar, before + * the input — a brand sprite, mark, or avatar. Omit it and the bar + * renders exactly as it always has, including its 24px left padding; + * supply one and the padding drops to 12px so the node sits where + * the text used to start. + * + * PURELY DECORATIVE. It is wrapped in `aria-hidden` and is not + * focusable, because the input already carries the accessible name + * for this control and a second announced element here would read + * as a separate widget. Do not pass anything interactive, and do + * not pass anything that carries meaning the sighted user gets and + * the screen-reader user does not. */ + leading?: ReactNode; } // ── SSE event shapes ─────────────────────────────────────────────────── @@ -202,6 +215,7 @@ export default function ConciergeWidget({ theme = 'dark', disclaimerOpener, disclaimerHotlines, + leading, }: ConciergeWidgetProps) { // Conversation + session const [messages, setMessages] = useState([]); @@ -1303,7 +1317,10 @@ export default function ConciergeWidget({ WebkitBackdropFilter: 'blur(32px) saturate(180%)', border: `${barBorderWidth} solid ${isFocused ? barBorderFocused : barBorder}`, borderRadius: '28px', - padding: '10px 12px 10px 24px', + // 24px is the bar's own optical inset for text starting at the + // edge. A leading node supplies its own visual mass, so it takes + // the smaller inset and the text lands where it already was. + padding: leading ? '10px 12px' : '10px 12px 10px 24px', boxShadow: isFocused ? `inset 0 1px 0 ${barTopHighlightFocused}, ${barShadowFocused}${focusAccentRing}` : `inset 0 1px 0 ${barTopHighlight}, ${barShadow}`, @@ -1311,6 +1328,24 @@ export default function ConciergeWidget({ transform: isFocused ? 'scale(1.005)' : 'scale(1)', }} > + {leading && ( + + )} +