@globalbrain/sefirot 4.0.0-3 → 4.1.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.
@@ -72,7 +72,7 @@ function getErrorMsg(validation: Validatable) {
72
72
  </span>
73
73
  </label>
74
74
 
75
- <slot />
75
+ <slot :has-error="error?.has" />
76
76
 
77
77
  <div class="help">
78
78
  <slot name="before-help" />
@@ -0,0 +1,229 @@
1
+ <script setup lang="ts">
2
+ import { type Component, computed, ref } from 'vue'
3
+ import { useTrans } from '../composables/Lang'
4
+ import { type Validatable } from '../composables/Validation'
5
+ import { formatSize } from '../support/File'
6
+ import SButton from './SButton.vue'
7
+ import SCard from './SCard.vue'
8
+ import SCardBlock from './SCardBlock.vue'
9
+ import SInputBase from './SInputBase.vue'
10
+ import SInputFileUploadItem from './SInputFileUploadItem.vue'
11
+
12
+ export type Size = 'mini' | 'small' | 'medium'
13
+ export type Color = 'neutral' | 'mute' | 'info' | 'success' | 'warning' | 'danger'
14
+
15
+ const props = defineProps<{
16
+ size?: Size
17
+ label?: string
18
+ info?: string
19
+ note?: string
20
+ help?: string
21
+ text?: string
22
+ placeholder?: string
23
+ emptyText?: string
24
+ accept?: string
25
+ multiple?: boolean
26
+ checkIcon?: Component
27
+ checkText?: string
28
+ checkColor?: Color
29
+ value?: File[]
30
+ modelValue?: File[]
31
+ hideError?: boolean
32
+ validation?: Validatable
33
+ }>()
34
+
35
+ const emit = defineEmits<{
36
+ 'update:model-value': [files: File[]]
37
+ 'change': [files: File[]]
38
+ }>()
39
+
40
+ const { t } = useTrans({
41
+ en: {
42
+ button_text: 'Choose File',
43
+ empty_text: 'No file selected',
44
+ selected_files: (c: number) => c === 1 ? `${c} file` : `${c} files`
45
+ },
46
+ ja: {
47
+ button_text: 'ファイルを選択',
48
+ empty_text: 'ファイルが選択されていません',
49
+ selected_files: (c: number) => `ファイル数: ${c}`
50
+ }
51
+ })
52
+
53
+ const _value = computed(() => {
54
+ return props.modelValue !== undefined
55
+ ? props.modelValue
56
+ : props.value !== undefined ? props.value : []
57
+ })
58
+
59
+ const input = ref<HTMLInputElement | null>(null)
60
+
61
+ const classes = computed(() => [props.size ?? 'small'])
62
+
63
+ const totalFileCountText = computed(() => {
64
+ return t.selected_files(_value.value.length)
65
+ })
66
+
67
+ const totalFileSizeText = computed(() => {
68
+ return formatSize(_value.value)
69
+ })
70
+
71
+ function open() {
72
+ input.value!.click()
73
+ }
74
+
75
+ function onChange(e: Event) {
76
+ const files = Array.from((e.target as HTMLInputElement).files ?? [])
77
+
78
+ // When files are empty, that means user have "canceled" the file selection
79
+ // so we should do nothing in this case.
80
+ if (files.length === 0) {
81
+ return
82
+ }
83
+
84
+ const newFiles = [..._value.value, ...files]
85
+
86
+ emit('update:model-value', newFiles)
87
+ emit('change', newFiles)
88
+
89
+ props.validation?.$touch()
90
+ }
91
+
92
+ function onRemove(index: number) {
93
+ const files = _value.value.filter((_, i) => i !== index)
94
+
95
+ emit('update:model-value', files)
96
+ emit('change', files)
97
+ }
98
+ </script>
99
+
100
+ <template>
101
+ <SInputBase
102
+ class="SInputFile"
103
+ :class="classes"
104
+ :label="label"
105
+ :note="note"
106
+ :info="info"
107
+ :help="help"
108
+ :check-icon="checkIcon"
109
+ :check-text="checkText"
110
+ :check-color="checkColor"
111
+ :validation="validation"
112
+ :hide-error="hideError"
113
+ >
114
+ <template #default="{ hasError }">
115
+ <input
116
+ ref="input"
117
+ class="input"
118
+ type="file"
119
+ :accept="accept"
120
+ multiple
121
+ @change="onChange"
122
+ >
123
+ <SCard :mode="hasError ? 'danger' : undefined">
124
+ <SCardBlock class="header">
125
+ <SButton
126
+ size="small"
127
+ :label="text ?? t.button_text"
128
+ @click="open"
129
+ />
130
+ <p v-if="placeholder" class="placeholder">
131
+ {{ placeholder }}
132
+ </p>
133
+ </SCardBlock>
134
+ <template v-if="_value.length">
135
+ <SInputFileUploadItem
136
+ v-for="file, i in _value"
137
+ :key="file.name"
138
+ :file="file"
139
+ @remove="() => { onRemove(i) }"
140
+ />
141
+ </template>
142
+ <template v-else>
143
+ <SCardBlock class="empty">
144
+ <p class="empty-text">
145
+ {{ emptyText ?? t.empty_text }}
146
+ </p>
147
+ </SCardBlock>
148
+ </template>
149
+ <SCardBlock class="footer">
150
+ <div class="footer-left">
151
+ <p class="footer-file-count">
152
+ {{ totalFileCountText }}
153
+ </p>
154
+ </div>
155
+ <div class="footer-right">
156
+ <p class="footer-file-size">
157
+ {{ totalFileSizeText }}
158
+ </p>
159
+ <div class="footer-spacer" />
160
+ </div>
161
+ </SCardBlock>
162
+ </SCard>
163
+ </template>
164
+ <template v-if="$slots.info" #info>
165
+ <slot name="info" />
166
+ </template>
167
+ </SInputBase>
168
+ </template>
169
+
170
+ <style lang="postcss" scoped>
171
+ .input {
172
+ display: none;
173
+ }
174
+
175
+ .header {
176
+ display: flex;
177
+ align-items: center;
178
+ gap: 12px;
179
+ height: 48px;
180
+ padding: 0 8px;
181
+ }
182
+
183
+ .placeholder {
184
+ line-height: 24px;
185
+ font-size: 12px;
186
+ color: var(--c-text-2);
187
+ }
188
+
189
+ .empty {
190
+ display: flex;
191
+ justify-content: center;
192
+ align-items: center;
193
+ height: 48px;
194
+ padding: 0 16px;
195
+ }
196
+
197
+ .empty-text {
198
+ line-height: 20px;
199
+ font-size: 12px;
200
+ color: var(--c-text-2);
201
+ }
202
+
203
+ .footer {
204
+ display: flex;
205
+ justify-content: space-between;
206
+ align-items: center;
207
+ height: 48px;
208
+ padding: 0 8px 0 16px;
209
+ }
210
+
211
+ .footer-right {
212
+ display: flex;
213
+ align-items: center;
214
+ gap: 8px;
215
+ }
216
+
217
+ .footer-file-count,
218
+ .footer-file-size {
219
+ line-height: 24px;
220
+ font-size: 12px;
221
+ color: var(--c-text-2);
222
+ }
223
+
224
+ .footer-spacer {
225
+ flex-shrink: 0;
226
+ width: 32px;
227
+ height: 32px;
228
+ }
229
+ </style>
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { formatSize } from '../support/File'
4
+ import SButton from './SButton.vue'
5
+ import SCardBlock from './SCardBlock.vue'
6
+ import IconFileText from '~icons/ph/file-text'
7
+ import IconTrash from '~icons/ph/trash'
8
+
9
+ const props = defineProps<{
10
+ file: File
11
+ }>()
12
+
13
+ defineEmits<{
14
+ 'remove': []
15
+ }>()
16
+
17
+ const fileSize = computed(() => {
18
+ return formatSize(props.file)
19
+ })
20
+ </script>
21
+
22
+ <template>
23
+ <SCardBlock class="SInputFileUploadItem">
24
+ <p class="name">
25
+ <IconFileText class="name-icon" />
26
+ <span class="name-text">{{ file.name }}</span>
27
+ </p>
28
+ <div class="size">{{ fileSize }}</div>
29
+ <SButton
30
+ size="small"
31
+ type="text"
32
+ mode="mute"
33
+ :icon="IconTrash"
34
+ @click="$emit('remove')"
35
+ />
36
+ </SCardBlock>
37
+ </template>
38
+
39
+ <style lang="postcss" scoped>
40
+ .SInputFileUploadItem {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 8px;
44
+ height: 48px;
45
+ padding: 0 8px 0 16px;
46
+ }
47
+
48
+ .name {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 8px;
52
+ flex-grow: 1;
53
+ white-space: nowrap;
54
+ overflow: hidden;
55
+ text-overflow: ellipsis;
56
+ }
57
+
58
+ .name-icon {
59
+ width: 16px;
60
+ height: 16px;
61
+ color: var(--c-text-2);
62
+ }
63
+
64
+ .name-text {
65
+ line-height: 24px;
66
+ font-size: 14px;
67
+ color: var(--c-text-1);
68
+ white-space: nowrap;
69
+ overflow: hidden;
70
+ text-overflow: ellipsis;
71
+ }
72
+
73
+ .size {
74
+ line-height: 24px;
75
+ font-size: 12px;
76
+ color: var(--c-text-2);
77
+ }
78
+ </style>
@@ -6,3 +6,19 @@ export function getExtension(file: File): string {
6
6
 
7
7
  return name.slice((name.lastIndexOf('.') - 1 >>> 0) + 2)
8
8
  }
9
+
10
+ /**
11
+ * Formats the file size in bytes to a human-readable format. It also accepts
12
+ * array of files and returns the total size.
13
+ */
14
+ export function formatSize(files: File | File[]): string {
15
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
16
+ files = Array.isArray(files) ? files : [files]
17
+ let size = files.reduce((previous, file) => previous + file.size, 0)
18
+ let index = 0
19
+ while (size >= 1024 && index < units.length) {
20
+ size /= 1024
21
+ index++
22
+ }
23
+ return `${size.toFixed(2)}${units[index]}`
24
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
3
  "type": "module",
4
- "version": "4.0.0-3",
4
+ "version": "4.1.0",
5
5
  "packageManager": "pnpm@9.5.0",
6
6
  "description": "Vue Components for Global Brain Design System.",
7
7
  "author": "Kia Ishii <ka.ishii@globalbrains.com>",