Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
15dd368
feat(BaseTypeahead): a menu config with listbox and dialog roles, and…
cixzhang Nov 7, 2026
2d4bded
fix(BaseTypeahead): the dialog role runs through usePopover; only ope…
cixzhang Nov 7, 2026
fd768d0
fix(BaseTypeahead): a dialog's focus return does not reopen the list;…
cixzhang Nov 7, 2026
ca5dc7f
fix(BaseTypeahead): focus return tied to the trap; the opener read at…
cixzhang Nov 8, 2026
91f2921
feat(Tokenizer): forward BaseTypeahead's menu, onOpenChange and open …
cixzhang Nov 8, 2026
3e96725
Merge #10544 (DEC-2 record) into the Tokenizer layer
cixzhang Nov 8, 2026
265a3f2
docs(BaseTypeahead): record DEC-2, no controlRef on TextInput
cixzhang Nov 8, 2026
863f6fa
Merge feat/typeahead-menu (main) into feat/tokenizer-menu
cixzhang Nov 8, 2026
b216a2b
Merge main into feat/typeahead-menu
cixzhang Nov 8, 2026
944e86f
review(Tokenizer): focus guards read the shared tracker and the menu'…
cixzhang Nov 8, 2026
9716e63
fix(Tokenizer): the menu returns to a pressed token, and a token's co…
cixzhang Nov 8, 2026
8135090
fix(Tokenizer): keep the pressed token through the press's blur; use …
cixzhang Nov 8, 2026
f74d44d
fix(Tokenizer): record the token control only while its click passes …
cixzhang Nov 9, 2026
29b4b0d
fix(Tokenizer): end the token-control record with the click's task
cixzhang Nov 9, 2026
82a3e3e
refactor(Typeahead): pass Tokenizer's focus wiring through an interna…
cixzhang Nov 9, 2026
4bbbc22
test(BaseTypeahead): wait for the active option before Enter picks it
cixzhang Nov 9, 2026
778c472
Merge #10544's spec wait into the Tokenizer layer
cixzhang Nov 9, 2026
a4faf6f
fix(Typeahead): a closed dialog menu returns focus to the token, not …
cixzhang Nov 9, 2026
49a6e14
Merge #10544's Typeahead focus-return fix into the Tokenizer layer
cixzhang Nov 9, 2026
8c593c0
fix(Typeahead): return a closed dialog menu's focus to the token at t…
cixzhang Nov 9, 2026
3a17bfd
Merge #10544's restore-point focus fix into the Tokenizer layer
cixzhang Nov 9, 2026
1f58a0e
test(BaseTypeahead): every pick flow in the menu spec waits for the a…
cixzhang Nov 9, 2026
ff4d67a
Merge #10544's spec waits into the Tokenizer layer
cixzhang Nov 9, 2026
910d188
test(BaseTypeahead): make the spec's poll callbacks async; end the ch…
cixzhang Nov 9, 2026
8aa37d5
Merge #10544's lint fix into the Tokenizer layer
cixzhang Nov 9, 2026
e26f00e
Merge main (7f45363bd4) into feat/typeahead-menu
cixzhang Nov 9, 2026
9f908c0
Merge #10544 (with main) into the Tokenizer layer
cixzhang Nov 9, 2026
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
7 changes: 7 additions & 0 deletions .changeset/tokenizer-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] Tokenizer forwards BaseTypeahead's `menu` and `onOpenChange` in its inline presentation, and its handle gains `open()` and `close()`. A token whose own button calls `open()` with a dialog-role menu opens the dialog, and Escape returns focus to that token: focus that a press puts on a token's control, and the menu's focus return, stay there instead of moving to the input. The sheet presentation keeps its own sheet.
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved

@cixzhang
7 changes: 7 additions & 0 deletions .changeset/typeahead-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] BaseTypeahead takes `menu`, which chooses what its anchored menu shows while Astryx keeps the layer: its position, top layer, outside press, Escape, focus return and ARIA. `{role: 'listbox', render}` places information such as a header or a key hint around the result list, and anything a person acts on stays a result, so the arrow keys reach it (a control outside the list warns in development). `{role: 'dialog', label, render}` shows content that takes focus in a dialog named by `label`; closing it returns focus to the element focused when it opened, or to the input. A new `handleRef` exposes `open()` and `close()`, and `onOpenChange` reports each change; there is no controlled open state. Calling `open()` from `onChange` keeps the menu open, so a pick can switch the list to a dialog on the same layer. Typeahead forwards `menu` and `handleRef`.

@cixzhang
180 changes: 180 additions & 0 deletions apps/storybook/stories/BaseTypeahead.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ import {
BaseTypeahead,
Typeahead,
createStaticSource,
type BaseTypeaheadHandle,
type BaseTypeaheadMenu,
type BaseTypeaheadProps,
type SearchableItem,
type SearchSource,
} from '@astryxdesign/core/Typeahead';
import {Tokenizer} from '@astryxdesign/core/Tokenizer';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
import {Text} from '@astryxdesign/core/Text';
import {FunnelIcon} from '@heroicons/react/24/outline';
import {
borderVars,
colorVars,
Expand Down Expand Up @@ -320,3 +328,175 @@ export const NarrowLongResult: Story = {
});
},
};

// A listbox menu with information around the list, and a dialog menu that a
// pick or a filter button opens.
type MenuField = SearchableItem & {values: string[]};

const fields: MenuField[] = [
{id: 'status', label: 'Status', values: ['Open', 'In review', 'Closed']},
{id: 'priority', label: 'Priority', values: ['High', 'Medium', 'Low']},
{id: 'owner', label: 'Owner', values: ['Me', 'My team', 'Anyone']},
];

const fieldSource = createStaticSource<MenuField>(fields);

type Filter = {field: MenuField; value: string};

const menuStyles = stylex.create({
root: {
width: '100%',
maxWidth: 420,
},
field: {
backgroundColor: colorVars['--color-background-surface'],
borderColor: colorVars['--color-border'],
borderRadius: radiusVars['--radius-element'],
borderStyle: 'solid',
borderWidth: borderVars['--border-width'],
paddingBlock: spacingVars['--spacing-1-5'],
paddingInline: spacingVars['--spacing-2'],
outlineColor: {
default: 'transparent',
':has(> input:focus-visible)': focusVars['--focus-outline-color'],
},
outlineOffset: focusVars['--focus-outline-offset'],
outlineStyle: 'solid',
outlineWidth: {
default: '0',
':has(> input:focus-visible)': focusVars['--focus-outline-width'],
},
},
menuNote: {
paddingBlock: spacingVars['--spacing-1'],
paddingInline: spacingVars['--spacing-3'],
},
editor: {
padding: spacingVars['--spacing-3'],
minWidth: 260,
},
});

/**
* A filter field built on BaseTypeahead's `menu`. Typing lists the fields
* with a heading and a key hint around the list. Picking a field switches the
* same menu to a dialog that edits its value; a filter's button reopens that
* dialog, and Escape returns focus to the button.
*/
function MenuExample() {
const fieldRef = useRef<HTMLDivElement>(null);
const typeahead = useRef<BaseTypeaheadHandle>(null);
const [filters, setFilters] = useState<Filter[]>([]);
const [editing, setEditing] = useState<Filter | null>(null);
const [draft, setDraft] = useState('');

const edit = (filter: Filter) => {
setEditing(filter);
setDraft(filter.value);
typeahead.current?.open();
};

const menu: BaseTypeaheadMenu =
editing == null
? {
role: 'listbox',
render: ({listbox, query}) => (
<>
<Text
type="supporting"
color="secondary"
xstyle={menuStyles.menuNote}>
{query === '' ? 'Filter by' : `Fields matching “${query}”`}
</Text>
{listbox}
<Text
type="supporting"
color="secondary"
xstyle={menuStyles.menuNote}>
Arrow keys move, Enter picks
</Text>
</>
),
}
: {
role: 'dialog',
label: `${editing.field.label} filter`,
render: ({close}) => (
<VStack gap={3} xstyle={menuStyles.editor}>
<RadioList
label={editing.field.label}
value={draft}
onChange={setDraft}>
{editing.field.values.map(value => (
<RadioListItem key={value} label={value} value={value} />
))}
</RadioList>
<HStack gap={2} hAlign="end">
<Button label="Cancel" variant="secondary" onClick={close} />
<Button
label="Apply"
variant="primary"
onClick={() => {
const next = {field: editing.field, value: draft};
setFilters(current => [
...current.filter(f => f.field.id !== next.field.id),
next,
]);
close();
}}
/>
</HStack>
</VStack>
),
};

return (
<VStack gap={3} xstyle={menuStyles.root}>
<HStack ref={fieldRef} gap={2} vAlign="center" xstyle={menuStyles.field}>
<Icon icon={FunnelIcon} size="sm" color="secondary" />
<BaseTypeahead<MenuField>
aria-label="Add a filter"
searchSource={fieldSource}
value={null}
onChange={field => {
if (field != null) {
edit({field, value: field.values[0]});
}
}}
onOpenChange={isOpen => {
if (!isOpen) {
setEditing(null);
}
}}
handleRef={typeahead}
menu={menu}
anchorRef={fieldRef}
placeholder="Add a filter…"
hasEntriesOnFocus
debounceMs={0}
/>
</HStack>
<HStack gap={2} wrap="wrap">
{filters.length === 0 ? (
<Text type="supporting" color="secondary">
No filters
</Text>
) : (
filters.map(filter => (
<Button
key={filter.field.id}
label={`${filter.field.label}: ${filter.value}`}
variant="secondary"
size="sm"
onClick={() => edit(filter)}
/>
))
)}
</HStack>
</VStack>
);
}

export const Menu: Story = {
render: () => <MenuExample />,
};
Loading
Loading