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
15 changes: 15 additions & 0 deletions .changeset/typeahead-tokenizer-busy-indicator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@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 `<Icon icon="clock">` — 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 latter leaving part of the ✕ unclickable. 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.

The lane is absolutely positioned — these wrappers wrap, and an in-flow sibling gets pushed onto a second row by a token — so it reserved no space, and at a narrow width the query ran underneath it. The input now reserves the lane's measured width, which also closes a pre-existing case of the same bug: at 280px with a value selected, the clear button covered 17px of the live query in Typeahead and 25px in Tokenizer before this change, with no spinner involved at all. Both are 0 now.

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.

@freddymeta
42 changes: 42 additions & 0 deletions apps/storybook/stories/Tokenizer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Tokenizer> = {
title: 'Core/Tokenizer',
component: Tokenizer,
Expand Down Expand Up @@ -454,3 +475,24 @@ export const StatusVariantComparison: Story = {
);
},
};

export const Loading: Story = {
render: args => {
const [value, setValue] = useState<SearchableItem[]>([users[0]]);
return (
<Tokenizer
{...args}
searchSource={slowUserSource}
value={value}
onChange={items => setValue(items)}
hasClear
endContent={<span>{value.length} selected</span>}
/>
);
},
args: {
label: 'Team Members',
placeholder: 'Search people...',
},
name: 'Loading (async source, with clear and end content)',
};
42 changes: 42 additions & 0 deletions apps/storybook/stories/Typeahead.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@ const fruitSource: SearchSource = {
bootstrap: () => fruits.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 — 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<typeof Typeahead> = {
title: 'Core/Typeahead',
component: Typeahead,
Expand Down Expand Up @@ -265,3 +287,23 @@ export const StatusVariantComparison: Story = {
);
},
};

export const Loading: Story = {
render: () => {
const [value, setValue] = useState<SearchableItem | null>(null);
return (
<div style={{width: 320}}>
<Typeahead
label="Fruit"
placeholder="Type to search…"
searchSource={slowFruitSource}
value={value}
onChange={setValue}
hasClear
debounceMs={0}
/>
</div>
);
},
name: 'Loading (async source)',
};
109 changes: 109 additions & 0 deletions packages/core/src/Field/useEndLaneReserve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

/**
* @file useEndLaneReserve.ts
* @input Uses React and the shared ResizeObserver
* @output Exports useEndLaneReserve, which keeps a field's input clear of the
* absolutely-positioned lane at its inline end
* @position Shared field internal. Used by Typeahead and Tokenizer, both of
* which park their clear button, end content and busy indicator in one
* absolutely-positioned lane at the field's inline end.
*
* SYNC: When modified, update this header and the two callers:
* - /packages/core/src/Typeahead/Typeahead.tsx
* - /packages/core/src/Tokenizer/Tokenizer.tsx
*/

import {useCallback} from 'react';
import * as stylex from '@stylexjs/stylex';
import {observeResize, unobserveResize} from '../utils/sharedResizeObserver';

/**
* The measured lane width, published on the field wrapper and read by the
* input through inheritance. A custom property is the point of the design: it
* carries a measurement into CSS without carrying it through React, so a lane
* that grows or shrinks repaints without re-rendering the field.
*/
const LANE_WIDTH_VAR = '--_astryx-end-lane-width';

// Keep the input's text and caret out from under the lane.
//
// The input's content box already stops one wrapper padding short of the
// border, and the lane is inset from that same border — so what is left for
// the input to clear is the lane's inset plus its width, less the padding it
// already has, plus a padding's worth of gap so the text does not touch the
// glyph. The two paddings cancel, which is why this reads as inset + width.
//
// The width arrives as a variable rather than a number, so this rule is
// static: one class, generated once, never regenerated as the lane changes.
// The `0px` fallback covers the frame before the lane is first measured.
const reserveStyles = stylex.create({
reserve: (laneInset: string) => ({
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,
* in Tokenizer, 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, () => {
// Border box: the lane's own padding and border are part of what the
// input has to clear. Rounded up, because a fractional width left as-is
// reserves a hair too little and the glyph's last subpixel column still
// lands on the caret.
host?.style.setProperty(
LANE_WIDTH_VAR,
`${Math.ceil(node.getBoundingClientRect().width)}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)];
}
39 changes: 33 additions & 6 deletions packages/core/src/Tokenizer/Tokenizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
type FieldStatusVariant,
} from '../Field';
import {Token} from '../Token';
import {Spinner} from '../Spinner';
import {useEndLaneReserve} from '../Field/useEndLaneReserve';
import {renderIconSlot, type IconType} from '../Icon';
import {OverflowList} from '../OverflowList';
import {useLayer} from '../Layer/useLayer';
Expand Down Expand Up @@ -232,6 +234,10 @@ export interface TokenizerProps<T extends SearchableItem> 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',
Expand Down Expand Up @@ -267,7 +273,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'],
Expand Down Expand Up @@ -461,6 +467,18 @@ export function Tokenizer<T extends SearchableItem>({
}));

// 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);
// 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;
Expand Down Expand Up @@ -814,17 +832,21 @@ export function Tokenizer<T extends SearchableItem>({
ariaDescribedBy={ariaDescribedBy}
onChangeQuery={onChangeQuery}
__queryEntries={createEntries}
__onLoadingChange={setIsLoading}
debounceMs={debounceMs}
onKeyDown={handleKeyDown}
anchorRef={wrapperRef}
size={size}
inputXStyle={
inputXStyle={[
isAtMax || isTruncated
? styles.inputAtMax
: value.length > 0
? styles.inputCompact
: undefined
}
: 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 => (
Expand All @@ -838,8 +860,13 @@ export function Tokenizer<T extends SearchableItem>({
disabled={isDisabled}
/>
))}
{(endContent || (hasClear && value.length > 0 && !isDisabled)) && (
<div {...stylex.props(styles.endSection, endSectionSizeStyles[size])}>
{hasEndLane && (
<div
ref={laneRef}
{...stylex.props(styles.endSection, endSectionSizeStyles[size])}>
{isLoading && (
<Spinner size="sm" aria-label={t('@astryx.typeahead.loading')} />
)}
{endContent}
{hasClear && value.length > 0 && !isDisabled && (
<InputClearButton
Expand Down
Loading
Loading