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.4.1",
"version": "1.4.2",
"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
36 changes: 35 additions & 1 deletion src/concierge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,22 @@ import { createRoot, type Root } from 'react-dom/client';
import { act } from 'react';
import ConciergeWidget from './concierge';

/** Flips the stubbed matchMedia so a test can render at phone width. */
const NARROW = { value: false };

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(() => {
NARROW.value = false;
// 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,
matches: NARROW.value && query.includes('max-width'),
media: query,
addEventListener() {},
removeEventListener() {},
Expand Down Expand Up @@ -151,3 +155,33 @@ describe('auto theme — the colour parsing these refactors touched', () => {
expect(bg).not.toBe('');
});
});

describe('the keyboard-shortcut chip', () => {
const chipText = (root: HTMLElement) =>
[...root.querySelectorAll('span')].map((el) => el.textContent ?? '').join('|');

it('shows on a wide viewport', () => {
const { bar } = render();
expect(chipText(bar)).toMatch(/K$/);
});

it('is hidden below 768px — it costs ~55px of the input for a key no phone has', () => {
NARROW.value = true;
const { bar } = render();
expect(chipText(bar)).not.toMatch(/K$/);
});

it('gives the freed width back to the input', () => {
// The point of the guard, stated as the property that matters: the same
// bar yields a wider text field once the chip is gone.
const wide = render().bar.querySelectorAll('span').length;
act(() => root.unmount());
container.remove();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
NARROW.value = true;
const narrow = render().bar.querySelectorAll('span').length;
expect(narrow).toBeLessThan(wide);
});
});
20 changes: 19 additions & 1 deletion src/concierge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,11 @@ export default function ConciergeWidget({
const [isFocused, setIsFocused] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isMac, setIsMac] = useState(true);
// Narrow viewports get no keyboard shortcut chip. Starts false so the
// server render and the first client paint agree; the effect below
// corrects it. (Deriving it inline from matchMedia would hydrate-mismatch,
// which is why isMac is shaped this way too.)
const [isNarrow, setIsNarrow] = useState(false);

// Lead form state — fires on session 3-turn cap OR tenant cap OR
// immediately when the server signals `hv_matched`. Persists until
Expand Down Expand Up @@ -270,6 +275,13 @@ export default function ConciergeWidget({

useEffect(() => {
setIsMac(navigator.platform?.toUpperCase().includes('MAC') ?? true);

// Same breakpoint the footer-collision check uses, kept in sync with it.
const mq = window.matchMedia('(max-width: 767px)');
const sync = () => setIsNarrow(mq.matches);
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);

// ── Chameleon theme detection — two-pass (data-theme + luminance) ──
Expand Down Expand Up @@ -660,7 +672,13 @@ export default function ConciergeWidget({
useHvEarly, useElaborated, hvForm, leadForm, requireAddress, showTranscriptCheckbox,
]);

const showShortcut = !isFocused && !input && !isLoading;
// ⚡ Not decoration: the chip costs ~55px of the input's visible width, and
// on a phone it buys nothing — there is no keyboard to press it with, and
// `isMac` reads navigator.platform, so a touch device can render the wrong
// modifier for a shortcut it cannot use either way. On a 390px screen that
// 55px is the difference between a placeholder that reads and one that
// clips mid-word.
const showShortcut = !isFocused && !input && !isLoading && !isNarrow;

// ── Theme tokens ──
// Light-mode bar + panel opacities intentionally kept low (<=0.75) so
Expand Down
Loading