@baklavue/ui 1.0.0-preview.2 → 1.0.0-preview.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ # [@baklavue/ui-v1.0.0-preview.4](https://github.com/erbilnas/baklavue/compare/@baklavue/ui-v1.0.0-preview.3...@baklavue/ui-v1.0.0-preview.4) (2026-02-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * add file upload component ([7aa41b1](https://github.com/erbilnas/baklavue/commit/7aa41b1766c72572be97c84bddc2ca5dccac9be8))
7
+ * add image component ([609eefe](https://github.com/erbilnas/baklavue/commit/609eefe9a4a9f74fc19c7e2a8aa2ab0d7529686f))
8
+
9
+ # [@baklavue/ui-v1.0.0-preview.3](https://github.com/erbilnas/baklavue/compare/@baklavue/ui-v1.0.0-preview.2...@baklavue/ui-v1.0.0-preview.3) (2026-02-11)
10
+
11
+
12
+ ### Features
13
+
14
+ * add new components ([b3c535e](https://github.com/erbilnas/baklavue/commit/b3c535e010e1e43b9b04365df2205b69a0777695))
15
+ * add new composables ([49105ea](https://github.com/erbilnas/baklavue/commit/49105eaa106f1a5b888f9e3f9638ed9776b3d55b))
16
+
1
17
  # [@baklavue/ui-v1.0.0-preview.2](https://github.com/erbilnas/baklavue/compare/@baklavue/ui-v1.0.0-preview.1...@baklavue/ui-v1.0.0-preview.2) (2026-02-11)
2
18
 
3
19
 
package/README.md CHANGED
@@ -1,15 +1,77 @@
1
- # ui
1
+ # @baklavue/ui
2
2
 
3
- To install dependencies:
3
+ Vue 3 UI kit for [Trendyol Baklava](https://github.com/Trendyol/baklava) design system. Vue-friendly wrappers with full `v-model` support, slots, and TypeScript.
4
+
5
+ ## Installation
4
6
 
5
7
  ```bash
6
- bun install
8
+ # bun
9
+ bun add @baklavue/ui
10
+
11
+ # npm
12
+ npm install @baklavue/ui
13
+
14
+ # pnpm
15
+ pnpm add @baklavue/ui
16
+
17
+ # yarn
18
+ yarn add @baklavue/ui
7
19
  ```
8
20
 
9
- To run:
21
+ Requires Vue 3 and TypeScript 5.9+ (peer dependencies).
10
22
 
11
- ```bash
12
- bun run index.ts
23
+ ## Setup
24
+
25
+ Components load Baklava styles and scripts automatically when mounted. For explicit loading (e.g. before any component mounts), call `loadBaklavaResources()` in `main.ts`:
26
+
27
+ ```ts
28
+ import { loadBaklavaResources } from "@baklavue/ui";
29
+
30
+ loadBaklavaResources(); // optional
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```vue
36
+ <template>
37
+ <BvButton variant="primary" @click="handleClick">Click me</BvButton>
38
+ <BvInput v-model="email" label="Email" placeholder="Enter your email" />
39
+ </template>
40
+
41
+ <script setup>
42
+ import { ref } from "vue";
43
+ import { BvButton, BvInput } from "@baklavue/ui";
44
+
45
+ const email = ref("");
46
+
47
+ const handleClick = () => {
48
+ console.log("Email:", email.value);
49
+ };
50
+ </script>
13
51
  ```
14
52
 
15
- This project was created using `bun init` in bun v1.1.34. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
53
+ ## Components
54
+
55
+ All components use the `Bv-` prefix and support TypeScript, `v-model`, Vue events, reactive props, and slots.
56
+
57
+ | Category | Components |
58
+ | ---------- | -------------------------------------------------------------------------- |
59
+ | **Form** | BvButton, BvInput, BvCheckbox, BvRadio, BvSwitch, BvSelect, BvTextarea, BvDatepicker |
60
+ | **Feedback** | BvAlert, BvBadge, BvTag, BvNotification, BvSpinner |
61
+ | **Layout** | BvDialog, BvDrawer, BvDropdown, BvTooltip, BvAccordion, BvTab, BvStepper |
62
+ | **Navigation** | BvLink, BvPagination, BvSplitButton |
63
+ | **Data** | BvTable, BvIcon |
64
+
65
+ ## Requirements
66
+
67
+ - **Vue 3.0+** — Composition API
68
+ - **TypeScript 5.9.2+** (peer dependency)
69
+
70
+ ## Documentation
71
+
72
+ - [Full docs](https://erbilnas.github.io/baklavue/) — Guide and examples
73
+ - [Components](https://erbilnas.github.io/baklavue/components/) — Component reference
74
+
75
+ ## License
76
+
77
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baklavue/ui",
3
- "version": "1.0.0-preview.2",
3
+ "version": "1.0.0-preview.4",
4
4
  "description": "Vue 3 UI kit for Trendyol Baklava Design System",
5
5
  "author": "erbilnas",
6
6
  "license": "MIT",
@@ -0,0 +1,440 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FileUpload Component
4
+ *
5
+ * A custom file upload component with drag-and-drop zone, click-to-browse,
6
+ * file list with remove, validation (size/type), and optional preview.
7
+ *
8
+ * @component
9
+ * @example
10
+ * ```vue
11
+ * <!-- Basic usage -->
12
+ * <template>
13
+ * <BvFileUpload v-model="file" label="Upload document" />
14
+ * </template>
15
+ * ```
16
+ *
17
+ * @example
18
+ * ```vue
19
+ * <!-- Multiple with validation -->
20
+ * <template>
21
+ * <BvFileUpload
22
+ * v-model="files"
23
+ * multiple
24
+ * accept="image/*"
25
+ * :max-size="1024 * 1024"
26
+ * @invalid="handleInvalid"
27
+ * />
28
+ * </template>
29
+ * ```
30
+ */
31
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
32
+ import BvIcon from "../icon/Icon.vue";
33
+ import BvTag from "../tag/Tag.vue";
34
+ import { loadBaklavaResources } from "../utils/loadBaklavaResources";
35
+ import type {
36
+ FileUploadInvalidEntry,
37
+ FileUploadProps,
38
+ } from "./file-upload.types";
39
+
40
+ const props = withDefaults(defineProps<FileUploadProps>(), {
41
+ modelValue: undefined,
42
+ multiple: false,
43
+ accept: undefined,
44
+ maxSize: undefined,
45
+ minSize: undefined,
46
+ maxFiles: undefined,
47
+ disabled: false,
48
+ label: undefined,
49
+ helpText: undefined,
50
+ invalidText: undefined,
51
+ showPreview: false,
52
+ size: "medium",
53
+ });
54
+
55
+ const emit = defineEmits<{
56
+ "update:modelValue": [value: File | File[] | null];
57
+ invalid: [entries: FileUploadInvalidEntry[]];
58
+ change: [files: File[]];
59
+ }>();
60
+
61
+ const inputRef = ref<HTMLInputElement | null>(null);
62
+ const isDragging = ref(false);
63
+ const previewUrls = ref<Map<File, string>>(new Map());
64
+
65
+ /** Normalize modelValue to File[] */
66
+ const filesList = computed<File[]>(() => {
67
+ const v = props.modelValue;
68
+ if (!v) return [];
69
+ return Array.isArray(v) ? [...v] : [v];
70
+ });
71
+
72
+ /** Check if file passes accept filter */
73
+ function matchesAccept(file: File): boolean {
74
+ if (!props.accept) return true;
75
+ const rules = props.accept.split(",").map((r) => r.trim());
76
+ const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
77
+
78
+ for (const rule of rules) {
79
+ if (rule.startsWith(".")) {
80
+ if (ext === rule.slice(1).toLowerCase()) return true;
81
+ } else {
82
+ const mime = file.type;
83
+ if (rule.endsWith("/*")) {
84
+ const base = rule.slice(0, -1);
85
+ if (mime.startsWith(base)) return true;
86
+ } else if (mime === rule) {
87
+ return true;
88
+ }
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+ /** Validate files and return invalid entries */
95
+ function validateFiles(
96
+ incoming: File[],
97
+ existingCount: number,
98
+ ): FileUploadInvalidEntry[] {
99
+ const invalid: FileUploadInvalidEntry[] = [];
100
+ const maxFiles = props.maxFiles ?? Infinity;
101
+ let validAdded = 0;
102
+
103
+ for (const file of incoming) {
104
+ if (!matchesAccept(file)) {
105
+ invalid.push({ file, reason: "type" });
106
+ continue;
107
+ }
108
+ if (props.maxSize !== undefined && file.size > props.maxSize) {
109
+ invalid.push({ file, reason: "size" });
110
+ continue;
111
+ }
112
+ if (props.minSize !== undefined && file.size < props.minSize) {
113
+ invalid.push({ file, reason: "size" });
114
+ continue;
115
+ }
116
+ if (props.multiple && existingCount + validAdded >= maxFiles) {
117
+ invalid.push({ file, reason: "count" });
118
+ continue;
119
+ }
120
+ validAdded++;
121
+ }
122
+
123
+ return invalid;
124
+ }
125
+
126
+ /** Process and emit validated files */
127
+ function processFiles(newFiles: File[]) {
128
+ const valid: File[] = [];
129
+ const invalid = validateFiles(newFiles, filesList.value.length);
130
+
131
+ for (const f of newFiles) {
132
+ const entry = invalid.find((e) => e.file === f);
133
+ if (!entry) valid.push(f);
134
+ }
135
+
136
+ if (invalid.length > 0) {
137
+ emit("invalid", invalid);
138
+ }
139
+
140
+ if (valid.length > 0) {
141
+ const combined = props.multiple
142
+ ? [...filesList.value, ...valid]
143
+ : [valid[0]];
144
+ const out = props.multiple ? combined : combined[0];
145
+ emit("update:modelValue", out);
146
+ emit("change", combined);
147
+ }
148
+ }
149
+
150
+ function handleInputChange(e: Event) {
151
+ const input = e.target as HTMLInputElement;
152
+ const files = input.files ? Array.from(input.files) : [];
153
+ processFiles(files);
154
+ input.value = "";
155
+ }
156
+
157
+ function handleDrop(e: DragEvent) {
158
+ e.preventDefault();
159
+ isDragging.value = false;
160
+ if (props.disabled) return;
161
+ const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
162
+ processFiles(files);
163
+ }
164
+
165
+ function handleDragOver(e: DragEvent) {
166
+ e.preventDefault();
167
+ e.stopPropagation();
168
+ if (props.disabled) return;
169
+ isDragging.value = true;
170
+ }
171
+
172
+ function handleDragLeave() {
173
+ isDragging.value = false;
174
+ }
175
+
176
+ function openFilePicker() {
177
+ if (props.disabled) return;
178
+ inputRef.value?.click();
179
+ }
180
+
181
+ function removeFile(index: number) {
182
+ const list = [...filesList.value];
183
+ const removed = list[index];
184
+ list.splice(index, 1);
185
+ if (props.showPreview && removed && removed.type.startsWith("image/")) {
186
+ const url = previewUrls.value.get(removed);
187
+ if (url) URL.revokeObjectURL(url);
188
+ previewUrls.value.delete(removed);
189
+ }
190
+ const out = props.multiple ? list : (list[0] ?? null);
191
+ emit("update:modelValue", out);
192
+ emit("change", list);
193
+ }
194
+
195
+ function getPreviewUrl(file: File): string {
196
+ if (!file.type.startsWith("image/")) return "";
197
+ let url = previewUrls.value.get(file);
198
+ if (!url) {
199
+ url = URL.createObjectURL(file);
200
+ previewUrls.value.set(file, url);
201
+ }
202
+ return url;
203
+ }
204
+
205
+ watch(
206
+ filesList,
207
+ (files) => {
208
+ const toRevoke = new Set(previewUrls.value.keys());
209
+ for (const f of files) {
210
+ toRevoke.delete(f);
211
+ }
212
+ for (const f of toRevoke) {
213
+ const url = previewUrls.value.get(f);
214
+ if (url) URL.revokeObjectURL(url);
215
+ previewUrls.value.delete(f);
216
+ }
217
+ },
218
+ { deep: true },
219
+ );
220
+
221
+ onMounted(() => {
222
+ loadBaklavaResources();
223
+ });
224
+
225
+ onBeforeUnmount(() => {
226
+ for (const url of previewUrls.value.values()) {
227
+ URL.revokeObjectURL(url);
228
+ }
229
+ previewUrls.value.clear();
230
+ });
231
+
232
+ const zoneSizeClass = computed(() => `file-upload-zone--${props.size}`);
233
+ const hasError = computed(() => !!props.invalidText);
234
+ </script>
235
+
236
+ <template>
237
+ <div class="file-upload">
238
+ <label v-if="label" class="file-upload-label">{{ label }}</label>
239
+
240
+ <div
241
+ class="file-upload-zone"
242
+ :class="[
243
+ zoneSizeClass,
244
+ {
245
+ 'file-upload-zone--dragging': isDragging,
246
+ 'file-upload-zone--disabled': disabled,
247
+ 'file-upload-zone--invalid': hasError,
248
+ },
249
+ ]"
250
+ @click="openFilePicker"
251
+ @drop="handleDrop"
252
+ @dragover="handleDragOver"
253
+ @dragleave="handleDragLeave"
254
+ >
255
+ <input
256
+ ref="inputRef"
257
+ type="file"
258
+ class="file-upload-input"
259
+ :accept="accept"
260
+ :multiple="multiple"
261
+ :disabled="disabled"
262
+ @change="handleInputChange"
263
+ />
264
+ <div class="file-upload-content">
265
+ <BvIcon name="upload" size="24px" class="file-upload-icon" />
266
+ <span class="file-upload-text">
267
+ <slot name="hint"> </slot>
268
+ </span>
269
+ </div>
270
+ </div>
271
+
272
+ <p v-if="helpText && !hasError" class="file-upload-help">{{ helpText }}</p>
273
+ <p v-if="invalidText" class="file-upload-invalid">{{ invalidText }}</p>
274
+
275
+ <div v-if="filesList.length > 0" class="file-upload-list">
276
+ <div
277
+ v-for="(file, index) in filesList"
278
+ :key="`${file.name}-${file.size}-${index}`"
279
+ class="file-upload-item"
280
+ >
281
+ <div
282
+ v-if="showPreview && file.type.startsWith('image/')"
283
+ class="file-upload-preview"
284
+ >
285
+ <img
286
+ :src="getPreviewUrl(file)"
287
+ :alt="file.name"
288
+ class="file-upload-thumb"
289
+ />
290
+ </div>
291
+ <BvTag
292
+ closable
293
+ size="small"
294
+ class="file-upload-tag"
295
+ @close="removeFile(index)"
296
+ >
297
+ {{ file.name }} ({{ (file.size / 1024).toFixed(1) }} KB)
298
+ </BvTag>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ </template>
303
+
304
+ <style scoped>
305
+ .file-upload {
306
+ display: flex;
307
+ flex-direction: column;
308
+ gap: var(--bl-spacing-2, 0.5rem);
309
+ }
310
+
311
+ .file-upload-label {
312
+ font: var(--bl-font-body-2-medium, 0.875rem 500);
313
+ color: var(--bl-color-neutral-darker, #374151);
314
+ }
315
+
316
+ .file-upload-zone {
317
+ display: flex;
318
+ align-items: center;
319
+ justify-content: center;
320
+ border: 2px dashed var(--bl-color-neutral-light, #e5e7eb);
321
+ border-radius: var(--bl-radius-m, 8px);
322
+ background: var(--bl-color-neutral-background, #f9fafb);
323
+ cursor: pointer;
324
+ transition:
325
+ border-color 0.2s,
326
+ background 0.2s;
327
+ }
328
+
329
+ .file-upload-zone:hover:not(.file-upload-zone--disabled) {
330
+ border-color: var(--bl-color-primary, #ff6000);
331
+ background: var(--bl-color-primary-background, #fff5f0);
332
+ }
333
+
334
+ .file-upload-zone--dragging {
335
+ border-color: var(--bl-color-primary, #ff6000);
336
+ background: var(--bl-color-primary-background, #fff5f0);
337
+ }
338
+
339
+ .file-upload-zone--disabled {
340
+ cursor: not-allowed;
341
+ opacity: 0.6;
342
+ }
343
+
344
+ .file-upload-zone--invalid {
345
+ border-color: var(--bl-color-danger, #dc2626);
346
+ }
347
+
348
+ .file-upload-zone--small {
349
+ min-height: 80px;
350
+ padding: var(--bl-spacing-3, 0.75rem);
351
+ }
352
+
353
+ .file-upload-zone--medium {
354
+ min-height: 120px;
355
+ padding: var(--bl-spacing-4, 1rem);
356
+ }
357
+
358
+ .file-upload-zone--large {
359
+ min-height: 160px;
360
+ padding: var(--bl-spacing-5, 1.25rem);
361
+ }
362
+
363
+ .file-upload-input {
364
+ position: absolute;
365
+ width: 0;
366
+ height: 0;
367
+ opacity: 0;
368
+ overflow: hidden;
369
+ pointer-events: none;
370
+ }
371
+
372
+ .file-upload-content {
373
+ display: flex;
374
+ flex-direction: column;
375
+ align-items: center;
376
+ gap: var(--bl-spacing-2, 0.5rem);
377
+ }
378
+
379
+ .file-upload-icon {
380
+ color: var(--bl-color-neutral-subtle, #9ca3af);
381
+ }
382
+
383
+ .file-upload-text {
384
+ font: var(--bl-font-body-2-regular, 0.875rem 400);
385
+ color: var(--bl-color-neutral-subtle, #6b7280);
386
+ }
387
+
388
+ .file-upload-browse {
389
+ color: var(--bl-color-primary, #ff6000);
390
+ text-decoration: underline;
391
+ }
392
+
393
+ .file-upload-help,
394
+ .file-upload-invalid {
395
+ font: var(--bl-font-body-3-regular, 0.75rem 400);
396
+ margin: 0;
397
+ }
398
+
399
+ .file-upload-help {
400
+ color: var(--bl-color-neutral-subtle, #6b7280);
401
+ }
402
+
403
+ .file-upload-invalid {
404
+ color: var(--bl-color-danger, #dc2626);
405
+ }
406
+
407
+ .file-upload-list {
408
+ display: flex;
409
+ flex-wrap: wrap;
410
+ gap: var(--bl-spacing-2, 0.5rem);
411
+ }
412
+
413
+ .file-upload-item {
414
+ display: flex;
415
+ flex-direction: column;
416
+ align-items: flex-start;
417
+ gap: var(--bl-spacing-1, 0.25rem);
418
+ }
419
+
420
+ .file-upload-preview {
421
+ width: 48px;
422
+ height: 48px;
423
+ border-radius: var(--bl-radius-s, 4px);
424
+ overflow: hidden;
425
+ background: var(--bl-color-neutral-light, #e5e7eb);
426
+ }
427
+
428
+ .file-upload-thumb {
429
+ width: 100%;
430
+ height: 100%;
431
+ object-fit: cover;
432
+ }
433
+
434
+ .file-upload-tag {
435
+ max-width: 240px;
436
+ overflow: hidden;
437
+ text-overflow: ellipsis;
438
+ white-space: nowrap;
439
+ }
440
+ </style>
@@ -0,0 +1,89 @@
1
+ /**
2
+ * File upload size.
3
+ */
4
+ export type FileUploadSize = "small" | "medium" | "large";
5
+
6
+ /**
7
+ * Validation failure reason.
8
+ */
9
+ export type FileUploadInvalidReason = "type" | "size" | "count";
10
+
11
+ /**
12
+ * Invalid file entry emitted on validation failure.
13
+ */
14
+ export interface FileUploadInvalidEntry {
15
+ file: File;
16
+ reason: FileUploadInvalidReason;
17
+ }
18
+
19
+ /**
20
+ * Props for the FileUpload component.
21
+ *
22
+ * A custom file upload component with drag-and-drop, validation,
23
+ * file list with remove, and optional preview.
24
+ *
25
+ * @interface FileUploadProps
26
+ */
27
+ export interface FileUploadProps {
28
+ /**
29
+ * Bound files (v-model). Single file or array when multiple.
30
+ */
31
+ modelValue?: File | File[] | null;
32
+
33
+ /**
34
+ * Allow multiple files.
35
+ */
36
+ multiple?: boolean;
37
+
38
+ /**
39
+ * Accepted MIME types or extensions (e.g. `image/*`, `.pdf`, `application/pdf`).
40
+ */
41
+ accept?: string;
42
+
43
+ /**
44
+ * Maximum file size in bytes.
45
+ */
46
+ maxSize?: number;
47
+
48
+ /**
49
+ * Minimum file size in bytes.
50
+ */
51
+ minSize?: number;
52
+
53
+ /**
54
+ * Maximum number of files when multiple is true.
55
+ */
56
+ maxFiles?: number;
57
+
58
+ /**
59
+ * Disabled state.
60
+ */
61
+ disabled?: boolean;
62
+
63
+ /**
64
+ * Label for the upload area.
65
+ */
66
+ label?: string;
67
+
68
+ /**
69
+ * Helper text below the upload area.
70
+ */
71
+ helpText?: string;
72
+
73
+ /**
74
+ * Error message when validation fails.
75
+ */
76
+ invalidText?: string;
77
+
78
+ /**
79
+ * Show image previews for image files.
80
+ */
81
+ showPreview?: boolean;
82
+
83
+ /**
84
+ * Drop zone size (small, medium, large).
85
+ *
86
+ * @default "medium"
87
+ */
88
+ size?: FileUploadSize;
89
+ }
@@ -0,0 +1,7 @@
1
+ export { default as BvFileUpload } from "./FileUpload.vue";
2
+ export type {
3
+ FileUploadProps,
4
+ FileUploadSize,
5
+ FileUploadInvalidReason,
6
+ FileUploadInvalidEntry,
7
+ } from "./file-upload.types";
@@ -0,0 +1,144 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Image Component
4
+ *
5
+ * Performance-focused image wrapper with lazy loading,
6
+ * skeleton placeholder, and error handling.
7
+ *
8
+ * @component
9
+ * @example
10
+ * ```vue
11
+ * <template>
12
+ * <BvImage src="/photo.jpg" alt="Photo" width="200px" height="120px" />
13
+ * </template>
14
+ * ```
15
+ */
16
+ import { ref, computed, onMounted } from "vue";
17
+ import { loadBaklavaResources } from "../utils/loadBaklavaResources";
18
+ import BvSkeleton from "../skeleton/Skeleton.vue";
19
+ import type { ImageProps } from "./image.types";
20
+
21
+ const props = withDefaults(defineProps<ImageProps>(), {
22
+ loading: "lazy",
23
+ placeholder: "skeleton",
24
+ objectFit: "cover",
25
+ });
26
+
27
+ const emit = defineEmits<{
28
+ load: [event: Event];
29
+ error: [event: Event];
30
+ }>();
31
+
32
+ const isLoading = ref(true);
33
+ const hasError = ref(false);
34
+
35
+ const showPlaceholder = computed(
36
+ () => isLoading.value && props.placeholder === "skeleton" && !hasError.value,
37
+ );
38
+
39
+ const showImage = computed(() => !hasError.value);
40
+
41
+ const wrapperStyle = computed(() => ({
42
+ width: props.width ?? "100%",
43
+ height: props.height ?? "auto",
44
+ aspectRatio: !props.height && props.width ? "16 / 9" : undefined,
45
+ }));
46
+
47
+ function onLoad(event: Event) {
48
+ isLoading.value = false;
49
+ emit("load", event);
50
+ }
51
+
52
+ function onError(event: Event) {
53
+ isLoading.value = false;
54
+ hasError.value = true;
55
+ emit("error", event);
56
+ }
57
+
58
+ onMounted(() => {
59
+ loadBaklavaResources();
60
+ });
61
+ </script>
62
+
63
+ <template>
64
+ <div class="image-wrapper" :style="wrapperStyle">
65
+ <!-- Placeholder (skeleton or custom slot) -->
66
+ <div v-if="showPlaceholder" class="image-placeholder">
67
+ <slot name="placeholder">
68
+ <BvSkeleton
69
+ class="image-skeleton"
70
+ width="100%"
71
+ height="100%"
72
+ variant="rectangle"
73
+ />
74
+ </slot>
75
+ </div>
76
+
77
+ <!-- Loaded image -->
78
+ <img
79
+ v-show="showImage"
80
+ :src="src"
81
+ :alt="alt"
82
+ :loading="loading"
83
+ :srcset="srcset"
84
+ :sizes="sizes"
85
+ class="image-img"
86
+ :style="{ objectFit }"
87
+ @load="onLoad"
88
+ @error="onError"
89
+ />
90
+
91
+ <!-- Error fallback -->
92
+ <div v-if="hasError" class="image-fallback">
93
+ <slot name="fallback">
94
+ <div class="image-fallback-default" role="img" :aria-label="alt">
95
+ Failed to load image
96
+ </div>
97
+ </slot>
98
+ </div>
99
+ </div>
100
+ </template>
101
+
102
+ <style scoped>
103
+ .image-wrapper {
104
+ position: relative;
105
+ overflow: hidden;
106
+ display: block;
107
+ }
108
+
109
+ .image-placeholder {
110
+ position: absolute;
111
+ inset: 0;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ }
116
+
117
+ .image-skeleton {
118
+ width: 100% !important;
119
+ height: 100% !important;
120
+ }
121
+
122
+ .image-img {
123
+ display: block;
124
+ width: 100%;
125
+ height: 100%;
126
+ vertical-align: middle;
127
+ }
128
+
129
+ .image-fallback {
130
+ position: absolute;
131
+ inset: 0;
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ }
136
+
137
+ .image-fallback-default {
138
+ background: var(--bl-color-neutral-light, #e5e7eb);
139
+ color: var(--bl-color-neutral-darker, #6b7280);
140
+ font-size: 0.875rem;
141
+ padding: 1rem;
142
+ text-align: center;
143
+ }
144
+ </style>
@@ -0,0 +1,57 @@
1
+ /** Native image loading behavior */
2
+ export type ImageLoading = "lazy" | "eager";
3
+
4
+ /** Placeholder type while image loads */
5
+ export type ImagePlaceholder = "skeleton" | "none";
6
+
7
+ export interface ImageProps {
8
+ /**
9
+ * Image URL (required).
10
+ */
11
+ src: string;
12
+
13
+ /**
14
+ * Accessible description (required).
15
+ */
16
+ alt: string;
17
+
18
+ /**
19
+ * CSS width (e.g. "200px", "100%").
20
+ * Recommended to prevent CLS.
21
+ */
22
+ width?: string;
23
+
24
+ /**
25
+ * CSS height (e.g. "120px", "auto").
26
+ * Recommended to prevent CLS.
27
+ */
28
+ height?: string;
29
+
30
+ /**
31
+ * Native loading behavior.
32
+ * @default "lazy"
33
+ */
34
+ loading?: ImageLoading;
35
+
36
+ /**
37
+ * Placeholder when lazy and not yet loaded.
38
+ * @default "skeleton"
39
+ */
40
+ placeholder?: ImagePlaceholder;
41
+
42
+ /**
43
+ * CSS object-fit.
44
+ * @default "cover"
45
+ */
46
+ objectFit?: string;
47
+
48
+ /**
49
+ * Responsive image sources (srcset attribute).
50
+ */
51
+ srcset?: string;
52
+
53
+ /**
54
+ * Sizes attribute for srcset.
55
+ */
56
+ sizes?: string;
57
+ }
@@ -0,0 +1,3 @@
1
+ // Component exports must use the "Bv-" prefix
2
+ export { default as BvImage } from "./Image.vue";
3
+ export type { ImageProps, ImageLoading, ImagePlaceholder } from "./image.types";
package/src/index.ts CHANGED
@@ -17,13 +17,17 @@ export * from "./datepicker";
17
17
  export * from "./dialog";
18
18
  export * from "./drawer";
19
19
  export * from "./dropdown";
20
+ export * from "./file-upload";
20
21
  export * from "./icon";
22
+ export * from "./image";
21
23
  export * from "./input";
22
24
  export * from "./link";
23
25
  export * from "./notification";
24
26
  export * from "./pagination";
25
27
  export * from "./radio";
28
+ export * from "./scroll-to-top";
26
29
  export * from "./select";
30
+ export * from "./skeleton";
27
31
  export * from "./spinner";
28
32
  export * from "./split-button";
29
33
  export * from "./stepper";
@@ -0,0 +1,130 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ScrollToTop Component
4
+ *
5
+ * A floating button that appears when the user scrolls past a threshold.
6
+ * Clicking it scrolls smoothly to the top of the page.
7
+ *
8
+ * @component
9
+ * @example
10
+ * ```vue
11
+ * <template>
12
+ * <BvScrollToTop />
13
+ * </template>
14
+ * ```
15
+ */
16
+ import { onMounted, onUnmounted, ref } from "vue";
17
+ import BvButton from "../button/Button.vue";
18
+ import { loadBaklavaResources } from "../utils/loadBaklavaResources";
19
+ import type { ScrollToTopProps } from "./scroll-to-top.types";
20
+
21
+ const props = withDefaults(defineProps<ScrollToTopProps>(), {
22
+ threshold: 300,
23
+ position: "bottom-right",
24
+ label: "Scroll to top",
25
+ size: "medium",
26
+ variant: "primary",
27
+ });
28
+
29
+ const emit = defineEmits<{
30
+ click: [];
31
+ }>();
32
+
33
+ const isVisible = ref(false);
34
+ let rafId: number | null = null;
35
+
36
+ const checkVisibility = () => {
37
+ isVisible.value = window.scrollY > props.threshold;
38
+ };
39
+
40
+ const handleScroll = () => {
41
+ if (rafId !== null) return;
42
+ rafId = requestAnimationFrame(() => {
43
+ checkVisibility();
44
+ rafId = null;
45
+ });
46
+ };
47
+
48
+ const scrollToTop = () => {
49
+ window.scrollTo({ top: 0, behavior: "smooth" });
50
+ emit("click");
51
+ };
52
+
53
+ const positionClasses: Record<
54
+ NonNullable<ScrollToTopProps["position"]>,
55
+ string
56
+ > = {
57
+ "bottom-right": "scroll-to-top--bottom-right",
58
+ "bottom-left": "scroll-to-top--bottom-left",
59
+ "top-right": "scroll-to-top--top-right",
60
+ "top-left": "scroll-to-top--top-left",
61
+ };
62
+
63
+ onMounted(() => {
64
+ loadBaklavaResources();
65
+ checkVisibility();
66
+ window.addEventListener("scroll", handleScroll, { passive: true });
67
+ });
68
+
69
+ onUnmounted(() => {
70
+ window.removeEventListener("scroll", handleScroll);
71
+ if (rafId !== null) cancelAnimationFrame(rafId);
72
+ });
73
+ </script>
74
+
75
+ <template>
76
+ <Transition name="scroll-to-top-fade">
77
+ <div
78
+ v-show="isVisible"
79
+ :class="['scroll-to-top', positionClasses[position]]"
80
+ role="complementary"
81
+ aria-label="Scroll to top"
82
+ >
83
+ <BvButton
84
+ :variant="variant"
85
+ :size="size"
86
+ :label="label"
87
+ icon="arrow_up"
88
+ @click="scrollToTop"
89
+ >
90
+ <template #default></template>
91
+ </BvButton>
92
+ </div>
93
+ </Transition>
94
+ </template>
95
+
96
+ <style scoped>
97
+ .scroll-to-top {
98
+ position: fixed;
99
+ z-index: 1000;
100
+ }
101
+
102
+ .scroll-to-top--bottom-right {
103
+ bottom: 1.5rem;
104
+ right: 1.5rem;
105
+ }
106
+
107
+ .scroll-to-top--bottom-left {
108
+ bottom: 1.5rem;
109
+ left: 1.5rem;
110
+ }
111
+
112
+ .scroll-to-top--top-right {
113
+ top: 1.5rem;
114
+ right: 1.5rem;
115
+ }
116
+
117
+ .scroll-to-top--top-left {
118
+ top: 1.5rem;
119
+ left: 1.5rem;
120
+ }
121
+
122
+ .scroll-to-top-fade-enter-active,
123
+ .scroll-to-top-fade-leave-active {
124
+ transition: opacity 0.2s ease;
125
+ }
126
+ .scroll-to-top-fade-enter-from,
127
+ .scroll-to-top-fade-leave-to {
128
+ opacity: 0;
129
+ }
130
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as BvScrollToTop } from "./ScrollToTop.vue";
2
+ export type { ScrollToTopProps, ScrollToTopPosition } from "./scroll-to-top.types";
@@ -0,0 +1,42 @@
1
+ import type { ButtonSize, ButtonVariant } from "@trendyol/baklava/dist/components/button/bl-button";
2
+
3
+ /**
4
+ * Position of the scroll-to-top button.
5
+ */
6
+ export type ScrollToTopPosition =
7
+ | "bottom-right"
8
+ | "bottom-left"
9
+ | "top-right"
10
+ | "top-left";
11
+
12
+ export interface ScrollToTopProps {
13
+ /**
14
+ * Scroll threshold in pixels. Button becomes visible when user scrolls past this.
15
+ * @default 300
16
+ */
17
+ threshold?: number;
18
+
19
+ /**
20
+ * Fixed position of the button.
21
+ * @default "bottom-right"
22
+ */
23
+ position?: ScrollToTopPosition;
24
+
25
+ /**
26
+ * Accessible label for screen readers.
27
+ * @default "Scroll to top"
28
+ */
29
+ label?: string;
30
+
31
+ /**
32
+ * Button size.
33
+ * @default "medium"
34
+ */
35
+ size?: ButtonSize;
36
+
37
+ /**
38
+ * Button variant.
39
+ * @default "primary"
40
+ */
41
+ variant?: ButtonVariant;
42
+ }
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Skeleton Component
4
+ *
5
+ * Animated placeholder for content loading states.
6
+ * Displays a shimmer effect with configurable variants.
7
+ *
8
+ * @component
9
+ * @example
10
+ * ```vue
11
+ * <template>
12
+ * <BvSkeleton />
13
+ * </template>
14
+ * ```
15
+ *
16
+ * @example
17
+ * ```vue
18
+ * <template>
19
+ * <BvSkeleton variant="text" :count="3" />
20
+ * </template>
21
+ * ```
22
+ */
23
+ import { computed, onMounted } from "vue";
24
+ import { loadBaklavaResources } from "../utils/loadBaklavaResources";
25
+ import type { SkeletonProps } from "./skeleton.types";
26
+
27
+ const props = withDefaults(defineProps<SkeletonProps>(), {
28
+ variant: "rectangle",
29
+ width: undefined,
30
+ height: undefined,
31
+ count: 1,
32
+ });
33
+
34
+ const effectiveWidth = computed(() => {
35
+ if (props.width) return props.width;
36
+ if (props.variant === "circle") return "40px";
37
+ return "100%";
38
+ });
39
+
40
+ const effectiveHeight = computed(() => {
41
+ if (props.height) return props.height;
42
+ if (props.variant === "circle") return "40px";
43
+ return "1rem";
44
+ });
45
+
46
+
47
+ onMounted(() => {
48
+ loadBaklavaResources();
49
+ });
50
+ </script>
51
+
52
+ <template>
53
+ <div
54
+ class="skeleton-wrapper"
55
+ :class="`skeleton-wrapper--${props.variant}`"
56
+ role="status"
57
+ aria-label="Loading"
58
+ >
59
+ <div
60
+ v-for="n in count"
61
+ :key="n"
62
+ class="skeleton"
63
+ :class="['skeleton--' + props.variant]"
64
+ :style="{
65
+ width: effectiveWidth,
66
+ height: effectiveHeight,
67
+ }"
68
+ />
69
+ </div>
70
+ </template>
71
+
72
+ <style scoped>
73
+ .skeleton-wrapper {
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 0.5rem;
77
+ }
78
+
79
+ .skeleton {
80
+ background: var(--bl-color-neutral-light, #e5e7eb);
81
+ border-radius: var(--bl-border-radius-s, 0.25rem);
82
+ position: relative;
83
+ overflow: hidden;
84
+ }
85
+
86
+ .skeleton::after {
87
+ content: "";
88
+ position: absolute;
89
+ inset: 0;
90
+ background: linear-gradient(
91
+ 90deg,
92
+ transparent 0%,
93
+ rgba(255, 255, 255, 0.4) 50%,
94
+ transparent 100%
95
+ );
96
+ animation: skeleton-shimmer 1.5s ease-in-out infinite;
97
+ }
98
+
99
+ .skeleton--circle {
100
+ border-radius: var(--bl-border-radius-circle, 50%);
101
+ }
102
+
103
+ .skeleton--text {
104
+ height: 1rem;
105
+ }
106
+
107
+ @keyframes skeleton-shimmer {
108
+ 0% {
109
+ transform: translateX(-100%);
110
+ }
111
+ 100% {
112
+ transform: translateX(100%);
113
+ }
114
+ }
115
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as BvSkeleton } from "./Skeleton.vue";
2
+ export type { SkeletonProps, SkeletonVariant } from "./skeleton.types";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Skeleton shape variant.
3
+ */
4
+ export type SkeletonVariant = "text" | "rectangle" | "circle";
5
+
6
+ export interface SkeletonProps {
7
+ /**
8
+ * Shape variant of the skeleton.
9
+ * - text: Line shape for text placeholders
10
+ * - rectangle: Default block shape
11
+ * - circle: Circular shape for avatars
12
+ * @default "rectangle"
13
+ */
14
+ variant?: SkeletonVariant;
15
+
16
+ /**
17
+ * Width as CSS value (e.g. "100%", "200px", "5rem").
18
+ * @default "100%" for text/rectangle, "40px" for circle
19
+ */
20
+ width?: string;
21
+
22
+ /**
23
+ * Height as CSS value (e.g. "1rem", "20px").
24
+ * @default "1rem" for text, "1rem" for rectangle, "40px" for circle
25
+ */
26
+ height?: string;
27
+
28
+ /**
29
+ * Number of skeleton elements to render (for text lines).
30
+ * @default 1
31
+ */
32
+ count?: number;
33
+ }