Skip to content

Commit

Permalink
feat: add onStateChange callback to Command component (#972)
Browse files Browse the repository at this point in the history
Co-authored-by: Hunter Johnston <[email protected]>
Co-authored-by: Hunter Johnston <[email protected]>
  • Loading branch information
3 people authored Dec 10, 2024
1 parent 028a078 commit 38d38f4
Show file tree
Hide file tree
Showing 7 changed files with 74 additions and 17 deletions.
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";

0 comments on commit 38d38f4

Please sign in to comment.