Tabs.vue 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  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. // Whether inactive tabs should remain rendered
  81. renderInactive: ComputedRef<boolean>
  82. activeTabID: ComputedRef<string>
  83. addTabEntry: (tabID: string, meta: TabMeta) => void
  84. updateTabEntry: (tabID: string, newMeta: TabMeta) => void
  85. removeTabEntry: (tabID: string) => void
  86. }
  87. const props = defineProps({
  88. styles: {
  89. type: String,
  90. default: "",
  91. },
  92. renderInactiveTabs: {
  93. type: Boolean,
  94. default: false,
  95. },
  96. vertical: {
  97. type: Boolean,
  98. default: false,
  99. },
  100. value: {
  101. type: String,
  102. required: true,
  103. },
  104. })
  105. const emit = defineEmits<{
  106. (e: "input", newTabID: string): void
  107. }>()
  108. const tabEntries = ref<Array<[string, TabMeta]>>([])
  109. const addTabEntry = (tabID: string, meta: TabMeta) => {
  110. tabEntries.value = pipe(
  111. tabEntries.value,
  112. O.fromPredicate(not(A.exists(([id]) => id === tabID))),
  113. O.map(A.append([tabID, meta] as [string, TabMeta])),
  114. O.getOrElseW(() => throwError(`Tab with duplicate ID created: '${tabID}'`))
  115. )
  116. }
  117. const updateTabEntry = (tabID: string, newMeta: TabMeta) => {
  118. tabEntries.value = pipe(
  119. tabEntries.value,
  120. A.findIndex(([id]) => id === tabID),
  121. O.chain((index) =>
  122. pipe(
  123. tabEntries.value,
  124. A.updateAt(index, [tabID, newMeta] as [string, TabMeta])
  125. )
  126. ),
  127. O.getOrElseW(() => throwError(`Failed to update tab entry: ${tabID}`))
  128. )
  129. }
  130. const removeTabEntry = (tabID: string) => {
  131. tabEntries.value = pipe(
  132. tabEntries.value,
  133. A.findIndex(([id]) => id === tabID),
  134. O.chain((index) => pipe(tabEntries.value, A.deleteAt(index))),
  135. O.getOrElseW(() => throwError(`Failed to remove tab entry: ${tabID}`))
  136. )
  137. // If we tried to remove the active tabEntries, switch to first tab entry
  138. if (props.value === tabID)
  139. if (tabEntries.value.length > 0) selectTab(tabEntries.value[0][0])
  140. }
  141. provide<TabProvider>("tabs-system", {
  142. renderInactive: computed(() => props.renderInactiveTabs),
  143. activeTabID: computed(() => props.value),
  144. addTabEntry,
  145. updateTabEntry,
  146. removeTabEntry,
  147. })
  148. const selectTab = (id: string) => {
  149. emit("input", id)
  150. }
  151. </script>
  152. <style scoped lang="scss">
  153. .tabs {
  154. @apply flex;
  155. @apply whitespace-nowrap;
  156. @apply overflow-auto;
  157. @apply flex-shrink-0;
  158. // &::after {
  159. // @apply absolute;
  160. // @apply inset-x-0;
  161. // @apply bottom-0;
  162. // @apply bg-dividerLight;
  163. // @apply z-1;
  164. // @apply h-0.5;
  165. // content: "";
  166. // }
  167. .tab {
  168. @apply relative;
  169. @apply flex;
  170. @apply flex-shrink-0;
  171. @apply items-center;
  172. @apply justify-center;
  173. @apply py-2 px-4;
  174. @apply text-secondary;
  175. @apply font-semibold;
  176. @apply cursor-pointer;
  177. @apply hover:text-secondaryDark;
  178. @apply focus:outline-none;
  179. @apply focus-visible:text-secondaryDark;
  180. .tab-info {
  181. @apply inline-flex;
  182. @apply items-center;
  183. @apply justify-center;
  184. @apply w-5;
  185. @apply h-4;
  186. @apply ml-2;
  187. @apply text-8px;
  188. @apply border border-divider;
  189. @apply rounded;
  190. @apply text-secondaryLight;
  191. }
  192. &::after {
  193. @apply absolute;
  194. @apply left-4;
  195. @apply right-4;
  196. @apply bottom-0;
  197. @apply bg-transparent;
  198. @apply z-2;
  199. @apply h-0.5;
  200. content: "";
  201. }
  202. &:focus::after {
  203. @apply bg-divider;
  204. }
  205. &.active {
  206. @apply text-secondaryDark;
  207. .tab-info {
  208. @apply text-secondary;
  209. @apply border-dividerDark;
  210. }
  211. &::after {
  212. @apply bg-accent;
  213. }
  214. }
  215. &.vertical {
  216. @apply p-2;
  217. @apply rounded;
  218. &:focus::after {
  219. @apply hidden;
  220. }
  221. &.active {
  222. @apply text-accent;
  223. .tab-info {
  224. @apply text-secondary;
  225. @apply border-dividerDark;
  226. }
  227. &::after {
  228. @apply hidden;
  229. }
  230. }
  231. }
  232. }
  233. }
  234. </style>