@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
|
@@ -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
|
|
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"
|