picocrank 1.34.0 → 1.35.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/docs/table.md CHANGED
@@ -36,6 +36,16 @@ their rows. `Table` reverses its result for descending order and keeps null
36
36
  values last. `sortRows(rows, sortBy, sortDir, comparator)` accepts the same
37
37
  optional comparator.
38
38
 
39
+ ## Column resize
40
+
41
+ Hover a data-column header to reveal a drag handle on its right edge. Drag to
42
+ set a pixel width; the first drag freezes sibling column widths so only the
43
+ active column changes. Double-click the handle to clear that column’s width and
44
+ return it to browser auto sizing. Set `resizable: false` on a header to hide its
45
+ handle. Initial widths still come from optional `header.width`. While any
46
+ column has a resized width, overflowing header and cell content is clipped with
47
+ ellipsis.
48
+
39
49
  ## Cells and display hooks
40
50
 
41
51
  Named `#cell-{key}` slots and the generic `#cell` slot receive:
@@ -79,8 +89,17 @@ the native click event.
79
89
  />
80
90
  ```
81
91
 
82
- Clicking a data cell emits `cell-click` with the enriched cell context and adds
83
- the visibly outlined `table-active-cell` class. The component ref exposes:
92
+ Clicking a data cell emits `cell-click` with the enriched cell context, focuses
93
+ the cell when the click target is not interactive, and adds the visibly outlined
94
+ `table-active-cell` class. While an active cell is focused, arrow keys move
95
+ selection within the displayed grid (with focus following).
96
+
97
+ Sortable headers expose `aria-sort` (`none` / `ascending` / `descending`).
98
+ Filterable headers use `aria-haspopup="dialog"` and `aria-expanded`. The table
99
+ sets `aria-busy` while loading and `aria-colcount` for the visible column count
100
+ (including select and column-options columns when present).
101
+
102
+ The component ref exposes:
84
103
 
85
104
  - `clearFilters()`
86
105
  - `focusActiveCell(options?)` (returns whether a displayed active cell was focused)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "type": "module",
5
5
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
6
6
  "main": "vite.config.js",
@@ -740,3 +740,169 @@ describe('Table row context menu', () => {
740
740
  expect(wrapper.findAll('tbody tr')[0].classes()).not.toContain('row-context-menu-active')
741
741
  })
742
742
  })
743
+
744
+ describe('Table column resize', () => {
745
+ it('renders resize handles for resizable headers', () => {
746
+ const wrapper = mountTable({
747
+ props: {
748
+ selectable: false,
749
+ headers: [
750
+ { key: 'name', label: 'Name', sortable: true },
751
+ { key: 'city', label: 'City', resizable: false },
752
+ ],
753
+ },
754
+ })
755
+
756
+ expect(wrapper.findAll('.column-resize-handle')).toHaveLength(1)
757
+ expect(wrapper.find('th[data-column-key="name"] .column-resize-handle').exists()).toBe(true)
758
+ expect(wrapper.find('th[data-column-key="city"] .column-resize-handle').exists()).toBe(false)
759
+ })
760
+
761
+ function mockHeaderWidths(wrapper, widthsByKey) {
762
+ wrapper.findAll('th[data-column-key]').forEach((headerCell) => {
763
+ const key = headerCell.attributes('data-column-key')
764
+ const width = widthsByKey[key] ?? 100
765
+ vi.spyOn(headerCell.element, 'getBoundingClientRect').mockReturnValue({
766
+ width,
767
+ height: 32,
768
+ top: 0,
769
+ left: 0,
770
+ right: width,
771
+ bottom: 32,
772
+ x: 0,
773
+ y: 0,
774
+ toJSON: () => ({}),
775
+ })
776
+ })
777
+ }
778
+
779
+ it('sets a pixel width while dragging, freezes siblings, and restores auto on double-click', async () => {
780
+ const wrapper = mountTable({
781
+ props: {
782
+ selectable: false,
783
+ },
784
+ })
785
+
786
+ const headerCell = wrapper.find('th[data-column-key="name"]')
787
+ const cityHeader = wrapper.find('th[data-column-key="city"]')
788
+ const handle = headerCell.find('.column-resize-handle')
789
+ mockHeaderWidths(wrapper, { name: 120, city: 160 })
790
+
791
+ await handle.trigger('pointerdown', { button: 0, clientX: 100, pointerId: 1 })
792
+ window.dispatchEvent(new PointerEvent('pointermove', { clientX: 160 }))
793
+ window.dispatchEvent(new PointerEvent('pointerup', { clientX: 160 }))
794
+ await nextTick()
795
+
796
+ expect(wrapper.find('table').classes()).toContain('has-column-widths')
797
+ expect(headerCell.attributes('style')).toContain('width: 180px')
798
+ expect(cityHeader.attributes('style')).toContain('width: 160px')
799
+
800
+ await handle.trigger('dblclick')
801
+ await nextTick()
802
+
803
+ expect(headerCell.attributes('style') || '').not.toMatch(/(?:^|;)\s*width:/)
804
+ expect(cityHeader.attributes('style')).toContain('width: 160px')
805
+ expect(wrapper.find('table').classes()).toContain('has-column-widths')
806
+ })
807
+
808
+ it('clips overflowing cell content with ellipsis while columns are resized', async () => {
809
+ const wrapper = mountTable({
810
+ props: {
811
+ selectable: false,
812
+ },
813
+ })
814
+
815
+ const headerCell = wrapper.find('th[data-column-key="name"]')
816
+ const handle = headerCell.find('.column-resize-handle')
817
+ mockHeaderWidths(wrapper, { name: 120, city: 160 })
818
+
819
+ await handle.trigger('pointerdown', { button: 0, clientX: 100, pointerId: 1 })
820
+ window.dispatchEvent(new PointerEvent('pointerup', { clientX: 100 }))
821
+ await nextTick()
822
+
823
+ const cell = wrapper.find('tbody td')
824
+ const style = getComputedStyle(cell.element)
825
+ expect(style.overflow).toBe('hidden')
826
+ expect(style.textOverflow).toBe('ellipsis')
827
+ expect(style.whiteSpace).toBe('nowrap')
828
+ })
829
+ })
830
+
831
+ describe('Table aria and keyboard navigation', () => {
832
+ it('exposes aria-sort and related header/table hints', async () => {
833
+ const wrapper = mountTable({
834
+ props: {
835
+ selectable: false,
836
+ filterable: true,
837
+ headers: [
838
+ { key: 'name', label: 'Name', sortable: true, filterable: true },
839
+ { key: 'city', label: 'City', sortable: true },
840
+ ],
841
+ },
842
+ })
843
+
844
+ const nameHeader = wrapper.find('th[data-column-key="name"]')
845
+ const cityHeader = wrapper.find('th[data-column-key="city"]')
846
+
847
+ expect(wrapper.find('table').attributes('aria-colcount')).toBe('2')
848
+ expect(nameHeader.attributes('scope')).toBe('col')
849
+ expect(nameHeader.attributes('aria-sort')).toBe('none')
850
+ expect(nameHeader.attributes('aria-haspopup')).toBe('dialog')
851
+ expect(nameHeader.attributes('aria-expanded')).toBe('false')
852
+
853
+ await nameHeader.trigger('click')
854
+ await nextTick()
855
+
856
+ expect(nameHeader.attributes('aria-sort')).toBe('ascending')
857
+ expect(cityHeader.attributes('aria-sort')).toBe('none')
858
+
859
+ await nameHeader.trigger('click')
860
+ await nextTick()
861
+
862
+ expect(nameHeader.attributes('aria-sort')).toBe('descending')
863
+ })
864
+
865
+ it('moves the active cell with arrow keys when one is set', async () => {
866
+ const wrapper = mountTable({
867
+ props: {
868
+ selectable: false,
869
+ },
870
+ })
871
+
872
+ const firstCell = wrapper.find('tbody td')
873
+ await firstCell.trigger('click')
874
+ await nextTick()
875
+
876
+ expect(wrapper.find('.table-active-cell').text()).toContain('Ada')
877
+ expect(document.activeElement).toBe(wrapper.find('.table-active-cell').element)
878
+
879
+ await wrapper.find('.table-active-cell').trigger('keydown', { key: 'ArrowRight' })
880
+ await nextTick()
881
+ expect(wrapper.emitted('update:activeCell')?.at(-1)?.[0]).toEqual({
882
+ rowKey: 'Ada',
883
+ columnKey: 'city',
884
+ })
885
+ expect(wrapper.find('.table-active-cell').text()).toContain('London')
886
+
887
+ await wrapper.find('.table-active-cell').trigger('keydown', { key: 'ArrowDown' })
888
+ await nextTick()
889
+ expect(wrapper.emitted('update:activeCell')?.at(-1)?.[0]).toEqual({
890
+ rowKey: 'Grace',
891
+ columnKey: 'city',
892
+ })
893
+
894
+ await wrapper.find('.table-active-cell').trigger('keydown', { key: 'ArrowLeft' })
895
+ await nextTick()
896
+ expect(wrapper.emitted('update:activeCell')?.at(-1)?.[0]).toEqual({
897
+ rowKey: 'Grace',
898
+ columnKey: 'name',
899
+ })
900
+
901
+ await wrapper.find('.table-active-cell').trigger('keydown', { key: 'ArrowUp' })
902
+ await nextTick()
903
+ expect(wrapper.emitted('update:activeCell')?.at(-1)?.[0]).toEqual({
904
+ rowKey: 'Ada',
905
+ columnKey: 'name',
906
+ })
907
+ })
908
+ })
@@ -4,11 +4,31 @@
4
4
  :class="needsTableWrapper ? tableWrapperClasses : undefined"
5
5
  :style="needsTableWrapper ? undefined : { display: 'contents' }"
6
6
  >
7
- <table class="row-hover" :class="{ loading: isLoading }">
7
+ <table
8
+ ref="tableElementRef"
9
+ class="row-hover"
10
+ :class="{
11
+ loading: isLoading,
12
+ 'has-column-widths': hasResizedColumns,
13
+ 'is-column-resizing': activeResizeKey != null,
14
+ }"
15
+ :aria-busy="isLoading ? 'true' : undefined"
16
+ :aria-colcount="ariaColCount"
17
+ >
18
+ <colgroup>
19
+ <col v-if="selectable" class="table-select-col-width" />
20
+ <col
21
+ v-for="(header, index) in visibleHeaders"
22
+ :key="header.key || index"
23
+ :style="columnWidthStyle(header)"
24
+ />
25
+ <col v-if="showColumnOptionsButton" class="table-column-options-col-width" />
26
+ </colgroup>
8
27
  <thead>
9
28
  <tr>
10
29
  <th
11
30
  v-if="selectable"
31
+ scope="col"
12
32
  class="table-select-col"
13
33
  @click.stop
14
34
  >
@@ -25,8 +45,13 @@
25
45
  <th
26
46
  v-for="(header, index) in visibleHeaders"
27
47
  :key="header.key || index"
28
- :class="[headerClasses(header), header.class]"
29
- :style="{ width: header.width || 'auto' }"
48
+ scope="col"
49
+ :data-column-key="header.key"
50
+ :class="[headerClasses(header), header.class, { 'is-resizing': activeResizeKey === header.key }]"
51
+ :style="[columnWidthStyle(header), columnOverflowStyle()]"
52
+ :aria-sort="headerAriaSort(header)"
53
+ :aria-haspopup="isHeaderFilterable(header) ? 'dialog' : undefined"
54
+ :aria-expanded="headerFilterExpanded(header)"
30
55
  @click="onHeaderClick(header, $event)"
31
56
  @contextmenu.prevent="openFilter(header, $event)"
32
57
  @pointerdown="onHeaderPointerDown(header, $event)"
@@ -56,7 +81,7 @@
56
81
  />
57
82
  </span>
58
83
 
59
- <span v-if="header.sortable" class="sort-indicator">
84
+ <span v-if="header.sortable" class="sort-indicator" aria-hidden="true">
60
85
  <HugeiconsIcon
61
86
  v-if="sortBy === header.key && sortDir === 'asc'"
62
87
  :icon="SortByUp02Icon"
@@ -74,9 +99,25 @@
74
99
  aria-hidden="true"
75
100
  />
76
101
  </span>
102
+
103
+ <span
104
+ v-if="isHeaderResizable(header)"
105
+ class="column-resize-handle"
106
+ role="separator"
107
+ aria-orientation="vertical"
108
+ :aria-label="`Resize ${header.label || header.key} column`"
109
+ :aria-valuemin="MIN_COLUMN_WIDTH"
110
+ :aria-valuenow="columnResizeValueNow(header)"
111
+ :class="{ 'is-active': activeResizeKey === header.key }"
112
+ @pointerdown.stop="onResizePointerDown(header, $event)"
113
+ @dblclick.stop="onResizeDoubleClick(header, $event)"
114
+ @click.stop
115
+ @contextmenu.stop.prevent
116
+ />
77
117
  </th>
78
118
  <th
79
119
  v-if="showColumnOptionsButton"
120
+ scope="col"
80
121
  class="actions table-column-options-header"
81
122
  >
82
123
  <span class="actions-menu-trigger">
@@ -179,9 +220,10 @@
179
220
  { 'table-active-cell': isActiveCell(item.row, header, item.displayIndex) },
180
221
  isLastColumnWithOptions(cellIndex) ? 'table-last-column-with-options' : null,
181
222
  ]"
182
- :style="resolveCellStyle(item.row, header, item.displayIndex)"
223
+ :style="[resolveCellStyle(item.row, header, item.displayIndex), columnOverflowStyle()]"
183
224
  :tabindex="isActiveCell(item.row, header, item.displayIndex) ? 0 : -1"
184
225
  @click="onCellClick(item.row, header, item.displayIndex, $event)"
226
+ @keydown="onCellKeydown(item.row, header, item.displayIndex, $event)"
185
227
  >
186
228
  <slot
187
229
  v-if="slots[`cell-${header.key}`]"
@@ -338,6 +380,7 @@ import {
338
380
  } from '../composables/tableGroupBy.js'
339
381
 
340
382
  const DEFAULT_PAGE_SIZE = 10
383
+ const MIN_COLUMN_WIDTH = 48
341
384
 
342
385
  const props = defineProps({
343
386
  headers: {
@@ -674,9 +717,177 @@ const defaultPresetId = ref(null)
674
717
  const activeLayoutPresetId = ref(null)
675
718
  const internalGroupBy = ref(null)
676
719
  const collapsedGroupIds = ref([])
720
+ /** @type {import('vue').Ref<Record<string, number | null>>} */
721
+ const columnWidths = ref({})
722
+ const activeResizeKey = ref(null)
723
+ const tableElementRef = ref(null)
724
+ let resizeSession = null
677
725
 
678
726
  const isRemote = computed(() => typeof props.fetchRows === 'function')
679
727
 
728
+ const hasResizedColumns = computed(() =>
729
+ Object.values(columnWidths.value).some((width) => width != null),
730
+ )
731
+
732
+ function isHeaderResizable(header) {
733
+ return Boolean(header?.key) && header.resizable !== false
734
+ }
735
+
736
+ function formatColumnWidth(width) {
737
+ if (width == null || width === '') {
738
+ return undefined
739
+ }
740
+ return typeof width === 'number' ? `${width}px` : width
741
+ }
742
+
743
+ function columnWidthStyle(header) {
744
+ if (!header?.key) {
745
+ return header?.width ? { width: formatColumnWidth(header.width) } : undefined
746
+ }
747
+
748
+ if (Object.prototype.hasOwnProperty.call(columnWidths.value, header.key)) {
749
+ const resized = columnWidths.value[header.key]
750
+ if (resized == null) {
751
+ return undefined
752
+ }
753
+ return { width: `${resized}px` }
754
+ }
755
+
756
+ if (header.width) {
757
+ return { width: formatColumnWidth(header.width) }
758
+ }
759
+
760
+ return undefined
761
+ }
762
+
763
+ function columnOverflowStyle() {
764
+ if (!hasResizedColumns.value) {
765
+ return undefined
766
+ }
767
+ return {
768
+ overflow: 'hidden',
769
+ textOverflow: 'ellipsis',
770
+ whiteSpace: 'nowrap',
771
+ }
772
+ }
773
+
774
+ function columnResizeValueNow(header) {
775
+ const width = columnWidths.value[header.key]
776
+ return typeof width === 'number' ? width : undefined
777
+ }
778
+
779
+ function setColumnWidths(nextWidths) {
780
+ columnWidths.value = nextWidths
781
+ }
782
+
783
+ function setColumnWidth(columnKey, width) {
784
+ columnWidths.value = {
785
+ ...columnWidths.value,
786
+ [columnKey]: width,
787
+ }
788
+ }
789
+
790
+ function freezeSiblingColumnWidths(tableEl, activeKey, activeWidth) {
791
+ const next = { ...columnWidths.value }
792
+ tableEl?.querySelectorAll('thead th[data-column-key]').forEach((th) => {
793
+ const key = th.dataset.columnKey
794
+ if (!key || key === activeKey) {
795
+ return
796
+ }
797
+ if (typeof next[key] === 'number') {
798
+ return
799
+ }
800
+ next[key] = Math.max(MIN_COLUMN_WIDTH, Math.round(th.getBoundingClientRect().width))
801
+ })
802
+ next[activeKey] = activeWidth
803
+ setColumnWidths(next)
804
+ }
805
+
806
+ function endColumnResize() {
807
+ if (!resizeSession) {
808
+ return
809
+ }
810
+
811
+ const { moveListener, upListener, previousUserSelect, previousCursor } = resizeSession
812
+ window.removeEventListener('pointermove', moveListener)
813
+ window.removeEventListener('pointerup', upListener)
814
+ window.removeEventListener('pointercancel', upListener)
815
+ document.body.style.userSelect = previousUserSelect
816
+ document.body.style.cursor = previousCursor
817
+ activeResizeKey.value = null
818
+ resizeSession = null
819
+ }
820
+
821
+ function onResizePointerDown(header, event) {
822
+ if (!isHeaderResizable(header) || event.button !== 0) {
823
+ return
824
+ }
825
+
826
+ const handle = event.currentTarget
827
+ const headerCell = handle?.closest('th')
828
+ if (!headerCell) {
829
+ return
830
+ }
831
+
832
+ event.preventDefault()
833
+ endColumnResize()
834
+
835
+ const startX = event.clientX
836
+ const startWidth = Math.max(MIN_COLUMN_WIDTH, Math.round(headerCell.getBoundingClientRect().width))
837
+ activeResizeKey.value = header.key
838
+ freezeSiblingColumnWidths(
839
+ tableElementRef.value ?? headerCell.closest('table'),
840
+ header.key,
841
+ startWidth,
842
+ )
843
+
844
+ const previousUserSelect = document.body.style.userSelect
845
+ const previousCursor = document.body.style.cursor
846
+ document.body.style.userSelect = 'none'
847
+ document.body.style.cursor = 'col-resize'
848
+
849
+ const moveListener = (moveEvent) => {
850
+ const nextWidth = Math.max(
851
+ MIN_COLUMN_WIDTH,
852
+ Math.round(startWidth + (moveEvent.clientX - startX)),
853
+ )
854
+ setColumnWidth(header.key, nextWidth)
855
+ }
856
+
857
+ const upListener = () => {
858
+ endColumnResize()
859
+ }
860
+
861
+ resizeSession = {
862
+ moveListener,
863
+ upListener,
864
+ previousUserSelect,
865
+ previousCursor,
866
+ }
867
+
868
+ window.addEventListener('pointermove', moveListener)
869
+ window.addEventListener('pointerup', upListener)
870
+ window.addEventListener('pointercancel', upListener)
871
+
872
+ if (typeof handle.setPointerCapture === 'function') {
873
+ try {
874
+ handle.setPointerCapture(event.pointerId)
875
+ } catch {
876
+ // Ignore capture failures (e.g. unsupported pointer type in tests).
877
+ }
878
+ }
879
+ }
880
+
881
+ function onResizeDoubleClick(header, event) {
882
+ if (!isHeaderResizable(header)) {
883
+ return
884
+ }
885
+
886
+ event.preventDefault()
887
+ endColumnResize()
888
+ setColumnWidth(header.key, null)
889
+ }
890
+
680
891
  const viewSettingsHandlers = computed(() =>
681
892
  getViewSettingsHandlers(props.viewSettings, props.layoutPresets))
682
893
 
@@ -808,6 +1019,17 @@ function isHeaderHidden(header) {
808
1019
 
809
1020
  const visibleHeaders = computed(() => orderedHeaders.value.filter((header) => !isHeaderHidden(header)))
810
1021
 
1022
+ const ariaColCount = computed(() => {
1023
+ let count = visibleHeaders.value.length
1024
+ if (props.selectable) {
1025
+ count += 1
1026
+ }
1027
+ if (showColumnOptionsButton.value) {
1028
+ count += 1
1029
+ }
1030
+ return count > 0 ? count : undefined
1031
+ })
1032
+
811
1033
  const visibleColumnKeys = computed(() =>
812
1034
  orderedHeaders.value
813
1035
  .filter((header) => !isHeaderHidden(header))
@@ -1043,6 +1265,25 @@ function isHeaderFiltered(header) {
1043
1265
  return isColumnFiltered(activeFilters.value?.[header.key], header, props.data)
1044
1266
  }
1045
1267
 
1268
+ function headerAriaSort(header) {
1269
+ if (!header?.sortable) {
1270
+ return undefined
1271
+ }
1272
+ if (sortBy.value !== header.key) {
1273
+ return 'none'
1274
+ }
1275
+ return sortDir.value === 'desc' ? 'descending' : 'ascending'
1276
+ }
1277
+
1278
+ function headerFilterExpanded(header) {
1279
+ if (!isHeaderFilterable(header)) {
1280
+ return undefined
1281
+ }
1282
+ return filterPopoverOpen.value && activeFilterHeader.value?.key === header.key
1283
+ ? 'true'
1284
+ : 'false'
1285
+ }
1286
+
1046
1287
  function headerClasses(header) {
1047
1288
  const priority = resolvedColumnPriority(header)
1048
1289
  return {
@@ -1308,6 +1549,14 @@ function moveActiveCell(rowDelta, columnDelta, options = {}) {
1308
1549
  return activateCellAt(rowIndex, columnIndex, options)
1309
1550
  }
1310
1551
 
1552
+ function isInteractiveCellTarget(target) {
1553
+ return Boolean(
1554
+ target?.closest?.(
1555
+ 'a, button, input, select, textarea, [contenteditable="true"], [data-row-click-ignore]',
1556
+ ),
1557
+ )
1558
+ }
1559
+
1311
1560
  function onCellClick(row, header, rowIndex, event) {
1312
1561
  const context = cellContext(row, header, rowIndex)
1313
1562
  const nextActiveCell = {
@@ -1315,6 +1564,9 @@ function onCellClick(row, header, rowIndex, event) {
1315
1564
  columnKey: header.key,
1316
1565
  }
1317
1566
  setActiveCell(nextActiveCell)
1567
+ if (!isInteractiveCellTarget(event.target)) {
1568
+ scheduleCellFocus(nextActiveCell, { focus: true })
1569
+ }
1318
1570
  emit('cell-click', {
1319
1571
  ...context,
1320
1572
  ...nextActiveCell,
@@ -1322,6 +1574,39 @@ function onCellClick(row, header, rowIndex, event) {
1322
1574
  })
1323
1575
  }
1324
1576
 
1577
+ function onCellKeydown(row, header, rowIndex, event) {
1578
+ if (!activeCell.value || !isActiveCell(row, header, rowIndex)) {
1579
+ return
1580
+ }
1581
+
1582
+ if (isInteractiveCellTarget(event.target) && event.target !== event.currentTarget) {
1583
+ return
1584
+ }
1585
+
1586
+ let rowDelta = 0
1587
+ let columnDelta = 0
1588
+
1589
+ switch (event.key) {
1590
+ case 'ArrowUp':
1591
+ rowDelta = -1
1592
+ break
1593
+ case 'ArrowDown':
1594
+ rowDelta = 1
1595
+ break
1596
+ case 'ArrowLeft':
1597
+ columnDelta = -1
1598
+ break
1599
+ case 'ArrowRight':
1600
+ columnDelta = 1
1601
+ break
1602
+ default:
1603
+ return
1604
+ }
1605
+
1606
+ event.preventDefault()
1607
+ moveActiveCell(rowDelta, columnDelta, { focus: true })
1608
+ }
1609
+
1325
1610
  const activeSelectedKeys = computed(() => (
1326
1611
  props.selectedKeys !== undefined ? props.selectedKeys : internalSelectedKeys.value
1327
1612
  ))
@@ -2573,6 +2858,7 @@ onMounted(async () => {
2573
2858
  })
2574
2859
 
2575
2860
  onBeforeUnmount(() => {
2861
+ endColumnResize()
2576
2862
  if (fetchAbortController.value) {
2577
2863
  fetchAbortController.value.abort()
2578
2864
  }
@@ -2594,11 +2880,85 @@ defineExpose({
2594
2880
  </script>
2595
2881
 
2596
2882
  <style scoped>
2883
+ table.has-column-widths {
2884
+ table-layout: fixed;
2885
+ width: 100%;
2886
+ }
2887
+
2888
+ /* Force fixed-layout cells to respect column widths so overflow clipping/ellipsis work. */
2889
+ table.has-column-widths th,
2890
+ table.has-column-widths td {
2891
+ max-width: 0;
2892
+ overflow: hidden;
2893
+ text-overflow: ellipsis;
2894
+ white-space: nowrap;
2895
+ }
2896
+
2897
+ table.has-column-widths th.table-select-col,
2898
+ table.has-column-widths td.table-select-col,
2899
+ table.has-column-widths th.table-column-options-header {
2900
+ max-width: none;
2901
+ }
2902
+
2903
+ table.is-column-resizing {
2904
+ cursor: col-resize;
2905
+ }
2906
+
2907
+ table.is-column-resizing thead th.sortable:hover {
2908
+ cursor: col-resize;
2909
+ }
2910
+
2911
+ col.table-select-col-width,
2912
+ col.table-column-options-col-width {
2913
+ width: 2.5rem;
2914
+ }
2915
+
2916
+ table thead th {
2917
+ position: relative;
2918
+ }
2919
+
2597
2920
  table thead th.sortable:hover {
2598
2921
  cursor: pointer;
2599
2922
  color: var(--table-header-active-fg);
2600
2923
  }
2601
2924
 
2925
+ .column-resize-handle {
2926
+ position: absolute;
2927
+ top: 0;
2928
+ right: -0.2rem;
2929
+ z-index: 2;
2930
+ width: 0.55rem;
2931
+ height: 100%;
2932
+ padding: 0;
2933
+ border: 0;
2934
+ background: transparent;
2935
+ cursor: col-resize;
2936
+ touch-action: none;
2937
+ user-select: none;
2938
+ opacity: 0;
2939
+ }
2940
+
2941
+ .column-resize-handle::after {
2942
+ content: '';
2943
+ position: absolute;
2944
+ top: 20%;
2945
+ bottom: 20%;
2946
+ left: 50%;
2947
+ width: 2px;
2948
+ transform: translateX(-50%);
2949
+ border-radius: 1px;
2950
+ background-color: var(--border-color, currentColor);
2951
+ opacity: 0.85;
2952
+ }
2953
+
2954
+ table thead th:hover .column-resize-handle,
2955
+ table thead th.is-resizing .column-resize-handle,
2956
+ .column-resize-handle:hover,
2957
+ .column-resize-handle:focus-visible,
2958
+ .column-resize-handle.is-active {
2959
+ opacity: 1;
2960
+ }
2961
+
2602
2962
  table thead th.filterable {
2603
2963
  cursor: context-menu;
2604
2964
  }