frappe-ui 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -196,7 +196,7 @@ export default {
196
196
  this.dialogActions = actions.map((action) => {
197
197
  let _action = {
198
198
  ...action,
199
- loading: false,
199
+ loading: action.loading || false,
200
200
  _onClick: action.onClick,
201
201
  onClick: () => this.handleAction(_action),
202
202
  }
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <svg
3
+ width="16"
4
+ height="17"
5
+ viewBox="0 0 16 17"
6
+ fill="none"
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ >
9
+ <path
10
+ d="M2 4.5H14"
11
+ stroke="currentColor"
12
+ stroke-miterlimit="10"
13
+ stroke-linecap="round"
14
+ stroke-linejoin="round"
15
+ />
16
+ <path
17
+ d="M4 8.5H12"
18
+ stroke="currentColor"
19
+ stroke-miterlimit="10"
20
+ stroke-linecap="round"
21
+ stroke-linejoin="round"
22
+ />
23
+ <path
24
+ d="M6.5 12.5H9.5"
25
+ stroke="currentColor"
26
+ stroke-miterlimit="10"
27
+ stroke-linecap="round"
28
+ stroke-linejoin="round"
29
+ />
30
+ </svg>
31
+ </template>
@@ -0,0 +1,292 @@
1
+ <template>
2
+ <NestedPopover>
3
+ <template #target>
4
+ <Button label="Filter">
5
+ <template #prefix><FilterIcon class="h-4" /></template>
6
+ <template v-if="filters.size" #suffix>
7
+ <div
8
+ class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white"
9
+ >
10
+ {{ filters.size }}
11
+ </div>
12
+ </template>
13
+ </Button>
14
+ </template>
15
+ <template #body="{ close }">
16
+ <div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
17
+ <div class="min-w-[400px] p-2">
18
+ <div
19
+ v-if="filters.length"
20
+ v-for="(filter, i) in filters"
21
+ :key="i"
22
+ id="filter-list"
23
+ class="mb-3 flex items-center justify-between gap-2"
24
+ >
25
+ <div class="flex flex-1 items-center gap-2">
26
+ <div
27
+ class="w-13 flex-shrink-0 pl-2 text-end text-base text-gray-600"
28
+ >
29
+ {{ i == 0 ? 'Where' : 'And' }}
30
+ </div>
31
+ <div id="fieldname" class="!min-w-[140px] flex-1">
32
+ <Autocomplete
33
+ :value="filter.fieldname"
34
+ :options="fields"
35
+ @change="filter.fieldname = $event.value"
36
+ placeholder="Filter by..."
37
+ />
38
+ </div>
39
+ <div id="operator" class="!min-w-[140px] flex-shrink-0">
40
+ <FormControl
41
+ type="select"
42
+ :modelValue="filter.operator"
43
+ @update:modelValue="filter.operator = $event.value"
44
+ :options="getOperators(filter.field.fieldtype)"
45
+ placeholder="Operator"
46
+ />
47
+ </div>
48
+ <div id="value" class="!min-w-[140px] flex-1">
49
+ <SearchComplete
50
+ v-if="
51
+ typeLink.includes(filter.field.fieldtype) &&
52
+ ['=', '!='].includes(filter.operator)
53
+ "
54
+ :doctype="filter.field.options"
55
+ :value="filter.value"
56
+ @change="filter.value = $event.value"
57
+ placeholder="Value"
58
+ />
59
+ <component
60
+ v-else
61
+ :is="
62
+ getValueSelector(
63
+ filter.field.fieldtype,
64
+ filter.field.options
65
+ )
66
+ "
67
+ v-model="filter.value"
68
+ placeholder="Value"
69
+ />
70
+ </div>
71
+ </div>
72
+ <div class="flex-shrink-0">
73
+ <Button variant="ghost" icon="x" @click="removeFilter(i)" />
74
+ </div>
75
+ </div>
76
+ <div
77
+ v-else
78
+ class="mb-3 flex h-7 items-center px-3 text-sm text-gray-600"
79
+ >
80
+ Empty - Choose a field to filter by
81
+ </div>
82
+ <div class="flex items-center justify-between gap-2">
83
+ <Autocomplete
84
+ value=""
85
+ :options="fields"
86
+ @change="(field) => addFilter(field.value)"
87
+ placeholder="Filter by..."
88
+ >
89
+ <template #target="{ togglePopover }">
90
+ <Button
91
+ class="!text-gray-600"
92
+ variant="ghost"
93
+ @click="togglePopover()"
94
+ label="Add filter"
95
+ >
96
+ <template #prefix>
97
+ <FeatherIcon name="plus" class="h-4" />
98
+ </template>
99
+ </Button>
100
+ </template>
101
+ </Autocomplete>
102
+ <Button
103
+ v-if="filters.length"
104
+ class="!text-gray-600"
105
+ variant="ghost"
106
+ label="Clear all filter"
107
+ @click="filters = []"
108
+ />
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </template>
113
+ </NestedPopover>
114
+ </template>
115
+
116
+ <script setup>
117
+ import { Autocomplete, FeatherIcon, FormControl } from 'frappe-ui'
118
+ import { computed, h } from 'vue'
119
+ import FilterIcon from './FilterIcon.vue'
120
+ import NestedPopover from './NestedPopover.vue'
121
+ import SearchComplete from './SearchComplete.vue'
122
+
123
+ const typeCheck = ['Check']
124
+ const typeLink = ['Link']
125
+ const typeNumber = ['Float', 'Int']
126
+ const typeSelect = ['Select']
127
+ const typeString = [
128
+ 'Data',
129
+ 'Long Text',
130
+ 'Small Text',
131
+ 'Text Editor',
132
+ 'Text',
133
+ 'JSON',
134
+ 'Code',
135
+ ]
136
+
137
+ const emits = defineEmits(['update:modelValue'])
138
+ const props = defineProps({
139
+ modelValue: {
140
+ type: Object,
141
+ default: () => ({}),
142
+ },
143
+ docfields: {
144
+ type: Array,
145
+ default: () => [],
146
+ },
147
+ })
148
+
149
+ const fields = computed(() => {
150
+ const fields = props.docfields
151
+ .filter((field) => {
152
+ return (
153
+ !field.is_virtual &&
154
+ (typeCheck.includes(field.fieldtype) ||
155
+ typeLink.includes(field.fieldtype) ||
156
+ typeNumber.includes(field.fieldtype) ||
157
+ typeSelect.includes(field.fieldtype) ||
158
+ typeString.includes(field.fieldtype))
159
+ )
160
+ })
161
+ .map((field) => {
162
+ return {
163
+ label: field.label,
164
+ value: field.fieldname,
165
+ description: field.fieldtype,
166
+ ...field,
167
+ }
168
+ })
169
+ return fields
170
+ })
171
+
172
+ const filters = computed({
173
+ get: () => makeFiltersList(props.modelValue),
174
+ set: (value) => emits('update:modelValue', makeFiltersDict(value)),
175
+ })
176
+
177
+ function makeFiltersList(filtersDict) {
178
+ return Object.entries(filtersDict).map(([fieldname, [operator, value]]) => {
179
+ const field = getField(fieldname)
180
+ return {
181
+ fieldname,
182
+ operator,
183
+ value,
184
+ field,
185
+ }
186
+ })
187
+ }
188
+
189
+ function getField(fieldname) {
190
+ return fields.value.find((f) => f.fieldname === fieldname)
191
+ }
192
+
193
+ function makeFiltersDict(filtersList) {
194
+ return filtersList.reduce((acc, filter) => {
195
+ const { fieldname, operator, value } = filter
196
+ acc[fieldname] = [operator, value]
197
+ return acc
198
+ }, {})
199
+ }
200
+
201
+ function getOperators(fieldtype) {
202
+ let options = []
203
+ if (typeString.includes(fieldtype) || typeLink.includes(fieldtype)) {
204
+ options.push(
205
+ ...[
206
+ { label: 'Equals', value: '=' },
207
+ { label: 'Not Equals', value: '!=' },
208
+ { label: 'Like', value: 'like' },
209
+ { label: 'Not Like', value: 'not like' },
210
+ ]
211
+ )
212
+ }
213
+ if (typeNumber.includes(fieldtype)) {
214
+ options.push(
215
+ ...[
216
+ { label: '<', value: '<' },
217
+ { label: '>', value: '>' },
218
+ { label: '<=', value: '<=' },
219
+ { label: '>=', value: '>=' },
220
+ { label: 'Equals', value: '=' },
221
+ { label: 'Not Equals', value: '!=' },
222
+ ]
223
+ )
224
+ }
225
+ if (typeSelect.includes(fieldtype)) {
226
+ options.push(
227
+ ...[
228
+ { label: 'Equals', value: '=' },
229
+ { label: 'Not Equals', value: '!=' },
230
+ ]
231
+ )
232
+ }
233
+ if (typeCheck.includes(fieldtype)) {
234
+ options.push(...[{ label: 'Equals', value: '=' }])
235
+ }
236
+ return options
237
+ }
238
+
239
+ function getDefaultOperator(fieldtype) {
240
+ if (
241
+ typeSelect.includes(fieldtype) ||
242
+ typeLink.includes(fieldtype) ||
243
+ typeCheck.includes(fieldtype) ||
244
+ typeNumber.includes(fieldtype)
245
+ ) {
246
+ return '='
247
+ }
248
+ return 'like'
249
+ }
250
+
251
+ function getValueSelector(fieldtype, options) {
252
+ if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
253
+ const _options =
254
+ fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
255
+ return h(FormControl, {
256
+ type: 'select',
257
+ options: _options,
258
+ })
259
+ } else {
260
+ return h(FormControl, { type: 'text' })
261
+ }
262
+ }
263
+
264
+ function getDefaultValue(field) {
265
+ if (typeSelect.includes(field.fieldtype)) {
266
+ return getSelectOptions(field.options)[0]
267
+ }
268
+ if (typeCheck.includes(field.fieldtype)) {
269
+ return 'Yes'
270
+ }
271
+ return ''
272
+ }
273
+
274
+ function getSelectOptions(options) {
275
+ return options.split('\n')
276
+ }
277
+
278
+ function addFilter(fieldname) {
279
+ const field = getField(fieldname)
280
+ const filter = {
281
+ fieldname,
282
+ operator: getDefaultOperator(field.fieldtype),
283
+ value: getDefaultValue(field),
284
+ field,
285
+ }
286
+ filters.value = [...filters.value, filter]
287
+ }
288
+
289
+ function removeFilter(index) {
290
+ filters.value = filters.value.filter((_, i) => i !== index)
291
+ }
292
+ </script>
@@ -0,0 +1,60 @@
1
+ <template>
2
+ <Popover v-slot="{ open }">
3
+ <PopoverButton
4
+ as="div"
5
+ ref="reference"
6
+ @click="updatePosition"
7
+ @focusin="updatePosition"
8
+ @keydown="updatePosition"
9
+ v-slot="{ open }"
10
+ >
11
+ <slot name="target" v-bind="{ open }" />
12
+ </PopoverButton>
13
+ <div v-show="open">
14
+ <PopoverPanel
15
+ v-slot="{ open, close }"
16
+ ref="popover"
17
+ static
18
+ class="z-[100]"
19
+ >
20
+ <slot name="body" v-bind="{ open, close }" />
21
+ </PopoverPanel>
22
+ </div>
23
+ </Popover>
24
+ </template>
25
+
26
+ <script setup>
27
+ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
28
+ import { createPopper } from '@popperjs/core'
29
+ import { nextTick, ref, onBeforeUnmount } from 'vue'
30
+
31
+ const props = defineProps({
32
+ placement: {
33
+ type: String,
34
+ default: 'bottom-start',
35
+ },
36
+ })
37
+
38
+ const reference = ref(null)
39
+ const popover = ref(null)
40
+
41
+ let popper = ref(null)
42
+
43
+ function setupPopper() {
44
+ if (!popper.value) {
45
+ popper.value = createPopper(reference.value.el, popover.value.el, {
46
+ placement: props.placement,
47
+ })
48
+ } else {
49
+ popper.value.update()
50
+ }
51
+ }
52
+
53
+ function updatePosition() {
54
+ nextTick(() => setupPopper())
55
+ }
56
+
57
+ onBeforeUnmount(() => {
58
+ popper.value?.destroy()
59
+ })
60
+ </script>
@@ -0,0 +1,85 @@
1
+ <template>
2
+ <Autocomplete
3
+ placeholder="Select an option"
4
+ :options="options"
5
+ :value="selection"
6
+ @update:query="(q) => onUpdateQuery(q)"
7
+ @change="(v) => (selection = v)"
8
+ />
9
+ </template>
10
+
11
+ <script setup>
12
+ import { Autocomplete, createListResource } from 'frappe-ui'
13
+ import { computed, ref, watch } from 'vue'
14
+
15
+ const props = defineProps({
16
+ value: {
17
+ type: String,
18
+ required: false,
19
+ default: '',
20
+ },
21
+ doctype: {
22
+ type: String,
23
+ required: true,
24
+ },
25
+ searchField: {
26
+ type: String,
27
+ required: false,
28
+ default: 'name',
29
+ },
30
+ labelField: {
31
+ type: String,
32
+ required: false,
33
+ default: 'name',
34
+ },
35
+ valueField: {
36
+ type: String,
37
+ required: false,
38
+ default: 'name',
39
+ },
40
+ pageLength: {
41
+ type: Number,
42
+ required: false,
43
+ default: 10,
44
+ },
45
+ })
46
+
47
+ watch(
48
+ () => props.doctype,
49
+ (value) => {
50
+ r.doctype = value
51
+ r.reload()
52
+ }
53
+ )
54
+
55
+ const r = createListResource({
56
+ doctype: props.doctype,
57
+ pageLength: props.pageLength,
58
+ cache: ['link_doctype', props.doctype],
59
+ auto: true,
60
+ fields: [props.labelField, props.searchField, props.valueField],
61
+ onSuccess: () => {
62
+ selection.value = props.value
63
+ ? options.value.find((o) => o.value === props.value)
64
+ : null
65
+ },
66
+ })
67
+ const options = computed(
68
+ () =>
69
+ r.data?.map((result) => ({
70
+ label: result[props.labelField],
71
+ value: result[props.valueField],
72
+ })) || []
73
+ )
74
+ const selection = ref(null)
75
+
76
+ function onUpdateQuery(query) {
77
+ r.update({
78
+ filters: {
79
+ [props.searchField]: ['like', `%${query}%`],
80
+ },
81
+ })
82
+
83
+ r.reload()
84
+ }
85
+ </script>
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <div
3
+ class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
4
+ :style="{
5
+ gridTemplateColumns: getGridTemplateColumns(columns, options.selectable),
6
+ }"
7
+ >
8
+ <Checkbox
9
+ v-if="options.selectable"
10
+ class="cursor-pointer duration-300"
11
+ :modelValue="allRowsSelected"
12
+ @click.stop="toggleAllRows"
13
+ />
14
+ <slot>
15
+ <ListHeaderItem
16
+ v-for="column in columns"
17
+ :key="column.key"
18
+ :item="column"
19
+ />
20
+ </slot>
21
+ </div>
22
+ </template>
23
+
24
+ <script setup>
25
+ import Checkbox from '../Checkbox.vue'
26
+ import ListHeaderItem from './ListHeaderItem.vue'
27
+ import { getGridTemplateColumns } from './utils'
28
+ import { inject } from 'vue'
29
+
30
+ const { columns, options, allRowsSelected, toggleAllRows } = inject('list')
31
+ </script>
@@ -0,0 +1,22 @@
1
+ <template>
2
+ <div
3
+ class="flex items-center space-x-2 text-base text-gray-600"
4
+ :class="alignmentMap[item.align]"
5
+ >
6
+ <slot name="prefix" v-bind="{ item }" />
7
+ <div>
8
+ {{ item.label }}
9
+ </div>
10
+ <slot name="suffix" v-bind="{ item }" />
11
+ </div>
12
+ </template>
13
+
14
+ <script setup>
15
+ import { alignmentMap } from './utils'
16
+ const props = defineProps({
17
+ item: {
18
+ type: Object,
19
+ required: true,
20
+ },
21
+ })
22
+ </script>
@@ -1,29 +1,72 @@
1
- <script setup>
2
- import { inject } from 'vue'
3
- import Checkbox from '../Checkbox.vue'
4
-
5
- const list = inject('list')
6
- const props = defineProps({
7
- as: { type: String, default: 'div' },
8
- row: { type: Object, default: () => ({}), required: true },
9
- })
10
- </script>
11
-
12
1
  <template>
13
2
  <component
14
- :is="as"
15
- class="mx-2 flex cursor-pointer items-center space-x-4 border-b py-2 transition-all duration-300 ease-in-out"
16
- :class="
17
- list.selections.has(row.name)
18
- ? 'bg-gray-100 hover:bg-gray-200'
19
- : 'hover:bg-gray-50'
3
+ :is="options.getRowRoute ? 'router-link' : 'div'"
4
+ class="flex cursor-pointer flex-col transition-all duration-300 ease-in-out"
5
+ v-bind="
6
+ options.getRowRoute
7
+ ? { to: options.getRowRoute(row) }
8
+ : { onClick: () => options.onRowClick(row) }
20
9
  "
21
10
  >
22
- <Checkbox
23
- :modelValue="list.selections.has(row.name)"
24
- @click.stop="list.toggleSelection(row.name)"
25
- class="cursor-pointer duration-300"
26
- />
27
- <slot></slot>
11
+ <component
12
+ :is="options.getRowRoute ? 'template' : 'button'"
13
+ class="[all:unset] hover:[all:unset]"
14
+ >
15
+ <div
16
+ class="grid items-center space-x-4 rounded px-2 py-2.5"
17
+ :class="
18
+ selections.has(row[rowKey])
19
+ ? 'bg-gray-100 hover:bg-gray-200'
20
+ : 'hover:bg-gray-50'
21
+ "
22
+ :style="{
23
+ gridTemplateColumns: getGridTemplateColumns(
24
+ columns,
25
+ options.selectable
26
+ ),
27
+ }"
28
+ >
29
+ <Checkbox
30
+ v-if="options.selectable"
31
+ :modelValue="selections.has(row[rowKey])"
32
+ @click.stop="toggleRow(row[rowKey])"
33
+ class="cursor-pointer duration-300"
34
+ />
35
+ <div
36
+ v-for="column in columns"
37
+ :key="column.key"
38
+ :class="alignmentMap[column.align]"
39
+ >
40
+ <slot v-bind="{ column, item: row[column.key] }">
41
+ <ListRowItem
42
+ :item="row[column.key]"
43
+ :type="column.type"
44
+ :align="column.align"
45
+ />
46
+ </slot>
47
+ </div>
48
+ </div>
49
+ <div v-if="!isLastRow" class="mx-2 h-px border-t border-gray-200" />
50
+ </component>
28
51
  </component>
29
52
  </template>
53
+
54
+ <script setup>
55
+ import Checkbox from '../Checkbox.vue'
56
+ import ListRowItem from './ListRowItem.vue'
57
+ import { alignmentMap, getGridTemplateColumns } from './utils'
58
+ import { computed, inject } from 'vue'
59
+
60
+ const props = defineProps({
61
+ row: {
62
+ type: Object,
63
+ required: true,
64
+ },
65
+ })
66
+
67
+ const isLastRow = computed(() => {
68
+ return rows[rows.length - 1][rowKey] === props.row[rowKey]
69
+ })
70
+
71
+ const { rows, columns, rowKey, options, selections, toggleRow } = inject('list')
72
+ </script>
@@ -1,10 +1,45 @@
1
- <script setup></script>
2
-
3
1
  <template>
4
- <div
5
- class="flex min-h-[1.5rem] flex-1 items-center space-x-2 truncate text-base"
6
- :class="$attrs.class"
2
+ <component
3
+ :is="options.showTooltip ? Tooltip : 'div'"
4
+ v-bind="options.showTooltip ? { text: label } : {}"
5
+ class="flex items-center space-x-2"
6
+ :class="alignmentMap[align]"
7
7
  >
8
- <slot> </slot>
9
- </div>
8
+ <slot name="prefix" />
9
+ <slot v-bind="{ label }">
10
+ <div class="truncate text-base">
11
+ {{ label }}
12
+ </div>
13
+ </slot>
14
+ <slot name="suffix" />
15
+ </component>
10
16
  </template>
17
+ <script setup>
18
+ import { alignmentMap } from './utils'
19
+ import Tooltip from '../Tooltip.vue'
20
+ import { computed, inject } from 'vue'
21
+
22
+ const props = defineProps({
23
+ item: {
24
+ type: [String, Number, Object],
25
+ default: '',
26
+ },
27
+ align: {
28
+ type: String,
29
+ default: 'left',
30
+ },
31
+ })
32
+
33
+ const label = computed(() => {
34
+ return getValue(props.item).label || ''
35
+ })
36
+
37
+ function getValue(value) {
38
+ if (value && typeof value === 'object') {
39
+ return value
40
+ }
41
+ return { label: value }
42
+ }
43
+
44
+ const { options } = inject('list')
45
+ </script>
@@ -0,0 +1,14 @@
1
+ <template>
2
+ <div class="h-full overflow-y-auto">
3
+ <slot>
4
+ <ListRow v-for="(row, i) in rows" :key="row[rowKey]" :row="row" />
5
+ </slot>
6
+ </div>
7
+ </template>
8
+
9
+ <script setup>
10
+ import ListRow from './ListRow.vue'
11
+ import { inject } from 'vue'
12
+
13
+ const { rows, rowKey } = inject('list')
14
+ </script>
@@ -0,0 +1,82 @@
1
+ <template>
2
+ <transition
3
+ enter-active-class="duration-300 ease-out"
4
+ enter-from-class="transform opacity-0"
5
+ enter-to-class="opacity-100"
6
+ leave-active-class="duration-300 ease-in"
7
+ leave-from-class="opacity-100"
8
+ leave-to-class="transform opacity-0"
9
+ >
10
+ <div
11
+ v-if="selections.size"
12
+ class="absolute inset-x-0 bottom-6 mx-auto w-max text-base"
13
+ >
14
+ <div
15
+ class="flex min-w-[596px] items-center space-x-3 rounded-lg bg-white px-4 py-2 shadow-2xl"
16
+ :class="$attrs.class"
17
+ >
18
+ <slot
19
+ v-bind="{
20
+ selections,
21
+ allRowsSelected,
22
+ selectAll: () => toggleAllRows(true),
23
+ unselectAll: () => toggleAllRows(false),
24
+ }"
25
+ >
26
+ <div
27
+ class="flex flex-1 justify-between border-r border-gray-300 text-gray-900"
28
+ >
29
+ <div class="flex items-center space-x-3">
30
+ <Checkbox
31
+ :modelValue="true"
32
+ :disabled="true"
33
+ class="text-gray-900"
34
+ />
35
+ <div>{{ selectedText }}</div>
36
+ </div>
37
+ <div class="mr-3">
38
+ <slot
39
+ name="actions"
40
+ v-bind="{
41
+ selections,
42
+ allRowsSelected,
43
+ selectAll: () => toggleAllRows(true),
44
+ unselectAll: () => toggleAllRows(false),
45
+ }"
46
+ />
47
+ </div>
48
+ </div>
49
+ <div class="flex items-center space-x-1">
50
+ <Button
51
+ class="w- text-gray-700"
52
+ :disabled="allRowsSelected"
53
+ :class="allRowsSelected ? 'cursor-not-allowed' : ''"
54
+ variant="ghost"
55
+ @click="toggleAllRows(true)"
56
+ >
57
+ Select all
58
+ </Button>
59
+ <Button icon="x" variant="ghost" @click="toggleAllRows(false)" />
60
+ </div>
61
+ </slot>
62
+ </div>
63
+ </div>
64
+ </transition>
65
+ </template>
66
+
67
+ <script setup>
68
+ import Checkbox from '../Checkbox.vue'
69
+ import Button from '../Button.vue'
70
+ import { computed, inject } from 'vue'
71
+
72
+ defineOptions({
73
+ inheritAttrs: false,
74
+ })
75
+
76
+ let selectedText = computed(() => {
77
+ let title = selections.size === 1 ? 'Row' : 'Rows'
78
+ return `${selections.size} ${title} selected`
79
+ })
80
+
81
+ const { selections, allRowsSelected, toggleAllRows } = inject('list')
82
+ </script>
@@ -1,116 +1,93 @@
1
1
  <template>
2
- <div id="content" class="flex w-full flex-1 flex-col overflow-x-auto">
3
- <div class="flex w-max min-w-full flex-col overflow-y-hidden">
4
- <div
5
- id="list-header"
6
- class="mx-2 flex items-center space-x-4 border-b py-2"
7
- >
8
- <Checkbox
9
- class="cursor-pointer duration-300"
10
- :modelValue="state.allRowsSelected"
11
- @click.stop="state.toggleAllRows(!allRowsSelected)"
12
- />
13
- <div
14
- v-for="column in props.columns"
15
- :key="column"
16
- class="flex-1 text-sm text-gray-600"
17
- :class="column.class"
18
- >
19
- {{ column.label }}
20
- </div>
21
- </div>
22
- <div id="list-rows" class="h-full overflow-y-auto">
23
- <template v-for="row in props.rows" :key="row.name">
24
- <slot name="list-row" :row="row">
25
- <ListRow as="div" :row="row">
26
- <ListRowItem v-for="column in props.columns" :key="column.name">
27
- {{ row[column.name] }}
28
- </ListRowItem>
29
- </ListRow>
30
- </slot>
31
- </template>
32
- </div>
33
- <transition
34
- enter-active-class="duration-300 ease-out"
35
- enter-from-class="transform opacity-0"
36
- enter-to-class="opacity-100"
37
- leave-active-class="duration-300 ease-in"
38
- leave-from-class="opacity-100"
39
- leave-to-class="transform opacity-0"
40
- >
41
- <div
42
- v-if="state.selections.size"
43
- class="fixed inset-x-0 bottom-6 mx-auto w-max text-base"
44
- >
45
- <div
46
- class="flex w-[596px] items-center space-x-3 rounded-lg bg-white px-4 py-2 shadow-2xl"
47
- >
48
- <div
49
- class="flex flex-1 items-center space-x-3 border-r border-gray-300 text-gray-900"
50
- >
51
- <Checkbox
52
- :modelValue="true"
53
- :disabled="true"
54
- class="text-gray-900"
55
- />
56
- <div>{{ state.selectedText }}</div>
57
- </div>
58
- <div class="flex items-center space-x-1">
59
- <Button
60
- class="text-gray-700"
61
- :disabled="state.allRowsSelected"
62
- :class="state.allRowsSelected ? 'cursor-not-allowed' : ''"
63
- variant="ghost"
64
- @click="state.toggleAllRows(true)"
65
- >
66
- Select all
67
- </Button>
68
- <Button
69
- icon="x"
70
- variant="ghost"
71
- @click="state.toggleAllRows(false)"
72
- />
73
- </div>
74
- </div>
75
- </div>
76
- </transition>
2
+ <div class="relative flex w-full flex-1 flex-col overflow-x-auto">
3
+ <div
4
+ class="flex w-max min-w-full flex-col overflow-y-hidden"
5
+ :class="$attrs.class"
6
+ >
7
+ <slot>
8
+ <ListHeader />
9
+ <ListRows />
10
+ <ListSelectBanner v-if="_options.selectable" />
11
+ </slot>
77
12
  </div>
78
13
  </div>
79
14
  </template>
80
15
  <script setup>
81
- import { computed, provide, reactive } from 'vue'
82
- import Button from '../Button.vue'
83
- import Checkbox from '../Checkbox.vue'
84
- import ListRow from './ListRow.vue'
85
- import ListRowItem from './ListRowItem.vue'
16
+ import ListHeader from './ListHeader.vue'
17
+ import ListRows from './ListRows.vue'
18
+ import ListSelectBanner from './ListSelectBanner.vue'
19
+ import { reactive, computed, provide } from 'vue'
20
+
21
+ defineOptions({
22
+ inheritAttrs: false,
23
+ })
86
24
 
87
25
  const props = defineProps({
88
- columns: { type: Array, default: [] },
89
- rows: { type: Array, default: [] },
26
+ columns: {
27
+ type: Array,
28
+ default: [],
29
+ },
30
+ rows: {
31
+ type: Array,
32
+ default: [],
33
+ },
34
+ rowKey: {
35
+ type: String,
36
+ required: true,
37
+ },
38
+ options: {
39
+ type: Object,
40
+ default: {
41
+ getRowRoute: null,
42
+ onRowClick: null,
43
+ showTooltip: true,
44
+ selectable: true,
45
+ },
46
+ },
90
47
  })
91
48
 
92
- const state = reactive({
93
- selections: new Set(),
94
- allRowsSelected: computed(() => state.selections.size === props.rows.length),
95
- selectedText: computed(() => {
96
- return state.selections.size > 1
97
- ? `${state.selections.size} items selected`
98
- : `${state.selections.size} item selected`
99
- }),
49
+ let selections = reactive(new Set())
50
+
51
+ let _options = computed(() => {
52
+ function defaultTrue(value) {
53
+ return value === undefined ? true : value
54
+ }
55
+
56
+ return {
57
+ getRowRoute: props.options.getRowRoute || null,
58
+ onRowClick: props.options.onRowClick || null,
59
+ showTooltip: defaultTrue(props.options.showTooltip),
60
+ selectable: defaultTrue(props.options.selectable),
61
+ }
100
62
  })
101
- provide('list', state)
102
63
 
103
- state.toggleAllRows = () => {
104
- return state.allRowsSelected
105
- ? state.selections.clear()
106
- : props.rows.forEach((row) => state.selections.add(row.name))
64
+ const allRowsSelected = computed(() => {
65
+ if (!props.rows.length) return false
66
+ return selections.size === props.rows.length
67
+ })
68
+
69
+ function toggleRow(row) {
70
+ if (!selections.delete(row)) {
71
+ selections.add(row)
72
+ }
107
73
  }
108
74
 
109
- state.toggleSelection = (name) => {
110
- if (state.selections.has(name)) {
111
- state.selections.delete(name)
112
- } else {
113
- state.selections.add(name)
75
+ function toggleAllRows(select) {
76
+ if (!select || allRowsSelected.value) {
77
+ selections.clear()
78
+ return
114
79
  }
80
+ props.rows.forEach((row) => selections.add(row[props.rowKey]))
115
81
  }
82
+
83
+ provide('list', {
84
+ rowKey: props.rowKey,
85
+ rows: props.rows,
86
+ columns: props.columns,
87
+ options: _options.value,
88
+ selections,
89
+ allRowsSelected,
90
+ toggleRow,
91
+ toggleAllRows,
92
+ })
116
93
  </script>
@@ -0,0 +1,22 @@
1
+ export function getGridTemplateColumns(columns, withCheckbox = true) {
2
+ let checkBoxWidth = withCheckbox ? '14px ' : ''
3
+ let columnsWidth = columns
4
+ .map((col) => {
5
+ let width = col.width || 1
6
+ if (typeof width === 'number') {
7
+ return width + 'fr'
8
+ }
9
+ return width
10
+ })
11
+ .join(' ')
12
+ return checkBoxWidth + columnsWidth
13
+ }
14
+
15
+ export const alignmentMap = {
16
+ left: 'justify-start',
17
+ start: 'justify-start',
18
+ center: 'justify-center',
19
+ middle: 'justify-center',
20
+ right: 'justify-end',
21
+ end: 'justify-end',
22
+ }
@@ -0,0 +1,123 @@
1
+ **Column:**
2
+
3
+ 1. `label` & `key` is required in column object.
4
+
5
+ 2. `width` is optional and it is used to set column width in list
6
+
7
+ 1. If you need a column to be `3` times a default column then add `3`. if
8
+ width is not mentioned default will be `1`
9
+ 2. You can also add custom width in px and rem e.g `300px` or `12rem`
10
+ 3. Combination of both can also be used.
11
+
12
+ 3. `align` is also optional. You can change the alignment of the content in the
13
+ column by setting it as.
14
+
15
+ 1. `start` or `left` (default)
16
+ 2. `center` or `middle`
17
+ 3. `end` or `right`
18
+
19
+ 4. You can add more attributes which can be used to render custom column header
20
+ items.
21
+
22
+ **Row**
23
+
24
+ 1. The row object must contain a unique_key which was mentioned in ListView
25
+ `row-key`
26
+ 2. You can either add all row fields in a separate `row` object or just add them
27
+ in directly if the fieldnames doesn't conflict with `route` or `onClick` E.g.
28
+ 1
29
+
30
+ ```
31
+ {
32
+ // unique_key 'id'
33
+ id: 1,
34
+
35
+ // row fields
36
+ name: 'John Doe',
37
+ age: 25,
38
+ email: 'john@doe.com',
39
+
40
+ // if you need to route
41
+ route: { label: 'User', { params: { userId: 1 } }
42
+
43
+ // if you need to perform action
44
+ onClick: () => console.log('John Doe was clicked')
45
+
46
+ // you can add more options after this which you can use to render custom row items
47
+ }
48
+ ```
49
+
50
+ E.g. 2
51
+
52
+ ```
53
+ {
54
+ // unique_key 'id'
55
+ id: 1,
56
+
57
+ // row fields in separate row object
58
+ row: {
59
+ name: 'John Doe',
60
+ age: 25,
61
+ email: 'john@doe.com',
62
+ route: '', // used separate row to avoid this conflict
63
+ }
64
+
65
+ // if you need to route
66
+ route: { label: 'User', { params: { userId: 1 } }
67
+
68
+ // if you need to perform action
69
+ onClick: () => console.log('John Doe was clicked')
70
+
71
+ // you can add more options after this which you can use to render custom row items
72
+ }
73
+ ```
74
+
75
+ 3. You can also add an object for the field value but make sure it has a `label`
76
+ attribute which holds the actual value to be shown
77
+ ```
78
+ row: {
79
+ name: {
80
+ label: 'John Doe',
81
+ image: '/johndoe.jpg',
82
+ },
83
+ age: 25,
84
+ status: {
85
+ label: 'Active',
86
+ color: 'green'
87
+ }
88
+ }
89
+ ```
90
+ 4. Click action: Add route or onClick event in row object
91
+ 1. If you want to route using router-link just add a
92
+ `route: { name: 'User', params: { userId: 2 } }`
93
+ 2. if you need to do some action or open a dialog add a click event instead
94
+ of a route `onClick: () => console.log('John Doe was clicked')`
95
+
96
+ **Selection Banner:**
97
+
98
+ **Without custom action buttons:**
99
+ <img width="1213" alt="image" src="https://github.com/frappe/frappe-ui/assets/30859809/36fafcf5-45c6-43f0-acde-f64afe38b550">
100
+
101
+ **With custom action buttons:**
102
+ <img width="1212" alt="image" src="https://github.com/frappe/frappe-ui/assets/30859809/55e751b2-df66-4ff0-b852-af463014463f">
103
+
104
+ ```
105
+ <ListSelectBanner>
106
+ <template #actions>
107
+ <div class="flex gap-2">
108
+ <Button variant="ghost" label="Delete" />
109
+ <Button variant="ghost" label="Edit" />
110
+ </div>
111
+ </template>
112
+ </ListSelectBanner>
113
+ ```
114
+
115
+ You can also make your own custom selection banner
116
+
117
+ <img width="629" alt="image" src="https://github.com/frappe/frappe-ui/assets/30859809/38dfa834-96a2-4ac5-ad4b-30b3e6871d3f">
118
+
119
+ ```
120
+ <ListSelectBanner>
121
+ <div>Custom Banner</div>
122
+ </ListSelectBanner>
123
+ ```
@@ -0,0 +1,200 @@
1
+ <script setup>
2
+ import ListView from './ListView/ListView.vue'
3
+ import ListHeader from './ListView/ListHeader.vue'
4
+ import ListHeaderItem from './ListView/ListHeaderItem.vue'
5
+ import ListRows from './ListView/ListRows.vue'
6
+ import ListRow from './ListView/ListRow.vue'
7
+ import ListRowItem from './ListView/ListRowItem.vue'
8
+ import ListSelectBanner from './ListView/ListSelectBanner.vue'
9
+ import FeatherIcon from './FeatherIcon.vue'
10
+ import Badge from './Badge.vue'
11
+ import Button from './Button.vue'
12
+ import Avatar from './Avatar.vue'
13
+
14
+ const simple_columns = [
15
+ {
16
+ label: 'Name',
17
+ key: 'name',
18
+ width: 3,
19
+ },
20
+ {
21
+ label: 'Email',
22
+ key: 'email',
23
+ width: '200px',
24
+ },
25
+ {
26
+ label: 'Role',
27
+ key: 'role',
28
+ },
29
+ {
30
+ label: 'Status',
31
+ key: 'status',
32
+ },
33
+ ]
34
+
35
+ const simple_rows = [
36
+ {
37
+ id: 1,
38
+ row: {
39
+ name: 'John Doe',
40
+ email: 'john@doe.com',
41
+ status: 'Active',
42
+ role: 'Developer',
43
+ },
44
+ onClick: () => console.log('John Doe was clicked'),
45
+ },
46
+ {
47
+ id: 2,
48
+ row: {
49
+ name: 'Jane Doe',
50
+ email: 'jane@doe.com',
51
+ status: 'Inactive',
52
+ role: 'HR',
53
+ },
54
+ route: { name: 'User', params: { userId: 2 } },
55
+ },
56
+ ]
57
+
58
+ const custom_columns = [
59
+ {
60
+ label: 'Name',
61
+ key: 'name',
62
+ width: 3,
63
+ icon: 'user',
64
+ },
65
+ {
66
+ label: 'Email',
67
+ key: 'email',
68
+ width: '200px',
69
+ icon: 'at-sign',
70
+ },
71
+ {
72
+ label: 'Role',
73
+ key: 'role',
74
+ icon: 'users',
75
+ },
76
+ {
77
+ label: 'Status',
78
+ key: 'status',
79
+ icon: 'check-circle',
80
+ },
81
+ ]
82
+
83
+ const custom_rows = [
84
+ {
85
+ id: 1,
86
+ row: {
87
+ name: {
88
+ label: 'John Doe',
89
+ image: 'https://avatars.githubusercontent.com/u/499550',
90
+ },
91
+ email: 'john@doe.com',
92
+ status: {
93
+ label: 'Active',
94
+ bg_color: 'bg-green-600',
95
+ },
96
+ role: {
97
+ label: 'Developer',
98
+ color: 'green',
99
+ },
100
+ },
101
+ onClick: () => console.log('John Doe was clicked'),
102
+ },
103
+ {
104
+ id: 2,
105
+ row: {
106
+ name: {
107
+ label: 'Jane Doe',
108
+ image: 'https://avatars.githubusercontent.com/u/499120',
109
+ },
110
+ email: 'jane@doe.com',
111
+ status: {
112
+ label: 'Inactive',
113
+ bg_color: 'bg-red-600',
114
+ },
115
+ role: {
116
+ label: 'HR',
117
+ color: 'red',
118
+ },
119
+ },
120
+ route: { name: 'User', params: { userId: 2 } },
121
+ },
122
+ ]
123
+ </script>
124
+
125
+ <template>
126
+ <Story :layout="{ type: 'grid', width: '95%' }">
127
+ <Variant title="Simple List">
128
+ <ListView
129
+ class="h-[250px]"
130
+ :columns="simple_columns"
131
+ :rows="simple_rows"
132
+ row-key="id"
133
+ />
134
+ </Variant>
135
+ <Variant title="Custom List">
136
+ <ListView
137
+ class="h-[250px]"
138
+ :columns="custom_columns"
139
+ :rows="custom_rows"
140
+ row-key="id"
141
+ >
142
+ <ListHeader>
143
+ <ListHeaderItem
144
+ v-for="column in custom_columns"
145
+ :key="column.key"
146
+ :column="column"
147
+ >
148
+ <template #prefix="{ column }">
149
+ <FeatherIcon :name="column.icon" class="h-4 w-4" />
150
+ </template>
151
+ </ListHeaderItem>
152
+ </ListHeader>
153
+ <ListRows>
154
+ <ListRow
155
+ v-for="(row, i) in custom_rows"
156
+ :key="i"
157
+ v-slot="{ column, item }"
158
+ :row="row"
159
+ :idx="i"
160
+ >
161
+ <ListRowItem :item="item" :align="column.align">
162
+ <template #prefix>
163
+ <div
164
+ v-if="column.key == 'status'"
165
+ class="h-3 w-3 rounded-full"
166
+ :class="item.bg_color"
167
+ />
168
+ <Avatar
169
+ v-if="column.key == 'name'"
170
+ :shape="'circle'"
171
+ :image="item.image"
172
+ size="sm"
173
+ />
174
+ </template>
175
+ <Badge
176
+ v-if="column.key == 'role'"
177
+ variant="subtle"
178
+ :theme="item.color"
179
+ size="md"
180
+ :label="item.label"
181
+ />
182
+ </ListRowItem>
183
+ </ListRow>
184
+ </ListRows>
185
+ <ListSelectBanner>
186
+ <template #actions="{ unselectAll }">
187
+ <div class="flex gap-2">
188
+ <Button variant="ghost" label="Delete" />
189
+ <Button
190
+ variant="ghost"
191
+ label="Unselect all"
192
+ @click="unselectAll"
193
+ />
194
+ </div>
195
+ </template>
196
+ </ListSelectBanner>
197
+ </ListView>
198
+ </Variant>
199
+ </Story>
200
+ </template>
package/src/index.js CHANGED
@@ -38,14 +38,19 @@ export {
38
38
  TextEditorFloatingMenu,
39
39
  TextEditorContent,
40
40
  } from './components/TextEditor'
41
- export { default as List } from './components/ListView/ListView.vue'
41
+ export { default as ListView } from './components/ListView/ListView.vue'
42
+ export { default as ListHeader } from './components/ListView/ListHeader.vue'
43
+ export { default as ListHeaderItem } from './components/ListView/ListHeaderItem.vue'
44
+ export { default as ListRows } from './components/ListView/ListRows.vue'
42
45
  export { default as ListRow } from './components/ListView/ListRow.vue'
43
46
  export { default as ListRowItem } from './components/ListView/ListRowItem.vue'
47
+ export { default as ListSelectBanner } from './components/ListView/ListSelectBanner.vue'
44
48
  export { default as Toast } from './components/Toast.vue'
45
49
  export { toast, Toasts } from './components/toast.js'
46
50
  export { default as Tooltip } from './components/Tooltip.vue'
47
51
  export { default as CommandPalette } from './components/CommandPalette/CommandPalette.vue'
48
52
  export { default as CommandPaletteItem } from './components/CommandPalette/CommandPaletteItem.vue'
53
+ export { default as ListFilter } from './components/ListFilter/ListFilter.vue'
49
54
 
50
55
  // directives
51
56
  export { default as onOutsideClickDirective } from './directives/onOutsideClick.js'