frappe-ui 0.1.24 → 0.1.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -2,24 +2,102 @@
2
2
  import { ref } from 'vue'
3
3
  import Autocomplete from './Autocomplete.vue'
4
4
 
5
- const value = ref('')
5
+ const single = ref('')
6
+ const people = ref(null)
6
7
  const options = [
7
- { label: 'John Doe', value: 'john-doe' },
8
- { label: 'Jane Doe', value: 'jane-doe' },
9
- { label: 'John Smith', value: 'john-smith' },
10
- { label: 'Jane Smith', value: 'jane-smith' },
11
- { label: 'John Wayne', value: 'john-wayne' },
12
- { label: 'Jane Wayne', value: 'jane-wayne' },
8
+ {
9
+ label: 'John Doe',
10
+ value: 'john-doe',
11
+ image: 'https://randomuser.me/api/portraits/men/59.jpg',
12
+ },
13
+ {
14
+ label: 'Jane Doe',
15
+ value: 'jane-doe',
16
+ image: 'https://randomuser.me/api/portraits/women/58.jpg',
17
+ },
18
+ {
19
+ label: 'John Smith',
20
+ value: 'john-smith',
21
+ image: 'https://randomuser.me/api/portraits/men/59.jpg',
22
+ },
23
+ {
24
+ label: 'Jane Smith',
25
+ value: 'jane-smith',
26
+ image: 'https://randomuser.me/api/portraits/women/59.jpg',
27
+ },
28
+ {
29
+ label: 'John Wayne',
30
+ value: 'john-wayne',
31
+ image: 'https://randomuser.me/api/portraits/men/57.jpg',
32
+ },
33
+ {
34
+ label: 'Jane Wayne',
35
+ value: 'jane-wayne',
36
+ image: 'https://randomuser.me/api/portraits/women/51.jpg',
37
+ },
13
38
  ]
14
39
  </script>
15
40
  <template>
16
41
  <Story :layout="{ width: 500, type: 'grid' }" autoPropsDisabled>
17
- <div class="p-2">
18
- <Autocomplete
19
- :options="options"
20
- v-model="value"
21
- placeholder="Select person"
22
- />
23
- </div>
42
+ <Variant title="Single option">
43
+ <div class="p-2">
44
+ <Autocomplete
45
+ :options="options"
46
+ v-model="single"
47
+ placeholder="Select person"
48
+ />
49
+ </div>
50
+ </Variant>
51
+ <Variant title="Single option with prefix slots">
52
+ <div class="p-2">
53
+ <Autocomplete
54
+ :options="options"
55
+ v-model="single"
56
+ placeholder="Select person"
57
+ >
58
+ <template #prefix>
59
+ <img
60
+ v-if="single"
61
+ :src="single.image"
62
+ class="mr-2 h-4 w-4 rounded-full"
63
+ />
64
+ </template>
65
+ <template #item-prefix="{ option }">
66
+ <img :src="option.image" class="h-4 w-4 rounded-full" />
67
+ </template>
68
+ </Autocomplete>
69
+ </div>
70
+ </Variant>
71
+ <Variant title="Single option without search">
72
+ <div class="p-2">
73
+ <Autocomplete
74
+ :options="options"
75
+ v-model="single"
76
+ placeholder="Select person"
77
+ hide-search="true"
78
+ />
79
+ </div>
80
+ </Variant>
81
+ <Variant title="Multiple options">
82
+ <div class="p-2">
83
+ <Autocomplete
84
+ :options="options"
85
+ v-model="people"
86
+ placeholder="Select people"
87
+ multiple="true"
88
+ />
89
+ </div>
90
+ </Variant>
91
+ <Variant title="Multiple options without search">
92
+ <div class="p-2">
93
+ <Autocomplete
94
+ :options="options"
95
+ v-model="people"
96
+ placeholder="Select people"
97
+ multiple="true"
98
+ hide-search="true"
99
+ />
100
+ </div>
101
+ </Variant>
24
102
  </Story>
25
103
  </template>
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
2
+ <Combobox
3
+ v-model="selectedValue"
4
+ :multiple="multiple"
5
+ nullable
6
+ v-slot="{ open: isComboboxOpen }"
7
+ >
3
8
  <Popover class="w-full" v-model:show="showOptions">
4
9
  <template #target="{ open: openPopover, togglePopover }">
5
10
  <slot name="target" v-bind="{ open: openPopover, togglePopover }">
@@ -9,15 +14,12 @@
9
14
  :class="{ 'bg-gray-200': isComboboxOpen }"
10
15
  @click="() => togglePopover()"
11
16
  >
12
- <div class="flex items-center">
17
+ <div class="flex items-center overflow-hidden">
13
18
  <slot name="prefix" />
14
- <span
15
- class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
16
- v-if="selectedValue"
17
- >
19
+ <span class="truncate text-base leading-5" v-if="selectedValue">
18
20
  {{ displayValue(selectedValue) }}
19
21
  </span>
20
- <span class="text-base leading-5 text-gray-500" v-else>
22
+ <span class="text-base leading-5 text-gray-600" v-else>
21
23
  {{ placeholder || '' }}
22
24
  </span>
23
25
  </div>
@@ -30,97 +32,161 @@
30
32
  </div>
31
33
  </slot>
32
34
  </template>
33
- <template #body="{ isOpen }">
35
+ <template #body="{ isOpen, togglePopover }">
34
36
  <div v-show="isOpen">
35
- <ComboboxOptions
36
- class="mt-1 max-h-[15rem] overflow-y-auto rounded-lg bg-white px-1.5 pb-1.5 shadow-2xl"
37
- static
37
+ <div
38
+ class="relative mt-1 rounded-lg bg-white text-base shadow-2xl"
39
+ :class="bodyClasses"
38
40
  >
39
- <div
40
- class="sticky top-0 z-10 flex items-stretch space-x-1.5 bg-white pt-1.5"
41
- >
42
- <div class="relative w-full">
43
- <ComboboxInput
44
- ref="search"
45
- class="form-input w-full"
46
- type="text"
47
- @change="
48
- (e) => {
49
- query = e.target.value
50
- }
51
- "
52
- :value="query"
53
- autocomplete="off"
54
- placeholder="Search"
55
- />
56
- <button
57
- class="absolute right-0 inline-flex h-7 w-7 items-center justify-center"
58
- @click="selectedValue = null"
59
- >
60
- <FeatherIcon name="x" class="w-4" />
61
- </button>
62
- </div>
63
- </div>
64
- <div
65
- class="mt-1.5"
66
- v-for="group in groups"
67
- :key="group.key"
68
- v-show="group.items.length > 0"
41
+ <ComboboxOptions
42
+ class="max-h-[15rem] overflow-y-auto px-1.5 pb-1.5"
43
+ :class="{ 'pt-1.5': hideSearch }"
44
+ static
69
45
  >
70
46
  <div
71
- v-if="group.group && !group.hideLabel"
72
- class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
47
+ v-if="!hideSearch"
48
+ class="sticky top-0 z-10 flex items-stretch space-x-1.5 bg-white py-1.5"
73
49
  >
74
- {{ group.group }}
50
+ <div class="relative w-full">
51
+ <ComboboxInput
52
+ ref="searchInput"
53
+ class="form-input w-full"
54
+ type="text"
55
+ @change="
56
+ (e) => {
57
+ query = e.target.value
58
+ }
59
+ "
60
+ :value="query"
61
+ autocomplete="off"
62
+ placeholder="Search"
63
+ />
64
+ <button
65
+ class="absolute right-0 inline-flex h-7 w-7 items-center justify-center"
66
+ @click="selectedValue = null"
67
+ >
68
+ <FeatherIcon name="x" class="w-4" />
69
+ </button>
70
+ </div>
75
71
  </div>
76
- <ComboboxOption
77
- as="template"
78
- v-for="option in group.items"
79
- :key="option.value"
80
- :value="option"
81
- v-slot="{ active, selected }"
72
+ <div
73
+ v-for="group in groups"
74
+ :key="group.key"
75
+ v-show="group.items.length > 0"
82
76
  >
83
- <li
84
- :class="[
85
- 'flex items-center rounded px-2.5 py-1.5 text-base',
86
- { 'bg-gray-100': active },
87
- ]"
77
+ <div
78
+ v-if="group.group && !group.hideLabel"
79
+ class="sticky top-10 truncate bg-white px-2.5 py-1.5 text-sm font-medium text-gray-600"
80
+ >
81
+ {{ group.group }}
82
+ </div>
83
+ <ComboboxOption
84
+ as="template"
85
+ v-for="(option, idx) in group.items.slice(0, 50)"
86
+ :key="option?.value || idx"
87
+ :value="option"
88
+ v-slot="{ active, selected }"
88
89
  >
89
- <slot
90
- name="item-prefix"
91
- v-bind="{ active, selected, option }"
90
+ <li
91
+ :class="[
92
+ 'flex cursor-pointer items-center justify-between rounded px-2.5 py-1.5 text-base',
93
+ { 'bg-gray-100': active },
94
+ ]"
95
+ >
96
+ <div class="flex flex-1 gap-2 overflow-hidden">
97
+ <div
98
+ v-if="$slots['item-prefix'] || $props.multiple"
99
+ class="flex-shrink-0"
100
+ >
101
+ <slot
102
+ name="item-prefix"
103
+ v-bind="{ active, selected, option }"
104
+ >
105
+ <FeatherIcon
106
+ name="check"
107
+ v-if="isOptionSelected(option)"
108
+ class="h-4 w-4 text-gray-700"
109
+ />
110
+ <div v-else class="h-4 w-4" />
111
+ </slot>
112
+ </div>
113
+ <span class="flex-1 truncate">
114
+ {{ getLabel(option) }}
115
+ </span>
116
+ </div>
117
+
118
+ <div
119
+ v-if="$slots['item-suffix'] || option?.description"
120
+ class="ml-2 flex-shrink-0"
121
+ >
122
+ <slot
123
+ name="item-suffix"
124
+ v-bind="{ active, selected, option }"
125
+ >
126
+ <div
127
+ v-if="option?.description"
128
+ class="text-sm text-gray-600"
129
+ >
130
+ {{ option.description }}
131
+ </div>
132
+ </slot>
133
+ </div>
134
+ </li>
135
+ </ComboboxOption>
136
+ </div>
137
+ <li
138
+ v-if="groups.length == 0"
139
+ class="rounded-md px-2.5 py-1.5 text-base text-gray-600"
140
+ >
141
+ No results found
142
+ </li>
143
+ </ComboboxOptions>
144
+
145
+ <div v-if="$slots.footer || multiple" class="border-t p-1">
146
+ <slot name="footer" v-bind="{ togglePopover }">
147
+ <div v-if="multiple" class="flex items-center justify-end">
148
+ <Button
149
+ v-if="!areAllOptionsSelected"
150
+ label="Select All"
151
+ @click.stop="selectAll"
92
152
  />
93
- {{ option.label }}
94
- </li>
95
- </ComboboxOption>
153
+ <Button
154
+ v-if="areAllOptionsSelected"
155
+ label="Clear All"
156
+ @click.stop="clearAll"
157
+ /></div
158
+ ></slot>
96
159
  </div>
97
- <li
98
- v-if="groups.length == 0"
99
- class="rounded-md px-2.5 py-1.5 text-base text-gray-600"
100
- >
101
- No results found
102
- </li>
103
- </ComboboxOptions>
160
+ </div>
104
161
  </div>
105
162
  </template>
106
163
  </Popover>
107
164
  </Combobox>
108
165
  </template>
166
+
109
167
  <script>
110
168
  import {
111
169
  Combobox,
170
+ ComboboxButton,
112
171
  ComboboxInput,
113
- ComboboxOptions,
114
172
  ComboboxOption,
115
- ComboboxButton,
173
+ ComboboxOptions,
116
174
  } from '@headlessui/vue'
175
+ import { nextTick } from 'vue'
117
176
  import Popover from './Popover.vue'
118
177
  import Button from './Button.vue'
119
178
  import FeatherIcon from './FeatherIcon.vue'
120
179
 
121
180
  export default {
122
181
  name: 'Autocomplete',
123
- props: ['modelValue', 'options', 'placeholder'],
182
+ props: [
183
+ 'modelValue',
184
+ 'options',
185
+ 'placeholder',
186
+ 'bodyClasses',
187
+ 'multiple',
188
+ 'hideSearch',
189
+ ],
124
190
  emits: ['update:modelValue', 'update:query', 'change'],
125
191
  components: {
126
192
  Popover,
@@ -132,6 +198,7 @@ export default {
132
198
  ComboboxOption,
133
199
  ComboboxButton,
134
200
  },
201
+ expose: ['togglePopover'],
135
202
  data() {
136
203
  return {
137
204
  query: '',
@@ -139,19 +206,25 @@ export default {
139
206
  }
140
207
  },
141
208
  computed: {
142
- valuePropPassed() {
143
- return 'value' in this.$attrs
144
- },
145
209
  selectedValue: {
146
210
  get() {
147
- return this.valuePropPassed ? this.$attrs.value : this.modelValue
211
+ if (!this.multiple) {
212
+ return this.findOption(this.modelValue)
213
+ }
214
+ // in case of `multiple`, modelValue is an array of values
215
+ // if the modelValue is a list of values, convert them to options
216
+ return isOptionOrValue(this.modelValue?.[0]) === 'value'
217
+ ? this.modelValue?.map((v) => this.findOption(v))
218
+ : this.modelValue
148
219
  },
149
220
  set(val) {
150
221
  this.query = ''
151
- if (val) {
152
- this.showOptions = false
222
+ if (val && !this.multiple) this.showOptions = false
223
+ if (!this.multiple) {
224
+ this.$emit('update:modelValue', val)
225
+ return
153
226
  }
154
- this.$emit(this.valuePropPassed ? 'change' : 'update:modelValue', val)
227
+ this.$emit('update:modelValue', val)
155
228
  },
156
229
  },
157
230
  groups() {
@@ -159,7 +232,7 @@ export default {
159
232
 
160
233
  let groups = this.options[0]?.group
161
234
  ? this.options
162
- : [{ group: '', items: this.options }]
235
+ : [{ group: '', items: this.sanitizeOptions(this.options) }]
163
236
 
164
237
  return groups
165
238
  .map((group, i) => {
@@ -167,47 +240,89 @@ export default {
167
240
  key: i,
168
241
  group: group.group,
169
242
  hideLabel: group.hideLabel || false,
170
- items: this.filterOptions(group.items),
243
+ items: this.filterOptions(this.sanitizeOptions(group.items)),
171
244
  }
172
245
  })
173
246
  .filter((group) => group.items.length > 0)
174
247
  },
248
+ allOptions() {
249
+ return this.groups.flatMap((group) => group.items)
250
+ },
251
+ areAllOptionsSelected() {
252
+ if (!this.multiple) return false
253
+ return this.allOptions.length === this.selectedValue?.length
254
+ },
175
255
  },
176
256
  watch: {
177
257
  query(q) {
178
258
  this.$emit('update:query', q)
179
259
  },
180
260
  showOptions(val) {
181
- if (val) {
182
- this.$nextTick(() => {
183
- this.$refs.search.el.focus()
184
- })
185
- }
261
+ if (val) nextTick(() => this.$refs.searchInput?.$el?.focus())
186
262
  },
187
263
  },
188
264
  methods: {
265
+ togglePopover(val) {
266
+ this.showOptions = val ?? !this.showOptions
267
+ },
268
+ findOption(option) {
269
+ if (!option) return option
270
+ const value = isOptionOrValue(option) === 'value' ? option : option.value
271
+ return this.allOptions.find((o) => o.value === value)
272
+ },
189
273
  filterOptions(options) {
190
- if (!this.query) {
191
- return options
192
- }
274
+ if (!this.query) return options
193
275
  return options.filter((option) => {
194
- let searchTexts = [option.label, option.value]
195
- return searchTexts.some((text) =>
196
- (text || '')
197
- .toString()
198
- .toLowerCase()
199
- .includes(this.query.toLowerCase())
276
+ return (
277
+ option.label.toLowerCase().includes(this.query.toLowerCase()) ||
278
+ option.value.toLowerCase().includes(this.query.toLowerCase())
200
279
  )
201
280
  })
202
281
  },
203
282
  displayValue(option) {
204
- if (typeof option === 'string') {
205
- let allOptions = this.groups.flatMap((group) => group.items)
206
- let selectedOption = allOptions.find((o) => o.value === option)
207
- return selectedOption?.label || option
283
+ if (!option) return ''
284
+
285
+ if (!this.multiple) {
286
+ return this.getLabel(this.findOption(option))
287
+ }
288
+
289
+ if (!Array.isArray(option)) return ''
290
+
291
+ // in case of `multiple`, option is an array of values
292
+ // so the display value should be comma separated labels
293
+ return option.map((v) => this.getLabel(this.findOption(v))).join(', ')
294
+ },
295
+ getLabel(option) {
296
+ if (isOptionOrValue(option) === 'value') return option
297
+ return option?.label || option?.value || 'No label'
298
+ },
299
+ sanitizeOptions(options) {
300
+ if (!options) return []
301
+ // in case the options are just values, convert them to objects
302
+ return options.map((option) => {
303
+ return isOptionOrValue(option) === 'option'
304
+ ? option
305
+ : { label: option, value: option }
306
+ })
307
+ },
308
+ isOptionSelected(option) {
309
+ if (!this.selectedValue) return false
310
+ const value = isOptionOrValue(option) === 'value' ? option : option.value
311
+ if (!this.multiple) {
312
+ return this.selectedValue?.value === value
208
313
  }
209
- return option?.label
314
+ return this.selectedValue?.find((v) => v && v.value === value)
315
+ },
316
+ selectAll() {
317
+ this.selectedValue = this.allOptions
318
+ },
319
+ clearAll() {
320
+ this.selectedValue = []
210
321
  },
211
322
  },
212
323
  }
324
+
325
+ function isOptionOrValue(optionOrValue) {
326
+ return typeof optionOrValue === 'object' ? 'option' : 'value'
327
+ }
213
328
  </script>
@@ -53,7 +53,7 @@
53
53
  <slot name="body-main">
54
54
  <div class="bg-white px-4 pb-6 pt-5 sm:px-6">
55
55
  <div class="flex">
56
- <div class="flex-1">
56
+ <div class="w-full flex-1">
57
57
  <div class="mb-6 flex items-center justify-between">
58
58
  <div class="flex items-center space-x-2">
59
59
  <div