Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/SearchBox/SearchBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,23 @@ export const CustomButtonContent: Story = {
),
],
};

/**
* At ≤480px the icon and input stay together on the first row and only the
* button wraps to a full-width second row. Regression coverage for the mobile
* layout reported in issues #27 and #33, where the leading search icon was
* stranded on its own line above the input.
*/
export const MobileLayout: Story = {
args: {
placeholder: 'Search across all legal knowledge, cases, and statutes...',
buttonText: 'Search',
},
decorators: [
(Story) => (
<div style={{ width: 320 }}>
<Story />
</div>
),
],
};
7 changes: 6 additions & 1 deletion src/SearchBox/SearchBox.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,12 @@ export const searchBoxStyles = `

.oc-search-box__input {
order: 1;
flex: 1 1 auto;
/* Zero flex-basis (matching the desktop base rule) keeps the input on the
first row beside the icon regardless of its intrinsic width, so the
leading icon is never stranded on a row of its own. A non-zero basis
(e.g. auto or 100%) lets the input's hypothetical size push it onto a
new flex line on narrow viewports. */
flex: 1 1 0;
min-width: 0;
}

Expand Down
77 changes: 77 additions & 0 deletions src/SearchBox/SearchBox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { SearchBox } from './SearchBox';
import { searchBoxStyles } from './SearchBox.styles';

afterEach(() => {
cleanup();
});

describe('SearchBox', () => {
it('renders the icon, input and button in document order', () => {
const { container } = render(<SearchBox placeholder="Search..." />);

const form = container.querySelector('.oc-search-box') as HTMLElement;
const children = Array.from(form.children);

expect(children[0].classList.contains('oc-search-box__icon')).toBe(true);
expect(children[1].classList.contains('oc-search-box__input')).toBe(true);
expect(children[2].classList.contains('oc-search-box__button')).toBe(true);
});

it('omits the button when hideButton is set', () => {
const { container } = render(<SearchBox hideButton />);
expect(container.querySelector('.oc-search-box__button')).toBeNull();
});

// ─── Mobile layout regression (issues #27, #33) ───────────────────────────
// On screens ≤ 480px the leading search icon must stay on the first row
// beside the input, with only the button wrapping to a full-width second
// row. A non-zero flex-basis on the input (e.g. `1 1 100%` or `1 1 auto`)
// lets the input's hypothetical size push it onto its own flex line and
// strands the icon above it. jsdom can't perform flex layout, so we assert
// on the CSS rule that governs the wrap.
describe('mobile layout (≤480px)', () => {
const mobileBlock = (() => {
const match = searchBoxStyles.match(
/@media\s*\(max-width:\s*480px\)\s*\{([\s\S]*)\}\s*$/
);
expect(match, 'expected a max-width: 480px media query').not.toBeNull();
return match![1];
})();

const ruleFor = (selector: string) => {
const re = new RegExp(
`${selector.replace(/[.]/g, '\\.')}\\s*\\{([^}]*)\\}`
);
const m = mobileBlock.match(re);
expect(m, `expected a rule for ${selector}`).not.toBeNull();
return m![1];
};

it('lets the input share the first row with the icon (zero flex-basis)', () => {
const inputRule = ruleFor('.oc-search-box__input');
const flex = inputRule.match(/flex:\s*([^;]+);/)?.[1].trim();

// Must grow/shrink with a zero basis so it never forces its own line.
expect(flex).toBe('1 1 0');
// Guard against the regressions reported in #27 / #33.
expect(flex).not.toContain('100%');
expect(flex).not.toContain('auto');
expect(inputRule).toMatch(/min-width:\s*0/);
});

it('wraps the button onto a full-width second row', () => {
const buttonRule = ruleFor('.oc-search-box__button');
expect(buttonRule).toMatch(/width:\s*100%/);
expect(buttonRule).toMatch(/order:\s*2/);
});

it('orders the icon before the input', () => {
expect(ruleFor('.oc-search-box__icon')).toMatch(/order:\s*0/);
expect(ruleFor('.oc-search-box__input')).toMatch(/order:\s*1/);
});
});
});
Loading