feat: migrate to vue 3 + vite (#2553)
Co-authored-by: amk-dev <akash.k.mohan98@gmail.com> Co-authored-by: liyasthomas <liyascthomas@gmail.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<!-- text-green-500 -->
|
||||
<!-- text-teal-500 -->
|
||||
<!-- text-blue-500 -->
|
||||
<!-- text-indigo-500 -->
|
||||
<!-- text-purple-500 -->
|
||||
<!-- text-yellow-500 -->
|
||||
<!-- text-orange-500 -->
|
||||
<!-- text-red-500 -->
|
||||
<!-- text-pink-500 -->
|
||||
<ButtonSecondary
|
||||
v-for="(color, index) of accentColors"
|
||||
:key="`color-${index}`"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${color.charAt(0).toUpperCase()}${color.slice(1)}`"
|
||||
:class="[{ 'bg-primaryLight': color === active }]"
|
||||
class="rounded"
|
||||
:icon="color === active ? IconCircleDot : IconCircle"
|
||||
:color="color"
|
||||
@click="setActiveColor(color)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconCircleDot from "~icons/lucide/circle-dot"
|
||||
import {
|
||||
HoppAccentColors,
|
||||
HoppAccentColor,
|
||||
applySetting,
|
||||
} from "~/newstore/settings"
|
||||
import { useSetting } from "@composables/settings"
|
||||
|
||||
const accentColors = HoppAccentColors
|
||||
const active = useSetting("THEME_COLOR")
|
||||
|
||||
const setActiveColor = (color: HoppAccentColor) => {
|
||||
document.documentElement.setAttribute("data-accent", color)
|
||||
applySetting("THEME_COLOR", color)
|
||||
}
|
||||
</script>
|
||||
70
packages/hoppscotch-app/src/components/smart/Anchor.vue
Normal file
70
packages/hoppscotch-app/src/components/smart/Anchor.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<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>
|
||||
245
packages/hoppscotch-app/src/components/smart/AutoComplete.vue
Normal file
245
packages/hoppscotch-app/src/components/smart/AutoComplete.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<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>
|
||||
108
packages/hoppscotch-app/src/components/smart/ChangeLanguage.vue
Normal file
108
packages/hoppscotch-app/src/components/smart/ChangeLanguage.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<span class="inline-flex">
|
||||
<tippy interactive trigger="click" theme="popover" arrow>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('settings.choose_language')"
|
||||
class="pr-8"
|
||||
:icon="IconLanguages"
|
||||
outline
|
||||
:label="currentLocale.name"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="sticky top-0">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="flex w-full p-4 py-2 !bg-popover input"
|
||||
:placeholder="`${t('action.search')}`"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartLink
|
||||
v-for="locale in filteredAppLanguages"
|
||||
:key="`locale-${locale.code}`"
|
||||
class="flex flex-1"
|
||||
@click="
|
||||
() => {
|
||||
changeLocale(locale.code)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
>
|
||||
<SmartItem
|
||||
:label="locale.name"
|
||||
:active-info-icon="currentLocale.code === locale.code"
|
||||
:info-icon="
|
||||
currentLocale.code === locale.code ? IconDone : null
|
||||
"
|
||||
/>
|
||||
</SmartLink>
|
||||
<div
|
||||
v-if="
|
||||
!(
|
||||
filteredAppLanguages.length !== 0 ||
|
||||
APP_LANGUAGES.length === 0
|
||||
)
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ searchQuery }}"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { computed, ref } from "vue"
|
||||
import { APP_LANGUAGES, FALLBACK_LANG, changeAppLanguage } from "@modules/i18n"
|
||||
import { useFullI18n } from "@composables/i18n"
|
||||
import IconLanguages from "~icons/lucide/languages"
|
||||
import IconDone from "~icons/lucide/check"
|
||||
|
||||
// TODO: This component might be completely whack right now
|
||||
|
||||
const i18n = useFullI18n()
|
||||
const t = i18n.t
|
||||
|
||||
const currentLocale = computed(() =>
|
||||
pipe(
|
||||
APP_LANGUAGES,
|
||||
A.findFirst(({ code }) => code === i18n.locale.value),
|
||||
O.getOrElse(() => FALLBACK_LANG)
|
||||
)
|
||||
)
|
||||
|
||||
const changeLocale = (locale: string) => {
|
||||
// TODO: Implement
|
||||
changeAppLanguage(locale)
|
||||
}
|
||||
|
||||
const searchQuery = ref("")
|
||||
|
||||
const filteredAppLanguages = computed(() => {
|
||||
return APP_LANGUAGES.filter((obj) =>
|
||||
Object.values(obj).some((val) =>
|
||||
val.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
)
|
||||
})
|
||||
</script>
|
||||
69
packages/hoppscotch-app/src/components/smart/Checkbox.vue
Normal file
69
packages/hoppscotch-app/src/components/smart/Checkbox.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center justify-center cursor-pointer transition flex-nowrap group hover:text-secondaryDark"
|
||||
role="checkbox"
|
||||
:aria-checked="on"
|
||||
@click="emit('change')"
|
||||
>
|
||||
<input
|
||||
id="checkbox"
|
||||
type="checkbox"
|
||||
name="checkbox"
|
||||
:checked="on"
|
||||
@change="emit('change')"
|
||||
/>
|
||||
<label
|
||||
for="checkbox"
|
||||
class="pl-0 font-semibold 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>
|
||||
input[type="checkbox"] {
|
||||
@apply appearance-none;
|
||||
@apply hidden;
|
||||
|
||||
& + label {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply cursor-pointer;
|
||||
|
||||
&::before {
|
||||
@apply border-divider border-2;
|
||||
@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-3;
|
||||
@apply transition;
|
||||
content: "\e876";
|
||||
}
|
||||
}
|
||||
|
||||
&:checked + label::before {
|
||||
@apply bg-accent;
|
||||
@apply border-accent;
|
||||
@apply text-accentContrast;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-for="(color, index) of colors"
|
||||
:key="`color-${index}`"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t(getColorModeName(color))"
|
||||
:class="{
|
||||
'bg-primaryLight !text-accent hover:text-accent': color === active,
|
||||
}"
|
||||
class="rounded"
|
||||
:icon="getIcon(color)"
|
||||
@click="setBGMode(color)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconMonitor from "~icons/lucide/monitor"
|
||||
import IconSun from "~icons/lucide/sun"
|
||||
import IconCloud from "~icons/lucide/cloud"
|
||||
import IconMoon from "~icons/lucide/moon"
|
||||
import { applySetting, HoppBgColor, HoppBgColors } from "~/newstore/settings"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const colors = HoppBgColors
|
||||
const active = useSetting("BG_COLOR")
|
||||
|
||||
const setBGMode = (color: HoppBgColor) => {
|
||||
applySetting("BG_COLOR", color)
|
||||
}
|
||||
|
||||
const getIcon = (color: HoppBgColor) => {
|
||||
switch (color) {
|
||||
case "system":
|
||||
return IconMonitor
|
||||
case "light":
|
||||
return IconSun
|
||||
case "dark":
|
||||
return IconCloud
|
||||
case "black":
|
||||
return IconMoon
|
||||
default:
|
||||
return IconMonitor
|
||||
}
|
||||
}
|
||||
|
||||
const getColorModeName = (colorMode: string) => {
|
||||
switch (colorMode) {
|
||||
case "system":
|
||||
return "settings.system_mode"
|
||||
case "light":
|
||||
return "settings.light_mode"
|
||||
case "dark":
|
||||
return "settings.dark_mode"
|
||||
case "black":
|
||||
return "settings.black_mode"
|
||||
default:
|
||||
return "settings.system_mode"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<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">
|
||||
<ButtonPrimary
|
||||
v-focus
|
||||
:label="yes ?? t('action.yes')"
|
||||
:loading="!!loadingState"
|
||||
@click="resolve"
|
||||
/>
|
||||
<ButtonSecondary :label="no ?? t('action.no')" @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>
|
||||
218
packages/hoppscotch-app/src/components/smart/EnvInput.vue
Normal file
218
packages/hoppscotch-app/src/components/smart/EnvInput.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center flex-1 flex-shrink-0 overflow-auto whitespace-nowrap"
|
||||
>
|
||||
<div
|
||||
ref="editor"
|
||||
:placeholder="placeholder"
|
||||
class="flex flex-1"
|
||||
:class="styles"
|
||||
@keydown.enter.prevent="emit('enter', $event)"
|
||||
@keyup="emit('keyup', $event)"
|
||||
@click="emit('click', $event)"
|
||||
@keydown="emit('keydown', $event)"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick, computed, Ref } from "vue"
|
||||
import {
|
||||
EditorView,
|
||||
placeholder as placeholderExt,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
keymap,
|
||||
tooltips,
|
||||
} from "@codemirror/view"
|
||||
import { EditorState, Extension } from "@codemirror/state"
|
||||
import { clone } from "lodash-es"
|
||||
import { history, historyKeymap } from "@codemirror/commands"
|
||||
import { inputTheme } from "~/helpers/editor/themes/baseTheme"
|
||||
import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
styles?: string
|
||||
envs?: { key: string; value: string; source: string }[] | null
|
||||
focus?: boolean
|
||||
readonly?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: "",
|
||||
placeholder: "",
|
||||
styles: "",
|
||||
envs: null,
|
||||
focus: false,
|
||||
readonly: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", data: string): void
|
||||
(e: "change", data: string): void
|
||||
(e: "paste", data: { prevValue: string; pastedValue: string }): void
|
||||
(e: "enter", ev: any): void
|
||||
(e: "keyup", ev: any): void
|
||||
(e: "keydown", ev: any): void
|
||||
(e: "click", ev: any): void
|
||||
}>()
|
||||
|
||||
const cachedValue = ref(props.modelValue)
|
||||
|
||||
const view = ref<EditorView>()
|
||||
|
||||
const editor = ref<any | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
const singleLinedText = newVal.replaceAll("\n", "")
|
||||
|
||||
const currDoc = view.value?.state.doc
|
||||
.toJSON()
|
||||
.join(view.value.state.lineBreak)
|
||||
|
||||
if (cachedValue.value !== singleLinedText || newVal !== currDoc) {
|
||||
cachedValue.value = singleLinedText
|
||||
|
||||
view.value?.dispatch({
|
||||
filter: false,
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.value.state.doc.length,
|
||||
insert: singleLinedText,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
flush: "sync",
|
||||
}
|
||||
)
|
||||
|
||||
let clipboardEv: ClipboardEvent | null = null
|
||||
let pastedValue: string | null = null
|
||||
|
||||
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref<
|
||||
AggregateEnvironment[]
|
||||
>
|
||||
|
||||
const envVars = computed(() =>
|
||||
props.envs
|
||||
? props.envs.map((x) => ({
|
||||
key: x.key,
|
||||
value: x.value,
|
||||
sourceEnv: x.source,
|
||||
}))
|
||||
: aggregateEnvs.value
|
||||
)
|
||||
|
||||
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
|
||||
|
||||
const initView = (el: any) => {
|
||||
const extensions: Extension = [
|
||||
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (props.readonly) {
|
||||
update.view.contentDOM.inputMode = "none"
|
||||
}
|
||||
}),
|
||||
EditorState.changeFilter.of(() => !props.readonly),
|
||||
inputTheme,
|
||||
props.readonly
|
||||
? EditorView.theme({
|
||||
".cm-content": {
|
||||
caretColor: "var(--secondary-dark-color)",
|
||||
color: "var(--secondary-dark-color)",
|
||||
backgroundColor: "var(--divider-color)",
|
||||
opacity: 0.25,
|
||||
},
|
||||
})
|
||||
: EditorView.theme({}),
|
||||
tooltips({
|
||||
position: "absolute",
|
||||
}),
|
||||
envTooltipPlugin,
|
||||
placeholderExt(props.placeholder),
|
||||
EditorView.domEventHandlers({
|
||||
paste(ev) {
|
||||
clipboardEv = ev
|
||||
pastedValue = ev.clipboardData?.getData("text") ?? ""
|
||||
},
|
||||
drop(ev) {
|
||||
ev.preventDefault()
|
||||
},
|
||||
}),
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
update(update: ViewUpdate) {
|
||||
if (props.readonly) return
|
||||
|
||||
if (update.docChanged) {
|
||||
const prevValue = clone(cachedValue.value)
|
||||
|
||||
cachedValue.value = update.state.doc
|
||||
.toJSON()
|
||||
.join(update.state.lineBreak)
|
||||
|
||||
// We do not update the cache directly in this case (to trigger value watcher to dispatch)
|
||||
// So, we desync cachedValue a bit so we can trigger updates
|
||||
const value = clone(cachedValue.value).replaceAll("\n", "")
|
||||
|
||||
emit("update:modelValue", value)
|
||||
emit("change", value)
|
||||
|
||||
const pasted = !!update.transactions.find((txn) =>
|
||||
txn.isUserEvent("input.paste")
|
||||
)
|
||||
|
||||
if (pasted && clipboardEv) {
|
||||
const pastedVal = pastedValue
|
||||
nextTick(() => {
|
||||
emit("paste", {
|
||||
pastedValue: pastedVal!,
|
||||
prevValue,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
clipboardEv = null
|
||||
pastedValue = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
history(),
|
||||
keymap.of([...historyKeymap]),
|
||||
]
|
||||
|
||||
view.value = new EditorView({
|
||||
parent: el,
|
||||
state: EditorState.create({
|
||||
doc: props.modelValue,
|
||||
extensions,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (editor.value) {
|
||||
if (!view.value) initView(editor.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(editor, () => {
|
||||
if (editor.value) {
|
||||
if (!view.value) initView(editor.value)
|
||||
} else {
|
||||
view.value?.destroy()
|
||||
view.value = undefined
|
||||
}
|
||||
})
|
||||
</script>
|
||||
28
packages/hoppscotch-app/src/components/smart/Expand.vue
Normal file
28
packages/hoppscotch-app/src/components/smart/Expand.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col overflow-hidden space-y-2"
|
||||
:class="expand ? 'h-full' : 'max-h-32'"
|
||||
>
|
||||
<slot name="body"></slot>
|
||||
<div class="sticky inset-x-0 bottom-0 flex items-center justify-center">
|
||||
<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>
|
||||
22
packages/hoppscotch-app/src/components/smart/FileChip.vue
Normal file
22
packages/hoppscotch-app/src/components/smart/FileChip.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<span class="inline-flex">
|
||||
<tippy interactive trigger="click" theme="popover" arrow>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('settings.change_font_size')"
|
||||
class="pr-8"
|
||||
:icon="IconType"
|
||||
outline
|
||||
:label="`${getFontSizeName(
|
||||
fontSizes.find((size) => size === active)
|
||||
)}`"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
class="flex flex-col"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(size, index) in fontSizes"
|
||||
:key="`size-${index}`"
|
||||
:label="`${getFontSizeName(size)}`"
|
||||
:icon="size === active ? IconCircleDot : IconCircle"
|
||||
:active="size === active"
|
||||
@click="
|
||||
() => {
|
||||
setActiveFont(size)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconCircleDot from "~icons/lucide/circle-dot"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconType from "~icons/lucide/type"
|
||||
import { HoppFontSizes, HoppFontSize, applySetting } from "~/newstore/settings"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const fontSizes = HoppFontSizes
|
||||
const active = useSetting("FONT_SIZE")
|
||||
|
||||
const getFontSizeName = (size: HoppFontSize) => {
|
||||
return t(`settings.font_size_${size}`)
|
||||
}
|
||||
|
||||
const setActiveFont = (size: HoppFontSize) => {
|
||||
applySetting("FONT_SIZE", size)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<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>
|
||||
140
packages/hoppscotch-app/src/components/smart/Item.vue
Normal file
140
packages/hoppscotch-app/src/components/smart/Item.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<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-2 <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>
|
||||
90
packages/hoppscotch-app/src/components/smart/Link.vue
Normal file
90
packages/hoppscotch-app/src/components/smart/Link.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<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>
|
||||
210
packages/hoppscotch-app/src/components/smart/Modal.vue
Normal file
210
packages/hoppscotch-app/src/components/smart/Modal.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<Transition name="fade" appear @leave="onTransitionLeaveStart">
|
||||
<div
|
||||
ref="modal"
|
||||
class="fixed inset-0 z-50 overflow-y-auto transition"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<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"
|
||||
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 border shadow-lg transition-all transform border-dividerDark bg-primary sm:rounded-xl sm:align-middle"
|
||||
:class="[{ 'mt-24 md:mb-8': placement === 'top' }, maxWidth]"
|
||||
>
|
||||
<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">
|
||||
<slot name="actions"></slot>
|
||||
<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,
|
||||
},
|
||||
maxWidth: {
|
||||
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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<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>
|
||||
34
packages/hoppscotch-app/src/components/smart/Radio.vue
Normal file
34
packages/hoppscotch-app/src/components/smart/Radio.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
26
packages/hoppscotch-app/src/components/smart/RadioGroup.vue
Normal file
26
packages/hoppscotch-app/src/components/smart/RadioGroup.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
3
packages/hoppscotch-app/src/components/smart/Spinner.vue
Normal file
3
packages/hoppscotch-app/src/components/smart/Spinner.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<icon-lucide-loader class="animate-spin svg-icons" />
|
||||
</template>
|
||||
75
packages/hoppscotch-app/src/components/smart/Tab.vue
Normal file
75
packages/hoppscotch-app/src/components/smart/Tab.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<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
|
||||
}>(),
|
||||
{
|
||||
icon: null,
|
||||
indicator: false,
|
||||
info: null,
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
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>
|
||||
263
packages/hoppscotch-app/src/components/smart/Tabs.vue
Normal file
263
packages/hoppscotch-app/src/components/smart/Tabs.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<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 },
|
||||
]"
|
||||
:aria-label="tabMeta.label || ''"
|
||||
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,
|
||||
}"
|
||||
>
|
||||
<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
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
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;
|
||||
|
||||
// &::after {
|
||||
// @apply absolute;
|
||||
// @apply inset-x-0;
|
||||
// @apply bottom-0;
|
||||
// @apply bg-dividerLight;
|
||||
// @apply z-1;
|
||||
// @apply h-0.5;
|
||||
// content: "";
|
||||
// }
|
||||
|
||||
.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;
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply absolute;
|
||||
@apply left-4;
|
||||
@apply right-4;
|
||||
@apply bottom-0;
|
||||
@apply bg-transparent;
|
||||
@apply z-2;
|
||||
@apply h-0.5;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&:focus::after {
|
||||
@apply bg-divider;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply text-secondaryDark;
|
||||
|
||||
.tab-info {
|
||||
@apply text-secondary;
|
||||
@apply border-dividerDark;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply bg-accent;
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
@apply p-2;
|
||||
@apply rounded;
|
||||
|
||||
&:focus::after {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply text-accent;
|
||||
|
||||
.tab-info {
|
||||
@apply text-secondary;
|
||||
@apply border-dividerDark;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
packages/hoppscotch-app/src/components/smart/Toggle.vue
Normal file
89
packages/hoppscotch-app/src/components/smart/Toggle.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<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 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>
|
||||
Reference in New Issue
Block a user