frappe-ui 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -50,6 +50,7 @@
50
50
  "@tiptap/starter-kit": "^2.0.3",
51
51
  "@tiptap/suggestion": "^2.0.3",
52
52
  "@tiptap/vue-3": "^2.0.3",
53
+ "@vueuse/core": "^10.4.1",
53
54
  "feather-icons": "^4.28.0",
54
55
  "idb-keyval": "^6.2.0",
55
56
  "showdown": "^2.1.0",
@@ -9,7 +9,7 @@
9
9
  >
10
10
  <slot name="prefix"></slot>
11
11
  </div>
12
- <slot>{{ props.label }}</slot>
12
+ <slot>{{ props.label?.toString() }}</slot>
13
13
  <div
14
14
  :class="[props.size == 'lg' ? 'max-h-6' : 'max-h-4']"
15
15
  v-if="$slots.suffix"
@@ -22,11 +22,15 @@
22
22
  <script lang="ts" setup>
23
23
  import { computed } from 'vue'
24
24
 
25
+ interface Label {
26
+ toString(): string
27
+ }
28
+
25
29
  interface BadgeProps {
26
30
  theme?: 'gray' | 'blue' | 'green' | 'orange' | 'red'
27
31
  size?: 'sm' | 'md' | 'lg'
28
32
  variant?: 'solid' | 'subtle' | 'outline' | 'ghost'
29
- label?: string
33
+ label?: Label | string | number
30
34
  }
31
35
 
32
36
  const props = withDefaults(defineProps<BadgeProps>(), {
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import { logEvent } from 'histoire/client'
3
+ import Breadcrumbs from './Breadcrumbs.vue'
4
+ </script>
5
+
6
+ <template>
7
+ <Story :layout="{ type: 'grid', width: 500 }">
8
+ <Variant title="With route option">
9
+ <Breadcrumbs
10
+ :items="[
11
+ {
12
+ label: 'Home',
13
+ route: { name: 'Home' },
14
+ },
15
+ {
16
+ label: 'Views',
17
+ route: '/components',
18
+ },
19
+ {
20
+ label: 'List',
21
+ route: '/components/breadcrumbs',
22
+ },
23
+ ]"
24
+ />
25
+ </Variant>
26
+ <Variant title="With onClick option">
27
+ <Breadcrumbs
28
+ :items="[
29
+ {
30
+ label: 'Home',
31
+ onClick: () => logEvent('onClick', 'Home'),
32
+ },
33
+ {
34
+ label: 'Views',
35
+ onClick: () => logEvent('onClick', 'Home'),
36
+ },
37
+ {
38
+ label: 'Kanban',
39
+ onClick: () => logEvent('onClick', 'Home'),
40
+ },
41
+ ]"
42
+ />
43
+ </Variant>
44
+
45
+ <Variant title="With prefix slot">
46
+ <Breadcrumbs
47
+ :items="[
48
+ {
49
+ label: 'Home',
50
+ icon: '🏡',
51
+ route: { name: 'Home' },
52
+ },
53
+ {
54
+ label: 'Views',
55
+ icon: '🏞️',
56
+ route: '/components',
57
+ },
58
+ {
59
+ label: 'List',
60
+ icon: '📃',
61
+ route: '/components/breadcrumbs',
62
+ },
63
+ ]"
64
+ >
65
+ <template #prefix="{ item }">
66
+ <span class="mr-1">
67
+ {{ item.icon }}
68
+ </span>
69
+ </template>
70
+ </Breadcrumbs>
71
+ </Variant>
72
+ </Story>
73
+ </template>
@@ -0,0 +1,108 @@
1
+ <template>
2
+ <div class="flex min-w-0 items-center">
3
+ <template v-if="dropdownItems.length">
4
+ <Dropdown class="h-7" :options="dropdownItems">
5
+ <Button variant="ghost">
6
+ <template #icon>
7
+ <svg
8
+ class="w-4 text-gray-600"
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ width="24"
11
+ height="24"
12
+ viewBox="0 0 24 24"
13
+ fill="none"
14
+ stroke="currentColor"
15
+ stroke-width="2"
16
+ stroke-linecap="round"
17
+ stroke-linejoin="round"
18
+ >
19
+ <circle cx="12" cy="12" r="1" />
20
+ <circle cx="19" cy="12" r="1" />
21
+ <circle cx="5" cy="12" r="1" />
22
+ </svg>
23
+ </template>
24
+ </Button>
25
+ </Dropdown>
26
+ <span class="ml-1 mr-0.5 text-base text-gray-500" aria-hidden="true">
27
+ /
28
+ </span>
29
+ </template>
30
+ <div
31
+ class="flex min-w-0 items-center overflow-hidden text-ellipsis whitespace-nowrap"
32
+ >
33
+ <template v-for="(item, i) in crumbs" :key="item.label">
34
+ <component
35
+ :is="item.route ? 'router-link' : 'button'"
36
+ class="flex items-center rounded px-0.5 py-1 text-lg font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400"
37
+ :class="[
38
+ i == crumbs.length - 1
39
+ ? 'text-gray-900'
40
+ : 'text-gray-600 hover:text-gray-700',
41
+ ]"
42
+ v-bind="item.route ? { to: item.route } : { onClick: item.onClick }"
43
+ >
44
+ <slot name="prefix" :item="item" />
45
+ <span>
46
+ {{ item.label }}
47
+ </span>
48
+ </component>
49
+ <span
50
+ v-if="i != crumbs.length - 1"
51
+ class="mx-0.5 text-base text-gray-500"
52
+ aria-hidden="true"
53
+ >
54
+ /
55
+ </span>
56
+ </template>
57
+ </div>
58
+ </div>
59
+ </template>
60
+ <script setup lang="ts">
61
+ import { useWindowSize } from '@vueuse/core'
62
+ import { computed } from 'vue'
63
+ import { RouterLinkProps, useRouter } from 'vue-router'
64
+ import Dropdown from '../components/Dropdown.vue'
65
+ import Button from '../components/Button.vue'
66
+
67
+ interface BreadcrumbItem {
68
+ label: string
69
+ route?: RouterLinkProps['to']
70
+ onClick?: () => void
71
+ [key: string]: any
72
+ }
73
+
74
+ interface BreadcrumbsProps {
75
+ items: BreadcrumbItem[]
76
+ }
77
+
78
+ const props = defineProps<BreadcrumbsProps>()
79
+
80
+ const router = useRouter()
81
+ const { width } = useWindowSize()
82
+
83
+ const items = computed(() => {
84
+ return (props.items || []).filter(Boolean)
85
+ })
86
+
87
+ const dropdownItems = computed(() => {
88
+ if (width.value > 640) return []
89
+
90
+ let allExceptLastTwo = items.value.slice(0, -2)
91
+ return allExceptLastTwo.map((item) => {
92
+ let onClick = item.onClick ? item.onClick : () => router.push(item.route)
93
+ return {
94
+ ...item,
95
+ icon: null,
96
+ label: item.label,
97
+ onClick,
98
+ }
99
+ })
100
+ })
101
+
102
+ const crumbs = computed(() => {
103
+ if (width.value > 640) return items.value
104
+
105
+ let lastTwo = items.value.slice(-2)
106
+ return lastTwo
107
+ })
108
+ </script>
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <div
3
+ class="mx-5 mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
4
+ :style="{ gridTemplateColumns: getGridTemplateColumns(columns) }"
5
+ >
6
+ <Checkbox
7
+ class="cursor-pointer duration-300"
8
+ :modelValue="allRowsSelected"
9
+ @click.stop="toggleAllRows"
10
+ />
11
+ <slot>
12
+ <ListHeaderItem
13
+ v-for="column in columns"
14
+ :key="column.key"
15
+ :column="column"
16
+ />
17
+ </slot>
18
+ </div>
19
+ </template>
20
+
21
+ <script setup>
22
+ import Checkbox from '../Checkbox.vue'
23
+ import ListHeaderItem from './ListHeaderItem.vue'
24
+ import { getGridTemplateColumns } from './utils'
25
+ import { inject } from 'vue'
26
+
27
+ const { columns, allRowsSelected, toggleAllRows } = inject('list')
28
+ </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[column.align]"
5
+ >
6
+ <slot name="prefix" v-bind="{ column }" />
7
+ <div>
8
+ {{ column.label }}
9
+ </div>
10
+ <slot name="suffix" v-bind="{ column }" />
11
+ </div>
12
+ </template>
13
+
14
+ <script setup>
15
+ import { alignmentMap } from './utils'
16
+ const props = defineProps({
17
+ column: {
18
+ type: Object,
19
+ required: true,
20
+ },
21
+ })
22
+ </script>
@@ -1,29 +1,72 @@
1
+ <template>
2
+ <component
3
+ :is="row.route ? 'router-link' : 'div'"
4
+ class="mx-5 flex cursor-pointer flex-col transition-all duration-300 ease-in-out"
5
+ v-bind="row.route ? { to: row.route } : { onClick: row.onClick }"
6
+ >
7
+ <component
8
+ :is="row.route ? 'template' : 'button'"
9
+ class="[all:unset] hover:[all:unset]"
10
+ >
11
+ <div
12
+ class="grid items-center space-x-4 rounded px-2 py-2.5"
13
+ :class="
14
+ selections.has(row[rowKey])
15
+ ? 'bg-gray-100 hover:bg-gray-200'
16
+ : 'hover:bg-gray-50'
17
+ "
18
+ :style="{ gridTemplateColumns: getGridTemplateColumns(columns) }"
19
+ >
20
+ <Checkbox
21
+ :modelValue="selections.has(row[rowKey])"
22
+ @click.stop="toggleRow(row[rowKey])"
23
+ class="cursor-pointer duration-300"
24
+ />
25
+ <div
26
+ v-for="column in columns"
27
+ :key="column.key"
28
+ :class="alignmentMap[column.align]"
29
+ >
30
+ <slot v-bind="{ column, item: _row(row)[column.key] }">
31
+ <ListRowItem
32
+ :item="_row(row)[column.key]"
33
+ :type="column.type"
34
+ :align="column.align"
35
+ />
36
+ </slot>
37
+ </div>
38
+ </div>
39
+ <div
40
+ v-if="idx < rows.length - 1"
41
+ class="mx-2 h-px border-t border-gray-200"
42
+ />
43
+ </component>
44
+ </component>
45
+ </template>
46
+
1
47
  <script setup>
2
- import { inject } from 'vue'
3
48
  import Checkbox from '../Checkbox.vue'
49
+ import ListRowItem from './ListRowItem.vue'
50
+ import { alignmentMap, getGridTemplateColumns } from './utils'
51
+ import { inject } from 'vue'
4
52
 
5
- const list = inject('list')
6
53
  const props = defineProps({
7
- as: { type: String, default: 'div' },
8
- row: { type: Object, default: () => ({}), required: true },
54
+ row: {
55
+ type: Object,
56
+ required: true,
57
+ },
58
+ idx: {
59
+ type: Number,
60
+ required: true,
61
+ },
9
62
  })
10
- </script>
11
63
 
12
- <template>
13
- <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'
20
- "
21
- >
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>
28
- </component>
29
- </template>
64
+ function _row(row) {
65
+ if (row.row && typeof row.row === 'object' && (row.onClick || row.route)) {
66
+ return row.row
67
+ }
68
+ return row
69
+ }
70
+
71
+ const { rows, columns, rowKey, selections, toggleRow } = inject('list')
72
+ </script>
@@ -1,10 +1,42 @@
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
+ <Tooltip
3
+ :text="label"
4
+ class="flex items-center space-x-2"
5
+ :class="alignmentMap[align]"
7
6
  >
8
- <slot> </slot>
9
- </div>
7
+ <slot name="prefix" />
8
+ <slot v-bind="{ label }">
9
+ <div class="truncate text-base">
10
+ {{ label }}
11
+ </div>
12
+ </slot>
13
+ <slot name="suffix" />
14
+ </Tooltip>
10
15
  </template>
16
+ <script setup>
17
+ import { alignmentMap } from './utils'
18
+ import Tooltip from '../Tooltip.vue'
19
+ import { computed } from 'vue'
20
+
21
+ const props = defineProps({
22
+ item: {
23
+ type: [String, Number, Object],
24
+ default: '',
25
+ },
26
+ align: {
27
+ type: String,
28
+ default: 'left',
29
+ },
30
+ })
31
+
32
+ const label = computed(() => {
33
+ return getValue(props.item).label || ''
34
+ })
35
+
36
+ function getValue(value) {
37
+ if (value && typeof value === 'object') {
38
+ return value
39
+ }
40
+ return { label: value }
41
+ }
42
+ </script>
@@ -0,0 +1,19 @@
1
+ <template>
2
+ <div class="h-full overflow-y-auto">
3
+ <slot>
4
+ <ListRow
5
+ v-for="(row, i) in rows"
6
+ :key="row[rowKey]"
7
+ :row="row"
8
+ :idx="i"
9
+ />
10
+ </slot>
11
+ </div>
12
+ </template>
13
+
14
+ <script setup>
15
+ import ListRow from './ListRow.vue'
16
+ import { inject } from 'vue'
17
+
18
+ const { rows, rowKey } = inject('list')
19
+ </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 { list, selections, allRowsSelected, toggleAllRows } = inject('list')
82
+ </script>
@@ -1,116 +1,70 @@
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="mt-3 flex w-max min-w-full flex-col overflow-y-hidden"
5
+ :class="$attrs.class"
6
+ >
7
+ <slot>
8
+ <ListHeader />
9
+ <ListRows />
10
+ <ListSelectBanner />
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
+ },
90
38
  })
91
39
 
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
- }),
40
+ let selections = reactive(new Set())
41
+
42
+ const allRowsSelected = computed(() => {
43
+ if (!props.rows.length) return false
44
+ return selections.size === props.rows.length
100
45
  })
101
- provide('list', state)
102
46
 
103
- state.toggleAllRows = () => {
104
- return state.allRowsSelected
105
- ? state.selections.clear()
106
- : props.rows.forEach((row) => state.selections.add(row.name))
47
+ function toggleRow(row) {
48
+ if (!selections.delete(row)) {
49
+ selections.add(row)
50
+ }
107
51
  }
108
52
 
109
- state.toggleSelection = (name) => {
110
- if (state.selections.has(name)) {
111
- state.selections.delete(name)
112
- } else {
113
- state.selections.add(name)
53
+ function toggleAllRows(select) {
54
+ if (!select || allRowsSelected.value) {
55
+ selections.clear()
56
+ return
114
57
  }
58
+ props.rows.forEach((row) => selections.add(row[props.rowKey]))
115
59
  }
60
+
61
+ provide('list', {
62
+ rowKey: props.rowKey,
63
+ rows: props.rows,
64
+ columns: props.columns,
65
+ selections,
66
+ allRowsSelected,
67
+ toggleRow,
68
+ toggleAllRows,
69
+ })
116
70
  </script>
@@ -0,0 +1,23 @@
1
+ export function getGridTemplateColumns(columns) {
2
+ return (
3
+ '14px ' +
4
+ columns
5
+ .map((col) => {
6
+ let width = col.width || 1
7
+ if (typeof width === 'number') {
8
+ return width + 'fr'
9
+ }
10
+ return width
11
+ })
12
+ .join(' ')
13
+ )
14
+ }
15
+
16
+ export const alignmentMap = {
17
+ left: 'justify-start',
18
+ start: 'justify-start',
19
+ center: 'justify-center',
20
+ middle: 'justify-center',
21
+ right: 'justify-end',
22
+ end: 'justify-end',
23
+ }
@@ -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>
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ import { h, reactive } from 'vue'
3
+ import Tabs from './Tabs.vue'
4
+ import FeatherIcon from './FeatherIcon.vue'
5
+ const state = reactive({
6
+ index: 0,
7
+ tabs_without_icon: [
8
+ {
9
+ label: 'Github',
10
+ content:
11
+ 'Github is a code hosting platform for version control and collaboration. It lets you and others work together on projects from anywhere.',
12
+ },
13
+ {
14
+ label: 'Twitter',
15
+ content:
16
+ 'Twitter is an American microblogging and social networking service on which users post and interact with messages known as "tweets".',
17
+ },
18
+ {
19
+ label: 'Linkedin',
20
+ content:
21
+ 'LinkedIn is an American business and employment-oriented online service that operates via websites and mobile apps.',
22
+ },
23
+ ],
24
+ tabs_with_icon: [
25
+ {
26
+ label: 'Github',
27
+ content:
28
+ 'Github is a code hosting platform for version control and collaboration. It lets you and others work together on projects from anywhere.',
29
+ icon: h(FeatherIcon, { class: 'w-4 h-4', name: 'github' }),
30
+ },
31
+ {
32
+ label: 'Twitter',
33
+ content:
34
+ 'Twitter is an American microblogging and social networking service on which users post and interact with messages known as "tweets".',
35
+ icon: h(FeatherIcon, { class: 'w-4 h-4', name: 'twitter' }),
36
+ },
37
+ {
38
+ label: 'Linkedin',
39
+ content:
40
+ 'LinkedIn is an American business and employment-oriented online service that operates via websites and mobile apps.',
41
+ icon: h(FeatherIcon, { class: 'w-4 h-4', name: 'linkedin' }),
42
+ },
43
+ ],
44
+ })
45
+ </script>
46
+
47
+ <template>
48
+ <Story :layout="{ type: 'grid', width: '80%' }">
49
+ <Variant title="Without Icon">
50
+ <Tabs
51
+ v-slot="{ tab }"
52
+ v-model="state.index"
53
+ :tabs="state.tabs_without_icon"
54
+ >
55
+ <div class="p-5">
56
+ {{ tab.content }}
57
+ </div>
58
+ </Tabs>
59
+ </Variant>
60
+ <Variant title="With Icon">
61
+ <Tabs v-slot="{ tab }" v-model="state.index" :tabs="state.tabs_with_icon">
62
+ <div class="p-5">
63
+ {{ tab.content }}
64
+ </div>
65
+ </Tabs>
66
+ </Variant>
67
+
68
+ <template #controls>
69
+ <HstNumber v-model="state.index" title="Tab Index" />
70
+ </template>
71
+ </Story>
72
+ </template>
@@ -0,0 +1,97 @@
1
+ <template>
2
+ <TabGroup
3
+ as="div"
4
+ class="flex flex-1 flex-col"
5
+ :defaultIndex="changedIndex"
6
+ :selectedIndex="changedIndex"
7
+ @change="(idx) => (changedIndex = idx)"
8
+ >
9
+ <TabList class="relative flex items-center gap-6 border-b pl-5">
10
+ <Tab
11
+ ref="tabRef"
12
+ as="template"
13
+ v-for="(tab, i) in tabs"
14
+ :key="i"
15
+ v-slot="{ selected }"
16
+ class="focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-gray-400"
17
+ >
18
+ <slot name="tab" v-bind="{ tab, selected }">
19
+ <button
20
+ class="-mb-px flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
21
+ :class="{ 'text-gray-900': selected }"
22
+ >
23
+ <component v-if="tab.icon" :is="tab.icon" class="h-5" />
24
+ {{ tab.label }}
25
+ </button>
26
+ </slot>
27
+ </Tab>
28
+ <div
29
+ ref="indicator"
30
+ class="absolute -bottom-px h-px bg-gray-900"
31
+ :style="{ left: `${indicatorLeftValue}px` }"
32
+ />
33
+ </TabList>
34
+ <TabPanels class="flex flex-1 overflow-hidden">
35
+ <TabPanel
36
+ class="flex flex-1 flex-col overflow-y-auto focus:outline-none"
37
+ v-for="(tab, i) in tabs"
38
+ :key="i"
39
+ >
40
+ <slot v-bind="{ tab }" />
41
+ </TabPanel>
42
+ </TabPanels>
43
+ </TabGroup>
44
+ </template>
45
+
46
+ <script setup>
47
+ import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
48
+ import { TransitionPresets, useTransition } from '@vueuse/core'
49
+ import { ref, watch, computed, onMounted, nextTick } from 'vue'
50
+
51
+ const props = defineProps({
52
+ tabs: {
53
+ type: Array,
54
+ required: true,
55
+ },
56
+ modelValue: {
57
+ type: Number,
58
+ default: 0,
59
+ },
60
+ })
61
+
62
+ const emit = defineEmits(['update:modelValue'])
63
+
64
+ const changedIndex = computed({
65
+ get: () => props.modelValue,
66
+ set: (index) => emit('update:modelValue', index),
67
+ })
68
+
69
+ const tabRef = ref([])
70
+ const indicator = ref(null)
71
+ const tabsLength = ref(props.tabs?.length)
72
+
73
+ let indicatorLeft = ref(0)
74
+
75
+ const indicatorLeftValue = useTransition(indicatorLeft, {
76
+ duration: 250,
77
+ ease: TransitionPresets.easeOutCubic,
78
+ })
79
+
80
+ function moveIndicator(index) {
81
+ if (index >= tabsLength.value) {
82
+ index = tabsLength.value - 1
83
+ }
84
+ const selectedTab = tabRef.value[index].el
85
+ indicator.value.style.width = `${selectedTab.offsetWidth}px`
86
+ indicatorLeft.value = selectedTab.offsetLeft
87
+ }
88
+
89
+ watch(changedIndex, (index) => {
90
+ if (index >= tabsLength.value) {
91
+ changedIndex.value = tabsLength.value - 1
92
+ }
93
+ nextTick(() => moveIndicator(index))
94
+ })
95
+
96
+ onMounted(() => moveIndicator(changedIndex.value))
97
+ </script>
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ export { default as Alert } from './components/Alert.vue'
3
3
  export { default as Autocomplete } from './components/Autocomplete.vue'
4
4
  export { default as Avatar } from './components/Avatar.vue'
5
5
  export { default as Badge } from './components/Badge.vue'
6
+ export { default as Breadcrumbs } from './components/Breadcrumbs.vue'
6
7
  export { default as Button } from './components/Button.vue'
7
8
  export { default as Card } from './components/Card.vue'
8
9
  export { default as Checkbox } from './components/Checkbox.vue'
@@ -27,6 +28,7 @@ export { default as Select } from './components/Select.vue'
27
28
  export { default as Spinner } from './components/Spinner.vue'
28
29
  export { default as Switch } from './components/Switch.vue'
29
30
  export { default as TabButtons } from './components/TabButtons.vue'
31
+ export { default as Tabs } from './components/Tabs.vue'
30
32
  export { default as TextInput } from './components/TextInput.vue'
31
33
  export { default as Textarea } from './components/Textarea.vue'
32
34
  export {
@@ -36,9 +38,13 @@ export {
36
38
  TextEditorFloatingMenu,
37
39
  TextEditorContent,
38
40
  } from './components/TextEditor'
39
- 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'
40
45
  export { default as ListRow } from './components/ListView/ListRow.vue'
41
46
  export { default as ListRowItem } from './components/ListView/ListRowItem.vue'
47
+ export { default as ListSelectBanner } from './components/ListView/ListSelectBanner.vue'
42
48
  export { default as Toast } from './components/Toast.vue'
43
49
  export { toast, Toasts } from './components/toast.js'
44
50
  export { default as Tooltip } from './components/Tooltip.vue'