Skip to content
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

feat: add onStateChange callback to Command component #972

Merged
merged 4 commits into from
Dec 10, 2024
Merged
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
5 changes: 5 additions & 0 deletions .changeset/clever-terms-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

feat: add `onStateChange` callback to `Command` component
52 changes: 36 additions & 16 deletions packages/bits-ui/src/lib/bits/command/command.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type CommandRootStateProps = WithRefProps<
loop: boolean;
vimBindings: boolean;
disablePointerSelection: boolean;
onStateChange?: (state: Readonly<CommandState>) => void;
}> &
WritableBoxedValues<{
value: string;
Expand All @@ -66,13 +67,15 @@ type CommandRootStateProps = WithRefProps<
type SetState = <K extends keyof CommandState>(key: K, value: CommandState[K], opts?: any) => void;

class CommandRootState {
allItems = new Set<string>(); // [...itemIds]
allGroups = new Map<string, Set<string>>(); // groupId → [...itemIds]
#updateScheduled = false;
allItems = new Set<string>();
allGroups = new Map<string, Set<string>>();
allIds = new Map<string, { value: string; keywords?: string[] }>();
id: CommandRootStateProps["id"];
ref: CommandRootStateProps["ref"];
filter: CommandRootStateProps["filter"];
shouldFilter: CommandRootStateProps["shouldFilter"];
onStateChange: CommandRootStateProps["onStateChange"];
loop: CommandRootStateProps["loop"];
// attempt to prevent the harsh delay when user is typing fast
key = $state(0);
Expand All @@ -87,9 +90,29 @@ class CommandRootState {
// internal state that we mutate in batches and publish to the `state` at once
_commandState = $state<CommandState>(null!);
snapshot = () => this._commandState;

#scheduleUpdate = () => {
if (this.#updateScheduled) return;
this.#updateScheduled = true;

afterTick(() => {
this.#updateScheduled = false;

const currentState = this.snapshot();
const hasStateChanged = !Object.is(this.commandState, currentState);

if (hasStateChanged) {
this.commandState = currentState;
this.onStateChange?.current?.($state.snapshot(currentState));
}
});
};

setState: SetState = (key, value, opts) => {
if (Object.is(this._commandState[key], value)) return;

this._commandState[key] = value;

if (key === "search") {
// Filter synchronously before emitting back to children
this.#filterItems();
Expand All @@ -102,11 +125,8 @@ class CommandRootState {
this.#scrollSelectedIntoView();
}
}
// notify subscribers that the state has changed
this.emit();
};
emit = () => {
this.commandState = $state.snapshot(this._commandState);

this.#scheduleUpdate();
};

constructor(props: CommandRootStateProps) {
Expand All @@ -118,6 +138,7 @@ class CommandRootState {
this.valueProp = props.value;
this.#vimBindings = props.vimBindings;
this.disablePointerSelection = props.disablePointerSelection;
this.onStateChange = props.onStateChange;

const defaultState = {
/** Value of the search query */
Expand All @@ -133,6 +154,7 @@ class CommandRootState {
groups: new Set<string>(),
},
};

this._commandState = defaultState;
this.commandState = defaultState;

Expand All @@ -149,7 +171,11 @@ class CommandRootState {
};

#sort = () => {
if (!this._commandState.search || this.shouldFilter.current === false) return;
if (!this._commandState.search || this.shouldFilter.current === false) {
// If no search and no selection yet, select first item
if (!this.commandState.value) this.#selectFirstItem();
return;
}

const scores = this._commandState.filtered.items;

Expand Down Expand Up @@ -366,7 +392,6 @@ class CommandRootState {
this._commandState.filtered.items.set(id, this.#score(value, keywords));

this.#sort();
this.emit();

return () => {
this.allIds.delete(id);
Expand All @@ -388,12 +413,7 @@ class CommandRootState {
this.#filterItems();
this.#sort();

// Could be initial mount, select the first item if none already selected
if (!this.commandState.value) {
this.#selectFirstItem();
}

this.emit();
this.#scheduleUpdate();
return () => {
this.allIds.delete(id);
this.allItems.delete(id);
Expand All @@ -406,7 +426,7 @@ class CommandRootState {
// so selection should be moved to the first
if (selectedItem?.getAttribute("id") === id) this.#selectFirstItem();

this.emit();
this.#scheduleUpdate();
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ref = $bindable(null),
value = $bindable(""),
onValueChange = noop,
onStateChange = noop,
loop = false,
shouldFilter = true,
filter = defaultFilter,
Expand Down Expand Up @@ -45,6 +46,7 @@
),
vimBindings: box.with(() => vimBindings),
disablePointerSelection: box.with(() => disablePointerSelection),
onStateChange: box.with(() => onStateChange),
});

const mergedProps = $derived(mergeProps(restProps, rootState.props));
Expand Down
5 changes: 5 additions & 0 deletions packages/bits-ui/src/lib/bits/command/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export type CommandRootPropsWithoutHTML = WithChild<{
*/
filter?: (value: string, search: string, keywords?: string[]) => number;

/**
* A function that is called when the command state changes.
*/
onStateChange?: (state: Readonly<CommandState>) => void;

/**
* Optionally provide or bind to the selected command menu item.
*/
Expand Down
7 changes: 6 additions & 1 deletion sites/docs/src/lib/content/api-reference/command.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
CommandViewportPropsWithoutHTML,
} from "bits-ui";
import { NoopProp, OnStringValueChangeProp } from "./extended-types/shared/index.js";
import { CommandFilterProp } from "./extended-types/command/index.js";
import { CommandFilterProp, CommandOnStateChangeProp } from "./extended-types/command/index.js";
import {
controlledValueProp,
createApiSchema,
Expand Down Expand Up @@ -56,6 +56,11 @@ const root = createApiSchema<CommandRootPropsWithoutHTML>({
description:
"Whether or not the command menu should filter items. This is useful when you want to apply custom filtering logic outside of the Command component.",
}),
onStateChange: createFunctionProp({
definition: CommandOnStateChangeProp,
description: `A callback that fires when the command's internal state changes. This callback receives a readonly snapshot of the current state.
The callback is debounced and only fires once per batch of related updates (e.g., when typing triggers filtering and selection changes).`,
}),
loop: createBooleanProp({
default: C.FALSE,
description:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
```ts
type CommandState = {
/** The value of the search query */
search: string;
/** The value of the selected command menu item */
value: string;
/** The filtered items */
filtered: {
/** The count of all visible items. */
count: number;
/** Map from visible item id to its search store. */
items: Map<string, number>;
/** Set of groups with at least one visible item. */
groups: Set<string>;
};
};

type onStateChange = (state: Readonly<CommandState>) => void;
```
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as CommandFilterProp } from "./command-filter-prop.md";
export { default as CommandOnStateChangeProp } from "./command-on-state-change-prop.md";
Loading