Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1b49367
Don't call onBlur when clicking within the floating menu
jdeichert Nov 7, 2025
fbfc8a1
Add test
jdeichert Nov 10, 2025
f3e2dea
Merge branch 'master' into JOB-141426/fix-autocomplete-onblur-issue
jdeichert Nov 10, 2025
5cc8061
use approach preventing focus rather than blur
ZakaryH Nov 24, 2025
dcfba2f
Revert "use approach preventing focus rather than blur"
ZakaryH Nov 26, 2025
c96f3d5
add blur related tests
ZakaryH Nov 26, 2025
8912561
Revert "add blur related tests"
ZakaryH Nov 26, 2025
1c46824
Reapply "use approach preventing focus rather than blur"
ZakaryH Nov 26, 2025
20d4787
fix focus management, fix tests, add new POM method
ZakaryH Nov 26, 2025
e3ac533
improve openOnFocus assertions
ZakaryH Nov 26, 2025
fee17f3
improve test clarity and quality
ZakaryH Nov 26, 2025
6ec26b6
add coverage for new open/click behavior
ZakaryH Nov 26, 2025
a9278ff
colocate ref logic to avoid passing around
ZakaryH Nov 26, 2025
ae234a6
reuse existing ref
ZakaryH Nov 26, 2025
d0ad2a6
use useClick rather than a separate system to open menu on click
ZakaryH Nov 26, 2025
1b1658d
swap off alerts to a text based 'last action' feedback mechanism to a…
ZakaryH Nov 27, 2025
07f910a
prevent issue with other non interactive elements
ZakaryH Nov 27, 2025
7609a3b
remove redundant focus tracking variable
ZakaryH Nov 27, 2025
2995e7f
remove debouce on clear to improve UX
ZakaryH Nov 27, 2025
ee803fd
move flag resetting to improve onFocus behavior too
ZakaryH Nov 27, 2025
33bc739
merge master, resolve conflicts
ZakaryH Nov 27, 2025
e37f5b8
add blur and focus logging to example to validate behavior
ZakaryH Nov 27, 2025
7d184b0
add example to illustrate focus behavior
ZakaryH Nov 27, 2025
2ae0880
swap to onPointerDown for better compatibility
ZakaryH Nov 28, 2025
8e3191e
clean up example for typos and usability
ZakaryH Nov 28, 2025
d043ec9
remove refs from deps, avoid casting
ZakaryH Nov 28, 2025
a776c22
add context to openOnFocus behavior
ZakaryH Nov 28, 2025
ddeea9f
unify event prevention, colocate methods in util file
ZakaryH Nov 28, 2025
74dd037
regen props
ZakaryH Nov 28, 2025
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
270 changes: 205 additions & 65 deletions docs/components/Autocomplete/WebV2.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ const TemplateSectioned: ComponentStory<typeof Autocomplete> = () => {
placeholder="Search"
value={value}
onChange={setValue}
onBlur={() => console.log("blurred")}
inputValue={inputValue}
onInputChange={setInputValue}
menu={sectionedMenu}
Expand All @@ -151,6 +152,7 @@ const TemplateSectioned: ComponentStory<typeof Autocomplete> = () => {
const TemplateWithActions: ComponentStory<typeof Autocomplete> = () => {
const [value, setValue] = useState<OptionLike | undefined>();
const [inputValue, setInputValue] = useState("");
const [lastAction, setLastAction] = useState("");

return (
<Content>
Expand All @@ -160,6 +162,7 @@ const TemplateWithActions: ComponentStory<typeof Autocomplete> = () => {
placeholder="Search"
value={value}
onChange={setValue}
onBlur={() => console.log("blurred")}
inputValue={inputValue}
onInputChange={setInputValue}
menu={defineMenu<OptionLike>([
Expand All @@ -171,7 +174,7 @@ const TemplateWithActions: ComponentStory<typeof Autocomplete> = () => {
{
type: "action",
label: "Add Service",
onClick: () => alert("Add Service"),
onClick: () => setLastAction("Add Service clicked"),
},
],
},
Expand All @@ -183,7 +186,7 @@ const TemplateWithActions: ComponentStory<typeof Autocomplete> = () => {
{
type: "action",
label: "Add Outdoor Service",
onClick: () => alert("Add Outdoor Service"),
onClick: () => setLastAction("Add Outdoor Service clicked"),
},
],
},
Expand All @@ -195,12 +198,17 @@ const TemplateWithActions: ComponentStory<typeof Autocomplete> = () => {
{
type: "action",
label: "Add Extras Service",
onClick: () => alert("Add Extras Service"),
onClick: () => setLastAction("Add Extras Service clicked"),
},
],
},
])}
/>
{lastAction && (
<Text>
<Emphasis variation="bold">Last action:</Emphasis> {lastAction}
</Text>
)}
</Content>
);
};
Expand All @@ -223,6 +231,8 @@ const TemplateEmptyStateAndActions: ComponentStory<
value={value}
onChange={setValue}
inputValue={inputValue}
onBlur={() => console.log("blurred")}
onFocus={() => console.log("focused")}
onInputChange={setInputValue}
emptyStateMessage="No services found"
emptyActions={[
Expand Down Expand Up @@ -298,6 +308,7 @@ const TemplateCustomRenderOption: ComponentStory<typeof Autocomplete> = () => {
const TemplateHeaderFooter: ComponentStory<typeof Autocomplete> = () => {
const [value, setValue] = useState<OptionLike | undefined>();
const [inputValue, setInputValue] = useState("");
const [lastAction, setLastAction] = useState("");

return (
<Content>
Expand All @@ -307,18 +318,29 @@ const TemplateHeaderFooter: ComponentStory<typeof Autocomplete> = () => {
placeholder="Search"
value={value}
onChange={setValue}
onBlur={() => console.log("blurred")}
onFocus={() => console.log("focused")}
inputValue={inputValue}
onInputChange={setInputValue}
menu={defineMenu<OptionLike>([
{ type: "header", label: "Pinned header", shouldClose: false },
{
type: "header",
label: "Pinned header",
shouldClose: false,
onClick: () => setLastAction("Header clicked"),
},
{ type: "options", options: simpleOptions },
{
type: "footer",
label: "Pinned footer",
onClick: () => alert("Footer"),
},
])}
/>
{lastAction && (
<Text>
<Emphasis variation="bold">Last action:</Emphasis> {lastAction}
</Text>
)}
</Content>
);
};
Expand Down Expand Up @@ -535,77 +557,78 @@ const customOptions2: CustomOption[] = [
},
];

const customActions: MenuAction<ActionExtraProps>[] = [
{
type: "action",
label: "Add Service",
icon: "plus",
onClick: () => alert("Add"),
},
];

const customActions2: MenuAction<ActionExtraProps>[] = [
{
type: "action",
label: "Add Other",
icon: "plus",
onClick: () => alert("Add"),
},
];

const customHeader: MenuHeader<ActionExtraProps> = {
type: "header",
label: "The prices of each service is in CAD",
};

const emptyActions: MenuAction<ActionExtraProps>[] = [
{
type: "action",
label: "Favorite",
icon: "star",
onClick: () => alert("Add"),
},
];

const customFooter: MenuFooter<ActionExtraProps> = {
type: "footer",
label: "Adjust prices",
icon: "edit",
onClick: () => alert("Footer"),
};

interface SectionExtraProps {
icon: IconNames;
}

const sectionedMenuCustomized = defineMenu<
CustomOption,
SectionExtraProps,
ActionExtraProps
>([
{
type: "section",
label: "Indoor",
icon: "home",
options: customOptions,
actions: customActions,
},
{
type: "section",
label: "Off-site",
icon: "fuel",
options: customOptions2,
actions: customActions2,
},
customHeader,
customFooter,
]);

const TemplateEverythingCustomized: ComponentStory<
typeof Autocomplete
> = () => {
const [value, setValue] = useState<CustomOption | undefined>();
const [inputValue, setInputValue] = useState("");
const [lastAction, setLastAction] = useState("");

const customActionsInline: MenuAction<ActionExtraProps>[] = [
{
type: "action",
label: "Add Service",
icon: "plus",
onClick: () => setLastAction("Add Service clicked"),
},
];

const customActions2Inline: MenuAction<ActionExtraProps>[] = [
{
type: "action",
label: "Add Other",
icon: "plus",
onClick: () => setLastAction("Add Other clicked"),
},
];

const emptyActionsInline: MenuAction<ActionExtraProps>[] = [
{
type: "action",
label: "Favorite",
icon: "star",
onClick: () => setLastAction("Favorite clicked"),
},
];

const customFooterInline: MenuFooter<ActionExtraProps> = {
type: "footer",
label: "Adjust prices",
icon: "edit",
onClick: () => setLastAction("Footer clicked"),
};

const sectionedMenuCustomizedInline = defineMenu<
CustomOption,
SectionExtraProps,
ActionExtraProps
>([
{
type: "section",
label: "Indoor",
icon: "home",
options: customOptions,
actions: customActionsInline,
},
{
type: "section",
label: "Off-site",
icon: "fuel",
options: customOptions2,
actions: customActions2Inline,
},
customHeader,
customFooterInline,
]);

return (
<Content>
Expand All @@ -617,8 +640,8 @@ const TemplateEverythingCustomized: ComponentStory<
onChange={setValue}
inputValue={inputValue}
onInputChange={setInputValue}
menu={sectionedMenuCustomized}
emptyActions={emptyActions}
menu={sectionedMenuCustomizedInline}
emptyActions={emptyActionsInline}
filterOptions={(options, searchTerm) => {
return options.filter(option => {
// Search both label and description
Expand Down Expand Up @@ -699,12 +722,128 @@ const TemplateEverythingCustomized: ComponentStory<
suffix={{
icon: "search",
ariaLabel: "Search",
onClick: () => alert("Search"),
onClick: () => setLastAction("Search icon clicked"),
}}
/>
);
}}
/>
{lastAction && (
<Text>
<Emphasis variation="bold">Last action:</Emphasis> {lastAction}
</Text>
)}
</Content>
);
};

const TemplateFocusBehavior: ComponentStory<typeof Autocomplete> = () => {
const [value, setValue] = useState<OptionLike | undefined>();
const [inputValue, setInputValue] = useState("");

const [secondInputValue, setSecondInputValue] = useState("");
const [secondValue, setSecondValue] = useState<OptionLike | undefined>();

const [otherInputValue, setOtherInputValue] = useState("");
const [anotherInputValue, setAnotherInputValue] = useState("");

const [lastBlur, setLastBlur] = useState("");
const [lastFocus, setLastFocus] = useState("");

const [openCreatModal, setOpenCreatModal] = useState(false);

const emptyActions: {
type: "action";
label: string;
icon: string;
onClick: () => void;
}[] = [
{
type: "action",
label: "Add Service",
icon: "plus",
onClick: () => setOpenCreatModal(true),
},
];

return (
<Content>
<Heading level={4}>Focus Behavior</Heading>
<Text>
Try tabbing through the inputs to see the difference between
openOnFocus=false and openOnFocus=true.
</Text>
<Text>
Both Autocompletes have empty state actions that launch a modal, moving
focus away from the Autocomplete.
</Text>
<InputText
placeholder="Another Field to Tab From (Not an Autocomplete)"
version={2}
value={otherInputValue}
onChange={setOtherInputValue}
onBlur={() => setLastBlur("First InputText blurred")}
onFocus={() => setLastFocus("First InputText focused")}
/>
<Autocomplete
version={2}
placeholder="openOnFocus=true"
value={value}
onChange={setValue}
onBlur={() => setLastBlur("First Autocomplete blurred")}
onFocus={() => setLastFocus("First Autocomplete focused")}
inputValue={inputValue}
onInputChange={setInputValue}
emptyStateMessage={false}
emptyActions={emptyActions}
menu={defineMenu<OptionLike>([
{ type: "section", label: "Services", options: serviceOptions },
])}
/>
<InputText
version={2}
placeholder="Another Field to Tab To and From (Not an Autocomplete)"
value={anotherInputValue}
onChange={setAnotherInputValue}
onBlur={() => setLastBlur("Second InputText blurred")}
onFocus={() => setLastFocus("Second InputText focused")}
/>
<Autocomplete
version={2}
placeholder="openOnFocus=false"
openOnFocus={false}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just something I never noticed before.. this makes me think it shouldn't open when clicking it, but it does. The jsdoc comment says Whether the menu should open when the input gains focus. which also seems to imply that it wouldn't open when clicking on it to focus it.

I guess i'm curious in what cases would openOnFocus=false be useful to consumers? Why would they not want the menu to open when focusing via keyboard?

Not a blocker of course.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's fair, it's a little counter intuitive. I added a note in the PR description but that's maybe not the best place since this will likely come up again.

always open the menu on input click (distinct from openOnFocus). focus is a subset of what happens during a click. a click on the input is clear intent to use it so we should show the menu.

so basically we are saying that a click is very high intent. barring misclicks, you are expressing a desire to use this input. as such, if you click on it and it's closed - we open the menu. it's almost a side effect that clicking causes focus.

pure focus that doesn't come from a click such as tabbing through the form elements is more ambiguous as far as intent. did you want to tab to this Autocomplete and use it, or are you actually trying to get to an element that comes after the Autocomplete in the form.

that's why I'd offer a different behavior at all compared to clicking.

as for a real flow or UX this enables: if you try the web story with the openOnFocus={false} using the empty actions, then closing the modal you'll notice that we do not open the Autocomplete again when it regains focus from the closed modal. of course it's not the only way to make that happen, but it's definitely one of the simpler ones rather than say moving focus onto the next sibling element rather than the Autocomplete that initiated the Modal open.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openOnFocus=true
Kapture 2025-11-28 at 10 49 52

openOnFocus=false
Kapture 2025-11-28 at 10 50 40

this is all with keyboard interactions. if I clicked then the behavior difference would be limited to only the Autocomplete not re-opening on Modal close/dismiss

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, that makes sense 👍

I guess my point is that maybe the jsdoc comment could more clearly state what conditions it works under.

Again, not a blocker nor the focus of this PR so I'm not saying we need to update that here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with that. I'll see if I can either document it better in the JSDoc, or if not there at least in the component docs.

menu={defineMenu<OptionLike>([
{ type: "options", options: simpleOptions.slice(0, 3) },
])}
emptyActions={emptyActions}
value={secondValue}
onChange={setSecondValue}
inputValue={secondInputValue}
onInputChange={setSecondInputValue}
onBlur={() => setLastBlur("Second Autocomplete blurred")}
onFocus={() => setLastFocus("Second Autocomplete focused")}
/>
<Modal
open={openCreatModal}
onRequestClose={() => setOpenCreatModal(false)}
>
<Content>
<Heading level={4}>Create service</Heading>
<InputText placeholder="Service name" />
<Button label="Create" onClick={() => setOpenCreatModal(false)} />
</Content>
</Modal>
<div style={{ height: "200px" }} />
{lastBlur && (
<Text>
<Emphasis variation="bold">Last blur:</Emphasis> {lastBlur}
</Text>
)}
{lastFocus && (
<Text>
<Emphasis variation="bold">Last focus:</Emphasis> {lastFocus}
</Text>
)}
</Content>
);
};
Expand All @@ -718,3 +857,4 @@ export const HeaderFooter = TemplateHeaderFooter.bind({});
export const FreeForm = TemplateFreeForm.bind({});
export const AsyncUserManaged = TemplateAsyncUserManaged.bind({});
export const EverythingCustomized = TemplateEverythingCustomized.bind({});
export const FocusBehavior = TemplateFocusBehavior.bind({});
Loading
Loading