@globalbrain/sefirot 4.9.0 → 4.10.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.
@@ -7,7 +7,7 @@ import IconMinusCircle from '~icons/ph/minus-circle'
7
7
  import IconXCircle from '~icons/ph/x-circle'
8
8
  import { computed } from 'vue'
9
9
 
10
- export type Size = 'nano' | 'mini' | 'small' | 'medium' | 'large' | 'jumbo'
10
+ export type Size = 'nano' | 'mini' | 'small' | 'medium' | 'large' | 'jumbo' | 'fill'
11
11
  export type State = 'pending' | 'ready' | 'queued' | 'running' | 'completed' | 'failed' | 'aborted'
12
12
  export type Mode = 'colored' | 'monochrome'
13
13
 
@@ -65,6 +65,7 @@ const classes = computed(() => [
65
65
  .SIndicator.medium { width: 32px; height: 32px; }
66
66
  .SIndicator.large { width: 40px; height: 40px; }
67
67
  .SIndicator.jumbo { width: 48px; height: 48px; }
68
+ .SIndicator.fill { width: 100%; height: 100%; }
68
69
 
69
70
  .SIndicator.queued {
70
71
  animation: indicator-blink 1.5s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite;
@@ -1,18 +1,42 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="T extends ModelType = 'file'">
2
+ import { type ValidationRuleWithParams } from '@vuelidate/core'
3
+ import { useDropZone } from '@vueuse/core'
2
4
  import { type Component, computed, ref } from 'vue'
3
5
  import { useTrans } from '../composables/Lang'
4
6
  import { type Validatable } from '../composables/Validation'
5
7
  import { formatSize } from '../support/File'
6
- import SButton from './SButton.vue'
8
+ import SButton, { type Mode as ButtonMode } from './SButton.vue'
7
9
  import SCard from './SCard.vue'
8
10
  import SCardBlock from './SCardBlock.vue'
11
+ import { type State as IndicatorState } from './SIndicator.vue'
9
12
  import SInputBase from './SInputBase.vue'
10
13
  import SInputFileUploadItem from './SInputFileUploadItem.vue'
14
+ import STrans from './STrans.vue'
11
15
 
12
16
  export type Size = 'mini' | 'small' | 'medium'
13
17
  export type Color = 'neutral' | 'mute' | 'info' | 'success' | 'warning' | 'danger'
14
18
 
15
- const props = defineProps<{
19
+ export type ModelType = 'file' | 'object'
20
+ export type ModelValue<T extends ModelType> = T extends 'file' ? File : FileObject
21
+
22
+ export interface FileObject {
23
+ file: File
24
+ indicatorState?: IndicatorState | null
25
+ canRemove?: boolean
26
+ action?: Action
27
+ errorMessage?: string | null
28
+ }
29
+
30
+ export interface Action {
31
+ mode?: ButtonMode
32
+ icon?: Component
33
+ leadIcon?: Component
34
+ trailIcon?: Component
35
+ label?: string
36
+ onClick(): void
37
+ }
38
+
39
+ const props = withDefaults(defineProps<{
16
40
  size?: Size
17
41
  label?: string
18
42
  info?: string
@@ -26,15 +50,20 @@ const props = defineProps<{
26
50
  checkIcon?: Component
27
51
  checkText?: string
28
52
  checkColor?: Color
29
- value?: File[]
30
- modelValue?: File[]
31
- hideError?: boolean
53
+ droppable?: boolean
54
+ value?: ModelValue<T>[]
55
+ modelType?: T
56
+ modelValue?: ModelValue<T>[]
57
+ rules?: Record<string, ValidationRuleWithParams>
32
58
  validation?: Validatable
33
- }>()
59
+ hideError?: boolean
60
+ }>(), {
61
+ modelType: 'file' as any // `ModelType` doesn't work so stubbing it.
62
+ })
34
63
 
35
64
  const emit = defineEmits<{
36
- 'update:model-value': [files: File[]]
37
- 'change': [files: File[]]
65
+ 'update:model-value': [files: ModelValue<T>[]]
66
+ 'change': [files: ModelValue<T>[]]
38
67
  }>()
39
68
 
40
69
  const { t } = useTrans({
@@ -50,28 +79,46 @@ const { t } = useTrans({
50
79
  }
51
80
  })
52
81
 
82
+ const dropZoneEl = ref<HTMLDivElement | null>(null)
83
+
84
+ const { isOverDropZone } = useDropZone(dropZoneEl, {
85
+ multiple: true,
86
+ onDrop: (files) => onDrop(files)
87
+ })
88
+
53
89
  const _value = computed(() => {
54
90
  return props.modelValue !== undefined
55
91
  ? props.modelValue
56
- : props.value !== undefined ? props.value : []
92
+ : props.value !== undefined ? props.value : [] as ModelValue<T>[]
57
93
  })
58
94
 
59
95
  const input = ref<HTMLInputElement | null>(null)
60
96
 
61
- const classes = computed(() => [props.size ?? 'small'])
97
+ const classes = computed(() => [
98
+ props.size ?? 'small',
99
+ { droppable: props.droppable },
100
+ { 'is-over-drop-zone': isOverDropZone.value }
101
+ ])
62
102
 
63
103
  const totalFileCountText = computed(() => {
64
104
  return t.selected_files(_value.value.length)
65
105
  })
66
106
 
67
107
  const totalFileSizeText = computed(() => {
68
- return formatSize(_value.value)
108
+ const files = _value.value.map((file) => file instanceof File ? file : file.file)
109
+ return formatSize(files)
69
110
  })
70
111
 
71
112
  function open() {
72
113
  input.value!.click()
73
114
  }
74
115
 
116
+ function onDrop(files: File[] | null) {
117
+ if (files !== null && files.length > 0) {
118
+ emitChange(append(files))
119
+ }
120
+ }
121
+
75
122
  function onChange(e: Event) {
76
123
  const files = Array.from((e.target as HTMLInputElement).files ?? [])
77
124
 
@@ -81,25 +128,35 @@ function onChange(e: Event) {
81
128
  return
82
129
  }
83
130
 
84
- const newFiles = [..._value.value, ...files]
85
-
86
- emit('update:model-value', newFiles)
87
- emit('change', newFiles)
88
-
89
- props.validation?.$touch()
131
+ emitChange(append(files))
90
132
  }
91
133
 
92
134
  function onRemove(index: number) {
93
135
  const files = _value.value.filter((_, i) => i !== index)
136
+ emitChange(files)
137
+ }
94
138
 
139
+ function emitChange(files: ModelValue<T>[]) {
95
140
  emit('update:model-value', files)
96
141
  emit('change', files)
142
+ props.validation?.$touch()
143
+ }
144
+
145
+ function append(files: File[]) {
146
+ return [
147
+ ..._value.value,
148
+ ...(props.modelType === 'file' ? files : toFileObjects(files))
149
+ ] as ModelValue<T>[]
150
+ }
151
+
152
+ function toFileObjects(files: File[]) {
153
+ return files.map((file) => ({ file } as ModelValue<T>))
97
154
  }
98
155
  </script>
99
156
 
100
157
  <template>
101
158
  <SInputBase
102
- class="SInputFile"
159
+ class="SInputFileUpload"
103
160
  :class="classes"
104
161
  :label="label"
105
162
  :note="note"
@@ -121,8 +178,29 @@ function onRemove(index: number) {
121
178
  @change="onChange"
122
179
  >
123
180
  <SCard :mode="hasError ? 'danger' : undefined">
124
- <SCardBlock class="header">
181
+ <SCardBlock v-if="droppable" class="drop-zone" ref="dropZoneEl" @click="open">
182
+ <div class="drop-zone-box">
183
+ <STrans lang="en">
184
+ <div class="drop-zone-text">
185
+ Drag and drop files here, or
186
+ </div>
187
+ <div class="drop-zone-action">
188
+ <SButton size="mini" label="Select files" />
189
+ </div>
190
+ </STrans>
191
+ <STrans lang="ja">
192
+ <div class="drop-zone-text">
193
+ ファイルをドラック&ドロップ、または
194
+ </div>
195
+ <div class="drop-zone-action">
196
+ <SButton size="mini" label="ファイルを選択" />
197
+ </div>
198
+ </STrans>
199
+ </div>
200
+ </SCardBlock>
201
+ <SCardBlock v-if="!droppable || placeholder" class="header">
125
202
  <SButton
203
+ v-if="!droppable"
126
204
  size="small"
127
205
  :label="text ?? t.button_text"
128
206
  @click="open"
@@ -134,8 +212,9 @@ function onRemove(index: number) {
134
212
  <template v-if="_value.length">
135
213
  <SInputFileUploadItem
136
214
  v-for="file, i in _value"
137
- :key="file.name"
215
+ :key="i"
138
216
  :file="file"
217
+ :rules="rules"
139
218
  @remove="() => { onRemove(i) }"
140
219
  />
141
220
  </template>
@@ -172,6 +251,35 @@ function onRemove(index: number) {
172
251
  display: none;
173
252
  }
174
253
 
254
+ .drop-zone {
255
+ padding: 12px;
256
+
257
+ &:hover .drop-zone-box {
258
+ border-color: var(--c-border-info-1);
259
+ cursor: pointer;
260
+ }
261
+ }
262
+
263
+ .drop-zone-box {
264
+ display: flex;
265
+ flex-direction: column;
266
+ justify-content: center;
267
+ align-items: center;
268
+ gap: 16px;
269
+ border: 1px dashed var(--c-border-mute-1);
270
+ border-radius: 3px;
271
+ padding: 24px 0;
272
+ min-height: 192px;
273
+ text-align: center;
274
+ transition: border-color 0.25s;
275
+ }
276
+
277
+ .drop-zone-text {
278
+ text-align: center;
279
+ font-size: 14px;
280
+ color: var(--c-text-2);
281
+ }
282
+
175
283
  .header {
176
284
  display: flex;
177
285
  align-items: center;
@@ -226,4 +334,16 @@ function onRemove(index: number) {
226
334
  width: 32px;
227
335
  height: 32px;
228
336
  }
337
+
338
+ .SInputFileUpload.droppable {
339
+ .header {
340
+ padding-left: 16px;
341
+ }
342
+ }
343
+
344
+ .SInputFileUpload.is-over-drop-zone {
345
+ .drop-zone-box {
346
+ border-color: var(--c-border-info-1);
347
+ }
348
+ }
229
349
  </style>
@@ -1,38 +1,99 @@
1
1
  <script setup lang="ts">
2
2
  import IconFileText from '~icons/ph/file-text'
3
3
  import IconTrash from '~icons/ph/trash'
4
- import { computed } from 'vue'
4
+ import { type ValidationRuleWithParams } from '@vuelidate/core'
5
+ import { type Component, computed } from 'vue'
6
+ import { useValidation } from '../composables/Validation'
5
7
  import { formatSize } from '../support/File'
6
- import SButton from './SButton.vue'
8
+ import SButton, { type Mode as ButtonMode } from './SButton.vue'
7
9
  import SCardBlock from './SCardBlock.vue'
10
+ import SIndicator, { type State as IndicatorState } from './SIndicator.vue'
8
11
 
9
- const props = defineProps<{
12
+ export interface FileObject {
10
13
  file: File
14
+ indicatorState?: IndicatorState | null
15
+ canRemove?: boolean
16
+ action?: Action | null
17
+ errorMessage?: string | null
18
+ }
19
+
20
+ export interface Action {
21
+ mode?: ButtonMode
22
+ icon?: Component
23
+ leadIcon?: Component
24
+ trailIcon?: Component
25
+ label?: string
26
+ onClick(): void
27
+ }
28
+
29
+ const props = defineProps<{
30
+ file: File | FileObject
31
+ rules?: Record<string, ValidationRuleWithParams>
11
32
  }>()
12
33
 
13
34
  defineEmits<{
14
35
  'remove': []
15
36
  }>()
16
37
 
17
- const fileSize = computed(() => {
18
- return formatSize(props.file)
38
+ const _file = computed(() => ({
39
+ name: props.file instanceof File ? props.file.name : props.file.file.name,
40
+ file: props.file instanceof File ? props.file : props.file.file,
41
+ size: formatSize(props.file instanceof File ? props.file : props.file.file),
42
+ indicatorState: props.file instanceof File ? null : props.file.indicatorState,
43
+ canRemove: props.file instanceof File ? true : props.file.canRemove ?? true,
44
+ action: props.file instanceof File ? null : props.file.action,
45
+ errorMessage: props.file instanceof File ? null : props.file.errorMessage
46
+ }))
47
+
48
+ const { validation } = useValidation(() => ({
49
+ file: _file.value.file
50
+ }), {
51
+ file: props.rules ?? {}
19
52
  })
53
+
54
+ validation.value.$touch()
20
55
  </script>
21
56
 
22
57
  <template>
23
58
  <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
- />
59
+ <div class="name">
60
+ <div class="name-label">
61
+ <div class="name-icon">
62
+ <IconFileText v-if="_file.indicatorState == null" class="name-icon-svg" />
63
+ <SIndicator size="fill" v-else :state="_file.indicatorState" />
64
+ </div>
65
+ <p class="name-text">{{ _file.name }}</p>
66
+ </div>
67
+ <p v-if="_file.errorMessage" class="error">{{ _file.errorMessage }}</p>
68
+ <p v-else-if="validation.$errors.length" class="error">{{ validation.$errors[0]?.$message }}</p>
69
+ </div>
70
+ <div v-if="_file.action" class="action">
71
+ <SButton
72
+ type="text"
73
+ size="small"
74
+ :mode="_file.action.mode"
75
+ :icon="_file.action.icon"
76
+ :lead-icon="_file.action.leadIcon"
77
+ :trail-icon="_file.action.trailIcon"
78
+ :label="_file.action.label"
79
+ @click="_file.action.onClick"
80
+ />
81
+ </div>
82
+ <div class="meta">
83
+ <div class="size">
84
+ {{ _file.size }}
85
+ </div>
86
+ <div class="delete">
87
+ <SButton
88
+ size="small"
89
+ type="text"
90
+ mode="mute"
91
+ :icon="IconTrash"
92
+ :disabled="!_file.canRemove"
93
+ @click="$emit('remove')"
94
+ />
95
+ </div>
96
+ </div>
36
97
  </SCardBlock>
37
98
  </template>
38
99
 
@@ -40,12 +101,20 @@ const fileSize = computed(() => {
40
101
  .SInputFileUploadItem {
41
102
  display: flex;
42
103
  align-items: center;
43
- gap: 8px;
44
- height: 48px;
45
- padding: 0 8px 0 16px;
104
+ gap: 16px;
105
+ padding: 8px 8px 8px 16px;
46
106
  }
47
107
 
48
108
  .name {
109
+ display: flex;
110
+ flex-direction: column;
111
+ flex-grow: 1;
112
+ white-space: nowrap;
113
+ overflow: hidden;
114
+ text-overflow: ellipsis;
115
+ }
116
+
117
+ .name-label {
49
118
  display: flex;
50
119
  align-items: center;
51
120
  gap: 8px;
@@ -56,6 +125,12 @@ const fileSize = computed(() => {
56
125
  }
57
126
 
58
127
  .name-icon {
128
+ flex-shrink: 0;
129
+ width: 16px;
130
+ height: 16px;
131
+ }
132
+
133
+ .name-icon-svg {
59
134
  width: 16px;
60
135
  height: 16px;
61
136
  color: var(--c-text-2);
@@ -70,9 +145,35 @@ const fileSize = computed(() => {
70
145
  text-overflow: ellipsis;
71
146
  }
72
147
 
148
+ .error {
149
+ padding-left: 24px;
150
+ line-height: 20px;
151
+ font-size: 12px;
152
+ color: var(--c-text-danger-1);
153
+ white-space: nowrap;
154
+ overflow: hidden;
155
+ text-overflow: ellipsis;
156
+ }
157
+
158
+ .action {
159
+ flex-shrink: 0;
160
+ }
161
+
162
+ .meta {
163
+ display: flex;
164
+ align-items: center;
165
+ flex-shrink: 0;
166
+ gap: 8px;
167
+ }
168
+
73
169
  .size {
170
+ flex-shrink: 0;
74
171
  line-height: 24px;
75
172
  font-size: 12px;
76
173
  color: var(--c-text-2);
77
174
  }
175
+
176
+ .delete {
177
+ flex-shrink: 0;
178
+ }
78
179
  </style>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
3
  "type": "module",
4
- "version": "4.9.0",
4
+ "version": "4.10.0",
5
5
  "packageManager": "pnpm@9.15.3",
6
6
  "description": "Vue Components for Global Brain Design System.",
7
7
  "author": "Kia Ishii <ka.ishii@globalbrains.com>",