@bmsuisse/datagrid 0.35.2 → 0.36.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/dist/index.d.ts CHANGED
@@ -258,6 +258,16 @@ type BooleanColumn<TRow> = BaseColumn<TRow> & {
258
258
  interface EnumOption {
259
259
  value: string;
260
260
  label: string;
261
+ /**
262
+ * Groups this option under a sticky header in `EnumFilter`'s dropdown,
263
+ * with a tri-state checkbox that selects/deselects the whole group at
264
+ * once — mirrors `@bmsuisse/ui`'s `ComboboxOption.group`/
265
+ * `TagComboboxOption.group`. Options sharing a `group` must be adjacent in
266
+ * `options`: a header renders the first time a group key is seen, so
267
+ * interleaved groups would render more than one header for the same key.
268
+ * Options with no `group` render individually, with no header.
269
+ */
270
+ group?: string;
261
271
  }
262
272
  type EnumColumn<TRow> = BaseColumn<TRow> & {
263
273
  type: "enum";
@@ -318,9 +328,22 @@ declare function DateRangeFilter<TRow>({ column, value, onChange, today, bare, }
318
328
  * - a tri-state "Select all" checkbox acts on whatever subset is currently
319
329
  * visible after that search, not the full option set — matching Excel's
320
330
  * own column-filter behavior ("select all of what's shown").
331
+ * - options sharing an `EnumOption.group` render under a sticky group
332
+ * header with its own tri-state checkbox, letting a whole group be
333
+ * selected/deselected in one click — mirrors `@bmsuisse/ui`'s
334
+ * `Combobox`/`TagCombobox` grouped-dropdown treatment (sticky `bg-muted`
335
+ * header, same contiguous-group chunking), kept as a local mirror rather
336
+ * than a shared dependency since `datagrid` and `ui` are sibling packages
337
+ * with no cross-dependency (see that package's `Combobox` doc, which
338
+ * mirrors this component's own plain-substring search for the same
339
+ * reason). The header's tri-state and toggle are scoped to that group's
340
+ * currently-*visible* options, same as "Select all" — unlike `Combobox`,
341
+ * which scopes its group toggle to the full option set regardless of
342
+ * search.
321
343
  * - every visible option gets its own plain checkbox + label row (a real
322
- * shadcn `Checkbox`, not a menu item), inside a scrollable list so long
323
- * option lists don't blow up the popover's height.
344
+ * shadcn `Checkbox`, not a menu item — unlike `Combobox`'s button/`role=
345
+ * "option"` rows), inside a scrollable list so long option lists don't
346
+ * blow up the popover's height.
324
347
  * - every toggle emits the updated `FilterDescriptor` immediately — there's
325
348
  * no OK/Cancel step, matching every other filter widget in this package.
326
349
  *
package/dist/index.js CHANGED
@@ -515,6 +515,27 @@ function selectedValuesOf(value) {
515
515
  function toggle(selected, optionValue) {
516
516
  return selected.includes(optionValue) ? selected.filter((v) => v !== optionValue) : [...selected, optionValue];
517
517
  }
518
+ function buildRenderChunks(options) {
519
+ const chunks = [];
520
+ options.forEach((option, index) => {
521
+ const row = { option, index };
522
+ const last = chunks[chunks.length - 1];
523
+ if (option.group && last?.kind === "group" && last.group === option.group) {
524
+ last.rows.push(row);
525
+ } else if (option.group) {
526
+ chunks.push({ kind: "group", group: option.group, rows: [row] });
527
+ } else {
528
+ chunks.push({ kind: "single", row });
529
+ }
530
+ });
531
+ return chunks;
532
+ }
533
+ function groupCheckState(candidates, selected, group) {
534
+ const members = candidates.filter((o) => o.group === group).map((o) => o.value);
535
+ const selectedCount = members.filter((v) => selected.includes(v)).length;
536
+ if (members.length === 0 || selectedCount === 0) return "unchecked";
537
+ return selectedCount === members.length ? "checked" : "indeterminate";
538
+ }
518
539
  function EnumFilter({
519
540
  column,
520
541
  value,
@@ -528,6 +549,7 @@ function EnumFilter({
528
549
  if (!term) return column.options;
529
550
  return column.options.filter((option) => option.label.toLowerCase().includes(term));
530
551
  }, [column.options, search]);
552
+ const renderChunks = useMemo(() => buildRenderChunks(visibleOptions), [visibleOptions]);
531
553
  function emit(next) {
532
554
  onChange(next.length === 0 ? void 0 : { field: column.id, operator: "in", value: next });
533
555
  }
@@ -544,6 +566,33 @@ function EnumFilter({
544
566
  for (const v of visibleValues) next.add(v);
545
567
  emit([...next]);
546
568
  }
569
+ function toggleGroup(group) {
570
+ const members = visibleOptions.filter((o) => o.group === group).map((o) => o.value);
571
+ if (groupCheckState(visibleOptions, selected, group) === "checked") {
572
+ emit(selected.filter((v) => !members.includes(v)));
573
+ return;
574
+ }
575
+ emit([.../* @__PURE__ */ new Set([...selected, ...members])]);
576
+ }
577
+ function renderOption(option) {
578
+ return /* @__PURE__ */ jsxs4(
579
+ "label",
580
+ {
581
+ className: "flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground",
582
+ children: [
583
+ /* @__PURE__ */ jsx9(
584
+ Checkbox,
585
+ {
586
+ checked: selected.includes(option.value),
587
+ onCheckedChange: () => emit(toggle(selected, option.value))
588
+ }
589
+ ),
590
+ option.label
591
+ ]
592
+ },
593
+ option.value
594
+ );
595
+ }
547
596
  const isFiltered = selected.length > 0;
548
597
  const summary = `${selected.length} selected`;
549
598
  const panel = /* @__PURE__ */ jsxs4("div", { className: bare ? "flex w-full flex-col gap-2" : "flex w-64 flex-col gap-2 p-2", children: [
@@ -567,23 +616,37 @@ function EnumFilter({
567
616
  ),
568
617
  "Select all"
569
618
  ] }),
570
- /* @__PURE__ */ jsx9("div", { className: "flex max-h-60 flex-col gap-1 overflow-y-auto", children: visibleOptions.length === 0 ? /* @__PURE__ */ jsx9("p", { className: "py-2 text-center text-sm text-muted-foreground", children: "No matches." }) : visibleOptions.map((option) => /* @__PURE__ */ jsxs4(
571
- "label",
572
- {
573
- className: "flex items-center gap-2 rounded-sm px-1 py-1 text-sm hover:bg-accent hover:text-accent-foreground",
574
- children: [
575
- /* @__PURE__ */ jsx9(
576
- Checkbox,
619
+ /* @__PURE__ */ jsx9("div", { className: "flex max-h-60 flex-col gap-0.5 overflow-y-auto", children: renderChunks.length === 0 ? /* @__PURE__ */ jsx9("p", { className: "py-2 text-center text-sm text-muted-foreground", children: "No matches." }) : renderChunks.map((chunk) => {
620
+ if (chunk.kind === "single") return renderOption(chunk.row.option);
621
+ const headerState = groupCheckState(visibleOptions, selected, chunk.group);
622
+ return (
623
+ // The header and every one of its group's rows share this wrapper --
624
+ // its bounds are the sticky header's containing block, so `sticky
625
+ // top-0` keeps the header pinned for the group's whole scroll extent
626
+ // instead of just past its first row.
627
+ /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-0.5", children: [
628
+ /* @__PURE__ */ jsxs4(
629
+ "label",
577
630
  {
578
- checked: selected.includes(option.value),
579
- onCheckedChange: () => emit(toggle(selected, option.value))
631
+ "data-group-header": true,
632
+ className: "sticky top-0 z-10 flex items-center gap-2 rounded-sm bg-muted px-2 py-1.5 text-sm font-semibold text-foreground",
633
+ children: [
634
+ /* @__PURE__ */ jsx9(
635
+ Checkbox,
636
+ {
637
+ checked: headerState === "indeterminate" ? "indeterminate" : headerState === "checked",
638
+ onCheckedChange: () => toggleGroup(chunk.group),
639
+ "aria-label": `Select all of ${chunk.group}`
640
+ }
641
+ ),
642
+ chunk.group
643
+ ]
580
644
  }
581
645
  ),
582
- option.label
583
- ]
584
- },
585
- option.value
586
- )) })
646
+ chunk.rows.map((row) => renderOption(row.option))
647
+ ] }, `group-${chunk.group}`)
648
+ );
649
+ }) })
587
650
  ] });
588
651
  if (bare) {
589
652
  return /* @__PURE__ */ jsx9("div", { "data-testid": `filter-${column.id}`, children: panel });
@@ -3477,7 +3540,7 @@ function DataGrid({
3477
3540
  return { style: column.width ? { width: column.width } : void 0 };
3478
3541
  }
3479
3542
  function structuralCellProps(side, offset, width, area = "body") {
3480
- const base = area === "header" ? "border-b border-border p-1 sticky top-0 z-20" : "border-b border-border p-1 sticky z-10";
3543
+ const base = area === "header" ? "border-b border-border p-1 sticky top-0 z-30" : "border-b border-border p-1 sticky z-10";
3481
3544
  const bg = area === "header" ? "bg-muted" : "bg-background";
3482
3545
  const bgOdd = area === "header" ? "bg-muted" : STRUCTURAL_ZEBRA_BG_CLASS;
3483
3546
  return {
@@ -3521,7 +3584,7 @@ function DataGrid({
3521
3584
  base,
3522
3585
  align,
3523
3586
  pinnedProps.className,
3524
- options.sticky && "sticky top-0 z-20 bg-muted"
3587
+ options.sticky && (column.pinned ? "sticky top-0 z-30 bg-muted" : "sticky top-0 z-20 bg-muted")
3525
3588
  );
3526
3589
  return { className, style: pinnedProps.style };
3527
3590
  }