From ad45a0c77cf9cee65d7cf32b08a451afe6fc7139 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 04:28:32 +0000 Subject: [PATCH] fix(SearchBox): keep icon and input on one row at <=480px The mobile media query gave the input a non-zero flex-basis, letting its hypothetical size push it onto its own flex line and stranding the leading search icon on the row above (icon / input / button as three stacked rows). Use flex: 1 1 0 on the input in the <=480px media query, matching the desktop base rule, so the input always shares the first row with the icon and grows to fill the remaining space; only the full-width button wraps to a second row. This is robust regardless of the input's intrinsic width, unlike the prior flex: 1 1 auto which could still wrap on sufficiently narrow viewports. Adds a SearchBox test asserting DOM order and the mobile flex rule, plus a MobileLayout story at 320px for visual regression coverage. Fixes #33 --- src/SearchBox/SearchBox.stories.tsx | 20 ++++++++ src/SearchBox/SearchBox.styles.ts | 7 ++- src/SearchBox/SearchBox.test.tsx | 77 +++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/SearchBox/SearchBox.test.tsx diff --git a/src/SearchBox/SearchBox.stories.tsx b/src/SearchBox/SearchBox.stories.tsx index e2f3e4e..a1df3a9 100644 --- a/src/SearchBox/SearchBox.stories.tsx +++ b/src/SearchBox/SearchBox.stories.tsx @@ -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) => ( +
+ +
+ ), + ], +}; diff --git a/src/SearchBox/SearchBox.styles.ts b/src/SearchBox/SearchBox.styles.ts index 0233ccb..21d6eea 100644 --- a/src/SearchBox/SearchBox.styles.ts +++ b/src/SearchBox/SearchBox.styles.ts @@ -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; } diff --git a/src/SearchBox/SearchBox.test.tsx b/src/SearchBox/SearchBox.test.tsx new file mode 100644 index 0000000..4c722c9 --- /dev/null +++ b/src/SearchBox/SearchBox.test.tsx @@ -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(); + + 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(); + 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/); + }); + }); +});