diff --git a/.changeset/typeahead-collapsed-width.md b/.changeset/typeahead-collapsed-width.md new file mode 100644 index 0000000000000..8c415a4942167 --- /dev/null +++ b/.changeset/typeahead-collapsed-width.md @@ -0,0 +1,76 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Typeahead: the field keeps its width when a value is selected, and the value stays out of the end controls (#5560) + +Two halves of one promise from the input-field family contract +(`docs/families/input-fields.md`): **FR1**, a field's available width does not +change because its value did; and **FR2**, a visible end affordance does not have +field content painted under it. + +**FR1 — the input keeps its place.** Every other field in the family gets a +stable width for free: the `` stays in flow, and the field is as wide as +the input's own intrinsic width. Typeahead took the input out of flow and zeroed +its width while a token showed, so the field was left measuring the token. In any +shrink-to-fit parent it snapped to the value's length. Block-level parents hid +it, because they fill their container whatever their content is, which is why no +story caught it. The input now keeps its place in the row and its own width — it +is only made invisible and inert — and the token is painted over that space +rather than beside it. In flow the token would add its own width instead, which +is the same value-dependent sizing from the other direction: a long value would +grow the field. + +**FR2 — the value is bounded by a content lane.** The input and the token share +a content lane: an ordinary flex item, `flex: 1` with `min-width: 0`, that ends +exactly where the end lane begins. That is TextInput's own arrangement — the lane +takes the free space so the end controls sit in the corner, and yields all of it +when the field is narrow, so a narrow field cannot overflow. The token is +anchored at both of the lane's inline edges, so a long value ellipsizes at the +lane's edge instead of reaching the controls. Positioned against the whole field +instead, as the first revision of this change did, it had no idea where those +controls start. + +Measured in Chromium. Widths are the field's border box, field in a `max-content` +parent, `Field.width` otherwise unset: + +| | empty | short value | long value | +| --------------------------------- | ----- | ----------- | ------------ | +| TextInput (family baseline) | 199px | 227px | 227px | +| Typeahead before | 199px | **54.7px** | **224.09px** | +| Typeahead after | 199px | 223px | 223px | +| Typeahead in `InputGroup`, before | 397px | **252.7px** | **422.09px** | +| Typeahead in `InputGroup`, after | 397px | 421px | 421px | + +The 24px between the empty and valued columns is the clear button entering the +row — ordinary for any field whose clear is conditional, it does not vary with the +value, and TextInput's is 28px. + +Overlap is the value's trailing edge past the clear button's leading edge; escape +is how far the value reaches past the field's border. The middle column is this +change's own first revision, which fixed the width and made the overlap worse: + +| field, long value | overlap on main | first revision | now | +| ----------------- | --------------- | -------------- | --------------- | +| shrink-to-fit | 12px | 28.09px | none, 7px clear | +| in `InputGroup` | 12px | 33px | none, 7px clear | +| 220px | 12px | 31.09px | none, 7px clear | +| 180px | 12px | 33px | none, 7px clear | +| 140px | 12px | 33px | none, 7px clear | +| escape, 140–220px | none | up to 4px | none | + +No new API and no constants. An earlier revision floored the field with a +`--typeahead-min-width` public var defaulting to 200px, which review rightly +rejected: it was a second sizing contract beside the documented `Field.width` +prop, it was hand-derived (the empty field measures 199, so the floor overshot by +1), `InputGroup` cancelled it, and it could not help `Tokenizer`. Nothing here +states a width; the lane's `min-width: 0` is the opposite of a floor. + +`Tokenizer` is **not** fixed here. It shares the family promise and breaks it — +199px empty to 114.7px with one token, in the same probe — but by a different +mechanism: its tokens are in flow and wrap, and its input deliberately becomes a +40px continuation lane after them, so what a wrapping multi-value field's width +should be is a design question rather than this bug. Its numbers are identical +before and after this change. + +@freddymeta diff --git a/.changeset/typeahead-tokenizer-busy-indicator.md b/.changeset/typeahead-tokenizer-busy-indicator.md new file mode 100644 index 0000000000000..a4a68cbe515cd --- /dev/null +++ b/.changeset/typeahead-tokenizer-busy-indicator.md @@ -0,0 +1,21 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Typeahead, Tokenizer: the busy indicator is a Spinner in the field's end lane, and the input keeps its text out from under it (#5555) + +Three defects in one block. The indicator a search painted was `` — a static glyph, in a family where every other input paints busy with a `Spinner`, and where `clock` otherwise means _time_. It was an in-flow item at the row's inline end, which is where each field independently parks its clear button, so the two landed on each other: 17×20px of overlap in Typeahead and 19×20px in Tokenizer. The overlap is visual, not functional — the clear button is positioned, so it paints above the in-flow indicator and stays clickable across the whole covered band. And the combobox never carried `aria-busy`, unlike every sibling input. + +The base engine now reports the busy state to the field, which paints it in the one inline-end lane it already owns beside its clear button and end content, and sets `aria-busy` on the input. A caller using `BaseTypeahead` directly is unaffected: it still renders its own visible, named "Loading" status, now a Spinner rather than the clock. + +Typeahead puts both controls **in flow**, as ordinary flex siblings of the input, exactly as TextInput does with its own spinner and clear button — an in-flow box takes up room, so the input cannot run under it and there is nothing to measure. Getting there meant dropping `flex-wrap: wrap` from its wrapper, which the shared field base does not set and TextInput does not use: this field holds at most one token, so there is no second row to wrap to, and wrapping is what made an in-flow lane impossible, since flex moves an item to a new line rather than shrinking it. Measured in Chromium: with `flex-wrap` restored and a token too wide to share the row, the end controls drop to a second row and a 280px field grows from 32px to 46px tall. Unwrapped, a long value ellipsizes in the token instead. + +Tokenizer's own pre-existing case of the overlap closes with it: at 280px with a token and no search running, its clear button covered 20px of the input's content box, and covers none now. + +Tokenizer keeps a measured lane, because it cannot use the in-flow shape: its lane stays pinned to the field's first row while tokens wrap below it, so it has to be out of flow, and an out-of-flow box reserves nothing. Its width is measured with `offsetWidth` rather than `getBoundingClientRect()`. The rect is in viewport space — it carries every CSS transform above the element — while the padding it feeds is in local space, so mixing them broke under any transform: measured in Chromium, `scale(.5)` reserved half of what was needed and put the query back under the controls by 22.83px, and `scale(2)` left the caret in a 202.69px gap. `offsetWidth` is the untransformed border-box width and reports the same number at every scale. + +The measurement reaches CSS as a custom property written to the field wrapper, never as React state, so a lane that grows or shrinks repaints without re-rendering the field. Held in state it cost a second commit every time the lane changed size — once as the spinner arrived and once as it left — which doubled the field's commits across a search for a value no JavaScript reads. The observation is shared too, through the same `observeResize` singleton `useTruncation` uses, so a page of fields costs one callback per frame rather than one observer each. The property is `--_tokenizer-end-lane-width`: private and component-named, like every other runtime layout var in the package, and never something a theme writes. + +The busy indicator now appears in each field's documented anatomy, delegating its theming to `component:Spinner` rather than gaining a target of its own — the disposition `TextArea`, `CheckboxList` and `CommandPalette` already use for the same part. + +@freddymeta diff --git a/apps/storybook/rtl-audit/targets.json b/apps/storybook/rtl-audit/targets.json index e45a938c7f1b3..9052f011a610d 100644 --- a/apps/storybook/rtl-audit/targets.json +++ b/apps/storybook/rtl-audit/targets.json @@ -1,4 +1,15 @@ [ + { + "component": "AppShell", + "storyId": "core-appshell--top-nav-with-side-nav", + "dims": [ + "D4" + ], + "selectors": { + "overlay": ".astryx-app-shell-sidenav", + "overlayRoot": ".astryx-app-shell" + } + }, { "component": "ButtonGroup", "storyId": "core-buttongroup--horizontal", @@ -10,6 +21,28 @@ "next": "[role=\"group\"] button:last-child" } }, + { + "component": "CheckboxList", + "storyId": "core-checkboxlist--rich-descriptions", + "dims": [ + "D2" + ], + "selectors": { + "prev": ".astryx-checkbox-input", + "next": "[data-testid=\"checkbox-end-content\"]" + } + }, + { + "component": "RadioList", + "storyId": "core-radiolist--rich-content", + "dims": [ + "D2" + ], + "selectors": { + "prev": "input[aria-label=\"Pro\"]", + "next": "[data-testid=\"radio-end-content\"]" + } + }, { "component": "Calendar", "storyId": "core-calendar--default", @@ -45,5 +78,27 @@ "prev": "button[aria-label=\"Previous\"]", "next": "button[aria-label=\"Next\"]" } + }, + { + "component": "Typeahead", + "storyId": "core-typeahead--logical-order", + "dims": [ + "D2" + ], + "selectors": { + "prev": ".astryx-token", + "next": ".astryx-input-clear-button" + } + }, + { + "component": "Tokenizer", + "storyId": "core-tokenizer--logical-order", + "dims": [ + "D2" + ], + "selectors": { + "prev": ".astryx-token", + "next": ".astryx-input-clear-button" + } } -] \ No newline at end of file +] diff --git a/apps/storybook/stories/Tokenizer.stories.tsx b/apps/storybook/stories/Tokenizer.stories.tsx index 35c84871efab8..d9cde7f0c8c5c 100644 --- a/apps/storybook/stories/Tokenizer.stories.tsx +++ b/apps/storybook/stories/Tokenizer.stories.tsx @@ -25,6 +25,27 @@ const userSource: SearchSource = { bootstrap: () => users.slice(0, 5), }; +/** + * A remote source, near enough — the busy state only exists between the + * keystroke and the response, so a synchronous source never shows it. + */ +const slowUserSource: SearchSource = { + search: (query: string) => + new Promise(resolve => { + setTimeout( + () => + resolve( + users.filter(u => + u.label.toLowerCase().includes(query.toLowerCase()), + ), + ), + 1200, + ); + }), + bootstrap: () => + new Promise(resolve => setTimeout(() => resolve(users.slice(0, 5)), 1200)), +}; + const meta: Meta = { title: 'Core/Tokenizer', component: Tokenizer, @@ -454,3 +475,51 @@ export const StatusVariantComparison: Story = { ); }, }; + +export const Loading: Story = { + render: args => { + const [value, setValue] = useState([users[0]]); + return ( + setValue(items)} + hasClear + endContent={{value.length} selected} + /> + ); + }, + args: { + label: 'Team Members', + placeholder: 'Search people...', + }, + name: 'Loading (async source, with clear and end content)', +}; + +/** + * Tokens plus a clear-all button — the two ends of the field. Under RTL they + * must swap sides; this is the story the RTL audit measures as a D2 + * layout-order-flip. + */ +export const LogicalOrder: Story = { + render: args => { + const [value, setValue] = useState([users[0], users[2]]); + return ( +
+ setValue(items)} + /> +
+ ); + }, + args: { + label: 'Team Members', + placeholder: 'Add more...', + hasClear: true, + }, + name: 'Logical order', +}; diff --git a/apps/storybook/stories/Typeahead.stories.tsx b/apps/storybook/stories/Typeahead.stories.tsx index 4acadf6d44a24..2a34d4e791a21 100644 --- a/apps/storybook/stories/Typeahead.stories.tsx +++ b/apps/storybook/stories/Typeahead.stories.tsx @@ -24,6 +24,45 @@ const fruitSource: SearchSource = { bootstrap: () => fruits.slice(0, 5), }; +/** + * A value longer than the input's own width — the case that used to collapse + * the field onto its value, and then to run under the clear button. + */ +const longFruit: SearchableItem = { + id: '9', + label: 'Elderberry and Blackcurrant Preserve', +}; + +const longFruitSource: SearchSource = { + search: (query: string) => + [...fruits, longFruit].filter(f => + f.label.toLowerCase().includes(query.toLowerCase()), + ), + bootstrap: () => [longFruit, ...fruits.slice(0, 4)], +}; + +/** + * A remote source, near enough. The busy state only exists between the + * keystroke and the response, so a synchronous source never shows it — which + * is why the indicator went unexercised long enough to ship as a clock. + */ +const slowFruitSource: SearchSource = { + search: (query: string) => + new Promise(resolve => { + setTimeout( + () => + resolve( + fruits.filter(f => + f.label.toLowerCase().includes(query.toLowerCase()), + ), + ), + 1200, + ); + }), + bootstrap: () => + new Promise(resolve => setTimeout(() => resolve(fruits.slice(0, 5)), 1200)), +}; + const meta: Meta = { title: 'Core/Typeahead', component: Typeahead, @@ -265,3 +304,90 @@ export const StatusVariantComparison: Story = { ); }, }; + +export const Loading: Story = { + render: () => { + const [value, setValue] = useState(null); + return ( +
+ +
+ ); + }, + name: 'Loading (async source)', +}; + +/** + * The two cases no Typeahead story covered, which is why a bug this visible + * survived: a value selected, and a parent that is sized by its content. + * + * Every other story renders in a fixed-width container, and a block-level + * parent fills its container whatever its content is — so both hid a field + * that sized itself to its value. Here the field is a flex item, so it is + * shrink-to-fit: a table cell, an inline toolbar, a floated column. + * + * Left, a long value: it must not widen the field, and it must ellipsize + * before the clear button rather than under it. Right, an empty field for + * comparison — the two must be the same width. + */ +export const SelectedValueInAContentSizedParent: Story = { + render: () => { + const [a, setA] = useState(longFruit); + const [b, setB] = useState(null); + return ( +
+ + +
+ ); + }, + name: 'Selected value in a content-sized parent', +}; + +/** + * One field with a selected value and a clear button — the two ends of the + * content lane. The token opens the lane and the clear button closes it, so + * under RTL they must swap sides: this is the story the RTL audit measures as + * a D2 layout-order-flip. + * + * A single field on purpose. The comparison story next to it renders two, and + * the audit's selectors would match across both. + */ +export const LogicalOrder: Story = { + render: () => { + const [value, setValue] = useState(longFruit); + return ( +
+ +
+ ); + }, + name: 'Logical order', +}; diff --git a/packages/core/src/Tokenizer/Tokenizer.doc.mjs b/packages/core/src/Tokenizer/Tokenizer.doc.mjs index d110408a9f994..9f17f62b49985 100644 --- a/packages/core/src/Tokenizer/Tokenizer.doc.mjs +++ b/packages/core/src/Tokenizer/Tokenizer.doc.mjs @@ -258,6 +258,7 @@ export const docs = { {name: 'Token chips', required: false, description: 'Removable chips representing each selected item. Each chip shows a label and a remove button.'}, {name: 'Search input', required: true, description: 'The text input where users type to search the data source. Hides when maxEntries is reached.'}, {name: 'Dropdown menu', required: false, description: 'The search results list that appears below the input as the user types.'}, + {name: 'Spinner', required: false, description: 'Loading indicator shown at the end of the field while a search is in flight.'}, {name: 'End content', required: false, description: 'A trailing slot after the input for action buttons, counts, or other controls.'}, {name: 'Clear button', required: false, description: 'A button that removes all selected tokens at once. Shown when hasClear is true and tokens are present.'}, ], @@ -483,6 +484,7 @@ export const docsZh = { {name: 'Token chips', required: false, description: 'Removable chips representing each selected item. Each chip shows a label and a remove button.'}, {name: 'Search input', required: true, description: 'The text input where users type to search the data source. Hides when maxEntries is reached.'}, {name: 'Dropdown menu', required: false, description: 'The search results list that appears below the input as the user types.'}, + {name: 'Spinner', required: false, description: 'Loading indicator shown at the end of the field while a search is in flight.'}, {name: 'End content', required: false, description: 'A trailing slot after the input for action buttons, counts, or other controls.'}, {name: 'Clear button', required: false, description: 'A button that removes all selected tokens at once. Shown when hasClear is true and tokens are present.'}, ], diff --git a/packages/core/src/Tokenizer/Tokenizer.test.tsx b/packages/core/src/Tokenizer/Tokenizer.test.tsx index 8f42abebf2761..1db25c3dd4b3a 100644 --- a/packages/core/src/Tokenizer/Tokenizer.test.tsx +++ b/packages/core/src/Tokenizer/Tokenizer.test.tsx @@ -9,8 +9,18 @@ * SYNC: When Tokenizer.tsx changes, update tests to match */ -import {describe, it, expect, vi, beforeAll, afterAll, afterEach} from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterAll, + afterEach, +} from 'vitest'; import {render, screen, fireEvent, act, waitFor} from '@testing-library/react'; +import {Profiler} from 'react'; import userEvent from '@testing-library/user-event'; import {Tokenizer} from './Tokenizer'; import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; @@ -1380,3 +1390,256 @@ describe('Tokenizer disabled theme state', () => { expect(root).not.toHaveAttribute('data-disabled'); }); }); + +describe('Tokenizer end-lane reserve', () => { + // Tokenizer keeps the measured lane that Typeahead no longer needs: its + // lane stays pinned to the field's first row while tokens wrap below it, + // so it has to be out of flow, and an out-of-flow box reserves nothing by + // definition. jsdom performs no layout — it reports every width as 0 and + // has no ResizeObserver — so these stub both, which is what makes the + // mechanism, and the bug it had, reproducible in CI. + class StubResizeObserver { + static instances = 0; + private readonly cb: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + StubResizeObserver.instances++; + } + observe(target: Element) { + this.cb( + [ + { + target, + borderBoxSize: [{inlineSize: 24, blockSize: 20}], + contentRect: {width: 24, height: 20}, + } as unknown as ResizeObserverEntry, + ], + this, + ); + } + unobserve() {} + disconnect() {} + } + + // The lane's true, untransformed width. `offsetWidth` is what reports it. + const LANE_LOCAL_WIDTH = 24; + // What `getBoundingClientRect()` would report for that same lane inside a + // `scale(.5)` subtree: viewport space, so half. Reading this instead is the + // bug — the number is spent as padding, which is in local space. + const LANE_VIEWPORT_WIDTH = 12; + + let originalRO: typeof ResizeObserver | undefined; + beforeEach(() => { + originalRO = globalThis.ResizeObserver; + StubResizeObserver.instances = 0; + globalThis.ResizeObserver = StubResizeObserver; + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue( + LANE_LOCAL_WIDTH, + ); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + width: LANE_VIEWPORT_WIDTH, + height: 20, + top: 0, + left: 0, + right: LANE_VIEWPORT_WIDTH, + bottom: 20, + x: 0, + y: 0, + toJSON: () => ({}), + }); + }); + afterEach(() => { + globalThis.ResizeObserver = originalRO as typeof ResizeObserver; + vi.restoreAllMocks(); + }); + + const pendingSource = () => { + let settle: (items: SearchableItem[]) => void = () => {}; + return { + source: { + search: async () => + new Promise(resolve => { + settle = resolve; + }), + bootstrap: () => [], + }, + settle: (items: SearchableItem[] = []) => settle(items), + }; + }; + + const laneHost = (container: HTMLElement) => + container.querySelector( + '[style*="--_tokenizer-end-lane-width"]', + ); + + it('reserves the lane\u2019s untransformed width, not its on-screen width', () => { + // The regression. A CSS transform anywhere above the field scales what + // `getBoundingClientRect()` reports but not what the padding means, so + // the two must not be mixed: measured in Chromium under `scale(.5)` the + // input reserved half what it needed and the live query ran under the + // controls again (22.83px), and under `scale(2)` the caret sat in a + // 202.69px gap. `offsetWidth` is the same number at every scale. + const {container} = render( + {}} + hasClear + />, + ); + expect( + laneHost(container)?.style.getPropertyValue( + '--_tokenizer-end-lane-width', + ), + ).toBe(`${LANE_LOCAL_WIDTH}px`); + }); + + it('reserves the lane when the spinner is the only thing in it', () => { + // Busy-only: no endContent and no clear button, so the lane exists for + // the duration of the search and for nothing else. It still has width, + // and the query still has to clear it. + const {source} = pendingSource(); + const {container} = render( + {}} + hasClear={false} + debounceMs={0} + />, + ); + expect(laneHost(container)).toBeNull(); + + act(() => { + fireEvent.change(screen.getByRole('combobox'), {target: {value: 'Al'}}); + }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect( + laneHost(container)?.style.getPropertyValue( + '--_tokenizer-end-lane-width', + ), + ).toBe(`${LANE_LOCAL_WIDTH}px`); + }); + + it('does not pad the input on the collapsed paths', () => { + // Collapsed: with tokens truncated the input is given no width to pad — + // `inputAtMax` zeroes its padding outright — so a reserve there would be + // padding applied to a zero-width box, fighting the collapse. The lane + // still publishes its width; what must not happen is the input claiming + // the reserve class. + const {container} = render( + {}} + tokenOverflowBehavior="unfocusedInline" + />, + ); + const input = screen.getByRole('combobox'); + expect(input.className).not.toBe(''); + // The reserve is the only rule that reads the lane variable; the + // collapsed input must not carry it. + const reserveClasses = new Set( + (laneHost(container)?.className ?? '').split(' '), + ); + expect( + [...input.classList].some(c => reserveClasses.has(c) && c !== ''), + ).toBe(false); + }); + + it('costs no commit of its own across a whole search', async () => { + // The measurement reaches CSS as a custom property written to the DOM, + // never as state, so it cannot re-render the field. The two commits below + // are the ones the search itself owes: the spinner arriving, and the + // spinner leaving. Held in state, the measurement doubled that. + const {source, settle} = pendingSource(); + const commits: string[] = []; + render( + commits.push(phase)}> + {}} + debounceMs={0} + /> + , + ); + const input = screen.getByRole('combobox'); + commits.length = 0; + + await act(async () => { + fireEvent.change(input, {target: {value: 'Al'}}); + }); + const afterStart = commits.length; + + await act(async () => { + settle([]); + await Promise.resolve(); + }); + + expect(afterStart).toBe(1); + expect(commits.length).toBe(2); + }); + + it('shares one observer across every field on the page', () => { + // Browsers batch per observer instance, so one observer per lane meant N + // callback dispatches a frame for N fields. + render( + <> + {}} + hasClear + /> + {}} + hasClear + /> + {}} + hasClear + /> + , + ); + expect(StubResizeObserver.instances).toBeLessThanOrEqual(1); + }); + + it('takes the room back when the lane goes', async () => { + const {source, settle} = pendingSource(); + const {container} = render( + {}} + hasClear={false} + debounceMs={0} + />, + ); + await act(async () => { + fireEvent.change(screen.getByRole('combobox'), {target: {value: 'Al'}}); + }); + expect(laneHost(container)).not.toBeNull(); + + await act(async () => { + settle([]); + await Promise.resolve(); + }); + await waitFor(() => { + expect(laneHost(container)).toBeNull(); + }); + }); +}); diff --git a/packages/core/src/Tokenizer/Tokenizer.tsx b/packages/core/src/Tokenizer/Tokenizer.tsx index 9b2494aaf31b3..a82465ab91f3e 100644 --- a/packages/core/src/Tokenizer/Tokenizer.tsx +++ b/packages/core/src/Tokenizer/Tokenizer.tsx @@ -25,6 +25,7 @@ import React, { type ReactNode, } from 'react'; import * as stylex from '@stylexjs/stylex'; +import {BusyIndicatorLaneProvider} from '../Typeahead/busyIndicatorLane'; import type {BaseProps} from '../BaseProps'; import type {SizeValue} from '../utils/types'; import {BaseTypeahead} from '../Typeahead/BaseTypeahead'; @@ -40,6 +41,8 @@ import { type FieldStatusVariant, } from '../Field'; import {Token} from '../Token'; +import {Spinner} from '../Spinner'; +import {useEndLaneReserve} from './useEndLaneReserve'; import {renderIconSlot, type IconType} from '../Icon'; import {OverflowList} from '../OverflowList'; import {useLayer} from '../Layer/useLayer'; @@ -232,6 +235,10 @@ export interface TokenizerProps extends Omit< // Styles // ============================================================================= +// How far the end lane sits from the field's inline-end border, named once +// because the input's reserve is derived from the same value. +const END_LANE_INSET = spacingVars['--spacing-2']; + const styles = stylex.create({ wrapper: { position: 'relative', @@ -267,7 +274,7 @@ const styles = stylex.create({ // Match the field's inline padding (inputWrapperStyles.base uses // spacing-2) so end content (clear button, resultCount) lines up with // the text/start-icon inset instead of hugging the border at ~3px. - insetInlineEnd: spacingVars['--spacing-2'], + insetInlineEnd: END_LANE_INSET, display: 'flex', alignItems: 'center', gap: spacingVars['--spacing-2'], @@ -461,6 +468,19 @@ export function Tokenizer({ })); // Focus-within state for overflow truncation + // Reported by BaseTypeahead so the indicator can live in this field's own + // end lane, beside endContent and the clear button. + const [isLoading, setIsLoading] = useState(false); + const busyLane = useMemo(() => ({onBusyChange: setIsLoading}), []); + // What sits in the end lane varies most here — a spinner, arbitrary + // `endContent`, a clear button, or all three — so its width is measured + // rather than assumed, and the input reserves it. + const [laneRef, laneReserve] = useEndLaneReserve(END_LANE_INSET); + // Whether a lane renders at all — known from props and state, without + // measuring anything, which is why the reserve costs no extra commit. + const hasEndLane = Boolean( + isLoading || endContent || (hasClear && value.length > 0 && !isDisabled), + ); const [isFocusedWithin, setIsFocusedWithin] = useState(false); const isTruncated = !isFocusedWithin && tokenOverflowBehavior !== 'none' && value.length > 0; @@ -795,37 +815,45 @@ export function Tokenizer({ ) : ( tokens )} - 0 - ? styles.inputCompact - : undefined - } - /> + {/* The base reports its busy state through this lane, so the + indicator lands in the end controls below beside the clear + button rather than as a second one inside the base. */} + + 0 + ? styles.inputCompact + : undefined, + // Not for the collapsed states above: those give the input no width + // to pad, and `inputAtMax` zeroes its padding outright. + !(isAtMax || isTruncated) && hasEndLane && laneReserve, + ]} + /> + {htmlName != null && value.map(item => ( ({ disabled={isDisabled} /> ))} - {(endContent || (hasClear && value.length > 0 && !isDisabled)) && ( -
+ {hasEndLane && ( +
+ {isLoading && ( + + )} {endContent} {hasClear && value.length > 0 && !isDisabled && ( ({ + paddingInlineEnd: `calc(${laneInset} + var(${LANE_WIDTH_VAR}, 0px))`, + }), +}); + +/** + * Keep a field's input clear of the lane at its inline end. + * + * The lane is absolutely positioned — it has to be, because these wrappers + * wrap, and an in-flow sibling gets pushed onto a second row by a token — and + * an out-of-flow box reserves no space by definition. There is no CSS that + * makes one do so: the input cannot see a sibling's width. So the lane is + * measured, and the input spends the measurement as padding. + * + * What the lane holds is not a fixed set: a clear button that comes and goes + * with the value, a busy indicator that comes and goes with the search, and + * arbitrary `endContent`. Measuring covers all of it, including the + * combinations, and needs no constant kept in step with what renders. + * + * **The measurement never enters React state.** It is written to a custom + * property on the field wrapper and inherited by the input. Held in state, it + * cost the field a second commit every time the lane changed size — once when + * the spinner arrived and again when it left — doubling the field's renders + * across a search for a value no JavaScript ever reads. The observation is + * shared as well: `observeResize` batches every field on the page into one + * callback per frame instead of one observer each. + * + * Takes the lane's inset from the field's inline-end border, as the CSS + * expression that positions it. Returns a ref callback for the lane, and the + * style for the input — which the caller applies only when it renders a lane, + * something it already knows without measuring anything. + */ +export function useEndLaneReserve( + laneInset: string, +): [(node: HTMLElement | null) => void, stylex.StyleXStyles] { + const laneRef = useCallback((node: HTMLElement | null) => { + if (node == null) { + return; + } + // The lane's parent: the field wrapper in both callers, and an ancestor + // of the input either way, which is what inheritance needs. + const host = node.parentElement; + + observeResize(node, () => { + // `offsetWidth`, not `getBoundingClientRect().width`. The rect is in + // VIEWPORT space — it carries every CSS transform between this element + // and the root — while the padding it feeds is in the element's own + // LOCAL space. Under `scale(.5)` the rect reads half the lane's real + // width and the input reserves half of what it needs, so the query runs + // under the controls again; under `scale(2)` it reads double and the + // caret sits in a gap twice the lane's width. Measured in Chromium on a + // 123px lane in a 280px field: at `scale(.5)` the rect published 61.48px + // and the spinner covered 14px of the input's content box; at `scale(2)` + // it published 245.91px, leaving a 125.95px gap and growing the field + // from 32px to 55px tall. `offsetWidth` is the untransformed border-box + // width, so it reports 123 at every scale — the number this padding is + // actually denominated in. + // + // It is already an integer, which is the rounding the old `Math.ceil` + // was there for: a fractional width left as-is reserves a hair too + // little and the glyph's last subpixel column lands on the caret. + host?.style.setProperty(LANE_WIDTH_VAR, `${node.offsetWidth}px`); + }); + + // React 19 runs a ref callback's return value as its cleanup, so the + // observer is released exactly when the lane unmounts. + return () => { + unobserveResize(node); + // The room the lane claimed goes back to the input. Removing beats + // setting 0px: the rule's fallback is already that, and this leaves no + // stale property behind on the DOM. + host?.style.removeProperty(LANE_WIDTH_VAR); + }; + }, []); + + return [laneRef, reserveStyles.reserve(laneInset)]; +} diff --git a/packages/core/src/Typeahead/BaseTypeahead.tsx b/packages/core/src/Typeahead/BaseTypeahead.tsx index 2f2b6af08bdf4..aab4d155c78ce 100644 --- a/packages/core/src/Typeahead/BaseTypeahead.tsx +++ b/packages/core/src/Typeahead/BaseTypeahead.tsx @@ -27,12 +27,15 @@ import React, { type RefObject, } from 'react'; import * as stylex from '@stylexjs/stylex'; +import {useBusyIndicatorLane} from './busyIndicatorLane'; import type {StyleXStyles} from '@stylexjs/stylex'; import {usePopover} from '../Popover/usePopover'; import {useAnnounce} from '../hooks/useAnnounce'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {isImeKeyEvent} from '../utils/ime'; import {TypeaheadItem} from './TypeaheadItem'; import {Icon} from '../Icon'; +import {Spinner} from '../Spinner'; import { colorVars, spacingVars, @@ -310,10 +313,15 @@ const styles = stylex.create({ fontSize: typeScaleVars['--text-supporting-size'], color: colorVars['--color-text-secondary'], }, - loadingSpinner: { + // The indicator a direct caller gets. In flow, where it has always been, so + // it reserves its own width and the input's text never runs under it. + // Typeahead and Tokenizer take the indicator over and paint it in their own + // inline-end lane instead; this is what renders for everyone else. + loadingStatus: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + flexShrink: 0, padding: spacingVars['--spacing-1'], }, }); @@ -424,6 +432,40 @@ export const BaseTypeahead = function BaseTypeahead({ const [isLoading, setIsLoading] = useState(false); const [hasSearched, setHasSearched] = useState(false); + // Report the busy state to a wrapper that has taken the indicator over. + // + // Through a ref, and at the call site rather than from an effect: an effect + // would run after this component had already committed, so the wrapper's + // own state change landed in a second commit — two renders of the whole + // field per transition, four across a search. Called here, the wrapper's + // setState batches with ours into the one commit that React was already + // doing. The ref keeps the identity of a caller's inline arrow from + // mattering, and the guard makes the report edge-triggered: the redundant + // `false` on every keystroke below the query threshold reports nothing. + // + // The ref is synced in a layout effect rather than during render, following + // `onMotionStartRef` in BottomSheetPanel — a render that React discards + // must not leave the ref pointing at the callback from the abandoned pass. + // This effect only writes a ref, so it commits nothing and no wrapper + // re-renders for it; every caller of `setLoading` runs from an event or an + // awaited continuation, long after the first commit. + // A wrapper that owns the inline-end lane subscribes through context; see + // busyIndicatorLane.tsx for why this is not a prop. + const busyLane = useBusyIndicatorLane(); + const onLoadingChangeRef = useRef(busyLane?.onBusyChange); + useIsomorphicLayoutEffect(() => { + onLoadingChangeRef.current = busyLane?.onBusyChange; + }, [busyLane]); + const loadingRef = useRef(false); + const setLoading = useCallback((next: boolean) => { + if (loadingRef.current === next) { + return; + } + loadingRef.current = next; + setIsLoading(next); + onLoadingChangeRef.current?.(next); + }, []); + // Track active pointer to defer popover.show() past click events. // With popover="auto", showing the popover between pointerdown and // pointerup/click causes the browser's light-dismiss to immediately @@ -499,7 +541,7 @@ export const BaseTypeahead = function BaseTypeahead({ // in-flight response for an older query fails the gen check below // instead of overwriting the newer results. const gen = ++searchGenRef.current; - setIsLoading(true); + setLoading(true); setHasSearched(true); try { const searchResults = await searchSource.search(searchQuery); @@ -534,7 +576,7 @@ export const BaseTypeahead = function BaseTypeahead({ setHighlightedIndex(-1); } finally { if (searchGenRef.current === gen) { - setIsLoading(false); + setLoading(false); } } }, @@ -545,6 +587,7 @@ export const BaseTypeahead = function BaseTypeahead({ announce, emptySearchResultsText, __queryEntries, + setLoading, t, ], ); @@ -552,7 +595,7 @@ export const BaseTypeahead = function BaseTypeahead({ // Perform bootstrap const performBootstrap = useCallback(async () => { const gen = ++searchGenRef.current; - setIsLoading(true); + setLoading(true); try { const bootstrapResults = await searchSource.bootstrap(); if (searchGenRef.current !== gen) { @@ -572,10 +615,10 @@ export const BaseTypeahead = function BaseTypeahead({ setResults([]); } finally { if (searchGenRef.current === gen) { - setIsLoading(false); + setLoading(false); } } - }, [searchSource, maxMenuItems, showLayer]); + }, [searchSource, maxMenuItems, showLayer, setLoading]); // Handle query change const handleQueryChange = useCallback( @@ -608,7 +651,7 @@ export const BaseTypeahead = function BaseTypeahead({ // its own `finally` will decline to clear this — so clear it here or // the field spins forever. Backspacing below the threshold on a remote // source is the everyday way to hit that. - setIsLoading(false); + setLoading(false); // Clear any lingering result-count / no-results announcement. announce(''); if (derived.length > 0) { @@ -645,6 +688,7 @@ export const BaseTypeahead = function BaseTypeahead({ debounceMs, searchSource, announce, + setLoading, ], ); @@ -673,11 +717,11 @@ export const BaseTypeahead = function BaseTypeahead({ // Same reason as in handleQueryChange: the invalidated search will not // clear this itself. Selecting a stale result while the next search is // still in flight would otherwise leave the field spinning. - setIsLoading(false); + setLoading(false); popover.hide(); inputRef.current?.focus(); }, - [onChange, popover, searchSource], + [onChange, popover, searchSource, setLoading], ); // Handle focus @@ -889,6 +933,7 @@ export const BaseTypeahead = function BaseTypeahead({ : undefined } aria-autocomplete="list" + aria-busy={isLoading || undefined} aria-describedby={ariaDescribedBy} aria-labelledby={ariaLabelledBy} aria-disabled={isFocusableDisabled ? 'true' : undefined} @@ -923,15 +968,11 @@ export const BaseTypeahead = function BaseTypeahead({ inputXStyle, )} /> - {isLoading && ( - - + {isLoading && busyLane == null && ( + + )} - {popover.render(
{ await waitFor(() => { expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument(); }); + expect(input).toHaveAttribute('aria-busy', 'true'); fireEvent.change(input, {target: {value: 'Ap'}}); await act(async () => { @@ -928,6 +932,7 @@ describe('BaseTypeahead minQueryLength', () => { expect( screen.queryByRole('status', {name: 'Loading'}), ).not.toBeInTheDocument(); + expect(input).not.toHaveAttribute('aria-busy'); expect(input).toHaveAttribute('aria-expanded', 'false'); }); }); @@ -1278,7 +1283,13 @@ describe('Typeahead disabledMessage', () => { />, ); - const container = screen.getByRole('combobox').parentElement as HTMLElement; + // The field itself, by its own class: the input no longer sits directly + // inside it — it is in the content lane that bounds the value — and + // `mouseEnter` does not bubble, so the hover has to land on the element + // the tooltip is actually bound to. + const container = document.querySelector( + '.astryx-typeahead', + ) as HTMLElement; const tooltip = screen.getByRole('tooltip', h); expect(tooltip).toHaveTextContent('You need the Editor role'); @@ -1445,3 +1456,361 @@ describe('Typeahead statusVariant forwarding', () => { ); }); }); + +describe('busy indicator ownership', () => { + /** A source that stays in flight until the test settles it. */ + const pendingSource = () => { + let settle: (items: SearchableItem[]) => void = () => {}; + return { + source: { + search: async () => + new Promise(resolve => { + settle = resolve; + }), + bootstrap: () => [], + }, + settle: (items: SearchableItem[] = []) => settle(items), + }; + }; + + it('renders its own named status for a direct caller', async () => { + // BaseTypeaheadProps is re-exported from the package entry point, so the + // base has direct callers this repo cannot see. They painted no indicator + // of their own — the base did it for them — so it keeps doing it, and the + // status stays a named one rather than a bare aria-busy that only reaches + // assistive tech. + const {source, settle} = pendingSource(); + render( + {}} + debounceMs={0} + />, + ); + const input = screen.getByRole('combobox'); + + fireEvent.change(input, {target: {value: 'App'}}); + await waitFor(() => { + expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument(); + }); + // A Spinner, not the static clock glyph this used to render: `clock` + // means *time* everywhere else in core, and nothing about it moved while + // a search was out. + expect( + screen.getByRole('status', {name: 'Loading'}).querySelector('svg'), + ).toBeInTheDocument(); + + await act(async () => { + settle(); + await Promise.resolve(); + }); + expect( + screen.queryByRole('status', {name: 'Loading'}), + ).not.toBeInTheDocument(); + }); + + it('hands the indicator over to a field that takes it, and renders none itself', async () => { + // Typeahead and Tokenizer paint the spinner in the one inline-end lane + // they already own. Two indicators in one field is the defect this PR + // exists to fix, so the base must yield rather than add to it. + const {source, settle} = pendingSource(); + const onLoadingChange = vi.fn(); + render( + + {}} + debounceMs={0} + /> + , + ); + const input = screen.getByRole('combobox'); + + fireEvent.change(input, {target: {value: 'App'}}); + await waitFor(() => { + expect(input).toHaveAttribute('aria-busy', 'true'); + }); + // Scoped by name: the announcer for result counts is a role="status" + // live region too, and it is not the indicator. + expect( + screen.queryByRole('status', {name: 'Loading'}), + ).not.toBeInTheDocument(); + expect(onLoadingChange).toHaveBeenLastCalledWith(true); + + await act(async () => { + settle(); + await Promise.resolve(); + }); + expect(onLoadingChange).toHaveBeenLastCalledWith(false); + }); + + it('keeps the busy handoff out of the exported prop surface', async () => { + // The handoff used to be `__onLoadingChange` on BaseTypeaheadProps, which + // the package entry point re-exports — so a builder reading the exported + // declaration would find it and could reasonably wire it, pinning a detail + // between two wrappers and their base as permanent API. An `@internal` tag + // is a note to a reader; a module boundary is the actual seam. + const entry = await import('./index'); + expect(Object.keys(entry)).not.toContain('BusyIndicatorLaneProvider'); + expect(Object.keys(entry)).not.toContain('useBusyIndicatorLane'); + + const source = await readFile( + resolve(__dirname, 'BaseTypeahead.tsx'), + 'utf8', + ); + expect(source).not.toContain('__onLoadingChange'); + }); + + it('reports each transition once, and reports nothing when nothing changed', async () => { + // Edge-triggered on purpose. Every keystroke below the query threshold + // clears the flag, and an unconditional report would hand the wrapper a + // `false` per character — each one a state write, and on a field that is + // re-rendering as the user types. + const {source, settle} = pendingSource(); + const onLoadingChange = vi.fn(); + render( + + {}} + minQueryLength={3} + debounceMs={0} + /> + , + ); + const input = screen.getByRole('combobox'); + + // Below the threshold: no search, so nothing to report. + fireEvent.change(input, {target: {value: 'A'}}); + fireEvent.change(input, {target: {value: 'Ap'}}); + await act(async () => { + await Promise.resolve(); + }); + expect(onLoadingChange).not.toHaveBeenCalled(); + + fireEvent.change(input, {target: {value: 'App'}}); + await waitFor(() => { + expect(onLoadingChange).toHaveBeenCalledTimes(1); + }); + expect(onLoadingChange).toHaveBeenCalledWith(true); + + // Back below the threshold: one report out, not one per keystroke. + fireEvent.change(input, {target: {value: 'Ap'}}); + fireEvent.change(input, {target: {value: 'A'}}); + fireEvent.change(input, {target: {value: ''}}); + await act(async () => { + settle(); + await Promise.resolve(); + }); + expect(onLoadingChange).toHaveBeenCalledTimes(2); + expect(onLoadingChange).toHaveBeenLastCalledWith(false); + }); +}); + +describe('end controls stay in flow', () => { + // The fix for the transform bug, expressed as a rule rather than a + // measurement: these controls are ordinary flex siblings of the input, so + // they take up room and nothing has to reserve it for them. jsdom performs + // no layout, so what is asserted is the absence of the two things that + // stopped that being true — an out-of-flow lane, and a padding reserve fed + // by a measured width. The geometry itself is browser-verified in the PR. + it('renders the clear button without taking it out of flow', () => { + const {container} = render( + {}} + />, + ); + const field = container.querySelector('.astryx-typeahead'); + const clear = screen.getByRole('button', {name: /clear/i}); + expect(field).toContainElement(clear); + // The controls sit in a lane that is itself an ordinary in-flow child of + // the field — not a box positioned over it, which is what reserved no + // room and put the input underneath. + const lane = clear.parentElement as HTMLElement; + expect(lane.parentElement).toBe(field); + expect(getComputedStyle(lane).position).not.toBe('absolute'); + }); + + it('never reserves room with a measured width', () => { + // The custom property is Tokenizer's mechanism and must not reappear + // here: a width measured in viewport space and spent as local padding is + // wrong under any CSS transform (measured on Tokenizer's 123px lane, + // scale(.5) covered 14px of the query and scale(2) left a 125.95px gap). + const {container} = render( + {}} + />, + ); + expect( + container.querySelector('[style*="--_tokenizer-end-lane-width"]'), + ).toBeNull(); + }); + + it('holds the controls at the inline end when a token shows', () => { + // `auto` gives free space to the margin rather than to a sibling, so the + // controls stay in the corner in the states where the content lane is not + // the only flexible item in the row. Without it the clear button sat + // against the token in mid-field instead of in the corner (measured: + // x=39 in a 300px field, against TextInput's 281). + render( + {}} + />, + ); + const clear = screen.getByRole('button', {name: /clear/i}); + const lane = clear.parentElement as HTMLElement; + expect(getComputedStyle(lane).marginInlineStart).toBe('auto'); + }); + + it('keeps the field on one row so the controls cannot be pushed off it', () => { + // `flex-wrap` moves an item to a new line rather than shrinking it, so a + // wrapping field put the controls on a row of their own once the token + // got long. The shared field base does not wrap; this must not either. + const {container} = render( + {}} + />, + ); + const field = container.querySelector('.astryx-typeahead') as HTMLElement; + expect(getComputedStyle(field).flexWrap).not.toBe('wrap'); + }); + + it('keeps the input in flow while a token shows, so the field keeps its width', () => { + // A field's width must not depend on its value. Every other field in the + // family gets that for free: the `` stays in flow and the field is + // as wide as the input's own intrinsic width. This one used to take the + // input out of flow and zero its width when a token showed, leaving the + // field measuring the token — in a shrink-to-fit parent it snapped to the + // value's length (199px to 57px in Chromium, #5560). Block parents hid it, + // which is why no story caught it. jsdom resolves no layout, so assert the + // mechanism: the input still occupies the row. + render( + {}} + />, + ); + const input = screen.getByRole('combobox'); + const style = getComputedStyle(input); + expect(style.position).not.toBe('absolute'); + expect(style.width).not.toBe('0px'); + expect(style.flex).not.toBe('0 0 0'); + }); + + it('paints the token over the input rather than beside it', () => { + // In flow the token would add its own width to the row — the same + // value-dependent sizing from the other direction, where a long value + // grows the field instead of collapsing it. + const {container} = render( + {}} + />, + ); + const token = container.querySelector('.astryx-token') as HTMLElement; + expect(getComputedStyle(token).position).toBe('absolute'); + }); + + it('lets the token take the pointer that the hidden input would swallow', () => { + // The input still covers that space, so it has to stop intercepting the + // clicks that enter edit mode. + render( + {}} + />, + ); + expect(getComputedStyle(screen.getByRole('combobox')).pointerEvents).toBe( + 'none', + ); + }); +}); + +describe('the value is bounded by the content lane', () => { + // Keeping the input in flow fixed the field's width, but it left the token + // positioned against the whole field, which has no idea where the end + // controls start. A value longer than the input then ran under the clear + // button and out past the field's own border — measured in Chromium at + // 28-33px of overlap and up to 4px outside the border, worse than the 12px + // of overlap on main. The lane is the box the value may occupy: an ordinary + // flex item that ends exactly where the end lane begins. + // + // jsdom resolves no layout, so what is asserted here is the mechanism. The + // geometry is browser-verified in the PR. + const renderWithValue = () => + render( + {}} + hasClear + />, + ); + + it('puts the input and the token in one lane, inside the field', () => { + const {container} = renderWithValue(); + const field = container.querySelector('.astryx-typeahead'); + const input = screen.getByRole('combobox'); + const token = container.querySelector('.astryx-token') as HTMLElement; + + const lane = input.parentElement as HTMLElement; + expect(lane).not.toBe(field); + expect(lane.parentElement).toBe(field); + // The token's containing block is the lane, which is what bounds it. + expect(token.parentElement).toBe(lane); + expect(getComputedStyle(lane).position).toBe('relative'); + }); + + it('lets the lane yield its whole width, so a narrow field cannot overflow', () => { + // `min-width: 0` is the half of this that the earlier `200px` floor got + // wrong: the field states no width of its own, so it still shrinks to + // whatever a narrow parent or an InputGroup gives it. + const {container} = renderWithValue(); + const lane = screen.getByRole('combobox').parentElement as HTMLElement; + const style = getComputedStyle(lane); + expect(style.minWidth).toBe('0'); + expect(style.flexGrow).toBe('1'); + // Nothing states a width: a narrow parent gets all of it back. + expect( + (container.querySelector('.astryx-typeahead') as HTMLElement).style + .minWidth, + ).toBe(''); + }); + + it("anchors the token at the lane's end, not just its start", () => { + // The bound that stops the value reaching the end controls. Anchored at + // one end only, the token is capped by the field's own padding box, which + // is past the clear button. + const {container} = renderWithValue(); + const token = container.querySelector('.astryx-token') as HTMLElement; + const style = getComputedStyle(token); + expect(style.insetInlineEnd).toBe('0'); + // `fit-content` against that pair of insets is what shrink-wraps the + // label yet still caps it at the lane; the `auto` end margin is what + // keeps the pair from being over-constrained and dropping the end inset. + expect(style.width).toBe('fit-content'); + expect(style.marginInlineEnd).toBe('auto'); + }); +}); diff --git a/packages/core/src/Typeahead/Typeahead.tsx b/packages/core/src/Typeahead/Typeahead.tsx index 4c05dd1579ff8..813de5c2bad3f 100644 --- a/packages/core/src/Typeahead/Typeahead.tsx +++ b/packages/core/src/Typeahead/Typeahead.tsx @@ -22,10 +22,12 @@ import React, { useCallback, useId, useRef, + useMemo, useState, type ReactNode, } from 'react'; import * as stylex from '@stylexjs/stylex'; +import {BusyIndicatorLaneProvider} from './busyIndicatorLane'; import {BaseTypeahead} from './BaseTypeahead'; import {useSize} from '../SizeContext/SizeContext'; import { @@ -42,6 +44,7 @@ import {Token} from '../Token'; import {useTooltip} from '../Tooltip'; import {renderIconSlot, type IconType} from '../Icon'; import {VisuallyHidden} from '../VisuallyHidden'; +import {Spinner} from '../Spinner'; import {spacingVars, sizeVars} from '../theme/tokens.stylex'; import {groupStyles} from '../InputGroup/groupStyles'; import {useInputGroup} from '../InputGroup/InputGroupContext'; @@ -170,7 +173,12 @@ export interface TypeaheadProps extends Omit< const styles = stylex.create({ wrapper: { position: 'relative', - flexWrap: 'wrap', + // No `flexWrap`. The shared field base does not wrap and neither does + // TextInput; this field only ever holds one token, so there is no second + // row to wrap to. It also cannot wrap and keep its end controls in flow: + // flex moves an item to a new line rather than shrinking it, so a long + // value put the clear button and spinner on a row of their own (a 280px + // field grew to 46px tall). Unwrapped, the token ellipsizes instead. gap: spacingVars['--spacing-1'], // Standard padding minus border width to prevent height jump // when a token (28px) is added inside the input @@ -180,29 +188,82 @@ const styles = stylex.create({ ':is(:disabled,[aria-disabled="true"])': 'default', }, }, - token: { - // Offset token so it sits 3px from the inner edge (4px from outer edge - // accounting for 1px border). Default inline padding is 8px, so - // -(8px - 3px) = -5px positions token equidistant from left edge as top. - margin: `calc(-1 * (${spacingVars['--spacing-2']} - ${spacingVars['--spacing-1']} + 1px))`, - }, - clearButton: { - position: 'absolute', - top: `calc((${sizeVars['--size-element-md']} - 20px) / 2 - 1px)`, - insetInlineEnd: `calc((${sizeVars['--size-element-md']} - 20px) / 2 - 1px)`, - height: '20px', + // The busy indicator and the clear button, at the field's inline end. + // + // In flow, as a flex child — an in-flow box takes up room, so the input + // cannot run underneath it and nothing has to be measured. That is + // TextInput's arrangement for the same two controls. + // + // The `auto` margin keeps them in the corner in the states where the + // content lane is not the only flexible item in the row (a start icon, a + // grouped row): `auto` gives free space to the margin rather than to a + // sibling, so it needs no sibling to exist. + endLane: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-1'], + marginInlineStart: 'auto', }, - clearButtonSm: { - top: `calc((${sizeVars['--size-element-sm']} - 20px) / 2 - 1px)`, - insetInlineEnd: `calc((${sizeVars['--size-element-sm']} - 20px) / 2 - 1px)`, + // The field's content lane: the input, and the token painted over it. + // + // This is the box the value is allowed to occupy, and it is what makes the + // end lane's space its own — the lane is an ordinary flex item that ends + // exactly where `endLane` begins, so a value bounded by it can never reach + // the clear button or the spinner. Before it existed the token was + // positioned against the whole field, so a value longer than the input ran + // under the end controls and past the field's border (measured 33px of + // overlap and 4px outside the border at a 180px field). + // + // `flex: 1` with `min-width: 0` is TextInput's own arrangement for its + // input: the lane takes the free space so the end controls sit in the + // corner, and yields all of it when the field is narrow, so a narrow field + // never overflows. Stretched, so its padding box matches the field's + // content box in the block direction and the token keeps the vertical + // placement it has always had. + contentLane: { + position: 'relative', + display: 'flex', + alignItems: 'center', + alignSelf: 'stretch', + flex: 1, + minWidth: 0, }, + // While a token shows, the input keeps its place in the row and its own + // intrinsic width — it is only made invisible and inert. That width is what + // sizes the field, exactly as it does for TextInput, so a Typeahead is as + // wide with a value as without one. Collapsing it to nothing instead left + // the field measuring the token: in any shrink-to-fit parent it snapped to + // the value's length (measured 199px to 57px, #5560), and block-level + // parents hid it because they fill their container whatever their content + // is, which is why no story caught it. inputHidden: { - width: 0, - minWidth: 0, - flex: '0 0 0', - padding: 0, opacity: 0, + // The token is painted over this space and owns the pointer; the input + // must not swallow clicks meant for it, or for the wrapper's own + // click-to-edit. + pointerEvents: 'none', + }, + // Painted over the input rather than beside it. In flow the token would add + // its own width to the row, which is the same value-dependent sizing from + // the other direction — a long value would grow the field. + // + // Bounded by the content lane at both ends. `fit-content` shrink-wraps the + // label but resolves against the space left between the two insets, so a + // long value ellipsizes at the lane's edge instead of running under the end + // controls; the `auto` end margin is what keeps that pair of insets from + // being over-constrained, which would drop the end one and let the token + // overflow again. The negative inline start and the zero block start put it + // where it has always sat: the lane's padding box is the field's content + // box, 3px inside the field's own padding on both axes. + tokenOverlay: { position: 'absolute' as const, + insetBlockStart: 0, + insetInlineStart: `calc(-1 * (${spacingVars['--spacing-1']} - 1px))`, + insetInlineEnd: 0, + width: 'fit-content', + marginBlock: 0, + marginInlineStart: 0, + marginInlineEnd: 'auto', }, }); @@ -299,6 +360,10 @@ export function Typeahead({ }); // Edit mode: when the user clicks the token to edit the selected value + // Reported by BaseTypeahead so the indicator can live in this field's own + // end lane, beside the clear button, rather than in the engine's row. + const [isLoading, setIsLoading] = useState(false); + const busyLane = useMemo(() => ({onBusyChange: setIsLoading}), []); const [isEditing, setIsEditing] = useState(false); const [editingValue, setEditingValue] = useState(null); @@ -456,56 +521,69 @@ export function Typeahead({ {inputGroup && ( {label} )} - {showToken && ( - - )} - - {hasClear && value && !isDisabled && ( - { - e.stopPropagation(); - handleClear(); - }} - xstyle={[styles.clearButton, size === 'sm' && styles.clearButtonSm]} - /> + {/* The base reports its busy state through this lane, so the + indicator lands in the end controls below beside the clear button + rather than as a second one inside the base. */} + +
+ {showToken && ( + + )} + +
+
+ {(isLoading || (hasClear && value && !isDisabled)) && ( +
+ {isLoading && ( + + )} + {hasClear && value && !isDisabled && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
)}
{showsDisabledMessage && diff --git a/packages/core/src/Typeahead/busyIndicatorLane.tsx b/packages/core/src/Typeahead/busyIndicatorLane.tsx new file mode 100644 index 0000000000000..7a2041fd6c361 --- /dev/null +++ b/packages/core/src/Typeahead/busyIndicatorLane.tsx @@ -0,0 +1,52 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file busyIndicatorLane.tsx + * @input A wrapper's loading setter, provided around BaseTypeahead + * @output Context the base uses to hand its busy state to that wrapper + * @position Package-internal; not exported from the package entry point + */ + +import {createContext, use} from 'react'; + +/** + * How the base hands its busy state to a wrapper that owns the inline-end lane. + * + * Typeahead and Tokenizer both render a clear button and end content in one + * corner, and both want the busy indicator in that same lane rather than a + * second one competing for the corner. The base is the only thing that knows + * when a search starts or settles, so that state has to travel from the base + * out to its wrapper. + * + * A CONTEXT rather than a prop, because a prop cannot be package-internal here: + * `BaseTypeaheadProps` is re-exported from the package entry point, so every + * name on it ships as public API however it is commented — an `@internal` tag + * is a note to a reader, not a boundary. A builder reading the exported + * declaration would find `__onLoadingChange` and could reasonably wire it, + * pinning a handoff between two wrappers and their base as permanent API. + * + * This module is not exported from `index.ts`, so the seam is closed by module + * boundary instead of by naming convention. + */ +export interface BusyIndicatorLane { + /** Called when a search starts or settles. */ + onBusyChange: (isBusy: boolean) => void; +} + +const BusyIndicatorLaneContext = createContext(null); +BusyIndicatorLaneContext.displayName = 'BusyIndicatorLaneContext'; + +export const BusyIndicatorLaneProvider = BusyIndicatorLaneContext.Provider; + +/** + * The wrapper's lane, or null when the base is used directly. + * + * Null is the released behaviour: the base renders its own visible, named + * status. Non-null means a wrapper paints it instead, so the base renders + * nothing and the two cannot both appear. + */ +export function useBusyIndicatorLane(): BusyIndicatorLane | null { + return use(BusyIndicatorLaneContext); +}