compote-ui 0.79.2 → 0.80.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 (58) hide show
  1. package/dist/components/data-table-v9/column-filter-editing.svelte.d.ts +41 -0
  2. package/dist/components/data-table-v9/column-filter-editing.svelte.js +88 -0
  3. package/dist/components/data-table-v9/column-filter-kind.d.ts +10 -0
  4. package/dist/components/data-table-v9/column-filter-kind.js +25 -0
  5. package/dist/components/data-table-v9/create-table.svelte.js +37 -25
  6. package/dist/components/data-table-v9/data-table-head.svelte +230 -201
  7. package/dist/components/data-table-v9/data-table-head.svelte.d.ts +2 -0
  8. package/dist/components/data-table-v9/data-table-utils.d.ts +7 -0
  9. package/dist/components/data-table-v9/data-table-utils.js +4 -0
  10. package/dist/components/data-table-v9/data-table.svelte +154 -151
  11. package/dist/components/data-table-v9/data-table.svelte.d.ts +2 -0
  12. package/dist/components/data-table-v9/date-cell.d.ts +13 -0
  13. package/dist/components/data-table-v9/date-cell.js +38 -0
  14. package/dist/components/data-table-v9/filter-editors/boolean-filter-editor.svelte +45 -0
  15. package/dist/components/data-table-v9/filter-editors/boolean-filter-editor.svelte.d.ts +7 -0
  16. package/dist/components/data-table-v9/filter-editors/column-filter-editor.svelte +33 -0
  17. package/dist/components/data-table-v9/filter-editors/column-filter-editor.svelte.d.ts +30 -0
  18. package/dist/components/data-table-v9/filter-editors/date-range-filter-editor.svelte +52 -0
  19. package/dist/components/data-table-v9/filter-editors/date-range-filter-editor.svelte.d.ts +28 -0
  20. package/dist/components/data-table-v9/filter-editors/range-filter-editor.svelte +51 -0
  21. package/dist/components/data-table-v9/filter-editors/range-filter-editor.svelte.d.ts +30 -0
  22. package/dist/components/data-table-v9/filter-editors/select-filter-editor.svelte +59 -0
  23. package/dist/components/data-table-v9/filter-editors/select-filter-editor.svelte.d.ts +28 -0
  24. package/dist/components/data-table-v9/filter-editors/text-filter-editor.svelte +20 -0
  25. package/dist/components/data-table-v9/filter-editors/text-filter-editor.svelte.d.ts +8 -0
  26. package/dist/components/data-table-v9/header/column-header-filter.svelte +66 -0
  27. package/dist/components/data-table-v9/header/column-header-filter.svelte.d.ts +30 -0
  28. package/dist/components/data-table-v9/table-view-state.svelte.d.ts +1 -0
  29. package/dist/components/data-table-v9/table-view-state.svelte.js +3 -0
  30. package/dist/components/data-table-v9/toolbar/data-table-column-filter.svelte +203 -459
  31. package/dist/components/data-table-v9/toolbar/data-table-column-filter.svelte.d.ts +1 -1
  32. package/dist/components/data-table-v9/virtual/data-table-virtualized.svelte +82 -79
  33. package/dist/components/data-table-v9/virtual/data-table-virtualized.svelte.d.ts +2 -0
  34. package/dist/components/dialog/alert-dialog.svelte +73 -71
  35. package/dist/components/dialog/dialog-root.svelte +45 -43
  36. package/dist/components/drawer/drawer-content.svelte +213 -211
  37. package/dist/components/popover/popover-content.svelte +97 -71
  38. package/dist/components/popover/popover-content.svelte.d.ts +2 -1
  39. package/dist/components/popover/popover-description.svelte +19 -15
  40. package/dist/components/popover/popover-labels.svelte.d.ts +18 -0
  41. package/dist/components/popover/popover-labels.svelte.js +35 -0
  42. package/dist/components/popover/popover-root.svelte +27 -24
  43. package/dist/components/popover/popover-title.svelte +19 -15
  44. package/dist/icons/PhFunnelFill.svelte +19 -0
  45. package/dist/icons/PhFunnelFill.svelte.d.ts +5 -0
  46. package/dist/icons/index.d.ts +1 -0
  47. package/dist/icons/index.js +1 -0
  48. package/dist/utils/date-filter.d.ts +31 -0
  49. package/dist/utils/date-filter.js +58 -0
  50. package/dist/utils/date.d.ts +19 -1
  51. package/dist/utils/date.js +48 -1
  52. package/dist/utils/release-focus-on-exit.d.ts +22 -0
  53. package/dist/utils/release-focus-on-exit.js +26 -0
  54. package/package.json +1 -1
  55. package/skills/component-usage/references/overlays-and-floating-ui.md +278 -273
  56. package/skills/data-table/SKILL.md +314 -283
  57. package/skills/data-table/references/column-options.md +175 -150
  58. package/skills/data-table/references/table-state-and-reactivity.md +65 -60
@@ -1,201 +1,230 @@
1
- <script lang="ts" generics="T extends RowData">
2
- import type { HeaderGroup, RowData } from '@tanstack/svelte-table';
3
- import { FlexRender } from '@tanstack/svelte-table';
4
- import { cn } from 'tailwind-variants';
5
- import { PhCaretDown, PhCaretUp } from '../../icons';
6
- import type { DataTableInstance } from './data-table-utils';
7
- import type { DataTableViewState } from './table-view-state.svelte';
8
- import type { DataTableFeatures } from './features';
9
- import {
10
- alignClass,
11
- getColumnMeta,
12
- getGroupPinningStyle,
13
- getHeaderAriaSort,
14
- getHeaderSortLabel,
15
- getPinningStyle,
16
- joinStyles,
17
- justifyClass,
18
- resizeHandleClass,
19
- resizeHandleStyle,
20
- sortButtonDirectionClass,
21
- virtualColumnSizeStyle,
22
- virtualGroupWithGrowSizeStyle,
23
- virtualGrowColumnSizeStyle,
24
- virtualSelectionColumnSizeStyle
25
- } from './data-table-utils';
26
-
27
- type Props = {
28
- table: DataTableInstance<T>;
29
- view: DataTableViewState<T>;
30
- isVirtual?: boolean;
31
- hasGrowColumn?: boolean;
32
- };
33
-
34
- let { table, view, isVirtual = false, hasGrowColumn = false }: Props = $props();
35
-
36
- type Header = HeaderGroup<DataTableFeatures, T>['headers'][number];
37
-
38
- function findGrowLeafHeader(header: Header): Header | undefined {
39
- if (header.subHeaders.length === 0) {
40
- return getColumnMeta(header.column.columnDef)?.grow ? header : undefined;
41
- }
42
- for (const sub of header.subHeaders) {
43
- const found = findGrowLeafHeader(sub);
44
- if (found) return found;
45
- }
46
- return undefined;
47
- }
48
-
49
- function headerSortDirection(header: Header) {
50
- void view.sorting;
51
- return header.column.getIsSorted();
52
- }
53
-
54
- function headerCellStyle(header: Header) {
55
- const canPinHeader = header.subHeaders.length === 0;
56
-
57
- let virtualSizeStyle: string | undefined;
58
- if (isVirtual) {
59
- void view.columnSizing;
60
- const isGrowLeaf = canPinHeader && getColumnMeta(header.column.columnDef)?.grow;
61
- if (isGrowLeaf) {
62
- virtualSizeStyle = virtualGrowColumnSizeStyle();
63
- } else {
64
- const growLeaf = findGrowLeafHeader(header);
65
- virtualSizeStyle = growLeaf
66
- ? virtualGroupWithGrowSizeStyle(header.getSize() - growLeaf.getSize())
67
- : virtualColumnSizeStyle(header.getSize());
68
- }
69
- }
70
-
71
- return joinStyles(
72
- virtualSizeStyle,
73
- canPinHeader
74
- ? getPinningStyle(header.column, table, view, true, view.isRowSelectionEnabled)
75
- : getGroupPinningStyle(
76
- header,
77
- view.getHeaderSection(header),
78
- view,
79
- view.isRowSelectionEnabled
80
- )
81
- );
82
- }
83
-
84
- function selectionHeaderStyle() {
85
- return isVirtual
86
- ? joinStyles(
87
- virtualSelectionColumnSizeStyle(),
88
- 'min-width: 40px',
89
- 'max-width: 40px',
90
- 'position: sticky',
91
- 'left: 0',
92
- 'z-index: 30'
93
- )
94
- : 'width: 40px; min-width: 40px; max-width: 40px; position: sticky; left: 0; z-index: 15';
95
- }
96
-
97
- function headerRowStyle() {
98
- if (!isVirtual) return undefined;
99
- return joinStyles('display: flex', 'width: 100%');
100
- }
101
-
102
- function shouldRenderSelectionCheckbox(headerGroupIndex: number) {
103
- if (!view.isMultiRowSelectionEnabled) return false;
104
- return headerGroupIndex === view.headerGroups.length - 1;
105
- }
106
- </script>
107
-
108
- <thead
109
- class="sticky top-0 z-20 bg-surface-2 text-left text-ink-dim"
110
- style={isVirtual ? 'display: grid; position: sticky; top: 0; z-index: 20' : undefined}
111
- >
112
- {#each view.headerGroups as headerGroup, headerGroupIndex (`${headerGroup.id}:${headerGroup.headers.map((header) => `${header.id}:${header.colSpan}`).join('|')}`)}
113
- {@const visibleHeaders = headerGroup.headers.filter((header) => header.colSpan > 0)}
114
- <tr class="h-9" style={headerRowStyle()}>
115
- {#if view.isRowSelectionEnabled}
116
- <th
117
- scope="col"
118
- class={cn(
119
- 'h-9 border-b border-surface-3 bg-surface-2 px-3 py-0 text-center align-middle leading-5 font-medium',
120
- isVirtual && 'items-center justify-center'
121
- )}
122
- style={selectionHeaderStyle()}
123
- >
124
- {#if shouldRenderSelectionCheckbox(headerGroupIndex)}
125
- <input
126
- type="checkbox"
127
- aria-label="Select all rows"
128
- class="table-checkbox mx-auto block size-4"
129
- checked={view.allRowsSelectionState === true}
130
- indeterminate={view.allRowsSelectionState === 'indeterminate'}
131
- onchange={(e) => table.toggleAllRowsSelected(e.currentTarget.checked)}
132
- />
133
- {/if}
134
- </th>
135
- {/if}
136
- {#each visibleHeaders as header, headerIndex (`${header.id}:${view.getHeaderSection(header) ?? ''}:${header.colSpan}:${header.column.getIsVisible()}`)}
137
- {@const meta = getColumnMeta(header.column.columnDef)}
138
- {@const sortDirection = headerSortDirection(header)}
139
- {@const continuesInNextFragment =
140
- visibleHeaders[headerIndex + 1]?.column.id === header.column.id}
141
- <th
142
- scope="col"
143
- class={cn(
144
- 'relative h-9 border-b border-surface-3 bg-surface-2 px-3 py-0 align-middle leading-5 font-medium',
145
- isVirtual && 'items-center',
146
- isVirtual && justifyClass(meta?.align),
147
- alignClass(meta?.align)
148
- )}
149
- colspan={header.colSpan}
150
- aria-sort={header.column.getCanSort() ? getHeaderAriaSort(sortDirection) : undefined}
151
- style={headerCellStyle(header)}
152
- >
153
- {#if !header.isPlaceholder}
154
- {#if header.column.getCanSort()}
155
- <button
156
- type="button"
157
- class={cn(
158
- 'inline-flex max-w-full appearance-none items-center gap-1 rounded-sm border-0 bg-transparent p-0 align-middle text-sm leading-5 text-inherit outline-none hover:text-ink data-focus-visible:outline-2 data-focus-visible:outline-offset-2 data-focus-visible:outline-ring',
159
- justifyClass(meta?.align),
160
- sortButtonDirectionClass(meta?.align)
161
- )}
162
- aria-label={`${getHeaderSortLabel(sortDirection)}. Toggle sorting.`}
163
- onclick={header.column.getToggleSortingHandler()}
164
- >
165
- <span class="min-w-0 truncate">
166
- <FlexRender {header} />
167
- </span>
168
- <span
169
- class="inline-flex size-3.5 shrink-0 items-center justify-center text-ink-dim"
170
- >
171
- {#if sortDirection === 'asc'}
172
- <PhCaretUp class="size-3.5" />
173
- {:else if sortDirection === 'desc'}
174
- <PhCaretDown class="size-3.5" />
175
- {/if}
176
- </span>
177
- </button>
178
- {:else}
179
- <FlexRender {header} />
180
- {/if}
181
- {/if}
182
- <!-- A group split by pinning renders as adjacent fragments; skip the
183
- divider between them so the group reads as one continuous header. -->
184
- {#if header.column.getCanResize() && !continuesInNextFragment}
185
- <div
186
- aria-hidden="true"
187
- class={resizeHandleClass(headerIndex, visibleHeaders.length)}
188
- style={resizeHandleStyle(table, header, view.columnResizing)}
189
- ondblclick={() => header.column.resetSize()}
190
- onmousedown={header.getResizeHandler()}
191
- ontouchstart={header.getResizeHandler()}
192
- ></div>
193
- {/if}
194
- </th>
195
- {/each}
196
- {#if !isVirtual && !hasGrowColumn}
197
- <th aria-hidden="true" class="h-9 border-b border-surface-3 bg-surface-2 p-0"></th>
198
- {/if}
199
- </tr>
200
- {/each}
201
- </thead>
1
+ <script lang="ts" generics="T extends RowData">
2
+ import type { HeaderGroup, RowData } from '@tanstack/svelte-table';
3
+ import { FlexRender } from '@tanstack/svelte-table';
4
+ import { cn } from 'tailwind-variants';
5
+ import { PhCaretDown, PhCaretUp } from '../../icons';
6
+ import type { DataTableInstance } from './data-table-utils';
7
+ import type { DataTableViewState } from './table-view-state.svelte';
8
+ import type { DataTableFeatures } from './features';
9
+ import type { DataTableAlign } from './types';
10
+ import ColumnHeaderFilter from './header/column-header-filter.svelte';
11
+ import {
12
+ alignClass,
13
+ getColumnMeta,
14
+ getGroupPinningStyle,
15
+ getHeaderAriaSort,
16
+ getHeaderSortLabel,
17
+ getPinningStyle,
18
+ joinStyles,
19
+ justifyClass,
20
+ resizeHandleClass,
21
+ resizeHandleStyle,
22
+ sortButtonDirectionClass,
23
+ virtualColumnSizeStyle,
24
+ virtualGroupWithGrowSizeStyle,
25
+ virtualGrowColumnSizeStyle,
26
+ virtualSelectionColumnSizeStyle
27
+ } from './data-table-utils';
28
+
29
+ type Props = {
30
+ table: DataTableInstance<T>;
31
+ view: DataTableViewState<T>;
32
+ isVirtual?: boolean;
33
+ hasGrowColumn?: boolean;
34
+ /** Show a filter funnel in the header of every filterable column. */
35
+ headerFilters?: boolean;
36
+ };
37
+
38
+ let {
39
+ table,
40
+ view,
41
+ isVirtual = false,
42
+ hasGrowColumn = false,
43
+ headerFilters = false
44
+ }: Props = $props();
45
+
46
+ type Header = HeaderGroup<DataTableFeatures, T>['headers'][number];
47
+
48
+ function findGrowLeafHeader(header: Header): Header | undefined {
49
+ if (header.subHeaders.length === 0) {
50
+ return getColumnMeta(header.column.columnDef)?.grow ? header : undefined;
51
+ }
52
+ for (const sub of header.subHeaders) {
53
+ const found = findGrowLeafHeader(sub);
54
+ if (found) return found;
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ function showHeaderFilter(header: Header) {
60
+ return headerFilters && header.subHeaders.length === 0 && header.column.getCanFilter();
61
+ }
62
+
63
+ /**
64
+ * Padding that keeps an active (always visible) filter funnel off the label, so it never
65
+ * hides the sort indicator. The funnel sits on the inner edge opposite the label.
66
+ */
67
+ function headerFilterPaddingClass(header: Header, align: DataTableAlign | undefined) {
68
+ if (!showHeaderFilter(header)) return undefined;
69
+ const filtered = view.columnFilters.some((filter) => filter.id === header.column.id);
70
+ if (!filtered) return undefined;
71
+ return align === 'right' ? 'pl-6' : 'pr-6';
72
+ }
73
+
74
+ function headerSortDirection(header: Header) {
75
+ void view.sorting;
76
+ return header.column.getIsSorted();
77
+ }
78
+
79
+ function headerCellStyle(header: Header) {
80
+ const canPinHeader = header.subHeaders.length === 0;
81
+
82
+ let virtualSizeStyle: string | undefined;
83
+ if (isVirtual) {
84
+ void view.columnSizing;
85
+ const isGrowLeaf = canPinHeader && getColumnMeta(header.column.columnDef)?.grow;
86
+ if (isGrowLeaf) {
87
+ virtualSizeStyle = virtualGrowColumnSizeStyle();
88
+ } else {
89
+ const growLeaf = findGrowLeafHeader(header);
90
+ virtualSizeStyle = growLeaf
91
+ ? virtualGroupWithGrowSizeStyle(header.getSize() - growLeaf.getSize())
92
+ : virtualColumnSizeStyle(header.getSize());
93
+ }
94
+ }
95
+
96
+ return joinStyles(
97
+ virtualSizeStyle,
98
+ canPinHeader
99
+ ? getPinningStyle(header.column, table, view, true, view.isRowSelectionEnabled)
100
+ : getGroupPinningStyle(
101
+ header,
102
+ view.getHeaderSection(header),
103
+ view,
104
+ view.isRowSelectionEnabled
105
+ )
106
+ );
107
+ }
108
+
109
+ function selectionHeaderStyle() {
110
+ return isVirtual
111
+ ? joinStyles(
112
+ virtualSelectionColumnSizeStyle(),
113
+ 'min-width: 40px',
114
+ 'max-width: 40px',
115
+ 'position: sticky',
116
+ 'left: 0',
117
+ 'z-index: 30'
118
+ )
119
+ : 'width: 40px; min-width: 40px; max-width: 40px; position: sticky; left: 0; z-index: 15';
120
+ }
121
+
122
+ function headerRowStyle() {
123
+ if (!isVirtual) return undefined;
124
+ return joinStyles('display: flex', 'width: 100%');
125
+ }
126
+
127
+ function shouldRenderSelectionCheckbox(headerGroupIndex: number) {
128
+ if (!view.isMultiRowSelectionEnabled) return false;
129
+ return headerGroupIndex === view.headerGroups.length - 1;
130
+ }
131
+ </script>
132
+
133
+ <thead
134
+ class="sticky top-0 z-20 bg-surface-2 text-left text-ink-dim"
135
+ style={isVirtual ? 'display: grid; position: sticky; top: 0; z-index: 20' : undefined}
136
+ >
137
+ {#each view.headerGroups as headerGroup, headerGroupIndex (`${headerGroup.id}:${headerGroup.headers.map((header) => `${header.id}:${header.colSpan}`).join('|')}`)}
138
+ {@const visibleHeaders = headerGroup.headers.filter((header) => header.colSpan > 0)}
139
+ <tr class="h-9" style={headerRowStyle()}>
140
+ {#if view.isRowSelectionEnabled}
141
+ <th
142
+ scope="col"
143
+ class={cn(
144
+ 'h-9 border-b border-surface-3 bg-surface-2 px-3 py-0 text-center align-middle leading-5 font-medium',
145
+ isVirtual && 'items-center justify-center'
146
+ )}
147
+ style={selectionHeaderStyle()}
148
+ >
149
+ {#if shouldRenderSelectionCheckbox(headerGroupIndex)}
150
+ <input
151
+ type="checkbox"
152
+ aria-label="Select all rows"
153
+ class="table-checkbox mx-auto block size-4"
154
+ checked={view.allRowsSelectionState === true}
155
+ indeterminate={view.allRowsSelectionState === 'indeterminate'}
156
+ onchange={(e) => table.toggleAllRowsSelected(e.currentTarget.checked)}
157
+ />
158
+ {/if}
159
+ </th>
160
+ {/if}
161
+ {#each visibleHeaders as header, headerIndex (`${header.id}:${view.getHeaderSection(header) ?? ''}:${header.colSpan}:${header.column.getIsVisible()}`)}
162
+ {@const meta = getColumnMeta(header.column.columnDef)}
163
+ {@const sortDirection = headerSortDirection(header)}
164
+ {@const continuesInNextFragment =
165
+ visibleHeaders[headerIndex + 1]?.column.id === header.column.id}
166
+ <th
167
+ scope="col"
168
+ class={cn(
169
+ 'group/th relative h-9 border-b border-surface-3 bg-surface-2 px-3 py-0 align-middle leading-5 font-medium',
170
+ isVirtual && 'items-center',
171
+ isVirtual && justifyClass(meta?.align),
172
+ alignClass(meta?.align),
173
+ headerFilterPaddingClass(header, meta?.align)
174
+ )}
175
+ colspan={header.colSpan}
176
+ aria-sort={header.column.getCanSort() ? getHeaderAriaSort(sortDirection) : undefined}
177
+ style={headerCellStyle(header)}
178
+ >
179
+ {#if !header.isPlaceholder}
180
+ {#if header.column.getCanSort()}
181
+ <button
182
+ type="button"
183
+ class={cn(
184
+ 'inline-flex max-w-full appearance-none items-center gap-1 rounded-sm border-0 bg-transparent p-0 align-middle text-sm leading-5 text-inherit outline-none hover:text-ink data-focus-visible:outline-2 data-focus-visible:outline-offset-2 data-focus-visible:outline-ring',
185
+ justifyClass(meta?.align),
186
+ sortButtonDirectionClass(meta?.align)
187
+ )}
188
+ aria-label={`${getHeaderSortLabel(sortDirection)}. Toggle sorting.`}
189
+ onclick={header.column.getToggleSortingHandler()}
190
+ >
191
+ <span class="min-w-0 truncate">
192
+ <FlexRender {header} />
193
+ </span>
194
+ <span
195
+ class="inline-flex size-3.5 shrink-0 items-center justify-center text-ink-dim"
196
+ >
197
+ {#if sortDirection === 'asc'}
198
+ <PhCaretUp class="size-3.5" />
199
+ {:else if sortDirection === 'desc'}
200
+ <PhCaretDown class="size-3.5" />
201
+ {/if}
202
+ </span>
203
+ </button>
204
+ {:else}
205
+ <FlexRender {header} />
206
+ {/if}
207
+ {/if}
208
+ {#if !header.isPlaceholder && showHeaderFilter(header)}
209
+ <ColumnHeaderFilter {table} column={header.column} />
210
+ {/if}
211
+ <!-- A group split by pinning renders as adjacent fragments; skip the
212
+ divider between them so the group reads as one continuous header. -->
213
+ {#if header.column.getCanResize() && !continuesInNextFragment}
214
+ <div
215
+ aria-hidden="true"
216
+ class={resizeHandleClass(headerIndex, visibleHeaders.length)}
217
+ style={resizeHandleStyle(table, header, view.columnResizing)}
218
+ ondblclick={() => header.column.resetSize()}
219
+ onmousedown={header.getResizeHandler()}
220
+ ontouchstart={header.getResizeHandler()}
221
+ ></div>
222
+ {/if}
223
+ </th>
224
+ {/each}
225
+ {#if !isVirtual && !hasGrowColumn}
226
+ <th aria-hidden="true" class="h-9 border-b border-surface-3 bg-surface-2 p-0"></th>
227
+ {/if}
228
+ </tr>
229
+ {/each}
230
+ </thead>
@@ -7,6 +7,8 @@ declare function $$render<T extends RowData>(): {
7
7
  view: DataTableViewState<T>;
8
8
  isVirtual?: boolean;
9
9
  hasGrowColumn?: boolean;
10
+ /** Show a filter funnel in the header of every filterable column. */
11
+ headerFilters?: boolean;
10
12
  };
11
13
  exports: {};
12
14
  bindings: "";
@@ -81,6 +81,13 @@ export declare function getUrlCellValue(value: unknown): string | undefined;
81
81
  export declare function openUrlCell(value: string): void;
82
82
  export declare function getPhoneCellValue(value: unknown): string | undefined;
83
83
  export declare function openPhoneCell(value: string): void;
84
+ /** A column's display name: its string header, or its id when the header is rendered. */
85
+ export declare function getColumnLabel(column: {
86
+ id: string;
87
+ columnDef: {
88
+ header?: unknown;
89
+ };
90
+ }): string;
84
91
  export declare function getColumnMeta(columnDef: {
85
92
  meta?: DataTableColumnMeta;
86
93
  }): DataTableColumnMeta | undefined;
@@ -154,6 +154,10 @@ export function getPhoneCellValue(value) {
154
154
  export function openPhoneCell(value) {
155
155
  window.location.href = `tel:${value}`;
156
156
  }
157
+ /** A column's display name: its string header, or its id when the header is rendered. */
158
+ export function getColumnLabel(column) {
159
+ return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
160
+ }
157
161
  // `columnDef.meta` is natively typed as DataTableColumnMeta via the `columnMeta`
158
162
  // type-only slot in features.ts; this accessor just narrows the columnDef shape.
159
163
  export function getColumnMeta(columnDef) {