@redseed/redseed-ui-vue3 8.38.0 → 8.40.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "8.38.0",
3
+ "version": "8.40.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -0,0 +1,146 @@
1
+ <script setup>
2
+ import { ref, computed } from 'vue'
3
+ import { useResizeObserver, useMutationObserver } from '@vueuse/core'
4
+ import Card from './Card.vue'
5
+
6
+ const props = defineProps({
7
+ clickable: {
8
+ type: Boolean,
9
+ default: true,
10
+ },
11
+ hoverable: {
12
+ type: Boolean,
13
+ default: true,
14
+ },
15
+ bordered: {
16
+ type: Boolean,
17
+ default: true,
18
+ },
19
+ disabled: {
20
+ type: Boolean,
21
+ default: false,
22
+ },
23
+ // Accessible name for the clickable action layer (forwarded to Card's ariaLabel prop).
24
+ // Equivalent to the #aria-label slot — use either form, not both.
25
+ ariaLabel: {
26
+ type: String,
27
+ default: undefined,
28
+ },
29
+ phComponent: {
30
+ type: String,
31
+ default: 'CardHorizontal',
32
+ },
33
+ })
34
+
35
+ defineEmits(['click'])
36
+
37
+ /* ── Floating thumbnail detection ────────────────────────────────────────────
38
+ Adds --floating to the thumbnail whenever the image does not reach the card
39
+ bottom, so CSS can restore the image's bottom-right radius.
40
+
41
+ Two independent conditions trigger floating. clientHeight is integer-rounded, so
42
+ strict `<` comparisons are stable — sub-pixel gaps (< 0.5 px) round to 0 and
43
+ produce equal clientHeights, leaving truly-flush images unaffected:
44
+ 1. imgH > 0 && imgH < thumbH
45
+ Landscape: the loaded img is shorter than the 160px rail.
46
+ The imgH > 0 guard avoids a false positive on square cards while the
47
+ Image component's picture is still v-show=false (img.clientHeight = 0).
48
+ 2. thumbH < cardContentH
49
+ Tall-body: the body column is taller than the 160px thumbnail.
50
+
51
+ NOTE: thumbIsFloating applies --floating in both stacked (mobile) and rail
52
+ (≥512px container) layouts. In the stacked layout the --floating CSS rules are
53
+ scoped to the @container/card-horizontal block so they have no visual effect —
54
+ the flag may be true in the stacked layout but is intentionally not guarded here.
55
+
56
+ useResizeObserver watches three targets: the thumbnail element, its parent
57
+ (content wrapper), and the currently-rendered img (currentImg ref — auto-retargets
58
+ via vueuse's reactive watch when the img reference changes, replacing the manual
59
+ unobserve/observe swap in the previous hand-rolled implementation).
60
+ useMutationObserver fires updateFloating when DOM children change in the thumbnail
61
+ subtree — catches Image component adding its <img> when originalUrl is set after
62
+ the card has already mounted (skeleton-first / data-loaded-later pattern).
63
+ All observers auto-cleanup on scope dispose (no onMounted/onUnmounted needed). */
64
+
65
+ const thumbEl = ref(null)
66
+ const thumbIsFloating = ref(false)
67
+ const currentImg = ref(null)
68
+ const thumbParent = computed(() => thumbEl.value?.parentElement ?? null)
69
+
70
+ function updateFloating() {
71
+ const thumb = thumbEl.value
72
+ if (!thumb) return
73
+
74
+ // Scoped img lookup: prefer the img inside an rsui-image wrapper (the common
75
+ // case), then fall back to a raw direct-child img. The :scope > img limit
76
+ // prevents a badge or avatar img nested deeper in the slot from being measured.
77
+ const img =
78
+ thumb.querySelector('.rsui-image img') ||
79
+ thumb.querySelector(':scope > img')
80
+
81
+ // Keep currentImg in sync so useResizeObserver(currentImg) auto-retargets
82
+ // the img observer when a late-appearing element replaces the previous one.
83
+ currentImg.value = img ?? null
84
+
85
+ const imgH = img ? img.clientHeight : 0
86
+ const thumbH = thumb.clientHeight
87
+ const cardContentH = thumb.parentElement ? thumb.parentElement.clientHeight : thumbH
88
+ // imgH > 0 guard on condition 1: while the Image component's picture is v-show=false
89
+ // (loading / empty state), img.clientHeight is 0. Without the guard a square card would
90
+ // incorrectly receive --floating at mount; the class self-corrects once the image loads
91
+ // and condition 1 can be re-evaluated with the real rendered height.
92
+ //
93
+ // Strict comparison (no px tolerance): clientHeight is integer-rounded, so a truly-flush
94
+ // image (sub-pixel gap < 0.5 px) produces equal integer heights and the condition is
95
+ // false. ANY visible gap (≥ 1 integer px) correctly triggers floating.
96
+ thumbIsFloating.value = (imgH > 0 && imgH < thumbH) || (thumbH < cardContentH)
97
+ }
98
+
99
+ useResizeObserver(thumbEl, updateFloating)
100
+ useResizeObserver(thumbParent, updateFloating)
101
+ useResizeObserver(currentImg, updateFloating)
102
+ useMutationObserver(thumbEl, updateFloating, { childList: true, subtree: true })
103
+ </script>
104
+ <template>
105
+ <Card
106
+ class="rsui-card-horizontal"
107
+ :clickable="clickable"
108
+ :hoverable="hoverable"
109
+ :bordered="bordered"
110
+ :disabled="disabled"
111
+ :padded="false"
112
+ :aria-label="ariaLabel"
113
+ :ph-component="phComponent"
114
+ @click="$emit('click')"
115
+ >
116
+ <template v-if="$slots['aria-label']" #aria-label>
117
+ <slot name="aria-label"></slot>
118
+ </template>
119
+
120
+ <!-- thumbnail slot: required for the horizontal rail layout.
121
+ Pass an Image component (originalUrl + alt) to populate the 160px column.
122
+ An empty slot renders a blank 160px rail gap in the rail layout. -->
123
+ <div
124
+ ref="thumbEl"
125
+ class="rsui-card-horizontal__thumbnail"
126
+ :class="{ 'rsui-card-horizontal__thumbnail--floating': thumbIsFloating }"
127
+ >
128
+ <slot name="thumbnail"></slot>
129
+ </div>
130
+
131
+ <div class="rsui-card-horizontal__body">
132
+ <div class="rsui-card-horizontal__content">
133
+ <div v-if="$slots.title" class="rsui-card-horizontal__title">
134
+ <slot name="title"></slot>
135
+ </div>
136
+ <div v-if="$slots.description" class="rsui-card-horizontal__description">
137
+ <slot name="description"></slot>
138
+ </div>
139
+ </div>
140
+
141
+ <div v-if="$slots.meta" class="rsui-card-horizontal__meta">
142
+ <slot name="meta"></slot>
143
+ </div>
144
+ </div>
145
+ </Card>
146
+ </template>
@@ -2,6 +2,7 @@ import ButtonCard from './ButtonCard.vue'
2
2
  import CheckboxCard from './CheckboxCard.vue'
3
3
  import Card from './Card.vue'
4
4
  import CardHeader from './CardHeader.vue'
5
+ import CardHorizontal from './CardHorizontal.vue'
5
6
  import CardResource from './CardResource.vue'
6
7
  import RadioCard from './RadioCard.vue'
7
8
  import MetricCard from './MetricCard.vue'
@@ -10,6 +11,7 @@ export {
10
11
  CheckboxCard,
11
12
  Card,
12
13
  CardHeader,
14
+ CardHorizontal,
13
15
  CardResource,
14
16
  RadioCard,
15
17
  MetricCard,
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, ref, watch } from 'vue'
2
+ import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
3
3
  import { Card, CardHeader } from '../Card'
4
4
  import Tr from './Tr.vue'
5
5
  import Th from './Th.vue'
@@ -49,6 +49,15 @@ const props = defineProps({
49
49
  type: Boolean,
50
50
  default: false,
51
51
  },
52
+ // Opt-in click-and-drag horizontal scrolling. When true, the user can grab
53
+ // the scrollable columns and drag left/right to pan the table from anywhere,
54
+ // not just the bottom scrollbar. A plain click still emits `click:row`; only a
55
+ // drag past a small threshold scrolls (and suppresses the trailing click).
56
+ // Mouse only — native touch panning is left untouched.
57
+ dragScroll: {
58
+ type: Boolean,
59
+ default: false,
60
+ },
52
61
  })
53
62
 
54
63
  // v-model:visibleKeys — undefined means "uncontrolled" / use internal default (all visible).
@@ -87,6 +96,80 @@ const visibleColumns = computed(() => {
87
96
  return effectiveVisibleKeys.value.includes(column.key)
88
97
  })
89
98
  })
99
+
100
+ // --- Drag-to-scroll (opt-in via `dragScroll`) --------------------------------
101
+ const scrollContainer = ref(null)
102
+ const isScrollable = ref(false) // does the container actually overflow horizontally?
103
+ const isDragging = ref(false) // true once a press crosses the drag threshold
104
+
105
+ const DRAG_THRESHOLD = 5 // px of movement before a press becomes a drag, not a click
106
+
107
+ let pressX = 0
108
+ let pressScrollLeft = 0
109
+ let pressing = false
110
+ let dragged = false
111
+
112
+ function updateScrollable() {
113
+ const el = scrollContainer.value
114
+ isScrollable.value = !!el && el.scrollWidth > el.clientWidth
115
+ }
116
+
117
+ function onPointerDown(event) {
118
+ if (!props.dragScroll) return
119
+ // Mouse only — leave native touch panning alone — and primary button only.
120
+ if (event.pointerType !== 'mouse' || event.button !== 0) return
121
+ const el = scrollContainer.value
122
+ if (!el || el.scrollWidth <= el.clientWidth) return
123
+ // The pinned column is the anchor: grab the scrollable columns, not it.
124
+ if (event.target.closest?.('.rsui-td--pinned, .rsui-th--pinned')) return
125
+ pressing = true
126
+ dragged = false
127
+ pressX = event.clientX
128
+ pressScrollLeft = el.scrollLeft
129
+ }
130
+
131
+ function onPointerMove(event) {
132
+ if (!pressing) return
133
+ const dx = event.clientX - pressX
134
+ if (!dragged) {
135
+ if (Math.abs(dx) < DRAG_THRESHOLD) return
136
+ dragged = true
137
+ isDragging.value = true
138
+ scrollContainer.value?.setPointerCapture?.(event.pointerId)
139
+ }
140
+ event.preventDefault() // suppress text selection while dragging
141
+ scrollContainer.value.scrollLeft = pressScrollLeft - dx
142
+ }
143
+
144
+ function endPress(event) {
145
+ if (!pressing) return
146
+ pressing = false
147
+ isDragging.value = false
148
+ const el = scrollContainer.value
149
+ if (el?.hasPointerCapture?.(event.pointerId)) el.releasePointerCapture(event.pointerId)
150
+ // `dragged` stays true so the click-capture handler swallows the click the
151
+ // browser fires after a drag; it resets on the next press.
152
+ }
153
+
154
+ function onClickCapture(event) {
155
+ // Swallow the click that trails a drag so a row isn't activated by scrolling.
156
+ if (dragged) {
157
+ event.stopPropagation()
158
+ event.preventDefault()
159
+ dragged = false
160
+ }
161
+ }
162
+
163
+ let resizeObserver = null
164
+ onMounted(() => {
165
+ updateScrollable()
166
+ if (window.ResizeObserver && scrollContainer.value) {
167
+ resizeObserver = new ResizeObserver(updateScrollable)
168
+ resizeObserver.observe(scrollContainer.value)
169
+ }
170
+ })
171
+ onBeforeUnmount(() => resizeObserver?.disconnect())
172
+ watch([() => props.rows, visibleColumns], () => nextTick(updateScrollable), { deep: true })
90
173
  </script>
91
174
 
92
175
  <template>
@@ -138,7 +221,20 @@ const visibleColumns = computed(() => {
138
221
  },
139
222
  ]"
140
223
  >
141
- <div class="rsui-table__container">
224
+ <div ref="scrollContainer"
225
+ :class="[
226
+ 'rsui-table__container',
227
+ {
228
+ 'rsui-table__container--draggable': dragScroll && isScrollable,
229
+ 'rsui-table__container--dragging': isDragging,
230
+ },
231
+ ]"
232
+ @pointerdown="onPointerDown"
233
+ @pointermove="onPointerMove"
234
+ @pointerup="endPress"
235
+ @pointercancel="endPress"
236
+ @click.capture="onClickCapture"
237
+ >
142
238
  <table
143
239
  :aria-labelledby="showHeader && $slots.title ? titleId : undefined"
144
240
  :aria-label="!showHeader || !$slots.title ? ariaLabel : undefined"