aplance-ui 1.5.2 → 1.6.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.
Files changed (29) hide show
  1. package/README.md +1 -1
  2. package/dist/api.d.ts +2 -1
  3. package/dist/api.js +103 -68
  4. package/dist/api.js.map +1 -1
  5. package/dist/frameworks/vite-react/vite.config.ts.template +1 -1
  6. package/dist/index.js +103 -68
  7. package/dist/index.js.map +1 -1
  8. package/package.json +12 -6
  9. package/registry/components/primitives/modal/modal.module.css +9 -3
  10. package/registry/components/primitives/popover/popover-context.ts +2 -0
  11. package/registry/components/primitives/popover/popover.tsx +47 -2
  12. package/registry/components/ui/chart/chart.tsx +51 -30
  13. package/registry/components/ui/chart/types.ts +5 -1
  14. package/registry/components/ui/data-table/checkbox-column.tsx +12 -8
  15. package/registry/components/ui/data-table/data-table.module.css +41 -19
  16. package/registry/components/ui/data-table/data-table.tsx +1285 -1238
  17. package/registry/components/ui/data-table/filter-bar.tsx +56 -13
  18. package/registry/components/ui/data-table/filter-modal.tsx +16 -82
  19. package/registry/components/ui/data-table/filter-sheet.tsx +97 -0
  20. package/registry/components/ui/data-table/filters.tsx +21 -21
  21. package/registry/components/ui/data-table/pagination-bar.tsx +4 -6
  22. package/registry/components/ui/data-table/types.ts +149 -80
  23. package/registry/components/ui/data-table/useDataTable.tsx +35 -16
  24. package/registry/components/ui/data-table/utils.ts +691 -471
  25. package/registry/components/ui/input-fields/date-picker/calendar.tsx +7 -15
  26. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +1 -1
  27. package/registry/components/ui/input-fields/date-picker/types.ts +3 -3
  28. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +1 -1
  29. package/registry/registry.json +16 -11
@@ -1,18 +1,44 @@
1
1
  import { Fragment } from "react";
2
- import type { FilterBarProps } from "./types";
2
+ import { FilterSheet } from "./filter-sheet";
3
+ import type { FilterBarProps, FilterField } from "./types";
4
+ import { placementOf, renderFilter } from "./utils";
3
5
  import s from "./data-table.module.css";
4
6
 
7
+ interface FilterRowProps extends FilterBarProps {
8
+ /** The filters kept behind the "Filters" button at the row's end. */
9
+ kept: FilterField[];
10
+ /** The button shows only its icon, beside the row's own fields. */
11
+ iconOnly?: boolean;
12
+ }
13
+
5
14
  /**
6
- * Headless single-row filter bar.
15
+ * A single row of filters, then the "Filters" button for those kept off it.
7
16
  * Each field owns its rendering via a render-prop and sets its own grid
8
- * column width. The bar never wraps - consumers pick widths that fit.
17
+ * column width. The row never wraps - consumers pick widths that fit.
18
+ * With no field of its own, the row is the button alone, with its label; with
19
+ * no filter at all, it renders nothing.
9
20
  *
10
21
  * Width defaults: input fields -> `1fr`, actions -> `auto`.
11
22
  */
12
- export const FilterBar = ({ fields, values, onChange }: FilterBarProps) => {
13
- const gridCols = fields
14
- .map((f) => f.width ?? (f.type === "action" ? "auto" : "1fr"))
15
- .join(" ");
23
+ export const FilterRow = ({
24
+ fields,
25
+ kept,
26
+ values,
27
+ onChange,
28
+ iconOnly = false,
29
+ }: FilterRowProps) => {
30
+ if (fields.length === 0 && kept.length === 0) return null;
31
+ if (fields.length === 0) {
32
+ return (
33
+ <div className={s.filter_modal_bar}>
34
+ <FilterSheet fields={kept} values={values} onChange={onChange} />
35
+ </div>
36
+ );
37
+ }
38
+ const gridCols = [
39
+ ...fields.map((f) => f.width ?? (f.type === "action" ? "auto" : "1fr")),
40
+ ...(kept.length > 0 ? ["auto"] : []),
41
+ ].join(" ");
16
42
 
17
43
  return (
18
44
  <div className={s.filter_bar} style={{ gridTemplateColumns: gridCols }}>
@@ -20,14 +46,31 @@ export const FilterBar = ({ fields, values, onChange }: FilterBarProps) => {
20
46
  <Fragment
21
47
  key={field.type === "action" ? `act:${i}` : `k:${field.key}`}
22
48
  >
23
- {field.type === "action"
24
- ? field.render()
25
- : field.render({
26
- value: values[field.key] ?? "",
27
- onChange: (v) => onChange(field.key, v),
28
- })}
49
+ {renderFilter(field, values, onChange)}
29
50
  </Fragment>
30
51
  ))}
52
+ {kept.length > 0 && (
53
+ <FilterSheet
54
+ fields={kept}
55
+ values={values}
56
+ onChange={onChange}
57
+ iconOnly={iconOnly}
58
+ />
59
+ )}
31
60
  </div>
32
61
  );
33
62
  };
63
+
64
+ /**
65
+ * Headless filter bar for a screen wide enough: every filter in one row,
66
+ * but those placed in the modal, which sit behind a "Filters" button at its
67
+ * end.
68
+ */
69
+ export const FilterBar = ({ fields, values, onChange }: FilterBarProps) => (
70
+ <FilterRow
71
+ fields={fields.filter((f) => placementOf(f) !== "modal")}
72
+ kept={fields.filter((f) => placementOf(f) === "modal")}
73
+ values={values}
74
+ onChange={onChange}
75
+ />
76
+ );
@@ -1,86 +1,20 @@
1
- import { useState } from "react";
2
- import { IconFilter } from "@tabler/icons-react";
3
- import {
4
- Modal,
5
- ModalBody,
6
- ModalFooter,
7
- ModalHeader,
8
- ModalTitle,
9
- } from "../../primitives/modal/modal";
10
- import { Icon } from "../../primitives/icon/icon";
11
- import { Button } from "../button/button";
1
+ import { FilterRow } from "./filter-bar";
12
2
  import type { FilterBarProps } from "./types";
13
- import s from "./data-table.module.css";
3
+ import { placementOf } from "./utils";
14
4
 
15
5
  /**
16
- * Small-screen counterpart to <FilterBar>. It keeps filtering live while moving
17
- * the same field contract into a Modal bottom sheet.
6
+ * Small-screen counterpart to <FilterBar>. The filters placed inline, as a
7
+ * search, keep their place in the row with an icon "Filters" button beside
8
+ * them; every other filter moves into its modal, a bottom sheet on phones,
9
+ * still filtering live. With no inline filter, the button stands alone with
10
+ * its label.
18
11
  */
19
- export const FilterModal = ({ fields, values, onChange }: FilterBarProps) => {
20
- const [isOpen, setIsOpen] = useState(false);
21
-
22
- // Action slots (buttons/menus) render alongside the inputs; only input fields
23
- // carry a value, so only they count toward the active badge + Clear-all reset.
24
- const inputFields = fields.filter((field) => field.type === "field");
25
- const activeCount = inputFields.filter(
26
- (field) => (values[field.key] ?? "") !== "",
27
- ).length;
28
- const clearAll = () => {
29
- inputFields.forEach((field) => {
30
- if ((values[field.key] ?? "") !== "") onChange(field.key, "");
31
- });
32
- };
33
-
34
- return (
35
- <div className={s.filter_modal_bar}>
36
- <Button
37
- type="button"
38
- variant="bordered"
39
- className={s.filter_trigger}
40
- onClick={() => setIsOpen(true)}
41
- >
42
- <Icon>
43
- <IconFilter />
44
- </Icon>
45
- Filters
46
- {activeCount > 0 && <span className={s.filter_count}>{activeCount}</span>}
47
- </Button>
48
-
49
- <Modal isOpen={isOpen} onOpenChange={setIsOpen} placement="bottom" size="md">
50
- <ModalHeader>
51
- <ModalTitle>Filters</ModalTitle>
52
- </ModalHeader>
53
- <ModalBody>
54
- <div className={s.filter_modal_fields}>
55
- {fields.map((field, i) => (
56
- <div
57
- className={s.filter_modal_field}
58
- key={field.type === "action" ? `act:${i}` : `k:${field.key}`}
59
- >
60
- {field.type === "action"
61
- ? field.render()
62
- : field.render({
63
- value: values[field.key] ?? "",
64
- onChange: (v) => onChange(field.key, v),
65
- })}
66
- </div>
67
- ))}
68
- </div>
69
- </ModalBody>
70
- <ModalFooter>
71
- <Button
72
- type="button"
73
- variant="ghost"
74
- onClick={clearAll}
75
- disabled={activeCount === 0}
76
- >
77
- Clear all
78
- </Button>
79
- <Button type="button" variant="primary" onClick={() => setIsOpen(false)}>
80
- Done
81
- </Button>
82
- </ModalFooter>
83
- </Modal>
84
- </div>
85
- );
86
- };
12
+ export const FilterModal = ({ fields, values, onChange }: FilterBarProps) => (
13
+ <FilterRow
14
+ fields={fields.filter((f) => placementOf(f) === "inline")}
15
+ kept={fields.filter((f) => placementOf(f) !== "inline")}
16
+ values={values}
17
+ onChange={onChange}
18
+ iconOnly
19
+ />
20
+ );
@@ -0,0 +1,97 @@
1
+ import { useState } from "react";
2
+ import { IconFilter } from "@tabler/icons-react";
3
+ import {
4
+ Modal,
5
+ ModalBody,
6
+ ModalFooter,
7
+ ModalHeader,
8
+ ModalTitle,
9
+ } from "../../primitives/modal/modal";
10
+ import { Icon } from "../../primitives/icon/icon";
11
+ import { Button } from "../button/button";
12
+ import type { FilterSheetProps } from "./types";
13
+ import { isFilterSet, renderFilter } from "./utils";
14
+ import s from "./data-table.module.css";
15
+
16
+ /**
17
+ * The "Filters" button and the modal it opens: the filters kept off a row,
18
+ * stacked, with Clear all and Done. The button counts the filters in use,
19
+ * each against its own default; the modal is a bottom sheet on phones.
20
+ */
21
+ export const FilterSheet = ({
22
+ fields,
23
+ values,
24
+ onChange,
25
+ iconOnly = false,
26
+ }: FilterSheetProps) => {
27
+ const [isOpen, setIsOpen] = useState(false);
28
+
29
+ // Action slots (buttons/menus) render alongside the inputs; only input fields
30
+ // carry a value, so only they count toward the badge and Clear all.
31
+ const inUse = fields.filter(
32
+ (field) => field.type === "field" && isFilterSet(field, values),
33
+ );
34
+ const clearAll = () => {
35
+ inUse.forEach((field) => {
36
+ if (field.type === "field") onChange(field.key, field.defaultValue ?? "");
37
+ });
38
+ };
39
+ // One name for every layout: the icon-only button has no visible text, and
40
+ // the count badge alone reads as a bare number.
41
+ const label =
42
+ inUse.length > 0 ? `Filters, ${inUse.length} in use` : "Filters";
43
+
44
+ return (
45
+ <>
46
+ <Button
47
+ type="button"
48
+ variant="bordered"
49
+ layout={iconOnly && inUse.length === 0 ? "box" : "default"}
50
+ className={s.filter_trigger}
51
+ aria-label={label}
52
+ onClick={() => setIsOpen(true)}
53
+ >
54
+ <Icon>
55
+ <IconFilter />
56
+ </Icon>
57
+ {iconOnly ? null : "Filters"}
58
+ {inUse.length > 0 && (
59
+ <span className={s.filter_count} aria-hidden="true">
60
+ {inUse.length}
61
+ </span>
62
+ )}
63
+ </Button>
64
+
65
+ <Modal isOpen={isOpen} onOpenChange={setIsOpen} size="md">
66
+ <ModalHeader>
67
+ <ModalTitle>Filters</ModalTitle>
68
+ </ModalHeader>
69
+ <ModalBody>
70
+ <div className={s.filter_modal_fields}>
71
+ {fields.map((field, i) => (
72
+ <div
73
+ className={s.filter_modal_field}
74
+ key={field.type === "action" ? `act:${i}` : `k:${field.key}`}
75
+ >
76
+ {renderFilter(field, values, onChange)}
77
+ </div>
78
+ ))}
79
+ </div>
80
+ </ModalBody>
81
+ <ModalFooter>
82
+ <Button
83
+ type="button"
84
+ variant="ghost"
85
+ onClick={clearAll}
86
+ disabled={inUse.length === 0}
87
+ >
88
+ Clear all
89
+ </Button>
90
+ <Button type="button" variant="primary" onClick={() => setIsOpen(false)}>
91
+ Done
92
+ </Button>
93
+ </ModalFooter>
94
+ </Modal>
95
+ </>
96
+ );
97
+ };
@@ -1,21 +1,21 @@
1
- import { useMediaQuery } from "../../../hooks/useMediaQuery";
2
- import { FilterBar } from "./filter-bar";
3
- import { FilterModal } from "./filter-modal";
4
- import type { FilterBarProps } from "./types";
5
-
6
- // Below this width an inline filter row can't fit its fields (matches the
7
- // `--md` custom-media token in styles/media.css): the filters collapse into a
8
- // single "Filters" button and a modal instead of stacking full-height.
9
- const FILTER_COMPACT_QUERY = "(max-width: 900px)";
10
-
11
- /**
12
- * A list's filters: the inline <FilterBar> while it fits, the <FilterModal>
13
- * on a narrower screen. DataTable shows its filters through it; a list built
14
- * on its own uses it the same way.
15
- */
16
- export const Filters = (props: FilterBarProps) =>
17
- useMediaQuery(FILTER_COMPACT_QUERY) ? (
18
- <FilterModal {...props} />
19
- ) : (
20
- <FilterBar {...props} />
21
- );
1
+ import { useMediaQuery } from "../../../hooks/useMediaQuery";
2
+ import { FilterBar } from "./filter-bar";
3
+ import { FilterModal } from "./filter-modal";
4
+ import type { FilterBarProps } from "./types";
5
+
6
+ // Below this width an inline filter row can't fit its fields (matches the
7
+ // `--md` custom-media token in styles/media.css): the filters collapse into a
8
+ // single "Filters" button and a modal instead of stacking full-height.
9
+ const FILTER_COMPACT_QUERY = "(max-width: 900px)";
10
+
11
+ /**
12
+ * A list's filters: the inline <FilterBar> while it fits, the <FilterModal>
13
+ * on a narrower screen. DataTable shows its filters through it; a list built
14
+ * on its own uses it the same way.
15
+ */
16
+ export const Filters = (props: FilterBarProps) =>
17
+ useMediaQuery(FILTER_COMPACT_QUERY) ? (
18
+ <FilterModal {...props} />
19
+ ) : (
20
+ <FilterBar {...props} />
21
+ );
@@ -4,6 +4,7 @@ import {
4
4
  IconChevronsLeft,
5
5
  IconChevronsRight,
6
6
  } from "@tabler/icons-react";
7
+ import type { RowData } from "@tanstack/react-table";
7
8
  import clsx from "clsx";
8
9
  import { Button } from "../button/button";
9
10
  import { CustomSelect } from "../input-fields/custom-select/custom-select";
@@ -18,7 +19,7 @@ const getSkeletonWidth = (
18
19
  maxCharacters: number,
19
20
  ) => `${Math.min(maxCharacters, Math.max(minCharacters, characters))}ch`;
20
21
 
21
- export const PaginationBar = <TData,>({
22
+ export const PaginationBar = <TData extends RowData>({
22
23
  table,
23
24
  pagination,
24
25
  onPaginationChange,
@@ -32,6 +33,7 @@ export const PaginationBar = <TData,>({
32
33
  label,
33
34
  extra,
34
35
  }: PaginationBarProps<TData>) => {
36
+ // -1 while the total is unknown (server-side, nothing counted yet).
35
37
  const pageCount = table.getPageCount();
36
38
  const total = totalCount ?? table.getRowCount();
37
39
  const canPrev = table.getCanPreviousPage();
@@ -82,11 +84,7 @@ export const PaginationBar = <TData,>({
82
84
  .map((n) => ({ value: String(n), label: String(n) }));
83
85
 
84
86
  const resolvedPageCount =
85
- pageCount > 0
86
- ? pageCount
87
- : totalCount === undefined
88
- ? Math.max(1, pagination.pageIndex + 1)
89
- : Math.max(1, Math.ceil(totalCount / pagination.pageSize));
87
+ pageCount > 0 ? pageCount : Math.max(1, pagination.pageIndex + 1);
90
88
  const countSkeletonWidth = getSkeletonWidth(
91
89
  String(total).length +
92
90
  rowLabel.length +