picocrank 1.31.0 → 1.32.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.
@@ -7,6 +7,21 @@
7
7
  <table class="row-hover" :class="{ loading: isLoading }">
8
8
  <thead>
9
9
  <tr>
10
+ <th
11
+ v-if="selectable"
12
+ class="table-select-col"
13
+ @click.stop
14
+ >
15
+ <label class="table-select-label">
16
+ <input
17
+ type="checkbox"
18
+ :checked="allDisplayRowsSelected"
19
+ :indeterminate="someDisplayRowsSelected"
20
+ :aria-label="allDisplayRowsSelected ? 'Deselect all rows on this page' : 'Select all rows on this page'"
21
+ @change="toggleSelectAllDisplayRows"
22
+ >
23
+ </label>
24
+ </th>
10
25
  <th
11
26
  v-for="(header, index) in visibleHeaders"
12
27
  :key="header.key || index"
@@ -62,9 +77,29 @@
62
77
  <tr
63
78
  v-for="(row, rowIndex) in displayRows"
64
79
  :key="resolveRowKey(row, rowIndex)"
65
- :class="{ 'row-clickable': rowClickable }"
80
+ :class="{
81
+ 'row-clickable': rowClickable,
82
+ 'row-selected': selectable && isRowSelected(row, rowIndex),
83
+ 'row-context-menu-active': isRowContextMenuTarget(row, rowIndex),
84
+ }"
66
85
  @click="onRowClick(row, rowIndex, $event)"
86
+ @contextmenu="onRowContextMenu(row, rowIndex, $event)"
67
87
  >
88
+ <td
89
+ v-if="selectable"
90
+ class="table-select-col"
91
+ data-row-click-ignore
92
+ @click.stop
93
+ >
94
+ <label class="table-select-label">
95
+ <input
96
+ type="checkbox"
97
+ :checked="isRowSelected(row, rowIndex)"
98
+ :aria-label="`Select row ${resolveRowKey(row, rowIndex)}`"
99
+ @change="toggleRowSelection(row, rowIndex)"
100
+ >
101
+ </label>
102
+ </td>
68
103
  <td
69
104
  v-for="(header, cellIndex) in visibleHeaders"
70
105
  :key="header.key || cellIndex"
@@ -120,9 +155,11 @@
120
155
  :value="activeFilterValue"
121
156
  :rows="props.data"
122
157
  :select-options="activeSelectOptions"
158
+ :can-hide="canHideActiveFilterColumn"
123
159
  @apply="onFilterApply"
124
160
  @clear="onFilterClear"
125
161
  @cancel="onFilterCancel"
162
+ @hide="onFilterHideColumn"
126
163
  />
127
164
 
128
165
  <TableColumnOptionsPopover
@@ -148,6 +185,15 @@
148
185
  @apply="onColumnOptionsApply"
149
186
  @cancel="onColumnOptionsCancel"
150
187
  />
188
+
189
+ <TableRowContextMenu
190
+ v-model:open="rowContextMenuOpen"
191
+ :items="rowContextMenuItems"
192
+ :client-x="rowContextMenuX"
193
+ :client-y="rowContextMenuY"
194
+ :target-count="rowContextMenuTargetKeys.length"
195
+ @select="onRowContextMenuSelect"
196
+ />
151
197
  </template>
152
198
 
153
199
  <script setup>
@@ -157,6 +203,7 @@ import { LayoutGridIcon } from '@hugeicons/core-free-icons'
157
203
  import Pagination from './Pagination.vue'
158
204
  import TableColumnFilterPopover from './TableColumnFilterPopover.vue'
159
205
  import TableColumnOptionsPopover from './TableColumnOptionsPopover.vue'
206
+ import TableRowContextMenu from './TableRowContextMenu.vue'
160
207
  import { useLongPress } from '../composables/useLongPress.js'
161
208
  import {
162
209
  buildSelectOptions,
@@ -276,6 +323,24 @@ const props = defineProps({
276
323
  type: Object,
277
324
  default: null,
278
325
  },
326
+ selectable: {
327
+ type: Boolean,
328
+ default: false,
329
+ },
330
+ selectedKeys: {
331
+ type: Array,
332
+ default: undefined,
333
+ },
334
+ /**
335
+ * Row right-click menu. Array of items, or a function that returns items
336
+ * for the click context. Each item may include `action(ctx)` where
337
+ * `ctx.keys` / `ctx.rows` are the target selection (clicked row, or all
338
+ * selected rows when the click is inside the current multi-selection).
339
+ */
340
+ rowContextMenu: {
341
+ type: [Array, Function],
342
+ default: null,
343
+ },
279
344
  })
280
345
 
281
346
  const emit = defineEmits([
@@ -284,6 +349,10 @@ const emit = defineEmits([
284
349
  'query-change',
285
350
  'fetch-error',
286
351
  'row-click',
352
+ 'update:selectedKeys',
353
+ 'selection-change',
354
+ 'row-context-menu',
355
+ 'row-context-menu-action',
287
356
  'update:columnVisibility',
288
357
  'column-visibility-change',
289
358
  'update:columnOrder',
@@ -297,8 +366,9 @@ const emit = defineEmits([
297
366
  const attrs = useAttrs()
298
367
  const slots = useSlots()
299
368
 
300
- const rowClickable = computed(() => Boolean(attrs.onRowClick))
369
+ const rowClickable = computed(() => Boolean(attrs.onRowClick) || props.selectable)
301
370
  const internalFilters = ref({})
371
+ const internalSelectedKeys = ref([])
302
372
  const remoteRows = ref([])
303
373
  const remoteTotal = ref(0)
304
374
  const internalLoading = ref(false)
@@ -632,6 +702,72 @@ function resolveRowKey(row, index) {
632
702
  return index
633
703
  }
634
704
 
705
+ const activeSelectedKeys = computed(() => (
706
+ props.selectedKeys !== undefined ? props.selectedKeys : internalSelectedKeys.value
707
+ ))
708
+
709
+ const selectedKeySet = computed(() => new Set(activeSelectedKeys.value))
710
+
711
+ function isRowSelected(row, index) {
712
+ return selectedKeySet.value.has(resolveRowKey(row, index))
713
+ }
714
+
715
+ function setSelectedKeys(nextKeys) {
716
+ const unique = [...new Set(nextKeys)]
717
+ if (props.selectedKeys === undefined) {
718
+ internalSelectedKeys.value = unique
719
+ }
720
+ emit('update:selectedKeys', unique)
721
+ emit('selection-change', unique)
722
+ }
723
+
724
+ function toggleRowSelection(row, index) {
725
+ const key = resolveRowKey(row, index)
726
+ const current = activeSelectedKeys.value
727
+ if (selectedKeySet.value.has(key)) {
728
+ setSelectedKeys(current.filter((item) => item !== key))
729
+ return
730
+ }
731
+ setSelectedKeys([...current, key])
732
+ }
733
+
734
+ const displayRowKeys = computed(() =>
735
+ displayRows.value.map((row, index) => resolveRowKey(row, index)),
736
+ )
737
+
738
+ const allDisplayRowsSelected = computed(() => {
739
+ const keys = displayRowKeys.value
740
+ return keys.length > 0 && keys.every((key) => selectedKeySet.value.has(key))
741
+ })
742
+
743
+ const someDisplayRowsSelected = computed(() => {
744
+ if (allDisplayRowsSelected.value) {
745
+ return false
746
+ }
747
+ return displayRowKeys.value.some((key) => selectedKeySet.value.has(key))
748
+ })
749
+
750
+ function toggleSelectAllDisplayRows() {
751
+ const pageKeys = displayRowKeys.value
752
+ if (pageKeys.length === 0) {
753
+ return
754
+ }
755
+
756
+ if (allDisplayRowsSelected.value) {
757
+ const pageKeySet = new Set(pageKeys)
758
+ setSelectedKeys(activeSelectedKeys.value.filter((key) => !pageKeySet.has(key)))
759
+ return
760
+ }
761
+
762
+ setSelectedKeys([...activeSelectedKeys.value, ...pageKeys])
763
+ }
764
+
765
+ watch(() => props.selectable, (enabled) => {
766
+ if (!enabled) {
767
+ setSelectedKeys([])
768
+ }
769
+ })
770
+
635
771
  function setFilters(nextFilters) {
636
772
  const cloned = cloneFilters(nextFilters)
637
773
  if (props.filters === undefined) {
@@ -686,9 +822,152 @@ function onRowClick(row, displayIndex, event) {
686
822
  if (event.target.closest('a, button, input, select, textarea, [data-row-click-ignore]')) {
687
823
  return
688
824
  }
825
+ if (props.selectable) {
826
+ toggleRowSelection(row, displayIndex)
827
+ return
828
+ }
689
829
  emit('row-click', { row, index: filteredRowIndex(displayIndex) })
690
830
  }
691
831
 
832
+ const rowContextMenuOpen = ref(false)
833
+ const rowContextMenuX = ref(0)
834
+ const rowContextMenuY = ref(0)
835
+ const rowContextMenuItems = ref([])
836
+ const rowContextMenuTargetKeys = ref([])
837
+ const rowContextMenuAnchor = ref(null)
838
+
839
+ const rowContextMenuTargetKeySet = computed(() => new Set(rowContextMenuTargetKeys.value))
840
+
841
+ function isRowContextMenuTarget(row, index) {
842
+ return rowContextMenuOpen.value && rowContextMenuTargetKeySet.value.has(resolveRowKey(row, index))
843
+ }
844
+
845
+ const hasRowContextMenu = computed(() => {
846
+ if (typeof props.rowContextMenu === 'function') {
847
+ return true
848
+ }
849
+ return Array.isArray(props.rowContextMenu) && props.rowContextMenu.length > 0
850
+ })
851
+
852
+ function buildRowLookup() {
853
+ const map = new Map()
854
+ const addRows = (rows) => {
855
+ rows.forEach((row, index) => {
856
+ const key = resolveRowKey(row, index)
857
+ if (!map.has(key)) {
858
+ map.set(key, row)
859
+ }
860
+ })
861
+ }
862
+
863
+ if (isRemote.value) {
864
+ addRows(remoteRows.value)
865
+ } else {
866
+ addRows(props.data)
867
+ }
868
+ addRows(displayRows.value)
869
+ return map
870
+ }
871
+
872
+ function resolveTargetRows(keys) {
873
+ const lookup = buildRowLookup()
874
+ return keys.map((key) => lookup.get(key)).filter((row) => row != null)
875
+ }
876
+
877
+ function normalizeContextMenuItems(rawItems) {
878
+ if (!Array.isArray(rawItems)) {
879
+ return []
880
+ }
881
+ return rawItems.filter((item) => item && (item.divider || item.label || item.id))
882
+ }
883
+
884
+ function closeRowContextMenu() {
885
+ rowContextMenuOpen.value = false
886
+ rowContextMenuItems.value = []
887
+ rowContextMenuTargetKeys.value = []
888
+ rowContextMenuAnchor.value = null
889
+ }
890
+
891
+ function onRowContextMenu(row, displayIndex, event) {
892
+ if (!hasRowContextMenu.value) {
893
+ return
894
+ }
895
+ if (event.target.closest('a, button, input, select, textarea, [data-row-click-ignore]')) {
896
+ return
897
+ }
898
+
899
+ event.preventDefault()
900
+ event.stopPropagation()
901
+
902
+ const key = resolveRowKey(row, displayIndex)
903
+ const index = filteredRowIndex(displayIndex)
904
+ let targetKeys
905
+
906
+ if (props.selectable && selectedKeySet.value.has(key) && activeSelectedKeys.value.length > 0) {
907
+ targetKeys = [...activeSelectedKeys.value]
908
+ } else {
909
+ targetKeys = [key]
910
+ if (props.selectable) {
911
+ setSelectedKeys([key])
912
+ }
913
+ }
914
+
915
+ const rows = resolveTargetRows(targetKeys)
916
+ const ctx = {
917
+ row,
918
+ key,
919
+ index,
920
+ rows,
921
+ keys: targetKeys,
922
+ selectedKeys: [...activeSelectedKeys.value],
923
+ }
924
+
925
+ const rawItems = typeof props.rowContextMenu === 'function'
926
+ ? props.rowContextMenu(ctx)
927
+ : props.rowContextMenu
928
+ const items = normalizeContextMenuItems(rawItems)
929
+ if (items.length === 0) {
930
+ closeRowContextMenu()
931
+ return
932
+ }
933
+
934
+ rowContextMenuAnchor.value = ctx
935
+ rowContextMenuTargetKeys.value = targetKeys
936
+ rowContextMenuItems.value = items
937
+ rowContextMenuX.value = event.clientX
938
+ rowContextMenuY.value = event.clientY
939
+ rowContextMenuOpen.value = true
940
+
941
+ emit('row-context-menu', {
942
+ ...ctx,
943
+ event,
944
+ })
945
+ }
946
+
947
+ function onRowContextMenuSelect(item) {
948
+ const anchor = rowContextMenuAnchor.value
949
+ const keys = [...rowContextMenuTargetKeys.value]
950
+ const lookup = buildRowLookup()
951
+ const rows = keys.map((key) => lookup.get(key)).filter((row) => row != null)
952
+ const selectedKeys = [...activeSelectedKeys.value]
953
+ const payload = {
954
+ item,
955
+ row: anchor?.row,
956
+ key: anchor?.key,
957
+ index: anchor?.index,
958
+ rows,
959
+ keys,
960
+ selectedKeys,
961
+ }
962
+
963
+ if (typeof item.action === 'function') {
964
+ item.action(payload)
965
+ }
966
+
967
+ emit('row-context-menu-action', payload)
968
+ closeRowContextMenu()
969
+ }
970
+
692
971
  function openFilter(header, event) {
693
972
  if (!isHeaderFilterable(header)) {
694
973
  return
@@ -756,6 +1035,39 @@ function onFilterCancel() {
756
1035
  activeFilterValue.value = []
757
1036
  }
758
1037
 
1038
+ const canHideActiveFilterColumn = computed(() => {
1039
+ if (!showColumnOptionsButton.value || !activeFilterHeader.value) {
1040
+ return false
1041
+ }
1042
+ const header = activeFilterHeader.value
1043
+ if (!header.key || header.hideable === false || header.hidden) {
1044
+ return false
1045
+ }
1046
+ if (!isHeaderHidden(header) && visibleColumnKeys.value.length <= 1) {
1047
+ return false
1048
+ }
1049
+ return configurableHeaders.value.some((item) => item.key === header.key)
1050
+ })
1051
+
1052
+ function onFilterHideColumn() {
1053
+ const header = activeFilterHeader.value
1054
+ if (!header?.key || !canHideActiveFilterColumn.value) {
1055
+ onFilterCancel()
1056
+ return
1057
+ }
1058
+
1059
+ const nextVisibleKeys = visibleColumnKeys.value.filter((key) => key !== header.key)
1060
+ if (nextVisibleKeys.length === 0) {
1061
+ onFilterCancel()
1062
+ return
1063
+ }
1064
+
1065
+ setColumnVisibility(nextVisibleKeys)
1066
+ updateActiveLayoutLabel()
1067
+ filterPopoverOpen.value = false
1068
+ onFilterCancel()
1069
+ }
1070
+
759
1071
  function buildColumnVisibility(visibleKeys) {
760
1072
  const visibleKeySet = new Set(visibleKeys)
761
1073
  return Object.fromEntries(
@@ -1457,10 +1769,75 @@ th:first-child {
1457
1769
  padding-left: 1rem;
1458
1770
  }
1459
1771
 
1772
+ th.table-select-col,
1773
+ td.table-select-col {
1774
+ width: 2.5rem;
1775
+ min-width: 2.5rem;
1776
+ max-width: 2.5rem;
1777
+ text-align: center;
1778
+ vertical-align: middle;
1779
+ padding-left: 1rem;
1780
+ padding-right: 0.25rem;
1781
+ }
1782
+
1783
+ .table-select-label {
1784
+ display: inline-flex;
1785
+ align-items: center;
1786
+ justify-content: center;
1787
+ margin: 0;
1788
+ cursor: pointer;
1789
+ }
1790
+
1460
1791
  tbody tr.row-clickable {
1461
1792
  cursor: pointer;
1462
1793
  }
1463
1794
 
1795
+ tbody tr.row-selected > td {
1796
+ background-color: var(--control-checked-bg);
1797
+ color: var(--control-checked-fg);
1798
+ }
1799
+
1800
+ table.row-hover tbody tr.row-selected:hover > td,
1801
+ table.row-hover tbody tr.row-selected:focus-within > td,
1802
+ table.row-hover tbody tr.row-selected.row-context-menu-active > td {
1803
+ background-color: var(--control-checked-hover-bg);
1804
+ color: var(--control-checked-fg);
1805
+ }
1806
+
1807
+ /* Keep hover styling while the row context menu is open (pointer has left the row). */
1808
+ table.row-hover tbody tr.row-context-menu-active > td {
1809
+ background-color: var(--table-row-hover-bg);
1810
+ color: var(--table-row-hover-fg);
1811
+ }
1812
+
1813
+ /* Beat Femtocrank sticky cell backgrounds so selection remains visible while scrolling. */
1814
+ .table-scroll.sticky-cols-1 table tbody tr.row-selected > td,
1815
+ .table-scroll.sticky-cols-2 table tbody tr.row-selected > td,
1816
+ .table-scroll.sticky-cols-3 table tbody tr.row-selected > td {
1817
+ background-color: var(--control-checked-bg);
1818
+ color: var(--control-checked-fg);
1819
+ }
1820
+
1821
+ .table-scroll.sticky-cols-1 table.row-hover tbody tr.row-selected:hover > td,
1822
+ .table-scroll.sticky-cols-1 table.row-hover tbody tr.row-selected:focus-within > td,
1823
+ .table-scroll.sticky-cols-1 table.row-hover tbody tr.row-selected.row-context-menu-active > td,
1824
+ .table-scroll.sticky-cols-2 table.row-hover tbody tr.row-selected:hover > td,
1825
+ .table-scroll.sticky-cols-2 table.row-hover tbody tr.row-selected:focus-within > td,
1826
+ .table-scroll.sticky-cols-2 table.row-hover tbody tr.row-selected.row-context-menu-active > td,
1827
+ .table-scroll.sticky-cols-3 table.row-hover tbody tr.row-selected:hover > td,
1828
+ .table-scroll.sticky-cols-3 table.row-hover tbody tr.row-selected:focus-within > td,
1829
+ .table-scroll.sticky-cols-3 table.row-hover tbody tr.row-selected.row-context-menu-active > td {
1830
+ background-color: var(--control-checked-hover-bg);
1831
+ color: var(--control-checked-fg);
1832
+ }
1833
+
1834
+ .table-scroll.sticky-cols-1 table.row-hover tbody tr.row-context-menu-active > td,
1835
+ .table-scroll.sticky-cols-2 table.row-hover tbody tr.row-context-menu-active > td,
1836
+ .table-scroll.sticky-cols-3 table.row-hover tbody tr.row-context-menu-active > td {
1837
+ background-color: var(--table-row-hover-bg);
1838
+ color: var(--table-row-hover-fg);
1839
+ }
1840
+
1464
1841
  .table-empty-state {
1465
1842
  display: flex;
1466
1843
  align-items: center;
@@ -10,9 +10,27 @@
10
10
  @click.stop
11
11
  >
12
12
  <div class="popover-body">
13
- <h4 :id="titleId" ref="titleRef" class="popover-title">
14
- {{ popoverTitle }}
15
- </h4>
13
+ <div class="popover-title-row">
14
+ <h4 :id="titleId" ref="titleRef" class="popover-title">
15
+ {{ popoverTitle }}
16
+ </h4>
17
+ <button
18
+ v-if="canHide"
19
+ type="button"
20
+ class="neutral inline-icon popover-hide-column"
21
+ :aria-label="`Hide ${columnLabel || 'column'} column`"
22
+ title="Hide column"
23
+ @click="hideColumn"
24
+ >
25
+ <HugeiconsIcon
26
+ :icon="ViewOffIcon"
27
+ width="1em"
28
+ height="1em"
29
+ :strokeWidth="2.5"
30
+ aria-hidden="true"
31
+ />
32
+ </button>
33
+ </div>
16
34
 
17
35
  <div v-if="filterType === 'text'" class="filter-fields">
18
36
  <label>
@@ -148,7 +166,7 @@
148
166
  <script setup>
149
167
  import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
150
168
  import { HugeiconsIcon } from '@hugeicons/vue'
151
- import { Delete02Icon } from '@hugeicons/core-free-icons'
169
+ import { Delete02Icon, ViewOffIcon } from '@hugeicons/core-free-icons'
152
170
  import CheckGroup from './CheckGroup.vue'
153
171
  import RadioGroup from './RadioGroup.vue'
154
172
  import {
@@ -186,9 +204,13 @@ const props = defineProps({
186
204
  type: Array,
187
205
  default: () => [],
188
206
  },
207
+ canHide: {
208
+ type: Boolean,
209
+ default: false,
210
+ },
189
211
  })
190
212
 
191
- const emit = defineEmits(['update:open', 'apply', 'clear', 'cancel'])
213
+ const emit = defineEmits(['update:open', 'apply', 'clear', 'cancel', 'hide'])
192
214
 
193
215
  const MIN_VISIBLE_OPTIONS = 3
194
216
  const MAX_VISIBLE_OPTIONS = 20
@@ -441,6 +463,14 @@ function cancel() {
441
463
  emit('cancel')
442
464
  }
443
465
 
466
+ function hideColumn() {
467
+ if (!props.canHide) {
468
+ return
469
+ }
470
+ emit('hide')
471
+ emit('update:open', false)
472
+ }
473
+
444
474
  function detachDocumentListeners() {
445
475
  document.removeEventListener('pointerdown', onDocumentPointerDown)
446
476
  document.removeEventListener('keydown', onDocumentKeyDown)
@@ -459,7 +489,9 @@ async function initializePopover() {
459
489
  updateCheckGroupScrollLimit()
460
490
  await nextTick()
461
491
  positionPopover()
462
- popoverRef.value?.querySelector('input, select, button')?.focus()
492
+ popoverRef.value?.querySelector(
493
+ '.filter-fields input, .filter-fields select, .filter-fields button, .popover-actions button',
494
+ )?.focus()
463
495
  attachDocumentListeners()
464
496
  }
465
497
 
@@ -511,10 +543,26 @@ onBeforeUnmount(() => {
511
543
  flex-direction: column;
512
544
  }
513
545
 
546
+ .popover-title-row {
547
+ display: flex;
548
+ align-items: flex-start;
549
+ justify-content: space-between;
550
+ gap: 0.5rem;
551
+ margin-bottom: 0.75rem;
552
+ flex-shrink: 0;
553
+ }
554
+
514
555
  .popover-title {
515
- margin: 0 0 0.75rem;
556
+ margin: 0;
516
557
  font-size: 0.95rem;
517
- flex-shrink: 0;
558
+ min-width: 0;
559
+ flex: 1 1 auto;
560
+ }
561
+
562
+ .popover-hide-column {
563
+ flex: 0 0 auto;
564
+ padding: 0.2em 0.45em;
565
+ line-height: 1;
518
566
  }
519
567
 
520
568
  .filter-fields {