Tabs.vue 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. <template>
  2. <div
  3. class="flex flex-1 h-full flex-nowrap"
  4. :class="{ 'flex-col h-auto': !vertical }"
  5. >
  6. <div
  7. class="relative tabs hide-scrollbar"
  8. :class="[{ 'border-r border-dividerLight': vertical }, styles]"
  9. >
  10. <div class="flex flex-1">
  11. <div
  12. class="flex justify-between flex-1"
  13. :class="{ 'flex-col': vertical }"
  14. >
  15. <div class="flex" :class="{ 'flex-col space-y-2 p-2': vertical }">
  16. <button
  17. v-for="([tabID, tabMeta], index) in tabEntries"
  18. :key="`tab-${index}`"
  19. class="tab"
  20. :class="[{ active: value === tabID }, { vertical: vertical }]"
  21. :aria-label="tabMeta.label || ''"
  22. role="button"
  23. @keyup.enter="selectTab(tabID)"
  24. @click="selectTab(tabID)"
  25. >
  26. <SmartIcon
  27. v-if="tabMeta.icon"
  28. class="svg-icons"
  29. :name="tabMeta.icon"
  30. />
  31. <tippy
  32. v-if="vertical && tabMeta.label"
  33. placement="left"
  34. theme="tooltip"
  35. :content="tabMeta.label"
  36. />
  37. <span v-else-if="tabMeta.label">{{ tabMeta.label }}</span>
  38. <span
  39. v-if="tabMeta.info && tabMeta.info !== 'null'"
  40. class="tab-info"
  41. >
  42. {{ tabMeta.info }}
  43. </span>
  44. <span
  45. v-if="tabMeta.indicator"
  46. class="w-1 h-1 ml-2 rounded-full bg-accentLight"
  47. ></span>
  48. </button>
  49. </div>
  50. <div class="flex items-center justify-center">
  51. <slot name="actions"></slot>
  52. </div>
  53. </div>
  54. </div>
  55. </div>
  56. <div
  57. class="w-full h-full contents"
  58. :class="{
  59. '!flex flex-col flex-1 overflow-y-auto hide-scrollbar': vertical,
  60. }"
  61. >
  62. <slot></slot>
  63. </div>
  64. </div>
  65. </template>
  66. <script setup lang="ts">
  67. import { pipe } from "fp-ts/function"
  68. import { not } from "fp-ts/Predicate"
  69. import * as A from "fp-ts/Array"
  70. import * as O from "fp-ts/Option"
  71. import { ref, ComputedRef, computed, provide } from "@nuxtjs/composition-api"
  72. import { throwError } from "~/helpers/functional/error"
  73. export type TabMeta = {
  74. label: string | null
  75. icon: string | null
  76. indicator: boolean
  77. info: string | null
  78. }
  79. export type TabProvider = {
  80. activeTabID: ComputedRef<string>
  81. addTabEntry: (tabID: string, meta: TabMeta) => void
  82. updateTabEntry: (tabID: string, newMeta: TabMeta) => void
  83. removeTabEntry: (tabID: string) => void
  84. }
  85. const props = defineProps({
  86. styles: {
  87. type: String,
  88. default: "",
  89. },
  90. vertical: {
  91. type: Boolean,
  92. default: false,
  93. },
  94. value: {
  95. type: String,
  96. required: true,
  97. },
  98. })
  99. const emit = defineEmits<{
  100. (e: "input", newTabID: string): void
  101. }>()
  102. const tabEntries = ref<Array<[string, TabMeta]>>([])
  103. const addTabEntry = (tabID: string, meta: TabMeta) => {
  104. tabEntries.value = pipe(
  105. tabEntries.value,
  106. O.fromPredicate(not(A.exists(([id]) => id === tabID))),
  107. O.map(A.append([tabID, meta] as [string, TabMeta])),
  108. O.getOrElseW(() => throwError(`Tab with duplicate ID created: '${tabID}'`))
  109. )
  110. }
  111. const updateTabEntry = (tabID: string, newMeta: TabMeta) => {
  112. tabEntries.value = pipe(
  113. tabEntries.value,
  114. A.findIndex(([id]) => id === tabID),
  115. O.chain((index) =>
  116. pipe(
  117. tabEntries.value,
  118. A.updateAt(index, [tabID, newMeta] as [string, TabMeta])
  119. )
  120. ),
  121. O.getOrElseW(() => throwError(`Failed to update tab entry: ${tabID}`))
  122. )
  123. }
  124. const removeTabEntry = (tabID: string) => {
  125. tabEntries.value = pipe(
  126. tabEntries.value,
  127. A.findIndex(([id]) => id === tabID),
  128. O.chain((index) => pipe(tabEntries.value, A.deleteAt(index))),
  129. O.getOrElseW(() => throwError(`Failed to remove tab entry: ${tabID}`))
  130. )
  131. // If we tried to remove the active tabEntries, switch to first tab entry
  132. if (props.value === tabID)
  133. if (tabEntries.value.length > 0) selectTab(tabEntries.value[0][0])
  134. }
  135. provide<TabProvider>("tabs-system", {
  136. activeTabID: computed(() => props.value),
  137. addTabEntry,
  138. updateTabEntry,
  139. removeTabEntry,
  140. })
  141. const selectTab = (id: string) => {
  142. emit("input", id)
  143. }
  144. </script>
  145. <style scoped lang="scss">
  146. .tabs {
  147. @apply flex;
  148. @apply whitespace-nowrap;
  149. @apply overflow-auto;
  150. @apply flex-shrink-0;
  151. // &::after {
  152. // @apply absolute;
  153. // @apply inset-x-0;
  154. // @apply bottom-0;
  155. // @apply bg-dividerLight;
  156. // @apply z-1;
  157. // @apply h-0.5;
  158. // content: "";
  159. // }
  160. .tab {
  161. @apply relative;
  162. @apply flex;
  163. @apply flex-shrink-0;
  164. @apply items-center;
  165. @apply justify-center;
  166. @apply py-2 px-4;
  167. @apply text-secondary;
  168. @apply font-semibold;
  169. @apply cursor-pointer;
  170. @apply hover:text-secondaryDark;
  171. @apply focus:outline-none;
  172. @apply focus-visible:text-secondaryDark;
  173. .tab-info {
  174. @apply inline-flex;
  175. @apply items-center;
  176. @apply justify-center;
  177. @apply w-5;
  178. @apply h-4;
  179. @apply ml-2;
  180. @apply text-8px;
  181. @apply border border-divider;
  182. @apply rounded;
  183. @apply text-secondaryLight;
  184. }
  185. &::after {
  186. @apply absolute;
  187. @apply left-4;
  188. @apply right-4;
  189. @apply bottom-0;
  190. @apply bg-transparent;
  191. @apply z-2;
  192. @apply h-0.5;
  193. content: "";
  194. }
  195. &:focus::after {
  196. @apply bg-divider;
  197. }
  198. &.active {
  199. @apply text-secondaryDark;
  200. .tab-info {
  201. @apply text-secondary;
  202. @apply border-dividerDark;
  203. }
  204. &::after {
  205. @apply bg-accent;
  206. }
  207. }
  208. &.vertical {
  209. @apply p-2;
  210. @apply rounded;
  211. &:focus::after {
  212. @apply hidden;
  213. }
  214. &.active {
  215. @apply text-accent;
  216. .tab-info {
  217. @apply text-secondary;
  218. @apply border-dividerDark;
  219. }
  220. &::after {
  221. @apply hidden;
  222. }
  223. }
  224. }
  225. }
  226. }
  227. </style>