srcdev-nuxt-components 6.2.12 → 6.3.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.
@@ -1,10 +1,44 @@
1
1
  <template>
2
- <div v-if="displayComponent" class="marquee-scroller" :reverse>
3
- <ul class="list">
4
- <li v-for="item in marqueeData" :key="item.id" class="item" :style="{ '--position': item.id }">
5
- <slot :name="item.id"></slot>
6
- </li>
7
- </ul>
2
+ <div
3
+ v-if="displayComponent"
4
+ class="marquee-scroller"
5
+ :class="{ reverse: reverse, paused: isPaused, 'reduced-motion': prefersReducedMotion }"
6
+ role="region"
7
+ :aria-label="ariaLabel || 'Scrolling content'"
8
+ :aria-live="isPaused ? 'polite' : 'off'"
9
+ tabindex="0"
10
+ @keydown="handleKeydown"
11
+ @focus="handleFocus"
12
+ @blur="handleBlur"
13
+ >
14
+ <!-- Screen reader instructions -->
15
+ <div class="sr-only">
16
+ {{ ariaDescription || "Use spacebar to pause/play animation, arrow keys when focused for manual control" }}
17
+ </div>
18
+
19
+ <!-- Pause/Play button -->
20
+ <button
21
+ v-if="showControls"
22
+ class="control-btn"
23
+ @click="togglePause"
24
+ :aria-label="isPaused ? 'Play animation' : 'Pause animation'"
25
+ type="button"
26
+ >
27
+ <span aria-hidden="true">{{ isPaused ? "▶" : "⏸" }}</span>
28
+ </button>
29
+
30
+ <div class="marquee-track" :aria-hidden="!isPaused">
31
+ <div class="marquee-group">
32
+ <div v-for="item in marqueeData" :key="item.id" class="item">
33
+ <slot :name="item.id"></slot>
34
+ </div>
35
+ </div>
36
+ <div class="marquee-group" aria-hidden="true">
37
+ <div v-for="item in marqueeData" :key="`duplicate-${item.id}`" class="item">
38
+ <slot :name="item.id"></slot>
39
+ </div>
40
+ </div>
41
+ </div>
8
42
  </div>
9
43
  </template>
10
44
 
@@ -27,30 +61,95 @@ const props = defineProps({
27
61
  default: () => ({
28
62
  width: "50px",
29
63
  height: "50px",
30
- quantity: 30,
64
+ gap: "16px",
31
65
  }),
32
66
  required: true,
33
67
  },
68
+ // Accessibility props
69
+ ariaLabel: {
70
+ type: String,
71
+ default: null,
72
+ },
73
+ ariaDescription: {
74
+ type: String,
75
+ default: null,
76
+ },
77
+ showControls: {
78
+ type: Boolean,
79
+ default: false,
80
+ },
81
+ respectReducedMotion: {
82
+ type: Boolean,
83
+ default: true,
84
+ },
34
85
  })
35
86
 
36
87
  const displayComponent = ref(false)
88
+ const isPaused = ref(false)
89
+ const isFocused = ref(false)
90
+ const prefersReducedMotion = ref(false)
37
91
 
38
92
  const height = computed(() => props.itemConfig.height)
39
- const quantity = computed(() => props.itemConfig.quantity)
40
93
  const width = computed(() => props.itemConfig.width)
94
+ const gap = computed(() => props.itemConfig.gap || "16px")
41
95
 
42
- const animationRuntimeNumber = computed(() => {
43
- const [seconds] = props.animationRuntime.split("s")
44
- return parseFloat(seconds ?? "0")
45
- })
96
+ // Check for reduced motion preference
97
+ const checkReducedMotion = () => {
98
+ if (typeof window !== "undefined" && props.respectReducedMotion) {
99
+ const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
100
+ prefersReducedMotion.value = mediaQuery.matches
46
101
 
47
- const animationDelay = computed(() => {
48
- return Math.floor(animationRuntimeNumber.value * 1.25) + "s"
49
- })
102
+ // Listen for changes
103
+ mediaQuery.addEventListener("change", (e) => {
104
+ prefersReducedMotion.value = e.matches
105
+ if (e.matches) {
106
+ isPaused.value = true
107
+ }
108
+ })
109
+ }
110
+ }
111
+
112
+ const togglePause = () => {
113
+ isPaused.value = !isPaused.value
114
+ }
115
+
116
+ const handleKeydown = (event: KeyboardEvent) => {
117
+ switch (event.key) {
118
+ case " ":
119
+ case "Spacebar":
120
+ event.preventDefault()
121
+ togglePause()
122
+ break
123
+ case "ArrowLeft":
124
+ case "ArrowRight":
125
+ // Could add manual stepping functionality here
126
+ event.preventDefault()
127
+ break
128
+ }
129
+ }
130
+
131
+ const handleFocus = () => {
132
+ isFocused.value = true
133
+ if (props.respectReducedMotion) {
134
+ isPaused.value = true
135
+ }
136
+ }
137
+
138
+ const handleBlur = () => {
139
+ isFocused.value = false
140
+ if (!prefersReducedMotion.value) {
141
+ isPaused.value = false
142
+ }
143
+ }
50
144
 
51
145
  onMounted(() => {
52
- console.log(`Mounted: quantity(${quantity.value}) | animationDelay(${animationDelay.value})`)
53
146
  displayComponent.value = true
147
+ checkReducedMotion()
148
+
149
+ // Auto-pause if user prefers reduced motion
150
+ if (prefersReducedMotion.value) {
151
+ isPaused.value = true
152
+ }
54
153
  })
55
154
  </script>
56
155
 
@@ -60,63 +159,129 @@ onMounted(() => {
60
159
  height: v-bind(height);
61
160
  overflow: hidden;
62
161
  mask-image: linear-gradient(to right, transparent, #000 10% 90%, transparent);
162
+ position: relative;
163
+
164
+ /* Focus styles */
165
+ &:focus {
166
+ outline: 2px solid var(--color-focus, #0066cc);
167
+ outline-offset: 2px;
168
+ }
169
+
170
+ /* Paused state */
171
+ &.paused .marquee-track {
172
+ animation-play-state: paused;
173
+ }
174
+
175
+ /* Reduced motion - disable animation completely */
176
+ &.reduced-motion .marquee-track {
177
+ animation: none !important;
178
+ }
179
+
180
+ &:hover .marquee-track {
181
+ animation-play-state: paused;
182
+ }
63
183
 
64
184
  &:hover .item {
65
- animation-play-state: paused !important;
66
185
  filter: grayscale(1);
67
186
  }
68
187
 
69
- &[reverse="true"] .item {
70
- animation: reversePlay v-bind(animationRuntime) linear infinite;
188
+ /* Screen reader only text */
189
+ .sr-only {
190
+ position: absolute;
191
+ width: 1px;
192
+ height: 1px;
193
+ padding: 0;
194
+ margin: -1px;
195
+ overflow: hidden;
196
+ clip: rect(0, 0, 0, 0);
197
+ white-space: nowrap;
198
+ border: 0;
71
199
  }
72
200
 
73
- .list {
201
+ /* Control button */
202
+ .control-btn {
203
+ position: absolute;
204
+ inset-block-start: 8px;
205
+ inset-inline-end: 8px;
206
+ z-index: 10;
207
+ background: rgba(0, 0, 0, 0.7);
208
+ color: white;
209
+ border: none;
210
+ border-radius: 4px;
211
+ padding: 8px;
212
+ cursor: pointer;
213
+ font-size: 14px;
214
+ transition: background-color 0.2s;
215
+
216
+ &:hover {
217
+ background-color: rgba(0, 0, 0, 0.9);
218
+ }
219
+
220
+ &:focus {
221
+ outline: 2px solid var(--color-focus, #0066cc);
222
+ outline-offset: 2px;
223
+ }
224
+ }
225
+
226
+ .marquee-track {
74
227
  display: flex;
75
- width: 100%;
228
+ width: fit-content;
229
+ gap: v-bind(gap);
230
+ animation: marqueeMove v-bind(animationRuntime) linear infinite;
231
+ }
232
+
233
+ &.reverse .marquee-track {
234
+ animation-direction: reverse;
235
+ }
236
+
237
+ .marquee-group {
238
+ display: flex;
239
+ gap: v-bind(gap);
240
+ flex-shrink: 0;
241
+ }
242
+
243
+ .item {
244
+ width: v-bind(width);
76
245
  height: v-bind(height);
77
- min-width: calc(v-bind(width) * v-bind(quantity));
78
- position: relative;
79
-
80
- .item {
81
- width: v-bind(width);
82
- height: v-bind(height);
83
- display: grid;
84
- place-items: center;
85
- position: absolute;
86
- aspect-ratio: 1 / 1;
87
- left: 100%;
88
- animation: autoRun v-bind(animationRuntime) linear infinite;
89
- transition: filter 0.5s;
90
- /* animation-delay: calc((50s / v-bind(quantity)) * (var(--position) - 1) - 50s) !important; */
91
- animation-delay: calc(
92
- (v-bind(animationDelay) / v-bind(quantity)) * (var(--position) - 1) - v-bind(animationDelay)
93
- ) !important;
94
-
95
- border: 1px solid light-dark(var(--gray-12), var(--gray-0));
96
- border-radius: 4px;
97
-
98
- &:hover {
99
- filter: grayscale(0);
100
- }
246
+ display: grid;
247
+ place-items: center;
248
+ aspect-ratio: 1 / 1;
249
+ transition: filter 0.5s;
250
+ flex-shrink: 0;
251
+
252
+ border: 1px solid light-dark(var(--gray-12), var(--gray-0));
253
+ border-radius: 4px;
254
+
255
+ &:hover {
256
+ filter: grayscale(0);
101
257
  }
102
258
  }
103
259
  }
104
260
 
105
- @keyframes autoRun {
261
+ @keyframes marqueeMove {
106
262
  from {
107
- left: 100%;
263
+ transform: translateX(0);
108
264
  }
109
265
  to {
110
- left: calc(v-bind(width) * -1);
266
+ transform: translateX(-50%);
111
267
  }
112
268
  }
113
269
 
114
- @keyframes reversePlay {
115
- from {
116
- left: calc(v-bind(width) * -1);
270
+ /* High contrast mode support */
271
+ @media (prefers-contrast: high) {
272
+ .marquee-scroller {
273
+ .control-btn {
274
+ background: ButtonFace;
275
+ color: ButtonText;
276
+ border: 1px solid ButtonText;
277
+ }
117
278
  }
118
- to {
119
- left: 100%;
279
+ }
280
+
281
+ /* Respect user's motion preferences */
282
+ @media (prefers-reduced-motion: reduce) {
283
+ .marquee-scroller .marquee-track {
284
+ animation: none !important;
120
285
  }
121
286
  }
122
287
  </style>
@@ -53,8 +53,8 @@ watch(
53
53
  --_border-color: light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%));
54
54
  --_color: light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%));
55
55
 
56
- columns: var(--_item-min-width);
57
- gap: 12px;
56
+ columns: auto var(--_item-min-width);
57
+ column-gap: var(--_masonry-grid-gap);
58
58
 
59
59
  .masonry-grid-item {
60
60
  break-inside: avoid;
@@ -14,7 +14,7 @@ import { useElementSize, useResizeObserver } from "@vueuse/core"
14
14
  const props = defineProps({
15
15
  gridData: {
16
16
  type: Object,
17
- default: {},
17
+ default: () => ({}),
18
18
  },
19
19
  minTileWidth: {
20
20
  type: Number,
@@ -0,0 +1,181 @@
1
+ <template>
2
+ <div class="capture-qr-stream" :class="[elementClasses]">
3
+ <h2>Capture QR Code</h2>
4
+ <div v-if="!state.error">
5
+ <QrcodeStream v-if="state.cameraOn" ref="qrcodeStreamRef" @error="onError" @detect="onDetect" />
6
+ <div v-else class="camera-stopped">
7
+ <p>Camera stopped</p>
8
+ </div>
9
+ <div class="pt-4">
10
+ <h5>Scanned QRCodes:</h5>
11
+ <ul v-if="result" class="list-disc pl-4">
12
+ <li v-for="(r, i) in result" :key="i">
13
+ <span class="text-wrap wrap-anywhere">
14
+ {{ r }}
15
+ </span>
16
+ </li>
17
+ </ul>
18
+ </div>
19
+ </div>
20
+ <div v-else>
21
+ <h3>
22
+ {{ state.errorMsg }}
23
+ </h3>
24
+ <button @click="resetCamera">reset</button>
25
+ </div>
26
+ </div>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ import type { DetectedBarcode } from "nuxt-qrcode"
31
+
32
+ const props = defineProps({
33
+ styleClassPassthrough: {
34
+ type: [String, Array] as PropType<string | string[]>,
35
+ default: () => [],
36
+ },
37
+ })
38
+
39
+ const qrcodeStreamRef = ref()
40
+ const result = ref<string[]>()
41
+ const state = reactive({
42
+ errorMsg: "",
43
+ error: false,
44
+ cameraOn: true,
45
+ })
46
+
47
+ // Reset camera state when component mounts
48
+ onMounted(() => {
49
+ // Reset to default state on mount
50
+ state.cameraOn = true
51
+ state.error = false
52
+ state.errorMsg = ""
53
+ result.value = []
54
+
55
+ const handleVisibilityChange = () => {
56
+ if (document.hidden) {
57
+ state.cameraOn = false
58
+ stopAllMediaStreams()
59
+ }
60
+ }
61
+
62
+ document.addEventListener("visibilitychange", handleVisibilityChange)
63
+
64
+ // Cleanup listener on unmount
65
+ onBeforeUnmount(() => {
66
+ document.removeEventListener("visibilitychange", handleVisibilityChange)
67
+ })
68
+ })
69
+
70
+ function onDetect(detectedCodes: DetectedBarcode[]) {
71
+ result.value = detectedCodes.map((code) => {
72
+ // toast.add({
73
+ // title: 'Detected',
74
+ // description: `Value: ${code.rawValue}`,
75
+ // actions: [
76
+ // {
77
+ // label: 'Copy',
78
+ // onClick: () => {
79
+ // navigator.clipboard.writeText(code.rawValue)
80
+ // },
81
+ // },
82
+ // ],
83
+ // })
84
+ return code.rawValue
85
+ })
86
+ }
87
+
88
+ function onError(err: Error) {
89
+ state.error = true
90
+ state.errorMsg = `[${err.name}]: ${err.message}`
91
+ }
92
+
93
+ function resetCamera() {
94
+ state.error = false
95
+ state.cameraOn = true
96
+ }
97
+
98
+ // Function to stop all media streams
99
+ function stopAllMediaStreams() {
100
+ // Stop streams via the QrcodeStream component ref
101
+ if (qrcodeStreamRef.value) {
102
+ try {
103
+ // Try to access the video element and stop its tracks
104
+ const videoElement = qrcodeStreamRef.value.$el?.querySelector("video")
105
+ if (videoElement && videoElement.srcObject) {
106
+ const stream = videoElement.srcObject as MediaStream
107
+ const tracks = stream.getTracks()
108
+ tracks.forEach((track) => {
109
+ track.stop()
110
+ })
111
+ videoElement.srcObject = null
112
+ }
113
+ } catch (error) {
114
+ console.warn("Error stopping camera stream:", error)
115
+ }
116
+ }
117
+
118
+ // Global cleanup: Find all video elements and stop their streams
119
+ try {
120
+ const allVideoElements = document.querySelectorAll("video")
121
+ allVideoElements.forEach((video) => {
122
+ if (video.srcObject) {
123
+ const stream = video.srcObject as MediaStream
124
+ const tracks = stream.getTracks()
125
+ tracks.forEach((track) => {
126
+ track.stop()
127
+ })
128
+ video.srcObject = null
129
+ }
130
+ })
131
+ } catch (error) {
132
+ console.warn("Error in global video cleanup:", error)
133
+ }
134
+ }
135
+
136
+ // Watch for camera state changes
137
+ watch(
138
+ () => state.cameraOn,
139
+ (newValue) => {
140
+ if (!newValue) {
141
+ // Wait a tick for the component to unmount, then clean up
142
+ nextTick(() => {
143
+ stopAllMediaStreams()
144
+ })
145
+ }
146
+ }
147
+ )
148
+
149
+ // Stop camera when component is unmounted (e.g., route change)
150
+ onBeforeUnmount(() => {
151
+ state.cameraOn = false
152
+ stopAllMediaStreams()
153
+ })
154
+
155
+ // Also handle dynamic component switching (like in [componentName].vue)
156
+ onDeactivated(() => {
157
+ state.cameraOn = false
158
+ stopAllMediaStreams()
159
+ })
160
+
161
+ onActivated(() => {
162
+ // Reset state when component becomes active again
163
+ state.cameraOn = true
164
+ state.error = false
165
+ state.errorMsg = ""
166
+ })
167
+
168
+ // Use Nuxt's navigation guard to stop camera before route changes
169
+ onBeforeRouteLeave(() => {
170
+ state.cameraOn = false
171
+ stopAllMediaStreams()
172
+ })
173
+
174
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
175
+ </script>
176
+
177
+ <style lang="css">
178
+ .capture-qr-stream {
179
+ aspect-ratio: 1 / 1;
180
+ }
181
+ </style>
@@ -0,0 +1,77 @@
1
+ <template>
2
+ <div class="decode-qr-code" :class="[elementClasses]">
3
+ <h2>Upload QR Code</h2>
4
+ <QrcodeCapture class="qr-code-capture" @detect="onDetect" />
5
+
6
+ <h2>Drop QR Code</h2>
7
+ <QrcodeDropZone class="qr-code-dropzone" @detect="onDetect" @dragover="onDropping" />
8
+
9
+ <div v-if="isDropping">
10
+ <h5>Scanned QRCodes (Dropped): {{ isDropping ? "Dropping..." : "" }}</h5>
11
+ </div>
12
+
13
+ <div class="pt-4">
14
+ <h5>Scanned QRCodes:</h5>
15
+ <ul v-if="result" class="list-disc pl-4">
16
+ <li v-for="(r, i) in result" :key="i">
17
+ <span class="text-wrap wrap-anywhere">
18
+ {{ r }}
19
+ </span>
20
+ </li>
21
+ </ul>
22
+ </div>
23
+ </div>
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ import type { DetectedBarcode } from "nuxt-qrcode"
28
+
29
+ const props = defineProps({
30
+ styleClassPassthrough: {
31
+ type: [String, Array] as PropType<string | string[]>,
32
+ default: () => [],
33
+ },
34
+ })
35
+
36
+ const result = ref<string[]>()
37
+ const isDropping = ref(false)
38
+
39
+ function onDropping(dropping: boolean) {
40
+ isDropping.value = dropping
41
+ }
42
+
43
+ function onDetect(detectedCodes: DetectedBarcode[]) {
44
+ result.value = detectedCodes.map((code) => {
45
+ // toast.add({
46
+ // title: 'Detected',
47
+ // description: `Value: ${code.rawValue}`,
48
+ // actions: [
49
+ // {
50
+ // label: 'Copy',
51
+ // onClick: () => {
52
+ // navigator.clipboard.writeText(code.rawValue)
53
+ // },
54
+ // },
55
+ // ],
56
+ // })
57
+ return code.rawValue
58
+ })
59
+ }
60
+
61
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
62
+ </script>
63
+
64
+ <style lang="css">
65
+ .decode-qr-code {
66
+ aspect-ratio: 1 / 1;
67
+
68
+ .qr-code-capture {
69
+ }
70
+
71
+ .qr-code-dropzone {
72
+ min-height: 3rem;
73
+ border-radius: 0.5rem;
74
+ border: 2px dashed gray;
75
+ }
76
+ }
77
+ </style>
@@ -0,0 +1,51 @@
1
+ <template>
2
+ <Qrcode :value="qrValue" class="display-qr-code" :variant :radius :blackColor :whiteColor :class="[elementClasses]" />
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import type { QrCodeVariant } from "~/types/components/qr-code" // Adjust the import path as needed
7
+
8
+ const props = defineProps({
9
+ qrValue: {
10
+ type: String,
11
+ required: true,
12
+ },
13
+ variant: {
14
+ type: Object as PropType<QrCodeVariant>,
15
+ default: {
16
+ inner: "default",
17
+ marker: "default",
18
+ pixel: "default",
19
+ },
20
+ },
21
+ radius: {
22
+ type: Number,
23
+ default: 0,
24
+ },
25
+ blackColor: {
26
+ type: String,
27
+ default: "currentColor",
28
+ },
29
+ whiteColor: {
30
+ type: String,
31
+ default: "transparent",
32
+ },
33
+ size: {
34
+ type: String,
35
+ default: "256px",
36
+ },
37
+ styleClassPassthrough: {
38
+ type: [String, Array] as PropType<string | string[]>,
39
+ default: () => [],
40
+ },
41
+ })
42
+
43
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
44
+ </script>
45
+
46
+ <style lang="css">
47
+ .display-qr-code {
48
+ aspect-ratio: 1 / 1;
49
+ width: v-bind(size);
50
+ }
51
+ </style>