refactor(web): drop combobox groups, SSR hydration options and a hand-typed form store

This commit is contained in:
Amruth Pillai
2026-09-29 10:27:07 +02:00
parent c302faa70b
commit cb3c594655
7 changed files with 7 additions and 196 deletions
@@ -2,7 +2,6 @@
import type { ComboboxOption } from "./combobox";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
@@ -47,26 +46,6 @@ describe("Combobox", () => {
expect(screen.getAllByText(/Gamma/).length).toBeGreaterThan(0);
});
it("renders grouped options with group labels", async () => {
const user = userEvent.setup();
const groupedOptions: ComboboxOption[] = [
{ value: "alpha", label: "Alpha", group: "Primary" },
{ value: "beta", label: "Beta", group: { value: "secondary", label: "Secondary" } },
{ value: "gamma", label: "Gamma", group: { value: "secondary", label: "Secondary" } },
];
wrap(<Combobox options={groupedOptions} placeholder="Pick something" />);
await user.click(screen.getByRole("combobox"));
expect(screen.getByText("Primary")).toBeInTheDocument();
expect(screen.getByText("Secondary")).toBeInTheDocument();
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByText("Gamma")).toBeInTheDocument();
expect(document.querySelectorAll("[data-slot=combobox-group]")).toHaveLength(2);
});
it("renders nothing extra when given an empty options array (no crash)", () => {
expect(() => wrap(<Combobox options={[]} placeholder="Empty" />)).not.toThrow();
expect(screen.getByText("Empty")).toBeInTheDocument();
+3 -61
View File
@@ -5,13 +5,10 @@ import React from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
ComboboxClear,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxRoot,
ComboboxTrigger,
@@ -28,21 +25,10 @@ type ComboboxOption<TValue extends string | number = string> = {
// Plain-text label used for the collapsed trigger and filtering when `label` is a ReactNode.
// Without it, a JSX label falls back to String(value) (e.g. a raw enum or locale code) in the trigger.
textValue?: string;
group?: string | ComboboxOptionGroup;
keywords?: string[];
disabled?: boolean;
};
type ComboboxOptionGroup = {
value: string;
label: React.ReactNode;
};
type GroupedComboboxOption<TValue extends string | number = string> = ComboboxOptionGroup & {
key: string;
items: ComboboxOption<TValue>[];
};
type SingleComboboxProps<TValue extends string | number = string> = {
options: ComboboxOption<TValue>[];
value?: TValue | null;
@@ -52,7 +38,6 @@ type SingleComboboxProps<TValue extends string | number = string> = {
disabled?: boolean;
showClear?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: React.ReactNode;
className?: string;
id?: string;
@@ -69,7 +54,6 @@ type MultiComboboxProps<TValue extends string | number = string> = {
disabled?: boolean;
showClear?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: React.ReactNode;
className?: string;
id?: string;
@@ -85,13 +69,6 @@ const listContent = <TValue extends string | number>(item: ComboboxOption<TValue
</ComboboxItem>
);
const groupedListContent = <TValue extends string | number>(group: GroupedComboboxOption<TValue>) => (
<ComboboxGroup key={group.key} items={group.items}>
{group.label !== null && group.label !== undefined ? <ComboboxLabel>{group.label}</ComboboxLabel> : null}
<ComboboxCollection>{listContent}</ComboboxCollection>
</ComboboxGroup>
);
function Combobox<TValue extends string | number = string>(props: ComboboxProps<TValue>) {
const {
options,
@@ -99,7 +76,6 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
disabled = false,
showClear = false,
placeholder,
searchPlaceholder,
emptyMessage,
className,
id,
@@ -111,40 +87,6 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
const optionMap = React.useMemo(() => new Map(options.map((opt) => [String(opt.value), opt])), [options]);
const optionGroups = React.useMemo(() => {
const groups: GroupedComboboxOption<TValue>[] = [];
const groupMap = new Map<string, GroupedComboboxOption<TValue>>();
let ungroupedGroup: GroupedComboboxOption<TValue> | null = null;
let hasGroupedOptions = false;
for (const option of options) {
if (option.group === undefined) {
if (!ungroupedGroup) {
ungroupedGroup = { key: "ungrouped", value: "", label: null, items: [] };
groups.push(ungroupedGroup);
}
ungroupedGroup.items.push(option);
continue;
}
hasGroupedOptions = true;
const group = typeof option.group === "string" ? { value: option.group, label: option.group } : option.group;
let optionGroup = groupMap.get(group.value);
if (!optionGroup) {
optionGroup = { ...group, key: `group:${group.value}`, items: [] };
groupMap.set(group.value, optionGroup);
groups.push(optionGroup);
}
optionGroup.items.push(option);
}
return hasGroupedOptions ? groups : null;
}, [options]);
const findOption = React.useCallback(
(v: TValue | TValue[] | null | undefined) => {
if (multiple) {
@@ -238,7 +180,7 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
return (
<ComboboxRoot
name={name}
items={optionGroups ?? options}
items={options}
filter={filter}
disabled={disabled}
value={selectedValue as ComboboxOption<TValue>[] & ComboboxOption<TValue>}
@@ -265,11 +207,11 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
<ComboboxContent>
<ComboboxInput
showTrigger={false}
placeholder={searchPlaceholder ?? placeholder ?? t`Search...`}
placeholder={placeholder ?? t`Search...`}
render={<Input disabled={disabled} className="border-none focus-visible:border-none focus-visible:ring-0" />}
/>
<ComboboxEmpty>{emptyMessage ?? t`No results found.`}</ComboboxEmpty>
<ComboboxList>{optionGroups ? groupedListContent : listContent}</ComboboxList>
<ComboboxList>{listContent}</ComboboxList>
</ComboboxContent>
</ComboboxRoot>
);
+2 -23
View File
@@ -1,3 +1,4 @@
import type { AnyFormApi } from "@tanstack/react-form";
import { t } from "@lingui/core/macro";
import { useStore } from "@tanstack/react-form";
import { useCallback, useEffect, useRef } from "react";
@@ -8,29 +9,7 @@ interface UseFormBlockerOptions {
shouldBlock?: () => boolean;
}
type BlockableFormStore = {
get: () => {
isDirty: boolean;
isSubmitting: boolean;
};
subscribe: {
(observer: {
next?: (value: { isDirty: boolean; isSubmitting: boolean }) => void;
error?: (error: unknown) => void;
complete?: () => void;
}): { unsubscribe: () => void };
(
next: (value: { isDirty: boolean; isSubmitting: boolean }) => void,
error?: (error: unknown) => void,
complete?: () => void,
): { unsubscribe: () => void };
};
};
export function useFormBlocker<TStore extends BlockableFormStore>(
form: { store: TStore },
options?: UseFormBlockerOptions,
) {
export function useFormBlocker(form: Pick<AnyFormApi, "store">, options?: UseFormBlockerOptions) {
const confirm = useConfirm();
const closeDialog = useDialogStore((state) => state.closeDialog);
const setOnBeforeClose = useDialogStore((state) => state.setOnBeforeClose);
-17
View File
@@ -29,21 +29,4 @@ describe("getQueryClient", () => {
// json/meta envelope is included
expect(hashA).toContain('"json"');
});
it("round-trips data through dehydrate/hydrate via oRPC serializer", () => {
const client = getQueryClient();
const serializeData = client.getDefaultOptions().dehydrate?.serializeData;
const deserializeData = client.getDefaultOptions().hydrate?.deserializeData;
expect(serializeData).toBeTypeOf("function");
expect(deserializeData).toBeTypeOf("function");
const original = { id: "x", count: 3, when: new Date("2024-01-01T00:00:00Z") };
const serialized = serializeData?.(original);
const restored = deserializeData?.(serialized) as typeof original;
expect(restored.id).toBe(original.id);
expect(restored.count).toBe(original.count);
expect(restored.when.getTime()).toBe(original.when.getTime());
});
});
-11
View File
@@ -14,17 +14,6 @@ export const getQueryClient = () => {
return JSON.stringify({ json, meta });
},
},
dehydrate: {
serializeData(data) {
const [json, meta] = serializer.serialize(data);
return { json, meta };
},
},
hydrate: {
deserializeData(data) {
return serializer.deserialize(data.json, data.meta);
},
},
},
mutationCache: new MutationCache({
onSettled: (_1, _2, _3, _4, _5, context) => {
+1 -45
View File
@@ -3,13 +3,10 @@ import { describe, expect, it } from "vitest";
import {
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxRoot,
ComboboxSeparator,
@@ -74,29 +71,11 @@ describe("ComboboxItem", () => {
});
});
describe("ComboboxList / ComboboxGroup / ComboboxLabel", () => {
describe("ComboboxList", () => {
it("ComboboxList renders with data-slot='combobox-list'", () => {
renderCombobox({ open: true });
expect(document.querySelector("[data-slot=combobox-list]")).toBeInTheDocument();
});
it("ComboboxGroup and ComboboxLabel render with their slots", () => {
render(
<ComboboxRoot items={items} open>
<ComboboxInput />
<ComboboxContent>
<ComboboxList>
<ComboboxGroup items={items}>
<ComboboxLabel>Fruits</ComboboxLabel>
<ComboboxItem value="Apple">Apple</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>,
);
expect(document.querySelector("[data-slot=combobox-group]")).toBeInTheDocument();
expect(document.querySelector("[data-slot=combobox-label]")).toBeInTheDocument();
});
});
describe("ComboboxSeparator and ComboboxEmpty", () => {
@@ -129,29 +108,6 @@ describe("ComboboxSeparator and ComboboxEmpty", () => {
});
});
describe("ComboboxCollection", () => {
it("renders without throwing", () => {
expect(() =>
render(
<ComboboxRoot items={items} open>
<ComboboxInput />
<ComboboxContent>
<ComboboxList>
<ComboboxCollection>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>,
),
).not.toThrow();
});
});
describe("ComboboxClear", () => {
it("renders inside ComboboxInput when showClear=true", () => {
const { container } = render(
+1 -18
View File
@@ -7,7 +7,7 @@ import {
InputGroupButton,
InputGroupInput,
} from "@reactive-resume/ui/components/input-group";
import { menuLabelClassName, menuSeparatorClassName } from "@reactive-resume/ui/components/menu-styles";
import { menuSeparatorClassName } from "@reactive-resume/ui/components/menu-styles";
import { cn } from "@reactive-resume/utils/style";
const ComboboxRoot = ComboboxPrimitive.Root;
@@ -145,20 +145,6 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return <ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />;
}
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel data-slot="combobox-label" className={cn(menuLabelClassName, className)} {...props} />
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
@@ -212,13 +198,10 @@ export {
ComboboxChips,
ComboboxChipsInput,
ComboboxClear,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxRoot,
ComboboxSeparator,