refactor: hoppscotch ui (#2887)

* feat: hopp ui initialized

* feat: button components added

* feat: windi css integration

* chore: package removed from hopp ui

* feat: storybook added

* feat: move all smart components hoppscotch-ui

* fix: import issue from components/smart

* fix: env input component import

* feat: add hoppui to windicss config

* fix: remove storybook

* feat: move components from hoppscotch-ui

* feat: storybook added

* feat: storybook progress

* feat: themeing storybook

* feat: add stories

* chore: package updated

* chore: stories added

* feat: stories added

* feat: stories added

* feat: icons resolved

* feat: i18n composable resolved

* feat: histoire added

* chore: resolved prettier issue

* feat: radio story added

* feat: story added for all components

* feat: new components added to stories

* fix: resolved issues

* feat: readme.md added

* feat: context/provider added

* chore: removed app component registry

* chore: remove importing of all components in hopp-ui to allow code splitting

* chore: fix vite config errors

* chore: jsdoc added

* chore: any replaced with smart-item

* chore: i18n added to ui components

* chore: clean up - removed a duplicate button

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Anwarul Islam
2023-01-28 08:57:00 +06:00
committed by GitHub
parent 9d7052c626
commit 0fcda0be1a
65 changed files with 2961 additions and 214 deletions

View File

@@ -50,9 +50,9 @@
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.d="documentation.$el.click()"
@keyup.s="shortcuts.$el.click()"
@keyup.c="chat.$el.click()"
@keyup.d="documentation!.$el.click()"
@keyup.s="shortcuts!.$el.click()"
@keyup.c="chat!.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
@@ -221,9 +221,9 @@ import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { currentUser$ } from "~/helpers/fb/auth"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { invokeAction } from "@helpers/actions"
import SmartItem from "@hoppscotch/ui/src/components/smart/Item.vue"
const t = useI18n()
const showDeveloperOptions = ref(false)
@@ -271,7 +271,7 @@ const showDeveloperOptionModal = () => {
// Template refs
const tippyActions = ref<TippyComponent | null>(null)
const documentation = ref<typeof SmartItem | null>(null)
const shortcuts = ref<typeof SmartItem | null>(null)
const chat = ref<typeof SmartItem | null>(null)
const documentation = ref<typeof SmartItem>()
const shortcuts = ref<typeof SmartItem>()
const chat = ref<typeof SmartItem>()
</script>

View File

@@ -1,101 +0,0 @@
<template>
<SmartLink
:to="to"
:blank="blank"
class="relative inline-flex items-center justify-center py-2 font-bold transition focus:outline-none focus-visible:bg-accentDark"
:class="[
color
? `text-${color}-800 bg-${color}-200 hover:(text-${color}-900 bg-${color}-300) focus-visible:(text-${color}-900 bg-${color}-300)`
: `text-accentContrast bg-accent hover:bg-accentDark focus-visible:bg-accentDark`,
label ? 'px-4' : 'px-2',
rounded ? 'rounded-full' : 'rounded',
{ 'opacity-75 cursor-not-allowed': disabled },
{ 'pointer-events-none': loading },
{ 'px-6 py-4 text-lg': large },
{ 'shadow-lg hover:shadow-xl': shadow },
{
'text-white bg-gradient-to-tr from-gradientFrom via-gradientVia to-gradientTo':
gradient,
},
{
'border border-accent hover:border-accentDark focus-visible:border-accentDark':
outline,
},
]"
:disabled="disabled"
:tabindex="loading ? '-1' : '0'"
role="button"
>
<span
class="inline-flex items-center justify-center whitespace-nowrap"
:class="[{ 'flex-row-reverse': reverse }, { 'opacity-50': loading }]"
>
<component
:is="icon"
v-if="icon"
class="svg-icons"
:class="[
{ '!text-2xl': large },
label ? (reverse ? 'ml-2' : 'mr-2') : '',
]"
/>
{{ label }}
<div v-if="shortcut.length" class="<sm:hidden">
<kbd
v-for="(key, index) in shortcut"
:key="`key-${index}`"
class="shortcut-key !bg-accentDark !border-accentLight"
>
{{ key }}
</kbd>
</div>
</span>
<span
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<SmartSpinner />
</span>
</SmartLink>
</template>
<script setup lang="ts">
import type { Component } from "vue"
interface Props {
to?: string
exact?: boolean
blank?: boolean
label?: string
icon?: object | null | Component // It is a component!
svg?: object | null | Component // It is a component!
color?: string
disabled?: boolean
loading?: boolean
large?: boolean
shadow?: boolean
reverse?: boolean
rounded?: boolean
gradient?: boolean
outline?: boolean
shortcut?: string[]
}
withDefaults(defineProps<Props>(), {
to: "",
exact: true,
blank: false,
label: "",
icon: null,
svg: null,
color: "",
disabled: false,
loading: false,
large: false,
shadow: false,
reverse: false,
rounded: false,
gradient: false,
outline: false,
shortcut: () => [],
})
</script>

View File

@@ -1,96 +0,0 @@
<template>
<SmartLink
:to="to"
:exact="exact"
:blank="blank"
class="inline-flex items-center justify-center py-2 font-semibold transition whitespace-nowrap focus:outline-none"
:class="[
color
? `text-${color}-500 hover:text-${color}-600 focus-visible:text-${color}-600`
: 'text-secondary hover:text-secondaryDark focus-visible:text-secondaryDark',
{ 'pointer-events-none': loading },
label ? 'rounded px-4' : 'px-2',
{ 'rounded-full': rounded },
{ 'opacity-75 cursor-not-allowed': disabled },
{ 'flex-row-reverse': reverse },
{ 'px-6 py-4 text-lg': large },
{
'border border-divider hover:border-dividerDark focus-visible:border-dividerDark':
outline,
},
{
'bg-primaryLight hover:bg-primaryDark focus-visible:bg-primaryDark':
filled,
},
]"
:disabled="disabled"
:tabindex="loading ? '-1' : '0'"
role="button"
>
<span
v-if="!loading"
class="inline-flex items-center justify-center whitespace-nowrap"
:class="{ 'flex-row-reverse': reverse }"
>
<component
:is="icon"
v-if="icon"
class="svg-icons"
:class="[
{ '!text-2xl': large },
label ? (reverse ? 'ml-2' : 'mr-2') : '',
]"
/>
{{ label }}
<div v-if="shortcut.length" class="<sm:hidden">
<kbd
v-for="(key, index) in shortcut"
:key="`key-${index}`"
class="shortcut-key !bg-inherit"
>
{{ key }}
</kbd>
</div>
</span>
<SmartSpinner v-else />
</SmartLink>
</template>
<script setup lang="ts">
import type { Component } from "vue"
interface Props {
to?: string
exact?: boolean
blank?: boolean
label?: string
icon?: object | null | Component // It is a component!
svg?: object | null | Component // It is a component!
color?: string
disabled?: boolean
loading?: boolean
reverse?: boolean
rounded?: boolean
large?: boolean
outline?: boolean
shortcut?: string[]
filled?: boolean
}
withDefaults(defineProps<Props>(), {
to: "",
exact: true,
blank: false,
label: "",
icon: null,
svg: null,
color: "",
disabled: false,
loading: false,
reverse: false,
rounded: false,
large: false,
outline: false,
shortcut: () => [],
filled: false,
})
</script>

View File

@@ -122,7 +122,7 @@ import {
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
import SmartItem from "@hoppscotch/ui/src/components/smart/Item.vue"
const t = useI18n()
const toast = useToast()
@@ -140,9 +140,9 @@ const confirmRemove = ref(false)
const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const edit = ref<typeof SmartItem | null>(null)
const duplicate = ref<typeof SmartItem | null>(null)
const deleteAction = ref<typeof SmartItem | null>(null)
const edit = ref<typeof SmartItem>()
const duplicate = ref<typeof SmartItem>()
const deleteAction = ref<typeof SmartItem>()
const removeEnvironment = () => {
if (props.environmentIndex === null) return

View File

@@ -108,7 +108,7 @@ import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import IconMoreVertical from "~icons/lucide/more-vertical"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
import SmartItem from "@hoppscotch/ui/src/components/smart/Item.vue"
const t = useI18n()
const toast = useToast()
@@ -126,9 +126,9 @@ const confirmRemove = ref(false)
const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const edit = ref<typeof SmartItem | null>(null)
const duplicate = ref<typeof SmartItem | null>(null)
const deleteAction = ref<typeof SmartItem | null>(null)
const edit = ref<typeof SmartItem>()
const duplicate = ref<typeof SmartItem>()
const deleteAction = ref<typeof SmartItem>()
const removeEnvironment = () => {
pipe(

View File

@@ -1,70 +0,0 @@
<template>
<SmartLink
:to="to"
:exact="exact"
:blank="blank"
class="inline-flex items-center justify-center focus:outline-none"
:class="[
color
? `text-${color}-500 hover:text-${color}-600 focus-visible:text-${color}-600`
: 'hover:text-secondaryDark focus-visible:text-secondaryDark',
{ 'opacity-75 cursor-not-allowed': disabled },
{ 'flex-row-reverse': reverse },
]"
:disabled="disabled"
tabindex="0"
>
<component
:is="icon"
v-if="icon"
class="svg-icons"
:class="label ? (reverse ? 'ml-2' : 'mr-2') : ''"
/>
{{ label }}
</SmartLink>
</template>
<script lang="ts">
import { Component, defineComponent, PropType } from "vue"
export default defineComponent({
props: {
to: {
type: String,
default: "",
},
exact: {
type: Boolean,
default: true,
},
blank: {
type: Boolean,
default: false,
},
label: {
type: String,
default: "",
},
icon: {
type: Object as PropType<Component | null>,
default: null,
},
svg: {
type: Object as PropType<Component | null>,
default: null,
},
color: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
},
})
</script>

View File

@@ -1,245 +0,0 @@
<template>
<div class="autocomplete-wrapper">
<input
ref="acInput"
:value="text"
type="text"
autocomplete="off"
:placeholder="placeholder"
:spellcheck="spellcheck"
:autocapitalize="autocapitalize"
:class="styles"
@input.stop="
(e) => {
$emit('input', e.target.value)
updateSuggestions(e)
}
"
@keyup="updateSuggestions"
@click="updateSuggestions"
@keydown="handleKeystroke"
@change="$emit('change', $event)"
/>
<ul
v-if="suggestions.length > 0 && suggestionsVisible"
class="suggestions"
:style="{ transform: `translate(${suggestionsOffsetLeft}px, 0)` }"
>
<li
v-for="(suggestion, index) in suggestions"
:key="`suggestion-${index}`"
:class="{ active: currentSuggestionIndex === index }"
@click.prevent="forceSuggestion(suggestion)"
>
{{ suggestion }}
</li>
</ul>
</div>
</template>
<script>
import { defineComponent } from "vue"
export default defineComponent({
props: {
spellcheck: {
type: Boolean,
default: true,
required: false,
},
autocapitalize: {
type: String,
default: "off",
required: false,
},
placeholder: {
type: String,
default: "",
required: false,
},
source: {
type: Array,
required: true,
},
value: {
type: String,
default: "",
required: false,
},
styles: {
type: String,
default: "",
},
},
emits: ["input", "change"],
data() {
return {
text: this.value,
selectionStart: 0,
suggestionsOffsetLeft: 0,
currentSuggestionIndex: -1,
suggestionsVisible: false,
}
},
computed: {
/**
* Gets the suggestions list to be displayed under the input box.
*
* @returns {default.props.source|{type, required}}
*/
suggestions() {
const input = this.text.substring(0, this.selectionStart)
return (
this.source
.filter(
(entry) =>
entry.toLowerCase().startsWith(input.toLowerCase()) &&
input.toLowerCase() !== entry.toLowerCase()
)
// Cut off the part that's already been typed.
.map((entry) => entry.substring(this.selectionStart))
// We only want the top 10 suggestions.
.slice(0, 10)
)
},
},
watch: {
value(newValue) {
this.text = newValue
},
},
mounted() {
this.updateSuggestions({
target: this.$refs.acInput,
})
},
methods: {
updateSuggestions(event) {
// Hide suggestions if ESC pressed.
if (event.code && event.code === "Escape") {
event.preventDefault()
this.suggestionsVisible = false
this.currentSuggestionIndex = -1
return
}
// As suggestions is a reactive property, this implicitly
// causes suggestions to update.
this.selectionStart = this.$refs.acInput.selectionStart
this.suggestionsOffsetLeft = 12 * this.selectionStart
this.suggestionsVisible = true
},
forceSuggestion(text) {
const input = this.text.substring(0, this.selectionStart)
this.text = input + text
this.selectionStart = this.text.length
this.suggestionsVisible = true
this.currentSuggestionIndex = -1
this.$emit("input", this.text)
},
handleKeystroke(event) {
switch (event.code) {
case "Enter":
event.preventDefault()
if (this.currentSuggestionIndex > -1)
this.forceSuggestion(
this.suggestions.find(
(_item, index) => index === this.currentSuggestionIndex
)
)
break
case "ArrowUp":
event.preventDefault()
this.currentSuggestionIndex =
this.currentSuggestionIndex - 1 >= 0
? this.currentSuggestionIndex - 1
: 0
break
case "ArrowDown":
event.preventDefault()
this.currentSuggestionIndex =
this.currentSuggestionIndex < this.suggestions.length - 1
? this.currentSuggestionIndex + 1
: this.suggestions.length - 1
break
case "Tab": {
const activeSuggestion =
this.suggestions[
this.currentSuggestionIndex >= 0 ? this.currentSuggestionIndex : 0
]
if (!activeSuggestion) {
return
}
event.preventDefault()
const input = this.text.substring(0, this.selectionStart)
this.text = input + activeSuggestion
break
}
}
},
},
})
</script>
<style lang="scss" scoped>
.autocomplete-wrapper {
@apply relative;
@apply contents;
input:focus + ul.suggestions,
ul.suggestions:hover {
@apply block;
}
ul.suggestions {
@apply hidden;
@apply bg-popover;
@apply absolute;
@apply mx-2;
@apply left-0;
@apply z-50;
@apply shadow-lg;
@apply max-h-46;
@apply overflow-y-auto;
top: calc(100% - 4px);
border-radius: 0 0 8px 8px;
li {
@apply w-full;
@apply block;
@apply py-2 px-4;
@apply text-secondary;
&:last-child {
border-radius: 0 0 8px 8px;
}
&:hover,
&.active {
@apply bg-accentDark;
@apply text-accentContrast;
@apply cursor-pointer;
}
}
}
}
</style>

View File

@@ -1,70 +0,0 @@
<template>
<div
class="inline-flex items-center justify-center transition cursor-pointer flex-nowrap group hover:text-secondaryDark"
role="checkbox"
:aria-checked="on"
@click="emit('change')"
>
<input
id="checkbox"
type="checkbox"
name="checkbox"
class="checkbox"
:checked="on"
@change="emit('change')"
/>
<label
for="checkbox"
class="pl-0 font-semibold truncate align-middle cursor-pointer"
>
<slot></slot>
</label>
</div>
</template>
<script setup lang="ts">
defineProps({
on: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: "change"): void
}>()
</script>
<style lang="scss" scoped>
.checkbox[type="checkbox"] {
@apply appearance-none;
@apply hidden;
& + label {
@apply inline-flex items-center justify-center;
@apply cursor-pointer;
&::before {
@apply border-2 border-divider;
@apply rounded;
@apply group-hover: border-accentDark;
@apply inline-flex;
@apply items-center;
@apply justify-center;
@apply text-transparent;
@apply h-4;
@apply w-4;
@apply font-icon;
@apply mr-2;
@apply transition;
@apply content-["\e876"];
}
}
&:checked + label::before {
@apply bg-accent;
@apply border-accent;
@apply text-accentContrast;
}
}
</style>

View File

@@ -1,69 +0,0 @@
<template>
<SmartModal
v-if="show"
dialog
:title="t('modal.confirm')"
role="dialog"
aria-modal="true"
@close="hideModal"
>
<template #body>
<div class="flex flex-col items-center justify-center">
{{ title }}
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary
v-focus
:label="yes ?? t('action.yes')"
:loading="!!loadingState"
outline
@click="resolve"
/>
<ButtonSecondary
:label="no ?? t('action.no')"
filled
outline
@click="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
const t = useI18n()
const props = withDefaults(
defineProps<{
show: boolean
title?: string | null
yes?: string | null
no?: string | null
loadingState?: boolean | null
}>(),
{
title: null,
yes: null,
no: null,
loadingState: null,
}
)
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "resolve", title: string | null): void
}>()
const hideModal = () => {
emit("hide-modal")
}
const resolve = () => {
emit("resolve", props.title)
if (props.loadingState === null) emit("hide-modal")
}
</script>

View File

@@ -1,30 +0,0 @@
<template>
<div
class="relative flex flex-col space-y-2 overflow-hidden"
:class="expand ? 'h-full' : 'max-h-32'"
>
<slot name="body"></slot>
<div
class="sticky inset-x-0 bottom-0 flex items-center justify-center flex-shrink-0 overflow-x-auto"
>
<ButtonSecondary
:icon="expand ? IconChevronUp : IconChevronDown"
:label="expand ? t('action.less') : t('action.more')"
filled
rounded
@click="expand = !expand"
/>
</div>
</div>
</template>
<script setup lang="ts">
import IconChevronUp from "~icons/lucide/chevron-up"
import IconChevronDown from "~icons/lucide/chevron-down"
import { ref } from "vue"
import { useI18n } from "@composables/i18n"
const t = useI18n()
const expand = ref(false)
</script>

View File

@@ -1,22 +0,0 @@
<template>
<span class="chip">
<component :is="IconFile" class="opacity-75 svg-icons" />
<span class="px-2 truncate max-w-32"><slot></slot></span>
</span>
</template>
<script setup lang="ts">
import IconFile from "~icons/lucide/file"
</script>
<style lang="scss" scoped>
.chip {
@apply inline-flex;
@apply items-center;
@apply justify-center;
@apply rounded;
@apply pl-2;
@apply pr-0.5;
@apply bg-primaryDark;
}
</style>

View File

@@ -1,38 +0,0 @@
<template>
<div ref="container">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue"
/*
Implements a wrapper listening to viewport intersections via
IntersectionObserver API
Events
------
intersecting (entry: IntersectionObserverEntry) -> When the component is intersecting the viewport
*/
const observer = ref<IntersectionObserver>()
const container = ref<Element>()
const emit = defineEmits<{
(e: "intersecting", entry: IntersectionObserverEntry): void
}>()
onMounted(() => {
observer.value = new IntersectionObserver(([entry]) => {
if (entry && entry.isIntersecting) {
emit("intersecting", entry)
}
})
observer.value.observe(container.value!)
})
onBeforeUnmount(() => {
observer.value!.disconnect()
})
</script>

View File

@@ -1,140 +0,0 @@
<template>
<SmartLink
:to="to"
:exact="exact"
:blank="blank"
class="inline-flex items-center flex-shrink-0 px-4 py-2 rounded transition hover:bg-primaryDark hover:text-secondaryDark focus:outline-none focus-visible:bg-primaryDark focus-visible:text-secondaryDark"
:class="[
{ 'opacity-75 cursor-not-allowed': disabled },
{ 'pointer-events-none': loading },
{ 'flex-1': label },
{ 'flex-row-reverse justify-end': reverse },
{
'border border-divider hover:border-dividerDark focus-visible:border-dividerDark':
outline,
},
]"
:disabled="disabled"
:tabindex="loading ? '-1' : '0'"
role="menuitem"
>
<span
v-if="!loading"
class="inline-flex items-center"
:class="{ 'self-start': !!infoIcon }"
>
<component
:is="icon"
v-if="icon"
class="opacity-75 svg-icons"
:class="[
label ? (reverse ? 'ml-4' : 'mr-4') : '',
{ 'text-accent': active },
]"
/>
</span>
<SmartSpinner v-else class="mr-4 text-secondaryDark" />
<div
class="inline-flex items-start flex-1 truncate"
:class="{ 'flex-col': description }"
>
<div class="font-semibold truncate">
{{ label }}
</div>
<p v-if="description" class="my-2 text-left text-secondaryLight">
{{ description }}
</p>
</div>
<component
:is="infoIcon"
v-if="infoIcon"
class="items-center self-center ml-4 svg-icons"
:class="{ 'text-accent': activeInfoIcon }"
/>
<div v-if="shortcut.length" class="ml-4 <sm:hidden font-medium">
<kbd
v-for="(key, index) in shortcut"
:key="`key-${index}`"
class="-mr-2 shortcut-key"
>
{{ key }}
</kbd>
</div>
</SmartLink>
</template>
<script setup lang="ts">
defineProps({
to: {
type: String,
default: "",
},
exact: {
type: Boolean,
default: true,
},
blank: {
type: Boolean,
default: false,
},
label: {
type: String,
default: "",
},
description: {
type: String,
default: "",
},
/**
* This will be a component!
*/
icon: {
type: Object,
default: null,
},
/**
* This will be a component!
*/
svg: {
type: Object,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
shortcut: {
type: Array,
default: () => [],
},
active: {
type: Boolean,
default: false,
},
activeInfoIcon: {
type: Boolean,
default: false,
},
/**
* This will be a component!
*/
infoIcon: {
type: Object,
default: null,
},
})
</script>

View File

@@ -1,90 +0,0 @@
<template>
<button
v-if="renderedTag === 'BUTTON'"
aria-label="button"
role="button"
v-bind="$attrs"
>
<slot></slot>
</button>
<a
v-else-if="renderedTag === 'ANCHOR' && !blank"
aria-label="Link"
:href="to"
role="link"
v-bind="updatedAttrs"
>
<slot></slot>
</a>
<a
v-else-if="renderedTag === 'ANCHOR' && blank"
aria-label="Link"
:href="to"
role="link"
target="_blank"
rel="noopener"
v-bind="updatedAttrs"
>
<slot></slot>
</a>
<RouterLink v-else :to="to" v-bind="updatedAttrs">
<slot></slot>
</RouterLink>
</template>
<script lang="ts">
/**
* for preventing the automatic binding of $attrs.
* we are manually binding $attrs or updatedAttrs.
* if this is not set to false, along with manually binded updatedAttrs, it will also bind $attrs.
*/
export default {
inheritAttrs: false,
}
</script>
<script setup lang="ts">
import { computed, useAttrs } from "vue"
import { RouterLink } from "vue-router"
import { omit } from "lodash-es"
const props = defineProps({
to: {
type: String,
default: "",
},
blank: {
type: Boolean,
default: false,
},
})
const renderedTag = computed(() => {
if (!props.to) {
return "BUTTON" as const
} else if (props.blank) {
return "ANCHOR" as const
} else if (/^\/(?!\/).*$/.test(props.to)) {
// regex101.com/r/LU1iFL/1
return "FRAMEWORK" as const
} else {
return "ANCHOR" as const
}
})
const $attrs = useAttrs()
/**
* tippy checks if the disabled attribute exists on the anchor tag, if it exists it won't show the tooltip.
* and when directly binding the disabled attribute using v-bind="attrs",
* vue renders the disabled attribute as disabled="false" ("false" being a string),
* which causes tippy to think the disabled attribute is present, ( it does a targetElement.hasAttribute("disabled") check ) and it won't show the tooltip.
*
* here we are just omiting disabled if it is false.
*/
const updatedAttrs = computed(() =>
renderedTag.value === "ANCHOR" && !$attrs.disabled
? omit($attrs, "disabled")
: $attrs
)
</script>

View File

@@ -1,210 +0,0 @@
<template>
<Transition name="fade" appear @leave="onTransitionLeaveStart">
<div
ref="modal"
class="fixed inset-0 z-50 overflow-y-auto transition"
role="dialog"
>
<div
class="flex items-end justify-center min-h-screen text-center sm:block"
>
<Transition name="fade" appear>
<div
class="fixed inset-0 transition-opacity"
@touchstart="!dialog ? close() : null"
@touchend="!dialog ? close() : null"
@mouseup="!dialog ? close() : null"
@mousedown="!dialog ? close() : null"
>
<div
class="absolute inset-0 opacity-80 bg-primaryLight focus:outline-none"
tabindex="0"
@click="!dialog ? close() : null"
></div>
</div>
</Transition>
<span
v-if="placement === 'center'"
class="sm:h-screen <sm:hidden sm:align-middle"
aria-hidden="true"
>&#8203;</span
>
<Transition name="bounce" appear>
<div
class="inline-block w-full overflow-hidden text-left align-bottom transition-all transform shadow-lg sm:border border-dividerDark bg-primary sm:rounded-xl sm:align-middle"
:class="[{ 'mt-24 md:mb-8': placement === 'top' }, styles]"
>
<div
v-if="title"
class="flex items-center justify-between border-b border-dividerLight"
:class="{ 'p-4': !fullWidth }"
>
<h3 class="heading" :class="{ 'ml-4': !fullWidth }">
{{ title }}
</h3>
<span class="flex items-center">
<slot name="actions"></slot>
<kbd class="mr-2 shortcut-key">ESC</kbd>
<ButtonSecondary
v-if="dimissible"
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
:title="t('action.close')"
:icon="IconX"
@click="close"
/>
</span>
</div>
<div
class="flex flex-col overflow-y-auto max-h-lg"
:class="{ 'p-4': !fullWidth }"
>
<slot name="body"></slot>
</div>
<div
v-if="hasFooterSlot"
class="flex items-center justify-between flex-1 border-t border-dividerLight bg-primaryContrast"
:class="{ 'p-4': !fullWidth }"
>
<slot name="footer"></slot>
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</template>
<script lang="ts">
const PORTAL_DOM_ID = "hoppscotch-modal-portal"
// An ID ticker for generating consistently unique modal IDs
let stackIDTicker = 0
// Why ?
const stack = (() => {
const stack: number[] = []
return {
push: stack.push.bind(stack),
pop: stack.pop.bind(stack),
peek: () => (stack.length === 0 ? undefined : stack[stack.length - 1]),
}
})()
</script>
<script setup lang="ts">
import IconX from "~icons/lucide/x"
import { ref, computed, useSlots, onMounted, onBeforeUnmount } from "vue"
import { useKeybindingDisabler } from "~/helpers/keybindings"
import { useI18n } from "@composables/i18n"
const t = useI18n()
defineProps({
dialog: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "",
},
dimissible: {
type: Boolean,
default: true,
},
placement: {
type: String,
default: "top",
},
fullWidth: {
type: Boolean,
default: false,
},
styles: {
type: String,
default: "sm:max-w-lg",
},
})
const emit = defineEmits<{
(e: "close"): void
}>()
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler()
onBeforeUnmount(() => {
enableKeybindings()
})
const stackId = ref(stackIDTicker++)
const shouldCleanupDomOnUnmount = ref(true)
const slots = useSlots()
const hasFooterSlot = computed(() => {
return !!slots.footer
})
const modal = ref<Element>()
onMounted(() => {
const portal = getPortal()
portal.appendChild(modal.value!)
stack.push(stackId.value)
document.addEventListener("keydown", onKeyDown)
disableKeybindings()
})
onBeforeUnmount(() => {
if (shouldCleanupDomOnUnmount.value && modal.value) {
getPortal().removeChild(modal.value)
}
stack.pop()
document.removeEventListener("keydown", onKeyDown)
})
const close = () => {
emit("close")
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && stackId.value === stack.peek()) {
e.preventDefault()
close()
}
}
const onTransitionLeaveStart = () => {
close()
shouldCleanupDomOnUnmount.value = false
}
const getPortal = () => {
let el = document.querySelector("#" + PORTAL_DOM_ID)
if (el) {
return el
}
el = document.createElement("DIV")
el.id = PORTAL_DOM_ID
document.body.appendChild(el)
return el
}
</script>
<style lang="scss" scoped>
.bounce-enter-active {
@apply transition;
animation: bounce-in 150ms;
}
@keyframes bounce-in {
0% {
transform: scale(0.95);
}
100% {
transform: scale(1);
}
}
</style>

View File

@@ -1,57 +0,0 @@
<template>
<svg :height="radius * 2" :width="radius * 2">
<circle
:stroke-width="stroke"
class="stroke-green-500"
fill="transparent"
:r="normalizedRadius"
:cx="radius"
:cy="radius"
/>
<circle
:stroke-width="stroke"
stroke="currentColor"
fill="transparent"
:r="normalizedRadius"
:cx="radius"
:cy="radius"
:style="{ strokeDashoffset: strokeDashoffset }"
:stroke-dasharray="circumference + ' ' + circumference"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue"
export default defineComponent({
props: {
radius: {
type: Number,
default: 12,
},
progress: {
type: Number,
default: 50,
},
stroke: {
type: Number,
default: 4,
},
},
data() {
const normalizedRadius = this.radius - this.stroke * 2
const circumference = normalizedRadius * 2 * Math.PI
return {
normalizedRadius,
circumference,
}
},
computed: {
strokeDashoffset() {
return this.circumference - (this.progress / 100) * this.circumference
},
},
})
</script>

View File

@@ -1,34 +0,0 @@
<template>
<SmartItem
:label="label"
:icon="selected ? IconCircleDot : IconCircle"
:active="selected"
role="radio"
:aria-checked="selected"
@click="emit('change', value)"
/>
</template>
<script setup lang="ts">
import IconCircleDot from "~icons/lucide/circle-dot"
import IconCircle from "~icons/lucide/circle"
defineProps({
value: {
type: String,
default: "",
},
label: {
type: String,
default: "",
},
selected: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: "change", value: string): void
}>()
</script>

View File

@@ -1,26 +0,0 @@
<template>
<div class="flex flex-col">
<SmartRadio
v-for="(radio, index) in radios"
:key="`radio-${index}`"
:value="radio.value"
:label="radio.label"
:selected="modelValue === radio.value"
@change="emit('update:modelValue', radio.value)"
/>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: "update:modelValue", value: string): void
}>()
defineProps<{
radios: Array<{
value: string // The key of the radio option
label: string
}>
modelValue: string // Should be a radio key given in the radios array
}>()
</script>

View File

@@ -1,66 +0,0 @@
<template>
<div>
<Transition name="fade" appear>
<div
v-if="show"
class="fixed inset-0 z-20 transition-opacity"
@keydown.esc="close()"
>
<div
class="absolute inset-0 bg-primaryLight opacity-90 focus:outline-none"
tabindex="0"
@click="close()"
></div>
</div>
</Transition>
<Transition name="slide" appear>
<aside
v-if="show"
class="fixed top-0 right-0 z-30 flex flex-col h-full max-w-full overflow-auto border-l shadow-xl border-dividerDark bg-primary w-96"
>
<div
class="flex items-center justify-between p-2 border-b border-dividerLight"
>
<h3 class="ml-4 heading">{{ title }}</h3>
<span class="flex items-center">
<kbd class="mr-2 shortcut-key">ESC</kbd>
<ButtonSecondary :icon="IconX" @click="close()" />
</span>
</div>
<slot name="content"></slot>
</aside>
</Transition>
</div>
</template>
<script setup lang="ts">
import { onMounted, watch } from "vue"
import IconX from "~icons/lucide/x"
const props = defineProps<{
show: boolean
title: string
}>()
const emit = defineEmits<{
(e: "close"): void
}>()
watch(
() => props.show,
(show) => {
if (show) document.body.style.setProperty("overflow", "hidden")
else document.body.style.removeProperty("overflow")
}
)
onMounted(() => {
document.addEventListener("keydown", (e) => {
if (e.keyCode === 27 && props.show) close()
})
})
const close = () => {
emit("close")
}
</script>

View File

@@ -1,3 +0,0 @@
<template>
<icon-lucide-loader class="animate-spin svg-icons" />
</template>

View File

@@ -1,78 +0,0 @@
<template>
<div v-if="shouldRender" v-show="active" class="flex flex-col flex-1">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import {
onMounted,
onBeforeUnmount,
inject,
computed,
watch,
Component,
markRaw,
} from "vue"
import { TabMeta, TabProvider } from "./Tabs.vue"
const props = withDefaults(
defineProps<{
id: string
label: string
icon?: Component | object | string | null
info?: string | null
indicator?: boolean
disabled?: boolean
}>(),
{
icon: null,
indicator: false,
info: null,
disabled: false,
}
)
const tabMeta = computed<TabMeta>(() => ({
// props.icon can store a component, which should not be made deeply reactive
icon:
props.icon && typeof props.icon === "object"
? markRaw(props.icon)
: props.icon,
indicator: props.indicator,
info: props.info,
label: props.label,
disabled: props.disabled,
}))
const {
activeTabID,
renderInactive,
addTabEntry,
updateTabEntry,
removeTabEntry,
} = inject<TabProvider>("tabs-system")!
const active = computed(() => activeTabID.value === props.id)
const shouldRender = computed(() => {
// If render inactive is true, then it should be rendered nonetheless
if (renderInactive.value) return true
// Else, return whatever is the active state
return active.value
})
onMounted(() => {
addTabEntry(props.id, tabMeta.value)
})
watch(tabMeta, (newMeta) => {
updateTabEntry(props.id, newMeta)
})
onBeforeUnmount(() => {
removeTabEntry(props.id)
})
</script>

View File

@@ -1,248 +0,0 @@
<template>
<div
class="flex flex-1 h-full flex-nowrap"
:class="{ 'flex-col h-auto': !vertical }"
>
<div
class="relative tabs border-dividerLight"
:class="[vertical ? 'border-r' : 'border-b', styles]"
>
<div class="flex flex-1">
<div
class="flex justify-between flex-1"
:class="{ 'flex-col': vertical }"
>
<div class="flex" :class="{ 'flex-col space-y-2 p-2': vertical }">
<button
v-for="([tabID, tabMeta], index) in tabEntries"
:key="`tab-${index}`"
v-tippy="{
theme: 'tooltip',
placement: 'left',
content: vertical ? tabMeta.label : null,
}"
class="tab"
:class="[
{ active: modelValue === tabID },
{ vertical: vertical },
{ 'opacity-75 !cursor-not-allowed': tabMeta.disabled },
]"
:aria-label="tabMeta.label || ''"
:disabled="tabMeta.disabled"
role="button"
@keyup.enter="selectTab(tabID)"
@click="selectTab(tabID)"
>
<component
:is="tabMeta.icon"
v-if="tabMeta.icon"
class="svg-icons"
/>
<span v-else-if="tabMeta.label">{{ tabMeta.label }}</span>
<span
v-if="tabMeta.info && tabMeta.info !== 'null'"
class="tab-info"
>
{{ tabMeta.info }}
</span>
<span
v-if="tabMeta.indicator"
class="w-1 h-1 ml-2 rounded-full bg-accentLight"
></span>
</button>
</div>
<div class="flex items-center justify-center">
<slot name="actions"></slot>
</div>
</div>
</div>
</div>
<div
class="w-full h-full contents"
:class="[
{
'!flex flex-col flex-1 overflow-y-auto': vertical,
},
contentStyles,
]"
>
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { pipe } from "fp-ts/function"
import { not } from "fp-ts/Predicate"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import type { Component } from "vue"
import { ref, ComputedRef, computed, provide } from "vue"
import { throwError } from "~/helpers/functional/error"
export type TabMeta = {
label: string | null
icon: string | Component | null
indicator: boolean
info: string | null
disabled: boolean
}
export type TabProvider = {
// Whether inactive tabs should remain rendered
renderInactive: ComputedRef<boolean>
activeTabID: ComputedRef<string>
addTabEntry: (tabID: string, meta: TabMeta) => void
updateTabEntry: (tabID: string, newMeta: TabMeta) => void
removeTabEntry: (tabID: string) => void
}
const props = defineProps({
styles: {
type: String,
default: "",
},
renderInactiveTabs: {
type: Boolean,
default: false,
},
vertical: {
type: Boolean,
default: false,
},
modelValue: {
type: String,
required: true,
},
contentStyles: {
type: String,
default: "",
},
})
const emit = defineEmits<{
(e: "update:modelValue", newTabID: string): void
}>()
const tabEntries = ref<Array<[string, TabMeta]>>([])
const addTabEntry = (tabID: string, meta: TabMeta) => {
tabEntries.value = pipe(
tabEntries.value,
O.fromPredicate(not(A.exists(([id]) => id === tabID))),
O.map(A.append([tabID, meta] as [string, TabMeta])),
O.getOrElseW(() => throwError(`Tab with duplicate ID created: '${tabID}'`))
)
}
const updateTabEntry = (tabID: string, newMeta: TabMeta) => {
tabEntries.value = pipe(
tabEntries.value,
A.findIndex(([id]) => id === tabID),
O.chain((index) =>
pipe(
tabEntries.value,
A.updateAt(index, [tabID, newMeta] as [string, TabMeta])
)
),
O.getOrElseW(() => throwError(`Failed to update tab entry: ${tabID}`))
)
}
const removeTabEntry = (tabID: string) => {
tabEntries.value = pipe(
tabEntries.value,
A.findIndex(([id]) => id === tabID),
O.chain((index) => pipe(tabEntries.value, A.deleteAt(index))),
O.getOrElseW(() => throwError(`Failed to remove tab entry: ${tabID}`))
)
// If we tried to remove the active tabEntries, switch to first tab entry
if (props.modelValue === tabID)
if (tabEntries.value.length > 0) selectTab(tabEntries.value[0][0])
}
provide<TabProvider>("tabs-system", {
renderInactive: computed(() => props.renderInactiveTabs),
activeTabID: computed(() => props.modelValue),
addTabEntry,
updateTabEntry,
removeTabEntry,
})
const selectTab = (id: string) => {
emit("update:modelValue", id)
}
</script>
<style lang="scss" scoped>
.tabs {
@apply flex;
@apply whitespace-nowrap;
@apply overflow-auto;
@apply flex-shrink-0;
.tab {
@apply relative;
@apply flex;
@apply flex-shrink-0;
@apply items-center;
@apply justify-center;
@apply py-2 px-4;
@apply text-secondary;
@apply font-semibold;
@apply cursor-pointer;
@apply hover: text-secondaryDark;
@apply focus: outline-none;
@apply focus-visible: text-secondaryDark;
@apply after:absolute;
@apply after:left-4;
@apply after:right-4;
@apply after:bottom-0;
@apply after:bg-transparent;
@apply after:z-2;
@apply after:h-0.5;
@apply after:content-DEFAULT;
@apply focus: after: bg-divider;
.tab-info {
@apply inline-flex;
@apply items-center;
@apply justify-center;
@apply w-5;
@apply h-4;
@apply ml-2;
@apply text-8px;
@apply border border-divider;
@apply rounded;
@apply text-secondaryLight;
}
&.active {
@apply text-secondaryDark;
@apply after:bg-accent;
.tab-info {
@apply text-secondary;
@apply border-dividerDark;
}
}
&.vertical {
@apply p-2;
@apply rounded;
@apply focus: after: hidden;
&.active {
@apply text-accent;
@apply after:hidden;
.tab-info {
@apply text-secondary;
@apply border-dividerDark;
}
}
}
}
}
</style>

View File

@@ -1,89 +0,0 @@
<template>
<div
class="inline-flex items-center justify-center cursor-pointer transition flex-nowrap group hover:text-secondaryDark rounded py-0.5 px-1 -my-0.5 -mx-1 focus:outline-none focus-visible:ring focus-visible:ring-accent focus-visible:text-secondaryDark"
tabindex="0"
@click="emit('change')"
@keyup.enter="emit('change')"
>
<span ref="toggle" class="toggle" :class="{ on: on }">
<span class="handle"></span>
</span>
<span class="pl-0 font-semibold truncate align-middle cursor-pointer">
<slot></slot>
</span>
</div>
</template>
<script setup lang="ts">
defineProps({
on: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: "change"): void
}>()
</script>
<style lang="scss" scoped>
$useBorder: true;
$borderColor: var(--divider-color);
$activeColor: var(--divider-dark-color);
$inactiveColor: var(--divider-color);
$inactiveHandleColor: var(--secondary-light-color);
$activeHandleColor: var(--accent-color);
$width: 1.6rem;
$height: 0.6rem;
$indicatorHeight: 0.4rem;
$indicatorWidth: 0.4rem;
$handleSpacing: 0.1rem;
$transition: all 0.2s ease-in-out;
.toggle {
@apply relative;
@apply flex;
@apply items-center;
@apply justify-center;
@apply rounded-full;
@apply p-0;
@apply mr-4;
@apply cursor-pointer;
@apply flex-shrink-0;
@apply transition;
@apply group-hover: border-accentDark;
@apply focus: outline-none;
@apply focus-visible: border-accentDark;
width: $width;
height: $height;
border: if($useBorder, 2px solid $borderColor, none);
background-color: if($useBorder, transparent, $inactiveColor);
box-sizing: initial;
.handle {
@apply absolute;
@apply flex;
@apply flex-shrink-0;
@apply inset-0;
@apply rounded-full;
@apply pointer-events-none;
transition: $transition;
margin: $handleSpacing;
background-color: $inactiveHandleColor;
width: $indicatorWidth;
height: $indicatorHeight;
}
&.on {
// background-color: $activeColor;
border-color: $activeColor;
@apply focus-visible: border-accentDark;
.handle {
background-color: $activeHandleColor;
left: #{$width - $height};
}
}
}
</style>

View File

@@ -1,49 +0,0 @@
<template>
<div v-show="active" class="flex flex-col flex-1 overflow-y-auto">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import {
onMounted,
onBeforeUnmount,
inject,
computed,
watch,
useSlots,
} from "vue"
import { TabMeta, TabProvider } from "./Windows.vue"
const slots = useSlots()
const props = defineProps({
label: { type: String, default: null },
info: { type: String, default: null },
id: { type: String, default: null, required: true },
isRemovable: { type: Boolean, default: true },
selected: {
type: Boolean,
default: false,
},
})
const tabMeta = computed<TabMeta>(() => ({
info: props.info,
label: props.label,
isRemovable: props.isRemovable,
icon: slots.icon,
}))
const { activeTabID, addTabEntry, updateTabEntry, removeTabEntry } =
inject<TabProvider>("tabs-system")!
const active = computed(() => activeTabID.value === props.id)
onMounted(() => {
addTabEntry(props.id, tabMeta.value)
})
watch(tabMeta, (newMeta) => {
updateTabEntry(props.id, newMeta)
})
onBeforeUnmount(() => {
removeTabEntry(props.id)
})
</script>

View File

@@ -1,251 +0,0 @@
<template>
<div class="flex flex-col flex-1 h-auto overflow-y-hidden flex-nowrap">
<div
class="relative sticky top-0 z-10 flex-shrink-0 overflow-x-auto tabs bg-primaryLight"
>
<div class="flex flex-1 flex-shrink-0 w-0 overflow-x-auto">
<div class="flex justify-between divide-x divide-dividerLight">
<div class="flex">
<draggable
v-bind="dragOptions"
:list="tabEntries"
:style="tabStyles"
:item-key="'window-'"
class="flex flex-shrink-0 overflow-x-auto transition divide-x divide-dividerLight"
@sort="sortTabs"
>
<template #item="{ element: [tabID, tabMeta] }">
<button
:key="`removable-tab-${tabID}`"
class="tab"
:class="[{ active: modelValue === tabID }]"
:aria-label="tabMeta.label || ''"
role="button"
@keyup.enter="selectTab(tabID)"
@click="selectTab(tabID)"
>
<div class="flex items-stretch truncate">
<span
v-if="tabMeta.icon"
class="flex items-center justify-center mx-4 cursor-pointer"
>
<component :is="tabMeta.icon" class="w-4 h-4 svg-icons" />
</span>
<span class="truncate">
{{ tabMeta.label }}
</span>
</div>
<ButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
:icon="IconX"
:style="{
visibility: tabMeta.isRemovable ? 'visible' : 'hidden',
}"
:title="t('action.close')"
:class="[{ active: modelValue === tabID }, 'close']"
class="mx-2 !p-0.5"
@click.stop="emit('removeTab', tabID)"
/>
</button>
</template>
</draggable>
</div>
<div
class="sticky right-0 flex items-center justify-center flex-shrink-0 overflow-x-auto z-8"
>
<slot name="actions">
<span
v-if="canAddNewTab"
class="flex items-center justify-center px-2 py-1.5 bg-primaryLight z-8"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.new')"
:icon="IconPlus"
class="rounded !p-1"
filled
@click="addTab"
/>
</span>
</slot>
</div>
</div>
</div>
</div>
<div class="w-full h-full contents">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import IconPlus from "~icons/lucide/plus"
import IconX from "~icons/lucide/x"
import { pipe } from "fp-ts/function"
import { not } from "fp-ts/Predicate"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import { ref, ComputedRef, computed, provide } from "vue"
import type { Slot } from "vue"
import draggable from "vuedraggable"
import { throwError } from "~/helpers/functional/error"
import { useI18n } from "~/composables/i18n"
export type TabMeta = {
label: string | null
icon: Slot | undefined
info: string | null
isRemovable: boolean
}
export type TabProvider = {
activeTabID: ComputedRef<string>
addTabEntry: (tabID: string, meta: TabMeta) => void
updateTabEntry: (tabID: string, newMeta: TabMeta) => void
removeTabEntry: (tabID: string) => void
}
const t = useI18n()
const props = defineProps({
styles: {
type: String,
default: "",
},
modelValue: {
type: String,
required: true,
},
canAddNewTab: {
type: Boolean,
default: true,
},
})
const emit = defineEmits<{
(e: "update:modelValue", newTabID: string): void
(e: "sort", body: { oldIndex: number; newIndex: number }): void
(e: "removeTab", tabID: string): void
(e: "addTab"): void
}>()
const tabEntries = ref<Array<[string, TabMeta]>>([])
const tabStyles = computed(() => ({
maxWidth: `${tabEntries.value.length * 184}px`,
width: "100%",
minWidth: "0px",
// transition: "max-width 0.2s",
}))
const dragOptions = {
group: "tabs",
animation: 250,
handle: ".tab",
draggable: ".tab",
ghostClass: "cursor-move",
}
const addTabEntry = (tabID: string, meta: TabMeta) => {
tabEntries.value = pipe(
tabEntries.value,
O.fromPredicate(not(A.exists(([id]) => id === tabID))),
O.map(A.append([tabID, meta] as [string, TabMeta])),
O.getOrElseW(() => throwError(`Tab with duplicate ID created: '${tabID}'`))
)
}
const updateTabEntry = (tabID: string, newMeta: TabMeta) => {
tabEntries.value = pipe(
tabEntries.value,
A.findIndex(([id]) => id === tabID),
O.chain((index) =>
pipe(
tabEntries.value,
A.updateAt(index, [tabID, newMeta] as [string, TabMeta])
)
),
O.getOrElseW(() => throwError(`Failed to update tab entry: ${tabID}`))
)
}
const removeTabEntry = (tabID: string) => {
tabEntries.value = pipe(
tabEntries.value,
A.findIndex(([id]) => id === tabID),
O.chain((index) => pipe(tabEntries.value, A.deleteAt(index))),
O.getOrElseW(() => throwError(`Failed to remove tab entry: ${tabID}`))
)
// If we tried to remove the active tabEntries, switch to first tab entry
if (props.modelValue === tabID)
if (tabEntries.value.length > 0) selectTab(tabEntries.value[0][0])
}
const sortTabs = (e: {
oldDraggableIndex: number
newDraggableIndex: number
}) => {
emit("sort", {
oldIndex: e.oldDraggableIndex,
newIndex: e.newDraggableIndex,
})
}
provide<TabProvider>("tabs-system", {
activeTabID: computed(() => props.modelValue),
addTabEntry,
updateTabEntry,
removeTabEntry,
})
const selectTab = (id: string) => {
emit("update:modelValue", id)
}
const addTab = () => {
emit("addTab")
}
</script>
<style scoped lang="scss">
.tabs {
@apply flex;
@apply whitespace-nowrap;
@apply overflow-auto;
@apply flex-shrink-0;
@apply after:absolute;
@apply after:inset-x-0;
@apply after:bottom-0;
@apply after:bg-dividerLight;
@apply after:z-10;
@apply after:h-0.25;
@apply after:content-DEFAULT;
.tab {
@apply relative;
@apply flex;
@apply py-2;
@apply font-semibold;
@apply w-46;
@apply transition;
@apply flex-1;
@apply items-center;
@apply justify-between;
@apply text-secondaryLight;
@apply hover:bg-primaryDark;
@apply hover:text-secondary;
@apply focus-visible:text-secondaryDark;
@apply before:absolute;
@apply before:left-0;
@apply before:right-0;
@apply before:top-0;
@apply before:bg-transparent;
@apply before:z-2;
@apply before:h-0.5;
@apply before:content-DEFAULT;
@apply focus: before: bg-divider;
&.active {
@apply text-secondaryDark;
@apply bg-primary;
@apply before:bg-accent;
}
.close {
@apply opacity-50;
&.active {
@apply opacity-80;
}
}
}
}
</style>