added web-app and landing

This commit is contained in:
johba 2025-09-23 14:18:04 +02:00
parent af031877a5
commit 769fa105b8
198 changed files with 22132 additions and 10 deletions

View file

@ -0,0 +1,112 @@
<template>
<button class="f-btn" :class="classObject" :style="styleObject">
<slot></slot>
</button>
</template>
<script setup lang="ts">
interface Props {
size?: string;
dense?: boolean;
disabled?: boolean;
invert?: boolean;
block?: boolean;
outlined?: boolean;
bgColor?: string;
light?: boolean;
dark?: boolean;
}
import { computed } from "vue";
const props = withDefaults(defineProps<Props>(), {
size: "medium",
});
const classObject = computed(() => ({
"f-btn--tiny": props.size === "tiny",
"f-btn--small": props.size === "small",
"f-btn--medium": props.size === "medium",
"f-btn--large": props.size === "large",
"f-btn--dense": props.dense,
"f-btn--disabled": props.disabled,
"f-btn--invert": props.invert,
"f-btn--block": props.block,
"f-btn--outlined": props.outlined,
"f-btn--light": props.light,
"f-btn--dark": props.dark,
}));
const styleObject = computed(() => {
const returnObject: any = {};
if (props.bgColor) {
returnObject["background-color"] = props.bgColor;
}
return returnObject;
});
</script>
<style lang="sass">
@use 'sass:color'
.f-btn
padding: var(--btn-padding, 16px 32px)
gap: 8px
border-radius: var(--border-radius, 12px)
border: none
display: inline-flex
text-decoration: none
background-color: var(--color-button-bg)
color: var(--color-button-font, white)
letter-spacing: 0.15px
transition: color .25s
transition: background-color .25s
justify-content: center
font-weight: bold
font-size: var(--font-size-button-medium)
&.f-btn--tiny
font-size: 13px
letter-spacing: 0.46px
padding: 4px 16px
&.f-btn--small
font-size: var(--font-size-button-small)
letter-spacing: 0.46px
&.f-btn--large
font-size: var(--font-size-button-large)
&.f-btn--disabled
background-color: var(--color-bg-disabled, #D6D6D6)
color: var(--color-disabled, #A1A3A7)
&:hover,&:focus,&:active
background-color: var(--color-bg-disabled, #D6D6D6)
color: var(--color-disabled, #A1A3A7)
cursor: not-allowed
&.f-btn--dense
padding: var(--btn-dense-padding, 8px 16px)
&.f-btn--invert
background-color: var(--btn-color, white)
color: var(--color-button-bg)
border: 2px solid var(--color-button-bg)
&:hover,&:focus,&:active
background-color: color.adjust(white, $lightness: -20%)
&.f-btn--light
background-color: var(--color-white, white)
color: var(--color-secondary)
border: 2px solid var(--color-white)
&:hover,&:focus,&:active
background-color: color.adjust(white, $lightness: -20%)
border: 2px solid var(--color-white)
&.f-btn--block
display: flex
flex: 1 0 auto
min-width: 100%
&.f-btn--outlined
background-color: transparent
color: var(--color-button-bg)
border: 1px solid var(--color-button-border--outlined)
&:hover,&:focus,&:active
cursor: pointer
background-color: var(--color)
&:hover,&:focus,&:active
cursor: pointer
background-color: #5f4884
</style>

View file

@ -0,0 +1,57 @@
<template>
<div class="f-card" ref="fCard" :style="computedStyles">
<div v-if="props.title" class="f-card__title">
<h5>
{{ props.title }}
</h5>
</div>
<div class="f-card__body">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
const fCard = ref();
interface Props {
bgColor?: string;
borderWidth?: number;
boxShadow?: string;
title?: string;
}
const props = withDefaults(defineProps<Props>(), {});
const computedStyles = computed(() => ({
"border-width": props.borderWidth,
"background-color": props.bgColor,
"box-shadow": props.boxShadow,
}));
onMounted(() => {
// if (props.bgcolor) {
// fCard.value.style["background-color"] = props.bgcolor;
// }
});
</script>
<style lang="sass">
.f-card
border-radius: var(--card-border-radius)
overflow: hidden
height: 100%
box-shadow: var(--color-card-box-shadow)
background: var(--color-card-bg)
color: var(--color-card-font)
h6
color: var(--color-card-font)
.f-card__title
border-bottom: 2px solid var(--color-based-blue)
color: var(--color-based-blue)
padding: 12px
.f-card__body
padding: 16px
</style>

View file

@ -0,0 +1,97 @@
<template>
<div class="f-collapse">
<div class="f-collapse-wrapper" :class="{loading: props.loading}">
<template v-if="loading">
<f-loader></f-loader>
</template>
<div class="f-collapse-inner">
<slot name="header"></slot>
<!-- <img class="toggle-collapse" src="../assets/expand-less.svg?url" alt="expand less" /> -->
<Icon v-if="isShow" class="toggle-collapse" icon="mdi:chevron-down" @click="openClose"></Icon>
<Icon v-else icon="mdi:chevron-up" class="toggle-collapse" @click="openClose"></Icon>
<!-- <img
class="toggle-collapse"
src="../assets/expand-more.svg?url"
alt="expand more"
v-else
/> -->
</div>
<Transition name="collapse">
<div v-if="isShow" class="collapsableDiv">
<slot></slot>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import {Icon} from "@iconify/vue";
import FLoader from "@/components/fcomponents/FLoader.vue";
const emit = defineEmits(["collapse:opened", "collapse:closed"]);
const props = defineProps<{
loading?: boolean;
}>();
const isShow = ref(false);
const openClose = () => {
isShow.value = !isShow.value;
if(isShow.value){
emit("collapse:opened")
} else{
emit("collapse:closed")
}
};
</script>
<style lang="sass">
.f-collapse
border-radius: var(--border-radius)
border: 1px solid var(--color-collapse-border)
background-color: var(--color-collapse-bg)
.f-collapse-wrapper
padding: 8px 16px
&.loading
opacity: 0.5
position: relative
pointer-events: none
.cube
position: absolute
left: 50%
top: 50%
transform: translate(-50%, -50%)
.f-collapse-inner
display: flex
position: relative
align-items: center
.toggle-collapse
font-size: 25px
align-self: flex-start
g
path
fill: var(--color-expand-icon)
&:hover, &:active, &:focus
cursor: pointer
.collapsableDiv
color: var(--color-collapse-font)
.collapse-enter-active
animation: collapse reverse 500ms ease
.collapse-leave-active
animation: collapse 500ms ease
@keyframes collapse
from
max-height: 200px
overflow: hidden
to
max-height: 0px
overflow: hidden
</style>

View file

@ -0,0 +1,135 @@
<template>
<div class="f-input" :class="classObject">
<div class="f-input-label subheader2">
<label v-if="props.label" :for="name">{{ props.label }}</label>
<icon>
<template v-slot:text v-if="slots.info">
<slot name="info"></slot>
</template>
</icon>
</div>
<div class="f-input__wrapper" ref="inputWrapper" @click="setFocus">
<input
:disabled="props.disabled"
:readonly="props.readonly"
:type="props.type"
:name="name"
:id="name"
@input="updateModelValue"
:value="props.modelValue"
/>
<div class="f-input--suffix" v-if="slots.suffix">
<slot name="suffix" >test </slot>
</div>
</div>
<div class="f-input--details" v-if="slots.details">
<slot name="details"> </slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, useSlots, ref } from "vue";
import useClickOutside from "@/composables/useClickOutside"
import Icon from "@/components/icons/IconInfo.vue";
interface Props {
size?: string;
info?: string;
label?: string;
disabled?: boolean;
modelValue?: any;
type?: string;
readonly?: boolean
}
const slots = useSlots();
const inputWrapper = ref();
const instance = getCurrentInstance();
const name = `f-input-${instance!.uid}`;
const props = withDefaults(defineProps<Props>(), {
size: "normal",
disabled: false,
readonly: false,
type: "string",
});
useClickOutside(inputWrapper, () => {
removeFocus();
})
const emit = defineEmits(["update:modelValue"]);
function updateModelValue($event: any) {
emit("update:modelValue", $event.target.value);
}
const classObject = computed(() => ({
"f-input--normal": props.size === "normal",
"f-input--big": props.size === "big",
"f-input--disabled": props.disabled,
}));
function removeFocus(){
const target = inputWrapper.value as HTMLElement
target.classList.remove("f-input__wrapper--focused")
}
function setFocus(event:MouseEvent){
console.log("setFocus");
if(props.disabled){
return
}
const target = inputWrapper.value as HTMLElement
target.classList.add("f-input__wrapper--focused")
}
</script>
<style lang="sass">
.f-input
display: flex
flex-direction: column
.info-icon
position: static
@media (min-width: 768px)
position: relative
.f-input-label
display: flex
align-items: center
gap: 8px
.f-input__wrapper
display: flex
border: 1px solid black
border-radius: 12px
width: 100%
background-color: #2D2D2D
&.f-input__wrapper--focused
outline: none
border: 1px solid #7550AE
box-shadow: 0 0 5px #7550AE
input
border: 0
border-radius: 12px
padding: 8px 12px
width: 100%
background-color: #2D2D2D
color: #FFFFFF
outline: none
&.f-input--big
font-size: 18px
&.f-input--normal
font-size: 16px
&.f-input--disabled
input
background-color: #202020
color: #F0F0F0
.f-input--suffix
display: flex
align-items: center
margin-right: 10px
</style>

View file

@ -0,0 +1,77 @@
<template>
<div class="cube">
<div class="side"></div>
<div class="side"></div>
<div class="side"></div>
<div class="side"></div>
<div class="side"></div>
<div class="side"></div>
</div>
</template>
<style lang="sass">
.cube
margin: auto
font-size: 24px
height: 1em
width: 1em
position: relative
transform: rotatex(30deg) rotatey(45deg)
transform-style: preserve-3d
animation: cube-spin 1.5s infinite ease-in-out alternate
.side
position: absolute
top: 0
bottom: 0
left: 0
right: 0
transform-style: preserve-3d
&::before
content: ""
position: absolute
top: 0
bottom: 0
left: 0
right: 0
background-color: currentcolor
transform: translatez(0.5em)
animation: cube-explode 1.5s infinite ease-in-out
opacity: 0.5
&:nth-child(1)
transform: rotatey(90deg)
&:nth-child(2)
transform: rotatey(180deg)
&:nth-child(3)
transform: rotatey(270deg)
&:nth-child(4)
transform: rotatey(360deg)
&:nth-child(5)
transform: rotatex(90deg)
&:nth-child(6)
transform: rotatex(270deg)
@keyframes cube-spin
0%
transform: rotatex(30deg) rotatey(45deg)
100%
transform: rotatex(30deg) rotatey(405deg)
@keyframes cube-explode
0%
transform: translatez(0.5em)
50%
transform: translatez(0.75em)
100%
transform: translatez(0.5em)
</style>

View file

@ -0,0 +1,73 @@
<template>
<div
class="output-wrapper"
:class="{
'no-name': !props.name,
'output--variant-1': props.variant === 1,
'output--variant-2': props.variant === 2,
'output--variant-3': props.variant === 3,
}"
>
<div class="output-left">
<div class="output-text">{{ props.name }}</div>
<div class="output-price">
<slot name="price">
{{ props.price }}
</slot>
</div>
</div>
<div class="output-right" v-if="slots.end">
<slot name="end"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { useSlots } from "vue";
const props = defineProps({
name: String,
price: [String, Number],
variant: {
type: Number,
required: false,
default: 1,
},
});
const slots = useSlots();
</script>
<style lang="sass">
.output-wrapper
height: 100%
display: flex
flex-direction: row
&.no-name
gap: 0
align-items: center
justify-content: center
&.output--variant-1
padding: 16px
border: 1px solid #2D2D2D
border-radius: var(--border-radius, 12px)
background-color: var(--color-input)
&.output--variant-2
border-bottom: 1px solid var(--color-grey)
padding: 8px
&.output--variant-3
padding: 8px
.output-left
display: flex
flex-direction: column
gap: 8px
text-align: left
.output-price
font-size: 18px
letter-spacing: 3px
.output-text
font-size: 14px
font-weight: bold
.output-right
margin-left: auto
align-self: center
</style>

View file

@ -0,0 +1,234 @@
<template>
<div class="o-select" @click="clickSelect" ref="componentRef">
<f-input hide-details :clearable="props.clearable" :label="props.label" :selectedable="false"
:focus="showList" :modelValue="`${year} % yearly`" readonly>
<template #info v-if="slots.info">
<slot name="info"></slot>
</template>
<template #suffix>
<Icon class="toggle-collapse" v-if="showList" icon="mdi:chevron-down"></Icon>
<Icon class="toggle-collapse" v-else icon="mdi:chevron-up"></Icon>
</template>
</f-input>
<div class="select-list-wrapper" v-show="showList" ref="selectList" >
<div class="select-list-inner" @click.stop>
<div class="select-list-item" v-for="(item, index) in props.items" :key="item.year" :class="{'active': year === item.year, 'hovered': activeIndex === index}"
@click.stop="clickItem(item)" @mouseenter="mouseEnter($event, index)" @mouseleave="mouseLeave($event, index)">
<div class="circle">
<div class="active" v-if="year === item.year"></div>
<div class="hovered" v-else-if="activeIndex === index"></div>
</div>
<div class="yearly">
<div class="value">{{ item.year }} %</div>
<div class="label">yearly</div>
</div>
<div class="daily">
<div class="value">{{ item.daily.toFixed(4) }} %</div>
<div class="label">daily</div>
</div>
</div>
</div>
</div>
</div>
<slot :style="[!props.editor ? {display: 'none'} : {}]"></slot>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, getCurrentInstance, type ComputedRef, useSlots } from "vue"
import FInput from "@/components/fcomponents/FInput.vue"
import useClickOutside from "@/composables/useClickOutside"
import {Icon} from "@iconify/vue";
const emit = defineEmits(['update:modelValue'])
const slots = useSlots();
interface Item {
year: number;
daily: number;
}
const props = defineProps({
items: {
type: [Array<Item>],
required: false,
default: []
},
clearable: {
type: Boolean,
required: false,
default: false
},
itemTitle: {
type: String,
required: false,
default: "label"
},
itemValue: {
type: String,
required: false,
default: "value"
},
label: {
type: String,
required: false,
default: null
},
modelValue: {
type: Number,
required: false,
default: null
},
value: {
type: String,
required: false,
default: null
},
editor: {
type: Boolean,
required: false,
default: false
}
});
const showList = ref(<boolean>false);
const slotElements = ref(<any[]>[])
const componentRef = ref(<any>null);
const selectList = ref();
const activeIndex= ref();
useClickOutside(componentRef, () => {
showList.value = false
})
const year= computed({
// getter
get() {
return props.modelValue || props.items[0].year
},
// setter
set(newValue: number) {
emit("update:modelValue", newValue)
}
})
function mouseEnter(event:MouseEvent , index:number){
const target = event. target as HTMLElement;
activeIndex.value = index;
target.classList.add("active")
}
function mouseLeave(event:MouseEvent, index:number){
const elements = selectList.value.querySelectorAll('.select-list-item');
elements.forEach((element:HTMLElement) => {
element.classList.remove("active")
});
}
onMounted(() => {
})
function clickSelect(event: any) {
showList.value = !showList.value;
}
function clickItem(item: any) {
console.log("item", item);
year.value = item.year
showList.value = false;
console.log("showList.value", showList.value);
// emit('input', item)
}
</script>
<style lang="sass">
.o-select
position: relative
display: inline-block
.toggle-collapse
color: black
font-size: 20px
&:active, &:focus, &:hover
cursor: pointer
.select-list-wrapper
position: absolute
box-shadow: 0 1px 6px 0 #20212447
background-color: var(--color-card-bg)
z-index: 10
padding: 16px
border-radius: 16px
border: 1px solid black
right: 0
margin-top: 4px
.select-list-inner
height: 330px
overflow-y: scroll
display: grid
.select-list-item
display: grid
grid-template-columns: 15px 110px 126px
place-content: end
margin-right: 8px
-webkit-touch-callout: none
-webkit-user-select: none
-khtml-user-select: none
-moz-user-select: none
-ms-user-select: none
user-select: none
padding: 4px 0
&:active, &:focus, &:hover
cursor: pointer
.daily, .yearly
display: flex
gap: 4px
justify-content: end
.daily
color: var(--color-grey)
.circle
height: 14px
width: 14px
.active
height: 14px
width: 14px
background-color: var(--color-based-blue)
border-radius: 14px
.hovered
height: 14px
width: 14px
background-color: var(--color-based-blue)
opacity: .5
border-radius: 14px
.select-list-inner
&::-webkit-scrollbar-track
border-radius: 10px
&::-webkit-scrollbar
width: 5px
&::-webkit-scrollbar-thumb
border-radius: 10px
background-color: lightgrey
</style>

View file

@ -0,0 +1,259 @@
<template>
<div>
<div class="slider-wrapper">
<div class="disabled-slider" :style="{ 'flex-basis': minPercentage + '%' }">
<div class="dot"></div>
</div>
<div class="range-slider">
<input
class="range-slider__range"
type="range"
ref="sliderInput"
:style="{
background: `linear-gradient(90deg, ${settings.fill} ${percentageDot}%, ${settings.background} ${percentageDot + 0.1}%)`,
}"
:value="props.modelValue"
:min="props.min"
:max="props.max"
@input="updateModelValue"
/>
<div
class="testbla"
@mousemove="testMove"
:style="{
left: percentageDot + '%',
}"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed } from "vue";
const sliderInput = ref();
const emit = defineEmits(["update:modelValue"]);
const props = defineProps<{
modelValue: number;
min: number;
max: number;
}>();
const minPercentage = computed(() => {
if (!props.min || !props.max) {
return 0n;
}
return (props.min * 100) / props.max;
});
function updateModelValue(event: any) {
emit("update:modelValue", event.target.valueAsNumber);
}
const sliderValue = computed({
// getter
get() {
return props.modelValue || props.min;
},
// setter
set(newValue) {
emit("update:modelValue", newValue);
},
});
const percentageDot = computed(() => {
let percentage = (100 * (sliderValue.value - props.min)) / (props.max - props.min);
if(percentage < 20){
percentage = percentage + 1;
} else if(percentage > 50){
percentage = percentage - 2;
} else if(percentage > 80){
percentage = percentage - 3;
}
return percentage
});
const settings = {
fill: "#7550AE",
background: "#000000",
};
onMounted(() => {
sliderInput.value.addEventListener("input", setSliderValue);
});
function setSliderValue(event: any){
sliderValue.value = event.target.value;
}
function testMove(event: any) {
}
onUnmounted(() => {
console.log("sliderInput.value", sliderInput.value);
if(sliderInput.value){
sliderInput.value.removeEventListener("input", setSliderValue);
}
})
</script>
<style lang="scss">
.slider-wrapper {
display: flex;
}
.testbla {
position: absolute;
height: 25px;
width: 25px;
background-color: var(--color-based-blue);
user-select: none;
top: 50%;
transform: translate(-50%, -50%);
right: 50%;
border-radius: 25px;
pointer-events: none;
z-index: 10;
}
.disabled-slider {
width: 60px;
height: 7px;
background-color: var(--color-border-main);
/* margin-top: auto; */
align-self: center;
margin-top: 2px;
position: relative;
z-index: 3;
pointer-events: none;
}
.dot {
border-radius: 50px;
height: 15px;
width: 15px;
background-color: var(--color-border-main);
position: absolute;
right: -14px;
/* bottom: 50%; */
top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
// Base Colors
$shade-10: #4682b4 !default;
$shade-1: #d7dcdf !default;
$shade-0: #fff !default;
$teal: #4682b4 !default;
// Range Slider
$range-width: 100% !default;
$range-handle-color: $shade-10 !default;
$range-handle-color-hover: $teal !default;
$range-handle-size: 15px !default;
$range-track-color: $shade-1 !default;
$range-track-height: 7px !default;
$range-label-color: $shade-10 !default;
$range-label-width: 60px !default;
.range-slider {
width: $range-width;
position: relative;
accent-color: #7550AE;
}
.range-slider__range {
// width: calc(100% - (#{$range-label-width + 13px}));
width: 100%;
height: $range-track-height;
border-radius: 5px;
background: $range-track-color;
outline: none;
padding: 0;
margin: 0;
// Range Handle
&::-webkit-slider-thumb {
appearance: none;
width: 30px;
height: 30px;
opacity: 0;
padding: 5px;
border-radius: 50%;
cursor: pointer;
}
&:active::-webkit-slider-thumb {
background: $range-handle-color-hover;
}
&::-moz-range-thumb {
width: $range-handle-size;
height: $range-handle-size;
border: 0;
border-radius: 50%;
background: $range-handle-color;
cursor: pointer;
transition: background 0.15s ease-in-out;
&:hover {
background: $range-handle-color-hover;
}
}
&:active::-moz-range-thumb {
background: $range-handle-color-hover;
}
// Focus state
&:focus {
&::-webkit-slider-thumb {
background-color: transparent;
}
}
}
// Range Label
.range-slider__value {
display: inline-block;
position: relative;
width: $range-label-width;
color: $shade-0;
line-height: 20px;
text-align: center;
border-radius: 3px;
background: $range-label-color;
padding: 5px 10px;
margin-left: 8px;
&:after {
position: absolute;
top: 8px;
left: -7px;
width: 0;
height: 0;
border-top: 7px solid transparent;
border-right: 7px solid $range-label-color;
border-bottom: 7px solid transparent;
content: "";
}
}
// Firefox Overrides
::-moz-range-track {
background: $range-track-color;
border: 0;
}
input::-moz-focus-inner,
input::-moz-focus-outer {
border: 0;
}
</style>

View file

@ -0,0 +1,14 @@
<template>
<div class="f-tag">
<slot></slot>
</div>
</template>
<style lang="sass">
.f-tag
border: 1px solid var(--color-font)
border-radius: var(--border-radius, 12px)
padding: 2px 16px
font-size: 12px
display: inline
</style>

View file

@ -0,0 +1,64 @@
<template>
<section v-show="value === props.name" ref="tab" role="tabpanel" tabindex="-1">
<slot></slot>
</section>
</template>
<script setup lang="ts">
import { onMounted, onBeforeMount, getCurrentInstance, ref, computed, watch, inject } from "vue";
const instance = ref<any>();
const isActive = ref<any>(null);
const value = inject("value");
var updateTab: any = inject("updateTab");
defineExpose({
isActive,
});
onBeforeMount(() => {});
onMounted(() => {
instance.value = getCurrentInstance();
var addTab: any = inject("addTab");
addTab({
uid: instance.value!.uid,
name: props.name,
label: props.label,
});
// resolved = {"test123": 123, "blub": "abc"}
});
// const test = computed(() => {
// console.log(instance.value);
// return instance.value?.parent
// })
//Props
const props = defineProps({
label: {
type: String,
required: true,
default: null,
},
name: {
type: [String],
required: true,
default: null,
},
});
watch(props, (newValue, oldValue) => {
console.log("newValue", newValue);
console.log("instance", instance.value);
updateTab({
uid: instance.value!.uid,
name: props.name,
label: props.label,
});
});
</script>

View file

@ -0,0 +1,126 @@
<template>
<div class="f-tabs" ref="tabsRef">
<div class="f-tabs__header" ref="headerRef">
<div
class="f-tab"
ref="tabsRef1"
:id="`f-tab-${tab.uid}`"
v-for="tab in tabs"
:key="tab.uid"
@click="setActive(tab)"
>
<h5>{{ tab.label }}</h5>
</div>
</div>
<div class="f-tabs__content">
<slot ref="tabsTest"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, h, computed, useSlots, provide, getCurrentInstance } from "vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: {
type: String,
required: false,
default: null,
},
});
const emit = defineEmits(["update:modelValue"]);
const slots1 = useSlots();
const tabsRef = ref<HTMLElement>();
const tabsRef1 = ref<HTMLElement>();
const headerRef = ref<HTMLElement>();
const tabsArray = ref<Array<HTMLElement>>();
const tabsComponents = ref<Array<any>>();
const content = ref<any>();
const target = ref<any>();
const slots = ref<any>();
const value = ref<any>();
const tabsTest = ref<any>();
const tabs = ref<Array<any>>([]);
provide("value", value);
provide("addTab", (tab: any) => {
tabs.value.push(tab);
});
provide("updateTab", (tab: any) => {
let changedTabIndex = tabs.value.findIndex((obj) => obj.uid === tab.uid);
if (changedTabIndex > -1) {
tabs.value[changedTabIndex] = tab;
}
});
// provide('addTab', tabs.value)
onMounted(() => {
nextTick(() => {
if(props.modelValue){
const tab = tabs.value.find((obj) => obj.name === props.modelValue)
setActive(tab);
} else {
setActive(tabs.value[0]);
}
});
});
function setActive(tab: any) {
nextTick(() => {
const tabElement: any = headerRef.value?.querySelector(`#f-tab-${tab.uid}`);
var array = Array.prototype.slice.call(tabsRef.value?.children[0].children);
array.forEach((element: HTMLElement) => {
element.classList.remove("f-tab--active");
});
tabElement.classList.add("f-tab--active");
value.value = tab.name;
emit("update:modelValue", tab.name);
});
}
</script>
<style lang="sass">
.f-tabs
display: flex
flex-direction: column
width: 100%
.f-tabs__header
display: flex
.f-tab, label
cursor: pointer
.f-tab
padding: 0.6em 1em
transition: background-color .2s,color .2s
align-items: center
justify-content: center
font-weight: 400
text-transform: uppercase
font-size: 24px
font-weight: bold
border-width: 0 0 2px 0
border-style: solid
border-color: transparent
flex: 1 1 auto
text-align: center
height: 50px
border-bottom: 2px solid var(--color-light-grey)
h5
color: var(--color-tab-font)
margin: 0
&.f-tab--active
border-bottom: 3px solid var(--color-tab-border--active)
h5
color: var(--color-tab-border--active)
.f-tabs__content
padding: 16px 12px
@media (min-width: 768px)
padding: 16px 24px
</style>

View file

@ -0,0 +1,72 @@
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import FTabs from './FTabs.vue';
import FTab from './FTab.vue';
describe('FTabs.vue', () => {
it('renders tabs correctly', () => {
const wrapper = mount(FTabs, {
slots: {
default: `
<f-tab label="Tab 1" name="tab1">Tab 1</f-tab>
<f-tab label="Tab 2" name="tab2">Tab 2</f-tab>
`,
},
global: {
components: { FTab },
},
});
expect(wrapper.text()).toContain('Tab 1');
expect(wrapper.text()).toContain('Tab 2');
});
it('activates the first tab by default via v-model', async () => {
const wrapper = mount(FTabs, {
slots: {
default: `
<f-tab label="Tab 1" name="tab1">Tab 1</f-tab>
<f-tab label="Tab 2" name="tab2">Tab 2</f-tab>
`,
},
props: {
modelValue: 'tab1', // v-model initial value
},
global: {
components: { FTab },
},
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const sections = wrapper.findAll('section');
expect(sections[0].attributes('style')).not.toContain('display: none');
expect(sections[1].attributes('style')).toContain('display: none');
});
it('switches to the correct tab on click', async () => {
const wrapper = mount(FTabs, {
slots: {
default: `
<f-tab label="Tab 1" name="tab1">Tab 1</f-tab>
<f-tab label="Tab 2" name="tab2">Tab 2</f-tab>
`,
},
global: {
components: { FTab },
},
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const tabs = wrapper.findAll('.f-tab');
await tabs[1].trigger('click');
const sections = wrapper.findAll('section');
expect(sections[0].attributes('style')).toContain('display: none');
expect(sections[1].attributes('style')).not.toContain('display: none');
});
});