Skip to content

feat: add disableAutoFilter prop to Combobox for custom filtering #3615

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

Yeison07
Copy link

Linked Issue

Closes #3600

Description

This PR addresses the issues reported in #3600, where the Combobox component experiences desynchronization between the provided data and the filtered options. The root cause is the internal "contains" filter logic, which always executes before any external handlers (e.g., onInputValueChange). This results in the data being one step behind, causing lag in scenarios involving dynamic filtering, such as minimum length checks or asynchronous API fetches.

To resolve this, I've introduced an optional boolean prop disableAutoFilter (default: false). When set to true, the internal filtering is skipped, allowing users full control over data updates via their own onInputValueChange handler. This ensures seamless handling of custom filtering logic without interference from the component's built-in behavior.

Additionally, a reactive $effect has been added to synchronize the options with the data prop when auto-filtering is disabled, maintaining reactivity.

This approach preserves backward compatibility, as the default behavior remains unchanged.

Alternatives Considered
I evaluated a more flexible option, such as introducing a filterFn prop to allow custom filter callbacks. However, the disableAutoFilter prop provides a simpler and more accessible solution for users, especially beginners, while still enabling advanced customization.

Checklist

Please read and apply all contribution requirements.

  • Your branch should be prefixed with: docs/, feature/, chore/, bugfix/
  • Contributions should target the main branch
  • Documentation should be updated to describe all relevant changes
  • Run pnpm check in the root of the monorepo
  • Run pnpm format in the root of the monorepo
  • Run pnpm lint in the root of the monorepo
  • Run pnpm test in the root of the monorepo
  • If you modify /package projects, please supply a Changeset

Changesets

View our documentation to learn more about Changesets. To create a Changeset:

  1. Navigate to the root of the monorepo in your terminal
  2. Run pnpm changeset and follow the prompts
  3. Commit and push the changeset before flagging your PR review for review.

Copy link

vercel bot commented Jul 19, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
themes.skeleton.dev 🔄 Building (Inspect) Visit Preview 💬 Add feedback Aug 5, 2025 1:29am
www.skeleton.dev ✅ Ready (Inspect) Visit Preview 💬 Add feedback Aug 5, 2025 1:29am

@endigo9740
Copy link
Contributor

@Yeison07 just confirming I see this is ready for review. I've been away the last week so I'm just getting back on my feet here. It may take me a bit longer to get a proper review in, but I will asap.

@endigo9740
Copy link
Contributor

endigo9740 commented Jul 31, 2025

@Yeison07 so reviewing this, I actually like your alternative idea, as we've had had a pending request for custom filtering for a while now:

We must preserve the default behavior for backwards compatibility (to avoid a breaking change), so here's my suggestion:

  1. Remove the disableAutoFilter prop in favor of a filter prop (named that if possible)
  2. Set the type to take a function of course
  3. Then when this is present, it should take over responsibility for the filtering feature
  4. See my comment below regarding the use of $effect
  5. We'll need to provide guidance on the doc page on how users can utilize this

Per the $effect I feel like there may be a better way to handle this. I'm going to defer to @Hugos68 to see if he has any suggestions. Hugo let me know your thoughts here. The goal is to ensure options state remains reactive if the original data changes externally.

zagProps.onInputValueChange?.(event);
}
}));

if (disableAutoFilter) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of:

let options = $state.raw(data);

if (disableAutoFilter) {
  $effect(() => {
	  options = data;
  });
}

We should do:

let options = $derived(data);

Copy link
Author

Choose a reason for hiding this comment

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

I implemented $derived for options, as you suggested, to avoid using $state.raw and $effect, making the logic more declarative and reactive. Instead of using disableAutoFilter, I introduced a prop filter that allows the user to pass a custom filter function or null. If filter is null, options directly uses data with $derived(filter === null ? data : internalOptions). If filter is provided, I maintain an internal state (internalOptions) that is updated in onInputValueChange with the custom filtering. This eliminates the need for $effect and allows for a more flexible API to control filtering from the parent component.

@@ -65,11 +66,19 @@
zagProps.onOpenChange?.(event);
},
onInputValueChange(event) {
const filtered = data.filter((item) => item.label.toLowerCase().includes(event.inputValue.toLowerCase()));
options = filtered;
if (!disableAutoFilter) {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should remove the if check and do:

options = data.filter(filter);

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, the default filter right now should be the default value for the filter in our props, so :
filter = (item, value) => item.label.toLowerCase().includes(value);

Copy link
Author

Choose a reason for hiding this comment

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

The prop filter now uses the suggested default filter, (item, value) => item.label.toLowerCase().includes(value.toLowerCase()), maintaining compatibility with the previous behavior. Instead of options = data.filter(filter), I use an internal state internalOptions and $derived(filter === null ? data : internalOptions) to give the parent component full control over filtering. If filter is null, the component uses data directly, which allows to handle complex cases such as input length checks or asynchronous requests without interference from internal filtering.

@endigo9740
Copy link
Contributor

@Yeison07 just checking on the status of this and the requested changes.

@Yeison07
Copy link
Author

Yeison07 commented Aug 4, 2025

hi @endigo9740 , checking this out, been a bit busy and couldn't, hope to finish it tonight.

…ovide a custom filtering function for items based on the input value.
Copy link

changeset-bot bot commented Aug 5, 2025

🦋 Changeset detected

Latest commit: 2ca02d3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@skeletonlabs/skeleton-svelte Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Yeison07
Copy link
Author

Yeison07 commented Aug 5, 2025

Hey @endigo9740 @Hugos68, I tested my code under three scenarios I think cover the main use cases for the Combobox:

  1. Passing filter: null: This gives the parent full control over filtering, which I think is the best way to handle complex cases like input length checks or async API fetches. It keeps the component simple, avoids extra props, and lets the user decide how to handle the data. I went with this instead of overcomplicating the component internally, and it feels like a solid way to give users total flexibility.
  2. Not passing filter (default behavior): This keeps the component's original behavior, using the default filter (item.label.toLowerCase().includes(value.toLowerCase())). It's great for static lists or simple drop-in use cases where you don't need extra logic. No surprises here, just works as expected.
  3. Passing a custom filter function: This was the trickiest case. It works well for switching filters (like contains to startsWith), but I ran into an issue: when the component first gets focus, onOpenChange loads the full data without applying preconditions (like input length checks). This happens because it resets internalOptions to data. After you clear and type again, the filter works fine, but that first focus feels inconsistent. I'm not sure if this is the expected behavior or if we should tweak onOpenChange to apply the filter with the current input value on open.

Overall, I think the filter prop with null support and internalOptions makes the component flexible, especially for dynamic cases like async fetches or custom preconditions. Since this is my first time working with Zag, I might've missed some edge cases. Let me know what you think about my approach in the latest commit and if the onOpenChange behavior is okay or needs a fix. Thanks for the feedback! 👍

@endigo9740
Copy link
Contributor

endigo9740 commented Aug 5, 2025

@Yeison07 so first things first, this looks much better.

Reviewing the original Zag implementation - which we strive to stick as close to as possible - I see filtering only occurs in onInputValueChange(), per their docs:

onInputValueChange({ inputValue }) {
      const filtered = comboboxData.filter((item) =>
        item.label.toLowerCase().includes(inputValue.toLowerCase()),
      )
      const newOptions = filtered.length > 0 ? filtered : comboboxData
      options = newOptions
},

But I would expect we could perhaps remove the need for internalOptions if we use $state.snapshot. Which could result in something like this:

let {
    // ...
    filter = (item: T, value: string) => item.label.toLowerCase().includes(value.toLowerCase()),
    // ...
}: ComboboxProps<T> = $props();
let options = $derived(data);
onInputValueChange({ inputValue }) {
    const filtered = $state.snapshot(options).filter((item) => filter(item, inputValue));
    options = filtered.length > 0 ? filtered : options;
},

This would:

  • Keep the default filter as the default filter prop value as you have it.
  • Keep a single derived instance of options for reactivity
  • Take a snapshot of the options just before the filter is applied (like cloning)

To be honest I haven't used snapshot() heavily, so I may be making a false assumption here. And I'm not sure if there's implications to to checking the filter state, or it being explicitly null, for other parts of the logic such as onOpenChange.

I'll also again ask @Hugos68 to chime in if he has any input on your code or my suggestion here.

One final thing @Yeison07 - when we do settle on the custom filter solution. We'll need to document this alongside the Combobox itself. Likely as a small paragraph + code snippet that explains that you can replace the filtering option, and showcasing a simple use case. Let me know if you need help with this.

Also make sure to update the changset to represent the shift to the customizable filter

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Combobox not updating with reactive data in Svelte 5 ($state)
3 participants