Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
116 changes: 116 additions & 0 deletions src/concierge.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
act(() => {
root.render(<ConciergeWidget {...props} />);
});
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 = <img src="/model.png" alt="" data-testid="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: <button type="button">nope</button> });
const slot = bar.firstElementChild as HTMLElement;
expect(slot.getAttribute('aria-hidden')).toBe('true');
expect(slot.style.pointerEvents).toBe('none');
});
});
39 changes: 37 additions & 2 deletions src/concierge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────
Expand Down Expand Up @@ -202,6 +215,7 @@ export default function ConciergeWidget({
theme = 'dark',
disclaimerOpener,
disclaimerHotlines,
leading,
}: ConciergeWidgetProps) {
// Conversation + session
const [messages, setMessages] = useState<Message[]>([]);
Expand Down Expand Up @@ -1303,14 +1317,35 @@ 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}`,
transition: 'all 400ms cubic-bezier(0.16, 1, 0.3, 1)',
transform: isFocused ? 'scale(1.005)' : 'scale(1)',
}}
>
{leading && (
<span
aria-hidden="true"
style={{
display: 'flex',
alignItems: 'center',
flex: 'none',
marginRight: '12px',
// The bar animates on focus; a decorative node should ride
// that transform rather than fight it for a frame.
pointerEvents: 'none',
userSelect: 'none',
}}
>
{leading}
</span>
)}

<input
ref={inputRef}
type="text"
Expand Down
Loading