diff --git a/.changeset/string-filter-case-sensitive.md b/.changeset/string-filter-case-sensitive.md
new file mode 100644
index 00000000..1211e6fb
--- /dev/null
+++ b/.changeset/string-filter-case-sensitive.md
@@ -0,0 +1,7 @@
+---
+"@tailor-platform/app-shell": minor
+---
+
+Add case-sensitivity control for string filters in `DataTable.Filters`. String filters are now case-insensitive by default (using Tailor Platform's `regex` operator with `(?i)` prefix). A "Case sensitive" checkbox allows users to opt into exact-case matching.
+
+The `Filter` type and `CollectionControl.addFilter` now accept an optional `caseSensitive` property to control this behavior programmatically.
diff --git a/examples/nextjs-app/src/modules/pages/mock-data.ts b/examples/nextjs-app/src/modules/pages/mock-data.ts
index 057ea0b5..3a9509b9 100644
--- a/examples/nextjs-app/src/modules/pages/mock-data.ts
+++ b/examples/nextjs-app/src/modules/pages/mock-data.ts
@@ -253,8 +253,8 @@ function compareValues(left: unknown, right: unknown): number | null {
}
function matchStringOperator(fieldValue: unknown, operator: string, expected: unknown): boolean {
- const value = String(fieldValue ?? "").toLowerCase();
- const needle = String(expected ?? "").toLowerCase();
+ const value = String(fieldValue ?? "");
+ const needle = String(expected ?? "");
switch (operator) {
case "contains":
@@ -284,6 +284,13 @@ function matchOperator(fieldValue: unknown, operator: string, expected: unknown)
return Array.isArray(expected) && expected.some((item) => item === fieldValue);
case "nin":
return Array.isArray(expected) && !expected.some((item) => item === fieldValue);
+ case "regex": {
+ const pattern = String(expected ?? "");
+ const caseInsensitive = pattern.startsWith("(?i)");
+ const regexBody = caseInsensitive ? pattern.slice(4) : pattern;
+ const re = new RegExp(regexBody, caseInsensitive ? "i" : "");
+ return re.test(String(fieldValue ?? ""));
+ }
case "contains":
case "notContains":
case "hasPrefix":
diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts
index 55245828..63aa6371 100644
--- a/packages/core/src/components/data-table/i18n.ts
+++ b/packages/core/src/components/data-table/i18n.ts
@@ -54,6 +54,7 @@ export const dataTableLabels = defineI18nLabels({
filterBetweenTo: "To",
filterBetweenMin: "Min",
filterBetweenMax: "Max",
+ filterCaseSensitive: "Case sensitive",
// Filter chip label templates
filterChipLabel: (props: { column: string; operator: string; value: string }) =>
@@ -105,6 +106,7 @@ export const dataTableLabels = defineI18nLabels({
filterBetweenTo: "終了",
filterBetweenMin: "最小",
filterBetweenMax: "最大",
+ filterCaseSensitive: "大文字小文字を区別する",
// Filter chip label templates (Japanese: column: value operator)
filterChipLabel: (props: { column: string; operator: string; value: string }) =>
diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx
index 9d18216f..4c25fb91 100644
--- a/packages/core/src/components/data-table/toolbar.test.tsx
+++ b/packages/core/src/components/data-table/toolbar.test.tsx
@@ -239,7 +239,9 @@ describe("StringFilterEditor", () => {
await user.type(input, "Bob");
await user.click(screen.getByRole("button", { name: "Apply" }));
- expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Bob");
+ expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Bob", {
+ caseSensitive: false,
+ });
});
it("Enter key calls addFilter with the updated value", async () => {
@@ -258,7 +260,9 @@ describe("StringFilterEditor", () => {
await user.type(input, "Charlie");
await user.keyboard("{Enter}");
- expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Charlie");
+ expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Charlie", {
+ caseSensitive: false,
+ });
});
it("Apply button calls removeFilter when the value is cleared", async () => {
@@ -278,6 +282,63 @@ describe("StringFilterEditor", () => {
expect(control.removeFilter).toHaveBeenCalledWith("name");
});
+
+ it("shows a Case sensitive checkbox", async () => {
+ const user = userEvent.setup();
+ const control = makeControl({
+ filters: [{ field: "name", operator: "contains", value: "Alice" }],
+ });
+ render(, {
+ wrapper,
+ });
+
+ await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+
+ expect(await screen.findByText("Case sensitive")).toBeDefined();
+ });
+
+ it("Apply with case-sensitive checked calls addFilter with caseSensitive option", async () => {
+ const user = userEvent.setup();
+ const control = makeControl({
+ filters: [{ field: "name", operator: "contains", value: "Alice" }],
+ });
+ render(, {
+ wrapper,
+ });
+
+ await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+
+ const checkbox = await screen.findByRole("checkbox");
+ await user.click(checkbox);
+
+ await user.click(screen.getByRole("button", { name: "Apply" }));
+
+ expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Alice", {
+ caseSensitive: true,
+ });
+ });
+
+ it("restores case-sensitive state from existing filter", async () => {
+ const user = userEvent.setup();
+ const control = makeControl({
+ filters: [
+ {
+ field: "name",
+ operator: "contains",
+ value: "Alice",
+ caseSensitive: true,
+ },
+ ],
+ });
+ render(, {
+ wrapper,
+ });
+
+ await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
+
+ const checkbox = await screen.findByRole("checkbox");
+ expect((checkbox as HTMLElement).dataset.checked).toBeDefined();
+ });
});
// ---------------------------------------------------------------------------
diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx
index 3b2051a9..191d9502 100644
--- a/packages/core/src/components/data-table/toolbar.tsx
+++ b/packages/core/src/components/data-table/toolbar.tsx
@@ -175,6 +175,7 @@ function AddFilterPopover({
const [field, setField] = useState(null);
const [operator, setOperator] = useState("eq");
const [value, setValue] = useState("");
+ const [caseSensitive, setCaseSensitive] = useState(false);
const fieldLabelMap = useMemo(
() => new Map(availableColumns.map((col) => [col.filter.field, col.label ?? col.filter.field])),
@@ -201,12 +202,14 @@ function AddFilterPopover({
setField(null);
setOperator("eq");
setValue("");
+ setCaseSensitive(false);
return;
}
setField(column.filter.field);
setOperator(DEFAULT_OPERATOR[column.filter.type]);
setValue(getInitialAddFilterDraftValue(column.filter.type));
+ setCaseSensitive(false);
}, []);
const handleOpenChange = useCallback(
@@ -230,6 +233,7 @@ function AddFilterPopover({
setField(nextField);
setOperator(DEFAULT_OPERATOR[nextColumn.filter.type]);
setValue(getInitialAddFilterDraftValue(nextColumn.filter.type));
+ setCaseSensitive(false);
},
[availableColumns],
);
@@ -242,9 +246,10 @@ function AddFilterPopover({
selectedColumn.filter.field,
operator,
toAddFilterSubmittedValue(selectedColumn.filter.type, operator, value),
+ selectedColumn.filter.type === "string" ? { caseSensitive } : undefined,
);
setOpen(false);
- }, [selectedColumn, value, operator, control]);
+ }, [selectedColumn, value, operator, caseSensitive, control]);
const renderValueEditor = () => {
if (!selectedColumn) return null;
@@ -434,6 +439,20 @@ function AddFilterPopover({
/>
) : null}
{renderValueEditor()}
+ {selectedColumn?.filter.type === "string" && (
+
+ )}