@for-the-people-initiative/design-system 1.3.2 → 1.3.4

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.
@@ -0,0 +1,264 @@
1
+ @use "../../../dist/scss/tokens" as *;
2
+
3
+ $c: lightbox;
4
+
5
+ // Overlay
6
+ .#{$c}__overlay {
7
+ position: fixed;
8
+ inset: 0;
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ z-index: var(--lightbox-zIndex-overlay, 2000);
13
+ background-color: var(--lightbox-overlay-background, rgba(0, 0, 0, 0.92));
14
+ outline: none;
15
+ }
16
+
17
+ // Close button
18
+ .#{$c}__close {
19
+ position: absolute;
20
+ top: 16px;
21
+ right: 16px;
22
+ z-index: 1;
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ width: var(--lightbox-closeButton-size, 40px);
27
+ height: var(--lightbox-closeButton-size, 40px);
28
+ padding: 0;
29
+ border: none;
30
+ background: var(--lightbox-nav-background-default, rgba(255, 255, 255, 0.1));
31
+ border-radius: var(--lightbox-nav-radius, 9999px);
32
+ color: var(--lightbox-nav-color-default, rgba(255, 255, 255, 0.8));
33
+ cursor: pointer;
34
+ transition: background-color 0.15s ease, color 0.15s ease;
35
+
36
+ &:hover {
37
+ background: var(--lightbox-nav-background-hover, rgba(255, 255, 255, 0.2));
38
+ color: var(--lightbox-nav-color-hover, #ffffff);
39
+ }
40
+
41
+ &:focus-visible {
42
+ outline: 2px solid rgba(255, 255, 255, 0.5);
43
+ outline-offset: 2px;
44
+ }
45
+
46
+ svg {
47
+ width: var(--lightbox-closeButton-iconSize, 20px);
48
+ height: var(--lightbox-closeButton-iconSize, 20px);
49
+ }
50
+ }
51
+
52
+ // Counter
53
+ .#{$c}__counter {
54
+ position: absolute;
55
+ top: 20px;
56
+ left: 50%;
57
+ transform: translateX(-50%);
58
+ z-index: 1;
59
+ color: var(--lightbox-counter-color, rgba(255, 255, 255, 0.7));
60
+ font-size: var(--lightbox-counter-fontSize, 14px);
61
+ font-variant-numeric: tabular-nums;
62
+ user-select: none;
63
+ }
64
+
65
+ // Navigation buttons
66
+ .#{$c}__nav {
67
+ position: absolute;
68
+ top: 50%;
69
+ transform: translateY(-50%);
70
+ z-index: 1;
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ width: var(--lightbox-nav-size, 48px);
75
+ height: var(--lightbox-nav-size, 48px);
76
+ padding: 0;
77
+ border: none;
78
+ background: var(--lightbox-nav-background-default, rgba(255, 255, 255, 0.1));
79
+ border-radius: var(--lightbox-nav-radius, 9999px);
80
+ color: var(--lightbox-nav-color-default, rgba(255, 255, 255, 0.8));
81
+ cursor: pointer;
82
+ transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
83
+
84
+ &:hover {
85
+ background: var(--lightbox-nav-background-hover, rgba(255, 255, 255, 0.2));
86
+ color: var(--lightbox-nav-color-hover, #ffffff);
87
+ }
88
+
89
+ &:focus-visible {
90
+ outline: 2px solid rgba(255, 255, 255, 0.5);
91
+ outline-offset: 2px;
92
+ }
93
+
94
+ svg {
95
+ width: var(--lightbox-nav-iconSize, 24px);
96
+ height: var(--lightbox-nav-iconSize, 24px);
97
+ }
98
+ }
99
+
100
+ .#{$c}__nav--prev {
101
+ left: 16px;
102
+ }
103
+
104
+ .#{$c}__nav--next {
105
+ right: 16px;
106
+ }
107
+
108
+ // Image container
109
+ .#{$c}__image-container {
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ width: 100%;
114
+ height: 100%;
115
+ padding: 64px 80px;
116
+ overflow: hidden;
117
+
118
+ @media (max-width: 768px) {
119
+ padding: 64px 16px;
120
+ }
121
+ }
122
+
123
+ // Image
124
+ .#{$c}__image {
125
+ max-width: 100%;
126
+ max-height: 100%;
127
+ object-fit: contain;
128
+ user-select: none;
129
+ cursor: default;
130
+ transition: transform 0.3s ease;
131
+ }
132
+
133
+ .#{$c}__image--zoomed {
134
+ cursor: zoom-out;
135
+ }
136
+
137
+ // Thumbnails
138
+ .#{$c}__thumbnails {
139
+ position: absolute;
140
+ bottom: 16px;
141
+ left: 50%;
142
+ transform: translateX(-50%);
143
+ display: flex;
144
+ gap: var(--lightbox-thumbnail-gap, 8px);
145
+ padding: 8px;
146
+ background: rgba(0, 0, 0, 0.4);
147
+ border-radius: 12px;
148
+ max-width: calc(100vw - 32px);
149
+ overflow-x: auto;
150
+ scrollbar-width: none;
151
+
152
+ &::-webkit-scrollbar {
153
+ display: none;
154
+ }
155
+ }
156
+
157
+ .#{$c}__thumbnail {
158
+ flex-shrink: 0;
159
+ width: var(--lightbox-thumbnail-size, 64px);
160
+ height: var(--lightbox-thumbnail-size, 64px);
161
+ padding: 0;
162
+ border: 2px solid transparent;
163
+ border-radius: var(--lightbox-thumbnail-radius, 6px);
164
+ background: none;
165
+ cursor: pointer;
166
+ overflow: hidden;
167
+ opacity: var(--lightbox-thumbnail-opacity-default, 0.5);
168
+ transition: opacity 0.15s ease, border-color 0.15s ease;
169
+
170
+ &:hover {
171
+ opacity: 0.8;
172
+ }
173
+
174
+ &:focus-visible {
175
+ outline: 2px solid rgba(255, 255, 255, 0.5);
176
+ outline-offset: 2px;
177
+ }
178
+
179
+ img {
180
+ width: 100%;
181
+ height: 100%;
182
+ object-fit: cover;
183
+ }
184
+ }
185
+
186
+ .#{$c}__thumbnail--active {
187
+ opacity: var(--lightbox-thumbnail-opacity-active, 1);
188
+ border-color: var(--lightbox-thumbnail-border-active, #ffffff);
189
+ }
190
+
191
+ // Transitions
192
+ .lightbox-fade-enter-active,
193
+ .lightbox-fade-leave-active {
194
+ transition: opacity 0.25s ease;
195
+ }
196
+
197
+ .lightbox-fade-enter-from,
198
+ .lightbox-fade-leave-to {
199
+ opacity: 0;
200
+ }
201
+
202
+ // Slide transitions
203
+ .lightbox-slide-left-enter-active,
204
+ .lightbox-slide-left-leave-active,
205
+ .lightbox-slide-right-enter-active,
206
+ .lightbox-slide-right-leave-active {
207
+ transition: transform 0.3s ease, opacity 0.3s ease;
208
+ position: absolute;
209
+ }
210
+
211
+ .lightbox-slide-left-enter-from {
212
+ transform: translateX(60px);
213
+ opacity: 0;
214
+ }
215
+
216
+ .lightbox-slide-left-leave-to {
217
+ transform: translateX(-60px);
218
+ opacity: 0;
219
+ }
220
+
221
+ .lightbox-slide-right-enter-from {
222
+ transform: translateX(-60px);
223
+ opacity: 0;
224
+ }
225
+
226
+ .lightbox-slide-right-leave-to {
227
+ transform: translateX(60px);
228
+ opacity: 0;
229
+ }
230
+
231
+ // Mobile adjustments
232
+ @media (max-width: 768px) {
233
+ .#{$c}__nav {
234
+ width: 40px;
235
+ height: 40px;
236
+
237
+ svg {
238
+ width: 20px;
239
+ height: 20px;
240
+ }
241
+ }
242
+
243
+ .#{$c}__nav--prev {
244
+ left: 8px;
245
+ }
246
+
247
+ .#{$c}__nav--next {
248
+ right: 8px;
249
+ }
250
+
251
+ .#{$c}__close {
252
+ top: 8px;
253
+ right: 8px;
254
+ }
255
+
256
+ .#{$c}__counter {
257
+ top: 12px;
258
+ }
259
+
260
+ .#{$c}__thumbnail {
261
+ width: 48px;
262
+ height: 48px;
263
+ }
264
+ }
@@ -0,0 +1,274 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <Transition name="lightbox-fade">
4
+ <div
5
+ v-if="visible"
6
+ class="lightbox__overlay"
7
+ role="dialog"
8
+ aria-modal="true"
9
+ aria-label="Image lightbox"
10
+ tabindex="-1"
11
+ @click="onOverlayClick"
12
+ @keydown.escape="close"
13
+ @keydown.left="prev"
14
+ @keydown.right="next"
15
+ >
16
+ <!-- Close button -->
17
+ <button
18
+ type="button"
19
+ class="lightbox__close"
20
+ aria-label="Close lightbox"
21
+ @click.stop="close"
22
+ >
23
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
24
+ <line x1="18" y1="6" x2="6" y2="18" />
25
+ <line x1="6" y1="6" x2="18" y2="18" />
26
+ </svg>
27
+ </button>
28
+
29
+ <!-- Counter -->
30
+ <div v-if="showCounter && images.length > 1" class="lightbox__counter">
31
+ {{ currentIndex + 1 }} / {{ images.length }}
32
+ </div>
33
+
34
+ <!-- Previous button -->
35
+ <button
36
+ v-if="images.length > 1"
37
+ type="button"
38
+ class="lightbox__nav lightbox__nav--prev"
39
+ aria-label="Previous image"
40
+ @click.stop="prev"
41
+ >
42
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
43
+ <polyline points="15 18 9 12 15 6" />
44
+ </svg>
45
+ </button>
46
+
47
+ <!-- Image -->
48
+ <div
49
+ class="lightbox__image-container"
50
+ @click.stop
51
+ @touchstart="onTouchStart"
52
+ @touchmove="onTouchMove"
53
+ @touchend="onTouchEnd"
54
+ >
55
+ <Transition :name="slideDirection">
56
+ <img
57
+ :key="currentIndex"
58
+ :src="currentImage"
59
+ :alt="`Image ${currentIndex + 1} of ${images.length}`"
60
+ class="lightbox__image"
61
+ :class="{ 'lightbox__image--zoomed': isZoomed }"
62
+ :style="zoomStyle"
63
+ draggable="false"
64
+ @click.stop="toggleZoom"
65
+ @load="onImageLoad"
66
+ />
67
+ </Transition>
68
+ </div>
69
+
70
+ <!-- Next button -->
71
+ <button
72
+ v-if="images.length > 1"
73
+ type="button"
74
+ class="lightbox__nav lightbox__nav--next"
75
+ aria-label="Next image"
76
+ @click.stop="next"
77
+ >
78
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
79
+ <polyline points="9 18 15 12 9 6" />
80
+ </svg>
81
+ </button>
82
+
83
+ <!-- Thumbnails -->
84
+ <div v-if="showThumbnails && images.length > 1" class="lightbox__thumbnails" @click.stop>
85
+ <button
86
+ v-for="(image, index) in images"
87
+ :key="index"
88
+ type="button"
89
+ class="lightbox__thumbnail"
90
+ :class="{ 'lightbox__thumbnail--active': index === currentIndex }"
91
+ :aria-label="`View image ${index + 1}`"
92
+ @click="goTo(index)"
93
+ >
94
+ <img :src="typeof image === 'string' ? image : image.src || image.thumbnail" :alt="`Thumbnail ${index + 1}`" />
95
+ </button>
96
+ </div>
97
+ </div>
98
+ </Transition>
99
+ </Teleport>
100
+ </template>
101
+
102
+ <style src="./Lightbox.scss"></style>
103
+
104
+ <script setup lang="ts">
105
+ import type { LightboxProps } from '../../types';
106
+ import { computed, ref, watch, onUnmounted, nextTick } from "vue";
107
+
108
+ defineOptions({ name: 'FtpLightbox' });
109
+
110
+ const props = withDefaults(defineProps<LightboxProps>(), {
111
+ images: () => [],
112
+ visible: false,
113
+ activeIndex: 0,
114
+ showThumbnails: false,
115
+ showCounter: true,
116
+ closeOnClickOutside: true,
117
+ zoom: false,
118
+ });
119
+
120
+ const emit = defineEmits<{
121
+ (e: 'update:visible', value: boolean): void
122
+ (e: 'update:activeIndex', value: number): void
123
+ (e: 'show'): void
124
+ (e: 'hide'): void
125
+ }>();
126
+
127
+ const internalIndex = ref(props.activeIndex);
128
+ const isZoomed = ref(false);
129
+ const slideDirection = ref('lightbox-slide-left');
130
+
131
+ // Touch/swipe state
132
+ const touchStartX = ref(0);
133
+ const touchStartY = ref(0);
134
+ const touchDeltaX = ref(0);
135
+ const isSwiping = ref(false);
136
+
137
+ const currentIndex = computed({
138
+ get: () => internalIndex.value,
139
+ set: (val) => {
140
+ internalIndex.value = val;
141
+ emit('update:activeIndex', val);
142
+ },
143
+ });
144
+
145
+ const currentImage = computed(() => {
146
+ const img = props.images[currentIndex.value];
147
+ if (!img) return '';
148
+ return typeof img === 'string' ? img : (img as any).src || '';
149
+ });
150
+
151
+ const zoomStyle = computed(() => {
152
+ if (!isZoomed.value) return {};
153
+ return { cursor: 'zoom-out', transform: 'scale(2)' };
154
+ });
155
+
156
+ watch(() => props.activeIndex, (val) => {
157
+ internalIndex.value = val;
158
+ });
159
+
160
+ // Body scroll lock
161
+ let didLockScroll = false;
162
+
163
+ watch(() => props.visible, async (newValue) => {
164
+ if (newValue) {
165
+ document.body.style.overflow = 'hidden';
166
+ didLockScroll = true;
167
+ emit('show');
168
+ await nextTick();
169
+ // Focus the overlay for keyboard events
170
+ const overlay = document.querySelector('.lightbox__overlay') as HTMLElement;
171
+ overlay?.focus();
172
+ } else {
173
+ if (didLockScroll) {
174
+ document.body.style.overflow = '';
175
+ didLockScroll = false;
176
+ }
177
+ isZoomed.value = false;
178
+ emit('hide');
179
+ }
180
+ });
181
+
182
+ const close = () => {
183
+ emit('update:visible', false);
184
+ };
185
+
186
+ const onOverlayClick = () => {
187
+ if (props.closeOnClickOutside) {
188
+ close();
189
+ }
190
+ };
191
+
192
+ const prev = () => {
193
+ if (isZoomed.value) return;
194
+ slideDirection.value = 'lightbox-slide-right';
195
+ if (currentIndex.value > 0) {
196
+ currentIndex.value--;
197
+ } else {
198
+ currentIndex.value = props.images.length - 1;
199
+ }
200
+ };
201
+
202
+ const next = () => {
203
+ if (isZoomed.value) return;
204
+ slideDirection.value = 'lightbox-slide-left';
205
+ if (currentIndex.value < props.images.length - 1) {
206
+ currentIndex.value++;
207
+ } else {
208
+ currentIndex.value = 0;
209
+ }
210
+ };
211
+
212
+ const goTo = (index: number) => {
213
+ slideDirection.value = index > currentIndex.value ? 'lightbox-slide-left' : 'lightbox-slide-right';
214
+ isZoomed.value = false;
215
+ currentIndex.value = index;
216
+ };
217
+
218
+ const toggleZoom = () => {
219
+ if (!props.zoom) return;
220
+ isZoomed.value = !isZoomed.value;
221
+ };
222
+
223
+ const onImageLoad = () => {
224
+ // Could emit event or handle loading state
225
+ };
226
+
227
+ // Touch/swipe support
228
+ const onTouchStart = (e: TouchEvent) => {
229
+ if (isZoomed.value) return;
230
+ touchStartX.value = e.touches[0].clientX;
231
+ touchStartY.value = e.touches[0].clientY;
232
+ touchDeltaX.value = 0;
233
+ isSwiping.value = false;
234
+ };
235
+
236
+ const onTouchMove = (e: TouchEvent) => {
237
+ if (isZoomed.value) return;
238
+ const deltaX = e.touches[0].clientX - touchStartX.value;
239
+ const deltaY = e.touches[0].clientY - touchStartY.value;
240
+
241
+ // Only swipe horizontally
242
+ if (Math.abs(deltaX) > Math.abs(deltaY)) {
243
+ isSwiping.value = true;
244
+ touchDeltaX.value = deltaX;
245
+ e.preventDefault();
246
+ }
247
+ };
248
+
249
+ const onTouchEnd = () => {
250
+ if (!isSwiping.value || isZoomed.value) return;
251
+ const threshold = 50;
252
+ if (touchDeltaX.value > threshold) {
253
+ prev();
254
+ } else if (touchDeltaX.value < -threshold) {
255
+ next();
256
+ }
257
+ isSwiping.value = false;
258
+ touchDeltaX.value = 0;
259
+ };
260
+
261
+ onUnmounted(() => {
262
+ if (didLockScroll) {
263
+ document.body.style.overflow = '';
264
+ didLockScroll = false;
265
+ }
266
+ });
267
+
268
+ defineExpose({
269
+ prev,
270
+ next,
271
+ goTo,
272
+ close,
273
+ });
274
+ </script>