@jielga/tmdatagrid 0.2.0 → 0.3.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 +397 -37
- package/dist/index.js +1550 -978
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/skills/columns/SKILL.md +1 -1
- package/skills/features/SKILL.md +1 -1
- package/skills/getting-started/SKILL.md +41 -6
- package/skills/options/SKILL.md +1 -1
- package/skills/server-side/SKILL.md +1 -1
- package/src/tmdatagrid/components/TMDataGrid.module.css +11 -1
- package/src/tmdatagrid/components/TMDataGrid.tsx +6 -0
- package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +7 -1
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +15 -0
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +87 -10
- package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
- package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +135 -0
- package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
- package/src/tmdatagrid/components/TMDataGridFooter.tsx +70 -42
- package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
- package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +147 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +6 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +77 -1
- package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +83 -6
- package/src/tmdatagrid/components/TMDataGridTable.module.css +38 -4
- package/src/tmdatagrid/components/TMDataGridTable.tsx +396 -32
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +3 -0
- package/src/tmdatagrid/components/icons.ts +5 -0
- package/src/tmdatagrid/core/capabilities.ts +81 -11
- package/src/tmdatagrid/core/filterOperators.ts +25 -0
- package/src/tmdatagrid/core/grouping.ts +21 -0
- package/src/tmdatagrid/core/persistence.ts +12 -0
- package/src/tmdatagrid/core/rowSelection.ts +202 -0
- package/src/tmdatagrid/index.ts +26 -2
- package/src/tmdatagrid/useTMDataGrid.tsx +240 -35
package/dist/styles.css
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
.
|
|
1
|
+
._root_13dg1_8[data-size=xs]{--dg-row-height:34px;--dg-header-height:32px;--dg-font-size:var(--mantine-font-size-xs);--dg-padding:6px}._root_13dg1_8[data-size=sm]{--dg-row-height:42px;--dg-header-height:38px;--dg-font-size:var(--mantine-font-size-sm);--dg-padding:8px}._root_13dg1_8[data-size=md]{--dg-row-height:52px;--dg-header-height:44px;--dg-font-size:var(--mantine-font-size-sm);--dg-padding:10px}._root_13dg1_8[data-size=lg]{--dg-row-height:62px;--dg-header-height:52px;--dg-font-size:var(--mantine-font-size-md);--dg-padding:14px}._root_13dg1_8[data-size=xl]{--dg-row-height:72px;--dg-header-height:60px;--dg-font-size:var(--mantine-font-size-lg);--dg-padding:18px}._root_13dg1_8{--dg-row-height:52px;--dg-header-height:44px;--dg-font-size:var(--mantine-font-size-sm);--dg-padding:10px;--dg-row-selected-bg:var(--mantine-primary-color-light);--dg-row-highlight-bg:var(--mantine-primary-color-light-hover);--dg-row-group-bg:var(--mantine-color-body);text-align:left;border:1px solid var(--mantine-color-default-border);border-radius:var(--mantine-radius-md);background-color:var(--mantine-color-body);flex-direction:column;min-height:0;display:flex;overflow:hidden}._columnsPanel_1492o_1{flex-direction:column;width:280px;display:flex}._columnsPanelSearch_1492o_7{padding:var(--mantine-spacing-sm)}._columnsPanelList_1492o_11{gap:var(--mantine-spacing-sm);padding:0 var(--mantine-spacing-sm) var(--mantine-spacing-sm);flex-direction:column;display:flex}._columnsPanelFooter_1492o_18{justify-content:space-between;align-items:center;gap:var(--mantine-spacing-sm);padding:var(--mantine-spacing-sm);border-top:1px solid var(--mantine-color-default-border);display:flex}._groupToggle_h06xv_4{width:100%;min-width:0;font-size:var(--dg-font-size);color:inherit;align-items:center;gap:6px;display:flex}._chevron_h06xv_14{color:var(--mantine-color-dimmed);flex:none;align-items:center;transition:transform .12s;display:flex}._chevron_h06xv_14[data-expanded=true]{transform:rotate(90deg)}@media (prefers-reduced-motion:reduce){._chevron_h06xv_14{transition:none}}._groupLabel_h06xv_34{white-space:nowrap;text-overflow:ellipsis;min-width:0;font-weight:600;overflow:hidden}._groupCount_h06xv_44{color:var(--mantine-color-dimmed);font-variant-numeric:tabular-nums;flex:none}._filterPanel_izakx_4{top:calc(var(--dg-header-height) + 2px);left:var(--mantine-spacing-md);z-index:5;padding:var(--mantine-spacing-sm);border-radius:var(--mantine-radius-md);border:1px solid var(--mantine-color-default-border);background-color:var(--mantine-color-body);box-shadow:var(--mantine-shadow-md);position:absolute}._filterRow_izakx_16{align-items:flex-end;gap:var(--mantine-spacing-xs);display:flex}._filterPanelHeader_izakx_22{justify-content:space-between;align-items:center;gap:var(--mantine-spacing-md);padding-bottom:var(--mantine-spacing-xs);display:flex}._filterPanelFooter_izakx_30{justify-content:space-between;align-items:center;gap:var(--mantine-spacing-md);display:flex}._filterPills_1yybb_3{align-items:center;gap:var(--mantine-spacing-xs);flex-wrap:wrap;display:flex}._filterPills_1yybb_3 ._pill_1yybb_15{background-color:var(--lightningcss-light,var(--mantine-color-gray-1))var(--lightningcss-dark,var(--mantine-color-dark-5));color:var(--lightningcss-light,var(--mantine-color-black))var(--lightningcss-dark,var(--mantine-color-dark-0));border:1px solid var(--lightningcss-light,var(--mantine-color-gray-3))var(--lightningcss-dark,var(--mantine-color-dark-3))}._pillLabel_1yybb_27{font:inherit;color:inherit;cursor:pointer}._pillLabel_1yybb_27:hover{text-decoration:underline}._footer_149ca_1{justify-content:flex-end;align-items:center;gap:var(--mantine-spacing-lg);padding:4px var(--mantine-spacing-sm);border-top:1px solid var(--mantine-color-default-border);flex-shrink:0;min-height:48px;display:flex}._footer_149ca_1[data-paging-suspended=true]{opacity:.55}._tableWrapper_chabz_2{flex-direction:column;flex:1;min-height:0;display:flex;position:relative}._scrollContainer_chabz_10{flex:1;min-height:0;position:relative;overflow:auto}._grid_chabz_18{display:grid}._headerRow_chabz_24{grid-column:1/-1;grid-template-columns:subgrid;z-index:3;display:grid;position:sticky;top:0}._bodyRow_chabz_33{--row-bg:var(--mantine-color-body);grid-column:1/-1;grid-template-columns:subgrid;align-items:stretch;display:grid}._bodyRow_chabz_33:hover{--row-bg:var(--mantine-color-default-hover)}._bodyRow_chabz_33[data-grouped=true]{--row-bg:var(--dg-row-group-bg)}._bodyRow_chabz_33[data-context-menu=true]{--row-bg:var(--mantine-color-default-hover)}._bodyRow_chabz_33[data-selected-bg=true]{--row-bg:var(--dg-row-selected-bg)}._bodyRow_chabz_33[data-highlighted=true]{--row-bg:var(--dg-row-highlight-bg)}._bodyRow_chabz_33[data-selects-on-click=true]{-webkit-user-select:none;user-select:none}._bodyRow_chabz_33:focus-visible{outline:2px solid var(--mantine-primary-color-filled);outline-offset:-2px}._bodyCell_chabz_91{padding:0 var(--dg-padding);border-bottom:1px solid var(--mantine-color-default-border);background-color:var(--row-bg);font-size:var(--dg-font-size);align-items:center;display:flex}._bodyCell_chabz_91[data-select-column=true]{padding:0}._cellContent_chabz_89{white-space:nowrap;text-overflow:ellipsis;min-width:0;overflow:hidden}._bodyCell_chabz_91[data-align=right]{justify-content:flex-end}._bodyCell_chabz_91[data-align=center]{justify-content:center}._bodyCell_chabz_91[data-align=left] ._cellContent_chabz_89{flex:auto}._messageRow_chabz_129{justify-content:center;align-items:center;gap:var(--mantine-spacing-xs);padding:var(--mantine-spacing-xl);flex-direction:column;grid-column:1/-1;display:flex}._stickyLeft_1iqrf_11{border-right:1px solid var(--mantine-color-default-border)}._stickyRight_1iqrf_15{border-left:1px solid var(--mantine-color-default-border)}._stickyLeft_1iqrf_11:after,._stickyRight_1iqrf_15:after{content:"";pointer-events:none;width:4px;position:absolute;top:0;bottom:0}._stickyLeft_1iqrf_11:after{background:linear-gradient(90deg,var(--lightningcss-light,#00000014)var(--lightningcss-dark,#00000047),#0000);left:100%}._stickyRight_1iqrf_15:after{background:linear-gradient(270deg,var(--lightningcss-light,#00000014)var(--lightningcss-dark,#00000047),#0000);right:100%}._headerCell_49xbo_1{padding:0 var(--dg-padding);min-width:0;min-height:var(--dg-header-height);font-weight:600;font-size:var(--dg-font-size);background-color:var(--mantine-color-body);border-bottom:1px solid var(--mantine-color-default-border);-webkit-user-select:none;user-select:none;align-items:center;gap:2px;display:flex;position:relative}._headerCell_49xbo_1[data-align=right]{justify-content:flex-end}._headerCell_49xbo_1[data-align=center]{justify-content:center}._headerCell_49xbo_1[data-select-column=true]{padding:0}._headerCell_49xbo_1[data-active=true] ._headerTitle_49xbo_31{color:var(--mantine-primary-color-filled)}._headerTitle_49xbo_31{text-overflow:ellipsis;white-space:nowrap;flex:auto;min-width:0;overflow:hidden}._headerCell_49xbo_1[data-align=right] ._headerTitle_49xbo_31{text-align:right}._headerCell_49xbo_1[data-align=center] ._headerTitle_49xbo_31{justify-content:center;display:flex}._headerActions_49xbo_54{flex-shrink:0;align-items:center;gap:0;margin-left:auto;display:flex}._headerCell_49xbo_1[data-align=right] ._headerActions_49xbo_54{order:-1;margin-left:0;margin-right:auto}._headerAction_49xbo_54{display:none}._headerCell_49xbo_1:hover ._headerAction_49xbo_54,._headerCell_49xbo_1:focus-within ._headerAction_49xbo_54,._headerAction_49xbo_54[data-pinned-visible=true]{display:flex}._headerCellSortable_49xbo_83{cursor:pointer}._headerCellDragging_49xbo_90{opacity:.4}._headerCellDropBefore_49xbo_94:before,._headerCellDropAfter_49xbo_95:before{content:"";background-color:var(--mantine-primary-color-filled);z-index:5;pointer-events:none;width:2px;position:absolute;top:0;bottom:0}._headerCellDropBefore_49xbo_94:before{left:0}._headerCellDropAfter_49xbo_95:before{right:0}._columnSeparator_49xbo_115{z-index:1;justify-content:center;width:10px;height:50%;display:flex;position:absolute;top:25%;right:0;transform:translate(50%)}._columnSeparatorResizable_49xbo_127{cursor:col-resize;touch-action:none}._columnSeparator_49xbo_115:after{content:"";background-color:#0000;width:1px;height:100%;transition:background-color .12s,width .12s}._headerCell_49xbo_1:hover ._columnSeparator_49xbo_115:after,._headerCell_49xbo_1:focus-within ._columnSeparator_49xbo_115:after{background-color:var(--mantine-color-default-border)}._columnSeparatorResizable_49xbo_127:hover:after,._columnSeparatorActive_49xbo_150:after{background-color:var(--mantine-primary-color-filled);width:2px}._menuItemActive_49xbo_156{background-color:var(--mantine-color-default-hover);font-weight:600}._toolbar_4my8t_1{align-items:center;gap:var(--mantine-spacing-xs);padding:6px var(--mantine-spacing-xs);flex-shrink:0;min-height:42px;display:flex}._toolbarSpacer_4my8t_10{flex:1}
|
|
2
2
|
/*$vite$:1*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jielga/tmdatagrid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A React data grid built on TanStack Table v9 and Mantine — always virtualized, with resizable, reorderable, sortable, filterable, hideable and pinnable columns.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/skills/columns/SKILL.md
CHANGED
package/skills/features/SKILL.md
CHANGED
|
@@ -4,13 +4,13 @@ description: >
|
|
|
4
4
|
Set up TMDataGrid, a compound React data grid built on TanStack Table v9 and
|
|
5
5
|
Mantine. Covers useTMDataGrid, the TMDataGrid root, context, the component
|
|
6
6
|
catalog (Table, Footer, Toolbar, Spacer, SummaryCount, FilterButton,
|
|
7
|
-
ColumnsButton, FilterPanel, ColumnsPanel), the size scale and the
|
|
7
|
+
ColumnsButton, FilterPanel, FilterPills, ColumnsPanel), the size scale and the
|
|
8
8
|
bounded-height layout requirement. Load when adding a grid, choosing which
|
|
9
9
|
parts to render, or when rows do not appear.
|
|
10
10
|
metadata:
|
|
11
11
|
type: core
|
|
12
12
|
library: '@jielga/tmdatagrid'
|
|
13
|
-
library_version: '0.
|
|
13
|
+
library_version: '0.3.0'
|
|
14
14
|
sources:
|
|
15
15
|
- 'Jielga/TMDataGrid:src/docs/getting-started.md'
|
|
16
16
|
- 'Jielga/TMDataGrid:src/docs/components.md'
|
|
@@ -123,21 +123,24 @@ takes a fixed pixel width once resized or pinned.
|
|
|
123
123
|
|
|
124
124
|
## Components
|
|
125
125
|
|
|
126
|
-
All read the grid from context and must be rendered inside `TMDataGrid
|
|
127
|
-
|
|
126
|
+
All read the grid from context and must be rendered inside `TMDataGrid`, except
|
|
127
|
+
`TMDataGrid.FilterPills`, which takes the grid as an `api` prop and can live
|
|
128
|
+
anywhere on the page. Order and presence are up to you — the root is a plain
|
|
129
|
+
flex column.
|
|
128
130
|
|
|
129
131
|
| Component | Props | Notes |
|
|
130
132
|
| --- | --- | --- |
|
|
131
133
|
| `TMDataGrid` | `table`, `ui`, `features`, `size`, `className`, `style` | Root. Provides context. `style` also takes CSS variables: `--dg-row-selected-bg`, `--dg-row-height`, `--dg-header-height`, `--dg-font-size`, `--dg-padding`. |
|
|
132
|
-
| `TMDataGrid.Table` | `onRowClick(row)` | Header, virtualized body, filter panel. `onRowClick` runs in addition to selection under `rowSelectionMode: "row"`. |
|
|
134
|
+
| `TMDataGrid.Table` | `onRowClick(row)`, `rowContextMenu` render prop, `rowContextMenuProps` | Header, virtualized body, filter panel. `onRowClick` runs in addition to selection under `rowSelectionMode: "row"`. |
|
|
133
135
|
| `TMDataGrid.Footer` | `pageSizeOptions` (default `[10, 25, 50, 100]`), `pagination` render prop | Pagination controls. Renders nothing unless pagination is enabled. |
|
|
134
136
|
| `TMDataGrid.Toolbar` | `children` | Flex row above the grid. |
|
|
135
137
|
| `TMDataGrid.Spacer` | — | Pushes later toolbar items right. |
|
|
136
138
|
| `TMDataGrid.SummaryCount` | `children` | Visible rows out of total. |
|
|
137
139
|
| `TMDataGrid.FilterButton` | — | Toggles filter panel. Renders nothing if no column is filterable. |
|
|
138
140
|
| `TMDataGrid.ColumnsButton` | — | Opens column manager. Renders nothing if no column is hideable. |
|
|
139
|
-
| `TMDataGrid.FilterPanel` | — | Rendered by `.Table`; exported for custom layouts. |
|
|
141
|
+
| `TMDataGrid.FilterPanel` | — | Rendered by `.Table`; exported for custom layouts. Header close button, Escape, click-away, "Add filter" and "Clear all". |
|
|
140
142
|
| `TMDataGrid.ColumnsPanel` | — | Rendered by `.ColumnsButton`; exported for custom layouts. |
|
|
143
|
+
| `TMDataGrid.FilterPills` | `api`, `size` (default `"sm"`), `showClearAll` (default `true`), `onPillClick(columnId)`, `className` | One pill per active filter, ✕ to clear it. Takes the api as a prop, so it can be rendered outside the grid. Also exported as `TMDataGridFilterPills`. |
|
|
141
144
|
|
|
142
145
|
Pass the row type so `onRowClick` stays typed:
|
|
143
146
|
|
|
@@ -145,6 +148,37 @@ Pass the row type so `onRowClick` stays typed:
|
|
|
145
148
|
<TMDataGrid.Table<Employee> onRowClick={(row) => open(row.original.id)} />
|
|
146
149
|
```
|
|
147
150
|
|
|
151
|
+
`rowContextMenu` fills the dropdown of a Mantine `Menu` the grid opens at the
|
|
152
|
+
pointer on a right-click (or a long press). The grid owns the `Menu` — opening,
|
|
153
|
+
positioning, and closing on Escape, outside click, body scroll or an item pick —
|
|
154
|
+
so the render prop only says what goes in it:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
<TMDataGrid.Table<Employee>
|
|
158
|
+
rowContextMenu={({ table, row, cell, close }) => (
|
|
159
|
+
<>
|
|
160
|
+
<Menu.Label>{row.original.firstName}</Menu.Label>
|
|
161
|
+
<Menu.Item onClick={() => open(row.original.id)}>Open</Menu.Item>
|
|
162
|
+
<Menu.Item
|
|
163
|
+
onClick={() =>
|
|
164
|
+
navigator.clipboard.writeText(String(cell?.getValue() ?? ""))
|
|
165
|
+
}
|
|
166
|
+
>
|
|
167
|
+
Copy cell value
|
|
168
|
+
</Menu.Item>
|
|
169
|
+
</>
|
|
170
|
+
)}
|
|
171
|
+
/>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`cell` is the one that was right-clicked, `table` is there for actions that read
|
|
175
|
+
the selection, and `close` is for dropdown content that is not a `Menu.Item`
|
|
176
|
+
(those close themselves). Return `null` to leave a row without a menu. It is
|
|
177
|
+
called during render, and only for the open row, so it must stay pure — put the
|
|
178
|
+
work in the item handlers. Right-clicking does not change the selection or the
|
|
179
|
+
highlight; the row carries `data-context-menu` while its menu is open. Pass
|
|
180
|
+
`rowContextMenuProps` for `width`, `shadow`, `position` and the rest.
|
|
181
|
+
|
|
148
182
|
`TMDataGrid.Footer` reads totals from `table.getRowCount()`, which prefers
|
|
149
183
|
`options.rowCount`. `TMDataGrid.SummaryCount` uses `meta.totalRowCount` when
|
|
150
184
|
provided, otherwise the pre-filtered row count.
|
|
@@ -194,6 +228,7 @@ mirrors them. Set `meta.rowHeight` for a height outside the scale.
|
|
|
194
228
|
| `openColumnFilter(api, columnId)` | Adds an empty filter for a column and opens the panel. |
|
|
195
229
|
| `getColumnLabel(column)` | `meta.label`, a string header, or the column id. |
|
|
196
230
|
| `getColumnType(column)` | `meta.type`, defaulting to `"string"`. |
|
|
231
|
+
| `formatFilterLabel({ label, type, filter })` | The one-line filter description used on the pills. |
|
|
197
232
|
| `moveColumn({ table, columnId, targetId, side })` | Moves a column next to another. See the `features` skill. |
|
|
198
233
|
| `moveColumnByStep({ table, columnId, direction })` | Moves a column one position within its region. |
|
|
199
234
|
| `SELECT_COLUMN_ID` | Id of the generated checkbox column. |
|
package/skills/options/SKILL.md
CHANGED
|
@@ -46,9 +46,19 @@
|
|
|
46
46
|
--dg-header-height: 44px;
|
|
47
47
|
--dg-font-size: var(--mantine-font-size-sm);
|
|
48
48
|
--dg-padding: 10px;
|
|
49
|
-
/* Background of a
|
|
49
|
+
/* Background of a selected row, when `showSelectedBackground` is on. Override
|
|
50
|
+
it on the grid:
|
|
50
51
|
<TMDataGrid style={{ "--dg-row-selected-bg": "var(--mantine-color-blue-0)" }} /> */
|
|
51
52
|
--dg-row-selected-bg: var(--mantine-primary-color-light);
|
|
53
|
+
/* Background of the highlighted row, under a selectionMode that has one.
|
|
54
|
+
Stronger than the selected background by default, since under
|
|
55
|
+
"checkboxAndHighlight" the two show up together. */
|
|
56
|
+
--dg-row-highlight-bg: var(--mantine-primary-color-light-hover);
|
|
57
|
+
/* Background of a group row. Transparent by default — the bold label and the
|
|
58
|
+
chevron are enough, and with the tree collapsed every row on screen is a
|
|
59
|
+
group row, so a tint would grey out the whole grid. Set it to give groups a
|
|
60
|
+
band of their own. */
|
|
61
|
+
--dg-row-group-bg: var(--mantine-color-body);
|
|
52
62
|
|
|
53
63
|
display: flex;
|
|
54
64
|
flex-direction: column;
|
|
@@ -4,6 +4,7 @@ import classes from "./TMDataGrid.module.css";
|
|
|
4
4
|
import { TMDataGridColumnsPanel } from "./TMDataGridColumnsPanel";
|
|
5
5
|
import { TMDataGridContextProvider } from "../TMDataGridContext";
|
|
6
6
|
import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
|
|
7
|
+
import { TMDataGridFilterPills } from "./TMDataGridFilterPills";
|
|
7
8
|
import { TMDataGridFooter } from "./TMDataGridFooter";
|
|
8
9
|
import { TMDataGridTable } from "./TMDataGridTable";
|
|
9
10
|
import {
|
|
@@ -101,6 +102,11 @@ export const TMDataGrid = Object.assign(TMDataGridRoot, {
|
|
|
101
102
|
Footer: TMDataGridFooter,
|
|
102
103
|
/** Rendered by `TMDataGrid.Table`; exported for custom layouts. */
|
|
103
104
|
FilterPanel: TMDataGridFilterPanel,
|
|
105
|
+
/**
|
|
106
|
+
* Takes the grid as an `api` prop rather than from context, so it can be
|
|
107
|
+
* rendered outside `<TMDataGrid>` — a page header, for instance.
|
|
108
|
+
*/
|
|
109
|
+
FilterPills: TMDataGridFilterPills,
|
|
104
110
|
/** Rendered by `TMDataGrid.ColumnsButton`; exported for custom layouts. */
|
|
105
111
|
ColumnsPanel: TMDataGridColumnsPanel,
|
|
106
112
|
});
|
|
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|
|
4
4
|
import classes from "./TMDataGridColumnsPanel.module.css";
|
|
5
5
|
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
6
6
|
import { getColumnLabel } from "../core/columnUtils";
|
|
7
|
+
import { GROUP_COLUMN_ID } from "./TMDataGridGroupColumn";
|
|
7
8
|
import { SearchIcon } from "./icons";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -20,7 +21,12 @@ export function TMDataGridColumnsPanel() {
|
|
|
20
21
|
(state) => state.columnVisibility,
|
|
21
22
|
);
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// The tree column is left out rather than listed and disabled: its visibility
|
|
25
|
+
// is not a setting at all, it follows the grouping state, so an unchecked box
|
|
26
|
+
// that cannot be ticked would only invite the question.
|
|
27
|
+
const columns = table
|
|
28
|
+
.getAllLeafColumns()
|
|
29
|
+
.filter((column) => column.id !== GROUP_COLUMN_ID);
|
|
24
30
|
const needle = search.trim().toLowerCase();
|
|
25
31
|
const visibleInPanel = needle
|
|
26
32
|
? columns.filter((column) =>
|
|
@@ -18,3 +18,18 @@
|
|
|
18
18
|
align-items: flex-end;
|
|
19
19
|
gap: var(--mantine-spacing-xs);
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
.filterPanelHeader {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
gap: var(--mantine-spacing-md);
|
|
27
|
+
padding-bottom: var(--mantine-spacing-xs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.filterPanelFooter {
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
justify-content: space-between;
|
|
34
|
+
gap: var(--mantine-spacing-md);
|
|
35
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { ActionIcon, Button, Select, Stack, TextInput } from "@mantine/core";
|
|
1
|
+
import { ActionIcon, Button, Select, Stack, Text, TextInput } from "@mantine/core";
|
|
2
2
|
import { useSelector } from "@tanstack/react-store";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
3
4
|
import classes from "./TMDataGridFilterPanel.module.css";
|
|
4
5
|
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
5
6
|
import { getColumnLabel, getColumnType } from "../core/columnUtils";
|
|
@@ -33,6 +34,27 @@ export function TMDataGridFilterPanel() {
|
|
|
33
34
|
table.store,
|
|
34
35
|
(state) => state.columnFilters,
|
|
35
36
|
);
|
|
37
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
|
|
39
|
+
// Clicking away hides the panel, the way any floating surface behaves. On
|
|
40
|
+
// pointerdown rather than click, so a press that starts outside dismisses it
|
|
41
|
+
// even when the pointer is released somewhere else.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!opened) return;
|
|
44
|
+
|
|
45
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
46
|
+
const target = event.target;
|
|
47
|
+
if (!(target instanceof Element)) return;
|
|
48
|
+
if (panelRef.current?.contains(target)) return;
|
|
49
|
+
// The toolbar button toggles the panel itself. Closing from here first
|
|
50
|
+
// would leave its click reopening what the user meant to close.
|
|
51
|
+
if (target.closest("[data-dg-filter-toggle]")) return;
|
|
52
|
+
ui.actions.closeFilterPanel();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
56
|
+
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
|
57
|
+
}, [opened, ui]);
|
|
36
58
|
|
|
37
59
|
if (!opened) return null;
|
|
38
60
|
|
|
@@ -86,6 +108,14 @@ export function TMDataGridFilterPanel() {
|
|
|
86
108
|
);
|
|
87
109
|
}
|
|
88
110
|
|
|
111
|
+
/** Clears every filter, including half-typed ones the pills never showed. */
|
|
112
|
+
function clearAllFilters() {
|
|
113
|
+
table.setColumnFilters([]);
|
|
114
|
+
// Same exit as removing the last filter row by hand: an empty panel has
|
|
115
|
+
// nothing to show but its own buttons.
|
|
116
|
+
ui.actions.closeFilterPanel();
|
|
117
|
+
}
|
|
118
|
+
|
|
89
119
|
function addFilter() {
|
|
90
120
|
const nextColumn = filterableColumns.find(
|
|
91
121
|
(column) => !columnFilters.some((filter) => filter.id === column.id),
|
|
@@ -103,8 +133,39 @@ export function TMDataGridFilterPanel() {
|
|
|
103
133
|
]);
|
|
104
134
|
}
|
|
105
135
|
|
|
136
|
+
const canAddFilter = filterableColumns.some(
|
|
137
|
+
(column) => !columnFilters.some((filter) => filter.id === column.id),
|
|
138
|
+
);
|
|
139
|
+
|
|
106
140
|
return (
|
|
107
|
-
<div
|
|
141
|
+
<div
|
|
142
|
+
ref={panelRef}
|
|
143
|
+
role="group"
|
|
144
|
+
aria-label="Filters"
|
|
145
|
+
className={classes.filterPanel}
|
|
146
|
+
// Escape is what closes a floating surface, and every control that can
|
|
147
|
+
// hold focus in here sits inside this element.
|
|
148
|
+
onKeyDown={(event) => {
|
|
149
|
+
if (event.key !== "Escape") return;
|
|
150
|
+
event.stopPropagation();
|
|
151
|
+
ui.actions.closeFilterPanel();
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<div className={classes.filterPanelHeader}>
|
|
155
|
+
<Text size={controlSize} fw={600}>
|
|
156
|
+
Filters
|
|
157
|
+
</Text>
|
|
158
|
+
<ActionIcon
|
|
159
|
+
variant="subtle"
|
|
160
|
+
color="gray"
|
|
161
|
+
size="sm"
|
|
162
|
+
aria-label="Close filters"
|
|
163
|
+
onClick={ui.actions.closeFilterPanel}
|
|
164
|
+
>
|
|
165
|
+
<CloseIcon size={16} stroke={1.6} />
|
|
166
|
+
</ActionIcon>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
108
169
|
<Stack gap="xs">
|
|
109
170
|
{columnFilters.map((filter) => {
|
|
110
171
|
const column = table.getColumn(filter.id);
|
|
@@ -129,6 +190,10 @@ export function TMDataGridFilterPanel() {
|
|
|
129
190
|
size={controlSize}
|
|
130
191
|
w={160}
|
|
131
192
|
allowDeselect={false}
|
|
193
|
+
// Both dropdowns render inside the panel: a portalled one is
|
|
194
|
+
// outside it in the DOM, and picking an option would read as a
|
|
195
|
+
// click away and close the panel under the user.
|
|
196
|
+
comboboxProps={{ withinPortal: false }}
|
|
132
197
|
data={columnOptions}
|
|
133
198
|
value={filter.id}
|
|
134
199
|
onChange={(next) => next && changeFilterColumn(filter.id, next)}
|
|
@@ -139,6 +204,7 @@ export function TMDataGridFilterPanel() {
|
|
|
139
204
|
size={controlSize}
|
|
140
205
|
w={170}
|
|
141
206
|
allowDeselect={false}
|
|
207
|
+
comboboxProps={{ withinPortal: false }}
|
|
142
208
|
data={getOperatorsForType(type).map((operator) => ({
|
|
143
209
|
value: operator,
|
|
144
210
|
label: FILTER_OPERATOR_LABELS[operator],
|
|
@@ -168,14 +234,25 @@ export function TMDataGridFilterPanel() {
|
|
|
168
234
|
);
|
|
169
235
|
})}
|
|
170
236
|
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
237
|
+
<div className={classes.filterPanelFooter}>
|
|
238
|
+
<Button
|
|
239
|
+
variant="subtle"
|
|
240
|
+
size="compact-sm"
|
|
241
|
+
disabled={!canAddFilter}
|
|
242
|
+
onClick={addFilter}
|
|
243
|
+
>
|
|
244
|
+
Add filter
|
|
245
|
+
</Button>
|
|
246
|
+
<Button
|
|
247
|
+
variant="subtle"
|
|
248
|
+
color="gray"
|
|
249
|
+
size="compact-sm"
|
|
250
|
+
disabled={columnFilters.length === 0}
|
|
251
|
+
onClick={clearAllFilters}
|
|
252
|
+
>
|
|
253
|
+
Clear all
|
|
254
|
+
</Button>
|
|
255
|
+
</div>
|
|
179
256
|
</Stack>
|
|
180
257
|
</div>
|
|
181
258
|
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* Wraps rather than scrolls: the pills are a summary, and a summary that goes
|
|
2
|
+
off-screen is not one. */
|
|
3
|
+
.filterPills {
|
|
4
|
+
display: flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
flex-wrap: wrap;
|
|
7
|
+
gap: var(--mantine-spacing-xs);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Mantine's dark-scheme pill is `dark-7` — the same colour as the body — so a
|
|
11
|
+
pill sitting on the page rather than inside a `PillsInput` disappears. Both
|
|
12
|
+
schemes are restated here so the two stay a pair, with a border to keep the
|
|
13
|
+
shape readable against any surface. Scoped through `.filterPills` for the
|
|
14
|
+
specificity to beat Mantine's own rule whichever stylesheet loads last. */
|
|
15
|
+
.filterPills .pill {
|
|
16
|
+
background-color: light-dark(
|
|
17
|
+
var(--mantine-color-gray-1),
|
|
18
|
+
var(--mantine-color-dark-5)
|
|
19
|
+
);
|
|
20
|
+
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
|
21
|
+
border: 1px solid
|
|
22
|
+
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* The label is a button so a pill can be clicked back open, but it must read as
|
|
26
|
+
part of the pill — Mantine's own label styles carry the font. */
|
|
27
|
+
.pillLabel {
|
|
28
|
+
font: inherit;
|
|
29
|
+
color: inherit;
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.pillLabel:hover {
|
|
34
|
+
text-decoration: underline;
|
|
35
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Button, Pill, UnstyledButton } from "@mantine/core";
|
|
2
|
+
import type { RowData } from "@tanstack/react-table";
|
|
3
|
+
import { useSelector } from "@tanstack/react-store";
|
|
4
|
+
import classes from "./TMDataGridFilterPills.module.css";
|
|
5
|
+
import { getColumnLabel, getColumnType } from "../core/columnUtils";
|
|
6
|
+
import {
|
|
7
|
+
formatFilterLabel,
|
|
8
|
+
isFilterActive,
|
|
9
|
+
isTMDataGridFilterValue,
|
|
10
|
+
} from "../core/filterOperators";
|
|
11
|
+
import type { TMDataGridSize } from "../core/sizes";
|
|
12
|
+
import { openColumnFilter, type TMDataGridApi } from "../useTMDataGrid";
|
|
13
|
+
|
|
14
|
+
export type TMDataGridFilterPillsProps<TData extends RowData> = {
|
|
15
|
+
/** The object returned by `useTMDataGrid`. */
|
|
16
|
+
api: TMDataGridApi<TData>;
|
|
17
|
+
/** Mantine size of the pills. Defaults to `"sm"`. */
|
|
18
|
+
size?: TMDataGridSize;
|
|
19
|
+
/** "Clear all" button, shown once two filters are active. Defaults to `true`. */
|
|
20
|
+
showClearAll?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Replaces what a click on a pill's label does. The default opens the grid's
|
|
23
|
+
* filter panel on that column.
|
|
24
|
+
*/
|
|
25
|
+
onPillClick?: (columnId: string) => void;
|
|
26
|
+
className?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One pill per active filter — `First name: Sofia ✕` — with the ✕ clearing that
|
|
31
|
+
* filter and a click on the label opening the filter panel on its column.
|
|
32
|
+
*
|
|
33
|
+
* The one grid component that takes the api as a prop instead of reading
|
|
34
|
+
* context, so it can live anywhere: a page header, a card title, a breadcrumb
|
|
35
|
+
* row. Renders nothing while no filter is active.
|
|
36
|
+
*
|
|
37
|
+
* ```tsx
|
|
38
|
+
* const grid = useTMDataGrid({ data, columns });
|
|
39
|
+
*
|
|
40
|
+
* <Group>
|
|
41
|
+
* <Title order={3}>Employees</Title>
|
|
42
|
+
* <TMDataGridFilterPills api={grid} />
|
|
43
|
+
* </Group>
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* Half-typed filters are left out: a filter that is not narrowing the rows yet
|
|
47
|
+
* has nothing to report, which is the same rule the funnel indicator uses.
|
|
48
|
+
*/
|
|
49
|
+
export function TMDataGridFilterPills<TData extends RowData>({
|
|
50
|
+
api,
|
|
51
|
+
size = "sm",
|
|
52
|
+
showClearAll = true,
|
|
53
|
+
onPillClick,
|
|
54
|
+
className,
|
|
55
|
+
}: TMDataGridFilterPillsProps<TData>) {
|
|
56
|
+
const { table } = api;
|
|
57
|
+
const columnFilters = useSelector(
|
|
58
|
+
table.store,
|
|
59
|
+
(state) => state.columnFilters,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const activeFilters = columnFilters.filter((filter) =>
|
|
63
|
+
isFilterActive(filter.value),
|
|
64
|
+
);
|
|
65
|
+
if (activeFilters.length === 0) return null;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
role="group"
|
|
70
|
+
aria-label="Active filters"
|
|
71
|
+
className={[classes.filterPills, className].filter(Boolean).join(" ")}
|
|
72
|
+
>
|
|
73
|
+
<Pill.Group size={size}>
|
|
74
|
+
{activeFilters.map((filter) => {
|
|
75
|
+
const column = table.getColumn(filter.id);
|
|
76
|
+
const label = column ? getColumnLabel(column) : filter.id;
|
|
77
|
+
const value = filter.value;
|
|
78
|
+
// isFilterActive already vouched for the shape; this narrows it.
|
|
79
|
+
if (!isTMDataGridFilterValue(value)) return null;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Pill
|
|
83
|
+
key={filter.id}
|
|
84
|
+
size={size}
|
|
85
|
+
className={classes.pill}
|
|
86
|
+
withRemoveButton
|
|
87
|
+
onRemove={() =>
|
|
88
|
+
table.setColumnFilters(
|
|
89
|
+
columnFilters.filter((other) => other.id !== filter.id),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
// Mantine hides its remove button from assistive tech and from
|
|
93
|
+
// the tab order, because inside a `PillsInput` Backspace is the
|
|
94
|
+
// way out. These pills stand on their own, so the ✕ has to be a
|
|
95
|
+
// real button.
|
|
96
|
+
removeButtonProps={{
|
|
97
|
+
"aria-label": `Clear ${label} filter`,
|
|
98
|
+
"aria-hidden": false,
|
|
99
|
+
tabIndex: 0,
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<UnstyledButton
|
|
103
|
+
className={classes.pillLabel}
|
|
104
|
+
onClick={() =>
|
|
105
|
+
onPillClick
|
|
106
|
+
? onPillClick(filter.id)
|
|
107
|
+
: openColumnFilter(api, filter.id)
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
{formatFilterLabel({
|
|
111
|
+
label,
|
|
112
|
+
type: column ? getColumnType(column) : "string",
|
|
113
|
+
filter: value,
|
|
114
|
+
})}
|
|
115
|
+
</UnstyledButton>
|
|
116
|
+
</Pill>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</Pill.Group>
|
|
120
|
+
|
|
121
|
+
{showClearAll && activeFilters.length > 1 && (
|
|
122
|
+
// Below two pills their own ✕ is the shorter path, so the button would
|
|
123
|
+
// only be a second way to do the same thing.
|
|
124
|
+
<Button
|
|
125
|
+
variant="subtle"
|
|
126
|
+
color="gray"
|
|
127
|
+
size="compact-xs"
|
|
128
|
+
onClick={() => table.setColumnFilters([])}
|
|
129
|
+
>
|
|
130
|
+
Clear all
|
|
131
|
+
</Button>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -8,3 +8,11 @@
|
|
|
8
8
|
flex-shrink: 0;
|
|
9
9
|
border-top: 1px solid var(--mantine-color-default-border);
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/* Grouping suspends paging. The pager stays put and fades rather than
|
|
13
|
+
disappearing, so the footer reads as switched off rather than as having
|
|
14
|
+
vanished — and the row it holds does not change height under the grid. Its
|
|
15
|
+
controls carry their own `disabled`; this is the label half. */
|
|
16
|
+
.footer[data-paging-suspended="true"] {
|
|
17
|
+
opacity: 0.55;
|
|
18
|
+
}
|