@undevy-org/tablewright 0.7.1 → 0.7.3

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.
@@ -6,7 +6,8 @@ import { Button } from "../ui/button.js";
6
6
  import { ButtonFooter } from "../ui/button-footer.js";
7
7
  import { Input } from "../ui/input.js";
8
8
  import { Combobox } from "../ui/combobox.js";
9
- import { isFilterValueEmpty, getDateRange, getNumberRange, getCompoundValue } from "./filter-values.js";
9
+ import { recordFilterFieldTouch, patchFieldState, resolveFieldApplyChange, formatTextMultiSummary, singleStringDraftValue, formatEnumMultiSummary, enumComboboxSelection, mergeCompoundSubDraft } from "./filter-form-panel-apply.js";
10
+ import { isFilterValueEmpty, getDateRange, getNumberRange, getStringArray, getCompoundValue } from "./filter-values.js";
10
11
  import { hasActiveManagedFilters } from "./managed-filters.js";
11
12
  const DEFAULT_LABELS = {
12
13
  closeFiltersAriaLabel: "Close filters",
@@ -21,7 +22,11 @@ const DEFAULT_LABELS = {
21
22
  compoundSubRangeFromAriaLabel: (subLabel) => `${subLabel} — from`,
22
23
  compoundSubRangeToAriaLabel: (subLabel) => `${subLabel} — to`,
23
24
  compoundSubRangeFromPlaceholder: (subLabel) => `${subLabel} from`,
24
- compoundSubRangeToPlaceholder: (subLabel) => `${subLabel} to`
25
+ compoundSubRangeToPlaceholder: (subLabel) => `${subLabel} to`,
26
+ multiValueFormHint: "Multiple values — edit using the column filter chips or popover above the table.",
27
+ valuesCount: (count) => `${count} values`,
28
+ selectedCount: (count) => `${count} selected`,
29
+ clearFieldForSingleEdit: "Clear to edit one value"
25
30
  };
26
31
  function deriveInitialState(fields, filters) {
27
32
  const byId = new Map(filters.map((f) => [f.columnId, f.value]));
@@ -71,24 +76,38 @@ function FilterFormPanel({
71
76
  return changed ? next : prev;
72
77
  });
73
78
  }, [filters, renderableFields]);
74
- const setField = useCallback((id2, value) => {
75
- touchedRef.current.add(id2);
76
- setState((prev) => ({ ...prev, [id2]: value }));
77
- }, []);
79
+ const setField = useCallback(
80
+ (id2, value, compoundSubKey) => {
81
+ recordFilterFieldTouch(touchedRef.current, id2, compoundSubKey);
82
+ setState((prev) => patchFieldState(prev, id2, value));
83
+ },
84
+ []
85
+ );
78
86
  const handleApply = useCallback(() => {
79
87
  const activeIds = new Set(filters.map((f) => f.columnId));
88
+ const committedById = new Map(filters.map((f) => [f.columnId, f.value]));
89
+ const touched = touchedRef.current;
80
90
  const changes = [];
81
91
  for (const id2 of renderableFields) {
82
- const value = state[id2];
83
- if (value === void 0 || isFilterValueEmpty(value)) {
84
- if (activeIds.has(id2)) changes.push({ columnId: id2, action: "remove" });
85
- } else {
86
- changes.push({ columnId: id2, action: "set", value });
87
- }
92
+ const config = filterConfigs[id2];
93
+ const committed = committedById.get(id2);
94
+ const draft = state[id2];
95
+ const resolution = resolveFieldApplyChange(
96
+ config,
97
+ id2,
98
+ committed,
99
+ draft,
100
+ touched,
101
+ hiddenSubSet,
102
+ activeIds.has(id2)
103
+ );
104
+ if (resolution.kind === "skip") continue;
105
+ if (resolution.kind === "remove") changes.push({ columnId: id2, action: "remove" });
106
+ else changes.push({ columnId: id2, action: "set", value: resolution.value });
88
107
  }
89
108
  onApply(changes);
90
109
  touchedRef.current.clear();
91
- }, [filters, onApply, renderableFields, state]);
110
+ }, [filterConfigs, filters, hiddenSubSet, onApply, renderableFields, state]);
92
111
  const handleClear = useCallback(() => {
93
112
  setState(deriveInitialState(renderableFields, []));
94
113
  onClear(renderableFields);
@@ -133,7 +152,7 @@ function FilterFormPanel({
133
152
  {
134
153
  config,
135
154
  value: state[fieldId],
136
- onChange: (v) => setField(fieldId, v),
155
+ onChange: (v, compoundSubKey) => setField(fieldId, v, compoundSubKey),
137
156
  fieldId,
138
157
  hiddenSubSet,
139
158
  labels
@@ -161,7 +180,20 @@ function FilterFormPanel({
161
180
  }
162
181
  function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
163
182
  if (config.type === "text") {
164
- const text = Array.isArray(value) ? value[0] ?? "" : "";
183
+ const strings = getStringArray(value);
184
+ if (strings.length > 1) {
185
+ return /* @__PURE__ */ jsx(
186
+ MultiValueStringSummary,
187
+ {
188
+ label: config.label,
189
+ summary: formatTextMultiSummary(strings, labels),
190
+ hint: labels.multiValueFormHint,
191
+ clearLabel: labels.clearFieldForSingleEdit,
192
+ onClearForEdit: () => onChange(void 0)
193
+ }
194
+ );
195
+ }
196
+ const text = singleStringDraftValue(strings);
165
197
  return /* @__PURE__ */ jsxs("label", { className: "flex min-w-0 flex-col gap-1.5", children: [
166
198
  /* @__PURE__ */ jsx("span", { className: "text-[11px] font-medium text-[var(--text-secondary)]", children: config.label }),
167
199
  /* @__PURE__ */ jsx(
@@ -179,14 +211,27 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
179
211
  ] });
180
212
  }
181
213
  if (config.type === "enum") {
182
- const selected = Array.isArray(value) ? value[0] ?? "" : "";
214
+ const strings = getStringArray(value);
215
+ if (strings.length > 1) {
216
+ return /* @__PURE__ */ jsx(
217
+ MultiValueStringSummary,
218
+ {
219
+ label: config.label,
220
+ summary: formatEnumMultiSummary(strings, config.options, labels),
221
+ hint: labels.multiValueFormHint,
222
+ clearLabel: labels.clearFieldForSingleEdit,
223
+ onClearForEdit: () => onChange(void 0)
224
+ }
225
+ );
226
+ }
227
+ const comboboxValue = enumComboboxSelection(strings);
183
228
  return /* @__PURE__ */ jsxs("label", { className: "flex min-w-0 flex-col gap-1.5", children: [
184
229
  /* @__PURE__ */ jsx("span", { className: "text-[11px] font-medium text-[var(--text-secondary)]", children: config.label }),
185
230
  /* @__PURE__ */ jsx(
186
231
  Combobox,
187
232
  {
188
- value: selected || null,
189
- onChange: (v) => onChange(v ? [v] : void 0),
233
+ value: comboboxValue,
234
+ onChange: (v) => onChange(v === null ? void 0 : [v]),
190
235
  options: config.options ?? [],
191
236
  placeholder: labels.anyPlaceholder,
192
237
  triggerAriaLabel: config.label,
@@ -267,10 +312,7 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
267
312
  if (config.type === "compound") {
268
313
  const compound = getCompoundValue(value);
269
314
  const updateSub = (subKey, next) => {
270
- const rest = { ...compound };
271
- delete rest[subKey];
272
- const merged = next === void 0 || isFilterValueEmpty(next) ? rest : { ...rest, [subKey]: next };
273
- onChange(Object.keys(merged).length === 0 ? void 0 : merged);
315
+ onChange(mergeCompoundSubDraft(compound, subKey, next), subKey);
274
316
  };
275
317
  const visibleSubs = (config.subFilters ?? []).filter(
276
318
  (sub) => !hiddenSubSet.has(`${fieldId}:${sub.key}`)
@@ -287,7 +329,22 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
287
329
  /* @__PURE__ */ jsx("div", { className: "grid min-w-0 gap-2 grid-cols-[repeat(auto-fit,minmax(180px,1fr))]", children: visibleSubs.map((sub) => {
288
330
  const subValue = compound[sub.key];
289
331
  if (sub.type === "text") {
290
- const text = Array.isArray(subValue) ? subValue[0] ?? "" : "";
332
+ const strings = getStringArray(subValue);
333
+ if (strings.length > 1) {
334
+ return /* @__PURE__ */ jsx(
335
+ MultiValueStringSummary,
336
+ {
337
+ label: sub.label,
338
+ summary: formatTextMultiSummary(strings, labels),
339
+ hint: labels.multiValueFormHint,
340
+ clearLabel: labels.clearFieldForSingleEdit,
341
+ onClearForEdit: () => updateSub(sub.key, void 0),
342
+ compact: true
343
+ },
344
+ sub.key
345
+ );
346
+ }
347
+ const text = singleStringDraftValue(strings);
291
348
  return /* @__PURE__ */ jsx(
292
349
  Input,
293
350
  {
@@ -304,12 +361,27 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
304
361
  );
305
362
  }
306
363
  if (sub.type === "enum") {
307
- const selected = Array.isArray(subValue) ? subValue[0] ?? "" : "";
364
+ const strings = getStringArray(subValue);
365
+ if (strings.length > 1) {
366
+ return /* @__PURE__ */ jsx(
367
+ MultiValueStringSummary,
368
+ {
369
+ label: sub.label,
370
+ summary: formatEnumMultiSummary(strings, sub.options, labels),
371
+ hint: labels.multiValueFormHint,
372
+ clearLabel: labels.clearFieldForSingleEdit,
373
+ onClearForEdit: () => updateSub(sub.key, void 0),
374
+ compact: true
375
+ },
376
+ sub.key
377
+ );
378
+ }
379
+ const comboboxValue = enumComboboxSelection(strings);
308
380
  return /* @__PURE__ */ jsx(
309
381
  Combobox,
310
382
  {
311
- value: selected || null,
312
- onChange: (v) => updateSub(sub.key, v ? [v] : void 0),
383
+ value: comboboxValue,
384
+ onChange: (v) => updateSub(sub.key, v === null ? void 0 : [v]),
313
385
  options: sub.options ?? [],
314
386
  placeholder: sub.label,
315
387
  triggerAriaLabel: sub.label,
@@ -365,6 +437,31 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
365
437
  }
366
438
  return null;
367
439
  }
440
+ function MultiValueStringSummary({
441
+ label,
442
+ summary,
443
+ hint,
444
+ clearLabel,
445
+ onClearForEdit,
446
+ compact
447
+ }) {
448
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0 flex-col gap-1.5", compact && "col-span-full"), children: [
449
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] font-medium text-[var(--text-secondary)]", children: label }),
450
+ /* @__PURE__ */ jsx("p", { className: "text-[12px] text-[var(--text-primary)]", children: summary }),
451
+ /* @__PURE__ */ jsx("p", { className: "text-[11px] leading-snug text-[var(--text-secondary)]", children: hint }),
452
+ /* @__PURE__ */ jsx(
453
+ Button,
454
+ {
455
+ type: "button",
456
+ variant: "secondary",
457
+ size: "sm",
458
+ className: "h-9 self-start",
459
+ onClick: onClearForEdit,
460
+ children: clearLabel
461
+ }
462
+ )
463
+ ] });
464
+ }
368
465
  export {
369
466
  FilterFormPanel
370
467
  };
@@ -0,0 +1,114 @@
1
+ import { isFilterValueEmpty, isMultiStringFilterValue, getCompoundValue } from "./filter-values.js";
2
+ function compoundSubTouchKey(fieldId, subKey) {
3
+ return `${fieldId}:${subKey}`;
4
+ }
5
+ function recordFilterFieldTouch(touched, fieldId, compoundSubKey) {
6
+ touched.add(fieldId);
7
+ if (compoundSubKey) {
8
+ touched.add(compoundSubTouchKey(fieldId, compoundSubKey));
9
+ }
10
+ }
11
+ function singleStringDraftValue(strings) {
12
+ return strings[0] ?? "";
13
+ }
14
+ function enumComboboxSelection(strings) {
15
+ return strings.length > 0 ? strings[0] : null;
16
+ }
17
+ function patchFieldState(prev, fieldId, value) {
18
+ return { ...prev, [fieldId]: value };
19
+ }
20
+ function mergeCompoundSubDraft(compound, subKey, next) {
21
+ const rest = { ...compound };
22
+ delete rest[subKey];
23
+ const merged = next === void 0 || isFilterValueEmpty(next) ? rest : { ...rest, [subKey]: next };
24
+ return Object.keys(merged).length === 0 ? void 0 : merged;
25
+ }
26
+ function formatTextMultiSummary(values, labels) {
27
+ if (values.length === 2) {
28
+ const joined = values.join(", ");
29
+ return joined.length > 24 ? labels.valuesCount(values.length) : joined;
30
+ }
31
+ return labels.valuesCount(values.length);
32
+ }
33
+ function formatEnumMultiSummary(values, options, labels) {
34
+ if (values.length === 2) {
35
+ const parts = values.map((v) => options?.find((o) => o.value === v)?.label ?? v);
36
+ const joined = parts.join(", ");
37
+ return joined.length > 24 ? labels.selectedCount(values.length) : joined;
38
+ }
39
+ return labels.selectedCount(values.length);
40
+ }
41
+ function shouldPreserveMultiSubOnApply(sub, subCommitted, touchKey, touched) {
42
+ if (sub.type !== "text" && sub.type !== "enum") return false;
43
+ return isMultiStringFilterValue(subCommitted) && !touched.has(touchKey);
44
+ }
45
+ function mergeCompoundValueForApply(config, fieldId, committed, draft, touched, hiddenSubSet) {
46
+ const committedCompound = getCompoundValue(committed);
47
+ const draftCompound = getCompoundValue(draft);
48
+ const merged = {};
49
+ for (const sub of config.subFilters ?? []) {
50
+ if (hiddenSubSet.has(compoundSubTouchKey(fieldId, sub.key))) continue;
51
+ const touchKey = compoundSubTouchKey(fieldId, sub.key);
52
+ const subCommitted = committedCompound[sub.key];
53
+ const subDraft = draftCompound[sub.key];
54
+ if (shouldPreserveMultiSubOnApply(sub, subCommitted, touchKey, touched)) {
55
+ if (subCommitted !== void 0 && !isFilterValueEmpty(subCommitted)) {
56
+ merged[sub.key] = subCommitted;
57
+ }
58
+ continue;
59
+ }
60
+ if (subDraft === void 0 || isFilterValueEmpty(subDraft)) continue;
61
+ merged[sub.key] = subDraft;
62
+ }
63
+ return Object.keys(merged).length === 0 ? void 0 : merged;
64
+ }
65
+ function resolveFieldApplyChange(config, fieldId, committed, draft, touched, hiddenSubSet, isActive) {
66
+ switch (config.type) {
67
+ case "text":
68
+ case "enum": {
69
+ if (isMultiStringFilterValue(committed) && !touched.has(fieldId)) {
70
+ return { kind: "skip" };
71
+ }
72
+ if (draft === void 0 || isFilterValueEmpty(draft)) {
73
+ return isActive ? { kind: "remove" } : { kind: "skip" };
74
+ }
75
+ return { kind: "set", value: draft };
76
+ }
77
+ case "date":
78
+ case "number-range": {
79
+ if (draft === void 0 || isFilterValueEmpty(draft)) {
80
+ return isActive ? { kind: "remove" } : { kind: "skip" };
81
+ }
82
+ return { kind: "set", value: draft };
83
+ }
84
+ case "compound": {
85
+ const merged = mergeCompoundValueForApply(
86
+ config,
87
+ fieldId,
88
+ committed,
89
+ draft,
90
+ touched,
91
+ hiddenSubSet
92
+ );
93
+ if (merged === void 0 || isFilterValueEmpty(merged)) {
94
+ return isActive ? { kind: "remove" } : { kind: "skip" };
95
+ }
96
+ return { kind: "set", value: merged };
97
+ }
98
+ default:
99
+ return { kind: "skip" };
100
+ }
101
+ }
102
+ export {
103
+ compoundSubTouchKey,
104
+ enumComboboxSelection,
105
+ formatEnumMultiSummary,
106
+ formatTextMultiSummary,
107
+ mergeCompoundSubDraft,
108
+ mergeCompoundValueForApply,
109
+ patchFieldState,
110
+ recordFilterFieldTouch,
111
+ resolveFieldApplyChange,
112
+ shouldPreserveMultiSubOnApply,
113
+ singleStringDraftValue
114
+ };
@@ -4,6 +4,9 @@ function isObjectValue(value) {
4
4
  function getStringArray(value) {
5
5
  return Array.isArray(value) ? value : [];
6
6
  }
7
+ function isMultiStringFilterValue(value) {
8
+ return getStringArray(value).length > 1;
9
+ }
7
10
  function getDateRange(value) {
8
11
  const range = {};
9
12
  if (!isObjectValue(value)) return range;
@@ -32,5 +35,6 @@ export {
32
35
  getDateRange,
33
36
  getNumberRange,
34
37
  getStringArray,
35
- isFilterValueEmpty
38
+ isFilterValueEmpty,
39
+ isMultiStringFilterValue
36
40
  };
package/dist/styles.css CHANGED
@@ -469,6 +469,9 @@
469
469
  .z-50 {
470
470
  z-index: 50
471
471
  }
472
+ .col-span-full {
473
+ grid-column: 1 / -1
474
+ }
472
475
  .mx-3 {
473
476
  margin-left: 0.75rem;
474
477
  margin-right: 0.75rem
@@ -889,6 +892,9 @@
889
892
  margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
890
893
  margin-bottom: calc(1rem * var(--tw-space-y-reverse))
891
894
  }
895
+ .self-start {
896
+ align-self: flex-start
897
+ }
892
898
  .self-stretch {
893
899
  align-self: stretch
894
900
  }
@@ -1280,6 +1286,9 @@
1280
1286
  .leading-none {
1281
1287
  line-height: 1
1282
1288
  }
1289
+ .leading-snug {
1290
+ line-height: 1.375
1291
+ }
1283
1292
  .leading-tight {
1284
1293
  line-height: 1.25
1285
1294
  }
@@ -2825,6 +2825,9 @@ function isObjectValue(value) {
2825
2825
  function getStringArray(value) {
2826
2826
  return Array.isArray(value) ? value : [];
2827
2827
  }
2828
+ function isMultiStringFilterValue(value) {
2829
+ return getStringArray(value).length > 1;
2830
+ }
2828
2831
  function getDateRange(value) {
2829
2832
  const range = {};
2830
2833
  if (!isObjectValue(value)) return range;
@@ -3998,6 +4001,106 @@ function AppliedStateChips({
3998
4001
  )
3999
4002
  ] });
4000
4003
  }
4004
+ function compoundSubTouchKey(fieldId, subKey) {
4005
+ return `${fieldId}:${subKey}`;
4006
+ }
4007
+ function recordFilterFieldTouch(touched, fieldId, compoundSubKey) {
4008
+ touched.add(fieldId);
4009
+ if (compoundSubKey) {
4010
+ touched.add(compoundSubTouchKey(fieldId, compoundSubKey));
4011
+ }
4012
+ }
4013
+ function singleStringDraftValue(strings) {
4014
+ return strings[0] ?? "";
4015
+ }
4016
+ function enumComboboxSelection(strings) {
4017
+ return strings.length > 0 ? strings[0] : null;
4018
+ }
4019
+ function patchFieldState(prev, fieldId, value) {
4020
+ return { ...prev, [fieldId]: value };
4021
+ }
4022
+ function mergeCompoundSubDraft(compound, subKey, next) {
4023
+ const rest = { ...compound };
4024
+ delete rest[subKey];
4025
+ const merged = next === void 0 || isFilterValueEmpty(next) ? rest : { ...rest, [subKey]: next };
4026
+ return Object.keys(merged).length === 0 ? void 0 : merged;
4027
+ }
4028
+ function formatTextMultiSummary(values, labels) {
4029
+ if (values.length === 2) {
4030
+ const joined = values.join(", ");
4031
+ return joined.length > 24 ? labels.valuesCount(values.length) : joined;
4032
+ }
4033
+ return labels.valuesCount(values.length);
4034
+ }
4035
+ function formatEnumMultiSummary(values, options, labels) {
4036
+ if (values.length === 2) {
4037
+ const parts = values.map((v) => options?.find((o) => o.value === v)?.label ?? v);
4038
+ const joined = parts.join(", ");
4039
+ return joined.length > 24 ? labels.selectedCount(values.length) : joined;
4040
+ }
4041
+ return labels.selectedCount(values.length);
4042
+ }
4043
+ function shouldPreserveMultiSubOnApply(sub, subCommitted, touchKey, touched) {
4044
+ if (sub.type !== "text" && sub.type !== "enum") return false;
4045
+ return isMultiStringFilterValue(subCommitted) && !touched.has(touchKey);
4046
+ }
4047
+ function mergeCompoundValueForApply(config, fieldId, committed, draft, touched, hiddenSubSet) {
4048
+ const committedCompound = getCompoundValue(committed);
4049
+ const draftCompound = getCompoundValue(draft);
4050
+ const merged = {};
4051
+ for (const sub of config.subFilters ?? []) {
4052
+ if (hiddenSubSet.has(compoundSubTouchKey(fieldId, sub.key))) continue;
4053
+ const touchKey = compoundSubTouchKey(fieldId, sub.key);
4054
+ const subCommitted = committedCompound[sub.key];
4055
+ const subDraft = draftCompound[sub.key];
4056
+ if (shouldPreserveMultiSubOnApply(sub, subCommitted, touchKey, touched)) {
4057
+ if (subCommitted !== void 0 && !isFilterValueEmpty(subCommitted)) {
4058
+ merged[sub.key] = subCommitted;
4059
+ }
4060
+ continue;
4061
+ }
4062
+ if (subDraft === void 0 || isFilterValueEmpty(subDraft)) continue;
4063
+ merged[sub.key] = subDraft;
4064
+ }
4065
+ return Object.keys(merged).length === 0 ? void 0 : merged;
4066
+ }
4067
+ function resolveFieldApplyChange(config, fieldId, committed, draft, touched, hiddenSubSet, isActive) {
4068
+ switch (config.type) {
4069
+ case "text":
4070
+ case "enum": {
4071
+ if (isMultiStringFilterValue(committed) && !touched.has(fieldId)) {
4072
+ return { kind: "skip" };
4073
+ }
4074
+ if (draft === void 0 || isFilterValueEmpty(draft)) {
4075
+ return isActive ? { kind: "remove" } : { kind: "skip" };
4076
+ }
4077
+ return { kind: "set", value: draft };
4078
+ }
4079
+ case "date":
4080
+ case "number-range": {
4081
+ if (draft === void 0 || isFilterValueEmpty(draft)) {
4082
+ return isActive ? { kind: "remove" } : { kind: "skip" };
4083
+ }
4084
+ return { kind: "set", value: draft };
4085
+ }
4086
+ case "compound": {
4087
+ const merged = mergeCompoundValueForApply(
4088
+ config,
4089
+ fieldId,
4090
+ committed,
4091
+ draft,
4092
+ touched,
4093
+ hiddenSubSet
4094
+ );
4095
+ if (merged === void 0 || isFilterValueEmpty(merged)) {
4096
+ return isActive ? { kind: "remove" } : { kind: "skip" };
4097
+ }
4098
+ return { kind: "set", value: merged };
4099
+ }
4100
+ default:
4101
+ return { kind: "skip" };
4102
+ }
4103
+ }
4001
4104
  function applyManagedChanges(orch, changes) {
4002
4105
  const activeIds = new Set(orch.columnFilters.map((f) => f.columnId));
4003
4106
  for (const change of changes) {
@@ -4031,7 +4134,11 @@ const DEFAULT_LABELS = {
4031
4134
  compoundSubRangeFromAriaLabel: (subLabel) => `${subLabel} — from`,
4032
4135
  compoundSubRangeToAriaLabel: (subLabel) => `${subLabel} — to`,
4033
4136
  compoundSubRangeFromPlaceholder: (subLabel) => `${subLabel} from`,
4034
- compoundSubRangeToPlaceholder: (subLabel) => `${subLabel} to`
4137
+ compoundSubRangeToPlaceholder: (subLabel) => `${subLabel} to`,
4138
+ multiValueFormHint: "Multiple values — edit using the column filter chips or popover above the table.",
4139
+ valuesCount: (count) => `${count} values`,
4140
+ selectedCount: (count) => `${count} selected`,
4141
+ clearFieldForSingleEdit: "Clear to edit one value"
4035
4142
  };
4036
4143
  function deriveInitialState(fields, filters) {
4037
4144
  const byId = new Map(filters.map((f) => [f.columnId, f.value]));
@@ -4081,24 +4188,38 @@ function FilterFormPanel({
4081
4188
  return changed ? next : prev;
4082
4189
  });
4083
4190
  }, [filters, renderableFields]);
4084
- const setField = React.useCallback((id2, value) => {
4085
- touchedRef.current.add(id2);
4086
- setState((prev) => ({ ...prev, [id2]: value }));
4087
- }, []);
4191
+ const setField = React.useCallback(
4192
+ (id2, value, compoundSubKey) => {
4193
+ recordFilterFieldTouch(touchedRef.current, id2, compoundSubKey);
4194
+ setState((prev) => patchFieldState(prev, id2, value));
4195
+ },
4196
+ []
4197
+ );
4088
4198
  const handleApply = React.useCallback(() => {
4089
4199
  const activeIds = new Set(filters.map((f) => f.columnId));
4200
+ const committedById = new Map(filters.map((f) => [f.columnId, f.value]));
4201
+ const touched = touchedRef.current;
4090
4202
  const changes = [];
4091
4203
  for (const id2 of renderableFields) {
4092
- const value = state[id2];
4093
- if (value === void 0 || isFilterValueEmpty(value)) {
4094
- if (activeIds.has(id2)) changes.push({ columnId: id2, action: "remove" });
4095
- } else {
4096
- changes.push({ columnId: id2, action: "set", value });
4097
- }
4204
+ const config = filterConfigs[id2];
4205
+ const committed = committedById.get(id2);
4206
+ const draft = state[id2];
4207
+ const resolution = resolveFieldApplyChange(
4208
+ config,
4209
+ id2,
4210
+ committed,
4211
+ draft,
4212
+ touched,
4213
+ hiddenSubSet,
4214
+ activeIds.has(id2)
4215
+ );
4216
+ if (resolution.kind === "skip") continue;
4217
+ if (resolution.kind === "remove") changes.push({ columnId: id2, action: "remove" });
4218
+ else changes.push({ columnId: id2, action: "set", value: resolution.value });
4098
4219
  }
4099
4220
  onApply(changes);
4100
4221
  touchedRef.current.clear();
4101
- }, [filters, onApply, renderableFields, state]);
4222
+ }, [filterConfigs, filters, hiddenSubSet, onApply, renderableFields, state]);
4102
4223
  const handleClear = React.useCallback(() => {
4103
4224
  setState(deriveInitialState(renderableFields, []));
4104
4225
  onClear(renderableFields);
@@ -4143,7 +4264,7 @@ function FilterFormPanel({
4143
4264
  {
4144
4265
  config,
4145
4266
  value: state[fieldId],
4146
- onChange: (v) => setField(fieldId, v),
4267
+ onChange: (v, compoundSubKey) => setField(fieldId, v, compoundSubKey),
4147
4268
  fieldId,
4148
4269
  hiddenSubSet,
4149
4270
  labels
@@ -4171,7 +4292,20 @@ function FilterFormPanel({
4171
4292
  }
4172
4293
  function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
4173
4294
  if (config.type === "text") {
4174
- const text = Array.isArray(value) ? value[0] ?? "" : "";
4295
+ const strings = getStringArray(value);
4296
+ if (strings.length > 1) {
4297
+ return /* @__PURE__ */ jsxRuntime.jsx(
4298
+ MultiValueStringSummary,
4299
+ {
4300
+ label: config.label,
4301
+ summary: formatTextMultiSummary(strings, labels),
4302
+ hint: labels.multiValueFormHint,
4303
+ clearLabel: labels.clearFieldForSingleEdit,
4304
+ onClearForEdit: () => onChange(void 0)
4305
+ }
4306
+ );
4307
+ }
4308
+ const text = singleStringDraftValue(strings);
4175
4309
  return /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "flex min-w-0 flex-col gap-1.5", children: [
4176
4310
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] font-medium text-[var(--text-secondary)]", children: config.label }),
4177
4311
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -4189,14 +4323,27 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
4189
4323
  ] });
4190
4324
  }
4191
4325
  if (config.type === "enum") {
4192
- const selected = Array.isArray(value) ? value[0] ?? "" : "";
4326
+ const strings = getStringArray(value);
4327
+ if (strings.length > 1) {
4328
+ return /* @__PURE__ */ jsxRuntime.jsx(
4329
+ MultiValueStringSummary,
4330
+ {
4331
+ label: config.label,
4332
+ summary: formatEnumMultiSummary(strings, config.options, labels),
4333
+ hint: labels.multiValueFormHint,
4334
+ clearLabel: labels.clearFieldForSingleEdit,
4335
+ onClearForEdit: () => onChange(void 0)
4336
+ }
4337
+ );
4338
+ }
4339
+ const comboboxValue = enumComboboxSelection(strings);
4193
4340
  return /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "flex min-w-0 flex-col gap-1.5", children: [
4194
4341
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] font-medium text-[var(--text-secondary)]", children: config.label }),
4195
4342
  /* @__PURE__ */ jsxRuntime.jsx(
4196
4343
  Combobox,
4197
4344
  {
4198
- value: selected || null,
4199
- onChange: (v) => onChange(v ? [v] : void 0),
4345
+ value: comboboxValue,
4346
+ onChange: (v) => onChange(v === null ? void 0 : [v]),
4200
4347
  options: config.options ?? [],
4201
4348
  placeholder: labels.anyPlaceholder,
4202
4349
  triggerAriaLabel: config.label,
@@ -4277,10 +4424,7 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
4277
4424
  if (config.type === "compound") {
4278
4425
  const compound = getCompoundValue(value);
4279
4426
  const updateSub = (subKey, next) => {
4280
- const rest = { ...compound };
4281
- delete rest[subKey];
4282
- const merged = next === void 0 || isFilterValueEmpty(next) ? rest : { ...rest, [subKey]: next };
4283
- onChange(Object.keys(merged).length === 0 ? void 0 : merged);
4427
+ onChange(mergeCompoundSubDraft(compound, subKey, next), subKey);
4284
4428
  };
4285
4429
  const visibleSubs = (config.subFilters ?? []).filter(
4286
4430
  (sub) => !hiddenSubSet.has(`${fieldId}:${sub.key}`)
@@ -4297,7 +4441,22 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
4297
4441
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid min-w-0 gap-2 grid-cols-[repeat(auto-fit,minmax(180px,1fr))]", children: visibleSubs.map((sub) => {
4298
4442
  const subValue = compound[sub.key];
4299
4443
  if (sub.type === "text") {
4300
- const text = Array.isArray(subValue) ? subValue[0] ?? "" : "";
4444
+ const strings = getStringArray(subValue);
4445
+ if (strings.length > 1) {
4446
+ return /* @__PURE__ */ jsxRuntime.jsx(
4447
+ MultiValueStringSummary,
4448
+ {
4449
+ label: sub.label,
4450
+ summary: formatTextMultiSummary(strings, labels),
4451
+ hint: labels.multiValueFormHint,
4452
+ clearLabel: labels.clearFieldForSingleEdit,
4453
+ onClearForEdit: () => updateSub(sub.key, void 0),
4454
+ compact: true
4455
+ },
4456
+ sub.key
4457
+ );
4458
+ }
4459
+ const text = singleStringDraftValue(strings);
4301
4460
  return /* @__PURE__ */ jsxRuntime.jsx(
4302
4461
  Input,
4303
4462
  {
@@ -4314,12 +4473,27 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
4314
4473
  );
4315
4474
  }
4316
4475
  if (sub.type === "enum") {
4317
- const selected = Array.isArray(subValue) ? subValue[0] ?? "" : "";
4476
+ const strings = getStringArray(subValue);
4477
+ if (strings.length > 1) {
4478
+ return /* @__PURE__ */ jsxRuntime.jsx(
4479
+ MultiValueStringSummary,
4480
+ {
4481
+ label: sub.label,
4482
+ summary: formatEnumMultiSummary(strings, sub.options, labels),
4483
+ hint: labels.multiValueFormHint,
4484
+ clearLabel: labels.clearFieldForSingleEdit,
4485
+ onClearForEdit: () => updateSub(sub.key, void 0),
4486
+ compact: true
4487
+ },
4488
+ sub.key
4489
+ );
4490
+ }
4491
+ const comboboxValue = enumComboboxSelection(strings);
4318
4492
  return /* @__PURE__ */ jsxRuntime.jsx(
4319
4493
  Combobox,
4320
4494
  {
4321
- value: selected || null,
4322
- onChange: (v) => updateSub(sub.key, v ? [v] : void 0),
4495
+ value: comboboxValue,
4496
+ onChange: (v) => updateSub(sub.key, v === null ? void 0 : [v]),
4323
4497
  options: sub.options ?? [],
4324
4498
  placeholder: sub.label,
4325
4499
  triggerAriaLabel: sub.label,
@@ -4375,6 +4549,31 @@ function Field({ config, value, onChange, fieldId, hiddenSubSet, labels }) {
4375
4549
  }
4376
4550
  return null;
4377
4551
  }
4552
+ function MultiValueStringSummary({
4553
+ label,
4554
+ summary,
4555
+ hint,
4556
+ clearLabel,
4557
+ onClearForEdit,
4558
+ compact
4559
+ }) {
4560
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn("flex min-w-0 flex-col gap-1.5", compact && "col-span-full"), children: [
4561
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[11px] font-medium text-[var(--text-secondary)]", children: label }),
4562
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-[12px] text-[var(--text-primary)]", children: summary }),
4563
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-[11px] leading-snug text-[var(--text-secondary)]", children: hint }),
4564
+ /* @__PURE__ */ jsxRuntime.jsx(
4565
+ Button,
4566
+ {
4567
+ type: "button",
4568
+ variant: "secondary",
4569
+ size: "sm",
4570
+ className: "h-9 self-start",
4571
+ onClick: onClearForEdit,
4572
+ children: clearLabel
4573
+ }
4574
+ )
4575
+ ] });
4576
+ }
4378
4577
  const STORAGE_PREFIX = "tablewright.filters.open.";
4379
4578
  function readPersisted(persistKey, fallback) {
4380
4579
  if (typeof window === "undefined") return fallback;
@@ -317,6 +317,9 @@
317
317
  .z-50 {
318
318
  z-index: 50
319
319
  }
320
+ .col-span-full {
321
+ grid-column: 1 / -1
322
+ }
320
323
  .mx-3 {
321
324
  margin-left: 0.75rem;
322
325
  margin-right: 0.75rem
@@ -737,6 +740,9 @@
737
740
  margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
738
741
  margin-bottom: calc(1rem * var(--tw-space-y-reverse))
739
742
  }
743
+ .self-start {
744
+ align-self: flex-start
745
+ }
740
746
  .self-stretch {
741
747
  align-self: stretch
742
748
  }
@@ -1128,6 +1134,9 @@
1128
1134
  .leading-none {
1129
1135
  line-height: 1
1130
1136
  }
1137
+ .leading-snug {
1138
+ line-height: 1.375
1139
+ }
1131
1140
  .leading-tight {
1132
1141
  line-height: 1.25
1133
1142
  }
@@ -14,6 +14,12 @@ export interface FilterFormPanelLabels {
14
14
  compoundSubRangeToAriaLabel: (subLabel: string) => string;
15
15
  compoundSubRangeFromPlaceholder: (subLabel: string) => string;
16
16
  compoundSubRangeToPlaceholder: (subLabel: string) => string;
17
+ /** Shown under read-only multi-value text fields in the form panel. */
18
+ multiValueFormHint: string;
19
+ valuesCount: (count: number) => string;
20
+ selectedCount: (count: number) => string;
21
+ /** Clears a multi-value field so the user can enter a single value. */
22
+ clearFieldForSingleEdit: string;
17
23
  }
18
24
  export interface FilterFormPanelProps {
19
25
  /** All known filter configs (same shape used by GroupedFilterAdd). */
@@ -0,0 +1,27 @@
1
+ import type { ActiveFilterValue, ColumnFilterConfig, CompoundFilterValue, CompoundSubFilter } from "./filter-types.js";
2
+ export interface MultiValueSummaryLabels {
3
+ valuesCount: (count: number) => string;
4
+ selectedCount: (count: number) => string;
5
+ }
6
+ export declare function compoundSubTouchKey(fieldId: string, subKey: string): string;
7
+ export declare function recordFilterFieldTouch(touched: Set<string>, fieldId: string, compoundSubKey?: string): void;
8
+ export declare function singleStringDraftValue(strings: string[]): string;
9
+ export declare function enumComboboxSelection(strings: string[]): string | null;
10
+ export declare function patchFieldState<T extends Record<string, ActiveFilterValue | undefined>>(prev: T, fieldId: string, value: ActiveFilterValue | undefined): T;
11
+ export declare function mergeCompoundSubDraft(compound: CompoundFilterValue, subKey: string, next: ActiveFilterValue | undefined): ActiveFilterValue | undefined;
12
+ export declare function formatTextMultiSummary(values: string[], labels: MultiValueSummaryLabels): string;
13
+ export declare function formatEnumMultiSummary(values: string[], options: {
14
+ label: string;
15
+ value: string;
16
+ }[] | undefined, labels: MultiValueSummaryLabels): string;
17
+ export declare function shouldPreserveMultiSubOnApply(sub: CompoundSubFilter, subCommitted: ActiveFilterValue | undefined, touchKey: string, touched: Set<string>): boolean;
18
+ export declare function mergeCompoundValueForApply(config: ColumnFilterConfig, fieldId: string, committed: ActiveFilterValue | undefined, draft: ActiveFilterValue | undefined, touched: Set<string>, hiddenSubSet: Set<string>): ActiveFilterValue | undefined;
19
+ export type ApplyResolution = {
20
+ kind: "skip";
21
+ } | {
22
+ kind: "remove";
23
+ } | {
24
+ kind: "set";
25
+ value: ActiveFilterValue;
26
+ };
27
+ export declare function resolveFieldApplyChange(config: ColumnFilterConfig, fieldId: string, committed: ActiveFilterValue | undefined, draft: ActiveFilterValue | undefined, touched: Set<string>, hiddenSubSet: Set<string>, isActive: boolean): ApplyResolution;
@@ -34,6 +34,14 @@ export type NumberRangeValue = {
34
34
  export type CompoundFilterValue = {
35
35
  [key: string]: ActiveFilterValue;
36
36
  };
37
+ /**
38
+ * Stored filter payload for one column. Text and enum filters are usually `string[]`
39
+ * (one element for a single selection; multiple for popover/chip multi-select).
40
+ *
41
+ * `FilterFormPanel` edits at most one string per text/enum field (and per compound
42
+ * sub-filter of those types). Multi-value text/enum filters are edited via column
43
+ * filter chips and `ColumnFilterPopover`, not the form panel.
44
+ */
37
45
  export type ActiveFilterValue = string | string[] | DateRangeValue | NumberRangeValue | CompoundFilterValue;
38
46
  export interface ActiveFilter {
39
47
  columnId: string;
@@ -1,6 +1,8 @@
1
1
  import type { ActiveFilterValue, CompoundFilterValue, DateRangeValue, NumberRangeValue } from "./filter-types.js";
2
2
  /** Read a text/enum filter value. Non-array values read as `[]`. */
3
3
  export declare function getStringArray(value: ActiveFilterValue | undefined): string[];
4
+ /** True when a text/enum value selects more than one string (popover/chip multi-select). */
5
+ export declare function isMultiStringFilterValue(value: ActiveFilterValue | undefined): boolean;
4
6
  /** Read a date filter value. Non-string bounds are dropped. */
5
7
  export declare function getDateRange(value: ActiveFilterValue | undefined): DateRangeValue;
6
8
  /** Read a number-range filter value. Non-number bounds are dropped. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@undevy-org/tablewright",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "React data-table and application-shell components: filters, drawers, row controls, sortable and resizable columns, built on Radix UI and TanStack Table.",
5
5
  "license": "MIT",
6
6
  "repository": {