@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 +16 -0
- package/README.md +69 -7
- package/package.json +1 -1
- package/src/file-upload/FileUpload.vue +440 -0
- package/src/file-upload/file-upload.types.ts +89 -0
- package/src/file-upload/index.ts +7 -0
- package/src/image/Image.vue +144 -0
- package/src/image/image.types.ts +57 -0
- package/src/image/index.ts +3 -0
- package/src/index.ts +4 -0
- package/src/scroll-to-top/ScrollToTop.vue +130 -0
- package/src/scroll-to-top/index.ts +2 -0
- package/src/scroll-to-top/scroll-to-top.types.ts +42 -0
- package/src/skeleton/Skeleton.vue +115 -0
- package/src/skeleton/index.ts +2 -0
- package/src/skeleton/skeleton.types.ts +33 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
21
|
+
Requires Vue 3 and TypeScript 5.9+ (peer dependencies).
|
|
10
22
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
@@ -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,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
|
+
}
|
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,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,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
|
+
}
|