@parallelworks/ui 0.88.1 → 0.89.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.
@@ -9,6 +9,7 @@ import { Input } from "../components/Input.js";
9
9
  import { Combobox } from "../components/Combobox.js";
10
10
  import { Calendar } from "../components/DatePicker.js";
11
11
  import { Drawer } from "../components/Drawer.js";
12
+ import { ToggleGroup } from "../components/ToggleGroup.js";
12
13
  import { DATE_PRESETS, fieldOperators, isDatePreset, isMultiple, operatorCount, parseDay, toDay } from "./filterModel.js";
13
14
  import { Menu } from "@base-ui/react/menu";
14
15
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
@@ -458,7 +459,7 @@ function BooleanValuePicker({ filters, filter, open, onOpenChange, trigger }) {
458
459
  }) })]
459
460
  });
460
461
  }
461
- function DateValuePicker({ filters, filter, open, onOpenChange, trigger }) {
462
+ function DateValuePicker({ filters, filter, field, open, onOpenChange, trigger }) {
462
463
  const t = useStrings().filters;
463
464
  const [custom, setCustom] = useState(false);
464
465
  const [first, second] = filter.values;
@@ -484,7 +485,7 @@ function DateValuePicker({ filters, filter, open, onOpenChange, trigger }) {
484
485
  className: "p-0",
485
486
  children: [filter.op === "is" && /* @__PURE__ */ jsxs("div", {
486
487
  className: cx("flex flex-col p-1", showCalendar && "border-b border-(--theme-border)"),
487
- children: [DATE_PRESETS.map((preset) => /* @__PURE__ */ jsxs("button", {
488
+ children: [(field.presets ?? DATE_PRESETS).map((preset) => /* @__PURE__ */ jsxs("button", {
488
489
  type: "button",
489
490
  "aria-pressed": first === preset,
490
491
  onClick: () => choosePreset(preset),
@@ -636,15 +637,177 @@ function AddFilterMenu({ filters, trigger, align = "start" }) {
636
637
  children: menu
637
638
  }) : menu;
638
639
  }
640
+ var NO_TOGGLE = "\0none";
641
+ /** A quick filter's toggles and what they show pressed, from the field's filter. */
642
+ function quickToggles(filters, quick, field) {
643
+ const filter = filters.active.find((f) => f.field === quick.field);
644
+ if (field.kind === "boolean") {
645
+ const on = filter?.op === "is" && filter.values[0] !== "false";
646
+ return {
647
+ options: [{
648
+ value: "true",
649
+ label: field.label,
650
+ count: quick.counts?.["true"],
651
+ tone: quick.tone
652
+ }],
653
+ value: on ? ["true"] : filter ? [NO_TOGGLE] : [],
654
+ multiple: true
655
+ };
656
+ }
657
+ const multiple = quick.multiple ?? isMultiple(field);
658
+ const values = quick.values ?? (field.options ?? []).map((o) => o.value);
659
+ const options = values.map((value) => {
660
+ const option = field.options?.find((o) => o.value === value);
661
+ return {
662
+ value,
663
+ label: option?.label ?? field.valueLabel?.(value) ?? value,
664
+ icon: option?.icon,
665
+ count: quick.counts?.[value] ?? option?.count,
666
+ tone: quick.tone
667
+ };
668
+ });
669
+ const shows = filter?.op === "is" && filter.values.every((v) => values.includes(v)) && (multiple || filter.values.length === 1);
670
+ return {
671
+ options,
672
+ value: !filter ? [] : shows ? [...filter.values] : [NO_TOGGLE],
673
+ multiple
674
+ };
675
+ }
676
+ /** Whether a filter is said by its quick filter's toggles alone, so it needs no chip. */
677
+ function saidByToggles(filters, filter) {
678
+ const quick = filters.quick.find((q) => q.field === filter.field);
679
+ const field = filters.fields.find((f) => f.key === filter.field);
680
+ if (!quick || !field || filters.draft === filter.field) return false;
681
+ const { value } = quickToggles(filters, quick, field);
682
+ return (value.length > 0 || Boolean(quick.all)) && !value.includes(NO_TOGGLE);
683
+ }
684
+ /** Every quick filter's toggles on one row, a rule between fields. On a narrow row they keep to
685
+ * one line, the pressed ones always among them, and "+n more" opens the rest in the sheet. */
686
+ function QuickRow({ filters, groups, narrow, onMore, end, stacked = false }) {
687
+ const t = useStrings().filters;
688
+ const row = useRef(null);
689
+ const [limit, setLimit] = useState(null);
690
+ const [width, setWidth] = useState(0);
691
+ const fits = narrow && Boolean(onMore);
692
+ const built = groups.map(({ quick, field }) => {
693
+ const toggles = quickToggles(filters, quick, field);
694
+ return {
695
+ quick,
696
+ field,
697
+ toggles,
698
+ items: [...quick.all ? [{
699
+ value: ALL_ITEM,
700
+ pressed: toggles.value.length === 0
701
+ }] : [], ...toggles.options.map((o) => ({
702
+ value: o.value,
703
+ pressed: toggles.value.includes(o.value)
704
+ }))]
705
+ };
706
+ });
707
+ const total = built.reduce((n, g) => n + g.items.length, 0);
708
+ const keys = built.map((g) => g.items.map((i) => `${i.value}${i.pressed ? "+" : ""}`).join(",")).join("|");
709
+ useLayoutEffect(() => {
710
+ const el = row.current;
711
+ if (!el || !fits) return;
712
+ let last = el.getBoundingClientRect().width;
713
+ setWidth(last);
714
+ const observer = new ResizeObserver(() => {
715
+ const next = el.getBoundingClientRect().width;
716
+ if (next !== last) {
717
+ last = next;
718
+ setWidth(next);
719
+ }
720
+ });
721
+ observer.observe(el);
722
+ return () => observer.disconnect();
723
+ }, [fits]);
724
+ useLayoutEffect(() => setLimit(null), [
725
+ fits,
726
+ keys,
727
+ width
728
+ ]);
729
+ useLayoutEffect(() => {
730
+ const el = row.current;
731
+ if (!el || !fits) return;
732
+ const tops = /* @__PURE__ */ new Set();
733
+ for (const button of el.querySelectorAll("button")) tops.add(Math.round(button.getBoundingClientRect().top));
734
+ const shown = limit ?? total;
735
+ if (tops.size > 1 && shown > 1) setLimit(shown - 1);
736
+ });
737
+ const cap = fits && limit !== null ? limit : total;
738
+ let index = 0;
739
+ let hidden = 0;
740
+ const parts = [];
741
+ for (const { quick, field, toggles, items } of built) {
742
+ const keep = /* @__PURE__ */ new Set();
743
+ for (const item of items) {
744
+ if (index < cap || item.pressed) keep.add(item.value);
745
+ else hidden += 1;
746
+ index += 1;
747
+ }
748
+ if (keep.size === 0) continue;
749
+ const all = quick.all;
750
+ const set = (next) => {
751
+ const values = next.filter((v) => v !== NO_TOGGLE);
752
+ if (values.length > 0) filters.update(quick.field, {
753
+ op: "is",
754
+ values
755
+ });
756
+ else if (field.required || field.pinned) filters.update(quick.field, { values: [] });
757
+ else filters.remove(quick.field);
758
+ };
759
+ if (parts.length > 0 && !stacked) parts.push(/* @__PURE__ */ jsx(QuickRule, {}, `rule-${quick.field}`));
760
+ parts.push(/* @__PURE__ */ jsx(ToggleGroup, {
761
+ label: field.label,
762
+ options: toggles.options.filter((o) => keep.has(o.value)),
763
+ value: toggles.value,
764
+ multiple: toggles.multiple,
765
+ all: keep.has(ALL_ITEM) && (typeof all === "object" ? { count: all.count } : all),
766
+ allLabel: typeof all === "object" ? all.label : void 0,
767
+ onValueChange: set,
768
+ dimIcons: false,
769
+ className: "min-w-0"
770
+ }, quick.field));
771
+ }
772
+ return /* @__PURE__ */ jsxs("div", {
773
+ ref: row,
774
+ className: cx("flex min-w-0", stacked ? "flex-col items-start gap-2" : "flex-wrap items-center gap-1"),
775
+ children: [
776
+ parts,
777
+ hidden > 0 && /* @__PURE__ */ jsx("button", {
778
+ type: "button",
779
+ onClick: onMore,
780
+ className: buttonClasses({
781
+ variant: "ghost",
782
+ size: "sm"
783
+ }),
784
+ children: t.quickMore(hidden)
785
+ }),
786
+ end
787
+ ]
788
+ });
789
+ }
790
+ var ALL_ITEM = "\0all";
791
+ function QuickRule() {
792
+ return /* @__PURE__ */ jsx("span", {
793
+ "aria-hidden": "true",
794
+ className: "mx-1 h-5 w-px shrink-0 bg-(--theme-border)"
795
+ });
796
+ }
639
797
  /** The chips that say what narrows a list, under its toolbar: one per filter, each editable in
640
798
  * place, then "+ Filter" and Clear (or Reset, where the page has default filters). It shows
641
799
  * only while a filter is on or a field is required. At phone width it stops at two lines and
642
- * a "+n filters" chip opens the rest in a sheet. */
800
+ * a "+n filters" chip opens the rest in a sheet.
801
+ *
802
+ * The list's quick filters show above the chips as toggles, always, setting the same filters
803
+ * the chips edit. At phone width each keeps to one line, and "+n more" opens the rest in the
804
+ * same sheet. */
643
805
  function ListFilterBar({ filters, trailing, label, className }) {
644
806
  const strings = useStrings();
645
807
  const t = strings.filters;
646
808
  const registerBar = filters?.registerBar;
647
809
  useEffect(() => registerBar?.(), [registerBar]);
810
+ const box = useRef(null);
648
811
  const row = useRef(null);
649
812
  const [narrow, setNarrow] = useState(false);
650
813
  const [width, setWidth] = useState(0);
@@ -653,10 +816,18 @@ function ListFilterBar({ filters, trailing, label, className }) {
653
816
  const [focusAt, setFocusAt] = useState(null);
654
817
  const chips = filters ? orderedChips(filters) : [];
655
818
  const fieldOf = (key) => filters?.fields.find((f) => f.key === key);
819
+ const quick = (filters?.quick ?? []).flatMap((q) => {
820
+ const field = fieldOf(q.field);
821
+ return field ? [{
822
+ quick: q,
823
+ field
824
+ }] : [];
825
+ });
656
826
  const empty = chips.length === 0;
827
+ const hasQuick = quick.length > 0;
657
828
  const chipKeys = chips.map((c) => c.filter.field).join("\0");
658
829
  useLayoutEffect(() => {
659
- const el = row.current;
830
+ const el = box.current;
660
831
  if (!el) return;
661
832
  const measure = () => {
662
833
  const w = el.getBoundingClientRect().width;
@@ -667,7 +838,7 @@ function ListFilterBar({ filters, trailing, label, className }) {
667
838
  const observer = new ResizeObserver(measure);
668
839
  observer.observe(el);
669
840
  return () => observer.disconnect();
670
- }, [empty]);
841
+ }, [empty, hasQuick]);
671
842
  useLayoutEffect(() => setLimit(null), [
672
843
  width,
673
844
  chipKeys,
@@ -694,7 +865,7 @@ function ListFilterBar({ filters, trailing, label, className }) {
694
865
  useEffect(() => {
695
866
  if (hiddenRequest) setSheet(true);
696
867
  }, [hiddenRequest]);
697
- if (!filters || empty) return null;
868
+ if (!filters || empty && !hasQuick) return null;
698
869
  const removable = filters.active.some((f) => !fieldOf(f.field)?.required);
699
870
  const resetting = filters.hasDefaults;
700
871
  const clearButton = (resetting ? !filters.isDefault : removable) && /* @__PURE__ */ jsx("button", {
@@ -706,23 +877,53 @@ function ListFilterBar({ filters, trailing, label, className }) {
706
877
  }),
707
878
  children: resetting ? strings.common.reset : strings.common.clear
708
879
  });
709
- const addButton = /* @__PURE__ */ jsx(AddFilterMenu, {
880
+ const addButton = (narrow) => /* @__PURE__ */ jsx(AddFilterMenu, {
710
881
  filters,
711
882
  trigger: /* @__PURE__ */ jsxs("button", {
712
883
  type: "button",
713
884
  "data-filter-add": "",
885
+ "aria-label": narrow ? t.addFilter : void 0,
714
886
  className: buttonClasses({
715
887
  variant: "ghost",
716
- size: "sm"
888
+ size: "sm",
889
+ iconOnly: narrow
717
890
  }),
718
- children: [/* @__PURE__ */ jsx(AddIcon, { "aria-hidden": "true" }), t.addFilter]
891
+ children: [/* @__PURE__ */ jsx(AddIcon, { "aria-hidden": "true" }), !narrow && t.addFilter]
719
892
  })
720
893
  });
721
- return /* @__PURE__ */ jsxs("div", {
722
- ref: row,
894
+ const drawer = /* @__PURE__ */ jsx(Drawer, {
895
+ open: sheet,
896
+ onClose: () => setSheet(false),
897
+ title: t.sheetTitle,
898
+ width: 400,
899
+ footer: /* @__PURE__ */ jsxs("div", {
900
+ className: "flex w-full items-center gap-1.5",
901
+ children: [addButton(false), clearButton]
902
+ }),
903
+ children: /* @__PURE__ */ jsxs("div", {
904
+ className: "flex flex-col gap-2",
905
+ children: [hasQuick && /* @__PURE__ */ jsx(QuickRow, {
906
+ filters,
907
+ groups: quick,
908
+ narrow: false,
909
+ stacked: true
910
+ }), chips.map(({ filter, field }) => /* @__PURE__ */ jsx(FilterChip, {
911
+ filters,
912
+ filter,
913
+ field,
914
+ stretch: true,
915
+ answers: sheet
916
+ }, filter.field))]
917
+ })
918
+ });
919
+ const chipRow = !empty && /* @__PURE__ */ jsxs("div", {
920
+ ref: hasQuick ? row : (node) => {
921
+ row.current = node;
922
+ box.current = node;
923
+ },
723
924
  role: "group",
724
925
  "aria-label": label ?? t.activeFilters,
725
- className: cx("flex flex-wrap items-center gap-1.5 py-1.5", className),
926
+ className: cx("flex flex-wrap items-center gap-1.5", hasQuick ? void 0 : cx("py-1.5", className)),
726
927
  children: [
727
928
  shownChips.map(({ filter, field }, i) => /* @__PURE__ */ jsx(FilterChip, {
728
929
  filters,
@@ -737,32 +938,32 @@ function ListFilterBar({ filters, trailing, label, className }) {
737
938
  className: "flex h-7 cursor-pointer items-center rounded-md border border-dashed border-(--theme-border) px-2 text-[12px] text-(--theme-app) hover:bg-(--theme-hover) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--theme-focus)",
738
939
  children: t.overflow(overflow)
739
940
  }),
740
- addButton,
941
+ addButton(narrow),
741
942
  clearButton,
742
943
  trailing && /* @__PURE__ */ jsx("div", {
743
944
  className: "ml-auto flex items-center gap-1.5",
744
945
  children: trailing
745
946
  }),
746
- /* @__PURE__ */ jsx(Drawer, {
747
- open: sheet,
748
- onClose: () => setSheet(false),
749
- title: t.sheetTitle,
750
- width: 400,
751
- footer: /* @__PURE__ */ jsxs("div", {
752
- className: "flex w-full items-center gap-1.5",
753
- children: [addButton, clearButton]
754
- }),
755
- children: /* @__PURE__ */ jsx("div", {
756
- className: "flex flex-col gap-2",
757
- children: chips.map(({ filter, field }) => /* @__PURE__ */ jsx(FilterChip, {
758
- filters,
759
- filter,
760
- field,
761
- stretch: true,
762
- answers: sheet
763
- }, filter.field))
764
- })
765
- })
947
+ !hasQuick && drawer
948
+ ]
949
+ });
950
+ if (!hasQuick) return chipRow;
951
+ return /* @__PURE__ */ jsxs("div", {
952
+ ref: box,
953
+ className: cx("flex min-w-0 flex-col gap-1.5 py-1.5", className),
954
+ children: [
955
+ /* @__PURE__ */ jsx(QuickRow, {
956
+ filters,
957
+ groups: quick,
958
+ narrow,
959
+ onMore: () => setSheet(true),
960
+ end: empty && /* @__PURE__ */ jsxs(Fragment$1, { children: [clearButton, trailing && /* @__PURE__ */ jsx("div", {
961
+ className: "ml-auto flex items-center gap-1.5",
962
+ children: trailing
963
+ })] })
964
+ }),
965
+ chipRow,
966
+ drawer
766
967
  ]
767
968
  });
768
969
  }
@@ -784,7 +985,7 @@ function orderedChips(filters) {
784
985
  field
785
986
  });
786
987
  }
787
- return out;
988
+ return out.filter(({ filter }) => !saidByToggles(filters, filter));
788
989
  }
789
990
  //#endregion
790
991
  export { AddFilterMenu, FilterChip, ListFilterBar };
@@ -51,6 +51,10 @@ export interface FilterField<T = never> {
51
51
  valueLabel?: ((value: string) => string | undefined) | undefined;
52
52
  /** enum and people: whether more than one value can be chosen (default true). */
53
53
  multiple?: boolean | undefined;
54
+ /** date: the presets its picker offers, in order, such as `['-24h', '-7d', '-30d', 'year',
55
+ * 'all']`. Defaults to {@link DATE_PRESETS}. Any `-Nh` or `-Nd` works, as do `today`,
56
+ * `month`, `year` and `all`. */
57
+ presets?: readonly string[] | undefined;
54
58
  /** Whether the picker has a search field. Default: more than eight options, or `loadOptions`. */
55
59
  searchable?: boolean | undefined;
56
60
  /** Always shows its chip, with no remove button, reading "Any" while unset; Clear leaves it. */
@@ -65,6 +69,28 @@ export interface FilterField<T = never> {
65
69
  * on the server. */
66
70
  matches?: ((row: T, filter: ActiveFilter) => boolean) | undefined;
67
71
  }
72
+ /** A field's values as a row of toggles above the chips, for the few a reader picks most, such as
73
+ * a job's state. The toggles set the same filter its chip edits, so either changes both. */
74
+ export interface QuickFilter {
75
+ /** The field the toggles set. */
76
+ field: string;
77
+ /** enum and people: the options shown as toggles, in order; every option by default. */
78
+ values?: readonly string[] | undefined;
79
+ /** How many rows hold each value, shown after its label; a yes-or-no field's is under `true`.
80
+ * An option's own `count` is used where this has none. */
81
+ counts?: Readonly<Record<string, number>> | undefined;
82
+ /** Leads with an "All" toggle, pressed while the field is unset, that clears it. `{ count }`
83
+ * shows the total beside it and `{ label }` names what it covers, such as "All states". */
84
+ all?: boolean | {
85
+ count?: number | undefined;
86
+ label?: string | undefined;
87
+ } | undefined;
88
+ /** Whether several can be pressed at once; the field's own `multiple` by default. */
89
+ multiple?: boolean | undefined;
90
+ /** `warning`: a toggle asking to be looked at, such as "Needs attention", in the warning tone
91
+ * while it is off. */
92
+ tone?: 'warning' | undefined;
93
+ }
68
94
  /** One filter in effect: plain data, so it can go in a URL, storage or a saved view. */
69
95
  export interface ActiveFilter {
70
96
  field: string;
@@ -73,9 +99,12 @@ export interface ActiveFilter {
73
99
  * relative wherever they are saved. A boolean is `true` or `false`. */
74
100
  values: readonly string[];
75
101
  }
76
- /** The relative date presets the date picker offers, as a filter stores them. */
102
+ /** The relative date presets the date picker offers unless a field names its own, as a filter
103
+ * stores them. */
77
104
  export declare const DATE_PRESETS: readonly ['today', '-7d', '-30d', '-90d', 'month'];
78
- export type DatePreset = (typeof DATE_PRESETS)[number];
105
+ /** A relative date a filter can hold: today, this month or year so far, every date (`all`), or
106
+ * the last N hours or days (`-24h`, `-365d`). */
107
+ export type DatePreset = (typeof DATE_PRESETS)[number] | 'year' | 'all' | `-${number}h` | `-${number}d`;
79
108
  type AnyField = FilterField<any>;
80
109
  export declare function fieldOperators(field: AnyField): readonly FilterOperator[];
81
110
  export declare function isMultiple(field: AnyField): boolean;
@@ -1,5 +1,6 @@
1
1
  //#region src/list/filterModel.ts
2
- /** The relative date presets the date picker offers, as a filter stores them. */
2
+ /** The relative date presets the date picker offers unless a field names its own, as a filter
3
+ * stores them. */
3
4
  var DATE_PRESETS = [
4
5
  "today",
5
6
  "-7d",
@@ -212,47 +213,72 @@ function parseDay(value) {
212
213
  }
213
214
  var startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
214
215
  var addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
216
+ var NAMED_PRESETS = /* @__PURE__ */ new Set([
217
+ "today",
218
+ "month",
219
+ "year",
220
+ "all"
221
+ ]);
215
222
  function isDatePreset(value) {
216
- return DATE_PRESETS.includes(value ?? "") || /^-\d+d$/.test(value ?? "");
223
+ return NAMED_PRESETS.has(value ?? "") || /^-\d+[dh]$/.test(value ?? "");
217
224
  }
218
- /** The days a preset or a day covers, as `[first, last]` local days, both included. */
219
- function presetDays(value, now) {
225
+ /** The span a preset or a day covers: `from` included, `to` excluded, either open. Whole local
226
+ * days, except the last N hours, which run from that moment on. */
227
+ function presetSpan(value, now) {
220
228
  const today = startOfDay(now);
221
- if (value === "today") return [today, today];
222
- if (value === "month") return [new Date(today.getFullYear(), today.getMonth(), 1), today];
229
+ const tomorrow = addDays(today, 1);
230
+ if (value === "all") return {};
231
+ if (value === "today") return {
232
+ from: today,
233
+ to: tomorrow
234
+ };
235
+ if (value === "month") return {
236
+ from: new Date(today.getFullYear(), today.getMonth(), 1),
237
+ to: tomorrow
238
+ };
239
+ if (value === "year") return {
240
+ from: new Date(today.getFullYear(), 0, 1),
241
+ to: tomorrow
242
+ };
243
+ const hours = value.match(/^-(\d+)h$/);
244
+ if (hours) return { from: /* @__PURE__ */ new Date(now.getTime() - Number(hours[1]) * 36e5) };
223
245
  const days = value.match(/^-(\d+)d$/);
224
- if (days) return [addDays(today, 1 - Number(days[1])), today];
246
+ if (days) return {
247
+ from: addDays(today, 1 - Number(days[1])),
248
+ to: tomorrow
249
+ };
225
250
  const day = parseDay(value);
226
- return day ? [day, day] : void 0;
251
+ return day ? {
252
+ from: day,
253
+ to: addDays(day, 1)
254
+ } : void 0;
227
255
  }
228
256
  /** The span a date filter covers right now: `from` included, `to` excluded (the start of the
229
257
  * day after the last), either open. Relative presets resolve against `now`. */
230
258
  function dateFilterRange(filter, now = /* @__PURE__ */ new Date()) {
231
259
  const [first, second] = filter.values;
232
- const days = (v) => v ? presetDays(v, now) : void 0;
260
+ const span = (v) => v ? presetSpan(v, now) : void 0;
261
+ const range = (from, to) => ({
262
+ ...from && { from },
263
+ ...to && { to }
264
+ });
233
265
  switch (filter.op) {
234
266
  case "is": {
235
- const span = days(first);
236
- return span && {
237
- from: span[0],
238
- to: addDays(span[1], 1)
239
- };
267
+ const s = span(first);
268
+ return s && range(s.from, s.to);
240
269
  }
241
270
  case "after": {
242
- const span = days(first);
243
- return span && { from: span[0] };
271
+ const s = span(first);
272
+ return s && range(s.from, void 0);
244
273
  }
245
274
  case "before": {
246
- const span = days(first);
247
- return span && { to: addDays(span[1], 1) };
275
+ const s = span(first);
276
+ return s && range(void 0, s.to);
248
277
  }
249
278
  case "between": {
250
- const a = days(first);
251
- const b = days(second);
252
- return a && b ? {
253
- from: a[0],
254
- to: addDays(b[1], 1)
255
- } : void 0;
279
+ const a = span(first);
280
+ const b = span(second);
281
+ return a && b ? range(a.from, b.to) : void 0;
256
282
  }
257
283
  default: return;
258
284
  }
@@ -291,6 +317,7 @@ function matchFilter(field, filter, row, now) {
291
317
  }
292
318
  if (field.kind === "date") {
293
319
  const range = dateFilterRange(filter, now);
320
+ if (range && range.from === void 0 && range.to === void 0) return true;
294
321
  const t = asTime(v);
295
322
  if (!range || t === void 0) return range === void 0;
296
323
  return (range.from === void 0 || t >= range.from.getTime()) && (range.to === void 0 || t < range.to.getTime());
@@ -1,5 +1,5 @@
1
1
  export { ActionToolbar, type ToolbarAction, ToolbarButton, type ToolbarItem, } from './ActionToolbar.js';
2
- export { type ActiveFilter, applyFilterList, DATE_PRESETS, type DatePreset, dateFilterRange, decodeFilters, encodeFilters, type FilterField, type FilterKind, type FilterOperator, type FilterOption, type FilterRowValue, filterParamKeys, matchFilter, sameFilters, withFilterParams, } from './filterModel.js';
2
+ export { type ActiveFilter, applyFilterList, DATE_PRESETS, type DatePreset, dateFilterRange, decodeFilters, encodeFilters, type FilterField, type FilterKind, type FilterOperator, type FilterOption, type FilterRowValue, filterParamKeys, matchFilter, type QuickFilter, sameFilters, withFilterParams, } from './filterModel.js';
3
3
  export { AddFilterMenu, type AddFilterMenuProps, FilterChip, ListFilterBar, type ListFilterBarProps, } from './ListFilterBar.js';
4
4
  export { GroupRow, ListColumns, ListPage, type ListPageEmpty, type ListPageProps, ListRow, ListRowActionsProvider, useListNavigate, } from './ListPage.js';
5
5
  export { actionsColumnWidth, ListActionsHeader, ListCellText, ListError, ListSelectAllHeader, ListSkeleton, listTableFixedProps, listTableProps, NAME_MIN_WIDTH_PX, NameCell, RowSelectCheckbox, STRETCHED_LINK, } from './ListTable.js';
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { ActiveFilter, FilterField } from './filterModel.js';
2
+ import type { ActiveFilter, FilterField, QuickFilter } from './filterModel.js';
3
3
  import { type FilterStore, type ListFilters } from './useListFilters.js';
4
4
  /** A toggleable column. Rendering stays in the page's row component, gated by
5
5
  * {@link ListView.isVisible}; this is just the header metadata + visibility. */
@@ -97,6 +97,9 @@ export interface ListViewConfig<T> {
97
97
  filterStore?: FilterStore | undefined;
98
98
  /** False leaves remembering the filters to `filterStore` alone. */
99
99
  rememberFilters?: boolean | undefined;
100
+ /** Fields whose values also show as toggles above the chips, such as a job's state with how
101
+ * many jobs are in each. */
102
+ quickFilters?: readonly QuickFilter[] | undefined;
100
103
  actions?: ActionDef[];
101
104
  /** Explicit pinned-button count when not using actions+isPinned; sizes the actions column. */
102
105
  actionCount?: number;
@@ -192,5 +195,5 @@ export interface ListView<T> extends ListViewChrome {
192
195
  }
193
196
  /** Persisted grouping + column-visibility + field-filter state for a list page,
194
197
  * driving the shared Display/Filter menus. */
195
- export declare function useListView<T>({ storageKey, sharedFiltersKey, columns, groupBys, defaultGroupBy, orderBys, defaultOrderBy, facets, filterFields, defaultFilters, filterStore, rememberFilters, actions, actionCount, defaultShowColumnHeaders, defaultShowActions, isFavorite, isOn, createdAt, }: ListViewConfig<T>): ListView<T>;
198
+ export declare function useListView<T>({ storageKey, sharedFiltersKey, columns, groupBys, defaultGroupBy, orderBys, defaultOrderBy, facets, filterFields, defaultFilters, filterStore, rememberFilters, quickFilters, actions, actionCount, defaultShowColumnHeaders, defaultShowActions, isFavorite, isOn, createdAt, }: ListViewConfig<T>): ListView<T>;
196
199
  export {};
@@ -28,7 +28,7 @@ var DEFAULT_ORDER = "__default";
28
28
  var FAVORITE_ORDER = "__favorite";
29
29
  /** Persisted grouping + column-visibility + field-filter state for a list page,
30
30
  * driving the shared Display/Filter menus. */
31
- function useListView({ storageKey, sharedFiltersKey, columns, groupBys = [], defaultGroupBy, orderBys = [], defaultOrderBy, facets = [], filterFields, defaultFilters, filterStore, rememberFilters = true, actions = [], actionCount, defaultShowColumnHeaders = true, defaultShowActions = true, isFavorite, isOn, createdAt }) {
31
+ function useListView({ storageKey, sharedFiltersKey, columns, groupBys = [], defaultGroupBy, orderBys = [], defaultOrderBy, facets = [], filterFields, defaultFilters, filterStore, rememberFilters = true, quickFilters, actions = [], actionCount, defaultShowColumnHeaders = true, defaultShowActions = true, isFavorite, isOn, createdAt }) {
32
32
  const t = useStrings().list;
33
33
  const tiebreak = orderBys[0]?.compare;
34
34
  const effectiveOrderBys = useMemo(() => {
@@ -76,7 +76,8 @@ function useListView({ storageKey, sharedFiltersKey, columns, groupBys = [], def
76
76
  fields,
77
77
  defaultFilters,
78
78
  store: filterStore,
79
- remember: rememberFilters
79
+ remember: rememberFilters,
80
+ quick: quickFilters
80
81
  });
81
82
  const filters = useMemo(() => {
82
83
  const out = {};
@@ -1,4 +1,4 @@
1
- import { type ActiveFilter, type FilterField, type FilterOperator } from './filterModel.js';
1
+ import { type ActiveFilter, type FilterField, type FilterOperator, type QuickFilter } from './filterModel.js';
2
2
  /** Where filters live besides what the list remembers, such as the page's URL. */
3
3
  export interface FilterStore {
4
4
  /** The filters it holds, or `null` when it holds none, such as a URL with no filter params,
@@ -20,6 +20,8 @@ export interface ListFiltersConfig<T> {
20
20
  store?: FilterStore | undefined;
21
21
  /** False leaves remembering to the store alone. */
22
22
  remember?: boolean | undefined;
23
+ /** Fields whose values also show as toggles above the chips. */
24
+ quick?: readonly QuickFilter[] | undefined;
23
25
  }
24
26
  /** A request for a chip to open one of its pickers, which the chip clears once it has. */
25
27
  export interface FilterEditRequest {
@@ -30,6 +32,8 @@ export interface FilterEditRequest {
30
32
  /** A list's filters, the state behind the chip row and the Filter menu. */
31
33
  export interface ListFilters {
32
34
  fields: readonly FilterField<any>[];
35
+ /** Fields whose values also show as toggles above the chips. */
36
+ quick: readonly QuickFilter[];
33
37
  /** The filters in effect, in the order they were added. */
34
38
  active: readonly ActiveFilter[];
35
39
  /** The chips to show: the filters in effect, then one being added that has no value yet. */
@@ -65,4 +69,4 @@ export interface ListFilters {
65
69
  /** A list's active filters: which, in what order, where they are remembered and how rows
66
70
  * match them. `useListView` builds one from its facets and fields; a page not on it, or
67
71
  * filtering on the server, can call this alone. */
68
- export declare function useListFilters<T>({ storageKey, sharedFiltersKey, fields, defaultFilters, store, remember, }: ListFiltersConfig<T>): ListFilters;
72
+ export declare function useListFilters<T>({ storageKey, sharedFiltersKey, fields, defaultFilters, store, remember, quick, }: ListFiltersConfig<T>): ListFilters;
@@ -3,10 +3,11 @@ import { applyFilterList, fieldOperators, fromStored, isComplete, sameFilters, t
3
3
  import { useCallback, useMemo, useRef, useState } from "react";
4
4
  //#region src/list/useListFilters.ts
5
5
  var NONE = [];
6
+ var NO_QUICK = [];
6
7
  /** A list's active filters: which, in what order, where they are remembered and how rows
7
8
  * match them. `useListView` builds one from its facets and fields; a page not on it, or
8
9
  * filtering on the server, can call this alone. */
9
- function useListFilters({ storageKey, sharedFiltersKey, fields, defaultFilters = NONE, store, remember = true }) {
10
+ function useListFilters({ storageKey, sharedFiltersKey, fields, defaultFilters = NONE, store, remember = true, quick = NO_QUICK }) {
10
11
  const sharedKeys = useMemo(() => new Set(fields.filter((f) => f.shared).map((f) => f.key)), [fields]);
11
12
  const defaults = useMemo(() => defaultFilters.filter((f) => !sharedKeys.has(f.field)), [defaultFilters, sharedKeys]);
12
13
  const sharedDefaults = useMemo(() => defaultFilters.filter((f) => sharedKeys.has(f.field)), [defaultFilters, sharedKeys]);
@@ -218,6 +219,7 @@ function useListFilters({ storageKey, sharedFiltersKey, fields, defaultFilters =
218
219
  const apply = useCallback((rows) => applyFilterList(rows, active, fields), [active, fields]);
219
220
  return {
220
221
  fields,
222
+ quick,
221
223
  active,
222
224
  chips: useMemo(() => {
223
225
  if (!draft) return active;