@olenbetong/appframe-ds 1.9.3 → 1.11.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @olenbetong/appframe-ds
2
2
 
3
+ ## 1.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - aeeb072: The grid's new item row is pinned at the top by default, instead of following the data object's `newRecordPosition`. At the bottom it sat at the foot of the viewport, far below the data on a short grid. Pass `newItemRow="bottom"` to keep it there.
8
+
9
+ ### Patch Changes
10
+
11
+ - 4c6a4e7: `endEdit` takes `{ newRecordPosition }` to place a record created from the new record first or last for that save only, overriding the data object's option. The grid uses it so a record saved from the new item row at the top is inserted first, where it was entered, instead of jumping to the end of the data.
12
+ - f4a37c0: The grid's column menu opens the grid's own panels instead of MUI's: Filter opens the filter builder with a new criterion for the column, its value focused (MUI's filter panel edited a filter the grid ignores), and Manage columns opens the grid's columns panel. `FilterBuilder` takes `autoFocusPath` to focus the value of a criterion when it opens.
13
+ - Updated dependencies [c7750da]
14
+ - Updated dependencies [4c6a4e7]
15
+ - @olenbetong/appframe-data@1.10.0
16
+
17
+ ## 1.10.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 3aea329: The filter can compare a field with another column of the same type, offers the dates Now, Day after tomorrow and Two weeks away, and lets each end of a range be typed or picked from the list. The data API cannot read such a filter object, so data handlers convert it through `api/filter/convert` and send it as a filter string (`needsFilterString`).
22
+ - 05f69f0: Date fields in the filter get the whole-period checks of the Windows filter editor, such as Is last week, Is today and Is earlier this month. They take no value, so the value field is hidden, and a filter using one is sent as a filter string.
23
+
24
+ ### Patch Changes
25
+
26
+ - a0fc1ea: AppMenuItem: a long label wraps onto a second line instead of being cut off with an ellipsis.
27
+ - b1cd401: The filter editor groups the relative week, month and year values under a "First day of" heading, as the Windows date chooser does, so "Last week" is not read as the whole week. The toggle next to the value shows a list icon while a relative value is selected.
28
+ - Updated dependencies [3aea329]
29
+ - Updated dependencies [05f69f0]
30
+ - @olenbetong/appframe-core@2.16.0
31
+ - @olenbetong/appframe-data@1.9.0
32
+
3
33
  ## 1.9.3
4
34
 
5
35
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-ds",
3
- "version": "1.9.3",
3
+ "version": "1.11.0",
4
4
  "description": "Components that bind Designsystemet components to Appframe data objects",
5
5
  "type": "module",
6
6
  "types": "./es/index.d.ts",
@@ -63,16 +63,16 @@
63
63
  "@atlaskit/pragmatic-drag-and-drop": "4.0.0",
64
64
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "3.0.0",
65
65
  "@base-ui/react": "1.9.0",
66
- "@olenbetong/appframe-core": "^2.15.2",
67
- "@olenbetong/appframe-data": "^1.8.6",
66
+ "@olenbetong/appframe-core": "^2.16.0",
67
+ "@olenbetong/appframe-data": "^1.10.0",
68
68
  "@olenbetong/appframe-react": "^2.2.8",
69
69
  "clsx": "^2.1.1",
70
- "dockview-react": "8.6.1",
70
+ "dockview-react": "8.8.3",
71
71
  "localforage": "^1.10.0",
72
72
  "mitt": "^3.0.1",
73
73
  "react": "19.3.1",
74
74
  "react-dom": "19.3.1",
75
- "react-window": "2.5.0",
75
+ "react-window": "2.5.3",
76
76
  "temporal-polyfill": "1.0.6"
77
77
  },
78
78
  "devDependencies": {
@@ -82,7 +82,7 @@
82
82
  "@mui/material": "9.5.0",
83
83
  "@mui/x-data-grid-premium": "8.29.5",
84
84
  "@mui/x-license": "8.29.3",
85
- "@navikt/aksel-icons": "8.20.0",
85
+ "@navikt/aksel-icons": "8.21.0",
86
86
  "@testing-library/dom": "^10.4.2",
87
87
  "@testing-library/jest-dom": "^7.0.1",
88
88
  "@testing-library/react": "16.3.3",
@@ -91,7 +91,7 @@
91
91
  "@types/react-dom": "19.3.0",
92
92
  "happy-dom": "21.0.4",
93
93
  "react-error-boundary": "6.1.6",
94
- "react-router": "8.5.0",
94
+ "react-router": "8.5.1",
95
95
  "typescript": "7.0.2",
96
96
  "vitest": "5.0.5"
97
97
  },
@@ -122,6 +122,24 @@ describe("FilterBuilder", () => {
122
122
  );
123
123
  });
124
124
 
125
+ it("focuses the value of the criterion at autoFocusPath", () => {
126
+ renderBuilder({
127
+ autoFocusPath: [1],
128
+ defaultValue: {
129
+ type: "group",
130
+ mode: "and",
131
+ items: [
132
+ { type: "expression", column: "PersonID", operator: "like", value: "a", valueType: "string" },
133
+ { type: "expression", column: "Age", operator: "equals", value: "", valueType: "number" },
134
+ ],
135
+ },
136
+ });
137
+
138
+ let values = screen.getAllByLabelText("Value");
139
+
140
+ expect(document.activeElement).toBe(values[1]);
141
+ });
142
+
125
143
  it("reports an empty filter as null", async () => {
126
144
  let onApply = vi.fn();
127
145
  renderBuilder({ onApply });
@@ -8,6 +8,7 @@ import {
8
8
  canShareSavedFilter,
9
9
  createEmptyFilter,
10
10
  findSavedFilter,
11
+ type FilterPath,
11
12
  type FilterTree,
12
13
  isEmptyFilter,
13
14
  pruneFilter,
@@ -17,7 +18,7 @@ import { type NamedFilterType, setNamedFilter, useFilterConversion, useSavedFilt
17
18
  import type { SaveFilterInput } from "@olenbetong/appframe-react";
18
19
  import type { DataObject } from "@olenbetong/appframe-data";
19
20
  import clsx from "clsx";
20
- import { type ReactNode, useCallback, useEffect, useState } from "react";
21
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
21
22
 
22
23
  import { FilterEditor } from "./FilterEditor.js";
23
24
  import { FilterNameDialog } from "./FilterNameDialog.js";
@@ -109,6 +110,11 @@ export type FilterBuilderProps = {
109
110
  renderMain?: (editor: ReactNode) => ReactNode;
110
111
  /** Hides the saved-filter list, leaving just the criteria editor. */
111
112
  hideSavedFilters?: boolean;
113
+ /**
114
+ * Path of the criterion whose value gets focus when the builder opens, for a caller that has
115
+ * just added it.
116
+ */
117
+ autoFocusPath?: FilterPath | null;
112
118
  className?: string;
113
119
  "data-size"?: FilterEditorSize;
114
120
  };
@@ -159,9 +165,25 @@ export function FilterBuilder({
159
165
  actions,
160
166
  renderMain,
161
167
  hideSavedFilters,
168
+ autoFocusPath,
162
169
  className,
163
170
  "data-size": size,
164
171
  }: FilterBuilderProps) {
172
+ let rootRef = useRef<HTMLDivElement>(null);
173
+ let autoFocusKey = autoFocusPath?.join(".");
174
+
175
+ /*
176
+ * Runs after the dialog's own effect has opened it, which focuses its first control. The value
177
+ * editor depends on the field and operator, so the first input in the row's value cell is used.
178
+ */
179
+ useEffect(() => {
180
+ if (!open || autoFocusKey === undefined) return;
181
+
182
+ let row = rootRef.current?.querySelector(`.ObFilterEditor-row[data-path="${autoFocusKey}"]`);
183
+ let input = row?.querySelector<HTMLElement>(".ObFilterEditor-valueCell :is(input, select, textarea)");
184
+
185
+ input?.focus();
186
+ }, [open, autoFocusKey]);
165
187
  let resolvedId = dbObjectId ?? viewName;
166
188
  let { mine, shared, filters, loading, save, rename, remove, setCrossDomain } = useSavedFilters({
167
189
  dbObjectId: reportId ? undefined : resolvedId,
@@ -299,6 +321,7 @@ export function FilterBuilder({
299
321
 
300
322
  let content = (
301
323
  <div
324
+ ref={rootRef}
302
325
  className={clsx("ObFilterBuilder-root", hideSavedFilters && "ObFilterBuilder-root--noSavedFilters", className)}
303
326
  data-size={size}
304
327
  >
@@ -136,6 +136,14 @@
136
136
  min-width: 8rem;
137
137
  }
138
138
 
139
+ /*
140
+ * An operator without a value, such as "Is last week", takes the value's place,
141
+ * so the field keeps the width it has in the other rows.
142
+ */
143
+ .ObFilterEditor-row:not(:has(.ObFilterEditor-valueCell)) .ObFilterEditor-operator {
144
+ flex: 2 1 calc(26rem + var(--ds-size-2));
145
+ }
146
+
139
147
  .ObFilterEditor-valueCell {
140
148
  display: flex;
141
149
  align-items: center;
@@ -157,6 +165,12 @@
157
165
  min-width: 0;
158
166
  }
159
167
 
168
+ /* Each end of a range carries its own list toggle. */
169
+ .ObFilterEditor-pair > .ObFilterEditor-valueCell {
170
+ flex: 1 1 0;
171
+ min-width: 0;
172
+ }
173
+
160
174
  .ObFilterEditor-pairSeparator {
161
175
  flex: 0 0 auto;
162
176
  color: var(--ds-color-neutral-text-subtle);
@@ -126,11 +126,29 @@ describe("FilterEditor", () => {
126
126
  <FilterEditor fields={FIELDS} value={filterWith("Created", "equals", "", "datetime")} onChange={onChange} />,
127
127
  );
128
128
 
129
- await user.click(screen.getByRole("button", { name: "Use a relative value" }));
129
+ await user.click(screen.getByRole("button", { name: "Choose from a list" }));
130
130
 
131
131
  expect(firstExpression(onChange)).toMatchObject({ value: "Yesterday", valueType: "special" });
132
132
  });
133
133
 
134
+ it("groups the week, month and year values under First day of", () => {
135
+ render(<FilterEditor fields={FIELDS} value={filterWith("Created", "equals", "LastWeek", "special")} />);
136
+
137
+ let group = screen.getByRole("group", { name: "First day of" });
138
+ let grouped = Array.from(group.querySelectorAll("option"), (option) => option.value);
139
+
140
+ expect(grouped).toContain("special:LastWeek");
141
+ expect(grouped).toContain("special:ThisMonth");
142
+ expect(grouped).not.toContain("special:Today");
143
+ expect(screen.getByRole("option", { name: "Today" }).parentElement).not.toBe(group);
144
+ });
145
+
146
+ it("does not group the current user value", () => {
147
+ render(<FilterEditor fields={FIELDS} value={filterWith("PersonID", "equals", "CurrentUser", "special")} />);
148
+
149
+ expect(screen.queryByRole("group", { name: "First day of" })).not.toBeInTheDocument();
150
+ });
151
+
134
152
  it("adds the seconds a datetime input leaves out", () => {
135
153
  let onChange = vi.fn();
136
154
  render(
@@ -160,7 +178,7 @@ describe("FilterEditor", () => {
160
178
  it("does not offer relative values for numbers", () => {
161
179
  render(<FilterEditor fields={FIELDS} value={filterWith("Age", "equals", "1", "number")} />);
162
180
 
163
- expect(screen.queryByRole("button", { name: "Use a relative value" })).not.toBeInTheDocument();
181
+ expect(screen.queryByRole("button", { name: "Choose from a list" })).not.toBeInTheDocument();
164
182
  });
165
183
 
166
184
  it("removes a condition", async () => {
@@ -283,3 +301,133 @@ describe("FilterEditor", () => {
283
301
  expect(screen.getByLabelText("Value")).toHaveValue("gold");
284
302
  });
285
303
  });
304
+
305
+ describe("FilterEditor value list", () => {
306
+ const DATE_FIELDS: FieldMetadata[] = [
307
+ { name: "DeliveryDate", caption: "Delivery date", type: "datetime" },
308
+ { name: "Date", caption: "Date", type: "date" },
309
+ { name: "Amount", caption: "Amount", type: "number" },
310
+ { name: "Quantity", caption: "Quantity", type: "number" },
311
+ ];
312
+
313
+ it("offers the other columns of the same type", () => {
314
+ render(<FilterEditor fields={DATE_FIELDS} value={filterWith("DeliveryDate", "greaterthan", "Date", "column")} />);
315
+
316
+ let group = screen.getByRole("group", { name: "Columns" });
317
+ let columns = Array.from(group.querySelectorAll("option"), (option) => option.textContent);
318
+
319
+ expect(columns).toEqual(["Date"]);
320
+ expect(screen.getByLabelText("Value")).toHaveValue("column:Date");
321
+ });
322
+
323
+ it("offers the dates only the Windows editor had", () => {
324
+ render(<FilterEditor fields={DATE_FIELDS} value={filterWith("DeliveryDate", "equals", "Now", "special")} />);
325
+
326
+ expect(screen.getByRole("option", { name: "Now" })).toBeInTheDocument();
327
+ expect(screen.getByRole("option", { name: "Day after tomorrow" })).toBeInTheDocument();
328
+ expect(screen.getByRole("option", { name: "Two weeks away" })).toBeInTheDocument();
329
+ });
330
+
331
+ it("picks a column as the value", () => {
332
+ let onChange = vi.fn();
333
+ render(
334
+ <FilterEditor
335
+ fields={DATE_FIELDS}
336
+ value={filterWith("DeliveryDate", "greaterthan", "Today", "special")}
337
+ onChange={onChange}
338
+ />,
339
+ );
340
+
341
+ fireEvent.change(screen.getByLabelText("Value"), { target: { value: "column:Date" } });
342
+
343
+ expect(firstExpression(onChange)).toMatchObject({ value: "Date", valueType: "column" });
344
+ });
345
+
346
+ it("lets a number field pick another number column", async () => {
347
+ let user = userEvent.setup();
348
+ let onChange = vi.fn();
349
+ render(
350
+ <FilterEditor fields={DATE_FIELDS} value={filterWith("Amount", "lessthan", "", "number")} onChange={onChange} />,
351
+ );
352
+
353
+ await user.click(screen.getByRole("button", { name: "Choose from a list" }));
354
+
355
+ expect(firstExpression(onChange)).toMatchObject({ value: "Quantity", valueType: "column" });
356
+ });
357
+
358
+ it("switches one end of a range to the list", async () => {
359
+ let user = userEvent.setup();
360
+ let onChange = vi.fn();
361
+ render(
362
+ <FilterEditor
363
+ fields={DATE_FIELDS}
364
+ value={filterWith("DeliveryDate", "between", ["", "2026-11-30T00:00:00"], "datetime")}
365
+ onChange={onChange}
366
+ />,
367
+ );
368
+
369
+ let [fromToggle] = screen.getAllByRole("button", { name: "Choose from a list" });
370
+ await user.click(fromToggle!);
371
+
372
+ expect(firstExpression(onChange)).toMatchObject({
373
+ value: [{ valueType: "special", value: "Yesterday" }, "2026-11-30T00:00:00"],
374
+ valueType: "datetime",
375
+ });
376
+ });
377
+
378
+ it("shows a list choice at each end of a range", () => {
379
+ render(
380
+ <FilterEditor
381
+ fields={DATE_FIELDS}
382
+ value={filterWith(
383
+ "DeliveryDate",
384
+ "between",
385
+ [
386
+ { valueType: "special", value: "Tomorrow" },
387
+ { valueType: "column", value: "Date" },
388
+ ],
389
+ "datetime",
390
+ )}
391
+ />,
392
+ );
393
+
394
+ expect(screen.getByLabelText("From")).toHaveValue("special:Tomorrow");
395
+ expect(screen.getByLabelText("To")).toHaveValue("column:Date");
396
+ });
397
+
398
+ it("hides the value for a whole-period check", async () => {
399
+ let user = userEvent.setup();
400
+ let onChange = vi.fn();
401
+ render(
402
+ <FilterEditor
403
+ fields={DATE_FIELDS}
404
+ value={filterWith("DeliveryDate", "equals", "", "datetime")}
405
+ onChange={onChange}
406
+ />,
407
+ );
408
+
409
+ await user.selectOptions(screen.getByLabelText("Operator"), "islastweek");
410
+
411
+ expect(firstExpression(onChange)).toMatchObject({ operator: "islastweek", value: null, valueType: "null" });
412
+ });
413
+
414
+ it("shows no value field for a whole-period check", () => {
415
+ render(<FilterEditor fields={DATE_FIELDS} value={filterWith("DeliveryDate", "islastweek", null, "null")} />);
416
+
417
+ expect(screen.getByLabelText("Operator")).toHaveValue("islastweek");
418
+ expect(screen.queryByLabelText("Value")).not.toBeInTheDocument();
419
+ });
420
+
421
+ it("does not offer columns for text checks", () => {
422
+ render(
423
+ <FilterEditor
424
+ fields={[...DATE_FIELDS, { name: "Name", type: "string" }, { name: "Other", type: "string" }]}
425
+ value={filterWith("Name", "contains", "a")}
426
+ />,
427
+ );
428
+
429
+ expect(screen.queryByRole("group", { name: "Columns" })).not.toBeInTheDocument();
430
+ // The current user can still be picked.
431
+ expect(screen.getByRole("button", { name: "Choose from a list" })).toBeInTheDocument();
432
+ });
433
+ });
@@ -111,6 +111,7 @@ export function FilterGroupEditor({
111
111
  // oxlint-disable-next-line no-array-index-key -- see above
112
112
  key={index}
113
113
  expression={item}
114
+ path={[...path, index]}
114
115
  fields={fields}
115
116
  getFieldOptions={getFieldOptions}
116
117
  readOnly={readOnly}
@@ -6,8 +6,10 @@ import {
6
6
  type FieldMetadata,
7
7
  type FilterExpression,
8
8
  type FilterOperator,
9
+ type FilterPath,
9
10
  getOperatorLabel,
10
11
  getOperatorsForField,
12
+ isRangeEnd,
11
13
  normalizeOperator,
12
14
  } from "@olenbetong/appframe-core";
13
15
  import { Combobox } from "../autocomplete/index.js";
@@ -16,6 +18,14 @@ import { FilterValueEditor } from "./FilterValueEditor.js";
16
18
  import type { FilterEditorSize, GetFieldOptions } from "./types.js";
17
19
  import { getLocalizedString } from "../i18n.js";
18
20
 
21
+ function holdsColumn(expression: FilterExpression): boolean {
22
+ if (expression.valueType === "column") return true;
23
+
24
+ return (
25
+ Array.isArray(expression.value) && expression.value.some((item) => isRangeEnd(item) && item.valueType === "column")
26
+ );
27
+ }
28
+
19
29
  export function getFieldLabel(field: FieldMetadata) {
20
30
  return field.caption || field.name;
21
31
  }
@@ -25,6 +35,8 @@ export type FilterRowProps = {
25
35
  fields: FieldMetadata[];
26
36
  onChange: (expression: FilterExpression) => void;
27
37
  onRemove: () => void;
38
+ /** Where the expression sits in the tree, written to `data-path` so a row can be found to focus it. */
39
+ path?: FilterPath;
28
40
  getFieldOptions?: GetFieldOptions;
29
41
  readOnly?: boolean;
30
42
  "data-size"?: FilterEditorSize;
@@ -36,6 +48,7 @@ export function FilterRow({
36
48
  fields,
37
49
  onChange,
38
50
  onRemove,
51
+ path,
39
52
  getFieldOptions,
40
53
  readOnly,
41
54
  "data-size": size,
@@ -60,11 +73,14 @@ export function FilterRow({
60
73
  let keep = operators.includes(operator) ? operator : undefined;
61
74
  let created = createExpression(next, keep);
62
75
 
63
- onChange(keep ? { ...created, value: expression.value, valueType: expression.valueType } : created);
76
+ // A column picked for the old field may not suit the new one.
77
+ let keepValue = keep && !holdsColumn(expression);
78
+
79
+ onChange(keepValue ? { ...created, value: expression.value, valueType: expression.valueType } : created);
64
80
  }
65
81
 
66
82
  return (
67
- <div className="ObFilterEditor-row">
83
+ <div className="ObFilterEditor-row" data-path={path?.join(".")}>
68
84
  <Combobox
69
85
  className="ObFilterEditor-field"
70
86
  data-size={size}
@@ -96,6 +112,7 @@ export function FilterRow({
96
112
  <FilterValueEditor
97
113
  expression={expression}
98
114
  field={effectiveField}
115
+ fields={fields}
99
116
  options={field ? getFieldOptions?.(field) : undefined}
100
117
  onChange={onChange}
101
118
  readOnly={readOnly}
@@ -1,15 +1,19 @@
1
1
  import { Select, Textfield } from "@digdir/designsystemet-react";
2
- import { PencilIcon } from "@navikt/aksel-icons";
2
+ import { PencilIcon, TasklistIcon } from "@navikt/aksel-icons";
3
3
  import {
4
4
  type DynamicValue,
5
5
  type FieldMetadata,
6
6
  type FilterExpression,
7
7
  type FilterOperator,
8
+ type FilterRangeEnd,
9
+ getColumnsForField,
8
10
  getDynamicValueLabel,
9
11
  getDynamicValuesForField,
10
12
  getOperatorArity,
11
13
  getValueType,
14
+ isRangeEnd,
12
15
  normalizeOperator,
16
+ supportsColumnValue,
13
17
  } from "@olenbetong/appframe-core";
14
18
  import { Combobox } from "../autocomplete/index.js";
15
19
 
@@ -20,6 +24,8 @@ import { getLocalizedString } from "../i18n.js";
20
24
  export type FilterValueEditorProps = {
21
25
  expression: FilterExpression;
22
26
  field?: FieldMetadata;
27
+ /** All fields of the view, offered as values to compare with. */
28
+ fields?: FieldMetadata[];
23
29
  options?: FilterValueOption[];
24
30
  onChange: (expression: FilterExpression) => void;
25
31
  readOnly?: boolean;
@@ -40,7 +46,7 @@ function getInputType(field: FieldMetadata | undefined): "text" | "number" | "da
40
46
  }
41
47
  }
42
48
 
43
- function toStringValue(value: FilterExpression["value"]): string {
49
+ function toStringValue(value: unknown): string {
44
50
  if (value === null || value === undefined) return "";
45
51
  if (value instanceof Date) return value.toISOString();
46
52
 
@@ -66,22 +72,151 @@ function toFilterValue(value: FilterExpression["value"], inputType: string): Fil
66
72
  return value;
67
73
  }
68
74
 
69
- function toArrayValue(value: FilterExpression["value"], length?: number): string[] {
70
- let array = Array.isArray(value) ? value.map(toStringValue) : [];
71
- if (length === undefined) return array;
75
+ /** Relative dates that stand for a single day or moment. */
76
+ const DAY_VALUES = new Set<string>([
77
+ "Yesterday",
78
+ "Today",
79
+ "Now",
80
+ "Tomorrow",
81
+ "DayAfterTomorrow",
82
+ "TwoWeeksAway",
83
+ "UserYesterday",
84
+ "UserToday",
85
+ "UserTomorrow",
86
+ ]);
87
+
88
+ /**
89
+ * Splits a date field's relative values into single days and the rest, which
90
+ * resolve to the first day of a week, month or year. Those are grouped under a
91
+ * "First day of" heading, as in the Windows date chooser, so "Last week" is
92
+ * not read as the whole week.
93
+ */
94
+ function groupDynamicValues(values: string[], field: FieldMetadata | undefined) {
95
+ let isDate = field?.type === "date" || field?.type === "datetime";
96
+ if (!isDate) return { ungrouped: values, periodStarts: [] };
97
+
98
+ return {
99
+ ungrouped: values.filter((value) => DAY_VALUES.has(value)),
100
+ periodStarts: values.filter((value) => !DAY_VALUES.has(value)),
101
+ };
102
+ }
103
+
104
+ function toArrayValue(value: FilterExpression["value"]): string[] {
105
+ return Array.isArray(value) ? value.map(toStringValue) : [];
106
+ }
107
+
108
+ /** A range end as the editor works with it: a typed value or a list choice. */
109
+ type RangeEndValue = string | FilterRangeEnd;
110
+
111
+ function toRangeEnds(value: FilterExpression["value"]): [RangeEndValue, RangeEndValue] {
112
+ let array: unknown[] = Array.isArray(value) ? value : [];
113
+ let toEnd = (item: unknown): RangeEndValue => (isRangeEnd(item) ? item : toStringValue(item));
72
114
 
73
115
  // Pair editors always render two inputs, even before both are filled in.
74
- return Array.from({ length }, (_, index) => array[index] ?? "");
116
+ return [toEnd(array[0]), toEnd(array[1])];
117
+ }
118
+
119
+ /** Encodes a list choice as a single `<option>` value. */
120
+ function toOptionValue(end: FilterRangeEnd): string {
121
+ return `${end.valueType}:${end.value ?? ""}`;
122
+ }
123
+
124
+ function fromOptionValue(optionValue: string): FilterRangeEnd {
125
+ let separator = optionValue.indexOf(":");
126
+
127
+ return {
128
+ valueType: optionValue.slice(0, separator) as FilterRangeEnd["valueType"],
129
+ value: optionValue.slice(separator + 1),
130
+ };
131
+ }
132
+
133
+ type ListChoicesProps = {
134
+ "aria-label": string;
135
+ columns: FieldMetadata[];
136
+ dynamicValues: string[];
137
+ field?: FieldMetadata;
138
+ readOnly?: boolean;
139
+ size?: FilterEditorSize;
140
+ value: FilterRangeEnd;
141
+ onChange: (value: FilterRangeEnd) => void;
142
+ };
143
+
144
+ /**
145
+ * The list a value can be picked from instead of typed: the other columns of
146
+ * the same type and the relative values, in the order of the Windows editor.
147
+ */
148
+ function ListChoices({
149
+ "aria-label": ariaLabel,
150
+ columns,
151
+ dynamicValues,
152
+ field,
153
+ readOnly,
154
+ size,
155
+ value,
156
+ onChange,
157
+ }: ListChoicesProps) {
158
+ let { ungrouped, periodStarts } = groupDynamicValues(dynamicValues, field);
159
+ let renderDynamic = (dynamicValue: string) => (
160
+ <Select.Option key={dynamicValue} value={toOptionValue({ valueType: "special", value: dynamicValue })}>
161
+ {getDynamicValueLabel(dynamicValue as DynamicValue)}
162
+ </Select.Option>
163
+ );
164
+
165
+ return (
166
+ <Select
167
+ className="ObFilterEditor-value"
168
+ aria-label={ariaLabel}
169
+ data-size={size}
170
+ disabled={readOnly}
171
+ value={toOptionValue(value)}
172
+ onChange={(event) => onChange(fromOptionValue(event.target.value))}
173
+ >
174
+ {columns.length > 0 && (
175
+ <Select.Optgroup label={getLocalizedString("Columns")}>
176
+ {columns.map((column) => (
177
+ <Select.Option key={column.name} value={toOptionValue({ valueType: "column", value: column.name })}>
178
+ {column.caption || column.name}
179
+ </Select.Option>
180
+ ))}
181
+ </Select.Optgroup>
182
+ )}
183
+ {ungrouped.map(renderDynamic)}
184
+ {periodStarts.length > 0 && (
185
+ <Select.Optgroup label={getLocalizedString("First day of")}>{periodStarts.map(renderDynamic)}</Select.Optgroup>
186
+ )}
187
+ </Select>
188
+ );
189
+ }
190
+
191
+ /** Switches a value between typing it and picking it from the list. */
192
+ function ListToggle({ isList, onToggle }: { isList: boolean; onToggle: () => void }) {
193
+ let label = getLocalizedString("Choose from a list");
194
+
195
+ return (
196
+ <button
197
+ type="button"
198
+ className="ObFilterEditor-iconButton"
199
+ aria-pressed={isList}
200
+ title={label}
201
+ aria-label={label}
202
+ onClick={onToggle}
203
+ >
204
+ {/* Shows the current mode, like the Windows editor's toggle. */}
205
+ {isList ? <TasklistIcon aria-hidden /> : <PencilIcon aria-hidden />}
206
+ </button>
207
+ );
75
208
  }
76
209
 
77
210
  /**
78
211
  * The value part of a filter row. Which editor is rendered depends on the
79
- * operator's arity, the field's type, and whether the value is a dynamic
80
- * ("special") value such as `Today` or `CurrentUser`.
212
+ * operator's arity, the field's type, and whether the value is picked from the
213
+ * list of other columns and relative ("special") values such as `Today` or
214
+ * `CurrentUser`. Each end of a range can be typed or picked on its own.
81
215
  */
82
216
  export function FilterValueEditor({
83
217
  expression,
84
218
  field,
219
+ fields,
85
220
  options,
86
221
  onChange,
87
222
  readOnly,
@@ -89,46 +224,111 @@ export function FilterValueEditor({
89
224
  }: FilterValueEditorProps) {
90
225
  let operator = normalizeOperator(expression.operator);
91
226
  let arity = getOperatorArity(operator);
92
- let isDynamic = expression.valueType === "special";
227
+ let isList = expression.valueType === "special" || expression.valueType === "column";
93
228
  let dynamicValues = field ? getDynamicValuesForField(field) : [];
229
+ let columns = field && fields && supportsColumnValue(operator) ? getColumnsForField(field, fields) : [];
94
230
  let inputType = getInputType(field);
231
+ let typedValueType = field ? getValueType(field, operator as FilterOperator) : "string";
232
+ let canPickFromList = !readOnly && (dynamicValues.length > 0 || columns.length > 0);
233
+
234
+ /** The first entry of the list, used when switching a value to it. */
235
+ let firstChoice: FilterRangeEnd =
236
+ columns.length > 0 && dynamicValues.length === 0
237
+ ? { valueType: "column", value: columns[0]!.name }
238
+ : { valueType: "special", value: dynamicValues[0] ?? null };
95
239
 
96
240
  function setValue(value: FilterExpression["value"]) {
97
- onChange({ ...expression, value: isDynamic ? value : toFilterValue(value, inputType) });
241
+ onChange({ ...expression, value: isList ? value : toFilterValue(value, inputType) });
98
242
  }
99
243
 
100
- function setDynamic(enabled: boolean) {
101
- if (enabled) {
102
- onChange({ ...expression, value: dynamicValues[0] ?? null, valueType: "special" });
244
+ function setChoice(choice: FilterRangeEnd) {
245
+ onChange({ ...expression, value: choice.value, valueType: choice.valueType });
246
+ }
247
+
248
+ function toggleList() {
249
+ if (isList) {
250
+ onChange({ ...expression, value: "", valueType: typedValueType });
103
251
  } else {
104
- onChange({
105
- ...expression,
106
- value: arity === "pair" ? ["", ""] : arity === "list" ? [] : "",
107
- valueType: field ? getValueType(field, operator as FilterOperator) : "string",
108
- });
252
+ setChoice(firstChoice);
109
253
  }
110
254
  }
111
255
 
112
256
  if (arity === "none") return null;
113
257
 
258
+ if (arity === "pair") {
259
+ let ends = toRangeEnds(expression.value);
260
+
261
+ function setEnd(index: 0 | 1, end: RangeEndValue) {
262
+ let next = [...ends];
263
+ next[index] = typeof end === "string" && inputType === "datetime-local" ? withSeconds(end) : end;
264
+
265
+ // Plain ends on both sides are sent the way the framework reads them.
266
+ let value = next.some(isRangeEnd) ? next : next.map((item) => toStringValue(item));
267
+ onChange({ ...expression, value, valueType: typedValueType });
268
+ }
269
+
270
+ let renderEnd = (index: 0 | 1, label: string) => {
271
+ let end = ends[index];
272
+
273
+ return (
274
+ <div className="ObFilterEditor-valueCell">
275
+ {isRangeEnd(end) ? (
276
+ <ListChoices
277
+ aria-label={label}
278
+ columns={columns}
279
+ dynamicValues={dynamicValues}
280
+ field={field}
281
+ readOnly={readOnly}
282
+ size={size}
283
+ value={end}
284
+ onChange={(choice) => setEnd(index, choice)}
285
+ />
286
+ ) : (
287
+ <Textfield
288
+ className="ObFilterEditor-value"
289
+ aria-label={label}
290
+ data-size={size}
291
+ readOnly={readOnly}
292
+ type={inputType}
293
+ value={end}
294
+ onChange={(event) => setEnd(index, event.target.value)}
295
+ />
296
+ )}
297
+ {canPickFromList && (
298
+ <ListToggle isList={isRangeEnd(end)} onToggle={() => setEnd(index, isRangeEnd(end) ? "" : firstChoice)} />
299
+ )}
300
+ </div>
301
+ );
302
+ };
303
+
304
+ return (
305
+ <div className="ObFilterEditor-valueCell">
306
+ <div className="ObFilterEditor-pair">
307
+ {renderEnd(0, getLocalizedString("From"))}
308
+ <span className="ObFilterEditor-pairSeparator">{getLocalizedString("and")}</span>
309
+ {renderEnd(1, getLocalizedString("To"))}
310
+ </div>
311
+ </div>
312
+ );
313
+ }
314
+
114
315
  let editor: React.ReactNode;
115
316
 
116
- if (isDynamic) {
317
+ if (isList) {
117
318
  editor = (
118
- <Select
119
- className="ObFilterEditor-value"
319
+ <ListChoices
120
320
  aria-label={getLocalizedString("Value")}
121
- data-size={size}
122
- disabled={readOnly}
123
- value={toStringValue(expression.value)}
124
- onChange={(event) => setValue(event.target.value)}
125
- >
126
- {dynamicValues.map((dynamicValue) => (
127
- <Select.Option key={dynamicValue} value={dynamicValue}>
128
- {getDynamicValueLabel(dynamicValue as DynamicValue)}
129
- </Select.Option>
130
- ))}
131
- </Select>
321
+ columns={columns}
322
+ dynamicValues={dynamicValues}
323
+ field={field}
324
+ readOnly={readOnly}
325
+ size={size}
326
+ value={{
327
+ valueType: expression.valueType as FilterRangeEnd["valueType"],
328
+ value: toStringValue(expression.value),
329
+ }}
330
+ onChange={setChoice}
331
+ />
132
332
  );
133
333
  } else if (arity === "list") {
134
334
  editor = options ? (
@@ -157,32 +357,6 @@ export function FilterValueEditor({
157
357
  onChange={setValue}
158
358
  />
159
359
  );
160
- } else if (arity === "pair") {
161
- let [from, to] = toArrayValue(expression.value, 2);
162
-
163
- editor = (
164
- <div className="ObFilterEditor-pair">
165
- <Textfield
166
- className="ObFilterEditor-value"
167
- aria-label={getLocalizedString("From")}
168
- data-size={size}
169
- readOnly={readOnly}
170
- type={inputType}
171
- value={from}
172
- onChange={(event) => setValue([event.target.value, to])}
173
- />
174
- <span className="ObFilterEditor-pairSeparator">{getLocalizedString("and")}</span>
175
- <Textfield
176
- className="ObFilterEditor-value"
177
- aria-label={getLocalizedString("To")}
178
- data-size={size}
179
- readOnly={readOnly}
180
- type={inputType}
181
- value={to}
182
- onChange={(event) => setValue([from, event.target.value])}
183
- />
184
- </div>
185
- );
186
360
  } else if (options) {
187
361
  let current = toStringValue(expression.value);
188
362
 
@@ -214,26 +388,13 @@ export function FilterValueEditor({
214
388
  );
215
389
  }
216
390
 
217
- // Dynamic values are resolved server side, so they can only replace the whole
218
- // value. Pairs would need one toggle per side, which the Windows editor does
219
- // not offer either.
220
- let canToggleDynamic = !readOnly && dynamicValues.length > 0 && arity === "single";
391
+ // A list of values is matched as a whole, so it has nothing to pick from.
392
+ let canToggle = canPickFromList && arity === "single";
221
393
 
222
394
  return (
223
395
  <div className="ObFilterEditor-valueCell">
224
396
  {editor}
225
- {canToggleDynamic && (
226
- <button
227
- type="button"
228
- className="ObFilterEditor-iconButton"
229
- aria-pressed={isDynamic}
230
- title={getLocalizedString("Use a relative value")}
231
- aria-label={getLocalizedString("Use a relative value")}
232
- onClick={() => setDynamic(!isDynamic)}
233
- >
234
- <PencilIcon aria-hidden />
235
- </button>
236
- )}
397
+ {canToggle && <ListToggle isList={isList} onToggle={toggleList} />}
237
398
  </div>
238
399
  );
239
400
  }
@@ -0,0 +1,30 @@
1
+ import { type GridColumnMenuItemProps, GridColumnMenuHideItem, useGridRootProps } from "@mui/x-data-grid-premium";
2
+
3
+ import { useOptionalAfGridContext } from "./AfGridContext.js";
4
+ import { getLocalizedString } from "../i18n.js";
5
+
6
+ /**
7
+ * The column menu's Hide and Manage columns items. MUI's Manage columns opens its own columns panel;
8
+ * this opens the grid's columns panel, the same one as the toolbar's Columns button.
9
+ */
10
+ export function AfColumnMenuColumnsItem(props: GridColumnMenuItemProps) {
11
+ let rootProps = useGridRootProps();
12
+ let context = useOptionalAfGridContext();
13
+
14
+ return (
15
+ <>
16
+ <GridColumnMenuHideItem {...props} />
17
+ {context && !rootProps.disableColumnSelector && (
18
+ <rootProps.slots.baseMenuItem
19
+ onClick={(event: React.MouseEvent<HTMLElement>) => {
20
+ props.onClick(event);
21
+ context.openColumnsPanel();
22
+ }}
23
+ iconStart={<rootProps.slots.columnMenuManageColumnsIcon fontSize="small" />}
24
+ >
25
+ {getLocalizedString("Manage columns")}
26
+ </rootProps.slots.baseMenuItem>
27
+ )}
28
+ </>
29
+ );
30
+ }
@@ -0,0 +1,36 @@
1
+ import { type GridColumnMenuItemProps, useGridRootProps } from "@mui/x-data-grid-premium";
2
+
3
+ import { useOptionalAfGridContext } from "./AfGridContext.js";
4
+ import { findFilterField } from "./useAfFilter.js";
5
+ import type { AfGridColDef } from "./useAfColumns.js";
6
+ import { getLocalizedString } from "../i18n.js";
7
+
8
+ /**
9
+ * The column menu's Filter item. MUI's opens its own filter panel, which edits a filter model the
10
+ * grid never reads; this opens the filter builder with a new criterion for the column instead.
11
+ */
12
+ export function AfColumnMenuFilterItem({ colDef, onClick }: GridColumnMenuItemProps) {
13
+ let rootProps = useGridRootProps();
14
+ let context = useOptionalAfGridContext();
15
+ let fieldName = (colDef as AfGridColDef).filterColumn ?? colDef.field;
16
+
17
+ if (!context || rootProps.disableColumnFilter || colDef.filterable === false || colDef.type === "actions") {
18
+ return null;
19
+ }
20
+
21
+ if (!findFilterField(context.filterFields, fieldName)) return null;
22
+
23
+ let { openFilterBuilder } = context;
24
+
25
+ return (
26
+ <rootProps.slots.baseMenuItem
27
+ onClick={(event: React.MouseEvent<HTMLElement>) => {
28
+ onClick(event);
29
+ openFilterBuilder(fieldName);
30
+ }}
31
+ iconStart={<rootProps.slots.columnMenuFilterIcon fontSize="small" />}
32
+ >
33
+ {getLocalizedString("Filter")}
34
+ </rootProps.slots.baseMenuItem>
35
+ );
36
+ }
@@ -8,8 +8,11 @@ export type AfGridContextValue = {
8
8
  apiRef: React.RefObject<GridApi | null>;
9
9
  /** Fields the filter builder can filter on, derived from the data object and the columns. */
10
10
  filterFields: FieldMetadata[];
11
- /** Opens the filter builder dialog. */
12
- openFilterBuilder: () => void;
11
+ /**
12
+ * Opens the filter builder dialog. Given a field, it adds an empty criterion for it first, unless
13
+ * the filter already has an unfinished one for that field.
14
+ */
15
+ openFilterBuilder: (field?: string) => void;
13
16
  /**
14
17
  * The grid's filter tree. The header filters and the filter builder edit the
15
18
  * same tree, so a criterion added in one shows up in the other.
@@ -95,7 +95,7 @@ export function GridToolbarFilterButton({ iconOnly }: GridToolbarIconOnlyProps =
95
95
  return (
96
96
  <BadgePosition placement="top-right" overlap="circle">
97
97
  {filterCount > 0 && <Badge data-color="brand" count={filterCount} aria-hidden="true" />}
98
- <Button variant="tertiary" icon aria-label={name} title={name} onClick={openFilterBuilder}>
98
+ <Button variant="tertiary" icon aria-label={name} title={name} onClick={() => openFilterBuilder()}>
99
99
  <FilterIcon aria-hidden />
100
100
  </Button>
101
101
  </BadgePosition>
@@ -103,7 +103,7 @@ export function GridToolbarFilterButton({ iconOnly }: GridToolbarIconOnlyProps =
103
103
  }
104
104
 
105
105
  return (
106
- <Button variant="tertiary" onClick={openFilterBuilder}>
106
+ <Button variant="tertiary" onClick={() => openFilterBuilder()}>
107
107
  <FilterIcon aria-hidden />
108
108
  {label}
109
109
  {filterCount > 0 ? ` (${filterCount})` : ""}
@@ -16,12 +16,15 @@ import {
16
16
  type GridRowParams,
17
17
  useGridApiRef,
18
18
  } from "@mui/x-data-grid-premium";
19
+ import { createExpression, type FilterPath, isCompleteExpression, isExpression } from "@olenbetong/appframe-core";
19
20
  import { type DataObject, uid } from "@olenbetong/appframe-data";
20
21
  import { DataObjectProvider, useLoading } from "@olenbetong/appframe-react";
21
22
 
22
23
  import clsx from "clsx";
23
24
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
24
25
  import { FilterBuilder } from "../filter/index.js";
26
+ import { AfColumnMenuColumnsItem } from "./AfColumnMenuColumnsItem.js";
27
+ import { AfColumnMenuFilterItem } from "./AfColumnMenuFilterItem.js";
25
28
  import { AfGridColumnsPanel } from "./AfGridColumnsPanel.js";
26
29
  import { AfHeaderFilterCell } from "./AfHeaderFilterCell.js";
27
30
  import { type AfGridContextValue, AfGridContextProvider } from "./AfGridContext.js";
@@ -52,7 +55,7 @@ import { type AfGridColDef, type AfGridRowAction, useAfColumns } from "./useAfCo
52
55
  import { useAfCurrentIndex } from "./useAfCurrentIndex.js";
53
56
  import { useAfData } from "./useAfData.js";
54
57
  import { useAfFilterFields } from "./useAfFilterFields.js";
55
- import { useAfFilter } from "./useAfFilter.js";
58
+ import { findFilterField, useAfFilter } from "./useAfFilter.js";
56
59
  import { type AfGridApi, useAfGridApi } from "./useAfGridApi.js";
57
60
  import { useAfKeyBindings } from "./useAfKeyBindings.js";
58
61
  import { type AfGridNewItemRowPosition, NEW_ITEM_ROW_ID, isNewItemRow, useAfNewItemRow } from "./useAfNewItemRow.js";
@@ -270,7 +273,7 @@ export type AfGridProps = {
270
273
  * Editing it moves the data object to the new record at index -1, and committing
271
274
  * it saves the record. Only shown when the data object allows insert and
272
275
  * `disableInsert` is not set. `none` turns it off, in which case the Add button
273
- * appends an unsaved row to the data instead. Defaults to `bottom`.
276
+ * appends an unsaved row to the data instead. Defaults to `top`.
274
277
  */
275
278
  newItemRow?: AfGridNewItemRowPosition;
276
279
  /**
@@ -371,9 +374,8 @@ function AfGrid_(props: AfGridProps) {
371
374
  getData,
372
375
  idField = "PrimKey",
373
376
  id,
374
- // Follows the data object's `newRecordPosition` when it has one (older data
375
- // packages have none and append), so the row sits where the record will land
376
- newItemRow: newItemRowPosition = dataObject.options?.newRecordPosition === "start" ? "top" : "bottom",
377
+ // At the bottom the row is pinned to the foot of the viewport, far below the data on a short grid
378
+ newItemRow: newItemRowPosition = "top",
377
379
  persistenceModel,
378
380
  initialState,
379
381
  pinnedRows,
@@ -420,6 +422,8 @@ function AfGrid_(props: AfGridProps) {
420
422
  let gridFilter = useAfFilter({ autoLoad, dataObject, id });
421
423
  let [snackbar, setSnackbar] = useState<string | null>(null);
422
424
  let [isFilterBuilderOpen, setFilterBuilderOpen] = useState(false);
425
+ // The criterion added from a column menu, so its value has focus when the builder opens.
426
+ let [filterBuilderFocus, setFilterBuilderFocus] = useState<FilterPath | null>(null);
423
427
  let [isColumnsPanelOpen, setColumnsPanelOpen] = useState(false);
424
428
  let editProps = useAfRowEditModel({ dataObject, disableEdit, idField, apiRef, newItemRow });
425
429
  useAfKeepFocusAfterEdit(apiRef);
@@ -493,7 +497,21 @@ function AfGrid_(props: AfGridProps) {
493
497
  apiRef,
494
498
  filterFields,
495
499
  ...gridFilter,
496
- openFilterBuilder: () => setFilterBuilderOpen(true),
500
+ openFilterBuilder: (fieldName?: string) => {
501
+ let field = fieldName ? findFilterField(filterFields, fieldName) : undefined;
502
+ let { filter, setFilter } = gridFilter;
503
+ let draftIndex = filter.items.findIndex(
504
+ (item) => isExpression(item) && item.column === field?.name && !isCompleteExpression(item),
505
+ );
506
+
507
+ if (field && draftIndex === -1) {
508
+ draftIndex = filter.items.length;
509
+ setFilter({ ...filter, items: [...filter.items, createExpression(field)] });
510
+ }
511
+
512
+ setFilterBuilderFocus(field ? [draftIndex] : null);
513
+ setFilterBuilderOpen(true);
514
+ },
497
515
  openColumnsPanel: () => setColumnsPanelOpen(true),
498
516
  }),
499
517
  [id, apiRef, filterFields, gridFilter],
@@ -555,6 +573,14 @@ function AfGrid_(props: AfGridProps) {
555
573
  initialState={initialState_}
556
574
  slotProps={{
557
575
  ...other.slotProps,
576
+ columnMenu: {
577
+ ...other.slotProps?.columnMenu,
578
+ slots: {
579
+ columnMenuColumnsItem: AfColumnMenuColumnsItem,
580
+ columnMenuFilterItem: AfColumnMenuFilterItem,
581
+ ...other.slotProps?.columnMenu?.slots,
582
+ },
583
+ },
558
584
  headerFilterCell: {
559
585
  ...other.slotProps?.headerFilterCell,
560
586
  headerClassName: clsx(other.slotProps?.headerFilterCell?.headerClassName, "AfGrid-headerCell"),
@@ -573,6 +599,7 @@ function AfGrid_(props: AfGridProps) {
573
599
  <FilterBuilder
574
600
  variant="dialog"
575
601
  open={isFilterBuilderOpen}
602
+ autoFocusPath={filterBuilderFocus}
576
603
  onClose={() => setFilterBuilderOpen(false)}
577
604
  fields={filterFields}
578
605
  viewName={filterViewName}
@@ -152,7 +152,9 @@ export function useAfNewItemRow({
152
152
  let focusBefore = gridFocusCellSelector(apiRef);
153
153
 
154
154
  try {
155
- await dataObject.endEdit();
155
+ // A record saved from a row above the data is inserted first, so it stays where it was
156
+ // entered; at the bottom the data object's own `newRecordPosition` decides.
157
+ await dataObject.endEdit(position === "top" ? { newRecordPosition: "start" } : undefined);
156
158
  } catch (error) {
157
159
  // The framework keeps a failed new record in storage as an unsaved row.
158
160
  // The grid keeps the draft in the new item row instead, so the unsaved
@@ -185,7 +187,7 @@ export function useAfNewItemRow({
185
187
 
186
188
  return blankRow;
187
189
  },
188
- [apiRef, dataObject, beginNewRecord, cancelNewRecord],
190
+ [apiRef, dataObject, position, beginNewRecord, cancelNewRecord],
189
191
  );
190
192
 
191
193
  useEffect(() => {
@@ -37,9 +37,9 @@
37
37
  font-weight: 500;
38
38
  color: var(--ds-color-neutral-text-default, rgba(0, 0, 0, 0.87));
39
39
  min-width: 0;
40
- overflow: hidden;
41
- text-overflow: ellipsis;
42
- white-space: nowrap;
40
+ /* Long labels wrap onto a second line, evened out, instead of being cut off */
41
+ overflow-wrap: anywhere;
42
+ text-wrap: balance;
43
43
  }
44
44
 
45
45
  .AppMenuItem-info {