@globalbrain/sefirot 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/SInputDate.vue +1 -1
- package/lib/components/SInputImage.vue +239 -0
- package/lib/components/SM.vue +56 -0
- package/lib/components/SMFade.vue +13 -0
- package/lib/composables/Image.ts +46 -0
- package/lib/styles/variables.css +1 -1
- package/lib/support/Utils.ts +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { type IconifyIcon } from '@iconify/vue/dist/offline'
|
|
3
|
+
import IconImage from '@iconify-icons/ph/image-bold'
|
|
4
|
+
import { computed, ref } from 'vue'
|
|
5
|
+
import { useImageSrcFromFile } from '../composables/Image'
|
|
6
|
+
import { type Validatable } from '../composables/Validation'
|
|
7
|
+
import SButton from './SButton.vue'
|
|
8
|
+
import SIcon from './SIcon.vue'
|
|
9
|
+
import SInputBase from './SInputBase.vue'
|
|
10
|
+
|
|
11
|
+
export type Size = 'mini' | 'small' | 'medium'
|
|
12
|
+
export type CheckColor = 'neutral' | 'mute' | 'info' | 'success' | 'warning' | 'danger'
|
|
13
|
+
export type ImageType = 'rectangle' | 'circle'
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<{
|
|
16
|
+
size?: Size
|
|
17
|
+
label?: string
|
|
18
|
+
info?: string
|
|
19
|
+
note?: string
|
|
20
|
+
help?: string
|
|
21
|
+
checkIcon?: IconifyIcon
|
|
22
|
+
checkText?: string
|
|
23
|
+
checkColor?: CheckColor
|
|
24
|
+
imageType?: ImageType
|
|
25
|
+
imageWidth?: string
|
|
26
|
+
imageAspectRatio?: string
|
|
27
|
+
selectText?: string
|
|
28
|
+
removeText?: string
|
|
29
|
+
accept?: string
|
|
30
|
+
nullable?: boolean
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
value?: File | string | null
|
|
33
|
+
modelValue?: File | string | null
|
|
34
|
+
hideError?: boolean
|
|
35
|
+
validation?: Validatable
|
|
36
|
+
}>(), {
|
|
37
|
+
imageType: 'rectangle',
|
|
38
|
+
imageWidth: '96px',
|
|
39
|
+
imageAspectRatio: '1 / 1',
|
|
40
|
+
selectText: 'Select image',
|
|
41
|
+
removeText: 'Remove image',
|
|
42
|
+
nullable: true
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{
|
|
46
|
+
(e: 'update:model-value', value: File | null): void
|
|
47
|
+
(e: 'change', value: File | null): void
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
const fileInput = ref<HTMLInputElement | null>(null)
|
|
51
|
+
|
|
52
|
+
const _value = computed(() => {
|
|
53
|
+
return (props.modelValue !== undefined)
|
|
54
|
+
? props.modelValue
|
|
55
|
+
: props.value !== undefined ? props.value : null
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const { src: imageSrc } = useImageSrcFromFile(_value)
|
|
59
|
+
|
|
60
|
+
function openFileSelect() {
|
|
61
|
+
fileInput.value!.click()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function onFileSelect(e: Event) {
|
|
65
|
+
const file = ((e.target as HTMLInputElement).files ?? [])[0]
|
|
66
|
+
|
|
67
|
+
emit('update:model-value', file ?? null)
|
|
68
|
+
emit('change', file ?? null)
|
|
69
|
+
|
|
70
|
+
file && props.validation?.$touch()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function onFileDelete() {
|
|
74
|
+
fileInput.value!.value = ''
|
|
75
|
+
|
|
76
|
+
emit('update:model-value', null)
|
|
77
|
+
emit('change', null)
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<template>
|
|
82
|
+
<SInputBase
|
|
83
|
+
class="SInputImage"
|
|
84
|
+
:class="[size]"
|
|
85
|
+
:label="label"
|
|
86
|
+
:note="note"
|
|
87
|
+
:info="info"
|
|
88
|
+
:check-icon="checkIcon"
|
|
89
|
+
:check-text="checkText"
|
|
90
|
+
:check-color="checkColor"
|
|
91
|
+
:hide-error="hideError"
|
|
92
|
+
:validation="validation"
|
|
93
|
+
>
|
|
94
|
+
<template #default>
|
|
95
|
+
<input
|
|
96
|
+
ref="fileInput"
|
|
97
|
+
class="file-input"
|
|
98
|
+
type="file"
|
|
99
|
+
:accept="accept"
|
|
100
|
+
@change="onFileSelect"
|
|
101
|
+
>
|
|
102
|
+
|
|
103
|
+
<div class="container">
|
|
104
|
+
<div class="image" :class="[imageType]">
|
|
105
|
+
<div v-if="imageSrc" class="image-fill">
|
|
106
|
+
<img class="image-fill-src" :src="imageSrc">
|
|
107
|
+
</div>
|
|
108
|
+
<div v-else class="image-empty">
|
|
109
|
+
<div class="image-empty-inner">
|
|
110
|
+
<SIcon class="image-empty-icon" :icon="IconImage" />
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="control">
|
|
115
|
+
<div class="actions">
|
|
116
|
+
<SButton
|
|
117
|
+
size="small"
|
|
118
|
+
:label="selectText"
|
|
119
|
+
:disabled="disabled"
|
|
120
|
+
@click="openFileSelect"
|
|
121
|
+
/>
|
|
122
|
+
<SButton
|
|
123
|
+
v-if="nullable && imageSrc"
|
|
124
|
+
size="small"
|
|
125
|
+
mode="danger"
|
|
126
|
+
:label="removeText"
|
|
127
|
+
:disabled="disabled"
|
|
128
|
+
@click="onFileDelete"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<p v-if="help" class="help">
|
|
132
|
+
{{ help }}
|
|
133
|
+
</p>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
137
|
+
|
|
138
|
+
<template v-if="$slots.info" #info>
|
|
139
|
+
<slot name="info" />
|
|
140
|
+
</template>
|
|
141
|
+
</SInputBase>
|
|
142
|
+
</template>
|
|
143
|
+
|
|
144
|
+
<style scoped lang="postcss">
|
|
145
|
+
.file-input {
|
|
146
|
+
display: none;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.container {
|
|
150
|
+
display: flex;
|
|
151
|
+
flex-direction: column;
|
|
152
|
+
gap: 12px;
|
|
153
|
+
padding-top: 4px;
|
|
154
|
+
|
|
155
|
+
@media (min-width: 640px) {
|
|
156
|
+
flex-direction: row;
|
|
157
|
+
align-items: center;
|
|
158
|
+
gap: 24px;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.image {
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-shrink: 0;
|
|
165
|
+
width: var(--input-image-width, v-bind(imageWidth));
|
|
166
|
+
aspect-ratio: v-bind(imageAspectRatio);
|
|
167
|
+
overflow: hidden;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.image-fill {
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-shrink: 0;
|
|
173
|
+
width: 100%;
|
|
174
|
+
height: 100%;
|
|
175
|
+
overflow: hidden;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.image-fill-src {
|
|
179
|
+
width: 100%;
|
|
180
|
+
height: 100%;
|
|
181
|
+
object-fit: cover;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.image-empty {
|
|
185
|
+
display: flex;
|
|
186
|
+
border: 1px solid var(--c-border-mute-1);
|
|
187
|
+
padding: 8px;
|
|
188
|
+
width: 100%;
|
|
189
|
+
height: 100%;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.image-empty-inner {
|
|
193
|
+
display: flex;
|
|
194
|
+
justify-content: center;
|
|
195
|
+
align-items: center;
|
|
196
|
+
border: 1px dashed var(--c-border-mute-1);
|
|
197
|
+
width: 100%;
|
|
198
|
+
height: 100%;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.image-empty-icon {
|
|
202
|
+
width: 24px;
|
|
203
|
+
height: 24px;
|
|
204
|
+
color: var(--c-text-3);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.control {
|
|
208
|
+
display: flex;
|
|
209
|
+
flex-direction: column;
|
|
210
|
+
gap: 8px;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.actions {
|
|
214
|
+
display: flex;
|
|
215
|
+
gap: 8px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.help {
|
|
219
|
+
margin: 0;
|
|
220
|
+
line-height: 20px;
|
|
221
|
+
font-size: 12px;
|
|
222
|
+
color: var(--c-text-2);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.image.rectangle .image-fill,
|
|
226
|
+
.image.rectangle .image-empty {
|
|
227
|
+
border-radius: 6px;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.image.rectangle .image-empty-inner {
|
|
231
|
+
border-radius: 3px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.image.circle .image-fill,
|
|
235
|
+
.image.circle .image-empty,
|
|
236
|
+
.image.circle .image-empty-inner {
|
|
237
|
+
border-radius: 50%;
|
|
238
|
+
}
|
|
239
|
+
</style>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useIntersectionObserver } from '@vueuse/core'
|
|
3
|
+
import { ref } from 'vue'
|
|
4
|
+
|
|
5
|
+
export interface Props {
|
|
6
|
+
as?: string
|
|
7
|
+
x?: string
|
|
8
|
+
y?: string
|
|
9
|
+
opacity?: string | number
|
|
10
|
+
duration?: string
|
|
11
|
+
delay?: string
|
|
12
|
+
once?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
16
|
+
as: 'div',
|
|
17
|
+
x: '0',
|
|
18
|
+
y: '0',
|
|
19
|
+
opacity: 1,
|
|
20
|
+
duration: '0.75s',
|
|
21
|
+
delay: '0s',
|
|
22
|
+
once: true
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const target = ref<HTMLElement | null>(null)
|
|
26
|
+
const on = ref(false)
|
|
27
|
+
|
|
28
|
+
const { stop } = useIntersectionObserver(target, ([{ isIntersecting }]) => {
|
|
29
|
+
on.value = isIntersecting
|
|
30
|
+
if (on.value && props.once) {
|
|
31
|
+
stop()
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<component :is="as" class="SM" :class="{ on }" ref="target">
|
|
38
|
+
<slot :on="on" />
|
|
39
|
+
</component>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<style scoped lang="postcss">
|
|
43
|
+
.SM {
|
|
44
|
+
position: relative;
|
|
45
|
+
opacity: v-bind(opacity);
|
|
46
|
+
transform: translate(v-bind(x), v-bind(y));
|
|
47
|
+
transition: opacity, transform;
|
|
48
|
+
transition-duration: v-bind(duration);
|
|
49
|
+
transition-delay: v-bind(delay);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.SM.on {
|
|
53
|
+
opacity: 1;
|
|
54
|
+
transform: translate(0, 0);
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type MaybeRefOrGetter, type Ref, ref, toValue, watchEffect } from 'vue'
|
|
2
|
+
import { isFile, isString } from '../support/Utils'
|
|
3
|
+
|
|
4
|
+
export interface ImageSrcFromFile {
|
|
5
|
+
src: Ref<string | null>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get renderable image src from the given file. If a string is given, it will
|
|
10
|
+
* be returned as is. It is useful when you want to render an image from a
|
|
11
|
+
* remote URL.
|
|
12
|
+
*/
|
|
13
|
+
export function useImageSrcFromFile(
|
|
14
|
+
file: MaybeRefOrGetter<File | string | null>
|
|
15
|
+
): ImageSrcFromFile {
|
|
16
|
+
const reader = new FileReader()
|
|
17
|
+
const src = ref<string | null>(null)
|
|
18
|
+
|
|
19
|
+
reader.onload = function () {
|
|
20
|
+
src.value = this.result?.toString() ?? null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
watchEffect(() => {
|
|
24
|
+
read()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function read(): void {
|
|
28
|
+
const f = toValue(file)
|
|
29
|
+
|
|
30
|
+
if (isFile(f)) {
|
|
31
|
+
reader.readAsDataURL(f)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (isString(f)) {
|
|
36
|
+
src.value = f
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
src.value = null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
src
|
|
45
|
+
}
|
|
46
|
+
}
|
package/lib/styles/variables.css
CHANGED
package/lib/support/Utils.ts
CHANGED
|
@@ -17,3 +17,7 @@ export function isArray(value: unknown): value is unknown[] {
|
|
|
17
17
|
export function isObject(value: unknown): value is Record<string, any> {
|
|
18
18
|
return typeof value === 'object' && value !== null && !isArray(value)
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
export function isFile(value: unknown): value is File {
|
|
22
|
+
return value instanceof File
|
|
23
|
+
}
|