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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>​</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<icon-lucide-loader class="animate-spin svg-icons" />
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user