frappe-ui 0.1.97 → 0.1.99
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 +2 -2
- package/src/components/Dropdown.vue +1 -1
- package/src/components/ListView/ListRow.vue +1 -1
- package/src/components/{ListView.story.vue → ListView/ListView.story.vue} +11 -12
- package/src/components/ListView/ListView.vue +7 -0
- package/src/components/Tabs/TabList.vue +81 -0
- package/src/components/Tabs/TabPanel.vue +17 -0
- package/src/components/Tabs/Tabs.story.md +97 -0
- package/src/components/{Tabs.story.vue → Tabs/Tabs.story.vue} +34 -9
- package/src/components/Tabs/Tabs.vue +52 -0
- package/src/data-fetching/docStore.ts +7 -6
- package/src/data-fetching/useDoc/useDoc.ts +51 -7
- package/src/data-fetching/useDoctype/useDoctype.ts +51 -1
- package/src/data-fetching/useList/listStore.ts +7 -0
- package/src/data-fetching/useList/types.ts +3 -2
- package/src/data-fetching/useList/useList.test.ts +2 -0
- package/src/data-fetching/useList/useList.ts +122 -25
- package/src/data-fetching/utils.ts +3 -3
- package/src/index.js +3 -1
- package/src/components/Tabs.story.md +0 -15
- package/src/components/Tabs.vue +0 -110
- /package/src/components/{ListView.story.md → ListView/ListView.story.md} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frappe-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.99",
|
|
4
4
|
"description": "A set of components and utilities for rapid UI development",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"typescript": "^5.0.2"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
|
-
"vue": ">=3.
|
|
63
|
+
"vue": ">=3.5.0",
|
|
64
64
|
"vue-router": "^4.1.6"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
<template #body>
|
|
18
18
|
<MenuItems
|
|
19
|
-
class="mt-2 min-w-40 divide-y divide-outline-gray-
|
|
19
|
+
class="mt-2 min-w-40 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
|
20
20
|
:class="{
|
|
21
21
|
'left-0 origin-top-left': placement == 'left',
|
|
22
22
|
'right-0 origin-top-right': placement == 'right',
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { reactive, h, ref } from 'vue'
|
|
3
|
-
import Avatar from '
|
|
4
|
-
import Badge from '
|
|
5
|
-
import { Button } from '
|
|
6
|
-
import FeatherIcon from '
|
|
7
|
-
import ListHeader from './
|
|
8
|
-
import ListHeaderItem from './
|
|
9
|
-
import ListRow from './
|
|
10
|
-
import ListRowItem from './
|
|
11
|
-
import ListRows from './
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import ListView from './ListView/ListView.vue'
|
|
3
|
+
import Avatar from '../Avatar.vue'
|
|
4
|
+
import Badge from '../Badge.vue'
|
|
5
|
+
import { Button } from '../Button'
|
|
6
|
+
import FeatherIcon from '../FeatherIcon.vue'
|
|
7
|
+
import ListHeader from './ListHeader.vue'
|
|
8
|
+
import ListHeaderItem from './ListHeaderItem.vue'
|
|
9
|
+
import ListRow from './ListRow.vue'
|
|
10
|
+
import ListRowItem from './ListRowItem.vue'
|
|
11
|
+
import ListRows from './ListRows.vue'
|
|
12
|
+
import ListSelectBanner from './ListSelectBanner.vue'
|
|
13
|
+
import ListView from './ListView.vue'
|
|
15
14
|
|
|
16
15
|
const state = reactive({
|
|
17
16
|
selectable: true,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<TabList
|
|
3
|
+
class="relative flex"
|
|
4
|
+
:class="
|
|
5
|
+
vertical
|
|
6
|
+
? 'flex-col border-r overflow-y-auto'
|
|
7
|
+
: 'gap-7.5 border-b overflow-x-auto items-center px-5'
|
|
8
|
+
"
|
|
9
|
+
>
|
|
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"
|
|
17
|
+
>
|
|
18
|
+
<slot v-bind="{ tab, selected }">
|
|
19
|
+
<button
|
|
20
|
+
class="flex items-center gap-1.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:text-ink-gray-9"
|
|
21
|
+
:class="[
|
|
22
|
+
selected ? 'text-ink-gray-9' : '',
|
|
23
|
+
vertical
|
|
24
|
+
? 'py-2.5 px-4 border-r border-transparent hover:border-outline-gray-3'
|
|
25
|
+
: 'py-3 border-b border-transparent hover:border-outline-gray-3',
|
|
26
|
+
]"
|
|
27
|
+
>
|
|
28
|
+
<component v-if="tab.icon" :is="tab.icon" class="size-4" />
|
|
29
|
+
{{ tab.label }}
|
|
30
|
+
</button>
|
|
31
|
+
</slot>
|
|
32
|
+
</Tab>
|
|
33
|
+
<div
|
|
34
|
+
ref="indicator"
|
|
35
|
+
class="tab-indicator absolute bg-surface-gray-7"
|
|
36
|
+
:class="[vertical ? 'right-0 w-px' : 'bottom-0 h-px', transitionClass]"
|
|
37
|
+
/>
|
|
38
|
+
</TabList>
|
|
39
|
+
</template>
|
|
40
|
+
<script setup>
|
|
41
|
+
import { TabList, Tab } from '@headlessui/vue'
|
|
42
|
+
import { ref, watch, computed, onMounted, nextTick, inject } from 'vue'
|
|
43
|
+
|
|
44
|
+
const tabIndex = inject('tabIndex')
|
|
45
|
+
const tabs = inject('tabs')
|
|
46
|
+
const vertical = inject('vertical')
|
|
47
|
+
|
|
48
|
+
const tabRef = ref([])
|
|
49
|
+
const indicator = ref(null)
|
|
50
|
+
const tabsLength = computed(() => tabs.value?.length)
|
|
51
|
+
|
|
52
|
+
const transitionClass = ref('')
|
|
53
|
+
|
|
54
|
+
function moveIndicator(index) {
|
|
55
|
+
if (index >= tabsLength.value) {
|
|
56
|
+
index = tabsLength.value - 1
|
|
57
|
+
}
|
|
58
|
+
const selectedTab = tabRef.value[index].el
|
|
59
|
+
if (vertical) {
|
|
60
|
+
indicator.value.style.height = `${selectedTab.offsetHeight}px`
|
|
61
|
+
indicator.value.style.top = `${selectedTab.offsetTop}px`
|
|
62
|
+
} else {
|
|
63
|
+
indicator.value.style.width = `${selectedTab.offsetWidth}px`
|
|
64
|
+
indicator.value.style.left = `${selectedTab.offsetLeft}px`
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
watch(tabIndex, (index) => {
|
|
69
|
+
if (index >= tabsLength.value) {
|
|
70
|
+
tabIndex.value = tabsLength.value - 1
|
|
71
|
+
}
|
|
72
|
+
transitionClass.value = 'transition-all duration-300 ease-in-out'
|
|
73
|
+
nextTick(() => moveIndicator(index))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
onMounted(() => {
|
|
77
|
+
nextTick(() => moveIndicator(tabIndex.value))
|
|
78
|
+
// Fix for indicator not moving on initial load
|
|
79
|
+
setTimeout(() => moveIndicator(tabIndex.value), 100)
|
|
80
|
+
})
|
|
81
|
+
</script>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<TabPanels class="flex flex-1 overflow-hidden">
|
|
3
|
+
<TabPanel
|
|
4
|
+
class="flex flex-1 flex-col overflow-y-auto focus:outline-none"
|
|
5
|
+
v-for="(tab, i) in tabs"
|
|
6
|
+
:key="i"
|
|
7
|
+
>
|
|
8
|
+
<slot v-bind="{ tab }" />
|
|
9
|
+
</TabPanel>
|
|
10
|
+
</TabPanels>
|
|
11
|
+
</template>
|
|
12
|
+
<script setup>
|
|
13
|
+
import { TabPanels, TabPanel } from '@headlessui/vue'
|
|
14
|
+
import { inject } from 'vue'
|
|
15
|
+
|
|
16
|
+
const tabs = inject('tabs')
|
|
17
|
+
</script>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
## Props
|
|
2
|
+
|
|
3
|
+
### tabs
|
|
4
|
+
|
|
5
|
+
It is an array of objects which contains the following attributes:
|
|
6
|
+
|
|
7
|
+
1. `label` is the name of the tab, it is required.
|
|
8
|
+
2. `icon` is the icon to be shown in the tab, it accept component and it is
|
|
9
|
+
optional.
|
|
10
|
+
3. You can add more attributes which can be used for custom rendering in the tab
|
|
11
|
+
header or content.
|
|
12
|
+
|
|
13
|
+
### v-model
|
|
14
|
+
|
|
15
|
+
It is used to set the active tab or change the active tab. It is required.
|
|
16
|
+
|
|
17
|
+
### vertical
|
|
18
|
+
|
|
19
|
+
It is used to show the tabs vertically. It is optional.
|
|
20
|
+
|
|
21
|
+
### as
|
|
22
|
+
|
|
23
|
+
You can set it to `div` to wrap tabs in a `div`. It can be any valid HTML tag.
|
|
24
|
+
This is useful to control the layout of the tabs. It is optional.
|
|
25
|
+
|
|
26
|
+
1. `as="div"` or any valid HTML tag
|
|
27
|
+
|
|
28
|
+
```html
|
|
29
|
+
<div>
|
|
30
|
+
<!-- container div -->
|
|
31
|
+
<div>
|
|
32
|
+
<div active>Tab 1</div>
|
|
33
|
+
<div>Tab 2</div>
|
|
34
|
+
<div>Tab 3</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div>
|
|
37
|
+
<div active>Content 1</div>
|
|
38
|
+
<div>Content 2</div>
|
|
39
|
+
<div>Content 3</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
2. `as` is not set
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<div>
|
|
48
|
+
<div active>Tab 1</div>
|
|
49
|
+
<div>Tab 2</div>
|
|
50
|
+
<div>Tab 3</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div>
|
|
53
|
+
<div active>Content 1</div>
|
|
54
|
+
<div>Content 2</div>
|
|
55
|
+
<div>Content 3</div>
|
|
56
|
+
</div>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Slots
|
|
60
|
+
|
|
61
|
+
1. **tab-item:** You can use this slot to render custom tab items. It is
|
|
62
|
+
optional.
|
|
63
|
+
2. **tab-panel:** You can use this slot to render custom tab panels. It is
|
|
64
|
+
required. Example:
|
|
65
|
+
|
|
66
|
+
```vue
|
|
67
|
+
<Tabs v-model="tabIndex" :tabs="tabs">
|
|
68
|
+
<template #tab-item="{ tab, selected }">
|
|
69
|
+
<div :class="{ 'text-gray-900 font-semibold': selected }">
|
|
70
|
+
<span>{{ tab.label }}</span>
|
|
71
|
+
<span>{{ tab.icon }}</span>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
<template #tab-panel="{ tab }">
|
|
75
|
+
<div>{{ tab.content }}</div>
|
|
76
|
+
</template>
|
|
77
|
+
</Tabs>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Layout Customization
|
|
81
|
+
|
|
82
|
+
You can customize the layout of the tabs by using `<TabList />` and `<TabPanels />`
|
|
83
|
+
components.
|
|
84
|
+
|
|
85
|
+
```vue
|
|
86
|
+
<Tabs v-model="tabIndex" :tabs="tabs">
|
|
87
|
+
<TabList v-slot="{ tab, selected }">
|
|
88
|
+
<div :class="{ 'text-gray-900 font-semibold': selected }">
|
|
89
|
+
<span>{{ tab.label }}</span>
|
|
90
|
+
<span>{{ tab.icon }}</span>
|
|
91
|
+
</div>
|
|
92
|
+
</TabList>
|
|
93
|
+
<TabPanel v-slot="{ tab }">
|
|
94
|
+
<div>{{ tab.content }}</div>
|
|
95
|
+
</TabPanel>
|
|
96
|
+
</Tabs>
|
|
97
|
+
```
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { h, reactive } from 'vue'
|
|
3
3
|
import Tabs from './Tabs.vue'
|
|
4
|
-
import FeatherIcon from '
|
|
4
|
+
import FeatherIcon from '../FeatherIcon.vue'
|
|
5
5
|
const state = reactive({
|
|
6
6
|
index: 0,
|
|
7
7
|
tabs_without_icon: [
|
|
@@ -48,20 +48,45 @@ const state = reactive({
|
|
|
48
48
|
<Story :layout="{ type: 'grid', width: '80%' }">
|
|
49
49
|
<Variant title="Without Icon">
|
|
50
50
|
<Tabs
|
|
51
|
-
|
|
51
|
+
as="div"
|
|
52
|
+
class="border rounded"
|
|
52
53
|
v-model="state.index"
|
|
53
54
|
:tabs="state.tabs_without_icon"
|
|
54
55
|
>
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
<template #tab-panel="{ tab }">
|
|
57
|
+
<div class="p-5">
|
|
58
|
+
{{ tab.content }}
|
|
59
|
+
</div>
|
|
60
|
+
</template>
|
|
58
61
|
</Tabs>
|
|
59
62
|
</Variant>
|
|
60
63
|
<Variant title="With Icon">
|
|
61
|
-
<Tabs
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
<Tabs
|
|
65
|
+
as="div"
|
|
66
|
+
class="border rounded"
|
|
67
|
+
v-model="state.index"
|
|
68
|
+
:tabs="state.tabs_with_icon"
|
|
69
|
+
>
|
|
70
|
+
<template #tab-panel="{ tab }">
|
|
71
|
+
<div class="p-5">
|
|
72
|
+
{{ tab.content }}
|
|
73
|
+
</div>
|
|
74
|
+
</template>
|
|
75
|
+
</Tabs>
|
|
76
|
+
</Variant>
|
|
77
|
+
<Variant title="Vertical Tabs">
|
|
78
|
+
<Tabs
|
|
79
|
+
as="div"
|
|
80
|
+
class="border rounded"
|
|
81
|
+
v-model="state.index"
|
|
82
|
+
:tabs="state.tabs_with_icon"
|
|
83
|
+
vertical
|
|
84
|
+
>
|
|
85
|
+
<template #tab-panel="{ tab }">
|
|
86
|
+
<div class="p-5">
|
|
87
|
+
{{ tab.content }}
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
65
90
|
</Tabs>
|
|
66
91
|
</Variant>
|
|
67
92
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<TabGroup
|
|
3
|
+
v-bind="
|
|
4
|
+
as !== 'template'
|
|
5
|
+
? {
|
|
6
|
+
as,
|
|
7
|
+
class: ['flex flex-1 overflow-hidden', vertical ? '' : 'flex-col '],
|
|
8
|
+
}
|
|
9
|
+
: {}
|
|
10
|
+
"
|
|
11
|
+
:defaultIndex="tabIndex"
|
|
12
|
+
:selectedIndex="tabIndex"
|
|
13
|
+
@change="(idx) => (tabIndex = idx)"
|
|
14
|
+
>
|
|
15
|
+
<slot>
|
|
16
|
+
<TabList v-slot="{ tab, selected }">
|
|
17
|
+
<slot name="tab-item" v-bind="{ tab, selected }" />
|
|
18
|
+
</TabList>
|
|
19
|
+
<TabPanel v-slot="{ tab }">
|
|
20
|
+
<slot name="tab-panel" v-bind="{ tab }" />
|
|
21
|
+
</TabPanel>
|
|
22
|
+
</slot>
|
|
23
|
+
</TabGroup>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup>
|
|
27
|
+
import TabList from './TabList.vue'
|
|
28
|
+
import TabPanel from './TabPanel.vue'
|
|
29
|
+
import { TabGroup } from '@headlessui/vue'
|
|
30
|
+
import { provide } from 'vue'
|
|
31
|
+
|
|
32
|
+
const props = defineProps({
|
|
33
|
+
as: {
|
|
34
|
+
type: String,
|
|
35
|
+
default: 'template',
|
|
36
|
+
},
|
|
37
|
+
tabs: {
|
|
38
|
+
type: Array,
|
|
39
|
+
required: true,
|
|
40
|
+
},
|
|
41
|
+
vertical: {
|
|
42
|
+
type: Boolean,
|
|
43
|
+
default: false,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const tabIndex = defineModel()
|
|
48
|
+
|
|
49
|
+
provide('tabIndex', tabIndex)
|
|
50
|
+
provide('tabs', props.tabs)
|
|
51
|
+
provide('vertical', props.vertical)
|
|
52
|
+
</script>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Ref, ref, MaybeRefOrGetter, toValue } from 'vue'
|
|
2
2
|
import { idbStore } from './idbStore'
|
|
3
3
|
|
|
4
4
|
type Doc = {
|
|
@@ -49,11 +49,8 @@ class DocStore {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
getDoc(
|
|
53
|
-
|
|
54
|
-
name: string | Ref<string> | ComputedRef<string>,
|
|
55
|
-
): Ref<Doc | null> {
|
|
56
|
-
const nameStr = unref(name)
|
|
52
|
+
getDoc(doctype: string, name: MaybeRefOrGetter<string>): Ref<Doc | null> {
|
|
53
|
+
const nameStr = toValue(name)
|
|
57
54
|
if (!doctype || !nameStr) {
|
|
58
55
|
throw new Error('doctype and name are required')
|
|
59
56
|
}
|
|
@@ -114,6 +111,10 @@ class DocStore {
|
|
|
114
111
|
await this.cleanup(key)
|
|
115
112
|
}
|
|
116
113
|
|
|
114
|
+
removeDoc(doctype: string, name: string) {
|
|
115
|
+
return this.invalidateDoc(doctype, name)
|
|
116
|
+
}
|
|
117
|
+
|
|
117
118
|
private getKey(doctype: string, name: string): DocKey {
|
|
118
119
|
return `${doctype.trim()}/${name.trim()}` as DocKey
|
|
119
120
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
reactive,
|
|
4
|
+
readonly,
|
|
5
|
+
Ref,
|
|
6
|
+
MaybeRefOrGetter,
|
|
7
|
+
toValue,
|
|
8
|
+
} from 'vue'
|
|
2
9
|
import { UseFetchOptions } from '@vueuse/core'
|
|
3
10
|
import { useFrappeFetch } from '../useFrappeFetch'
|
|
4
11
|
import { useCall } from '../useCall/useCall'
|
|
@@ -24,7 +31,7 @@ interface DocMethodOption<T = any>
|
|
|
24
31
|
|
|
25
32
|
interface UseDocOptions {
|
|
26
33
|
doctype: string
|
|
27
|
-
name:
|
|
34
|
+
name: MaybeRefOrGetter<string>
|
|
28
35
|
baseUrl?: string
|
|
29
36
|
methods?: Record<string, string | DocMethodOption>
|
|
30
37
|
immediate?: boolean
|
|
@@ -42,15 +49,28 @@ export function useDoc<TDoc extends { name: string }, TMethods = {}>(
|
|
|
42
49
|
} = options
|
|
43
50
|
|
|
44
51
|
const url = computed(
|
|
45
|
-
() => `${baseUrl}/api/v2/document/${doctype}/${
|
|
52
|
+
() => `${baseUrl}/api/v2/document/${doctype}/${toValue(name)}`,
|
|
46
53
|
)
|
|
47
54
|
|
|
55
|
+
type SuccessCallback = (doc: TDoc) => void
|
|
56
|
+
const successCallbacks: SuccessCallback[] = []
|
|
57
|
+
const triggerSuccessCallbacks = (doc: TDoc) => {
|
|
58
|
+
for (let cb of successCallbacks) {
|
|
59
|
+
try {
|
|
60
|
+
cb(doc)
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.error('Error in onSuccess hook:', e)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
48
67
|
const fetchOptions: UseFetchOptions = {
|
|
49
68
|
immediate,
|
|
50
69
|
refetch: true,
|
|
51
70
|
afterFetch(ctx) {
|
|
52
71
|
docStore.setDoc({ doctype, ...ctx.data })
|
|
53
72
|
listStore.updateRow(doctype, ctx.data)
|
|
73
|
+
triggerSuccessCallbacks(ctx.data)
|
|
54
74
|
return ctx
|
|
55
75
|
},
|
|
56
76
|
}
|
|
@@ -86,7 +106,7 @@ export function useDoc<TDoc extends { name: string }, TMethods = {}>(
|
|
|
86
106
|
baseUrl,
|
|
87
107
|
url: computed(
|
|
88
108
|
() =>
|
|
89
|
-
`/api/v2/document/${doctype}/${
|
|
109
|
+
`/api/v2/document/${doctype}/${toValue(name)}/method/${option.name}`,
|
|
90
110
|
),
|
|
91
111
|
}
|
|
92
112
|
|
|
@@ -95,17 +115,30 @@ export function useDoc<TDoc extends { name: string }, TMethods = {}>(
|
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
let setValue = useCall<TDoc, Partial<TDoc>>({
|
|
98
|
-
|
|
99
|
-
refetch: false,
|
|
118
|
+
url: computed(() => `/api/v2/document/${doctype}/${toValue(name)}`),
|
|
100
119
|
method: 'PUT',
|
|
101
120
|
baseUrl,
|
|
102
|
-
|
|
121
|
+
immediate: false,
|
|
122
|
+
refetch: false,
|
|
103
123
|
onSuccess(data) {
|
|
104
124
|
docStore.setDoc({ doctype, ...data })
|
|
105
125
|
listStore.updateRow(doctype, data)
|
|
106
126
|
},
|
|
107
127
|
})
|
|
108
128
|
|
|
129
|
+
type DeleteResponse = 'ok'
|
|
130
|
+
const delete_ = useCall<DeleteResponse>({
|
|
131
|
+
url: computed(() => `/api/v2/document/${doctype}/${toValue(name)}`),
|
|
132
|
+
method: 'DELETE',
|
|
133
|
+
baseUrl,
|
|
134
|
+
immediate: false,
|
|
135
|
+
refetch: false,
|
|
136
|
+
onSuccess() {
|
|
137
|
+
docStore.removeDoc(doctype, toValue(name))
|
|
138
|
+
listStore.removeRow(doctype, toValue(name))
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
109
142
|
const doc = docStore.getDoc(doctype, name) as Ref<TDoc | null>
|
|
110
143
|
|
|
111
144
|
let out = reactive({
|
|
@@ -121,6 +154,17 @@ export function useDoc<TDoc extends { name: string }, TMethods = {}>(
|
|
|
121
154
|
reload: execute,
|
|
122
155
|
abort,
|
|
123
156
|
setValue,
|
|
157
|
+
delete: delete_,
|
|
158
|
+
onSuccess: (callback: SuccessCallback) => {
|
|
159
|
+
successCallbacks.push(callback)
|
|
160
|
+
return () => {
|
|
161
|
+
// unsubscribe function
|
|
162
|
+
const index = successCallbacks.indexOf(callback)
|
|
163
|
+
if (index > -1) {
|
|
164
|
+
successCallbacks.splice(index, 1)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
124
168
|
...docMethods,
|
|
125
169
|
})
|
|
126
170
|
|
|
@@ -13,14 +13,16 @@ export function useDoctype<T>(
|
|
|
13
13
|
) {
|
|
14
14
|
const insert = useInsert<T>(doctype, options)
|
|
15
15
|
const delete_ = useDelete(doctype, options)
|
|
16
|
-
const runDocMethod = useRunDocMethod(doctype, options)
|
|
17
16
|
const setValue = useSetValue<T>(doctype, options)
|
|
17
|
+
const runDocMethod = useRunDocMethod(doctype, options)
|
|
18
|
+
const runMethod = useRunMethod(doctype, options)
|
|
18
19
|
|
|
19
20
|
return reactive({
|
|
20
21
|
insert,
|
|
21
22
|
delete: delete_,
|
|
22
23
|
setValue,
|
|
23
24
|
runDocMethod,
|
|
25
|
+
runMethod,
|
|
24
26
|
})
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -107,6 +109,54 @@ function useRunDocMethod(doctype: string, options: UseDoctypeOptions = {}) {
|
|
|
107
109
|
} as RunDocMethodReturnValue)
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
function useRunMethod(doctype: string, options: UseDoctypeOptions = {}) {
|
|
113
|
+
let { baseUrl = '' } = options
|
|
114
|
+
let url = ref(`/api/v2/method/${doctype}/<method>`)
|
|
115
|
+
|
|
116
|
+
interface RunMethodParams {
|
|
117
|
+
method: string
|
|
118
|
+
validate?: () => string | void
|
|
119
|
+
params?: Record<string, any>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type RunMethodReturnValue = ReturnType<typeof useCall> & {
|
|
123
|
+
submit: (params: RunMethodParams) => Promise<any>
|
|
124
|
+
isLoading: (method: string) => boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let runMethod = useCall<any, RunMethodParams['params']>({
|
|
128
|
+
url,
|
|
129
|
+
method: 'POST',
|
|
130
|
+
immediate: false,
|
|
131
|
+
baseUrl,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
let validateError = ref<Error | null>(null)
|
|
135
|
+
|
|
136
|
+
return reactive({
|
|
137
|
+
...runMethod,
|
|
138
|
+
error: computed(() => validateError.value || runMethod.error),
|
|
139
|
+
submit: ({ method, validate, params }: RunMethodParams) => {
|
|
140
|
+
url.value = `/api/v2/method/${doctype}/${method}`
|
|
141
|
+
if (validate) {
|
|
142
|
+
const errorMessage = validate()
|
|
143
|
+
if (errorMessage) {
|
|
144
|
+
validateError.value = new Error(errorMessage)
|
|
145
|
+
return Promise.reject(validateError.value)
|
|
146
|
+
} else {
|
|
147
|
+
validateError.value = null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return runMethod.submit(params)
|
|
151
|
+
},
|
|
152
|
+
isLoading: (method: string) => {
|
|
153
|
+
return (
|
|
154
|
+
runMethod.loading && url.value === `/api/v2/method/${doctype}/${method}`
|
|
155
|
+
)
|
|
156
|
+
},
|
|
157
|
+
} as RunMethodReturnValue)
|
|
158
|
+
}
|
|
159
|
+
|
|
110
160
|
function useSetValue<T>(doctype: string, options: UseDoctypeOptions = {}) {
|
|
111
161
|
let { baseUrl = '' } = options
|
|
112
162
|
let url = ref(`/api/v2/document/${doctype}/<name>`)
|
|
@@ -30,6 +30,13 @@ class ListStore {
|
|
|
30
30
|
})
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
removeRow(doctype: string, name: string) {
|
|
34
|
+
this.ensureList(doctype)
|
|
35
|
+
this.byDocType[doctype].forEach((list) => {
|
|
36
|
+
list.removeRow(name)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
ensureList(docType: string) {
|
|
34
41
|
if (!this.byDocType[docType]) {
|
|
35
42
|
this.byDocType[docType] = []
|
|
@@ -13,7 +13,7 @@ export type FilterValue =
|
|
|
13
13
|
| boolean
|
|
14
14
|
| [string, string | number | boolean | Ref<string | number | boolean>]
|
|
15
15
|
|
|
16
|
-
export interface
|
|
16
|
+
export interface Filters {
|
|
17
17
|
[key: Field]: FilterValue
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -26,7 +26,7 @@ export type OrderBy =
|
|
|
26
26
|
export interface UseListOptions<T> {
|
|
27
27
|
doctype: string
|
|
28
28
|
fields?: Array<keyof T | ChildTableField>
|
|
29
|
-
filters?: Reactive<
|
|
29
|
+
filters?: Reactive<Filters>
|
|
30
30
|
orderBy?: OrderBy
|
|
31
31
|
start?: number
|
|
32
32
|
limit?: number
|
|
@@ -38,6 +38,7 @@ export interface UseListOptions<T> {
|
|
|
38
38
|
immediate?: boolean
|
|
39
39
|
refetch?: boolean
|
|
40
40
|
baseUrl?: string
|
|
41
|
+
url?: `/${string}`
|
|
41
42
|
transform?: (data: T[]) => T[]
|
|
42
43
|
onSuccess?: (data: T[]) => void
|
|
43
44
|
onError?: (error: Error) => void
|
|
@@ -64,6 +64,8 @@ describe('useList', () => {
|
|
|
64
64
|
expect(users.start).toBe(2)
|
|
65
65
|
expect(users.hasPreviousPage).toBe(true)
|
|
66
66
|
expect(users.data).toStrictEqual([
|
|
67
|
+
{ name: 'User1', email: 'user1@example.com' },
|
|
68
|
+
{ name: 'User2', email: 'user2@example.com' },
|
|
67
69
|
{ name: 'User3', email: 'user3@example.com' },
|
|
68
70
|
{ name: 'User4', email: 'user4@example.com' },
|
|
69
71
|
])
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
MaybeRefOrGetter,
|
|
4
|
+
reactive,
|
|
5
|
+
readonly,
|
|
6
|
+
Ref,
|
|
7
|
+
ref,
|
|
8
|
+
toValue,
|
|
9
|
+
} from 'vue'
|
|
2
10
|
import {
|
|
3
11
|
AfterFetchContext,
|
|
4
12
|
OnFetchErrorContext,
|
|
@@ -10,6 +18,7 @@ import { parseFilters, makeGetParams, normalizeCacheKey } from '../utils'
|
|
|
10
18
|
import { UseListOptions, UseListResponse } from './types'
|
|
11
19
|
import { idbStore } from '../idbStore'
|
|
12
20
|
import { listStore } from './listStore'
|
|
21
|
+
import { docStore } from '../docStore'
|
|
13
22
|
|
|
14
23
|
export function useList<T extends { name: string }>(
|
|
15
24
|
options: UseListOptions<T>,
|
|
@@ -29,13 +38,14 @@ export function useList<T extends { name: string }>(
|
|
|
29
38
|
refetch = true,
|
|
30
39
|
cacheKey,
|
|
31
40
|
baseUrl = '',
|
|
41
|
+
url = '',
|
|
32
42
|
transform,
|
|
33
43
|
} = options
|
|
34
44
|
|
|
35
45
|
const _start = ref(start || 0)
|
|
36
46
|
const _limit = ref(limit || 20)
|
|
37
47
|
|
|
38
|
-
const
|
|
48
|
+
const _url = computed(() => {
|
|
39
49
|
const parsedFilters = parseFilters(filters || {})
|
|
40
50
|
const params = makeGetParams({
|
|
41
51
|
fields: fields?.length ? JSON.stringify(fields) : null,
|
|
@@ -47,16 +57,21 @@ export function useList<T extends { name: string }>(
|
|
|
47
57
|
parent: parent,
|
|
48
58
|
debug: debug,
|
|
49
59
|
})
|
|
60
|
+
if (url) {
|
|
61
|
+
return `${baseUrl}${url}?${params}`
|
|
62
|
+
}
|
|
50
63
|
return `${baseUrl}/api/v2/document/${doctype}?${params}`
|
|
51
64
|
})
|
|
52
65
|
|
|
66
|
+
const allData: Ref<T[] | null> = ref(null)
|
|
67
|
+
|
|
53
68
|
const fetchOptions: UseFetchOptions = {
|
|
54
69
|
immediate,
|
|
55
70
|
refetch,
|
|
56
71
|
initialData: initialData
|
|
57
72
|
? { result: initialData, has_next_page: false }
|
|
58
73
|
: null,
|
|
59
|
-
afterFetch: handleAfterFetch<T>(options),
|
|
74
|
+
afterFetch: handleAfterFetch<T>({ ...options, allData, _start }),
|
|
60
75
|
onFetchError: handleFetchError<T>(options),
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -69,7 +84,7 @@ export function useList<T extends { name: string }>(
|
|
|
69
84
|
aborted,
|
|
70
85
|
abort,
|
|
71
86
|
execute,
|
|
72
|
-
} = useFrappeFetch<UseListResponse<T>>(
|
|
87
|
+
} = useFrappeFetch<UseListResponse<T>>(_url, fetchOptions).get()
|
|
73
88
|
|
|
74
89
|
let normalizedCacheKey = normalizeCacheKey(cacheKey, 'useList')
|
|
75
90
|
let cachedResponse = ref<UseListResponse<T> | null>(null)
|
|
@@ -87,7 +102,7 @@ export function useList<T extends { name: string }>(
|
|
|
87
102
|
return data.result
|
|
88
103
|
}
|
|
89
104
|
}
|
|
90
|
-
return
|
|
105
|
+
return allData.value
|
|
91
106
|
})
|
|
92
107
|
const hasNextPage = computed(() => {
|
|
93
108
|
if (normalizedCacheKey && (out.loading || !out.isFinished)) {
|
|
@@ -119,23 +134,30 @@ export function useList<T extends { name: string }>(
|
|
|
119
134
|
type PartialDoc = Partial<T extends { name: string } ? T : { name: string }>
|
|
120
135
|
|
|
121
136
|
const updateRow = (doc: PartialDoc) => {
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
137
|
+
if (allData.value == null) return
|
|
138
|
+
let changed = false
|
|
139
|
+
for (let row of allData.value) {
|
|
140
|
+
if (doc.name && doc.name === row.name) {
|
|
141
|
+
for (let key in doc) {
|
|
142
|
+
if (key in row) {
|
|
143
|
+
row[key] = doc[key]
|
|
144
|
+
changed = true
|
|
131
145
|
}
|
|
132
|
-
break
|
|
133
146
|
}
|
|
147
|
+
break
|
|
134
148
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
149
|
+
}
|
|
150
|
+
if (changed) {
|
|
151
|
+
allData.value = [...allData.value]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const removeRow = (name: string) => {
|
|
156
|
+
if (allData.value == null) return
|
|
157
|
+
const index = allData.value.findIndex((row) => row.name === name)
|
|
158
|
+
if (index > -1) {
|
|
159
|
+
allData.value.splice(index, 1)
|
|
160
|
+
allData.value = [...allData.value]
|
|
139
161
|
}
|
|
140
162
|
}
|
|
141
163
|
|
|
@@ -149,6 +171,26 @@ export function useList<T extends { name: string }>(
|
|
|
149
171
|
},
|
|
150
172
|
})
|
|
151
173
|
|
|
174
|
+
const setValueUrl = ref(`/api/v2/document/${doctype}/<name>`)
|
|
175
|
+
|
|
176
|
+
const setValue = useCall<T, Partial<T>>({
|
|
177
|
+
url: setValueUrl,
|
|
178
|
+
method: 'PUT',
|
|
179
|
+
baseUrl,
|
|
180
|
+
immediate: false,
|
|
181
|
+
refetch: false,
|
|
182
|
+
beforeSubmit(params) {
|
|
183
|
+
if (params?.name) {
|
|
184
|
+
setValueUrl.value = `/api/v2/document/${doctype}/${params.name}`
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
onSuccess(data) {
|
|
188
|
+
docStore.setDoc({ doctype, ...data })
|
|
189
|
+
listStore.updateRow(doctype, data)
|
|
190
|
+
if (refetch) execute()
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
|
|
152
194
|
let deleteUrl = ref(`/api/v2/document/${doctype}/<name>`)
|
|
153
195
|
type DeleteResponse = 'ok'
|
|
154
196
|
type DeleteParams = { name: string }
|
|
@@ -167,6 +209,42 @@ export function useList<T extends { name: string }>(
|
|
|
167
209
|
},
|
|
168
210
|
})
|
|
169
211
|
|
|
212
|
+
function useEdit(name: MaybeRefOrGetter<string>) {
|
|
213
|
+
if (!allData.value) {
|
|
214
|
+
throw new Error('Data not found')
|
|
215
|
+
}
|
|
216
|
+
let row = allData.value.find((row) => row.name === toValue(name))
|
|
217
|
+
if (!row) {
|
|
218
|
+
throw new Error(`Couldn't find row with name ${toValue(name)}`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let originalRow = JSON.parse(JSON.stringify(row))
|
|
222
|
+
let doc = reactive(row)
|
|
223
|
+
|
|
224
|
+
const setValue = useCall<T, Partial<T>>({
|
|
225
|
+
url: `/api/v2/document/${doctype}/${toValue(name)}`,
|
|
226
|
+
method: 'PUT',
|
|
227
|
+
baseUrl,
|
|
228
|
+
immediate: false,
|
|
229
|
+
refetch: false,
|
|
230
|
+
onSuccess(data) {
|
|
231
|
+
docStore.setDoc({ doctype, ...data })
|
|
232
|
+
listStore.updateRow(doctype, data)
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
doc,
|
|
238
|
+
reset: () => {
|
|
239
|
+
for (let key in originalRow) {
|
|
240
|
+
doc[key] = originalRow[key]
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
setValue,
|
|
244
|
+
update: () => setValue.submit(doc),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
170
248
|
let out = reactive({
|
|
171
249
|
data: result,
|
|
172
250
|
hasNextPage,
|
|
@@ -179,7 +257,7 @@ export function useList<T extends { name: string }>(
|
|
|
179
257
|
isFinished,
|
|
180
258
|
canAbort,
|
|
181
259
|
aborted,
|
|
182
|
-
url,
|
|
260
|
+
url: _url,
|
|
183
261
|
abort,
|
|
184
262
|
next,
|
|
185
263
|
previous,
|
|
@@ -187,8 +265,11 @@ export function useList<T extends { name: string }>(
|
|
|
187
265
|
fetch: execute,
|
|
188
266
|
reload: execute,
|
|
189
267
|
updateRow,
|
|
268
|
+
removeRow,
|
|
190
269
|
insert,
|
|
270
|
+
setValue,
|
|
191
271
|
delete: delete_,
|
|
272
|
+
edit: useEdit,
|
|
192
273
|
})
|
|
193
274
|
|
|
194
275
|
listStore.addList(doctype, out)
|
|
@@ -196,19 +277,35 @@ export function useList<T extends { name: string }>(
|
|
|
196
277
|
return out
|
|
197
278
|
}
|
|
198
279
|
|
|
199
|
-
function handleAfterFetch<T>({
|
|
280
|
+
function handleAfterFetch<T extends { name: string }>({
|
|
200
281
|
transform,
|
|
201
282
|
onSuccess,
|
|
202
283
|
cacheKey,
|
|
203
|
-
|
|
284
|
+
allData,
|
|
285
|
+
_start,
|
|
286
|
+
}: UseListOptions<T> & {
|
|
287
|
+
allData: Ref<T[] | null>
|
|
288
|
+
_start: Ref<number>
|
|
289
|
+
}) {
|
|
204
290
|
return function (ctx: AfterFetchContext) {
|
|
291
|
+
let resultData = (ctx.data.result as T[]).map((item) => ({
|
|
292
|
+
...item,
|
|
293
|
+
name: String(item.name),
|
|
294
|
+
}))
|
|
205
295
|
if (transform) {
|
|
206
|
-
const returnValue = transform(
|
|
296
|
+
const returnValue = transform(resultData)
|
|
207
297
|
if (Array.isArray(returnValue)) {
|
|
208
|
-
|
|
298
|
+
resultData = returnValue
|
|
209
299
|
}
|
|
210
300
|
}
|
|
211
301
|
|
|
302
|
+
if (_start.value === 0) {
|
|
303
|
+
allData.value = resultData
|
|
304
|
+
} else {
|
|
305
|
+
allData.value = [...(allData.value || []), ...resultData]
|
|
306
|
+
}
|
|
307
|
+
ctx.data.result = allData.value
|
|
308
|
+
|
|
212
309
|
let normalizedCacheKey = normalizeCacheKey(cacheKey, 'useList')
|
|
213
310
|
if (normalizedCacheKey) {
|
|
214
311
|
idbStore.set(normalizedCacheKey, ctx.data)
|
|
@@ -216,7 +313,7 @@ function handleAfterFetch<T>({
|
|
|
216
313
|
|
|
217
314
|
if (onSuccess) {
|
|
218
315
|
try {
|
|
219
|
-
onSuccess(
|
|
316
|
+
onSuccess(allData.value)
|
|
220
317
|
} catch (e) {
|
|
221
318
|
console.error('Error in onSuccess hook:', e)
|
|
222
319
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MaybeRef, unref } from 'vue'
|
|
2
|
-
import {
|
|
2
|
+
import { Filters } from './useList/types'
|
|
3
3
|
|
|
4
4
|
export function makeGetParams(params: Record<string, any>) {
|
|
5
5
|
let url = new URLSearchParams()
|
|
@@ -16,8 +16,8 @@ export function isEmptyObject(obj: any) {
|
|
|
16
16
|
return Object.keys(obj).length === 0 && obj.constructor === Object
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function parseFilters(filters:
|
|
20
|
-
let parsedFilters:
|
|
19
|
+
export function parseFilters(filters: Filters): Filters | null {
|
|
20
|
+
let parsedFilters: Filters = {}
|
|
21
21
|
for (let key in filters) {
|
|
22
22
|
let value = filters[key]
|
|
23
23
|
if (Array.isArray(value)) {
|
package/src/index.js
CHANGED
|
@@ -32,7 +32,9 @@ export { default as Select } from './components/Select.vue'
|
|
|
32
32
|
export { default as Spinner } from './components/Spinner.vue'
|
|
33
33
|
export { default as Switch } from './components/Switch.vue'
|
|
34
34
|
export { default as TabButtons } from './components/TabButtons.vue'
|
|
35
|
-
export { default as Tabs } from './components/Tabs.vue'
|
|
35
|
+
export { default as Tabs } from './components/Tabs/Tabs.vue'
|
|
36
|
+
export { default as TabList } from './components/Tabs/TabList.vue'
|
|
37
|
+
export { default as TabPanel } from './components/Tabs/TabPanel.vue'
|
|
36
38
|
export { default as TextInput } from './components/TextInput.vue'
|
|
37
39
|
export { default as Textarea } from './components/Textarea.vue'
|
|
38
40
|
export {
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
## Props
|
|
2
|
-
|
|
3
|
-
### Tabs
|
|
4
|
-
|
|
5
|
-
It is an array of objects which contains the following attributes:
|
|
6
|
-
|
|
7
|
-
1. `label` is the name of the tab, it is required.
|
|
8
|
-
2. `icon` is the icon to be shown in the tab, it accept component and it is
|
|
9
|
-
optional.
|
|
10
|
-
3. You can add more attributes which can be used for custom rendering in the tab
|
|
11
|
-
header or content.
|
|
12
|
-
|
|
13
|
-
## v-model
|
|
14
|
-
|
|
15
|
-
It is used to set the active tab or change the active tab. It is required.
|
package/src/components/Tabs.vue
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<TabGroup
|
|
3
|
-
as="div"
|
|
4
|
-
class="flex flex-1 flex-col overflow-y-hidden"
|
|
5
|
-
:style="`height: calc(100vh - ${tabListRef?.$el.offsetTop}px)`"
|
|
6
|
-
:defaultIndex="changedIndex"
|
|
7
|
-
:selectedIndex="changedIndex"
|
|
8
|
-
@change="(idx) => (changedIndex = idx)"
|
|
9
|
-
>
|
|
10
|
-
<TabList
|
|
11
|
-
ref="tabListRef"
|
|
12
|
-
class="relative flex items-center gap-7.5 overflow-x-auto border-b px-5"
|
|
13
|
-
:class="tablistClass"
|
|
14
|
-
>
|
|
15
|
-
<Tab
|
|
16
|
-
ref="tabRef"
|
|
17
|
-
as="template"
|
|
18
|
-
v-for="(tab, i) in tabs"
|
|
19
|
-
:key="i"
|
|
20
|
-
v-slot="{ selected }"
|
|
21
|
-
class="focus:outline-none focus:transition-none"
|
|
22
|
-
>
|
|
23
|
-
<slot name="tab" v-bind="{ tab, selected }">
|
|
24
|
-
<button
|
|
25
|
-
class="flex items-center gap-1.5 border-b border-transparent py-3 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
|
|
26
|
-
:class="{ 'text-ink-gray-9': selected }"
|
|
27
|
-
>
|
|
28
|
-
<component v-if="tab.icon" :is="tab.icon" class="size-4" />
|
|
29
|
-
{{ tab.label }}
|
|
30
|
-
</button>
|
|
31
|
-
</slot>
|
|
32
|
-
</Tab>
|
|
33
|
-
<div
|
|
34
|
-
ref="indicator"
|
|
35
|
-
class="tab-indicator absolute bottom-0 h-px bg-surface-gray-7"
|
|
36
|
-
:class="transitionClass"
|
|
37
|
-
/>
|
|
38
|
-
</TabList>
|
|
39
|
-
<TabPanels class="flex flex-1 overflow-hidden" :class="tabPanelClass">
|
|
40
|
-
<TabPanel
|
|
41
|
-
class="flex flex-1 flex-col overflow-y-auto focus:outline-none"
|
|
42
|
-
v-for="(tab, i) in tabs"
|
|
43
|
-
:key="i"
|
|
44
|
-
>
|
|
45
|
-
<slot v-bind="{ tab }" />
|
|
46
|
-
</TabPanel>
|
|
47
|
-
</TabPanels>
|
|
48
|
-
</TabGroup>
|
|
49
|
-
</template>
|
|
50
|
-
|
|
51
|
-
<script setup>
|
|
52
|
-
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
|
|
53
|
-
import { ref, watch, computed, onMounted, nextTick } from 'vue'
|
|
54
|
-
|
|
55
|
-
const props = defineProps({
|
|
56
|
-
tabs: {
|
|
57
|
-
type: Array,
|
|
58
|
-
required: true,
|
|
59
|
-
},
|
|
60
|
-
modelValue: {
|
|
61
|
-
type: Number,
|
|
62
|
-
default: 0,
|
|
63
|
-
},
|
|
64
|
-
tablistClass: {
|
|
65
|
-
type: String,
|
|
66
|
-
default: '',
|
|
67
|
-
},
|
|
68
|
-
tabPanelClass: {
|
|
69
|
-
type: String,
|
|
70
|
-
default: '',
|
|
71
|
-
},
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
const emit = defineEmits(['update:modelValue'])
|
|
75
|
-
|
|
76
|
-
const changedIndex = computed({
|
|
77
|
-
get: () => props.modelValue,
|
|
78
|
-
set: (index) => emit('update:modelValue', index),
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
const tabListRef = ref(null)
|
|
82
|
-
const tabRef = ref([])
|
|
83
|
-
const indicator = ref(null)
|
|
84
|
-
const tabsLength = computed(() => props.tabs?.length)
|
|
85
|
-
|
|
86
|
-
const transitionClass = ref('')
|
|
87
|
-
|
|
88
|
-
function moveIndicator(index) {
|
|
89
|
-
if (index >= tabsLength.value) {
|
|
90
|
-
index = tabsLength.value - 1
|
|
91
|
-
}
|
|
92
|
-
const selectedTab = tabRef.value[index].el
|
|
93
|
-
indicator.value.style.width = `${selectedTab.offsetWidth}px`
|
|
94
|
-
indicator.value.style.left = `${selectedTab.offsetLeft}px`
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
watch(changedIndex, (index) => {
|
|
98
|
-
if (index >= tabsLength.value) {
|
|
99
|
-
changedIndex.value = tabsLength.value - 1
|
|
100
|
-
}
|
|
101
|
-
transitionClass.value = 'transition-all duration-300 ease-in-out'
|
|
102
|
-
nextTick(() => moveIndicator(index))
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
onMounted(() => {
|
|
106
|
-
nextTick(() => moveIndicator(changedIndex.value))
|
|
107
|
-
// Fix for indicator not moving on initial load
|
|
108
|
-
setTimeout(() => moveIndicator(changedIndex.value), 100)
|
|
109
|
-
})
|
|
110
|
-
</script>
|
|
File without changes
|