@navikt/ds-react 8.5.1 → 8.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/cjs/data/table/root/DataTableRoot.d.ts +27 -0
  2. package/cjs/data/table/root/DataTableRoot.js +8 -2
  3. package/cjs/data/table/root/DataTableRoot.js.map +1 -1
  4. package/cjs/data/table/td/DataTableTd.d.ts +5 -4
  5. package/cjs/data/table/td/DataTableTd.js +2 -2
  6. package/cjs/data/table/td/DataTableTd.js.map +1 -1
  7. package/cjs/data/token-filter/AutoSuggest.d.ts +2 -14
  8. package/cjs/data/token-filter/AutoSuggest.js +16 -89
  9. package/cjs/data/token-filter/AutoSuggest.js.map +1 -1
  10. package/cjs/data/token-filter/AutoSuggest.types.d.ts +12 -0
  11. package/cjs/data/token-filter/AutoSuggest.types.js +3 -0
  12. package/cjs/data/token-filter/AutoSuggest.types.js.map +1 -0
  13. package/cjs/data/token-filter/TokenFilter.js +18 -7
  14. package/cjs/data/token-filter/TokenFilter.js.map +1 -1
  15. package/cjs/data/token-filter/TokenFilter.types.d.ts +8 -2
  16. package/cjs/data/token-filter/helpers/generate-autocomplete-options.d.ts +12 -58
  17. package/cjs/data/token-filter/helpers/generate-autocomplete-options.js +159 -133
  18. package/cjs/data/token-filter/helpers/generate-autocomplete-options.js.map +1 -1
  19. package/cjs/data/token-filter/helpers/grouping.d.ts +28 -0
  20. package/cjs/data/token-filter/helpers/grouping.js +61 -0
  21. package/cjs/data/token-filter/helpers/grouping.js.map +1 -0
  22. package/cjs/data/token-filter/helpers/operators.d.ts +22 -0
  23. package/cjs/data/token-filter/helpers/operators.js +66 -0
  24. package/cjs/data/token-filter/helpers/operators.js.map +1 -0
  25. package/cjs/data/token-filter/helpers/parse-query-text.d.ts +1 -7
  26. package/cjs/data/token-filter/helpers/parse-query-text.js +5 -50
  27. package/cjs/data/token-filter/helpers/parse-query-text.js.map +1 -1
  28. package/cjs/data/token-filter/helpers/query-builder.d.ts +20 -0
  29. package/cjs/data/token-filter/helpers/query-builder.js +38 -0
  30. package/cjs/data/token-filter/helpers/query-builder.js.map +1 -0
  31. package/cjs/data/token-filter/helpers/text-matching.d.ts +16 -0
  32. package/cjs/data/token-filter/helpers/text-matching.js +47 -0
  33. package/cjs/data/token-filter/helpers/text-matching.js.map +1 -0
  34. package/cjs/form/combobox/Input/InputController.js +1 -1
  35. package/cjs/form/combobox/Input/InputController.js.map +1 -1
  36. package/cjs/form/file-upload/dropzone/FileUploadDropzone.js +1 -1
  37. package/cjs/form/file-upload/dropzone/FileUploadDropzone.js.map +1 -1
  38. package/esm/data/table/root/DataTableRoot.d.ts +27 -0
  39. package/esm/data/table/root/DataTableRoot.js +8 -2
  40. package/esm/data/table/root/DataTableRoot.js.map +1 -1
  41. package/esm/data/table/td/DataTableTd.d.ts +5 -4
  42. package/esm/data/table/td/DataTableTd.js +2 -2
  43. package/esm/data/table/td/DataTableTd.js.map +1 -1
  44. package/esm/data/token-filter/AutoSuggest.d.ts +2 -14
  45. package/esm/data/token-filter/AutoSuggest.js +17 -90
  46. package/esm/data/token-filter/AutoSuggest.js.map +1 -1
  47. package/esm/data/token-filter/AutoSuggest.types.d.ts +12 -0
  48. package/esm/data/token-filter/AutoSuggest.types.js +2 -0
  49. package/esm/data/token-filter/AutoSuggest.types.js.map +1 -0
  50. package/esm/data/token-filter/TokenFilter.js +18 -7
  51. package/esm/data/token-filter/TokenFilter.js.map +1 -1
  52. package/esm/data/token-filter/TokenFilter.types.d.ts +8 -2
  53. package/esm/data/token-filter/helpers/generate-autocomplete-options.d.ts +12 -58
  54. package/esm/data/token-filter/helpers/generate-autocomplete-options.js +159 -133
  55. package/esm/data/token-filter/helpers/generate-autocomplete-options.js.map +1 -1
  56. package/esm/data/token-filter/helpers/grouping.d.ts +28 -0
  57. package/esm/data/token-filter/helpers/grouping.js +59 -0
  58. package/esm/data/token-filter/helpers/grouping.js.map +1 -0
  59. package/esm/data/token-filter/helpers/operators.d.ts +22 -0
  60. package/esm/data/token-filter/helpers/operators.js +60 -0
  61. package/esm/data/token-filter/helpers/operators.js.map +1 -0
  62. package/esm/data/token-filter/helpers/parse-query-text.d.ts +1 -7
  63. package/esm/data/token-filter/helpers/parse-query-text.js +2 -45
  64. package/esm/data/token-filter/helpers/parse-query-text.js.map +1 -1
  65. package/esm/data/token-filter/helpers/query-builder.d.ts +20 -0
  66. package/esm/data/token-filter/helpers/query-builder.js +34 -0
  67. package/esm/data/token-filter/helpers/query-builder.js.map +1 -0
  68. package/esm/data/token-filter/helpers/text-matching.d.ts +16 -0
  69. package/esm/data/token-filter/helpers/text-matching.js +45 -0
  70. package/esm/data/token-filter/helpers/text-matching.js.map +1 -0
  71. package/esm/form/combobox/Input/InputController.js +1 -1
  72. package/esm/form/combobox/Input/InputController.js.map +1 -1
  73. package/esm/form/file-upload/dropzone/FileUploadDropzone.js +1 -1
  74. package/esm/form/file-upload/dropzone/FileUploadDropzone.js.map +1 -1
  75. package/package.json +3 -3
  76. package/src/data/table/root/DataTableRoot.tsx +30 -1
  77. package/src/data/table/td/DataTableTd.tsx +13 -6
  78. package/src/data/token-filter/AutoSuggest.tsx +38 -162
  79. package/src/data/token-filter/AutoSuggest.types.ts +14 -0
  80. package/src/data/token-filter/TokenFilter.tsx +16 -11
  81. package/src/data/token-filter/TokenFilter.types.ts +8 -2
  82. package/src/data/token-filter/helpers/generate-autocomplete-options.test.ts +896 -0
  83. package/src/data/token-filter/helpers/generate-autocomplete-options.ts +233 -188
  84. package/src/data/token-filter/helpers/grouping.test.ts +206 -0
  85. package/src/data/token-filter/helpers/grouping.ts +73 -0
  86. package/src/data/token-filter/helpers/operators.test.ts +281 -0
  87. package/src/data/token-filter/helpers/operators.ts +91 -0
  88. package/src/data/token-filter/helpers/parse-query-text.test.ts +4 -213
  89. package/src/data/token-filter/helpers/parse-query-text.ts +7 -69
  90. package/src/data/token-filter/helpers/query-builder.test.ts +126 -0
  91. package/src/data/token-filter/helpers/query-builder.ts +41 -0
  92. package/src/data/token-filter/helpers/text-matching.test.ts +125 -0
  93. package/src/data/token-filter/helpers/text-matching.ts +58 -0
  94. package/src/form/combobox/Input/InputController.tsx +0 -1
  95. package/src/form/file-upload/dropzone/FileUploadDropzone.tsx +0 -1
@@ -1,4 +1,10 @@
1
1
  import type { ParsedProperty, QueryFilterOperator } from "../TokenFilter.types";
2
+ import {
3
+ QUERY_OPERATORS,
4
+ matchFilteringProperty,
5
+ matchOperator,
6
+ matchOperatorPrefix,
7
+ } from "./operators";
2
8
 
3
9
  type ParsedText =
4
10
  | {
@@ -76,73 +82,5 @@ function parseQueryText(
76
82
  };
77
83
  }
78
84
 
79
- /**
80
- * Operators ordered by specificity (longest/most specific first)
81
- * This ensures longer operators like ">=" and "<=" are matched
82
- * before shorter ones like ">" and "<"
83
- */
84
- const QUERY_OPERATORS: QueryFilterOperator[] = [
85
- ">=",
86
- "<=",
87
- "!=",
88
- "!:",
89
- "!^",
90
- "=",
91
- ":",
92
- "^",
93
- ">",
94
- "<",
95
- ];
96
-
97
- /**
98
- * Match a property from the input text by longest property label.
99
- * Case-insensitive matching.
100
- */
101
- function matchFilteringProperty(
102
- filteringProperties: ParsedProperty[],
103
- text: string,
104
- ): ParsedProperty | undefined {
105
- const sortedProperties = [...filteringProperties].sort(
106
- (a, b) => b.propertyLabel.length - a.propertyLabel.length,
107
- );
108
- return sortedProperties.find((prop) =>
109
- text.toLowerCase().startsWith(prop.propertyLabel.toLowerCase()),
110
- );
111
- }
112
-
113
- /**
114
- * Check if the input text is a valid prefix of any allowed operator.
115
- * Returns the prefix if valid, null otherwise.
116
- */
117
- function matchOperatorPrefix(
118
- allowedOperators: QueryFilterOperator[],
119
- filteringText: string,
120
- ): string | null {
121
- const trimmedText = filteringText.trim();
122
-
123
- if (trimmedText.length === 0) {
124
- return "";
125
- }
126
-
127
- const isValidPrefix = allowedOperators.some((operator) =>
128
- operator.toLowerCase().startsWith(trimmedText.toLowerCase()),
129
- );
130
-
131
- return isValidPrefix ? trimmedText : null;
132
- }
133
-
134
- /**
135
- * Match an operator from the input text.
136
- * Operators are already sorted by specificity, so no re-sorting needed.
137
- */
138
- function matchOperator(
139
- allowedOperators: QueryFilterOperator[],
140
- text: string,
141
- ): QueryFilterOperator | undefined {
142
- return allowedOperators.find((operator) =>
143
- text.toLowerCase().startsWith(operator.toLowerCase()),
144
- );
145
- }
146
-
147
- export { QUERY_OPERATORS, parseQueryText };
85
+ export { parseQueryText };
148
86
  export type { ParsedText };
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { buildQueryString } from "./query-builder";
3
+
4
+ describe("buildQueryString", () => {
5
+ describe("basic query building", () => {
6
+ test("builds complete query with all parts", () => {
7
+ expect(buildQueryString("Status", "=", "active")).toBe("Status = active");
8
+ });
9
+
10
+ test("builds query with property and operator only", () => {
11
+ expect(buildQueryString("Status", "=", "")).toBe("Status =");
12
+ });
13
+
14
+ test("builds query with property only", () => {
15
+ expect(buildQueryString("Status", "", "")).toBe("Status");
16
+ });
17
+
18
+ test("returns empty string when all parts are empty", () => {
19
+ expect(buildQueryString("", "", "")).toBe("");
20
+ });
21
+ });
22
+
23
+ describe("operator variations", () => {
24
+ test("builds query with contains operator", () => {
25
+ expect(buildQueryString("Name", ":", "test")).toBe("Name : test");
26
+ });
27
+
28
+ test("builds query with not equal operator", () => {
29
+ expect(buildQueryString("Status", "!=", "inactive")).toBe(
30
+ "Status != inactive",
31
+ );
32
+ });
33
+
34
+ test("builds query with starts with operator", () => {
35
+ expect(buildQueryString("ID", "^", "prefix")).toBe("ID ^ prefix");
36
+ });
37
+
38
+ test("builds query with greater than or equal operator", () => {
39
+ expect(buildQueryString("Count", ">=", "10")).toBe("Count >= 10");
40
+ });
41
+
42
+ test("builds query with all comparison operators", () => {
43
+ expect(buildQueryString("Value", ">", "5")).toBe("Value > 5");
44
+ expect(buildQueryString("Value", "<", "5")).toBe("Value < 5");
45
+ expect(buildQueryString("Value", ">=", "5")).toBe("Value >= 5");
46
+ expect(buildQueryString("Value", "<=", "5")).toBe("Value <= 5");
47
+ });
48
+ });
49
+
50
+ describe("whitespace handling", () => {
51
+ test("joins parts with single space", () => {
52
+ expect(buildQueryString("Property", "=", "value")).toBe(
53
+ "Property = value",
54
+ );
55
+ });
56
+
57
+ test("does not add extra spaces for missing parts", () => {
58
+ expect(buildQueryString("Property", "", "value")).toBe("Property value");
59
+ });
60
+
61
+ test("handles value with spaces", () => {
62
+ expect(buildQueryString("Region", "=", "US East")).toBe(
63
+ "Region = US East",
64
+ );
65
+ });
66
+
67
+ test("handles property with spaces", () => {
68
+ expect(buildQueryString("Instance ID", "=", "12345")).toBe(
69
+ "Instance ID = 12345",
70
+ );
71
+ });
72
+ });
73
+
74
+ describe("edge cases", () => {
75
+ test("handles numeric values", () => {
76
+ expect(buildQueryString("Count", "=", "123")).toBe("Count = 123");
77
+ });
78
+
79
+ test("handles special characters in value", () => {
80
+ expect(buildQueryString("Path", "=", "/var/log")).toBe("Path = /var/log");
81
+ });
82
+
83
+ test("handles hyphenated values", () => {
84
+ expect(buildQueryString("Region", "=", "us-east-1")).toBe(
85
+ "Region = us-east-1",
86
+ );
87
+ });
88
+
89
+ test("omits operator when undefined", () => {
90
+ expect(buildQueryString("Status", undefined as any, "active")).toBe(
91
+ "Status active",
92
+ );
93
+ });
94
+
95
+ test("omits value when null", () => {
96
+ expect(buildQueryString("Status", "=", null as any)).toBe("Status =");
97
+ });
98
+
99
+ test("filters out falsy values correctly", () => {
100
+ // 0 is falsy but should be filtered by Boolean()
101
+ expect(buildQueryString("Count", "=", "0")).toBe("Count = 0");
102
+ // Empty string is falsy and should be filtered
103
+ expect(buildQueryString("Test", "", "")).toBe("Test");
104
+ });
105
+ });
106
+
107
+ describe("real-world examples", () => {
108
+ test("builds property-only queries for operator selection", () => {
109
+ expect(buildQueryString("Status", "!", "")).toBe("Status !");
110
+ });
111
+
112
+ test("builds complete filter queries", () => {
113
+ expect(buildQueryString("Availability Zone", ":", "east")).toBe(
114
+ "Availability Zone : east",
115
+ );
116
+ });
117
+
118
+ test("builds negation queries", () => {
119
+ expect(buildQueryString("Status", "!=", "terminated")).toBe(
120
+ "Status != terminated",
121
+ );
122
+ expect(buildQueryString("Name", "!:", "test")).toBe("Name !: test");
123
+ expect(buildQueryString("ID", "!^", "prod")).toBe("ID !^ prod");
124
+ });
125
+ });
126
+ });
@@ -0,0 +1,41 @@
1
+ import type { QueryFilterOperator } from "../TokenFilter.types";
2
+
3
+ /**
4
+ * Human-readable labels for query filter operators.
5
+ * Used for displaying operator descriptions in autocomplete suggestions.
6
+ * TODO: Support i18n
7
+ */
8
+ const OPERATOR_LABELS: Record<QueryFilterOperator, string> = {
9
+ ":": "contains",
10
+ "!:": "does not contain",
11
+ "=": "is",
12
+ "!=": "is not",
13
+ "^": "starts with",
14
+ "!^": "does not start with",
15
+ ">=": "is greater than or equal to",
16
+ "<=": "is less than or equal to",
17
+ ">": "is greater than",
18
+ "<": "is less than",
19
+ };
20
+
21
+ /**
22
+ * Builds a query string from property label, operator, and value.
23
+ * Only includes non-empty parts, joined by spaces.
24
+ * @returns Space-joined query string
25
+ *
26
+ * @example
27
+ * buildQueryString("Status", "=", "active") // "Status = active"
28
+ * buildQueryString("Status", "=", "") // "Status ="
29
+ * buildQueryString("Status", "", "") // "Status"
30
+ * buildQueryString("", "", "") // ""
31
+ */
32
+ function buildQueryString(
33
+ propertyLabel: string,
34
+ operator: string,
35
+ value: string,
36
+ ): string {
37
+ const parts = [propertyLabel, operator, value].filter(Boolean);
38
+ return parts.join(" ");
39
+ }
40
+
41
+ export { buildQueryString, OPERATOR_LABELS };
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { matchesFilterText } from "./text-matching";
3
+
4
+ describe("matchesFilterText", () => {
5
+ describe("basic matching", () => {
6
+ test("returns true for empty filter text", () => {
7
+ expect(matchesFilterText(["hello", "world"], "")).toBe(true);
8
+ });
9
+
10
+ test("returns true for whitespace-only filter text", () => {
11
+ expect(matchesFilterText(["hello", "world"], " ")).toBe(true);
12
+ });
13
+
14
+ test("returns true when single word matches", () => {
15
+ expect(matchesFilterText(["hello", "world"], "hello")).toBe(true);
16
+ });
17
+
18
+ test("returns true when match is case-insensitive", () => {
19
+ expect(matchesFilterText(["Hello World"], "hello")).toBe(true);
20
+ expect(matchesFilterText(["hello world"], "HELLO")).toBe(true);
21
+ });
22
+
23
+ test("returns true when partial match exists", () => {
24
+ expect(matchesFilterText(["testing"], "test")).toBe(true);
25
+ });
26
+
27
+ test("returns false when no match exists", () => {
28
+ expect(matchesFilterText(["hello", "world"], "foo")).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("multi-word matching", () => {
33
+ test("returns true when all words match across fields", () => {
34
+ expect(matchesFilterText(["hello", "world"], "hello world")).toBe(true);
35
+ });
36
+
37
+ test("returns true when all words match in single field", () => {
38
+ expect(matchesFilterText(["hello world"], "hello world")).toBe(true);
39
+ });
40
+
41
+ test("returns true when words match in any order", () => {
42
+ expect(matchesFilterText(["world hello"], "hello world")).toBe(true);
43
+ });
44
+
45
+ test("returns false when not all words match", () => {
46
+ expect(matchesFilterText(["hello"], "hello world")).toBe(false);
47
+ });
48
+
49
+ test("handles multiple spaces in filter text", () => {
50
+ expect(matchesFilterText(["hello world"], "hello world")).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe("null/undefined handling", () => {
55
+ test("returns false for null searchFieldValues", () => {
56
+ expect(matchesFilterText(null as any, "test")).toBe(false);
57
+ });
58
+
59
+ test("returns false for undefined searchFieldValues", () => {
60
+ expect(matchesFilterText(undefined as any, "test")).toBe(false);
61
+ });
62
+
63
+ test("returns true for null filterText", () => {
64
+ expect(matchesFilterText(["test"], null as any)).toBe(true);
65
+ });
66
+
67
+ test("returns true for undefined filterText", () => {
68
+ expect(matchesFilterText(["test"], undefined as any)).toBe(true);
69
+ });
70
+
71
+ test("filters out null values in searchFieldValues", () => {
72
+ expect(matchesFilterText(["hello", null as any, "world"], "world")).toBe(
73
+ true,
74
+ );
75
+ });
76
+
77
+ test("filters out undefined values in searchFieldValues", () => {
78
+ expect(
79
+ matchesFilterText(["hello", undefined as any, "world"], "world"),
80
+ ).toBe(true);
81
+ });
82
+
83
+ test("returns false when all searchFieldValues are null/undefined", () => {
84
+ expect(matchesFilterText([null as any, undefined as any], "test")).toBe(
85
+ false,
86
+ );
87
+ });
88
+ });
89
+
90
+ describe("edge cases", () => {
91
+ test("returns true for empty searchFieldValues with empty filter", () => {
92
+ expect(matchesFilterText([], "")).toBe(true);
93
+ });
94
+
95
+ test("returns false for empty searchFieldValues with non-empty filter", () => {
96
+ expect(matchesFilterText([], "test")).toBe(false);
97
+ });
98
+
99
+ test("handles special characters in filter text", () => {
100
+ expect(matchesFilterText(["test-value"], "test-value")).toBe(true);
101
+ expect(matchesFilterText(["test.value"], "test.value")).toBe(true);
102
+ });
103
+
104
+ test("handles numbers in filter text", () => {
105
+ expect(matchesFilterText(["version 123"], "123")).toBe(true);
106
+ });
107
+
108
+ test("handles unicode characters", () => {
109
+ expect(matchesFilterText(["café"], "café")).toBe(true);
110
+ expect(matchesFilterText(["🎉 party"], "party")).toBe(true);
111
+ });
112
+
113
+ test("trims whitespace from filter text", () => {
114
+ expect(matchesFilterText(["hello"], " hello ")).toBe(true);
115
+ });
116
+
117
+ test("handles array with single empty string", () => {
118
+ expect(matchesFilterText([""], "test")).toBe(false);
119
+ });
120
+
121
+ test("matches when filter is substring of field", () => {
122
+ expect(matchesFilterText(["prefix-test-suffix"], "test")).toBe(true);
123
+ });
124
+ });
125
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Checks if search field values match the given filter text.
3
+ *
4
+ * @param searchFieldValues - Array of strings to search within (e.g., labels, tags, descriptions)
5
+ * @param filterText - The search text to match against
6
+ * @returns true if all space-separated parts of filterText are found in at least one searchFieldValue
7
+ *
8
+ * @example
9
+ * matchesFilterText(['Hello World', 'foo'], 'hello') // true
10
+ * matchesFilterText(['Hello World', 'foo'], 'hello bar') // false
11
+ * matchesFilterText(['Hello World', 'bar'], 'hello bar') // true
12
+ * matchesFilterText([], 'test') // false
13
+ * matchesFilterText(['test'], '') // true (empty filter matches all)
14
+ */
15
+ function matchesFilterText(
16
+ searchFieldValues: string[],
17
+ filterText: string,
18
+ ): boolean {
19
+ /* Guard against null/undefined inputs */
20
+ if (!searchFieldValues || !Array.isArray(searchFieldValues)) {
21
+ return false;
22
+ }
23
+
24
+ if (filterText === null || filterText === undefined) {
25
+ return true;
26
+ }
27
+
28
+ const normalizedFilter = filterText.trim().toLowerCase();
29
+
30
+ /* Empty filter matches everything */
31
+ if (!normalizedFilter) {
32
+ return true;
33
+ }
34
+
35
+ /* Split filter into parts ("nord land" -> ["nord", "land"]) */
36
+ const parts = normalizedFilter.split(/\s+/).filter(Boolean);
37
+
38
+ if (parts.length === 0) {
39
+ return true;
40
+ }
41
+
42
+ /* Normalize and filter out nullish values */
43
+ const normalizedFields = searchFieldValues
44
+ .map((value) => value?.toLowerCase())
45
+ .filter(Boolean);
46
+
47
+ /* If no valid fields to search, no match */
48
+ if (normalizedFields.length === 0) {
49
+ return false;
50
+ }
51
+
52
+ /* Every part of the filter must be found in at least one field */
53
+ return parts.every((part) =>
54
+ normalizedFields.some((field) => field.includes(part)),
55
+ );
56
+ }
57
+
58
+ export { matchesFilterText };
@@ -1,5 +1,4 @@
1
1
  /* eslint-disable jsx-a11y/click-events-have-key-events */
2
-
3
2
  /* eslint-disable jsx-a11y/no-static-element-interactions */
4
3
  import React, { forwardRef } from "react";
5
4
  import { Floating } from "../../../utils/components/floating/Floating";
@@ -1,5 +1,4 @@
1
1
  /* eslint-disable jsx-a11y/no-static-element-interactions */
2
-
3
2
  /* eslint-disable jsx-a11y/click-events-have-key-events */
4
3
  import React, { forwardRef } from "react";
5
4
  import { CircleSlashIcon, CloudUpIcon } from "@navikt/aksel-icons";