@leavepulse/ui 0.28.1 → 0.28.2

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,299 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A place to put files: drop them, click to browse, or paste from the
4
+ * clipboard.
5
+ *
6
+ * All three, because they are the same intent arriving by different routes and
7
+ * a zone that only accepts one of them is the one people call broken. Paste in
8
+ * particular is what a screenshot is: cropped with the OS tool, Ctrl+V, done —
9
+ * there is no file on disk to browse for.
10
+ *
11
+ * Not tied to images. `accept` is passed to the input and used to filter what a
12
+ * drop actually yields, so this is equally the place a YAML import or an
13
+ * attachment lands.
14
+ *
15
+ * The zone is a BUTTON. It was a div with a click handler first, which is
16
+ * invisible to a keyboard: nothing focusable, nothing that Enter activates, and
17
+ * the file dialog unreachable without a mouse.
18
+ */
19
+ import { onBeforeUnmount, onMounted, ref } from "vue"
20
+ import { useReveal } from "../composables/useReveal"
21
+ import LpIcon from "./LpIcon.vue"
22
+
23
+ const props = withDefaults(
24
+ defineProps<{
25
+ /** Same syntax as the input's: "image/*", ".yaml,.yml". */
26
+ accept?: string
27
+ /** Take more than one at a time. */
28
+ multiple?: boolean
29
+ disabled?: boolean
30
+ /**
31
+ * Also accept a paste anywhere on the page, not only while focused.
32
+ * Off by default: two zones both listening would each take the same
33
+ * screenshot, and a page with one obvious target is the case for it.
34
+ */
35
+ pasteTarget?: boolean
36
+ /** Largest file accepted, in bytes. Bigger ones are rejected, not silently
37
+ * dropped — a picture that vanishes reads as a broken uploader. */
38
+ maxBytes?: number
39
+ icon?: string
40
+ title?: string
41
+ hint?: string
42
+ /**
43
+ * How the well sits against what holds it. A drop target reads as a hole
44
+ * in the surface, not a tile on top of it, so the default is darker than
45
+ * the card — which is what a dashed border is already suggesting.
46
+ * `flush` for a zone that fills a panel of its own, `soft` when it sits
47
+ * directly on the page background and needs to lift off it instead.
48
+ */
49
+ tone?: "sunken" | "flush" | "soft"
50
+ /** Draw the entrance when the zone first comes into view. */
51
+ animate?: boolean
52
+ }>(),
53
+ {
54
+ accept: "",
55
+ multiple: false,
56
+ icon: "lucide:upload",
57
+ title: "",
58
+ hint: "",
59
+ tone: "sunken",
60
+ animate: true,
61
+ },
62
+ )
63
+
64
+ const TONES = {
65
+ sunken: "bg-surface",
66
+ flush: "bg-surface-raised",
67
+ soft: "bg-surface-soft",
68
+ } as const
69
+
70
+ // Entrance on arrival, not on mount — the kit's rule, and the reason a zone in
71
+ // a modal that opens later still draws rather than being found already there.
72
+ const { el: revealAnchor, revealed } = useReveal({ immediate: () => !props.animate })
73
+
74
+ const emit = defineEmits<{
75
+ (e: "files", files: File[]): void
76
+ /** Something was refused, and why — the caller decides how loudly to say so. */
77
+ (e: "rejected", reason: { file: File; cause: "type" | "size" }[]): void
78
+ }>()
79
+
80
+ const input = ref<HTMLInputElement | null>(null)
81
+ /*
82
+ * Is a drag over the zone?
83
+ *
84
+ * Answered by asking where the pointer is, not by counting enter/leave pairs.
85
+ * The count is the usual trick and it does not balance: `dragenter` fires for
86
+ * every child the pointer crosses and bubbles, while the matching `dragleave`
87
+ * may not — one stuck increment and the zone stays lit after the drag is long
88
+ * gone, which is exactly what it did.
89
+ */
90
+ const over = ref(false)
91
+
92
+ function onDragOver(event: DragEvent) {
93
+ if (props.disabled) return
94
+ over.value = true
95
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"
96
+ }
97
+
98
+ function onDragLeave(event: DragEvent) {
99
+ // relatedTarget is where the pointer went. Still inside the zone means the
100
+ // drag merely crossed a child, and the highlight stays.
101
+ const to = event.relatedTarget as Node | null
102
+ if (to && (event.currentTarget as HTMLElement).contains(to)) return
103
+ over.value = false
104
+ }
105
+
106
+ /** Does the file match `accept`? Mirrors the input's own matching so a drop
107
+ * and a browse cannot disagree about what is allowed. */
108
+ function matches(file: File): boolean {
109
+ if (!props.accept) return true
110
+ return props.accept.split(",").some((rule) => {
111
+ const pattern = rule.trim().toLowerCase()
112
+ if (!pattern) return false
113
+ if (pattern.startsWith(".")) return file.name.toLowerCase().endsWith(pattern)
114
+ if (pattern.endsWith("/*")) return file.type.startsWith(pattern.slice(0, -1))
115
+ return file.type.toLowerCase() === pattern
116
+ })
117
+ }
118
+
119
+ function take(list: FileList | File[] | null | undefined) {
120
+ if (props.disabled || !list) return
121
+ const all = Array.from(list)
122
+ const kept: File[] = []
123
+ const refused: { file: File; cause: "type" | "size" }[] = []
124
+
125
+ for (const file of all) {
126
+ if (!matches(file)) refused.push({ file, cause: "type" })
127
+ else if (props.maxBytes && file.size > props.maxBytes) {
128
+ refused.push({ file, cause: "size" })
129
+ } else kept.push(file)
130
+ }
131
+
132
+ // `multiple` bounds the result rather than rejecting the extras: dropping a
133
+ // folder of photographs on a single-file zone should take the first, not
134
+ // refuse the lot.
135
+ const bounded = props.multiple ? kept : kept.slice(0, 1)
136
+ if (refused.length) emit("rejected", refused)
137
+ if (bounded.length) emit("files", bounded)
138
+ }
139
+
140
+ function onDrop(event: DragEvent) {
141
+ over.value = false
142
+ take(event.dataTransfer?.files)
143
+ }
144
+
145
+ function onPaste(event: ClipboardEvent) {
146
+ const files = Array.from(event.clipboardData?.files ?? [])
147
+ if (!files.length) return
148
+ // Only swallow the paste when it actually carried a file: a paste into a
149
+ // field that happens to sit inside the zone must still reach the field.
150
+ event.preventDefault()
151
+ take(files)
152
+ }
153
+
154
+ function onPicked(event: Event) {
155
+ const el = event.target as HTMLInputElement
156
+ take(el.files)
157
+ // Cleared so picking the same file twice in a row fires again — otherwise
158
+ // "I chose the wrong one, let me redo it" silently does nothing.
159
+ el.value = ""
160
+ }
161
+
162
+ function windowPaste(event: ClipboardEvent) {
163
+ if (props.disabled) return
164
+ const target = event.target as HTMLElement | null
165
+ // A paste aimed at somewhere you can type belongs to that thing.
166
+ if (target?.closest("input, textarea, [contenteditable]")) return
167
+ onPaste(event)
168
+ }
169
+
170
+ onMounted(() => {
171
+ if (props.pasteTarget) window.addEventListener("paste", windowPaste)
172
+ })
173
+ onBeforeUnmount(() => window.removeEventListener("paste", windowPaste))
174
+
175
+ defineExpose({ browse: () => input.value?.click() })
176
+ </script>
177
+
178
+ <template>
179
+ <button
180
+ ref="revealAnchor"
181
+ type="button"
182
+ :disabled="disabled"
183
+ class="lp-dropzone relative flex w-full flex-col items-center gap-2 rounded-card px-6 py-8 text-center transition-colors duration-[var(--duration-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-55"
184
+ :class="[
185
+ over ? 'bg-brand-soft' : TONES[tone],
186
+ over ? 'lp-dropzone--over' : '',
187
+ animate && !revealed ? 'lp-dropzone--waiting' : '',
188
+ ]"
189
+ @click="input?.click()"
190
+ @paste="onPaste"
191
+ @dragenter.prevent="onDragOver"
192
+ @dragover.prevent="onDragOver"
193
+ @dragleave="onDragLeave"
194
+ @drop.prevent="onDrop"
195
+ >
196
+ <!-- The border is drawn rather than declared, because `border-style:
197
+ dashed` cannot be moved: a CSS border has no dash offset to animate.
198
+ At rest it is a plain dashed outline; while something is being dragged
199
+ over it the dashes travel, which answers "yes, let go here" to
200
+ peripheral vision — the pointer is what the person is watching, not
201
+ this box. -->
202
+ <svg
203
+ aria-hidden="true"
204
+ class="lp-dropzone__frame pointer-events-none absolute inset-0 size-full"
205
+ >
206
+ <rect
207
+ x="0.5"
208
+ y="0.5"
209
+ width="calc(100% - 1px)"
210
+ height="calc(100% - 1px)"
211
+ rx="var(--radius-card)"
212
+ fill="none"
213
+ stroke-width="1"
214
+ stroke-dasharray="6 5"
215
+ />
216
+ </svg>
217
+
218
+ <slot :over="over">
219
+ <LpIcon
220
+ :name="icon"
221
+ :size="22"
222
+ class="text-muted transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
223
+ :class="over ? 'scale-110 text-brand' : ''"
224
+ />
225
+ <span v-if="title" class="text-sm font-medium text-ink">{{ title }}</span>
226
+ <span v-if="hint" class="text-xs text-muted">{{ hint }}</span>
227
+ </slot>
228
+
229
+ <input
230
+ ref="input"
231
+ type="file"
232
+ class="hidden"
233
+ :accept="accept || undefined"
234
+ :multiple="multiple"
235
+ :disabled="disabled"
236
+ @change="onPicked"
237
+ @click.stop
238
+ >
239
+ </button>
240
+ </template>
241
+
242
+ <style scoped>
243
+ .lp-dropzone__frame rect {
244
+ stroke: var(--color-line);
245
+ transition: stroke var(--duration-fast) linear;
246
+ }
247
+
248
+ .lp-dropzone:hover .lp-dropzone__frame rect {
249
+ stroke: var(--color-line-strong);
250
+ }
251
+
252
+ /* Marching ants, and only while a drag is in flight. At rest this would be a
253
+ loop nobody asked for, running for as long as the page is open and competing
254
+ with every other thing on it; under a drag it is the answer to "can I let go
255
+ here". One dash period per cycle, so the travel is continuous rather than
256
+ jumping back at the seam. */
257
+ .lp-dropzone--over .lp-dropzone__frame rect {
258
+ stroke: var(--color-brand);
259
+ animation: lp-dropzone-ants 500ms linear infinite;
260
+ }
261
+
262
+ @keyframes lp-dropzone-ants {
263
+ to {
264
+ stroke-dashoffset: -11;
265
+ }
266
+ }
267
+
268
+ /* The dashes fade in and the block settles up a few pixels. */
269
+ .lp-dropzone {
270
+ animation: lp-dropzone-in var(--duration-slow) var(--ease-settle) both;
271
+ }
272
+
273
+ .lp-dropzone--waiting {
274
+ opacity: 0;
275
+ animation: none;
276
+ }
277
+
278
+ @keyframes lp-dropzone-in {
279
+ from {
280
+ opacity: 0;
281
+ transform: translateY(6px);
282
+ }
283
+ }
284
+
285
+ @media (prefers-reduced-motion: reduce) {
286
+ .lp-dropzone,
287
+ .lp-dropzone--waiting {
288
+ opacity: 1;
289
+ transform: none;
290
+ animation: none;
291
+ }
292
+
293
+ /* The ants stop, the colour stays: the brand stroke is what says "drop
294
+ here", and it says it standing still. */
295
+ .lp-dropzone--over .lp-dropzone__frame rect {
296
+ animation: none;
297
+ }
298
+ }
299
+ </style>