This commit is contained in:
2025-05-24 01:49:48 +09:00
commit 62abbcf4eb
2376 changed files with 325522 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { SlotsType } from 'vue'
export default defineComponent({
name: 'ClientOnly',
slots: Object as SlotsType<{
default: void
}>,
setup(_, { slots }) {
const show = ref(false)
onMounted(() => {
show.value = true
})
return () => (show.value && slots.default ? slots.default() : null)
},
})
</script>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useVueroContext } from '/@src/composables/vuero-context.ts'
import SideblockPageHeading from '/@src/components/layouts/sideblock/SideblockPageHeading.vue'
// 페이지 제목을 context에서 받아옴
const pageTitle = useVueroContext<string>('page-title')
// 사이드바 상태 제어
import { useSideblockLayoutContext } from '/@src/components/layouts/sideblock/sideblock.context'
const { isDesktopSideblockOpen } = useSideblockLayoutContext()
onMounted(() => {
isDesktopSideblockOpen.value = true
})
</script>
<template>
<SideblockPageHeading v-if="pageTitle">
{{ pageTitle }}
<template #toolbar>
<button class="button is-primary">추가</button>
</template>
</SideblockPageHeading>
</template>

View File

@@ -0,0 +1,595 @@
<script setup lang="ts">
import { type VNode } from 'vue'
import { flewTableWrapperSymbol } from '../base/VFlexTableWrapper.vue'
export interface VFlexTableColumn {
key: string
label: string
format: (value: any, row: any, index: number) => any
renderHeader?: () => VNode
renderRow?: (row: any, column: VFlexTableColumn, index: number) => VNode
align?: 'start' | 'center' | 'end'
bold?: boolean
inverted?: boolean
scrollX?: boolean
scrollY?: boolean
grow?: boolean | 'lg' | 'xl'
media?: boolean
cellClass?: string
}
export interface VFlexTableProps {
data?: any[]
columns?: Record<string, string | Partial<VFlexTableColumn>>
printObjects?: boolean
reactive?: boolean
compact?: boolean
rounded?: boolean
separators?: boolean
clickable?: boolean
subtable?: boolean
noHeader?: boolean
usePaymentHeader?: boolean
}
const emits = defineEmits<{
(e: 'rowClick', row: any, index: number): void
}>()
const props = withDefaults(defineProps<VFlexTableProps>(), {
columns: undefined,
usePaymentHeader: false,
data: () => [],
})
const wrapper = inject(flewTableWrapperSymbol, null)
const data = computed(() => {
if (wrapper?.data) return wrapper.data
if (props.reactive) {
if (isReactive(props.data)) {
return props.data
}
else {
return reactive(props.data)
}
}
return toRaw(props.data)
})
const defaultFormatter = (value: any) => value
const columns = computed(() => {
const columnsSrc = wrapper?.columns ?? props.columns
let columns: VFlexTableColumn[] = []
if (columnsSrc) {
for (const [key, label] of Object.entries(columnsSrc)) {
if (typeof label === 'string') {
columns.push({
format: defaultFormatter,
label,
key,
})
}
else {
columns.push({
format: defaultFormatter,
label: key,
key,
...(label as any),
})
}
}
}
else if (data.value.length > 0) {
for (const [key] of Object.entries(data.value[0])) {
columns.push({
format: defaultFormatter,
label: key,
key,
})
}
}
return columns
})
</script>
<template>
<div
class="flex-table"
:class="[
props.compact && 'is-compact',
props.rounded && 'is-rounded',
props.separators && 'with-separators',
props.noHeader && 'no-header',
props.clickable && 'is-table-clickable',
props.subtable && 'sub-table',
]"
>
<slot v-if="props.usePaymentHeader" name="payment-header">
<div
v-if="!props.noHeader"
class="flex-table-header"
>
<template
v-for="column in columns"
:key="'col' + column.key"
>
<slot
name="header-column"
:column="column"
>
<component
:is="{ render: column.renderHeader } as any"
v-if="column.renderHeader"
:class="[
column.grow === true && 'is-grow',
column.grow === 'lg' && 'is-grow-lg',
column.grow === 'xl' && 'is-grow-xl',
column.align === 'end' && 'cell-end',
column.align === 'center' && 'cell-center',
]"
/>
<span
v-else
:class="[
column.grow === true && 'is-grow',
column.grow === 'lg' && 'is-grow-lg',
column.grow === 'xl' && 'is-grow-xl',
column.align === 'end' && 'cell-end',
column.align === 'center' && 'cell-center',
]"
>{{ column.label }}</span>
</slot>
</template>
</div>
</slot>
<slot v-else name="header">
<div
v-if="!props.noHeader"
class="flex-table-header"
>
<template
v-for="column in columns"
:key="'col' + column.key"
>
<slot
name="header-column"
:column="column"
>
<component
:is="{ render: column.renderHeader } as any"
v-if="column.renderHeader"
:class="[
column.grow === true && 'is-grow',
column.grow === 'lg' && 'is-grow-lg',
column.grow === 'xl' && 'is-grow-xl',
column.align === 'end' && 'cell-end',
column.align === 'center' && 'cell-center',
]"
/>
<span
v-else
:class="[
column.grow === true && 'is-grow',
column.grow === 'lg' && 'is-grow-lg',
column.grow === 'xl' && 'is-grow-xl',
column.align === 'end' && 'cell-end',
column.align === 'center' && 'cell-center',
]"
>{{ column.label }}</span>
</slot>
</template>
</div>
</slot>
<slot name="body">
<template
v-for="(row, index) in data"
:key="index"
>
<slot
name="body-row-pre"
:row="row"
:columns="columns"
:index="index"
/>
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div
class="flex-table-item"
:class="[props.clickable && 'is-clickable']"
:tabindex="props.clickable ? 0 : undefined"
:role="props.clickable ? 'button' : undefined"
@keydown.enter.prevent="
() => {
props.clickable && emits('rowClick', row, index)
}
"
@click="
() => {
props.clickable && emits('rowClick', row, index)
}
"
>
<slot
name="body-row"
:row="row"
:columns="columns"
:index="index"
>
<template
v-for="column in columns"
:key="'row' + column.key"
>
<VFlexTableCell :column="column">
<slot
name="body-cell"
:row="row"
:column="column"
:index="index"
:value="column.format(row[column.key], row, index)"
>
<component
:is="
{
render: () => column.renderRow?.(row, column, index),
} as any
"
v-if="column.renderRow"
/>
<span
v-else-if="
typeof column.format(row[column.key], row, index) === 'object'
"
:class="[
column.cellClass,
column.inverted && 'dark-inverted',
// !column.inverted && (column.bold ? 'dark-text' : 'light-text'),
]"
>
<details v-if="printObjects">
<div class="language-json py-4">
<pre><code>{{ column.format(row[column.key], row, index) }}</code></pre>
</div>
</details>
</span>
<span
v-else
:class="[
column.cellClass,
column.inverted && 'dark-inverted',
// !column.inverted && (column.bold ? 'dark-text' : 'light-text'),
]"
>
{{ column.format(row[column.key], row, index) }}
</span>
</slot>
</VFlexTableCell>
</template>
</slot>
</div>
<slot
name="body-row-post"
:row="row"
:columns="columns"
:index="index"
/>
</template>
</slot>
</div>
</template>
<style lang="scss">
.flex-table {
.flex-table-header {
display: flex;
align-items: center;
padding: 20px 10px;
background-color: var(--primary);
> span,
.text {
flex: 1 1 0;
display: flex;
align-items: center;
font-size: 1.0rem;
font-weight: 600;
color: var(--smoke-white);
text-transform: uppercase;
padding: 0 10px;
justify-content: center;
align-items: center;
display: flex;
&.is-checkbox {
display: flex;
justify-content: center;
align-items: center;
width: 30px;
max-width: 30px;
.checkbox {
padding: 0;
> span {
height: 22px;
}
}
}
&.cell-center {
justify-content: center;
}
&.cell-end {
justify-content: flex-end;
}
&.is-grow {
flex-grow: 2;
}
&.is-grow-lg {
flex-grow: 3;
}
&.is-grow-xl {
flex-grow: 6;
}
a {
color: var(--muted-grey);
}
}
.checkbox {
padding-bottom: 10px;
padding-top: 0;
> span {
min-height: 20px;
}
}
}
.flex-table-item {
display: flex;
align-items: stretch;
width: 100%;
min-height: 60px;
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
padding: 8px;
margin-bottom: 6px;
&.is-row {
border: none;
background: transparent;
}
}
&.sub-table {
.flex-table-item {
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
min-height: 40px;
border: none;
background: transparent;
.table-label {
font-family: var(--font);
text-transform: uppercase;
font-size: 0.8rem;
color: var(--light-text);
}
.table-total {
font-family: var(--font);
color: var(--dark-text);
font-weight: 500;
&.is-bigger {
font-size: 1.2rem;
font-weight: 600;
}
}
}
}
&.is-compact {
.flex-table-item {
margin-bottom: 0;
border-radius: 0;
&:not(:last-child) {
border-bottom: none;
}
}
&.is-rounded {
&:not(.no-header) {
.flex-table-item {
&:nth-of-type(2) {
border-radius: 8px 8px 0 0;
}
&:last-child {
margin-bottom: 6px;
border-radius: 0 0 8px 8px;
}
}
}
&.no-header {
.flex-table-item {
&:first-child {
border-radius: 8px 8px 0 0;
}
&:last-child {
margin-bottom: 6px;
border-radius: 0 0 8px 8px;
}
}
}
}
}
&:not(.is-compact) {
&.is-rounded {
.flex-table-item {
border-radius: 8px;
}
}
}
&.is-table-clickable {
.flex-table-item {
&:hover,
&:focus-within {
background: var(--widget-grey) !important;
}
}
}
&.with-separators {
.flex-table-item {
.flex-table-cell {
&:not(:first-of-type) {
border-inline-start: solid 1px color-mix(in oklab, var(--fade-grey), black 3%);
}
}
}
}
}
/* ==========================================================================
2. Flex Table Dark mode
========================================================================== */
.is-dark {
.flex-table {
&:not(.sub-table) {
.flex-table-item {
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
&.with-separators {
.flex-table-item {
.flex-table-cell {
&:not(:first-of-type) {
border-inline-start: dashed 1px color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
}
}
&.is-table-clickable {
.flex-table-item {
&:hover,
&:focus-within {
background: color-mix(in oklab, var(--dark-sidebar), white 12%) !important;
}
}
}
}
}
/* ==========================================================================
3. Media Queries
========================================================================== */
@media (width <= 767px) {
.flex-table {
.flex-table-header {
display: none;
}
.flex-table-item {
flex-direction: column;
justify-content: center;
width: 100% !important;
padding: 20px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
> div {
border: none !important;
}
}
&:not(.sub-table) {
.flex-table-item {
.flex-table-cell {
> span,
> small,
> strong,
> p,
> div,
> .is-pushed-mobile,
> .text {
margin-inline-start: auto;
&.no-push {
margin-inline-start: 0 !important;
}
}
}
&:not(:first-child) {
.flex-table-cell {
&[data-th] {
&::before {
content: attr(data-th);
font-size: 0.9rem;
text-transform: uppercase;
font-weight: 500;
color: var(--muted-grey);
}
}
}
}
}
}
}
}
@media only screen and (width <= 767px) {
.flex-table {
&.sub-table {
padding-top: 16px;
.is-vhidden {
display: none !important;
}
.flex-table-item:not(.is-vhidden) {
flex-direction: revert !important;
}
}
}
}
.paymentColumn1 {
min-width: 20%;
}
.paymentColumn2 {
min-width: 40%;
}
.paymentColumn3 {
min-width: 10%;
}
.paymentColumn4 {
min-width: 20%;
}
.paymentColumn5 {
min-width: 10%;
}
</style>

View File

@@ -0,0 +1,494 @@
<script lang="ts">
import type { SlotsType, InjectionKey, PropType } from 'vue'
import type { VFlexTableColumn } from '../base/VFlexTable.vue'
import VFlexTableSortColumn from '../base/VFlexTableSortColumn.vue'
export type VFlexTableWrapperDataResolver<T = any> = (parameters: {
searchTerm: string
start: number
limit: number
sort?: string
controller?: AbortController
}) => T[] | Promise<T[]>
export type VFlexTableWrapperSortFunction<T = any> = (parameters: {
key: string
column: Partial<VFlexTableWrapperColumn>
order: 'asc' | 'desc'
a: T
b: T
}) => number
export type VFlexTableWrapperFilterFunction<T = any> = (parameters: {
searchTerm: string
value: any
row: T
column: Partial<VFlexTableWrapperColumn>
index: number
}) => boolean
export interface VFlexTableWrapperColumn extends VFlexTableColumn {
searchable?: boolean
sortable?: boolean
sort?: VFlexTableWrapperSortFunction
filter?: VFlexTableWrapperFilterFunction
}
export interface VFlexTableWrapperInjection {
data?: any[] | undefined
columns?: Record<string, Partial<VFlexTableWrapperColumn>>
loading?: boolean
searchInput?: string
searchTerm?: string
start?: number
limit?: number
sort?: string
page?: number
total?: number
totalPages?: number
fetchData: (controller?: AbortController) => Promise<void>
}
export const flewTableWrapperSymbol: InjectionKey<VFlexTableWrapperInjection> = Symbol()
const defaultFormatter = (value: any) => value
const defaultSortFunction: VFlexTableWrapperSortFunction = ({ key, order, a, b }) => {
const aValue = a[key]
const bValue = b[key]
if (typeof aValue === 'string') {
if (order === 'asc') {
return aValue.localeCompare(bValue)
}
else {
return bValue.localeCompare(aValue)
}
}
if (aValue > bValue) {
return order === 'asc' ? 1 : -1
}
if (aValue < bValue) {
return order === 'asc' ? -1 : 1
}
return 0
}
export default defineComponent({
props: {
data: {
type: [Array, Function] as PropType<any[] | VFlexTableWrapperDataResolver>,
default: undefined,
},
columns: {
type: Object as PropType<Record<string, string | Partial<VFlexTableWrapperColumn>>>,
default: undefined,
},
sort: {
type: String,
default: undefined,
},
searchTerm: {
type: String,
default: undefined,
},
limit: {
type: Number,
default: undefined,
},
page: {
type: Number,
default: undefined,
},
total: {
type: Number,
default: undefined,
},
debounceSearch: {
type: Number,
default: 300,
},
},
slots: Object as SlotsType<{
default: VFlexTableWrapperInjection
}>,
emits: ['update:sort', 'update:page', 'update:limit', 'update:searchTerm'],
setup(props, context) {
const rawData = ref<any[]>()
const loading = ref(false)
const defaultSort = ref('')
const sort = computed({
get: () => props.sort ?? defaultSort.value,
set(value) {
if (props.sort === undefined) {
defaultSort.value = value
}
else {
context.emit('update:sort', value)
}
},
})
const defaultSearchInput = ref('')
const searchInput = computed({
get: () => props.searchTerm ?? defaultSearchInput.value,
set(value) {
if (props.searchTerm === undefined) {
defaultSearchInput.value = value
}
else {
context.emit('update:searchTerm', value)
}
},
})
const defaultPage = ref(1)
const page = computed({
get: () => props.page ?? defaultPage.value,
set(value) {
if (props.page === undefined) {
defaultPage.value = value
}
else {
context.emit('update:page', value)
}
},
})
const defaultLimit = ref(10)
const limit = computed({
get: () => Math.max(1, props.limit ?? defaultLimit.value),
set(value) {
if (props.limit === undefined) {
defaultLimit.value = value
}
else {
context.emit('update:limit', value)
}
},
})
const columns = computed(() => {
const columnProps = props.columns
if (!columnProps) return columnProps
const wrapperColumns: Record<string, Partial<VFlexTableWrapperColumn>> = {}
Object.keys(columnProps).reduce((acc, key) => {
const value = columnProps[key]
if (typeof value === 'string') {
acc[key] = {
format: defaultFormatter,
label: value,
key,
}
}
else if (typeof value === 'object') {
acc[key] = {
format: defaultFormatter,
label: key,
key,
...value,
}
if (value.sortable === true) {
if (value.renderHeader) {
acc[key].renderHeader = () => {
return h(
VFlexTableSortColumn,
{
'id': key,
'noRouter': true,
'modelValue': sort.value,
'onUpdate:modelValue': value => (sort.value = value),
},
{
default: value.renderHeader,
},
)
}
}
else {
acc[key].renderHeader = () => {
return h(VFlexTableSortColumn, {
'id': key,
'label': value.label ?? key,
'noRouter': true,
'modelValue': sort.value,
'onUpdate:modelValue': value => (sort.value = value),
})
}
}
}
if (value.searchable === true && !value.sort) {
acc[key].sort = defaultSortFunction
}
}
return acc
}, wrapperColumns)
return wrapperColumns
})
const filteredData = computed(() => {
let data = rawData.value
if (!data) return data
if (typeof props.data === 'function') return data
// filter data
if (searchTerm.value) {
const searchableColumns = columns.value
? (Object.values(columns.value).filter((column) => {
if (!column || typeof column === 'string') return false
return column.searchable === true
}) as Partial<VFlexTableWrapperColumn>[])
: []
if (searchableColumns.length) {
const _searchRe = new RegExp(searchTerm.value, 'i')
data = data.filter((row, index) => {
return searchableColumns.some((column) => {
if (!column.key) return false
const value = row[column.key]
if (column.filter) {
return column.filter({
searchTerm: searchTerm.value,
value,
row,
column,
index,
})
}
if (typeof value === 'string') return value.match(_searchRe)
return false
})
})
}
}
return data
})
const sortedData = computed(() => {
let data = filteredData.value
if (!data) return data
if (typeof props.data === 'function') return data
// sort data
if (sort.value && sort.value.includes(':')) {
const [sortField, sortOrder] = sort.value.split(':') as [string, 'desc' | 'asc']
const sortingColumn = columns.value
? (Object.values(columns.value).find((column) => {
if (!column || typeof column === 'string') return false
return column.sortable === true && column.key === sortField
}) as Partial<VFlexTableWrapperColumn>)
: null
if (sortingColumn) {
const sorted = [...data]
sorted.sort((a, b) => {
if (!sortingColumn.key) return 0
if (!sortingColumn.sort) return 0
return sortingColumn.sort({
order: sortOrder,
column: sortingColumn,
key: sortingColumn.key,
a,
b,
})
})
data = sorted
}
}
return data
})
const data = computed(() => {
if (typeof props.data === 'function') return rawData.value
if (!rawData.value) return rawData.value
let data = sortedData.value
// paginate data
return data?.slice(start.value, start.value + limit.value)
})
const searchTerm = useDebounce(searchInput, props.debounceSearch)
const total = computed(() => props.total ?? sortedData.value?.length ?? 0)
const start = computed(() => (page.value - 1) * limit.value)
const totalPages = computed(() =>
total.value ? Math.ceil(total.value / limit.value) : 0,
)
async function fetchData(controller?: AbortController) {
if (typeof props.data === 'function') {
loading.value = true
try {
rawData.value = await props.data({
searchTerm: searchTerm.value,
start: start.value,
limit: limit.value,
sort: sort.value,
controller,
})
}
finally {
loading.value = false
}
}
}
watch([searchTerm, limit], () => {
if (page.value !== 1) {
page.value = 1
}
})
watchEffect(async (onInvalidate) => {
let controller: AbortController
if (typeof props.data === 'function') {
controller = new AbortController()
await fetchData(controller)
}
else {
rawData.value = props.data
}
onInvalidate(() => {
controller?.abort()
})
})
const wrapperState = reactive({
data,
columns,
loading,
searchInput,
searchTerm,
start,
page,
limit,
sort,
total,
totalPages,
fetchData,
}) as VFlexTableWrapperInjection
provide(flewTableWrapperSymbol, wrapperState)
context.expose(wrapperState)
return () => {
const slotContent = context.slots.default?.(wrapperState)
return h('div', { class: 'flex-table-wrapper' }, slotContent)
}
},
})
</script>
<style lang="scss">
/* 테이블 헤더 & 로우 공통 스타일 */
.flex-table-header,
.flex-table-row {
display: flex;
width: 100%;
border-bottom: 1px solid #e0e0e0;
}
/* 개별 셀 스타일 */
.flex-table-item {
flex: 1;
padding: 12px 15px;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 컬럼별 너비 설정 */
.paymentColumn1 { flex: 0 0 15%; } /* 결재번호 */
.paymentColumn2 { flex: 0 0 30%; } /* 제목 */
.paymentColumn3 { flex: 0 0 15%; } /* 작성자 */
.paymentColumn4 { flex: 0 0 20%; } /* 등록일 */
.paymentColumn5 { flex: 0 0 20%; } /* 구분 */
.flex-table-wrapper {
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
border-radius: 8px;
padding: 20px;
.flex-table {
.flex-table-item {
margin-bottom: 0;
border-radius: 0;
border-inline-start: none;
border-inline-end: none;
border-top: none;
&:last-child {
margin-bottom: 6px;
border-bottom: none;
}
&:focus-visible {
border-radius: 4px;
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
}
}
}
/* ==========================================================================
6. Flex Table advanced wrapper Dark mode
========================================================================== */
.is-dark {
.flex-table-wrapper {
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
/* ==========================================================================
9. Media Queries
========================================================================== */
@media (width <= 767px) {
.flex-table-wrapper {
.flex-table {
.flex-table-header {
.is-checkbox {
display: none;
}
}
.flex-table-item {
padding-inline-start: 0;
padding-inline-end: 0;
.is-checkbox {
display: none;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import {useCodes} from '/src/stores/codeStore.ts'
const detailCode = useCodes()
export interface VSelectProps {
raw?: boolean
multiple?: boolean
cd_grp?: string
placeholder?: string
}
defineOptions({
inheritAttrs: false,
})
const modelValue = defineModel<any>({
default: '',
})
const props = defineProps<VSelectProps>()
const attrs = useAttrs()
const { field, id } = useVFieldContext({
create: false,
help: 'VSelect',
})
const internal = computed({
get() {
if (field?.value) {
return field.value.value
}
else {
return modelValue.value
}
},
set(value: any) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
const classes = computed(() => {
if (props.raw) return []
return ['select', props.multiple && 'is-multiple']
})
const cdItems = ref<Array<{ cd: string; nm: string }>>([])
watch(() => props.cd_grp, async (newVal) => {
if (newVal) {
await detailCode.setDetailCode(newVal)
cdItems.value = detailCode.getCodeList(newVal)
}
}, { immediate: true })
</script>
<template>
<div :class="classes">
<select
:id="id"
v-bind="attrs"
v-model="internal"
:name="id"
:multiple="props.multiple"
@change="field?.handleChange"
@blur="field?.handleBlur"
>
<option value="">{{ props.placeholder || '선택하세요' }}</option>
<option
v-for="item in cdItems"
:key="item.cd"
:value="item.cd"
>
<slot name="code" :item="item">
{{ item.nm }}
</slot>
</option>
</select>
</div>
</template>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import { type Person } from '/@src/utils/types'
import { getUserList } from '/src/service/UserApi'
const model = defineModel<Person[]>()
const userSession = useUserSession()
const dataUser = reactive({
userSession: null,
})
// onBeforeMount(() => {
// dataUser.userSession = userSession.user.data
// const sessioinData = [{
// gubunCd: '0000', //구분
// gubunNm: '입안',
// deptNm: dataUser.userSession.dept.deptNm,
// sabun: dataUser.userSession.sabun,
// name: dataUser.userSession.name,
// attendCd: '', // 비고
//
// }]
// model.value = sessioinData
// })
const initiator = ref<Person | null>(null)
const approvers = ref<Person[]>([])
onBeforeMount(() => {
dataUser.userSession = userSession.user.data
initiator.value = {
gubunCd: '0000',
gubunNm: '입안',
deptNm: dataUser.userSession.dept.deptNm,
sabun: dataUser.userSession.sabun,
name: dataUser.userSession.name,
attendCd: '',
}
})
// 부모에게 넘길 결재선 전체
const apprLine = computed(() => {
return initiator.value ? [initiator.value, ...approvers.value] : [...approvers.value]
})
watch(apprLine, (val) => {
model.value = val
})
const props = defineProps({
label: {
type: String,
default: '직원검색',
},
placeholder: {
type: String,
},
disabled: {
type: Boolean
}
})
const tagsOptions = ref([])
const onKeyup = async (e: any) => {
const regex_jaeum = /[ㄱ-ㅎ]/g
if (e.key === 'Process') {
if (e.target.value.match(regex_jaeum) === null) {
try {
const res = await getUserList({params:{name : e.target.value}})
if (res.length > 0) {
res.forEach(u => {
if (model.value?.length > 0) {
const ignore = model.value.reduce((a: Person, b: Person) => {
return a.sabun + '|' + b.sabun
})
if (typeof ignore !== 'object') {
if (ignore.includes(u.sabun)) {
u['disabled'] = true
}
} else {
if (ignore.sabun.includes(u.sabun)) {
u['disabled'] = true
}
}
}
})
tagsOptions.value = res
}
} catch (e) {
console.error('Error fetching user list:', e)
}
}
if (e.target.value === '') {
tagsOptions.value = []
}
}
}
</script>
<template>
<VField v-slot="{ id }" class="pr-2 is-autocomplete-select">
<VLabel class="has-fullwidth">
{{props.label}}
</VLabel>
<VControl icon="lucide:search">
<Multiselect
v-model="approvers"
:attrs="{ id }"
:searchable="true"
:disabled="props.disabled"
:options="tagsOptions"
mode="multiple"
noResultsText="조회된 결과가 없습니다."
noOptionsText="검색된 결과가 없습니다."
:placeholder="props.placeholder"
@keyup="onKeyup"
>
<template #multiplelabel="{ values }">
<div class="multiselect-multiple-label pl-6">
{{props.placeholder}}
</div>
</template>
</Multiselect>
</VControl>
</VField>
</template>
<style scoped lang="scss">
.field {
padding-bottom: 10px !important;
.label {
font-size: 1.3em;
color: var(--modal-text) !important;
}
}
</style>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import type { ChartOptions, Chart } from 'billboard.js'
import 'billboard.js/dist/billboard.min.css'
export interface VBillboardJSEmits {
(e: 'ready', billboard: Chart): void
}
export interface VBillboardJSProps {
options: ChartOptions
}
const emit = defineEmits<VBillboardJSEmits>()
const props = defineProps<VBillboardJSProps>()
const element = ref<HTMLElement>()
onMounted(async () => {
if (!element.value) return
try {
const bb = await import('billboard.js').then(m => m.default || m)
const billboard = bb.generate({
...props.options,
bindto: element.value,
})
emit('ready', billboard)
nextTick(() => {
billboard.resize()
})
}
catch (error) {
console.error(error)
}
})
</script>
<template>
<div ref="element" />
</template>
<style lang="scss">
.bb-title {
font-family: var(--font-alt) !important;
font-size: 1rem !important;
font-weight: 600 !important;
color: var(--dark-text);
}
.bb-legend-background,
.bb-chart-arcs-background {
fill: none;
}
.bb-axis line,
.bb-axis .domain {
color: color-mix(in oklab, var(--fade-grey), black 4%);
stroke: color-mix(in oklab, var(--fade-grey), black 4%);
fill: none;
}
.tick {
text tspan {
fill: color-mix(in oklab, var(--light-text), black 5%);
}
}
.is-dark {
.bb-title {
fill: var(--dark-dark-text) !important;
}
.bb-axis line,
.bb-axis .domain {
color: color-mix(in oklab, var(--dark-sidebar), white 20%) !important;
stroke: color-mix(in oklab, var(--dark-sidebar), white 20%) !important;
}
.bb-legend {
.bb-legend-background rect {
fill: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
color: color-mix(in oklab, var(--dark-sidebar), white 12%) !important;
stroke: color-mix(in oklab, var(--dark-sidebar), white 12%) !important;
}
.bb-legend-item {
text {
fill: var(--dark-dark-text);
}
}
}
.bb-chart-arc path {
color: color-mix(in oklab, var(--dark-sidebar), white 12%) !important;
stroke: color-mix(in oklab, var(--dark-sidebar), white 12%) !important;
}
.bb-chart-arc .bb-gauge-value {
fill: var(--light-text) !important;
}
.bb-chart-arcs .bb-chart-arcs-background {
color: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
fill: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
stroke: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
}
.bb-chart-arcs-title,
.bb-gauge-value,
.bb-axis text {
fill: var(--dark-dark-text);
}
.bb-tooltip {
border: 1px solid color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
// background-color: var(--white);
th {
border-color: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
background-color: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
color: #fffdfd !important;
font-family: var(--font) !important;
font-weight: 400 !important;
span {
font-family: var(--font) !important;
font-weight: 400 !important;
color: #fffdfd !important;
}
}
tr {
border-color: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
}
td {
background-color: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
color: var(--light-text) !important;
> span,
> .iconify {
border-color: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
fill: var(--white) !important;
color: var(--white) !important;
}
}
.bb-tooltip-title {
color: #fffdfd !important;
}
.bb-tooltip-detail {
.bb-tooltip-name,
.bb-tooltip-value {
color: #fffdfd !important;
span {
color: #fffdfd !important;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import type { VCreditCardColor } from '/@src/composables/credit-card'
export interface VCreditCardEmits {
(e: 'flip'): void
}
export interface VCreditCardProps {
number?: string
name?: string
expiry?: string
cvc?: string | number
color?: VCreditCardColor
flipped?: boolean
}
const emit = defineEmits<VCreditCardEmits>()
const props = withDefaults(defineProps<VCreditCardProps>(), {
color: 'grey',
name: 'John Doe',
number: '1234 1234 1234 1234',
cvc: '123',
expiry: '01/30',
})
const { t } = useI18n()
const nameUppercase = computed(() => props.name?.toUpperCase() ?? '')
</script>
<template>
<div class="card-container">
<div
:class="[props.flipped && 'flipped']"
class="creditcard"
role="button"
tabindex="0"
@keydown.enter.prevent="emit('flip')"
@click="emit('flip')"
>
<div class="front">
<slot />
<svg
id="cardfront"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 750 471"
style="enable-background: new 0 0 750 471"
xml:space="preserve"
>
<g id="Front2">
<g id="CardBackground">
<g id="Page-1_1_">
<g id="amex_1_">
<path
id="Rectangle-1_1_"
class="lightcolor"
:class="props.color"
d="M40,0h670c22.1,0,40,17.9,40,40v391c0,22.1-17.9,40-40,40H40c-22.1,0-40-17.9-40-40V40
C0,17.9,17.9,0,40,0z"
/>
</g>
</g>
<path
class="darkcolor"
:class="`${props.color}dark`"
d="M750,431V193.2c-217.6-57.5-556.4-13.5-750,24.9V431c0,22.1,17.9,40,40,40h670C732.1,471,750,453.1,750,431z"
/>
</g>
<text
id="svgnumber"
transform="matrix(1 0 0 1 60.106 295.0121)"
class="st2 st3 st4"
>
{{ props.number }}
</text>
<text
id="svgname"
transform="matrix(1 0 0 1 54.1064 428.1723)"
class="st2 st5 st6"
>
{{ nameUppercase }}
</text>
<text
transform="matrix(1 0 0 1 54.1074 389.8793)"
class="st7 st5 st8"
>
{{ t('components.v-credit-card.holder-label') }}
</text>
<text
transform="matrix(1 0 0 1 479.7754 388.8793)"
class="st7 st5 st8"
>
{{ t('components.v-credit-card.expiration-label') }}
</text>
<text
transform="matrix(1 0 0 1 65.1054 241.5)"
class="st7 st5 st8"
>
{{ t('components.v-credit-card.number-label') }}
</text>
<g>
<text
id="svgexpire"
transform="matrix(1 0 0 1 574.4219 433.8095)"
class="st2 st5 st9"
>
{{ props.expiry }}
</text>
<text
transform="matrix(1 0 0 1 479.3848 417.0097)"
class="st2 st10 st11"
>
{{ t('components.v-credit-card.valid-label') }}
</text>
<text
transform="matrix(1 0 0 1 479.3848 435.6762)"
class="st2 st10 st11"
>
{{ t('components.v-credit-card.valid-thru-label') }}
</text>
<polygon
class="st2"
points="554.5,421 540.4,414.2 540.4,427.9"
/>
</g>
<g id="cchip">
<g>
<path
class="st2"
d="M168.1,143.6H82.9c-10.2,0-18.5-8.3-18.5-18.5V74.9c0-10.2,8.3-18.5,18.5-18.5h85.3
c10.2,0,18.5,8.3,18.5,18.5v50.2C186.6,135.3,178.3,143.6,168.1,143.6z"
/>
</g>
<g>
<g>
<rect
x="82"
y="70"
class="st12"
width="1.5"
height="60"
/>
</g>
<g>
<rect
x="167.4"
y="70"
class="st12"
width="1.5"
height="60"
/>
</g>
<g>
<path
class="st12"
d="M125.5,130.8c-10.2,0-18.5-8.3-18.5-18.5c0-4.6,1.7-8.9,4.7-12.3c-3-3.4-4.7-7.7-4.7-12.3
c0-10.2,8.3-18.5,18.5-18.5s18.5,8.3,18.5,18.5c0,4.6-1.7,8.9-4.7,12.3c3,3.4,4.7,7.7,4.7,12.3
C143.9,122.5,135.7,130.8,125.5,130.8z M125.5,70.8c-9.3,0-16.9,7.6-16.9,16.9c0,4.4,1.7,8.6,4.8,11.8l0.5,0.5l-0.5,0.5
c-3.1,3.2-4.8,7.4-4.8,11.8c0,9.3,7.6,16.9,16.9,16.9s16.9-7.6,16.9-16.9c0-4.4-1.7-8.6-4.8-11.8l-0.5-0.5l0.5-0.5
c3.1-3.2,4.8-7.4,4.8-11.8C142.4,78.4,134.8,70.8,125.5,70.8z"
/>
</g>
<g>
<rect
x="82.8"
y="82.1"
class="st12"
width="25.8"
height="1.5"
/>
</g>
<g>
<rect
x="82.8"
y="117.9"
class="st12"
width="26.1"
height="1.5"
/>
</g>
<g>
<rect
x="142.4"
y="82.1"
class="st12"
width="25.8"
height="1.5"
/>
</g>
<g>
<rect
x="142"
y="117.9"
class="st12"
width="26.2"
height="1.5"
/>
</g>
</g>
</g>
</g>
<g id="Back" />
</svg>
</div>
<div class="back">
<svg
id="cardback"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 750 471"
style="enable-background: new 0 0 750 471"
xml:space="preserve"
>
<g id="Front">
<line
class="st0"
x1="35.3"
y1="10.4"
x2="36.7"
y2="11"
/>
</g>
<g id="Back2">
<g id="Page-1_2_">
<g id="amex_2_">
<path
id="Rectangle-1_2_"
class="darkcolor"
:class="`${props.color}dark`"
d="M40,0h670c22.1,0,40,17.9,40,40v391c0,22.1-17.9,40-40,40H40c-22.1,0-40-17.9-40-40V40
C0,17.9,17.9,0,40,0z"
/>
</g>
</g>
<rect
y="61.6"
class="st2"
width="750"
height="78"
/>
<g>
<path
class="st3"
d="M701.1,249.1H48.9c-3.3,0-6-2.7-6-6v-52.5c0-3.3,2.7-6,6-6h652.1c3.3,0,6,2.7,6,6v52.5
C707.1,246.4,704.4,249.1,701.1,249.1z"
/>
<rect
x="42.9"
y="198.6"
class="st4"
width="664.1"
height="10.5"
/>
<rect
x="42.9"
y="224.5"
class="st4"
width="664.1"
height="10.5"
/>
<path
class="st5"
d="M701.1,184.6H618h-8h-10v64.5h10h8h83.1c3.3,0,6-2.7,6-6v-52.5C707.1,187.3,704.4,184.6,701.1,184.6z"
/>
</g>
<text
id="svgsecurity"
transform="matrix(1 0 0 1 621.999 227.2734)"
class="st6 st7"
>
{{ props.cvc }}
</text>
<g class="st8">
<text
transform="matrix(1 0 0 1 518.083 280.0879)"
class="st9 st6 st10"
>
{{ t('components.v-credit-card.cvc-label') }}
</text>
</g>
<rect
x="58.1"
y="378.6"
class="st11"
width="375.5"
height="13.5"
/>
<rect
x="58.1"
y="405.6"
class="st11"
width="421.7"
height="13.5"
/>
<text
id="svgnameback"
transform="matrix(1 0 0 1 59.5073 228.6099)"
class="st12 st13"
>
{{ props.name }}
</text>
</g>
</svg>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,182 @@
<script lang="ts">
import type { FilePondEvent, FilePondOptions } from 'filepond'
import * as FilePond from 'filepond'
import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size'
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type'
import FilePondPluginImageExitOrientation from 'filepond-plugin-image-exif-orientation'
import FilePondPluginImageCrop from 'filepond-plugin-image-crop'
import FilePondPluginImageEdit from 'filepond-plugin-image-edit'
import FilePondPluginImagePreview from 'filepond-plugin-image-preview'
import FilePondPluginImageResize from 'filepond-plugin-image-resize'
import FilePondPluginImageTransform from 'filepond-plugin-image-transform'
import 'filepond/dist/filepond.min.css'
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css'
import 'filepond-plugin-image-edit/dist/filepond-plugin-image-edit.min.css'
import { type PropType, type ComponentObjectPropsOptions, type EmitsOptions } from 'vue'
type FilePondSize = undefined | 'small' | 'tiny'
const plugins = [
FilePondPluginFileValidateSize,
FilePondPluginFileValidateType,
FilePondPluginImageExitOrientation,
FilePondPluginImageCrop,
FilePondPluginImageEdit,
FilePondPluginImagePreview,
FilePondPluginImageResize,
FilePondPluginImageTransform,
]
FilePond.registerPlugin(...plugins)
const types = {
boolean: Boolean,
int: Number,
number: Number,
string: String,
array: Array,
object: Object,
function: Function,
// action: Function, not used
serverapi: Object,
// regex: String, not used
}
// Setup initial prop types and update when plugins are added
const getNativeConstructorFromType = (type: keyof typeof types) => {
if (!type) {
return String
}
return types[type]
}
const _OptionTypes = FilePond.OptionTypes as Record<string, keyof typeof types>
// Activated props
const propsOptions: ComponentObjectPropsOptions = {}
// Events that need to be mapped to emitters
const eventNames: EmitsOptions = []
const defaultOptions = FilePond.getOptions() as Record<string, any>
for (const prop in _OptionTypes) {
// don't add events to the props array
if (/^on/.test(prop)) {
eventNames.push(prop.replace('on', ''))
continue
}
// get property type ( can be either a String or the type defined within FilePond )
propsOptions[prop] = {
type: getNativeConstructorFromType(_OptionTypes[prop]),
default: () => defaultOptions[prop],
}
}
export default defineComponent({
name: 'VFilePond',
props: {
...propsOptions,
size: {
type: String as PropType<FilePondSize>,
default: undefined,
validator: (value: FilePondSize) => {
// The value must match one of these strings
if ([undefined, 'small', 'tiny'].indexOf(value) === -1) {
console.warn(
`VFilePond: invalid "${value}" size. Should be small, tiny or undefined`,
)
return false
}
return true
},
},
},
emits: ['input', ...eventNames],
setup(props, { emit, expose }) {
const pond = ref<FilePond.FilePond>()
const inputElement = ref<HTMLInputElement>()
const pondOptions = Object.assign({}, { ...props }) as FilePondOptions
expose({
pond,
inputElement,
})
onMounted(() => {
if (inputElement.value && FilePond.supported()) {
pond.value = FilePond.create(inputElement.value, {
...pondOptions,
fileValidateTypeDetectType: (source, type) =>
new Promise((resolve, reject) => {
if (pondOptions.acceptedFileTypes) {
const index = pondOptions.acceptedFileTypes.findIndex(
allowedType => allowedType === type,
)
if (index > -1) {
resolve(type)
return
}
}
reject()
}),
})
for (const eventName of eventNames) {
const event = eventName as FilePondEvent
if (event) {
pond.value.on(event, (...event) => {
emit('input', pond.value ? pond.value.getFiles() : [])
emit(eventName, ...event)
})
}
}
}
})
onUnmounted(() => {
if (pond.value) {
for (const eventName of eventNames) {
const event = eventName as FilePondEvent
if (event) {
pond.value.off(event, (event) => {
emit(eventName, event)
})
}
}
pond.value.destroy()
}
})
return () => {
const input = h('input', {
type: 'file',
ref: inputElement,
id: pondOptions.id,
name: pondOptions.name,
class: pondOptions.className,
required: pondOptions.required,
accept: pondOptions.acceptedFileTypes,
multiple: pondOptions.allowMultiple,
capture: pondOptions.captureMethod,
})
const wrapper = h('div', { class: 'filepond--wrapper' }, [input])
return h(
'div',
{
class: ['filepond-profile-wrap', props.size && `is-${props.size}`],
},
[wrapper],
)
}
},
})
</script>

View File

@@ -0,0 +1,76 @@
<script lang="ts" setup generic="Opts extends FactoryArg">
import type { InputMask, FactoryArg, UpdateOpts } from 'imask'
import IMask from 'imask'
const props = defineProps<{
modelValue: string
options: Opts
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'accept': [value: InputMask<Opts>, event?: InputEvent]
'complete': [value: InputMask<Opts>, event?: InputEvent]
}>()
const inputElement = ref<HTMLElement>()
const inputMask = shallowRef<InputMask<Opts>>()
watch([inputElement, () => props.options, () => props.modelValue], () => {
if (inputElement.value && props.options) {
try {
if (inputMask.value) {
inputMask.value.updateOptions(props.options as UpdateOpts<Opts>)
inputMask.value.unmaskedValue = props.modelValue
return
}
inputMask.value = IMask(inputElement.value, props.options ?? {})
if (props.modelValue) {
inputMask.value.unmaskedValue = props.modelValue
inputMask.value.updateValue()
emit('accept', inputMask.value, undefined)
}
inputMask.value.on('accept', (inputEvent) => {
if (!inputMask.value) return
emit('update:modelValue', inputMask.value?.value || '')
emit('accept', inputMask.value, inputEvent)
})
inputMask.value.on('complete', (inputEvent) => {
if (!inputMask.value) return
emit('complete', inputMask.value, inputEvent)
})
}
catch (error) {
console.error(
'VIMaskInput: bad imask options, see https://imask.js.org/ for available parameters',
)
console.error(error)
}
}
})
onUnmounted(() => {
if (inputMask.value) {
inputMask.value.destroy()
inputMask.value = undefined
}
})
defineExpose({
inputMask,
})
</script>
<template>
<input
ref="inputElement"
type="text"
:value="props.modelValue"
>
</template>

View File

@@ -0,0 +1,497 @@
<script setup lang="ts">
import type {
BUILT_IN_COMMANDS,
CommandTrigger,
TextareaMarkdownOptions,
Command,
} from 'textarea-markdown-editor/dist/esm/types'
import type { Cursor } from 'textarea-markdown-editor/dist/esm/Cursor.new'
import { bootstrapTextareaMarkdown } from 'textarea-markdown-editor/dist/esm/bootstrap'
type VMarkdownEditorAction = (typeof BUILT_IN_COMMANDS)[number]
type VMarkdownEditorContext = {
textarea: HTMLTextAreaElement
cursor: Cursor
trigger: CommandTrigger
value: string
}
type VMarkdownEditorCommandAction = {
icon: string
tooltip?: string
label?: string
action: VMarkdownEditorAction | ((ctx: VMarkdownEditorContext) => void | Promise<void>)
}
type VMarkdownEditorCommandGroup = {
icon: string
tooltip?: string
vertical?: boolean
label?: string
children: Record<string, VMarkdownEditorCommandAction>
}
type VMarkdownEditorToolbar = Record<
string,
VMarkdownEditorCommandAction | VMarkdownEditorCommandGroup
>
const modelValue = defineModel<string>({
default: '',
})
const props = withDefaults(
defineProps<{
autogrow?: boolean
options?: Partial<TextareaMarkdownOptions>
commands?: Command[]
toolbar?: VMarkdownEditorToolbar
}>(),
{
options: () => ({
enableIndentExtension: true,
enableLinkPasteExtension: true,
enableOrderedListAutoCorrectExtension: true,
enablePrefixWrappingExtension: true,
enableProperLineRemoveBehaviorExtension: true,
}),
commands: () => [],
toolbar: () => ({
'bold': {
icon: 'ci:bold',
tooltip: 'Bold (Ctrl + B)',
action: 'bold',
},
'italic': {
icon: 'ci:italic',
tooltip: 'Italic (Ctrl + I)',
action: 'italic',
},
'strike-through': {
icon: 'ci:strikethrough',
tooltip: 'Strike Through (Ctrl + Shift + X)',
action: 'strike-through',
},
'headings': {
icon: 'ci:heading',
tooltip: 'Headings',
children: {
h1: {
icon: 'ci:heading-h1',
tooltip: 'H1',
action: 'h1',
},
h2: {
icon: 'ci:heading-h2',
tooltip: 'H2',
action: 'h2',
},
h3: {
icon: 'ci:heading-h3',
tooltip: 'H3',
action: 'h3',
},
h4: {
icon: 'ci:heading-h4',
tooltip: 'H4',
action: 'h4',
},
h5: {
icon: 'ci:heading-h5',
tooltip: 'H5',
action: 'h5',
},
h6: {
icon: 'ci:heading-h6',
tooltip: 'H6',
action: 'h6',
},
},
},
'unordered-list': {
icon: 'ci:list-ul',
tooltip: 'Unordered List',
action: 'unordered-list',
},
'ordered-list': {
icon: 'ci:list-ol',
tooltip: 'Ordered List',
action: 'ordered-list',
},
'code-block': {
icon: 'ci:terminal',
tooltip: 'Code Block',
action: 'code-block',
},
'code-inline': {
icon: 'ci:code',
tooltip: 'Code Inline',
action: 'code-inline',
},
// code: {
// icon: 'ci:code',
// tooltip: 'Code',
// },
'link': {
icon: 'ci:link',
tooltip: 'Link',
action: 'link',
},
'image': {
icon: 'ci:image',
tooltip: 'Image',
action: 'image',
},
'block-quotes': {
icon: 'ci:double-quotes-l',
tooltip: 'Block Quotes',
action: 'block-quotes',
},
}),
},
)
const { field, id } = useVFieldContext({
help: 'VMarkdownEditor',
})
const textareaRef = ref<HTMLTextAreaElement>()
const mode = ref<'write' | 'preview'>('write')
const trigger = shallowRef<CommandTrigger>()
const cursor = shallowRef<Cursor>()
const internal = computed({
get() {
if (field?.value) {
return String(field.value.value)
}
else {
return modelValue.value
}
},
set(value: string) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
function fitSize() {
if (!textareaRef.value) {
return
}
if (props.autogrow) {
textareaRef.value.style.height = 'auto'
textareaRef.value.style.height = textareaRef.value.scrollHeight + 'px'
}
}
function triggerAction(
action: VMarkdownEditorAction | ((ctx: VMarkdownEditorContext) => void | Promise<void>),
) {
if (typeof action === 'function') {
action({
textarea: textareaRef.value!,
cursor: cursor.value!,
trigger: trigger.value!,
value: internal.value,
})
}
else {
trigger.value?.(action)
}
}
watchEffect((cleanup) => {
if (textareaRef.value) {
const mde = bootstrapTextareaMarkdown(textareaRef.value, {
options: props.options, // optional options config
commands: [], // optional commands configs
})
trigger.value = mde.trigger
cursor.value = mde.cursor
fitSize()
cleanup(mde.dispose)
}
})
</script>
<template>
<div class="markdown-editor">
<VFlex
justify-content="space-between"
class="toolbar"
>
<VFlexItem class="toolbar-mode">
<VButtons addons>
<VAction
dark="2"
:active="mode === 'write'"
@click="mode = 'write'"
>
<VIcon icon="lucide:edit-3" />
<span>Write</span>
</VAction>
<VAction
dark="2"
:disabled="!internal"
:active="mode === 'preview'"
@click="mode = 'preview'"
>
<VIcon icon="lucide:eye" />
<span>Preview</span>
</VAction>
</VButtons>
</VFlexItem>
<VFlexItem class="toolbar-actions">
<!-- toolbar -->
<VButtons
v-if="mode === 'write'"
addons
>
<div
v-for="(command, key) in props.toolbar"
:key="key"
class="toolbar-item"
>
<VAction
v-if="'action' in command"
v-tooltip.rounded="command.tooltip"
dark="2"
class="toolbar-action"
@click.prevent="() => triggerAction(command.action)"
>
<VIcon
v-if="command.icon"
:icon="command.icon"
/>
<span v-if="command.label">{{ command.label }}</span>
</VAction>
<VDropdown
v-else
class="toolbar-dropdown"
:class="[command.vertical && 'is-vertical']"
>
<template #button="dropdown">
<VAction
v-tooltip.rounded="command.tooltip"
dark="2"
:active="dropdown.isOpen"
class="toolbar-dropdown-trigger"
@keydown.enter.prevent="dropdown.toggle"
@click="dropdown.toggle"
>
<VIcon
v-if="command.icon"
:icon="command.icon"
/>
<span v-if="command.label">{{ command.label }}</span>
</VAction>
</template>
<template #content>
<VButtons
class="mt-1"
addons
>
<VAction
v-for="(sub, subkey) in command.children"
:key="`action-${subkey}`"
v-tooltip.rounded="sub.tooltip"
class="toolbar-dropdown-action"
dark="2"
@click.prevent="() => triggerAction(sub.action)"
>
<VIcon
v-if="sub.icon"
:icon="sub.icon"
/>
<span v-if="sub.label">{{ sub.label }}</span>
</VAction>
</VButtons>
</template>
</VDropdown>
</div>
</VButtons>
</VFlexItem>
</VFlex>
<!-- textarea input -->
<slot
v-if="mode === 'write'"
name="before-textarea"
/>
<textarea
v-show="mode === 'write'"
:id="id"
ref="textareaRef"
v-model="internal"
v-bind="$attrs"
class="textarea mt-0"
autocomplete="no"
rows="10"
@input="fitSize"
/>
<slot
v-if="mode === 'write'"
name="after-textarea"
/>
<slot
v-if="mode === 'preview'"
name="preview"
v-bind="{ value: internal }"
>
<VCard radius="smooth">
<VMarkdownPreview :source="internal" />
</VCard>
</slot>
</div>
</template>
<style lang="scss" scoped>
.markdown-editor {
margin-top: 2.5rem;
margin-bottom: 1.5rem;
.toolbar {
margin-bottom: 0.5rem;
--primary-box-shadow: none;
.buttons {
margin-bottom: 0;
}
.button {
margin-bottom: 0;
}
}
.toolbar-actions {
.buttons {
.button {
padding: 0 8px;
height: 31px;
border-radius: 0;
:deep(.iconify) {
font-size: 1.2rem;
}
}
}
}
.textarea {
max-height: 500px;
font-family: var(--font-monospace);
font-size: 0.9rem;
}
:deep(.dropdown-menu) {
padding: 0;
min-width: 0;
.dropdown-content {
padding: 0;
.buttons {
flex-wrap: nowrap;
.button {
margin-bottom: 0;
}
}
}
}
.toolbar-dropdown-action.button {
&:first-of-type {
border-start-start-radius: 3px;
border-start-end-radius: 0;
border-end-start-radius: 3px;
border-end-end-radius: 0;
}
&:last-of-type {
border-start-start-radius: 0;
border-start-end-radius: 3px;
border-end-start-radius: 0;
border-end-end-radius: 3px;
}
}
.toolbar-item {
&:first-of-type {
.toolbar-action {
border-start-start-radius: 3px;
border-end-start-radius: 3px;
}
.toolbar-dropdown-trigger {
border-start-start-radius: 3px;
border-end-start-radius: 3px;
}
}
&:last-of-type {
.toolbar-action {
border-start-end-radius: 3px;
border-end-end-radius: 3px;
}
.toolbar-dropdown-trigger {
border-start-end-radius: 3px;
border-end-end-radius: 3px;
}
}
// &:not(:last-of-type) {
// margin-inline-start: -1px;
// }
~ .toolbar-item {
margin-inline-start: -1px;
}
}
.toolbar-dropdown {
&.is-vertical {
.buttons {
align-items: stretch;
.button {
place-content: normal;
}
}
.toolbar-dropdown-action.button {
&:first-of-type {
border-start-start-radius: 3px;
border-start-end-radius: 3px;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
&:last-of-type {
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 3px;
border-end-end-radius: 3px;
}
}
.buttons {
flex-direction: column;
.toolbar-dropdown-action {
margin-bottom: -1px;
margin-inline-end: 0;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,255 @@
<script lang="ts">
import type { BuiltinLanguage, BuiltinTheme } from 'shiki'
import { h, type PropType } from 'vue'
async function loadModules() {
const [
rehypeShiki,
rehypeExternalLinks,
rehypeRaw,
[rehypeSanitize, defaultSchema],
rehypeStringify,
rehypeSlug,
rehypeAutolinkHeadings,
remarkGfm,
remarkParse,
remarkRehype,
unified,
] = await Promise.all([
import('@shikijs/rehype').then(m => m.default),
import('rehype-external-links').then(m => m.default),
import('rehype-raw').then(m => m.default),
import('rehype-sanitize').then(m => [m.default, m.defaultSchema] as const),
import('rehype-stringify').then(m => m.default),
import('rehype-slug').then(m => m.default),
import('rehype-autolink-headings').then(m => m.default),
import('remark-gfm').then(m => m.default),
import('remark-parse').then(m => m.default),
import('remark-rehype').then(m => m.default),
import('unified').then(m => m.unified),
])
return {
rehypeShiki,
rehypeExternalLinks,
rehypeRaw,
rehypeSanitize,
defaultSchema,
rehypeStringify,
rehypeSlug,
rehypeAutolinkHeadings,
remarkGfm,
remarkParse,
remarkRehype,
unified,
}
}
export default defineComponent({
name: 'VMarkdownPreview',
props: {
source: {
type: String,
default: '',
},
size: {
type: String as PropType<undefined | 'large' | 'medium' | 'small'>,
default: undefined,
},
maxWidth: {
type: String as PropType<undefined | 'fullwidth' | 'medium' | 'small'>,
default: undefined,
},
shiki: {
type: Object as PropType<{
langs: BuiltinLanguage[]
theme:
| BuiltinTheme
| {
light: BuiltinTheme
dark: BuiltinTheme
}
}>,
default: () => ({
theme: {
light: 'min-light',
dark: 'github-dark',
},
langs: ['vue', 'vue-html', 'typescript', 'bash', 'scss'],
}),
},
},
async setup(props) {
const processor = ref<any>()
const html = ref('')
const loadProcessors = async () => {
const langs = props.shiki.langs
const themes = {
light:
typeof props.shiki.theme === 'string'
? props.shiki.theme
: props.shiki.theme.light,
dark:
typeof props.shiki.theme === 'string'
? props.shiki.theme
: props.shiki.theme.dark,
}
const {
rehypeShiki,
rehypeExternalLinks,
rehypeRaw,
rehypeSanitize,
defaultSchema,
rehypeStringify,
rehypeSlug,
rehypeAutolinkHeadings,
remarkGfm,
remarkParse,
remarkRehype,
unified,
} = await loadModules()
processor.value = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
pre: [...(defaultSchema.attributes?.pre || []), ['className'], ['style']],
code: [...(defaultSchema.attributes?.code || []), ['className'], ['style']],
i: [...(defaultSchema.attributes?.i || []), ['className']],
span: [
...(defaultSchema.attributes?.span || []),
['className'],
['style'],
['dataHint'],
],
},
})
.use(rehypeShiki, {
themes,
langs,
})
.use(rehypeExternalLinks, { rel: ['nofollow'], target: '_blank' })
.use(rehypeSlug)
.use(rehypeAutolinkHeadings, {
behavior: 'append',
content: {
type: 'element',
tagName: 'iconify-icon',
properties: {
className: ['iconify toc-link-anchor'],
icon: 'lucide:link',
},
children: [],
},
})
.use(rehypeStringify)
}
const processMd = async () => {
const _source = unref(props.source)
const _processor = unref(processor)
if (!_processor) return
if (!_source) return
const result = (await _processor.process(_source)).toString()
html.value = result
}
if (import.meta.env.SSR) {
await loadProcessors()
await processMd()
}
else {
watchEffect(loadProcessors)
watchEffect(processMd)
}
const classes = computed(() => {
return {
'markdown content': true,
'is-max-width-fullwidth': props.maxWidth === 'fullwidth',
'is-max-width-medium': props.maxWidth === 'medium',
'is-max-width-small': props.maxWidth === 'small',
'is-small': props.size === 'small',
'is-medium': props.size === 'medium',
'is-large': props.size === 'large',
}
})
return () => {
return h('div', {
class: classes.value,
innerHTML: html.value,
})
}
},
})
</script>
<style lang="scss" scoped>
.is-max-width-full {
max-width: 100%;
}
.is-max-width-medium {
max-width: 48rem;
}
.is-max-width-small {
max-width: 42rem;
}
.markdown {
:deep(a) {
color: var(--primary);
}
&.is-small {
font-size: 0.875rem;
:deep(pre) {
padding: 0.5rem 0.8rem 0.4rem;
}
}
:deep(.toc-link-anchor) {
color: var(--light-text);
margin-inline-start: 0.5rem;
font-size: 1rem;
transition: color 0.2s;
outline: none;
&:hover,
&:focus {
color: var(--primary);
}
}
:deep(.shiki) {
border-radius: var(--radius-large);
// code {
// counter-reset: step;
// counter-increment: step 0;
// }
// code .line::before {
// content: counter(step);
// counter-increment: step;
// width: 1rem;
// margin-inline-end: 1.5rem;
// display: inline-block;
// text-align: inset-inline-end;
// color: #898d98;
// }
}
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import type { PeityOptions, PeityType } from '/@src/utils/peity/types'
import { drawBar, drawLine, drawPie } from '/@src/utils/peity'
export interface VPeityProps {
values: number[]
type: PeityType
min?: number
max?: number
radius?: number
innerRadius?: number
height?: number
width?: number
padding?: number
stroke?: string
strokeWidth?: number
fill?: string[]
}
const props = withDefaults(defineProps<VPeityProps>(), {
type: 'line',
radius: 8,
padding: 0.1,
innerRadius: 5,
min: undefined,
max: undefined,
height: 16,
width: 16,
stroke: undefined,
strokeWidth: 1,
fill: undefined,
values: () => [],
})
const svgElement = ref<HTMLElement>()
const svgHeight = computed(() => {
const height = props.height || 16
if (props.type === 'pie' || props.type === 'donut') {
const diameter = props.radius * 2
return height || diameter
}
return height
})
const svgWidth = computed(() => {
const width = props.width || 16
if (props.type === 'pie' || props.type === 'donut') {
const diameter = props.radius * 2
return width || diameter
}
return width
})
watchPostEffect(() => {
if (!svgElement.value) {
return
}
const element = svgElement.value
element.innerHTML = ''
const opts: PeityOptions = {
type: props.type,
height: props.height,
width: props.width,
fill: (idx: number): string => {
const f = props.fill ?? []
return f[idx % f.length]
},
}
switch (props.type) {
case 'bar':
opts.min = props.min
opts.padding = props.padding
drawBar(element, props.values, opts)
break
case 'line':
opts.min = props.min
opts.stroke = props.stroke
opts.strokeWidth = props.strokeWidth
drawLine(element, props.values, opts)
break
case 'pie':
case 'donut':
opts.radius = props.radius
opts.innerRadius = props.innerRadius
drawPie(element, props.values, opts)
break
}
})
</script>
<template>
<svg
ref="svgElement"
xmlns="http://www.w3.org/2000/svg"
class="peity"
:height="svgHeight"
:width="svgWidth"
/>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import PhotoSwipeLightbox, { type PhotoSwipeOptions } from 'photoswipe/lightbox'
import 'photoswipe/style.css'
export interface VPhotoSwipeItem {
src: string
msrc?: string
thumbnail?: string
alt?: string
w?: number
h?: number
title?: string
el?: HTMLElement
}
export interface VPhotoSwipeProps {
items?: VPhotoSwipeItem[]
options?: PhotoSwipeOptions
singleThumbnail?: boolean
thumbnailRadius?: string
}
const props = withDefaults(defineProps<VPhotoSwipeProps>(), {
items: () => [],
options: () => ({}),
thumbnailRadius: undefined,
})
const { onceError } = useImageError()
let lightbox: PhotoSwipeLightbox | null = null
const galleryElement = ref<HTMLElement>()
onMounted(() => {
lightbox = new PhotoSwipeLightbox({
gallery: galleryElement.value,
pswpModule: () => import('photoswipe'),
...props.options,
children: 'a',
})
lightbox.init()
})
onUnmounted(() => {
if (lightbox) {
lightbox.destroy()
lightbox = null
}
})
</script>
<template>
<div
ref="galleryElement"
class="my-gallery"
itemscope
itemtype="http://schema.org/ImageGallery"
>
<figure
v-for="(item, index) in items"
v-show="index === 0 || !singleThumbnail"
:key="index"
class="gallery-thumbnail"
itemprop="associatedMedia"
itemscope
itemtype="http://schema.org/ImageObject"
:src="item.src"
>
<a
:href="item.src"
:title="item.title"
itemprop="contentUrl"
:data-pswp-width="item.w"
:data-pswp-height="item.h"
data-cropped="true"
target="_blank"
rel="noreferrer"
>
<img
:class="[thumbnailRadius && `radius-${thumbnailRadius}`]"
:src="item.thumbnail"
:alt="item.alt"
itemprop="thumbnail"
@error.once="onceError($event, item.w, item.h)"
>
</a>
</figure>
</div>
</template>
<style lang="scss" scoped>
.gallery-thumbnail {
display: inline;
margin: 5px;
}
</style>

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import type { Options } from 'plyr'
import 'plyr/dist/plyr.css'
export type VPlyrCaptions = {
src: string
srclang: string
default?: boolean
}
export type VPlyrFormat = '4by3' | '16by9' | 'square'
export interface VPlyrProps {
source: string
title?: string
poster?: string
captions?: VPlyrCaptions[]
reversed?: boolean
embed?: boolean
ratio?: VPlyrFormat
options?: Options
}
const props = withDefaults(defineProps<VPlyrProps>(), {
ratio: '16by9',
title: '',
poster: '',
options: () => ({}),
captions: () => [],
})
const player = ref()
const videoElement = ref<HTMLElement>()
onMounted(async () => {
if (videoElement.value) {
const Plyr = await import('plyr').then(mod => mod.default || mod)
player.value = new Plyr(videoElement.value, props.options)
}
})
onBeforeUnmount(() => {
if (player.value) {
player.value.destroy()
player.value = undefined
}
})
</script>
<template>
<div
class="video-player-container"
:class="[ratio && 'is-' + ratio, reversed && 'reversed-play']"
>
<!-- video element -->
<iframe
v-if="embed"
:src="`${source}`"
:title="props.title"
allowfullscreen
allowtransparency
allow="autoplay"
/>
<video
v-else
ref="videoElement"
controls
crossorigin="anonymous"
playsinline
:data-poster="poster"
>
<source
:src="source"
type="video/mp4"
>
<track
v-for="(caption, key) in props.captions"
:key="key"
:default="caption.default"
kind="captions"
:srclang="caption.srclang"
:src="caption.src"
>
</video>
</div>
</template>
<style lang="scss">
.video-player-container-wrapper {
max-width: 840px;
margin: 0 auto;
}
.video-player-container {
margin: 0 auto;
overflow: hidden;
iframe {
position: absolute;
top: 0;
inset-inline-start: 0;
width: 100%;
height: 100%;
}
&.is-square {
position: relative;
height: 440px;
width: 480px;
.plyr {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 100%;
width: 100%;
display: block;
}
}
&.is-4by3 {
position: relative;
padding-top: 75%;
width: 100%;
.plyr {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 100%;
width: 100%;
display: block;
}
}
&.is-16by9 {
position: relative;
padding-top: 56.25%;
width: 100%;
.plyr {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 100%;
width: 100%;
display: block;
}
}
&.reversed-play {
.plyr--full-ui.plyr--video .plyr__control--overlaid {
background: var(--white) !important;
border: 1px solid var(--primary);
color: var(--primary) !important;
&:hover,
&:focus {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: var(--white) !important;
.iconify {
fill: var(--white) !important;
stroke: var(--white) !important;
}
}
.iconify {
fill: none;
stroke: var(--primary);
stroke-width: 1.6px;
}
}
}
video {
background-color: transparent !important;
}
}
.plyr__video-wrapper {
height: 100%;
}
.plyr--full-ui.plyr--video .plyr__control--overlaid {
background: var(--primary) !important;
box-shadow: var(--primary-box-shadow);
}
.plyr--video .plyr__control.plyr__tab-focus,
.plyr--video .plyr__control:hover,
.plyr--video .plyr__control[aria-expanded='true'],
.plyr__menu__container .plyr__control[role='menuitemradio'][aria-checked='true']::before {
background: var(--primary);
}
.plyr--full-ui input[type='range'] {
color: var(--primary);
}
.plyr__controls {
transition: all 0.3s; // transition-all test
}
.plyr--paused,
.plyr--stopped {
.plyr__controls {
opacity: 0;
pointer-events: none;
}
}
@media only screen and (width <= 767px) {
.video-player-container {
&.is-square {
height: 303px;
width: 330px;
}
}
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/vue'
export interface VReloadPromptProps {
appName: string
}
const loading = ref(false)
const props = defineProps<VReloadPromptProps>()
const { t } = useI18n()
const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW()
const close = async () => {
loading.value = false
offlineReady.value = false
needRefresh.value = false
}
const update = async () => {
loading.value = true
await updateServiceWorker()
loading.value = false
}
</script>
<template>
<Transition name="from-bottom">
<VCard
v-if="offlineReady || needRefresh"
class="pwa-toast"
role="alert"
radius="smooth"
>
<div class="pwa-message">
<span v-if="offlineReady">
{{ t('components.v-reload-prompt.offline-ready', { appName: props.appName }) }}
</span>
<span v-else>
{{ t('components.v-reload-prompt.need-refresh', { appName: props.appName }) }}
</span>
</div>
<VButtons align="right">
<VButton
v-if="needRefresh"
color="primary"
icon="ion:reload-outline"
:loading="loading"
@click="() => update()"
>
{{ t('components.v-reload-prompt.reload-button') }}
</VButton>
<VButton
icon="lucide:x"
@click="close"
>
{{ t('components.v-reload-prompt.close-button') }}
</VButton>
</VButtons>
</VCard>
</Transition>
</template>
<style lang="scss">
.pwa-toast {
position: fixed;
inset-inline-end: 0;
bottom: 0;
max-width: 350px;
margin: 16px;
padding: 12px;
border: 1px solid #8885;
border-radius: 4px;
z-index: 10;
text-align: inset-inline-start;
box-shadow: 3px 4px 5px 0 #8885;
}
.pwa-message {
padding: 0.5rem 1rem;
margin-bottom: 1rem;
font-size: 1.1rem;
}
</style>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import Vivus from 'vivus'
export default defineComponent({
props: {
options: {
type: Object,
default: () => ({}),
},
},
emits: ['ready'],
setup(props, { emit }) {
const element = ref<HTMLElement>()
watchEffect(() => {
if (element.value) {
new Vivus(element.value, props.options, (vivus: any) => {
emit('ready', vivus)
})
}
})
return () => h('div', { ref: element, class: 'vivus-svg' })
},
})
</script>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
interface VAccordionProps {
items: {
title: string
content: string
}[]
openItems?: number[]
exclusive?: boolean
}
const props = withDefaults(defineProps<VAccordionProps>(), {
items: () => [],
openItems: () => [],
})
const internalOpenItems = ref(props.openItems)
const toggle = (key: number) => {
const wasOpen = internalOpenItems.value.includes(key)
if (props.exclusive) {
internalOpenItems.value.splice(0, internalOpenItems.value.length)
if (!wasOpen) {
internalOpenItems.value.push(key)
}
return
}
if (wasOpen) {
internalOpenItems.value.splice(internalOpenItems.value.indexOf(key), 1)
}
else {
internalOpenItems.value.push(key)
}
}
</script>
<template>
<div
class="single-accordion"
:class="[exclusive && 'is-exclusive']"
>
<details
v-for="(item, key) in items"
:key="key"
class="accordion-item"
:open="internalOpenItems?.includes(key) ?? undefined"
:class="[internalOpenItems?.includes(key) && 'is-active']"
>
<slot
name="accordion-item"
:item="item"
:index="key"
:toggle="toggle"
>
<summary
class="accordion-header"
tabindex="0"
role="button"
@keydown.enter.prevent="() => toggle(key)"
@click.prevent="() => toggle(key)"
>
<slot
name="accordion-item-summary"
:item="item"
:index="key"
:toggle="toggle"
>
{{ item.title }}
</slot>
</summary>
<div class="accordion-content">
<slot
name="accordion-item-content"
:item="item"
:index="key"
:toggle="toggle"
>
{{ item.content }}
</slot>
</div>
</slot>
</details>
</div>
</template>
<style lang="scss">
.single-accordion {
background: var(--white);
margin: 0 auto;
box-shadow: var(--light-box-shadow);
border-radius: var(--radius-large);
overflow: hidden;
.accordion-item {
&.is-active {
.accordion-header {
&::before {
background-color: var(--primary);
}
}
.accordion-content {
display: block;
}
}
}
.accordion-header {
border-bottom: 1px solid #dde0e7;
color: var(--dark-text);
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
font-family: var(--font-alt);
padding: 1.5rem;
display: block;
&:hover,
&:focus {
background: #f6f7f9;
}
&::before {
content: '';
vertical-align: middle;
display: inline-block;
width: 0.75rem;
height: 0.75rem;
border-radius: var(--radius-rounded);
background-color: #b1b5be;
margin-inline-end: 0.75rem;
}
}
.accordion-content {
display: none;
border-bottom: 1px solid #dde0e7;
background: #f6f7f9;
padding: 1.5rem;
color: var(--light-text);
font-family: var(--font);
}
}
.is-dark {
.single-accordion {
background: color-mix(in oklab, var(--dark-sidebar), white 4%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
.accordion-header {
color: var(--dark-dark-text);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
&:hover,
&:focus {
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
}
&::before {
background: var(--dark-sidebar);
}
&.is-active {
&::before {
background-color: var(--primary);
}
}
}
.accordion-content {
background: var(--dark-sidebar);
border-color: color-mix(in oklab, var(--dark-sidebar), white 8%);
}
}
}
</style>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
export interface VAccordionImageItem {
title: string
content: string
image: string
}
export interface VAccordionImageEmits {
(e: 'select', key: string | number): void
}
export interface VAccordionImageProps {
items: VAccordionImageItem[]
}
const emit = defineEmits<VAccordionImageEmits>()
const props = withDefaults(defineProps<VAccordionImageProps>(), {
items: () => [],
})
const toggle = (key: number) => {
emit('select', key)
}
</script>
<template>
<div class="image-accordion">
<ul>
<li
v-for="(item, key) in props.items"
:key="key"
class="has-background-image"
tabindex="0"
:style="{ backgroundImage: `url(${item.image})` }"
>
<slot
name="accordion-item"
:item="item"
:index="key"
:toggle="toggle"
>
<div>
<a
tabindex="0"
role="button"
@keydown.enter.prevent="toggle(key)"
@click="toggle(key)"
>
<h2>
<slot
name="accordion-item-summary"
:item="item"
:index="key"
:toggle="toggle"
>
{{ item.title }}
</slot>
</h2>
<p>
<slot
name="accordion-item-content"
:item="item"
:index="key"
:toggle="toggle"
>
{{ item.content }}
</slot>
</p>
</a>
</div>
</slot>
</li>
</ul>
</div>
</template>
<style lang="scss">
$a-height: 250px;
$text-offset: $a-height - 90;
.image-accordion {
width: 100%;
height: $a-height;
overflow: hidden;
margin: 50px auto;
ul {
width: 100%;
display: table;
table-layout: fixed;
margin: 0;
padding: 0;
li {
display: table-cell;
vertical-align: bottom;
position: relative;
width: 16.666%;
height: $a-height;
background-repeat: no-repeat;
background-position: center center;
transition: all 500ms ease;
div {
display: block;
overflow: hidden;
width: 100%;
a {
display: block;
height: $a-height;
width: 100%;
position: relative;
z-index: 3;
vertical-align: bottom;
padding: 15px 20px;
box-sizing: border-box;
color: var(--white);
text-decoration: none;
font-family: 'Open Sans', sans-serif;
transition: all 200ms ease;
* {
opacity: 0;
margin: 0;
width: 100%;
text-overflow: ellipsis;
position: relative;
z-index: 5;
white-space: nowrap;
overflow: hidden;
transform: translateX(calc(var(--transform-direction) * -20px));
transition: all 400ms ease;
}
h2 {
font-family: var(--font-alt);
font-weight: 300;
text-overflow: clip;
font-size: 1.4rem;
text-transform: uppercase;
margin-bottom: 0;
top: $text-offset;
}
p {
top: $text-offset;
font-size: 13.5px;
color: var(--white);
}
}
}
}
&:hover li,
&:focus-within li {
width: 8%;
}
&:hover li:hover,
li:focus,
&:focus-within li:focus {
width: 60%;
a {
background: rgb(0 0 0 / 40%);
* {
opacity: 1;
transform: translateX(calc(var(--transform-direction) * 0));
}
}
}
&:hover li {
width: 8% !important;
a * {
opacity: 0 !important;
}
}
&:hover li:hover {
width: 60% !important;
a {
background: rgb(0 0 0 / 40%);
* {
opacity: 1 !important;
transform: translateX(calc(var(--transform-direction) * 0));
}
}
}
}
}
@media screen and (width <= 600px) {
.image-accordion {
height: auto;
ul,
ul:hover {
li,
li:hover {
position: relative;
display: table;
table-layout: fixed;
width: 100% !important;
transition: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { type RouteLocationAsString } from 'unplugin-vue-router'
export type VActionDark = '1' | '2' | '3' | '4' | '5' | '6'
export interface VActionProps {
to?: RouteLocationAsString
dark?: VActionDark
active?: boolean
rounded?: boolean
grey?: boolean
}
const props = withDefaults(defineProps<VActionProps>(), {
to: undefined,
dark: undefined,
})
</script>
<template>
<RouterLink
v-if="props.to"
:to="props.to"
class="button v-action"
:class="[
props.active && 'is-active',
props.rounded && 'is-rounded',
props.dark && `is-dark-bg-${props.dark}`,
props.grey && 'is-grey',
]"
>
<slot />
</RouterLink>
<button
v-else
class="button v-action"
:class="[
props.active && 'is-active',
props.rounded && 'is-rounded',
props.dark && `is-dark-bg-${props.dark}`,
props.grey && 'is-grey',
]"
>
<slot />
</button>
</template>
<style lang="scss">
.button {
font-family: var(--font);
transition: all 0.3s; // transition-all test
&.v-action {
padding: 8px 16px;
font-weight: 500;
font-size: 0.9rem;
line-height: 0;
border-radius: 3px;
background: var(--white);
color: var(--dark-text);
border: 1px solid var(--placeholder);
transition: border-color 0.3s; // transition-all test
cursor: pointer;
box-shadow: none !important;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
&.is-rounded {
border-radius: 500px;
}
&:focus-visible {
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
&:hover,
&:focus {
border-color: var(--primary);
box-shadow: var(--primary-box-shadow);
}
&:not(.is-active) {
&:focus {
color: var(--dark-text) !important;
}
&:active {
background: var(--smoke-white);
}
}
&:focus-visible {
outline-color: var(--primary);
}
&.is-grey {
background: color-mix(in oklab, var(--fade-grey), white 2%);
border-color: color-mix(in oklab, var(--fade-grey), white 2%);
color: var(--muted-grey);
}
&.is-active {
background: var(--primary);
border-color: var(--primary);
color: var(--smoke-white);
box-shadow: var(--primary-box-shadow);
}
}
}
.is-dark {
.button {
&.v-action {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
color: var(--dark-dark-text);
&:hover,
&:focus {
background: var(--primary);
border-color: var(--primary);
color: var(--smoke-white);
text-shadow: 0 0 1px rgb(0 0 0 / 70%);
}
&:focus {
color: var(--smoke-white) !important;
}
&.is-active {
background: var(--primary) !important;
border-color: var(--primary) !important;
box-shadow: var(--primary-box-shadow) !important;
color: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
text-shadow: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,362 @@
<script setup lang="ts" generic="T extends unknown">
export type VAnimatedCheckboxColor =
| 'primary'
| 'info'
| 'success'
| 'warning'
| 'danger'
| 'purple'
defineOptions({
inheritAttrs: false,
})
const modelValue = defineModel<T[]>({
default: () => [],
})
const props = withDefaults(
defineProps<{
value: T
color?: VAnimatedCheckboxColor
}>(),
{
color: undefined,
},
)
const animatedCheckboxId = ref<string | undefined>()
const element = ref<HTMLElement>()
const innerElement = ref<HTMLElement>()
const checked = computed(() =>
Boolean(modelValue.value.find(item => toRaw(item) === toRaw(props.value))),
)
onMounted(() => {
animatedCheckboxId.value = `v-animated-checkbox-${crypto.randomUUID()}`
})
const updateCheckbox = () => {
if (element.value && innerElement.value) {
if (checked.value) {
element.value.classList.add('is-checked')
innerElement.value.classList.add('is-opaque')
setTimeout(() => {
element.value?.classList.remove('is-unchecked')
}, 150)
}
else {
element.value.classList.add('is-unchecked')
element.value.classList.remove('is-checked')
setTimeout(() => {
innerElement.value?.classList.remove('is-opaque')
}, 150)
}
}
}
function change() {
const values = [...modelValue.value]
const index = values.findIndex(item => toRaw(item) === toRaw(props.value))
if (index > -1) {
values.splice(index, 1)
}
else {
values.push(toRaw(props.value))
}
modelValue.value = values
}
watchEffect(updateCheckbox)
</script>
<template>
<div
ref="element"
class="animated-checkbox"
:class="[color && 'is-' + color]"
>
<input
:id="animatedCheckboxId"
type="checkbox"
:value="value"
v-bind="$attrs"
@change="change"
>
<label
:for="animatedCheckboxId"
class="checkmark-wrap"
>
<div
ref="innerElement"
class="shadow-circle"
/>
<svg
class="checkmark"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52"
>
<circle
class="checkmark-circle"
cx="26"
cy="26"
r="25"
fill="none"
/>
<path
class="checkmark-check"
fill="none"
d="M14.1 27.2l7.1 7.2 16.7-16.8"
/>
</svg>
</label>
</div>
</template>
<style lang="scss">
$curve: cubic-bezier(0.65, 0, 0.45, 1);
.animated-checkbox {
position: relative;
height: 32px;
width: 32px;
&:focus-within {
border-radius: 50%;
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
&.is-purple {
.checkmark-circle {
color: var(--purple) !important;
stroke: var(--purple) !important;
}
.checkmark {
box-shadow: inset 0 0 0 var(--purple) !important;
}
.checkmark-check {
color: var(--purple) !important;
stroke: var(--purple) !important;
}
}
&.is-primary {
.checkmark-circle {
color: var(--primary) !important;
stroke: var(--primary) !important;
}
.checkmark {
box-shadow: inset 0 0 0 var(--primary) !important;
}
.checkmark-check {
color: var(--primary) !important;
stroke: var(--primary) !important;
}
}
&.is-info {
.checkmark-circle {
color: var(--info) !important;
stroke: var(--info) !important;
}
.checkmark {
box-shadow: inset 0 0 0 var(--info) !important;
}
.checkmark-check {
color: var(--info) !important;
stroke: var(--info) !important;
}
}
&.is-success {
.checkmark-circle {
color: var(--success) !important;
stroke: var(--success) !important;
}
.checkmark {
box-shadow: inset 0 0 0 var(--success) !important;
}
.checkmark-check {
color: var(--success) !important;
stroke: var(--success) !important;
}
}
&.is-warning {
.checkmark-circle {
color: var(--warning) !important;
stroke: var(--warning) !important;
}
.checkmark {
box-shadow: inset 0 0 0 var(--warning) !important;
}
.checkmark-check {
color: var(--warning) !important;
stroke: var(--warning) !important;
}
}
&.is-danger {
.checkmark-circle {
color: var(--red) !important;
stroke: var(--red) !important;
}
.checkmark {
box-shadow: inset 0 0 0 var(--red) !important;
}
.checkmark-check {
color: var(--red) !important;
stroke: var(--red) !important;
}
}
input {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 100%;
width: 100%;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.checkmark-wrap {
position: relative;
height: 32px;
width: 32px;
display: inline-block;
.shadow-circle {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 32px;
width: 32px;
border-radius: var(--radius-rounded);
border: 1px solid var(--placeholder);
z-index: 0;
opacity: 1;
transition: all 0.2s;
&.is-opaque {
opacity: 0;
}
}
.checkmark-circle {
height: 32px;
width: 32px;
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
fill: none;
color: var(--primary);
stroke: var(--primary);
}
// Checkmark
.checkmark {
width: 32px;
height: 32px;
border-radius: var(--radius-rounded);
display: block;
stroke-width: 2;
color: var(--placeholder);
stroke: var(--placeholder);
stroke-miterlimit: 10;
margin: 0 auto;
box-shadow: inset 0 0 0 var(--primary);
}
// Check symbol
.checkmark-check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
color: var(--primary);
stroke: var(--primary);
}
}
&.is-checked {
.checkmark-circle {
animation: stroke 0.6s $curve both;
}
.checkmark-check {
animation: stroke 0.3s $curve 0.8s both;
}
}
&.is-unchecked {
.checkmark-circle {
animation: reverseCircle 0.6s $curve 0.2s both;
}
.checkmark-check {
animation: reverseCheck 0.3s $curve 0.1s both;
}
}
// Keyframes
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
@keyframes reverseCircle {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 166;
}
}
@keyframes reverseCheck {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 48;
}
}
}
.is-dark {
.animated-checkbox {
.checkmark-wrap {
.checkmark-circle,
.checkmark-check {
color: var(--primary);
}
.shadow-circle {
border-color: color-mix(in oklab, var(--dark-sidebar), white 20%);
}
}
}
}
</style>

View File

@@ -0,0 +1,751 @@
<script setup lang="ts">
export type VAvatarSize = 'small' | 'medium' | 'large' | 'big' | 'xl'
export type VAvatarColor =
| 'primary'
| 'success'
| 'info'
| 'warning'
| 'danger'
| 'h-purple'
| 'h-orange'
| 'h-blue'
| 'h-green'
| 'h-red'
| 'h-yellow'
export type VAvatarDotColor = 'primary' | 'success' | 'info' | 'warning' | 'danger'
export interface VAvatarProps {
picture?: string
pictureDark?: string
placeholder?: string
badge?: string
initials?: string
size?: VAvatarSize
color?: VAvatarColor
dotColor?: VAvatarDotColor
squared?: boolean
dot?: boolean
}
const props = withDefaults(defineProps<VAvatarProps>(), {
picture: undefined,
pictureDark: undefined,
placeholder: 'https://via.placeholder.com/50x50',
initials: '?',
badge: undefined,
size: undefined,
color: undefined,
dotColor: undefined,
})
const { onceError } = useImageError()
</script>
<template>
<div
class="v-avatar"
:class="[
size && `is-${props.size}`,
dot && 'has-dot',
dotColor && `dot-${props.dotColor}`,
squared && dot && 'has-dot-squared',
]"
>
<slot name="avatar">
<img
v-if="props.picture"
class="avatar"
:class="[props.squared && 'is-squared', props.pictureDark && 'light-image']"
:src="props.picture"
alt=""
@error.once="onceError($event, props.placeholder)"
>
<span
v-else
class="avatar is-fake"
:class="[props.squared && 'is-squared', props.color && `is-${props.color}`]"
>
<span>{{ props.initials }}</span>
</span>
<img
v-if="props.picture && props.pictureDark"
class="avatar dark-image"
:class="[props.squared && 'is-squared']"
:src="props.pictureDark"
alt=""
@error.once="onceError($event, props.placeholder)"
>
</slot>
<slot name="badge">
<img
v-if="props.badge"
class="badge"
:src="props.badge"
alt=""
@error.once="onceError($event, props.placeholder)"
>
</slot>
</div>
</template>
<style lang="scss">
.v-avatar {
position: relative;
display: inline-block;
vertical-align: bottom;
&.has-dot {
&::after {
content: '';
position: absolute;
top: 1px;
inset-inline-end: 1px;
height: 12px;
width: 12px;
border-radius: var(--radius-rounded);
background: var(--success);
border: 1.8px solid var(--white);
}
&.has-dot-squared {
&::after {
top: -3px;
inset-inline-end: -3px;
}
}
&.dot-primary {
&::after {
background: var(--primary);
}
}
&.dot-info {
&::after {
background: var(--info);
}
}
&.dot-warning {
&::after {
background: var(--warning);
}
}
&.dot-danger {
&::after {
background: var(--danger);
}
}
&.dot-grey {
&::after {
background: var(--light-text);
}
}
}
.avatar {
width: 40px;
min-width: 40px;
height: 40px;
object-fit: cover;
border: 2px solid var(--white);
&.is-squared {
border-radius: 10px !important;
}
&.is-fake {
display: flex;
justify-content: center;
align-items: center;
background: var(--fade-grey);
border-radius: var(--radius-rounded);
&.is-primary {
background: color-mix(in oklab, var(--primary), white 80%);
span {
color: var(--primary);
}
}
&.is-accent {
background: color-mix(in oklab, var(--primary), white 80%);
span {
color: var(--primary);
}
}
&.is-success {
background: color-mix(in oklab, var(--success), white 80%);
span {
color: var(--success);
}
}
&.is-info {
background: color-mix(in oklab, var(--info), white 80%);
span {
color: var(--info);
}
}
&.is-warning {
background: color-mix(in oklab, var(--warning), white 80%);
span {
color: var(--warning);
}
}
&.is-danger {
background: color-mix(in oklab, var(--danger), white 80%);
span {
color: var(--danger);
}
}
&.is-h-purple {
background: color-mix(in oklab, var(--purple), white 80%);
span {
color: var(--purple);
}
}
&.is-h-orange {
background: color-mix(in oklab, var(--orange), white 80%);
span {
color: var(--orange);
}
}
&.is-h-blue {
background: color-mix(in oklab, var(--blue), white 80%);
span {
color: var(--blue);
}
}
&.is-h-red {
background: color-mix(in oklab, var(--red), white 80%);
span {
color: var(--red);
}
}
&.is-h-green {
background: color-mix(in oklab, var(--green), white 80%);
span {
color: var(--green);
}
}
&.is-h-yellow {
background: color-mix(in oklab, var(--yellow), white 80%);
span {
color: color-mix(in oklab, var(--yellow), black 8%);
}
}
span {
position: relative;
display: block;
font-size: 1rem;
font-weight: 500;
text-transform: uppercase;
color: var(--muted-grey);
}
}
&.is-more {
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--radius-rounded);
border-width: 0;
.inner {
width: 40px;
min-width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
border: 2px solid var(--white);
border-radius: var(--radius-rounded);
background: color-mix(in oklab, var(--fade-grey), white 2%);
// border: 1px solid var(--fade-grey);
span {
line-height: 1;
position: relative;
// top: -1px;
inset-inline-start: -2px;
display: block;
font-size: 0.9rem;
font-weight: 500;
color: var(--light-text);
}
}
}
}
.badge {
position: absolute;
bottom: 0;
inset-inline-end: 0;
height: 16px;
width: 16px;
border: 1px solid var(--white);
}
img {
display: block;
border-radius: var(--radius-rounded);
}
&.is-small {
max-width: 32px;
min-width: 32px;
max-height: 32px;
&.has-dot {
&::after {
content: '';
top: 0;
inset-inline-end: 0;
height: 8px;
width: 8px;
border-width: 1.4px;
}
&.has-dot-squared {
&::after {
top: -2px;
inset-inline-end: -2px;
}
}
}
.avatar {
width: 32px;
min-width: 32px;
height: 32px;
&.is-squared {
border-radius: 8px !important;
}
&.is-fake,
&.is-more {
width: 32px;
min-width: 32px;
height: 32px;
.inner {
width: 32px;
min-width: 32px;
height: 32px;
}
span {
font-size: 0.85rem;
}
}
}
.badge {
border-width: 1px;
width: 12px;
height: 12px;
}
}
&.is-medium {
max-width: 50px;
min-width: 50px;
&.has-dot {
&::after {
content: '';
top: 1px;
inset-inline-end: 1px;
height: 12px;
width: 12px;
border-width: 2px;
}
&.has-dot-squared {
&::after {
top: -3px;
inset-inline-end: -3px;
}
}
}
.avatar {
width: 50px;
min-width: 50px;
height: 50px;
&.is-squared {
border-radius: 12px !important;
}
&.is-fake,
&.is-more {
width: 50px;
min-width: 50px;
height: 50px;
.inner {
width: 50px;
min-width: 50px;
height: 50px;
}
span {
font-size: 1.2rem;
}
}
}
.badge {
border-width: 2px;
height: 20px;
width: 20px;
}
}
&.is-large {
max-width: 68px;
min-width: 68px;
&.has-dot {
&::after {
content: '';
top: 4px;
inset-inline-end: 4px;
height: 14px;
width: 14px;
border-width: 2.6px;
}
&.has-dot-squared {
&::after {
top: -4px;
inset-inline-end: -1px;
}
}
}
.avatar {
width: 68px;
min-width: 68px;
height: 68px;
&.is-squared {
border-radius: 16px !important;
}
&.is-fake {
width: 68px;
min-width: 68px;
height: 68px;
span {
font-size: 1.4rem;
}
}
}
.badge {
border-width: 2px;
height: 24px;
width: 24px;
}
}
&.is-big {
max-width: 80px;
min-width: 80px;
&.has-dot {
&::after {
content: '';
top: 4px;
inset-inline-end: 4px;
height: 16px;
width: 16px;
border-width: 2.8px;
}
&.has-dot-squared {
&::after {
top: -4px;
inset-inline-end: -1px;
}
}
}
.avatar {
width: 80px;
min-width: 80px;
height: 80px;
&.is-squared {
border-radius: 18px !important;
}
&.is-fake {
width: 80px;
min-width: 80px;
height: 80px;
span {
font-size: 1.4rem;
}
}
}
.badge {
border-width: 2.4px;
height: 28px;
width: 28px;
}
}
&.is-xl {
max-width: 100px;
min-width: 100px;
&.has-dot {
&::after {
content: '';
top: 6px;
inset-inline-end: 5px;
height: 18px;
width: 18px;
border-width: 2.8px;
}
&.has-dot-squared {
&::after {
top: -3px;
inset-inline-end: -3px;
}
}
}
.avatar {
width: 100px;
min-width: 100px;
height: 100px;
&.is-squared {
border-radius: 22px !important;
}
&.is-fake {
width: 100px;
min-width: 100px;
height: 100px;
span {
font-size: 1.6rem;
}
}
}
.badge {
border-width: 3px;
height: 34px;
width: 34px;
}
}
}
.avatar-stack {
display: flex;
.v-avatar {
border-radius: var(--radius-rounded);
&.is-small {
border-radius: var(--radius-rounded);
&:not(:first-child) {
$var: 12;
@for $i from 1 through 99 {
&:nth-child(#{$i}) {
margin-inline-start: -#{$var}px;
}
}
}
}
&.is-medium {
border-radius: var(--radius-rounded);
&:not(:first-child) {
$var: 16;
@for $i from 1 through 99 {
&:nth-child(#{$i}) {
margin-inline-start: -#{$var}px;
}
}
}
}
&:not(:first-child) {
$var: 14;
@for $i from 1 through 99 {
&:nth-child(#{$i}) {
margin-inline-start: -#{$var}px;
}
}
}
}
}
.is-dark {
.v-avatar {
&.has-dot {
&::after {
border-color: color-mix(in oklab, var(--dark-sidebar), white 6%);
}
}
}
.avatar {
border-color: color-mix(in oklab, var(--dark-sidebar), white 6%);
&.is-fake {
&.is-primary {
background: var(--primary);
span {
color: var(--white);
}
}
&.is-accent {
background: var(--primary);
span {
color: var(--white);
}
}
&.is-success {
background: var(--success);
span {
color: var(--white);
}
}
&.is-info {
background: var(--info);
span {
color: var(--white);
}
}
&.is-warning {
background: var(--warning);
span {
color: var(--white);
}
}
&.is-danger {
background: var(--danger);
span {
color: var(--white);
}
}
&.is-h-purple {
background: var(--purple);
span {
color: var(--white);
}
}
&.is-h-orange {
background: var(--orange);
span {
color: var(--white);
}
}
&.is-h-blue {
background: var(--blue);
span {
color: var(--white);
}
}
&.is-h-red {
background: var(--red);
span {
color: var(--white);
}
}
&.is-h-green {
background: var(--green);
span {
color: var(--white);
}
}
&.is-h-yellow {
background: var(--yellow);
span {
color: var(--white);
}
}
}
&.is-more {
border-color: color-mix(in oklab, var(--dark-sidebar), black 12%);
.inner {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
}
}
&.is-fake {
border-color: color-mix(in oklab, var(--dark-sidebar), white 6%);
}
}
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { VAvatarProps } from './VAvatar.vue'
export type VAvatarStackSize = 'small' | 'medium' | 'large' | 'big' | 'xl'
export interface VAvatarStackProps {
limit?: number
size?: VAvatarStackSize
avatars?: VAvatarProps[]
}
const props = withDefaults(defineProps<VAvatarStackProps>(), {
limit: 5,
size: undefined,
avatars: () => [],
})
</script>
<template>
<div class="avatar-stack">
<slot>
<VAvatar
v-for="(avatar, index) in avatars.slice(0, props.limit)"
:key="index"
:size="props.size"
:picture="avatar.picture"
:initials="avatar.initials"
:color="avatar.color"
/>
<div
v-if="avatars.length > props.limit"
class="v-avatar"
:class="[size && 'is-' + props.size]"
>
<span class="avatar is-more">
<span class="inner">
<span>+{{ avatars.length - props.limit }}</span>
</span>
</span>
</div>
</slot>
</div>
</template>

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
export interface VBlockProps {
title?: string
subtitle?: string
infratitle?: string
center?: boolean
lighter?: boolean
narrow?: boolean
mResponsive?: boolean
tResponsive?: boolean
}
const props = withDefaults(defineProps<VBlockProps>(), {
title: undefined,
subtitle: undefined,
infratitle: undefined,
})
</script>
<template>
<div
:class="[
!props.center && 'media-flex',
props.center && 'media-flex-center',
props.narrow && 'no-margin',
props.mResponsive && 'is-responsive-mobile',
props.tResponsive && 'is-responsive-tablet-p',
]"
>
<slot name="icon" />
<div
class="flex-meta"
:class="[props.lighter && 'is-lighter']"
>
<slot name="title">
<span>{{ props.title }}</span>
<span v-if="props.subtitle">{{ props.subtitle }}</span>
<span v-if="props.infratitle">{{ props.infratitle }}</span>
</slot>
<slot />
</div>
<div class="flex-end">
<slot name="action" />
</div>
</div>
</template>
<style lang="scss">
.media-flex {
display: flex;
margin-bottom: 1rem;
width: 100%;
&:last-child,
&.no-margin {
margin-bottom: 0;
}
.flex-meta {
margin-inline-start: 12px;
line-height: 1.3;
&.is-lighter {
span,
> a {
&:first-child {
font-weight: 400;
}
}
}
&.is-light {
span,
> a {
&:first-child {
font-weight: 500;
}
}
}
span,
> a {
display: block;
&:first-child {
font-family: var(--font-alt);
color: var(--dark-text);
font-weight: 600;
}
&:nth-child(2) {
font-family: var(--font);
color: var(--light-text);
font-size: 0.9rem;
}
}
a:hover {
color: var(--primary);
}
}
.flex-end {
margin-inline-start: auto;
display: flex;
justify-content: flex-end;
.end-action {
margin-inline-start: 1rem;
}
}
}
.media-flex-center {
display: flex;
align-items: center;
margin-bottom: 1rem;
width: 100%;
&:last-child,
&.no-margin {
margin-bottom: 0;
}
.flex-meta {
margin-inline-start: 12px;
line-height: 1.4;
&.is-lighter {
span,
> a {
&:first-child {
font-weight: 400;
}
}
}
&.is-light {
span,
> a {
&:first-child {
font-weight: 500;
}
}
}
span,
> a {
display: block;
&:first-child {
font-family: var(--font-alt);
font-size: 0.95rem;
color: var(--dark-text);
font-weight: 600;
}
&:nth-child(2) {
font-family: var(--font);
color: var(--light-text);
font-size: 0.9rem;
}
}
a:hover {
color: var(--primary);
}
}
.flex-end {
margin-inline-start: auto;
display: flex;
align-items: center;
justify-content: flex-end;
.end-action {
margin-inline-start: 1rem;
}
}
}
.is-dark {
.media-flex-center,
.media-flex {
.flex-meta {
span,
a {
&:first-child {
color: var(--dark-dark-text) !important;
}
}
a:hover {
color: var(--primary);
}
}
}
}
@media only screen and (width <= 767px) {
.media-flex,
.media-flex-center {
&.is-responsive-mobile {
flex-direction: column;
text-align: center;
.v-avatar,
.v-icon {
margin: 0 auto;
}
.flex-meta {
margin: 10px auto 0;
}
.flex-end {
margin: 10px auto;
.end-action {
margin-inline-start: 0;
}
.button {
min-width: 140px;
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.media-flex,
.media-flex-center {
&.is-responsive-tablet-p {
flex-direction: column;
text-align: center;
.v-avatar,
.v-icon {
margin: 0 auto;
}
.flex-meta {
margin: 10px auto 0;
}
.flex-end {
margin: 10px auto;
.end-action {
margin-inline-start: 0;
}
.button {
min-width: 140px;
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: landscape) {
.media-flex,
.media-flex-center {
&.is-responsive-tablet-l {
flex-direction: column;
text-align: center;
.v-avatar,
.v-icon {
margin: 0 auto;
}
.flex-meta {
margin: 10px auto 0;
}
.flex-end {
margin: 10px auto;
.end-action {
margin-inline-start: 0;
}
.button {
min-width: 140px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
export type VBreadcrumbSeparator = 'arrow' | 'bullet' | 'dot' | 'succeeds'
export type VBreadcrumbAlign = 'center' | 'right'
export interface VBreadcrumbItem {
label: string
hideLabel?: boolean
icon?: string
link?: string
to?: any
}
export interface VBreadcrumbsProps {
items: VBreadcrumbItem[]
separator?: VBreadcrumbSeparator
align?: VBreadcrumbAlign
withIcons?: boolean
}
const props = withDefaults(defineProps<VBreadcrumbsProps>(), {
separator: undefined,
align: undefined,
})
</script>
<template>
<nav
role="navigation"
class="breadcrumb"
aria-label="breadcrumbs"
itemscope
itemtype="https://schema.org/BreadcrumbList"
:class="[`has-${props.separator}-separator`, props.align && `is-${props.align}`]"
>
<ul>
<li
v-for="(item, key) in props.items"
:key="key"
:aria-current="key === items.length - 1 ? 'page' : undefined"
itemprop="itemListElement"
itemscope
itemtype="https://schema.org/ListItem"
>
<slot
name="breadcrumb-item"
:item="item"
:index="key"
>
<RouterLink
v-if="item.to"
class="breadcrumb-item"
itemprop="item"
:to="item.to"
>
<span
v-if="props.withIcons && !!item.icon"
class="icon is-small"
:class="[item.hideLabel && props.withIcons && !!item.icon && 'is-solo']"
>
<VIcon :icon="item.icon" />
</span>
<meta
v-if="item.hideLabel && props.withIcons && !!item.icon"
itemprop="name"
:content="item.label"
>
<span
v-else
itemprop="name"
>
<slot
name="breadcrumb-item-label"
:item="item"
:index="key"
>
{{ item.label }}
</slot>
</span>
<meta
itemprop="position"
:content="`${key + 1}`"
>
</RouterLink>
<a
v-else-if="item.link"
class="breadcrumb-item"
itemprop="item"
:href="item.link"
>
<span
v-if="props.withIcons && !!item.icon"
class="icon is-small"
:class="[item.hideLabel && props.withIcons && !!item.icon && 'is-solo']"
>
<VIcon :icon="item.icon" />
</span>
<meta
v-if="item.hideLabel && props.withIcons && !!item.icon"
itemprop="name"
:content="item.label"
>
<span
v-else
itemprop="name"
>
<slot
name="breadcrumb-item-label"
:item="item"
:index="key"
>
{{ item.label }}
</slot>
</span>
<meta
itemprop="position"
:content="`${key + 1}`"
>
</a>
<span
v-else
class="breadcrumb-item"
>
<span
v-if="props.withIcons && !!item.icon"
class="icon is-small"
:class="[item.hideLabel && props.withIcons && !!item.icon && 'is-solo']"
>
<VIcon :icon="item.icon" />
</span>
<meta
v-if="item.hideLabel && props.withIcons && item.icon"
itemprop="name"
:content="item.label"
>
<span
v-else
itemprop="name"
>
<slot
name="breadcrumb-item-label"
:item="item"
:index="key"
>
{{ item.label }}
</slot>
</span>
<meta
itemprop="position"
:content="`${key + 1}`"
>
</span>
</slot>
</li>
</ul>
</nav>
</template>
<style lang="scss">
.breadcrumb {
&.is-narrow {
margin-bottom: 10px;
}
ul {
li {
&:first-child {
.breadcrumb-item {
padding-inline-start: 0;
}
}
.breadcrumb-item {
font-family: var(--font);
color: var(--light-text);
padding: 0 0.75em;
.icon {
&.is-solo {
.iconify {
top: 2px;
}
}
.iconify {
position: relative;
top: 0;
font-size: 16px;
height: 16px;
min-width: 16px;
}
}
}
a {
&.breadcrumb-item {
&:hover {
color: var(--primary);
}
}
}
}
}
}
.is-dark {
.breadcrumb {
ul {
li {
a {
&.breadcrumb-item {
&:hover {
color: var(--primary);
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,666 @@
<script lang="ts">
import type { RouteLocationAsString } from 'unplugin-vue-router'
import type { SlotsType, PropType } from 'vue'
import { RouterLink } from 'vue-router'
import VPlaceload from '/@src/components/base/VPlaceload.vue'
export type VButtonSize = 'medium' | 'big' | 'huge'
export type VButtonColor =
| 'primary'
| 'info'
| 'success'
| 'warning'
| 'danger'
| 'white'
| 'dark'
| 'light'
| 'validation'
export type VButtonDark = '1' | '2' | '3' | '4' | '5' | '6'
export default defineComponent({
props: {
to: {
type: [Object, String] as PropType<RouteLocationAsString>,
default: undefined,
},
href: {
type: String,
default: undefined,
},
icon: {
type: String,
default: undefined,
},
iconCaret: {
type: String,
default: undefined,
},
placeload: {
type: String,
default: undefined,
validator: (value: string) => {
if (value.match(CssUnitRe) === null) {
console.warn(
`VButton: invalid "${value}" placeload. Should be a valid css unit value.`,
)
}
return true
},
},
color: {
type: String as PropType<VButtonColor>,
default: undefined,
validator: (value: VButtonColor) => {
// The value must match one of these strings
if (
[
undefined,
'primary',
'info',
'success',
'warning',
'danger',
'white',
'dark',
'light',
'validation',
].indexOf(value) === -1
) {
console.warn(
`VButton: invalid "${value}" color. Should be primary, info, success, warning, danger, dark, light, validation, white or undefined`,
)
console.log(VButtonColor)
return false
}
return true
},
},
size: {
type: String as PropType<VButtonSize>,
default: undefined,
validator: (value: VButtonSize) => {
// The value must match one of these strings
if ([undefined, 'medium', 'big', 'huge'].indexOf(value) === -1) {
console.warn(
`VButton: invalid "${value}" size. Should be big, huge, medium or undefined`,
)
return false
}
return true
},
},
dark: {
type: String as PropType<VButtonDark>,
default: undefined,
validator: (value: VButtonDark) => {
// The value must match one of these strings
if ([undefined, '1', '2', '3', '4', '5', '6'].indexOf(value) === -1) {
console.warn(
`VButton: invalid "${value}" dark. Should be 1, 2, 3, 4, 5, 6 or undefined`,
)
return false
}
return true
},
},
rounded: {
type: Boolean,
default: false,
},
bold: {
type: Boolean,
default: false,
},
fullwidth: {
type: Boolean,
default: false,
},
light: {
type: Boolean,
default: false,
},
raised: {
type: Boolean,
default: false,
},
elevated: {
type: Boolean,
default: false,
},
outlined: {
type: Boolean,
default: false,
},
darkOutlined: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
lower: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
static: {
type: Boolean,
default: false,
},
},
slots: Object as SlotsType<{
default: void
}>,
setup(props, { slots, attrs }) {
const classes = computed(() => {
const defaultClasses = (attrs?.class || []) as string[] | string
return [
defaultClasses,
'button',
'v-button',
props.disabled && 'is-disabled',
props.rounded && 'is-rounded',
props.bold && 'is-bold',
props.size && `is-${props.size}`,
props.lower && 'is-lower',
props.fullwidth && 'is-fullwidth',
props.outlined && 'is-outlined',
props.dark && `is-dark-bg-${props.dark}`,
props.darkOutlined && 'is-dark-outlined',
props.raised && 'is-raised',
props.elevated && 'is-elevated',
props.loading && !props.placeload && 'is-loading',
props.color && `is-${props.color}`,
props.light && 'is-light',
props.static && 'is-static',
]
})
const isIconify = computed(() => props.icon && props.icon.indexOf(':') !== -1)
const isCaretIconify = computed(
() => props.iconCaret && props.iconCaret.indexOf(':') !== -1,
)
const getChildrens = () => {
const childrens = []
let iconWrapper
if (isIconify.value) {
const icon = h('iconify-icon', {
class: 'iconify',
icon: props.icon,
})
iconWrapper = h('span', { class: 'icon' }, icon)
}
else if (props.icon) {
const icon = h('i', { 'aria-hidden': true, 'class': props.icon })
iconWrapper = h('span', { class: 'icon rtl-reflect' }, icon)
}
let caretWrapper
if (isCaretIconify.value) {
const caret = h('iconify-icon', {
class: 'iconify',
icon: props.iconCaret,
})
caretWrapper = h('span', { class: 'caret' }, caret)
}
else if (props.iconCaret) {
const caret = h('i', { 'aria-hidden': true, 'class': props.iconCaret })
caretWrapper = h('span', { class: 'caret' }, caret)
}
if (iconWrapper) {
childrens.push(iconWrapper)
}
if (props.placeload) {
childrens.push(
h(VPlaceload, {
width: props.placeload,
}),
)
}
else {
childrens.push(h('span', slots.default?.()))
}
if (caretWrapper) {
childrens.push(caretWrapper)
}
return childrens
}
return () => {
if (props.to) {
return h(
RouterLink,
{
...attrs,
'aria-hidden': !!props.placeload,
'to': props.to,
'class': ['button', ...classes.value],
},
{
default: getChildrens,
},
)
}
else if (props.href) {
return h(
'a',
{
...attrs,
'aria-hidden': !!props.placeload,
'href': props.href,
'class': classes.value,
},
{
default: getChildrens,
},
)
}
return h(
'button',
{
'type': 'button',
...attrs,
'aria-hidden': !!props.placeload,
'disabled': props.disabled,
'class': ['button', ...classes.value],
},
{
default: getChildrens,
},
)
}
},
})
</script>
<style lang="scss">
.button {
&.is-circle {
border-radius: var(--radius-rounded);
}
&.v-button {
padding: 8px 22px;
height: 38px;
line-height: 1.1;
font-size: 0.95rem;
font-family: var(--font);
transition: all 0.3s; // transition-all test
&:not([disabled]) {
cursor: pointer;
}
&:active,
&:focus {
box-shadow: none !important;
border-color: color-mix(in oklab, var(--fade-grey), black 2%);
}
&:not(
.is-primary,
.is-success,
.is-info,
.is-warning,
.is-danger,
.is-light,
.is-white,
.is-validation
) {
&.is-active {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: var(--white) !important;
box-shadow: var(--primary-box-shadow) !important;
}
}
&:focus-visible {
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
&.is-bold {
font-weight: 500;
}
&.is-primary {
&.is-raised:hover {
opacity: 0.9;
box-shadow: var(--primary-box-shadow);
}
&.is-elevated {
box-shadow: var(--primary-box-shadow);
}
}
&.is-success {
&.is-raised:hover {
opacity: 0.9;
box-shadow: var(--success-box-shadow);
}
&.is-elevated {
box-shadow: var(--success-box-shadow);
}
}
&.is-info {
&.is-raised:hover {
opacity: 0.9;
box-shadow: var(--info-box-shadow);
}
&.is-elevated {
box-shadow: var(--info-box-shadow);
}
}
&.is-warning {
&.is-raised:hover {
opacity: 0.9;
box-shadow: var(--warning-box-shadow);
}
&.is-elevated {
box-shadow: var(--warning-box-shadow);
}
}
&.is-validation {
&.is-raised:hover {
opacity: 0.9;
box-shadow: var(--warning-box-shadow);
}
&.is-elevated {
box-shadow: var(--warning-box-shadow);
}
}
&.is-danger {
&.is-raised:hover {
opacity: 0.9;
box-shadow: var(--danger-box-shadow);
}
&.is-elevated {
box-shadow: var(--danger-box-shadow);
}
}
&.is-lower {
text-transform: none !important;
font-size: 0.9rem;
}
&.is-big {
height: 40px;
}
&.is-medium {
height: 2.5rem;
font-size: 1rem;
}
&.is-huge {
height: 50px;
width: 220px;
}
}
&.simple-action {
height: 32px;
padding: 0 24px;
line-height: 0;
border-radius: 100px;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s; // transition-all test
&.is-purple {
background: var(--primary);
border-color: var(--primary);
color: var(--smoke-white);
&:hover,
&:focus {
opacity: 0.95;
box-shadow: var(--primary-box-shadow);
color: var(--smoke-white) !important;
}
}
&.has-icon {
.iconify {
height: 16px;
width: 16px;
font-size: 16px;
}
}
&:hover {
border-color: var(--primary);
color: var(--primary);
}
.iconify {
height: 18px;
width: 18px;
font-size: 18px;
}
}
.icon {
.iconify {
height: 14px;
width: 14px;
font-size: 14px;
}
}
}
.is-dark {
.v-button {
&:not(
.is-primary,
.is-success,
.is-info,
.is-warning,
.is-danger,
.is-light,
.is-white,
.is-validation
) {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
color: var(--dark-dark-text);
&:hover,
&:focus {
border-color: color-mix(in oklab, var(--dark-sidebar), white 18%);
}
}
&.is-light {
border: none;
background: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
color: var(--smoke-white) !important;
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
}
}
&.is-primary {
border-color: var(--primary);
background: var(--primary);
&.is-raised:hover {
box-shadow: var(--primary-box-shadow) !important;
}
&.is-elevated {
box-shadow: var(--primary-box-shadow) !important;
}
&.is-outlined {
background: transparent;
border-color: var(--primary) !important;
color: var(--primary);
&:hover,
&:focus {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: var(--white) !important;
}
}
&.is-light {
border: none;
background: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
color: color-mix(in oklab, var(--primary), white 20%) !important;
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
}
}
}
&.is-info {
&.is-light {
background: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
color: color-mix(in oklab, var(--info), white 20%) !important;
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
}
}
}
&.is-success {
&.is-light {
background: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
color: color-mix(in oklab, var(--success), white 20%) !important;
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
}
}
}
&.is-warning {
&.is-light {
background: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
color: color-mix(in oklab, var(--warning), white 20%) !important;
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
}
}
}
&.is-validation {
&.is-light {
background: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
color: color-mix(in oklab, var(--warning), white 20%) !important;
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
}
}
}
&.is-danger {
&.is-light {
background: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
color: color-mix(in oklab, var(--danger), white 20%) !important;
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
}
}
}
&.is-white {
background: color-mix(in oklab, var(--dark-sidebar), white 6%) !important;
border-color: var(--muted-grey) !important;
color: var(--muted-grey) !important;
}
&.is-dark-outlined {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
color: var(--dark-dark-text);
&:hover,
&:focus {
border-color: var(--primary) !important;
color: var(--primary) !important;
}
}
}
.button {
&:not(
.is-primary,
.is-success,
.is-info,
.is-warning,
.is-danger,
.is-light,
.is-white,
.is-validation
) {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
color: var(--dark-dark-text);
&:hover,
&:focus {
border-color: color-mix(in oklab, var(--dark-sidebar), white 18%);
}
}
&.is-primary {
border-color: var(--primary);
background: var(--primary);
}
&.is-white {
background: color-mix(in oklab, var(--dark-sidebar), white 6%) !important;
border-color: var(--muted-grey) !important;
color: var(--muted-grey) !important;
}
&.is-dark-outlined {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
color: var(--dark-dark-text);
&:hover,
&:focus {
border-color: var(--primary) !important;
color: var(--primary) !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
export type VButtonsAlign = 'centered' | 'right'
export interface VButtonsProps {
align?: VButtonsAlign
addons?: boolean
}
const props = withDefaults(defineProps<VButtonsProps>(), {
align: undefined,
})
</script>
<template>
<div
class="buttons"
:class="[props.addons && 'has-addons', props.align && `is-${props.align}`]"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
export type VCardRadius = 'regular' | 'smooth' | 'rounded'
export type VCardColor =
| 'primary'
| 'secondary'
| 'info'
| 'success'
| 'warning'
| 'danger'
export interface VCardProps {
radius?: VCardRadius
color?: VCardColor
elevated?: boolean
}
const props = withDefaults(defineProps<VCardProps>(), {
radius: undefined,
color: undefined,
elevated: false,
})
const cardRadius = computed(() => {
if (props.radius === 'smooth') {
return 's-card'
}
else if (props.radius === 'rounded') {
return 'l-card'
}
return 'r-card'
})
</script>
<template>
<div :class="[cardRadius, elevated && 'is-raised', props.color && `is-${props.color}`]">
<slot />
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
export type VCardActionRadius = 'regular' | 'smooth' | 'rounded'
export interface VCardActionProps {
title: string
subtitle?: string
avatar?: string
badge?: string
content?: string
radius?: VCardActionRadius
}
const props = withDefaults(defineProps<VCardActionProps>(), {
subtitle: undefined,
avatar: undefined,
badge: undefined,
content: undefined,
radius: 'regular',
})
const slots = useSlots()
const hasDefaultSlot = ref(!!slots.default?.())
onUpdated(() => {
hasDefaultSlot.value = !!slots.default?.()
})
</script>
<template>
<div
class="is-raised"
:class="[
props.radius === 'regular' && 's-card',
props.radius === 'smooth' && 'r-card',
props.radius === 'rounded' && 'l-card',
]"
>
<div class="card-head">
<VBlock
:title="props.title"
:subtitle="props.subtitle"
center
>
<template #icon>
<VAvatar
:picture="props.avatar"
:badge="props.badge"
/>
</template>
<template #action>
<slot name="action" />
</template>
</VBlock>
</div>
<div
v-if="hasDefaultSlot"
class="card-inner"
>
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
export type VCardAdvancedRadius = 'regular' | 'smooth' | 'rounded'
export interface VCardAdvancedProps {
radius?: VCardAdvancedRadius
}
const props = withDefaults(defineProps<VCardAdvancedProps>(), {
radius: 'regular',
})
</script>
<template>
<div
:class="[
props.radius === 'regular' && 's-card-advanced',
props.radius === 'smooth' && 'r-card-advanced',
props.radius === 'rounded' && 'l-card-advanced',
]"
>
<div class="card-head">
<div class="left">
<slot name="header-left" />
</div>
<div class="right">
<slot name="header-right" />
</div>
</div>
<div class="card-body">
<slot name="content" />
</div>
<div class="card-foot">
<div class="left">
<slot name="footer-left" />
</div>
<div class="right">
<slot name="footer-right" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
export type VCardMediaFormat = '4by3' | '16by9'
export interface VCardMediaProps {
title: string
subtitle?: string
image?: string
avatar?: string
badge?: string
placeholder?: string
format?: VCardMediaFormat
}
const props = withDefaults(defineProps<VCardMediaProps>(), {
subtitle: undefined,
image: undefined,
avatar: undefined,
badge: undefined,
placeholder: 'https://via.placeholder.com/1280x960',
format: '4by3',
})
const slots = useSlots()
const hasDefaultSlot = ref(!!slots.default?.())
function placeholderHandler(event: Event) {
const target = event.target as HTMLImageElement
target.src = props.placeholder
}
onUpdated(() => {
hasDefaultSlot.value = !!slots.default?.()
})
</script>
<template>
<div class="card v-card">
<div
v-if="props.image"
class="card-image"
>
<figure
class="image is-4by3"
:class="[props.format && `is-${props.format}`]"
>
<img
:src="image"
alt=""
@error.once="placeholderHandler"
>
</figure>
</div>
<div class="card-content">
<VBlock
:title="props.title"
:subtitle="props.subtitle"
center
narrow
>
<template #icon>
<VAvatar
v-if="props.avatar"
:picture="props.avatar"
:badge="props.badge"
size="medium"
/>
</template>
</VBlock>
<div
v-if="hasDefaultSlot"
class="inner-content pt-5"
>
<slot />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
export type VCardSocialNetwork =
| 'facebook'
| 'twitter'
| 'linkedin'
| 'tumblr'
| 'github'
| 'dribbble'
| 'google-plus'
| 'youtube'
| 'reddit'
| 'invision'
| 'amazon'
| 'instagram'
export interface VCardSocialEmits {
(e: 'iconClick'): void
(e: 'share'): void
(e: 'like'): void
(e: 'hashtagClick', tag: string): void
}
export interface VCardSocialProps {
title: string
network: VCardSocialNetwork
hashtags?: string[]
avatar?: string
username?: string
shareLabel?: string
likeLabel?: string
}
const emit = defineEmits<VCardSocialEmits>()
const props = withDefaults(defineProps<VCardSocialProps>(), {
hashtags: () => [],
avatar: undefined,
username: undefined,
shareLabel: 'Share',
likeLabel: 'Like',
})
const icon = computed(() => {
switch (props.network) {
case 'facebook':
return 'fa-brands:facebook-f'
case 'twitter':
return 'fa-brands:twitter'
case 'linkedin':
return 'fa-brands:linkedin-in'
case 'tumblr':
return 'fa-brands:tumblr'
case 'github':
return 'fa-brands:github-alt'
case 'dribbble':
return 'fa-brands:dribbble'
case 'google-plus':
return 'fa-brands:google-plus-g'
case 'youtube':
return 'fa-brands:youtube'
case 'reddit':
return 'fa-brands:reddit-alien'
case 'invision':
return 'fa-brands:invision'
case 'amazon':
return 'fa-brands:amazon'
case 'instagram':
return 'fa-brands:instagram'
}
return ''
})
</script>
<template>
<div class="card v-card">
<header class="card-header">
<div class="card-header-title">
{{ props.title }}
</div>
<a
v-if="icon"
class="card-header-icon"
:class="[props.network && `text-${props.network}`]"
:aria-label="`View on ${props.network}`"
tabindex="0"
role="button"
@keydown.enter.prevent="emit('iconClick')"
@click="emit('iconClick')"
>
<VIcon :icon="icon" />
</a>
</header>
<div class="card-content">
<VBlock
:title="props.username"
class="pb-3"
>
<template #icon>
<VAvatar
v-if="props.avatar"
size="medium"
:picture="props.avatar"
squared
/>
</template>
<slot />
<span v-if="props.hashtags.length">
<a
v-for="(hashtag, index) in props.hashtags"
:key="index"
class="px-1"
:class="[network && `text-${network}`]"
tabindex="0"
role="button"
@keydown.enter.prevent="emit('hashtagClick', hashtag)"
@click="emit('hashtagClick', hashtag)"
>
{{ hashtag }}
</a>
</span>
</VBlock>
</div>
<footer class="card-footer">
<a
v-if="props.shareLabel"
:class="[network && `hover-bg-${network}`]"
class="card-footer-item"
tabindex="0"
role="button"
@keydown.enter.prevent="emit('share')"
@click="emit('share')"
>
{{ props.shareLabel }}
</a>
<a
v-if="props.likeLabel"
:class="[network && `hover-text-${network}`]"
class="card-footer-item"
tabindex="0"
role="button"
@keydown.enter.prevent="emit('like')"
@click="emit('like')"
>
{{ props.likeLabel }}
</a>
</footer>
</div>
</template>

View File

@@ -0,0 +1,349 @@
<script setup lang="ts">
export type VCheckboxColor = 'primary' | 'info' | 'success' | 'warning' | 'danger'
export interface VCheckboxProps {
id?: string
raw?: boolean
label?: string
color?: VCheckboxColor
trueValue?: any
falseValue?: any
value?: any
circle?: boolean
solid?: boolean
paddingless?: boolean
wrapperClass?: string
}
const modelValue = defineModel<any>({
default: false,
})
const props = withDefaults(defineProps<VCheckboxProps>(), {
id: undefined,
label: undefined,
color: undefined,
trueValue: true,
falseValue: false,
value: undefined,
circle: false,
solid: false,
paddingless: false,
wrapperClass: undefined,
})
const context = useVFieldContext()
const internal = computed({
get() {
if (context.field?.value) {
return context.field.value.value
}
else {
return modelValue.value
}
},
set(value: any) {
if (context.field?.value) {
context.field.value.setValue(value)
}
modelValue.value = value
},
})
const classes = computed(() => {
if (props.raw) return [props.wrapperClass]
return [
'checkbox',
props.wrapperClass,
props.solid ? 'is-solid' : 'is-outlined',
props.circle && 'is-circle',
props.color && `is-${props.color}`,
props.paddingless && 'is-paddingless',
]
})
</script>
<template>
<VLabel
:id="props.id || context.id.value"
raw
:class="classes"
>
<input
:id="props.id || context.id.value"
v-model="internal"
v-bind="$attrs"
:true-value="props.trueValue"
:false-value="props.falseValue"
:value="props.value"
type="checkbox"
>
<span />
<slot v-bind="context">
{{ props.label }}
</slot>
</VLabel>
</template>
<style lang="scss">
%controller {
position: relative;
font-family: var(--font);
cursor: pointer;
padding: 1em;
&::selection {
background: transparent;
}
input + span {
position: relative;
top: -1px;
background: var(--white);
content: '';
display: inline-block;
margin-inline-end: 0.5rem;
padding: 0;
vertical-align: middle;
width: 1.4em;
height: 1.4em;
border: 1px solid color-mix(in oklab, var(--fade-grey), black 8%);
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
&::after {
content: '';
display: block;
transform: scale(0);
transition: transform 0.2s;
}
}
@media screen and (width >= 768px) {
&:hover input + span {
box-shadow: 0 2px 4px rgba(#000, 0.15);
}
}
input:active + span {
box-shadow: 0 4px 8px rgba(#000, 0.15);
}
input:checked + span::after {
transform: translate(calc(var(--transform-direction) * -50%), -50%) scale(1) !important;
}
input {
position: absolute;
cursor: pointer;
opacity: 0;
transition: all 0.3s; // transition-all test
}
}
.checkbox {
@extend %controller;
color: var(--light-text);
&:hover,
&:focus {
color: var(--light-text);
}
&.is-paddingless {
padding: 0 !important;
}
&.is-circle {
input + span {
border-radius: var(--radius-rounded);
}
}
&.is-solid {
input + span {
background: color-mix(in oklab, var(--fade-grey), white 3%);
}
&.is-primary {
input + span {
border-color: var(--primary);
background: var(--primary);
&::after {
color: var(--white);
}
}
}
&.is-success {
input + span {
border-color: var(--success);
background: var(--success);
&::after {
color: var(--white);
}
}
}
&.is-info {
input + span {
border-color: var(--info);
background: var(--info);
&::after {
color: var(--white);
}
}
}
&.is-warning {
input + span {
border-color: var(--warning);
background: var(--warning);
&::after {
color: var(--white);
}
}
}
&.is-danger {
input + span {
border-color: var(--danger);
background: var(--danger);
&::after {
color: var(--white);
}
}
}
}
&.is-outlined {
&.is-primary {
input:checked + span {
border-color: var(--primary);
}
input + span {
&::after {
color: var(--primary);
}
}
}
&.is-success {
input:checked + span {
border-color: var(--success);
}
input + span {
&::after {
color: var(--success);
}
}
}
&.is-info {
input:checked + span {
border-color: var(--info);
}
input + span {
&::after {
color: var(--info);
}
}
}
&.is-warning {
input:checked + span {
border-color: var(--warning);
}
input + span {
&::after {
color: var(--warning);
}
}
}
&.is-danger {
input:checked + span {
border-color: var(--danger);
}
input + span {
&::after {
color: var(--danger);
}
}
}
}
input + span {
border-radius: var(--radius-small);
transition: all 0.3s; // transition-all test
&::after {
background-size: contain;
position: absolute;
top: 48%;
inset-inline-start: 50%;
transform: translate(-50%, -50%) scale(0);
content: '\f00c';
font-family: 'Font Awesome\ 5 Free';
font-weight: 900;
font-size: 0.7rem;
}
}
input:focus + span,
input:active + span {
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-color: var(--accessibility-focus-outline-color);
outline-style: var(--accessibility-focus-outline-style);
}
}
.is-dark {
%controller {
input + span {
background-color: color-mix(in oklab, var(--dark-sidebar), white 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 4%);
&::after {
color: var(--dark-dark-text);
}
}
input + span {
border-color: color-mix(in oklab, var(--dark-sidebar), white 16%);
}
}
.checkbox {
&.is-solid.is-primary {
input + span {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
}
}
&.is-outlined.is-primary {
input:checked + span {
border-color: var(--primary) !important;
&::after {
color: var(--primary) !important;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,223 @@
<script setup lang="ts">
export interface VCollapseItem {
title: string
content: string
value?: any
url?: string
}
export interface VCollapseProps {
items: VCollapseItem[]
itemOpen?: number
withChevron?: boolean
}
const props = withDefaults(defineProps<VCollapseProps>(), {
items: () => [],
itemOpen: undefined,
})
const internalItemOpen = ref<number | undefined>(props.itemOpen)
const toggle = (key: number) => {
if (internalItemOpen.value === key) {
internalItemOpen.value = undefined
return
}
internalItemOpen.value = key
}
</script>
<template>
<details
v-for="(item, key) in items"
:key="key"
:class="[withChevron && 'has-chevron', !withChevron && 'has-plus']"
:open="internalItemOpen === key || undefined"
class="collapse"
>
<slot
name="collapse-item"
:item="item"
:index="key"
:toggle="toggle"
>
<summary
class="collapse-header"
tabindex="0"
role="button"
@keydown.enter.prevent="() => toggle(key)"
@click.prevent="() => toggle(key)"
>
<h3>
<slot
name="collapse-item-summary"
:item="item"
:index="key"
:toggle="toggle"
>
{{ item.title }}
</slot>
</h3>
<div class="collapse-head-info">
<slot
name="collapse-item-head"
:item="item"
:index="key"
/>
<div class="collapse-icon">
<VIcon
v-if="withChevron"
icon="lucide:chevron-down"
/>
<VIcon
v-else-if="!withChevron"
icon="lucide:plus"
/>
</div>
</div>
</summary>
<div class="collapse-content">
<slot
name="collapse-item-content"
:item="item"
:index="key"
:toggle="toggle"
>
<p>
{{ item.content }}
</p>
</slot>
</div>
</slot>
</details>
</template>
<style lang="scss">
@import '/@src/scss/abstracts/all';
.collapse {
@include vuero-s-card;
padding: 0;
margin-bottom: 1.5rem;
&.has-plus {
&[open] {
.collapse-header {
.collapse-icon {
transform: rotate(calc(var(--transform-direction) * 45deg));
}
}
.collapse-content {
display: block;
}
}
}
&.has-chevron {
&[open] {
.collapse-header {
.collapse-icon {
transform: rotate(calc(var(--transform-direction) * 180deg));
}
}
.collapse-content {
display: block;
}
}
}
&[open] {
.collapse-icon {
border-color: color-mix(in oklab, var(--fade-grey), black 3%) !important;
box-shadow: var(--light-box-shadow);
}
}
.collapse-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 20px;
cursor: pointer;
h3 {
font-family: var(--font-alt);
font-size: 0.9rem;
font-weight: 600;
color: var(--dark-text);
}
.collapse-head-info {
display: flex;
align-items: center;
justify-content: flex-end;
}
.collapse-icon {
display: flex;
justify-content: center;
align-items: center;
height: 30px;
width: 30px;
background: var(--white);
border-radius: var(--radius-rounded);
border: 1px solid transparent;
transition: all 0.3s; // transition-all test
> span {
display: block;
height: 16px;
width: 16px;
}
.iconify {
height: 16px;
width: 16px;
color: var(--light-text);
}
}
}
.collapse-content {
display: none;
padding: 0 20px 20px;
color: var(--light-text);
font-family: var(--font);
p:not(:last-child) {
margin-bottom: 12px;
}
}
}
.is-dark {
.collapse {
@include vuero-card--dark;
&[open] {
.collapse-header {
.collapse-icon {
background: color-mix(in oklab, var(--dark-sidebar), white 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
}
}
}
.collapse-header {
h3 {
color: var(--dark-dark-text);
}
.collapse-icon {
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 6%);
}
}
}
}
</style>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { withoutTrailingSlash } from 'ufo'
const props = defineProps<{
links: {
label: string
to: string
icon?: string
tag?: string | number
}[]
}>()
const route = useRoute()
const isOpen = ref(false)
onMounted(() => {
if (props.links.some(link => withoutTrailingSlash(link.to) === withoutTrailingSlash(route.path))) {
isOpen.value = true
}
})
function toggle() {
isOpen.value = !isOpen.value
}
</script>
<template>
<li class="collapse-links has-children" :class="[isOpen && 'active']">
<div class="collapse-wrap">
<a
role="button"
tabindex="0"
class="parent-link"
@click.prevent="() => toggle()"
@keydown.enter.prevent="() => toggle()"
>
<slot />
<VIcon
class="rtl-hidden"
icon="lucide:chevron-right"
/>
<VIcon
class="ltr-hidden"
icon="lucide:chevron-left"
/>
</a>
</div>
<Transition
name="collapse-links-transition"
mode="out-in"
>
<ul v-if="isOpen" class="collapse-content">
<li
v-for="child of props.links"
:key="child.to"
>
<VLink
class="is-submenu"
:to="child.to"
>
<VIcon
v-if="child.icon"
:icon="child.icon"
/>
<span>{{ child.label }}</span>
<VTag
v-if="child.tag"
:label="child.tag"
color="primary"
outlined
curved
/>
</VLink>
</li>
</ul>
</Transition>
</li>
</template>
<style lang="scss" scoped>
.collapse-links {
overflow: hidden;
user-select: none;
}
.collapse-links-transition-enter-active,
.collapse-links-transition-leave-active {
opacity: 1;
transform: translateY(0) scaleY(1);
transform-origin: center top;
}
.collapse-links-transition-enter-active {
transition:
opacity 0.2s ease-in,
transform 0.1s ease-in;
}
.collapse-links-transition-leave-active {
transition:
opacity 0.2s ease-out,
transform 0.1s ease-out;
}
.collapse-links-transition-enter-from,
.collapse-links-transition-leave-to {
transform: translateY(-10px) scaleY(0.2);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.collapse-links-transition-enter-active,
.collapse-links-transition-leave-active {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
export interface VCollapseItem {
title: string
content: string
}
interface VCollapseProps {
items: VCollapseItem[]
withChevron?: boolean
}
const modelValue = defineModel<number | undefined>({
default: undefined,
})
const props = defineProps<VCollapseProps>()
const toggle = (key: number) => {
if (modelValue.value === key) {
modelValue.value = undefined
return
}
modelValue.value = key
}
</script>
<template>
<details
v-for="(item, key) in props.items"
:key="key"
:class="[props.withChevron && 'has-chevron', !props.withChevron && 'has-plus']"
:open="modelValue === key || undefined"
class="collapse"
>
<slot
name="collapse-item"
:item="item"
:index="key"
:toggle="toggle"
>
<summary
class="collapse-header"
tabindex="0"
role="button"
@keydown.enter.prevent="() => toggle(key)"
@click.prevent="() => toggle(key)"
>
<h3>
<slot
name="collapse-item-summary"
:item="item"
:index="key"
:toggle="toggle"
>
{{ item.title }}
</slot>
</h3>
<div class="collapse-icon">
<VIcon
v-if="props.withChevron"
icon="lucide:chevron-down"
/>
<VIcon
v-else-if="!props.withChevron"
icon="lucide:plus"
/>
</div>
</summary>
<div class="collapse-content">
<p>
<slot
name="collapse-item-content"
:item="item"
:index="key"
:toggle="toggle"
>
{{ item.content }}
</slot>
</p>
</div>
</slot>
</details>
</template>
<style lang="scss">
.collapse {
@include vuero-s-card;
padding: 0;
margin-bottom: 1.5rem;
&.has-plus {
&[open] {
.collapse-header {
.collapse-icon {
transform: rotate(calc(var(--transform-direction) * 45deg));
}
}
.collapse-content {
display: block;
}
}
}
&.has-chevron {
&[open] {
.collapse-header {
.collapse-icon {
transform: rotate(calc(var(--transform-direction) * 180deg));
}
}
.collapse-content {
display: block;
}
}
}
&[open] {
.collapse-icon {
border-color: color-mix(in oklab, var(--fade-grey), black 3%) !important;
box-shadow: var(--light-box-shadow);
}
}
.collapse-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 20px;
cursor: pointer;
h3 {
font-family: var(--font-alt);
font-size: 0.9rem;
font-weight: 600;
color: var(--dark-text);
}
.collapse-icon {
display: flex;
justify-content: center;
align-items: center;
height: 30px;
width: 30px;
background: var(--white);
border-radius: var(--radius-rounded);
border: 1px solid transparent;
transition: all 0.3s; // transition-all test
> span {
display: block;
height: 16px;
width: 16px;
}
.iconify {
height: 16px;
width: 16px;
color: var(--light-text);
}
}
}
.collapse-content {
display: none;
padding: 0 20px 20px;
color: var(--light-text);
font-family: var(--font);
p:not(:last-child) {
margin-bottom: 12px;
}
}
}
.is-dark {
.collapse {
@include vuero-card--dark;
&[open] {
.collapse-header {
.collapse-icon {
background: color-mix(in oklab, var(--dark-sidebar), white 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
}
}
}
.collapse-header {
h3 {
color: var(--dark-dark-text);
}
.collapse-icon {
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 6%);
}
}
}
}
</style>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import VLabel from '/@src/components/base/VLabel.vue'
const props = defineProps({
id: {
type: String,
default: undefined,
},
icon: {
type: String,
default: undefined,
},
isValid: {
type: Boolean,
default: undefined,
},
hasError: {
type: Boolean,
default: undefined,
},
loading: {
type: Boolean,
default: undefined,
},
expanded: {
type: Boolean,
default: undefined,
},
fullwidth: {
type: Boolean,
default: undefined,
},
textaddon: {
type: Boolean,
default: undefined,
},
nogrow: {
type: Boolean,
default: undefined,
},
subcontrol: {
type: Boolean,
default: undefined,
},
raw: {
type: Boolean,
default: undefined,
},
})
const { field, id } = useVFieldContext({
id: props.id,
inherit: !props.subcontrol,
})
const isValid = computed(() => props.isValid)
const hasError = computed(() =>
field?.value ? Boolean(field?.value?.errorMessage?.value) : props.hasError,
)
const controlClasees = computed(() => {
if (props.raw) return []
return [
'control',
props.icon && 'has-icon',
props.loading && 'is-loading',
props.expanded && 'is-expanded',
props.fullwidth && 'is-fullwidth',
props.nogrow && 'is-nogrow',
props.textaddon && 'is-textarea-addon',
isValid.value && 'has-validation has-success',
hasError.value && 'has-validation has-error',
props.subcontrol && 'subcontrol',
]
})
</script>
<template>
<div :class="controlClasees">
<slot v-bind="{ field, id }" />
<VIcon
v-if="props.icon"
:icon="props.icon"
class="form-icon"
/>
<VLabel
v-if="isValid"
class="validation-icon is-success"
>
<VIcon icon="lucide:check" />
</VLabel>
<a
v-else-if="hasError"
class="validation-icon is-error"
role="button"
tabindex="0"
@click.prevent="() => field?.resetField?.()"
@keydown.enter.prevent="() => field?.resetField?.()"
>
<VIcon icon="lucide:x" />
</a>
<slot
v-bind="{ field, id }"
name="extra"
/>
</div>
</template>
<style lang="scss" scoped>
.is-nogrow {
flex-grow: 0 !important;
}
.is-fullwidth {
width: 100%;
}
</style>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
const { isDark, onChange } = useDarkmode()
</script>
<template>
<label
class="theme-toggle"
tabindex="0"
role="button"
@keydown.enter.prevent="(e) => (e.target as HTMLLabelElement).click()"
>
<ClientOnly>
<input
:checked="isDark"
type="checkbox"
@click="onChange"
>
<span class="toggler">
<span class="dark">
<VIcon
icon="lucide:moon"
/>
</span>
<span class="light">
<VIcon
icon="lucide:sun"
/>
</span>
</span>
</ClientOnly>
</label>
</template>
<style scoped lang="scss">
.theme-toggle {
display: block;
position: relative;
cursor: pointer;
font-size: 14px;
user-select: none;
transform: scale(0.9);
&:focus-within {
border-radius: 50px;
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
input {
position: absolute;
opacity: 0;
cursor: pointer;
&:checked ~ .toggler {
border-color: var(--primary);
.dark,
.light {
transform: translateX(calc(var(--transform-direction) * 100%))
rotate(360deg);
}
.dark {
opacity: 1 !important;
}
.light {
opacity: 0 !important;
}
}
}
.toggler {
position: relative;
display: block;
height: 31px;
width: 53px;
border: 2px solid var(--primary);
border-radius: 100px;
transition:
color 0.3s,
background-color 0.3s,
border-color 0.3s,
height 0.3s,
width 0.3s;
.dark,
.light {
position: absolute;
top: 2px;
inset-inline-start: 2px;
height: 22px;
width: 22px;
border-radius: var(--radius-rounded);
background: black;
display: flex;
justify-content: center;
align-items: center;
transform: translateX(calc(var(--transform-direction) * 0))
rotate(calc(var(--transform-direction) * 0));
transition: all 0.3s ease;
.iconify {
color: var(--white) !important;
height: 14px !important;
width: 14px !important;
opacity: 1 !important;
}
}
.light {
background: var(--primary);
border-color: var(--primary);
opacity: 1;
z-index: 1;
}
.dark {
background: var(--primary);
border-color: var(--primary);
opacity: 0;
z-index: 0;
.iconify {
color: var(--white) !important;
}
}
}
}
@media (width <= 767px) {
.theme-toggle {
margin: 0 auto;
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.theme-toggle {
margin: 0 auto;
}
}
</style>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
const { isDark, onChange } = useDarkmode()
</script>
<template>
<label
class="dark-mode"
tabindex="0"
role="button"
@keydown.enter.prevent="(e) => (e.target as HTMLLabelElement).click()"
>
<ClientOnly>
<input
type="checkbox"
:checked="!isDark"
@click="onChange"
>
<span />
</ClientOnly>
</label>
</template>

View File

@@ -0,0 +1,610 @@
<script setup lang="ts">
import type { DropdownOptions } from '/@src/composables/dropdown'
export type VDropdownColor = 'primary' | 'info' | 'success' | 'warning' | 'danger'
export interface VDropdownProps {
title?: string
color?: VDropdownColor
icon?: string
up?: boolean
end?: boolean
right?: boolean
modern?: boolean
spaced?: boolean
options?: DropdownOptions
classes?: {
wrapper?: string | string[]
content?: string | string[]
}
}
const props = withDefaults(defineProps<VDropdownProps>(), {
title: undefined,
color: undefined,
icon: undefined,
options: undefined,
classes: undefined,
})
const dropdownElement = ref<HTMLElement>()
const dropdown = useDropdownContext(dropdownElement, props.options)
defineExpose({
...dropdown,
})
</script>
<template>
<div
ref="dropdownElement"
:class="[
props.right && 'is-right',
props.up && 'is-up',
props.end && 'is-end',
props.icon && 'is-dots',
props.modern && 'is-modern',
props.spaced && 'is-spaced',
dropdown.isOpen && 'is-active',
...(typeof props.classes?.wrapper === 'string'
? [props.classes?.wrapper]
: props.classes?.wrapper ?? ''),
]"
class="dropdown"
>
<slot
name="button"
v-bind="dropdown"
>
<a
v-if="props.icon"
tabindex="0"
class="is-trigger dropdown-trigger"
aria-label="View more actions"
@keydown.enter.prevent="dropdown.toggle"
@click="dropdown.toggle"
>
<VIcon :icon="props.icon" />
</a>
<a
v-else
tabindex="0"
class="is-trigger button dropdown-trigger"
:class="[props.color && `is-${props.color}`]"
@keydown.enter.prevent="dropdown.toggle"
@click="dropdown.toggle"
>
<span v-if="props.title">{{ props.title }}</span>
<span :class="[!props.modern && 'base-caret', props.modern && 'base-caret']">
<VIcon
v-if="!dropdown.isOpen"
icon="fa6-solid:angle-down"
/>
<VIcon
v-else
icon="fa6-solid:angle-up"
/>
</span>
</a>
</slot>
<div
class="dropdown-menu"
role="menu"
>
<div
class="dropdown-content"
:class="props.classes?.content"
>
<slot
name="content"
v-bind="dropdown"
/>
</div>
</div>
</div>
</template>
<style lang="scss">
.dropdown {
&:not(.is-right) {
.dropdown-menu {
inset-inline-start: 0;
}
}
&.is-right {
.dropdown-menu {
inset-inline-start: initial;
inset-inline-end: 0;
}
}
&.is-end {
.dropdown-menu {
bottom: 0;
inset-inline-start: 145%;
transform: translateY(-100%);
height: fit-content;
}
}
&.is-dots {
&:hover,
&.is-active {
.is-trigger {
background: color-mix(in oklab, var(--fade-grey), white 2%);
.iconify {
color: color-mix(in oklab, var(--light-text), black 4%);
}
}
}
.is-trigger {
display: flex;
justify-content: center;
align-items: center;
height: 30px;
width: 30px;
border-radius: var(--radius-rounded);
cursor: pointer;
transition: all 0.3s; // transition-all test
.iconify {
vertical-align: middle;
}
.iconify {
font-size: 20px;
color: var(--light-text);
}
}
.dropdown-menu {
margin-top: 6px;
padding-bottom: 0;
text-align: inset-inline-start;
}
}
&.is-modern {
&.is-active {
.caret {
transform: rotate(calc(var(--transform-direction) * 180deg));
}
}
.is-trigger {
padding-inline-end: 0.75em;
.caret {
height: 22px;
width: 22px;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s; // transition-all test
margin-inline-start: 6px;
.iconify {
vertical-align: middle;
}
.iconify {
height: 16px;
width: 16px;
color: var(--light-text);
}
}
}
.dropdown-menu {
margin-top: 6px;
}
}
&.is-spaced {
.dropdown-menu {
box-shadow: 0 5px 16px rgb(0 0 0 / 5%);
border-color: var(--fade-grey);
padding-top: 0;
min-width: 260px;
&.has-margin {
margin-top: 10px;
}
.dropdown-content {
border: 1px solid var(--fade-grey);
box-shadow: none;
}
}
// Item
.dropdown-item {
padding: 0.5rem 1rem;
font-size: 0.95rem;
color: var(--light-text);
transition: all 0.3s; // transition-all test
&:not(.is-button):hover,
&:not(.is-button).is-active {
background: color-mix(in oklab, var(--fade-grey), white 3%);
color: var(--dark-text);
}
&.no-hover {
&:hover {
background: var(--white);
}
}
&.is-media {
display: flex;
align-items: center;
&:hover,
&:focus,
&.is-active {
.icon {
.iconify {
color: var(--primary);
}
.lnir,
.lnil {
color: var(--primary);
}
}
}
.icon {
display: flex;
justify-content: center;
align-items: center;
height: 28px;
width: 28px;
.iconify {
height: 18px;
width: 18px;
transition: stroke 0.3s;
}
.lnir,
.lnil {
font-size: 16px;
transition: color 0.3s;
}
}
.item-img {
display: block;
height: 32px;
width: 32px;
border-radius: var(--radius-large);
&.is-rounded {
border-radius: var(--radius-rounded);
}
}
.meta {
margin-inline-start: 10px;
span {
display: block;
line-height: 1.3;
&:first-child {
font-family: var(--font-alt);
font-size: 0.9rem;
font-weight: 600;
color: var(--dark-text);
}
&:nth-child(2) {
font-family: var(--font);
color: var(--light-text);
font-size: 0.9rem;
}
}
}
}
}
}
.is-trigger {
&.button {
font-family: var(--font);
&:focus {
border-color: color-mix(in oklab, var(--fade-grey), black 4%);
box-shadow: var(--light-box-shadow);
}
.base-caret {
display: flex;
justify-content: center;
align-items: center;
height: 16px;
width: 16px;
.iconify {
font-size: 14px;
margin-inline-start: 0.65rem;
}
}
}
}
// Dropdown menu
.dropdown-menu {
.dropdown-item {
text-align: start;
color: var(--light-text);
font-family: var(--font);
&:hover,
&:focus {
color: var(--dark-text);
}
&.is-active {
background: color-mix(in oklab, var(--fade-grey), white 3%);
// color: var(--white);
}
// Child dropdown parent
&.has-child {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding-inline-end: 1rem;
.iconify {
height: 16px;
width: 16px;
color: var(--muted-grey);
}
// Child hover dropdown
.child-dropdown {
position: absolute;
inset-inline-end: -282px;
top: 0;
width: 280px;
transition: all 0.3s; // transition-all test
opacity: 0;
transform: translateY(10px);
pointer-events: none;
// Inner
.inner {
position: relative;
height: 100%;
width: 100%;
background: var(--white);
border: 1px solid var(--primary-grey);
border-radius: var(--radius-large);
padding: 8px 0;
// Kanban columns settings
.column-setting {
padding: 0 6px;
display: flex;
align-items: center;
margin-bottom: 10px;
label {
transform: scale(0.7);
}
.text {
span {
display: block;
font-size: 0.8rem;
&:first-child {
color: var(--dark-text);
font-weight: 500;
}
&:nth-child(2) {
color: var(--muted-grey);
}
}
}
}
}
}
// Hover state
&:hover {
.child-dropdown {
opacity: 1;
transform: translateY(0);
pointer-events: all;
}
}
}
}
}
}
/* ==========================================================================
2. Dropdown Dark mode
========================================================================== */
.is-dark {
.toolbar-link {
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 2%) !important;
}
.iconify {
color: var(--dark-dark-text);
}
}
.dropdown {
&.is-spaced,
&.is-dots {
&:hover,
&.is-active {
.is-trigger {
background: color-mix(in oklab, var(--dark-sidebar), white 2%) !important;
.iconify {
color: var(--dark-dark-text);
}
}
}
.dropdown-menu {
.dropdown-content {
background: var(--dark-sidebar) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), white 8%) !important;
.heading {
border-color: color-mix(in oklab, var(--dark-sidebar), white 8%) !important;
&:hover,
&:focus,
*:hover {
background: var(--dark-sidebar) !important;
}
.heading-right {
.notification-link {
color: var(--primary) !important;
}
}
}
.notification-list {
li {
.notification-item {
&:hover,
*:hover {
background: var(--dark-sidebar) !important;
}
.user-content {
.user-info {
color: var(--dark-dark-text) !important;
}
}
}
}
}
.is-media {
&:hover {
.icon {
.iconify {
color: var(--primary) !important;
}
.lnir,
.lnil {
color: var(--primary);
}
}
}
&.is-active {
.icon {
.iconify {
color: var(--white) !important;
}
.lnir,
.lnil {
color: var(--white);
}
}
.meta span {
color: var(--white) !important;
}
}
.icon {
.iconify {
color: var(--light-text);
}
.lnir,
.lnil {
color: var(--light-text);
}
}
.meta {
span {
&:first-child {
color: var(--dark-dark-text);
}
}
}
}
}
}
}
.dropdown-menu {
.dropdown-content {
background: var(--dark-sidebar);
border-color: color-mix(in oklab, var(--dark-sidebar), white 8%) !important;
.dropdown-item {
color: var(--light-text);
&.is-active {
background: color-mix(in oklab, var(--dark-sidebar), white 2%) !important;
// color: var(--white) !important;
}
}
.dropdown-divider {
background: color-mix(in oklab, var(--dark-sidebar), white 5%);
}
a:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 5%) !important;
}
}
}
}
.child-dropdown {
.inner {
background: var(--dark-sidebar) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
&:hover,
&:focus {
background: var(--dark-sidebar) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
}
ul {
li {
.text {
span {
&:first-child {
color: var(--dark-dark-text) !important;
}
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
export type VFieldProps = {
id?: string
label?: string
addons?: boolean
textaddon?: boolean
grouped?: boolean
multiline?: boolean
horizontal?: boolean
subcontrol?: boolean
raw?: boolean
}
const props = withDefaults(defineProps<VFieldProps>(), {
id: undefined,
label: undefined,
})
const { field, id } = useVFieldContext({ id: props.id, inherit: !props.subcontrol })
const slots = useSlots()
const hasLabel = computed(() => Boolean(slots?.label?.() || props.label))
const classes = computed(() => {
if (props.raw) return []
return [
'field',
props.addons && 'has-addons',
props.textaddon && 'has-textarea-addon',
props.grouped && 'is-grouped',
props.grouped && props.multiline && 'is-grouped-multiline',
props.horizontal && 'is-horizontal',
]
})
defineExpose({ field, id })
</script>
<template>
<div :class="classes">
<template v-if="props.addons">
<div
v-if="hasLabel"
class="field-addon-label is-normal"
>
<slot
v-bind="{ field, id }"
name="label"
>
<VLabel>{{ props.label }}</VLabel>
</slot>
</div>
<div class="field-addon-body">
<slot v-bind="{ field, id }" />
</div>
</template>
<template v-else-if="hasLabel && props.horizontal">
<div class="field-label is-normal">
<slot
v-bind="{ field, id }"
name="label"
>
<VLabel>{{ props.label }}</VLabel>
</slot>
</div>
<div class="field-body">
<slot v-bind="{ field, id }" />
</div>
</template>
<template v-else-if="hasLabel">
<slot
v-bind="{ field, id }"
name="label"
>
<VLabel>{{ props.label }}</VLabel>
</slot>
<slot v-bind="{ field, id }" />
</template>
<template v-else>
<slot v-bind="{ field, id }" />
</template>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
export type VFlexDirection = 'row' | 'row-reverse' | 'column' | 'column-reverse'
export type VFlexWrap = 'nowrap' | 'wrap' | 'wrap-reverse'
export type VFlexJustifyContent =
| 'flex-start'
| 'flex-end'
| 'start'
| 'end'
| 'left'
| 'right'
| 'center'
| 'space-between'
| 'space-around'
| 'space-evenly'
| 'normal'
export type VFlexAlignItems =
| 'flex-start'
| 'flex-end'
| 'start'
| 'end'
| 'left'
| 'right'
| 'center'
| 'baseline'
| 'stretch'
| 'normal'
export type VFlexAlignContent =
| 'flex-start'
| 'flex-end'
| 'start'
| 'end'
| 'left'
| 'right'
| 'center'
| 'space-between'
| 'space-around'
| 'space-evenly'
| 'normal'
export interface VFlexProps {
inline?: boolean
flexDirection?: VFlexDirection
flexWrap?: VFlexWrap
justifyContent?: VFlexJustifyContent
alignItems?: VFlexAlignItems
alignContent?: VFlexAlignContent
rowGap?: string
columnGap?: string
}
const props = withDefaults(defineProps<VFlexProps>(), {
flexDirection: 'row',
flexWrap: 'nowrap',
justifyContent: 'normal',
alignItems: 'normal',
alignContent: 'normal',
rowGap: 'normal',
columnGap: 'normal',
})
const display = computed(() => (props.inline ? 'inline-flex' : 'flex'))
</script>
<template>
<div class="v-flex">
<slot />
</div>
</template>
<style lang="scss">
.v-flex {
display: v-bind(display);
flex-direction: v-bind('props.flexDirection');
flex-wrap: v-bind('props.flexWrap');
justify-content: v-bind('props.justifyContent');
align-items: v-bind('props.alignItems');
align-content: v-bind('props.alignContent');
row-gap: v-bind('props.rowGap');
column-gap: v-bind('props.columnGap');
}
</style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
export type VFlexItemAlignSelf =
| 'auto'
| 'flex-start'
| 'flex-end'
| 'center'
| 'baseline'
| 'stretch'
export interface VFlexItemProps {
order?: string | number
flexGrow?: string | number
flexShrink?: number
flexBasis?: string | 'auto'
alignSelf?: VFlexItemAlignSelf
}
const props = withDefaults(defineProps<VFlexItemProps>(), {
order: 0,
flexGrow: 0,
flexShrink: 0,
flexBasis: 'auto',
alignSelf: 'auto',
})
</script>
<template>
<div class="v-flex-item">
<slot />
</div>
</template>
<style lang="scss">
.v-flex-item {
order: v-bind('props.order');
flex-grow: v-bind('props.flexGrow');
flex-shrink: v-bind('props.flexShrink');
flex-basis: v-bind('props.flexBasis');
align-self: v-bind('props.alignSelf');
}
</style>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import type { RouteLocationOptions } from 'vue-router/auto'
export interface VFlexPaginationProps {
itemPerPage: number
totalItems: number
currentPage?: number
maxLinksDisplayed?: number
noRouter?: boolean
routerQueryKey?: string
}
export interface VFlexPaginationEmits {
(e: 'update:currentPage', currentPage: number): void
}
const emits = defineEmits<VFlexPaginationEmits>()
const props = withDefaults(defineProps<VFlexPaginationProps>(), {
currentPage: 1,
maxLinksDisplayed: 4,
useRouter: true,
routerQueryKey: 'page',
})
const route = useRoute()
const lastPage = computed(() => Math.ceil(props.totalItems / props.itemPerPage) || 1)
const totalPageDisplayed = computed(() =>
lastPage.value > props.maxLinksDisplayed + 2
? props.maxLinksDisplayed + 2
: lastPage.value,
)
const pages = computed(() => {
const _pages = []
let firstButton = props.currentPage - Math.floor(totalPageDisplayed.value / 2)
let lastButton
= firstButton + (totalPageDisplayed.value - Math.ceil(totalPageDisplayed.value % 2))
if (firstButton < 1) {
firstButton = 1
lastButton = firstButton + (totalPageDisplayed.value - 1)
}
if (lastButton > lastPage.value) {
lastButton = lastPage.value
firstButton = lastButton - (totalPageDisplayed.value - 1)
}
for (let page = firstButton; page <= lastButton; page += 1) {
if (page === firstButton || page === lastButton) {
continue
}
_pages.push(page)
}
return _pages
})
const showLastLink = computed(() => lastPage.value > 1)
const paginatedLink = (page = 1) => {
if (props.noRouter) {
return {}
}
const _page = Math.max(1, Math.min(page, lastPage.value))
const query: any = {
...route.query,
}
if (props.routerQueryKey) {
query[props.routerQueryKey] = _page <= 1 ? undefined : _page
}
return {
name: route.name,
params: route.params,
query,
} as RouteLocationOptions
}
const handleLinkClick = (e: MouseEvent, page = 1) => {
const _page = Math.max(1, Math.min(page, lastPage.value))
emits('update:currentPage', _page)
if (props.noRouter) {
e.preventDefault()
e.stopPropagation()
return false
}
}
</script>
<template>
<VFlex
role="navigation"
class="flex-pagination pagination is-rounded"
aria-label="pagination"
justify-content="space-between"
>
<ul class="pagination-list">
<slot name="before-pagination" />
<li>
<RouterLink
:to="paginatedLink(1)"
tabindex="0"
class="pagination-link"
:class="[currentPage === 1 && 'is-current']"
@keydown.enter.prevent="
(e: MouseEvent) => (e.target as HTMLAnchorElement).click()
"
@click="(e: MouseEvent) => handleLinkClick(e, 1)"
>
1
</RouterLink>
</li>
<li v-if="showLastLink && (pages.length === 0 || pages[0] > 2)">
<span class="pagination-ellipsis"></span>
</li>
<li
v-for="page in pages"
:key="page"
>
<RouterLink
:to="paginatedLink(page)"
tabindex="0"
class="pagination-link"
:aria-current="currentPage === page ? 'page' : undefined"
:class="[currentPage === page && 'is-current']"
@keydown.enter.prevent="
(e: MouseEvent) => (e.target as HTMLAnchorElement).click()
"
@click="(e: MouseEvent) => handleLinkClick(e, page)"
>
{{ page }}
</RouterLink>
</li>
<li v-if="showLastLink && pages[pages.length - 1] < lastPage - 1">
<span class="pagination-ellipsis"></span>
</li>
<li v-if="showLastLink">
<RouterLink
:to="paginatedLink(lastPage)"
tabindex="0"
class="pagination-link"
:class="[currentPage === lastPage && 'is-current']"
@keydown.enter.prevent="
(e: MouseEvent) => (e.target as HTMLAnchorElement).click()
"
@click="(e: MouseEvent) => handleLinkClick(e, lastPage)"
>
{{ lastPage }}
</RouterLink>
</li>
<slot name="after-pagination" />
</ul>
<slot name="before-navigation" />
<RouterLink
:to="paginatedLink(currentPage - 1)"
tabindex="0"
class="pagination-previous has-chevron"
@keydown.enter.prevent="(e: MouseEvent) => (e.target as HTMLAnchorElement).click()"
@click="(e: MouseEvent) => handleLinkClick(e, currentPage - 1)"
>
<VIcon icon="lucide:chevron-left" class="rtl-hidden" />
<VIcon icon="lucide:chevron-right" class="ltr-hidden" />
</RouterLink>
<RouterLink
:to="paginatedLink(currentPage + 1)"
tabindex="0"
class="pagination-next has-chevron"
@keydown.enter.prevent="(e: MouseEvent) => (e.target as HTMLAnchorElement).click()"
@click="(e: MouseEvent) => handleLinkClick(e, currentPage + 1)"
>
<VIcon icon="lucide:chevron-left" class="ltr-hidden" />
<VIcon icon="lucide:chevron-right" class="rtl-hidden" />
</RouterLink>
<slot name="after-navigation" />
</VFlex>
</template>

View File

@@ -0,0 +1,536 @@
<script setup lang="ts">
import { type VNode } from 'vue'
import { flewTableWrapperSymbol } from './VFlexTableWrapper.vue'
export interface VFlexTableColumn {
key: string
label: string
format: (value: any, row: any, index: number) => any
renderHeader?: () => VNode
renderRow?: (row: any, column: VFlexTableColumn, index: number) => VNode
align?: 'start' | 'center' | 'end'
bold?: boolean
inverted?: boolean
scrollX?: boolean
scrollY?: boolean
grow?: boolean | 'lg' | 'xl'
media?: boolean
cellClass?: string
}
export interface VFlexTableProps {
data?: any[]
columns?: Record<string, string | Partial<VFlexTableColumn>>
printObjects?: boolean
reactive?: boolean
compact?: boolean
rounded?: boolean
separators?: boolean
clickable?: boolean
subtable?: boolean
noHeader?: boolean
}
const emits = defineEmits<{
(e: 'rowClick', row: any, index: number): void
}>()
const props = withDefaults(defineProps<VFlexTableProps>(), {
columns: undefined,
data: () => [],
})
const wrapper = inject(flewTableWrapperSymbol, null)
const data = computed(() => {
if (wrapper?.data) return wrapper.data
if (props.reactive) {
if (isReactive(props.data)) {
return props.data
}
else {
return reactive(props.data)
}
}
return toRaw(props.data)
})
const defaultFormatter = (value: any) => value
const columns = computed(() => {
const columnsSrc = wrapper?.columns ?? props.columns
let columns: VFlexTableColumn[] = []
if (columnsSrc) {
for (const [key, label] of Object.entries(columnsSrc)) {
if (typeof label === 'string') {
columns.push({
format: defaultFormatter,
label,
key,
})
}
else {
columns.push({
format: defaultFormatter,
label: key,
key,
...(label as any),
})
}
}
}
else if (data.value.length > 0) {
for (const [key] of Object.entries(data.value[0])) {
columns.push({
format: defaultFormatter,
label: key,
key,
})
}
}
return columns
})
</script>
<template>
<div
class="flex-table"
:class="[
props.compact && 'is-compact',
props.rounded && 'is-rounded',
props.separators && 'with-separators',
props.noHeader && 'no-header',
props.clickable && 'is-table-clickable',
props.subtable && 'sub-table',
]"
>
<slot name="header">
<div
v-if="!props.noHeader"
class="flex-table-header"
>
<template
v-for="column in columns"
:key="'col' + column.key"
>
<slot
name="header-column"
:column="column"
>
<component
:is="{ render: column.renderHeader } as any"
v-if="column.renderHeader"
:class="[
column.grow === true && 'is-grow',
column.grow === 'lg' && 'is-grow-lg',
column.grow === 'xl' && 'is-grow-xl',
column.align === 'end' && 'cell-end',
column.align === 'center' && 'cell-center',
]"
/>
<span
v-else
:class="[
column.grow === true && 'is-grow',
column.grow === 'lg' && 'is-grow-lg',
column.grow === 'xl' && 'is-grow-xl',
column.align === 'end' && 'cell-end',
column.align === 'center' && 'cell-center',
]"
>{{ column.label }}</span>
</slot>
</template>
</div>
</slot>
<slot name="body">
<template
v-for="(row, index) in data"
:key="index"
>
<slot
name="body-row-pre"
:row="row"
:columns="columns"
:index="index"
/>
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div
class="flex-table-item"
:class="[props.clickable && 'is-clickable']"
:tabindex="props.clickable ? 0 : undefined"
:role="props.clickable ? 'button' : undefined"
@keydown.enter.prevent="
() => {
props.clickable && emits('rowClick', row, index)
}
"
@click="
() => {
props.clickable && emits('rowClick', row, index)
}
"
>
<slot
name="body-row"
:row="row"
:columns="columns"
:index="index"
>
<template
v-for="column in columns"
:key="'row' + column.key"
>
<VFlexTableCell :column="column">
<slot
name="body-cell"
:row="row"
:column="column"
:index="index"
:value="column.format(row[column.key], row, index)"
>
<component
:is="
{
render: () => column.renderRow?.(row, column, index),
} as any
"
v-if="column.renderRow"
/>
<span
v-else-if="
typeof column.format(row[column.key], row, index) === 'object'
"
:class="[
column.cellClass,
column.inverted && 'dark-inverted',
!column.inverted && (column.bold ? 'dark-text' : 'light-text'),
]"
>
<details v-if="printObjects">
<div class="language-json py-4">
<pre><code>{{ column.format(row[column.key], row, index) }}</code></pre>
</div>
</details>
</span>
<span
v-else
:class="[
column.cellClass,
column.inverted && 'dark-inverted',
!column.inverted && (column.bold ? 'dark-text' : 'light-text'),
]"
>
{{ column.format(row[column.key], row, index) }}
</span>
</slot>
</VFlexTableCell>
</template>
</slot>
</div>
<slot
name="body-row-post"
:row="row"
:columns="columns"
:index="index"
/>
</template>
</slot>
</div>
</template>
<style lang="scss">
.flex-table {
.flex-table-header {
display: flex;
align-items: center;
padding: 0 10px;
> span,
.text {
flex: 1 1 0;
display: flex;
align-items: center;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted-grey);
text-transform: uppercase;
padding: 0 10px 10px;
&.is-checkbox {
display: flex;
justify-content: center;
align-items: center;
width: 30px;
max-width: 30px;
.checkbox {
padding: 0;
> span {
height: 22px;
}
}
}
&.cell-center {
justify-content: center;
}
&.cell-end {
justify-content: flex-end;
}
&.is-grow {
flex-grow: 2;
}
&.is-grow-lg {
flex-grow: 3;
}
&.is-grow-xl {
flex-grow: 6;
}
a {
color: var(--muted-grey);
}
}
.checkbox {
padding-bottom: 10px;
padding-top: 0;
> span {
min-height: 20px;
}
}
}
.flex-table-item {
display: flex;
flex: 1;
align-items: stretch;
width: 100%;
min-height: 60px;
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
padding: 8px;
margin-bottom: 6px;
&.is-row {
border: none;
background: transparent;
}
}
&.sub-table {
.flex-table-item {
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
min-height: 40px;
border: none;
background: transparent;
.table-label {
font-family: var(--font);
text-transform: uppercase;
font-size: 0.8rem;
color: var(--light-text);
}
.table-total {
font-family: var(--font);
color: var(--dark-text);
font-weight: 500;
&.is-bigger {
font-size: 1.2rem;
font-weight: 600;
}
}
}
}
&.is-compact {
.flex-table-item {
margin-bottom: 0;
border-radius: 0;
&:not(:last-child) {
border-bottom: none;
}
}
&.is-rounded {
&:not(.no-header) {
.flex-table-item {
&:nth-of-type(2) {
border-radius: 8px 8px 0 0;
}
&:last-child {
margin-bottom: 6px;
border-radius: 0 0 8px 8px;
}
}
}
&.no-header {
.flex-table-item {
&:first-child {
border-radius: 8px 8px 0 0;
}
&:last-child {
margin-bottom: 6px;
border-radius: 0 0 8px 8px;
}
}
}
}
}
&:not(.is-compact) {
&.is-rounded {
.flex-table-item {
border-radius: 8px;
}
}
}
&.is-table-clickable {
.flex-table-item {
&:hover,
&:focus-within {
background: var(--widget-grey) !important;
}
}
}
&.with-separators {
.flex-table-item {
.flex-table-cell {
&:not(:first-of-type) {
border-inline-start: dashed 1px color-mix(in oklab, var(--fade-grey), black 3%);
}
}
}
}
}
/* ==========================================================================
2. Flex Table Dark mode
========================================================================== */
.is-dark {
.flex-table {
&:not(.sub-table) {
.flex-table-item {
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
&.with-separators {
.flex-table-item {
.flex-table-cell {
&:not(:first-of-type) {
border-inline-start: dashed 1px color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
}
}
&.is-table-clickable {
.flex-table-item {
&:hover,
&:focus-within {
background: color-mix(in oklab, var(--dark-sidebar), white 12%) !important;
}
}
}
}
}
/* ==========================================================================
3. Media Queries
========================================================================== */
@media (width <= 767px) {
.flex-table {
.flex-table-header {
display: none;
}
.flex-table-item {
flex-direction: column;
justify-content: center;
width: 100% !important;
padding: 20px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
> div {
border: none !important;
}
}
&:not(.sub-table) {
.flex-table-item {
.flex-table-cell {
> span,
> small,
> strong,
> p,
> div,
> .is-pushed-mobile,
> .text {
margin-inline-start: auto;
&.no-push {
margin-inline-start: 0 !important;
}
}
}
&:not(:first-child) {
.flex-table-cell {
&[data-th] {
&::before {
content: attr(data-th);
font-size: 0.9rem;
text-transform: uppercase;
font-weight: 500;
color: var(--muted-grey);
}
}
}
}
}
}
}
}
@media only screen and (width <= 767px) {
.flex-table {
&.sub-table {
padding-top: 16px;
.is-vhidden {
display: none !important;
}
.flex-table-item:not(.is-vhidden) {
flex-direction: revert !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,427 @@
<script setup lang="ts">
import type { VFlexTableColumn } from './VFlexTable.vue'
export interface VFlexTableCellProps {
column?: Partial<VFlexTableColumn>
}
const props = withDefaults(defineProps<VFlexTableCellProps>(), {
column: () => ({}),
})
</script>
<template>
<div
class="flex-table-cell is-relative"
:class="[
props.column.bold && 'is-bold',
props.column.media && 'is-media',
props.column.grow === true && 'is-grow',
props.column.grow === 'lg' && 'is-grow-lg',
props.column.grow === 'xl' && 'is-grow-xl',
props.column.scrollX && !props.column.scrollY && 'has-slimscroll-x',
!props.column.scrollX && props.column.scrollY && 'has-slimscroll',
props.column.scrollX && props.column.scrollY && 'has-slimscroll-all',
props.column.align === 'end' && 'cell-end',
props.column.align === 'center' && 'cell-center',
props.column.cellClass,
]"
:data-th="props.column.label || undefined"
>
<slot />
</div>
</template>
<style lang="scss">
.flex-table-cell {
flex: 1 1 0;
display: flex;
align-items: center;
padding: 0 10px;
font-family: var(--font);
word-break: keep-all;
white-space: nowrap;
text-align: inset-inline-start;
&.is-scrollable-x {
overflow-x: auto;
}
&.is-scrollable-y {
overflow-y: auto;
}
&.is-grow {
flex-grow: 2;
}
&.cell-center {
justify-content: center;
}
&.cell-end {
justify-content: flex-end;
.button {
&.has-dot {
.dot {
position: relative;
top: 1px;
font-size: 4px;
margin: 0 6px;
}
}
}
.action-link {
font-size: 0.9rem;
}
}
&.is-bold {
> span {
font-family: var(--font-alt);
font-size: 0.9rem;
font-weight: 600;
}
}
&.is-checkbox {
display: flex;
justify-content: center;
align-items: center;
width: 30px;
max-width: 30px;
.checkbox {
padding: 0;
margin-inline-start: 4px;
}
}
&.is-grow {
flex-grow: 2;
}
&.is-grow-lg {
flex-grow: 3;
}
&.is-grow-xl {
flex-grow: 6;
}
&.is-user,
&.is-media {
padding-inline-start: 0;
> div span:not(.avatar) {
display: block;
margin-inline-start: 10px;
}
> div {
line-height: 1.2;
.item-name {
font-family: var(--font-alt);
font-size: 0.9rem;
font-weight: 600;
color: var(--dark);
}
.item-meta {
color: var(--light-text);
.iconify {
position: relative;
top: 2px;
height: 14px;
width: 14px;
stroke-width: 1.6px;
margin-inline-end: 4px;
}
span,
.text {
display: inline-block;
margin-inline-start: 0;
font-size: 0.9rem;
}
.flex-media {
margin-inline-start: 10px;
margin-top: 4px;
.v-avatar {
width: 26px !important;
min-width: 26px !important;
height: 26px !important;
.avatar {
width: 26px !important;
min-width: 26px !important;
height: 26px !important;
}
}
}
.separator {
padding: 0 8px;
}
}
}
.v-avatar {
margin-inline-start: 0 !important;
.avatar.is-fake {
span,
.text {
margin: 0;
}
}
+ div {
margin-inline-start: 0.5rem !important;
}
}
.media {
display: block;
width: 100%;
max-width: 130px;
min-height: 95px;
object-fit: cover;
border-radius: 8px;
}
.cell-image {
display: block;
width: 100%;
max-width: 80px;
&.is-mini {
max-width: 40px;
}
}
&::before {
display: none;
}
}
.cell-icon {
margin-inline-end: 4px;
color: var(--light-text);
}
.tag {
margin-bottom: 0 !important;
line-height: 1.8;
height: 1.8em;
}
.flex-media {
display: flex;
align-items: center;
.meta {
margin-inline-start: 6px;
line-height: 1.3;
span,
.text {
display: block !important;
font-size: 0.8rem;
color: var(--light-text);
font-family: var(--font);
}
}
}
.dot-levels {
display: flex;
align-items: center;
.dot {
font-size: 8px;
color: color-mix(in oklab, var(--light-text), white 6%);
margin: 0 6px;
&.active {
color: var(--primary);
}
}
}
.edit-icon-link {
color: var(--light-text);
.iconify {
opacity: 0;
transition: opacity 0.3s;
}
&:hover,
&:focus-within {
color: var(--primary);
.iconify {
opacity: 1;
}
}
}
}
.is-dark {
.flex-table-cell {
&.is-user,
&.is-media {
.v-avatar {
.badge {
border-color: color-mix(in oklab, var(--dark-sidebar), white 6%) !important;
}
}
}
&.cell-end {
.button {
&.dark-outlined {
&:hover,
&:focus-within {
border-color: var(--primary) !important;
color: var(--primary) !important;
}
}
}
}
.dark-text {
color: var(--dark-dark-text) !important;
}
.avatar-stack {
.v-avatar {
.avatar {
border-color: color-mix(in oklab, var(--dark-sidebar), white 6%) !important;
}
.is-more {
.inner {
border-color: color-mix(in oklab, var(--dark-sidebar), white 6%) !important;
}
}
}
}
.dot-levels {
.dot {
&.active {
color: var(--primary);
}
}
}
}
}
@media (width <= 767px) {
.flex-table-cell {
position: relative;
margin-bottom: 12px;
&.no-label-mobile {
&::before {
display: none !important;
}
}
&.cell-end {
justify-content: flex-start !important;
.btn-group {
margin-inline-start: auto;
}
}
&.is-user,
&.is-media {
padding-inline-start: 10px;
span,
.text {
font-size: 1.2rem;
}
.media {
max-width: 80px;
min-height: 80px;
+ div {
margin-inline-start: 10px !important;
.item-name {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 205px;
}
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.flex-table-cell {
&.is-user {
img {
min-width: 50px;
}
}
&.is-media {
.media {
max-width: 60px;
min-height: 60px;
+ div {
margin-inline-start: 10px !important;
.item-name {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: landscape) {
.flex-table-cell {
&.is-media {
.media {
max-width: 60px;
min-height: 60px;
+ div {
margin-inline-start: 10px !important;
.item-name {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import type { SlotsType } from 'vue'
import type {
RouteLocationOptions,
LocationQueryValue,
} from 'vue-router/auto'
import { RouterLink } from 'vue-router'
export default defineComponent({
props: {
id: {
type: String,
required: true,
},
label: {
type: String,
default: undefined,
},
modelValue: {
type: String,
default: undefined,
},
noRouter: {
type: Boolean,
default: undefined,
},
routerQueryKey: {
type: String,
default: 'sort',
},
},
slots: Object as SlotsType<{
default: {
isDesc: boolean
isAsc: boolean
nextSort?: string
value: string | LocationQueryValue[]
}
}>,
emits: ['update:modelValue'],
setup(props, context) {
const route = useRoute()
const rawSort = computed(
() => props.modelValue ?? route.query[props.routerQueryKey] ?? '',
)
const isAsc = computed(() => rawSort.value === `${props.id}:asc`)
const isDesc = computed(() => rawSort.value === `${props.id}:desc`)
const nextSort = computed(() => {
return isAsc.value
? `${props.id}:desc`
: isDesc.value
? undefined
: `${props.id}:asc`
})
const sortedLink = computed(() => {
if (props.noRouter) {
return {}
}
const query: any = {
...route.query,
}
if (props.routerQueryKey) {
query[props.routerQueryKey] = nextSort.value
}
return {
name: route.name,
params: route.params,
query: query,
} as RouteLocationOptions
})
const handleLinkClick = (e: MouseEvent) => {
context.emit('update:modelValue', nextSort.value)
if (props.noRouter) {
e.preventDefault()
e.stopPropagation()
return false
}
}
return () => {
const slotContent = context.slots?.default?.({
isDesc: isDesc.value,
isAsc: isAsc.value,
nextSort: nextSort.value,
value: rawSort.value,
})
const link = h(
RouterLink,
{
to: sortedLink.value,
onClick: handleLinkClick,
onKeydown(e: KeyboardEvent) {
if (e.code === 'Space') {
e.preventDefault()
e.stopPropagation()
if (e.target instanceof HTMLAnchorElement) {
e.target.dispatchEvent(new MouseEvent('click'))
}
}
},
},
{
default() {
const icon = h('iconify-icon', {
class: 'ml-3 iconify',
icon: isAsc.value
? 'fa6-solid:sort-up'
: isDesc.value
? 'fa6-solid:sort-down'
: 'fa6-solid:sort',
})
return [slotContent ?? props.label, icon]
},
},
)
return h('span', {}, link)
}
},
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex-table-toolbar">
<div class="left">
<slot name="left" />
</div>
<div class="right">
<slot name="right" />
</div>
</div>
</template>
<style lang="scss">
.flex-table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 40px;
}
@media (width <= 767px) {
.flex-table-toolbar {
margin-bottom: 10px;
.left {
flex-grow: 2;
}
.right {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,469 @@
<script lang="ts">
import type { SlotsType, InjectionKey, PropType } from 'vue'
import type { VFlexTableColumn } from './VFlexTable.vue'
import VFlexTableSortColumn from './VFlexTableSortColumn.vue'
export type VFlexTableWrapperDataResolver<T = any> = (parameters: {
searchTerm: string
start: number
limit: number
sort?: string
controller?: AbortController
}) => T[] | Promise<T[]>
export type VFlexTableWrapperSortFunction<T = any> = (parameters: {
key: string
column: Partial<VFlexTableWrapperColumn>
order: 'asc' | 'desc'
a: T
b: T
}) => number
export type VFlexTableWrapperFilterFunction<T = any> = (parameters: {
searchTerm: string
value: any
row: T
column: Partial<VFlexTableWrapperColumn>
index: number
}) => boolean
export interface VFlexTableWrapperColumn extends VFlexTableColumn {
searchable?: boolean
sortable?: boolean
sort?: VFlexTableWrapperSortFunction
filter?: VFlexTableWrapperFilterFunction
}
export interface VFlexTableWrapperInjection {
data?: any[] | undefined
columns?: Record<string, Partial<VFlexTableWrapperColumn>>
loading?: boolean
searchInput?: string
searchTerm?: string
start?: number
limit?: number
sort?: string
page?: number
total?: number
totalPages?: number
fetchData: (controller?: AbortController) => Promise<void>
}
export const flewTableWrapperSymbol: InjectionKey<VFlexTableWrapperInjection> = Symbol()
const defaultFormatter = (value: any) => value
const defaultSortFunction: VFlexTableWrapperSortFunction = ({ key, order, a, b }) => {
const aValue = a[key]
const bValue = b[key]
if (typeof aValue === 'string') {
if (order === 'asc') {
return aValue.localeCompare(bValue)
}
else {
return bValue.localeCompare(aValue)
}
}
if (aValue > bValue) {
return order === 'asc' ? 1 : -1
}
if (aValue < bValue) {
return order === 'asc' ? -1 : 1
}
return 0
}
export default defineComponent({
props: {
data: {
type: [Array, Function] as PropType<any[] | VFlexTableWrapperDataResolver>,
default: undefined,
},
columns: {
type: Object as PropType<Record<string, string | Partial<VFlexTableWrapperColumn>>>,
default: undefined,
},
sort: {
type: String,
default: undefined,
},
searchTerm: {
type: String,
default: undefined,
},
limit: {
type: Number,
default: undefined,
},
page: {
type: Number,
default: undefined,
},
total: {
type: Number,
default: undefined,
},
debounceSearch: {
type: Number,
default: 300,
},
},
slots: Object as SlotsType<{
default: VFlexTableWrapperInjection
}>,
emits: ['update:sort', 'update:page', 'update:limit', 'update:searchTerm'],
setup(props, context) {
const rawData = ref<any[]>()
const loading = ref(false)
const defaultSort = ref('')
const sort = computed({
get: () => props.sort ?? defaultSort.value,
set(value) {
if (props.sort === undefined) {
defaultSort.value = value
}
else {
context.emit('update:sort', value)
}
},
})
const defaultSearchInput = ref('')
const searchInput = computed({
get: () => props.searchTerm ?? defaultSearchInput.value,
set(value) {
if (props.searchTerm === undefined) {
defaultSearchInput.value = value
}
else {
context.emit('update:searchTerm', value)
}
},
})
const defaultPage = ref(1)
const page = computed({
get: () => props.page ?? defaultPage.value,
set(value) {
if (props.page === undefined) {
defaultPage.value = value
}
else {
context.emit('update:page', value)
}
},
})
const defaultLimit = ref(10)
const limit = computed({
get: () => Math.max(1, props.limit ?? defaultLimit.value),
set(value) {
if (props.limit === undefined) {
defaultLimit.value = value
}
else {
context.emit('update:limit', value)
}
},
})
const columns = computed(() => {
const columnProps = props.columns
if (!columnProps) return columnProps
const wrapperColumns: Record<string, Partial<VFlexTableWrapperColumn>> = {}
Object.keys(columnProps).reduce((acc, key) => {
const value = columnProps[key]
if (typeof value === 'string') {
acc[key] = {
format: defaultFormatter,
label: value,
key,
}
}
else if (typeof value === 'object') {
acc[key] = {
format: defaultFormatter,
label: key,
key,
...value,
}
if (value.sortable === true) {
if (value.renderHeader) {
acc[key].renderHeader = () => {
return h(
VFlexTableSortColumn,
{
'id': key,
'noRouter': true,
'modelValue': sort.value,
'onUpdate:modelValue': value => (sort.value = value),
},
{
default: value.renderHeader,
},
)
}
}
else {
acc[key].renderHeader = () => {
return h(VFlexTableSortColumn, {
'id': key,
'label': value.label ?? key,
'noRouter': true,
'modelValue': sort.value,
'onUpdate:modelValue': value => (sort.value = value),
})
}
}
}
if (value.searchable === true && !value.sort) {
acc[key].sort = defaultSortFunction
}
}
return acc
}, wrapperColumns)
return wrapperColumns
})
const filteredData = computed(() => {
let data = rawData.value
if (!data) return data
if (typeof props.data === 'function') return data
// filter data
if (searchTerm.value) {
const searchableColumns = columns.value
? (Object.values(columns.value).filter((column) => {
if (!column || typeof column === 'string') return false
return column.searchable === true
}) as Partial<VFlexTableWrapperColumn>[])
: []
if (searchableColumns.length) {
const _searchRe = new RegExp(searchTerm.value, 'i')
data = data.filter((row, index) => {
return searchableColumns.some((column) => {
if (!column.key) return false
const value = row[column.key]
if (column.filter) {
return column.filter({
searchTerm: searchTerm.value,
value,
row,
column,
index,
})
}
if (typeof value === 'string') return value.match(_searchRe)
return false
})
})
}
}
return data
})
const sortedData = computed(() => {
let data = filteredData.value
if (!data) return data
if (typeof props.data === 'function') return data
// sort data
if (sort.value && sort.value.includes(':')) {
const [sortField, sortOrder] = sort.value.split(':') as [string, 'desc' | 'asc']
const sortingColumn = columns.value
? (Object.values(columns.value).find((column) => {
if (!column || typeof column === 'string') return false
return column.sortable === true && column.key === sortField
}) as Partial<VFlexTableWrapperColumn>)
: null
if (sortingColumn) {
const sorted = [...data]
sorted.sort((a, b) => {
if (!sortingColumn.key) return 0
if (!sortingColumn.sort) return 0
return sortingColumn.sort({
order: sortOrder,
column: sortingColumn,
key: sortingColumn.key,
a,
b,
})
})
data = sorted
}
}
return data
})
const data = computed(() => {
if (typeof props.data === 'function') return rawData.value
if (!rawData.value) return rawData.value
let data = sortedData.value
// paginate data
return data?.slice(start.value, start.value + limit.value)
})
const searchTerm = useDebounce(searchInput, props.debounceSearch)
const total = computed(() => props.total ?? sortedData.value?.length ?? 0)
const start = computed(() => (page.value - 1) * limit.value)
const totalPages = computed(() =>
total.value ? Math.ceil(total.value / limit.value) : 0,
)
async function fetchData(controller?: AbortController) {
if (typeof props.data === 'function') {
loading.value = true
try {
rawData.value = await props.data({
searchTerm: searchTerm.value,
start: start.value,
limit: limit.value,
sort: sort.value,
controller,
})
}
finally {
loading.value = false
}
}
}
watch([searchTerm, limit], () => {
if (page.value !== 1) {
page.value = 1
}
})
watchEffect(async (onInvalidate) => {
let controller: AbortController
if (typeof props.data === 'function') {
controller = new AbortController()
await fetchData(controller)
}
else {
rawData.value = props.data
}
onInvalidate(() => {
controller?.abort()
})
})
const wrapperState = reactive({
data,
columns,
loading,
searchInput,
searchTerm,
start,
page,
limit,
sort,
total,
totalPages,
fetchData,
}) as VFlexTableWrapperInjection
provide(flewTableWrapperSymbol, wrapperState)
context.expose(wrapperState)
return () => {
const slotContent = context.slots.default?.(wrapperState)
return h('div', { class: 'flex-table-wrapper' }, slotContent)
}
},
})
</script>
<style lang="scss">
.flex-table-wrapper {
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
border-radius: 8px;
padding: 20px;
.flex-table {
.flex-table-item {
margin-bottom: 0;
border-radius: 0;
border-inline-start: none;
border-inline-end: none;
border-top: none;
&:last-child {
margin-bottom: 6px;
border-bottom: none;
}
&:focus-visible {
border-radius: 4px;
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
}
}
}
/* ==========================================================================
6. Flex Table advanced wrapper Dark mode
========================================================================== */
.is-dark {
.flex-table-wrapper {
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
/* ==========================================================================
9. Media Queries
========================================================================== */
@media (width <= 767px) {
.flex-table-wrapper {
.flex-table {
.flex-table-header {
.is-checkbox {
display: none;
}
}
.flex-table-item {
padding-inline-start: 0;
padding-inline-end: 0;
.is-checkbox {
display: none;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
export type VGridJustifyItems = 'start' | 'end' | 'center' | 'stretch' | 'initial'
export type VGridAlignItems = 'start' | 'end' | 'center' | 'stretch' | 'initial'
export type VGridJustifyContent =
| 'start'
| 'end'
| 'center'
| 'stretch'
| 'space-around'
| 'space-between'
| 'space-evenly'
| 'initial'
export type VGridAlignContent =
| 'start'
| 'end'
| 'center'
| 'stretch'
| 'space-around'
| 'space-between'
| 'space-evenly'
| 'initial'
export type VGridAutoFlow = 'row' | 'column' | 'row dense' | 'column dense' | 'initial'
export interface VGridProps {
inline?: boolean
gridTemplateColumns?: string
gridTemplateRows?: string
gridTemplateAreas?: string
columnGap?: string
rowGap?: string
justifyItems?: VGridJustifyItems
alignItems?: VGridAlignItems
justifyContent?: VGridJustifyContent
alignContent?: VGridAlignContent
placeContent?: string
gridAutoColumns?: string
gridAutoRows?: string
gridAutoFlow?: VGridAutoFlow
}
const props = withDefaults(defineProps<VGridProps>(), {
gridTemplateColumns: 'none',
gridTemplateRows: 'none',
gridTemplateAreas: 'none',
columnGap: 'normal',
rowGap: 'normal',
justifyItems: 'initial',
alignItems: 'initial',
justifyContent: 'initial',
alignContent: 'initial',
placeContent: 'normal',
gridAutoColumns: 'auto',
gridAutoRows: 'auto',
gridAutoFlow: 'row',
})
const display = computed(() => (props.inline ? 'inline-grid' : 'grid'))
</script>
<template>
<div class="v-grid">
<slot />
</div>
</template>
<style lang="scss">
.v-grid {
display: v-bind(display);
grid-template-columns: v-bind('props.gridTemplateColumns');
grid-template-rows: v-bind('props.gridTemplateRows');
grid-template-areas: v-bind('props.gridTemplateAreas');
column-gap: v-bind('props.columnGap');
row-gap: v-bind('props.rowGap');
justify-items: v-bind('props.justifyItems');
align-items: v-bind('props.alignItems');
justify-content: v-bind('props.justifyContent');
align-content: v-bind('props.alignContent');
/* stylelint-disable-next-line declaration-block-no-shorthand-property-overrides */
place-content: v-bind('props.placeContent');
grid-auto-columns: v-bind('props.gridAutoColumns');
grid-auto-rows: v-bind('props.gridAutoRows');
grid-auto-flow: v-bind('props.gridAutoFlow');
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
export type VGridJustifySelf = 'start' | 'end' | 'center' | 'stretch' | 'auto'
export type VGridAlignSelf = 'start' | 'end' | 'center' | 'stretch' | 'auto'
export interface VGridItemProps {
gridColumnStart?: string | number
gridColumnEnd?: string | number
gridRowStart?: string | number
gridRowEnd?: string | number
justifySelf?: VGridJustifySelf
alignSelf?: VGridAlignSelf
placeSelf?: string
}
const props = withDefaults(defineProps<VGridItemProps>(), {
gridColumnStart: 'auto',
gridColumnEnd: 'auto',
gridRowStart: 'auto',
gridRowEnd: 'auto',
justifySelf: 'auto',
alignSelf: 'auto',
placeSelf: 'auto',
})
</script>
<template>
<div class="v-grid-item">
<slot />
</div>
</template>
<style lang="scss">
.v-grid-item {
grid-column-start: v-bind('props.gridColumnStart');
grid-column-end: v-bind('props.gridColumnEnd');
grid-row-start: v-bind('props.gridRowStart');
grid-row-end: v-bind('props.gridRowEnd');
justify-self: v-bind('props.justifySelf');
align-self: v-bind('props.alignSelf');
/* stylelint-disable-next-line declaration-block-no-shorthand-property-overrides */
place-self: v-bind('props.placeSelf');
}
</style>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
const props = defineProps<{
icon?: string
}>()
const isIconify = computed(() => {
return props.icon && props.icon.indexOf(':') !== -1
})
</script>
<template>
<iconify-icon
v-if="isIconify"
class="iconify"
:icon="props.icon"
/>
<i
v-else
aria-hidden="true"
:class="props.icon"
/>
</template>

View File

@@ -0,0 +1,717 @@
<script setup lang="ts">
export type VIconBoxSize = 'small' | 'medium' | 'large' | 'big' | 'xl'
export type VIconBoxColor =
| 'primary'
| 'info'
| 'success'
| 'warning'
| 'danger'
| 'purple'
| 'yellow'
| 'orange'
| 'green'
| 'red'
| 'blue'
export interface VIconProps {
size?: VIconBoxSize
color?: VIconBoxColor
rounded?: boolean
bordered?: boolean
}
const props = withDefaults(defineProps<VIconProps>(), {
size: undefined,
color: undefined,
})
</script>
<template>
<div
class="v-icon"
:class="[
props.size && 'is-' + props.size,
props.color && 'is-' + props.color,
props.rounded && 'is-rounded',
props.bordered && 'is-bordered',
]"
>
<slot />
</div>
</template>
<style scoped lang="scss">
.icons {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
.v-icon {
margin: 0 6px;
}
}
.v-icon {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 40px;
min-width: 40px;
border-radius: 8px;
background: var(--fade-grey);
transition: all 0.3s; // transition-all test
:deep(.fas),
:deep(.fab),
:deep(.far),
:deep(.fal) {
font-size: 1.2rem;
color: var(--muted-grey);
transition: color 0.3s;
}
:deep(.lnil),
:deep(.lnir) {
font-size: 1.4rem;
color: var(--muted-grey);
transition: color 0.3s;
}
:deep(.iconify) {
font-size: 20px;
}
&.is-small {
height: 32px;
width: 32px;
min-width: 32px;
:deep(.fas),
:deep(.fab),
:deep(.far),
:deep(.fal) {
font-size: 1rem;
}
:deep(.lnil),
:deep(.lnir) {
font-size: 1.2rem;
}
:deep(.iconify) {
font-size: 16px;
}
}
&.is-medium {
height: 50px;
width: 50px;
min-width: 50px;
&.is-bordered {
border-width: 1.6px;
}
:deep(.fas),
:deep(.fab),
:deep(.far),
:deep(.fal) {
font-size: 1.5rem;
}
:deep(.lnil),
:deep(.lnir) {
font-size: 1.7rem;
}
:deep(.iconify) {
font-size: 25px;
}
}
&.is-large {
height: 68px;
width: 68px;
min-width: 68px;
&.is-bordered {
border-width: 2px;
}
:deep(.fas),
:deep(.fab),
:deep(.far),
:deep(.fal) {
font-size: 2rem;
}
:deep(.lnil),
:deep(.lnir) {
font-size: 2.2rem;
}
:deep(.iconify) {
font-size: 34px;
}
}
&.is-big {
height: 80px;
width: 80px;
min-width: 80px;
&.is-bordered {
border-width: 2px;
}
:deep(.fas),
:deep(.fab),
:deep(.far),
:deep(.fal) {
font-size: 2.4rem;
}
:deep(.lnil),
:deep(.lnir) {
font-size: 2.6rem;
}
:deep(.iconify) {
height: 40px;
width: 40px;
font-size: 40px;
stroke-width: 3px;
}
}
&.is-xl {
height: 100px;
width: 100px;
min-width: 100px;
&.is-bordered {
border-width: 2px;
}
:deep(.fas),
:deep(.fab),
:deep(.far),
:deep(.fal) {
font-size: 3rem;
}
:deep(.lnil),
:deep(.lnir) {
font-size: 3.4rem;
}
:deep(.iconify) {
height: 50px;
width: 50px;
font-size: 50px;
stroke-width: 3px;
}
}
&.is-rounded {
border-radius: var(--radius-rounded);
}
&.is-primary {
background: color-mix(in oklab, var(--primary), white 82%);
&.is-bordered {
border-color: var(--primary);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--primary);
}
}
&.is-secondary {
background: color-mix(in oklab, var(--secondary), white 82%);
&.is-bordered {
border-color: var(--secondary);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--secondary);
}
}
&.is-accent {
background: color-mix(in oklab, var(--primary), white 76%);
&.is-bordered {
border-color: var(--primary);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--primary);
}
}
&.is-success {
background: color-mix(in oklab, var(--success), white 80%);
&.is-bordered {
border-color: var(--success);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--success);
}
}
&.is-info {
background: color-mix(in oklab, var(--info), white 80%);
&.is-bordered {
border-color: var(--info);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--info);
}
}
&.is-warning {
background: color-mix(in oklab, var(--warning), white 80%);
&.is-bordered {
border-color: var(--warning);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--warning);
}
}
&.is-danger {
background: color-mix(in oklab, var(--danger), white 80%);
&.is-bordered {
border-color: var(--danger);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--danger);
}
}
&.is-purple {
background: color-mix(in oklab, var(--purple), white 80%);
&.is-bordered {
border-color: var(--purple);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--purple);
}
}
&.is-blue {
background: color-mix(in oklab, var(--blue), white 80%);
&.is-bordered {
border-color: var(--blue);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--blue);
}
}
&.is-yellow {
background: color-mix(in oklab, var(--yellow), white 80%);
&.is-bordered {
border-color: var(--yellow);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--yellow);
}
}
&.is-orange {
background: color-mix(in oklab, var(--orange), white 80%);
&.is-bordered {
border-color: var(--orange);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--orange);
}
}
&.is-green {
background: color-mix(in oklab, var(--green), white 80%);
&.is-bordered {
border-color: var(--green);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--green);
}
}
&.is-red {
background: color-mix(in oklab, var(--red), white 80%);
&.is-bordered {
border-color: var(--red);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.lnil),
:deep(.lnir),
:deep(.fab) {
color: var(--red);
}
}
&.is-bordered {
border: 1px solid var(--muted-grey);
}
}
.is-dark {
.v-icon {
background-color: color-mix(in oklab, var(--dark-sidebar), white 2%);
&.is-primary {
background: var(--primary);
&.is-bordered {
border-color: var(--primary);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-accent {
background: var(--primary);
&.is-bordered {
border-color: var(--primary);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-success {
background: var(--success);
&.is-bordered {
border-color: var(--success);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-info {
background: var(--info);
&.is-bordered {
border-color: var(--info);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-warning {
background: var(--warning);
&.is-bordered {
border-color: var(--warning);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-danger {
background: var(--danger);
&.is-bordered {
border-color: var(--danger);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-purple {
background: var(--purple);
&.is-bordered {
border-color: var(--purple);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-blue {
background: var(--blue);
&.is-bordered {
border-color: var(--blue);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-yellow {
background: var(--yellow);
&.is-bordered {
border-color: var(--yellow);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-orange {
background: var(--orange);
&.is-bordered {
border-color: var(--orange);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-green {
background: var(--green);
&.is-bordered {
border-color: var(--green);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
&.is-red {
background: var(--red);
&.is-bordered {
border-color: var(--red);
}
:deep(.lnil),
:deep(.lnir) {
color: var(--white);
}
:deep(.iconify),
:deep(.fas),
:deep(.far),
:deep(.fal),
:deep(.fab) {
color: var(--smoke-white);
}
}
}
}
</style>

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { type PropType } from 'vue'
export type VIconButtonDark = '1' | '2' | '3' | '4' | '5' | '6'
export type VIconButtonColor =
| 'primary'
| 'info'
| 'success'
| 'warning'
| 'danger'
| 'white'
export default defineComponent({
props: {
icon: {
type: String,
required: true,
},
to: {
type: Object,
default: undefined,
},
href: {
type: String,
default: undefined,
},
color: {
type: String as PropType<VIconButtonColor>,
default: undefined,
validator: (value: VIconButtonColor) => {
// The value must match one of these strings
if (
[undefined, 'primary', 'info', 'success', 'warning', 'danger', 'white'].indexOf(
value,
) === -1
) {
console.warn(
`VIconButton: invalid "${value}" color. Should be primary, info, success, warning, danger, white or undefined`,
)
return false
}
return true
},
},
dark: {
type: String as PropType<VIconButtonDark>,
default: '1',
validator: (value: VIconButtonDark) => {
if (!value) return true
// The value must match one of these strings
if (['1', '2', '3', '4', '5', '6'].indexOf(value) === -1) {
console.warn(
`VIconButton: invalid "${value}" dark. Should be 1, 2, 3, 4, 5, 6 or undefined`,
)
return false
}
return true
},
},
circle: {
type: Boolean,
default: false,
},
bold: {
type: Boolean,
default: false,
},
light: {
type: Boolean,
default: false,
},
raised: {
type: Boolean,
default: false,
},
outlined: {
type: Boolean,
default: false,
},
darkOutlined: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { attrs }) {
const classes = computed(() => {
const defaultClasses = (attrs?.class || []) as string[] | string
return [
defaultClasses,
props.disabled && 'is-disabled',
props.circle && 'is-circle',
props.bold && 'is-bold',
props.outlined && 'is-outlined',
props.raised && 'is-raised',
props.dark && `is-dark-bg-${props.dark}`,
props.darkOutlined && 'is-dark-outlined',
props.loading && 'is-loading',
props.color && `is-${props.color}`,
props.light && 'is-light',
]
})
const isIconify = computed(() => props.icon && props.icon.indexOf(':') !== -1)
return () => {
let icon
if (isIconify.value) {
icon = h('iconify-icon', {
class: 'iconify',
icon: props.icon,
})
}
else {
icon = h('i', { 'aria-hidden': true, 'class': props.icon })
}
const iconWrapper = h('span', { class: 'icon' }, icon)
if (props.to) {
return h(
resolveComponent('RouterLink'),
{
...attrs,
to: props.to,
class: ['button', ...classes.value],
},
iconWrapper,
)
}
else if (props.href) {
return h(
'a',
{
...attrs,
href: props.href,
class: classes.value,
},
iconWrapper,
)
}
return h(
'button',
{
type: 'button',
...attrs,
disabled: props.disabled,
class: ['button', ...classes.value],
},
iconWrapper,
)
}
},
})
</script>
<style lang="scss" scoped>
.button {
height: 38px;
width: 38px;
}
</style>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
export type VIconWrapDark = '1' | '2' | '3' | '4' | '5' | '6'
export type VIconWrapSize = 'small' | 'medium' | 'large'
export type VIconWrapColor =
| 'white'
| 'black'
| 'light'
| 'dark'
| 'primary'
| 'secondary'
| 'link'
| 'info'
| 'success'
| 'warning'
| 'danger'
export interface VIconWrapProps {
icon?: string
picture?: string
color?: VIconWrapColor
size?: VIconWrapSize
dark?: VIconWrapDark
hasLargeIcon?: boolean
hasBackground?: boolean
placeholder?: boolean
darkPrimary?: boolean
darkCardBordered?: boolean
}
const props = withDefaults(defineProps<VIconWrapProps>(), {
icon: undefined,
picture: undefined,
color: undefined,
size: undefined,
dark: '3',
})
const { onceError } = useImageError()
</script>
<template>
<div
class="icon-wrap"
:class="[
props.color && !props.hasBackground && `has-text-${props.color}`,
props.color && props.hasBackground && `has-background-${props.color}`,
props.color && props.color !== 'white' && props.hasBackground && `has-text-white`,
props.color && props.color === 'white' && props.hasBackground && `has-text-black`,
props.size && `is-${props.size}`,
props.dark && !props.hasBackground && `is-dark-bg-${props.dark}`,
props.darkPrimary && 'is-dark-primary',
props.darkCardBordered && 'is-dark-card-bordered',
props.hasLargeIcon && 'has-large-icon',
props.picture && 'has-img',
props.placeholder && 'is-placeholder',
]"
>
<img
v-if="props.picture"
:src="props.picture"
alt=""
@error.once="onceError($event, 32)"
>
<VIcon :icon="props.icon" />
<slot name="after" />
</div>
</template>
<style lang="scss">
.icon-wrap {
display: flex;
justify-content: center;
align-items: center;
height: 32px;
width: 32px;
min-width: 32px;
border-radius: var(--radius-rounded);
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
box-shadow: var(--light-box-shadow);
color: var(--primary);
font-size: 1rem;
&.has-large-icon {
font-size: 1.3rem;
}
&.is-small {
font-size: 0.9rem;
height: 24px;
width: 24px;
min-width: 24px;
&.has-large-icon {
font-size: 1rem;
}
}
&.is-medium {
font-size: 1.4rem;
height: 42px;
width: 42px;
min-width: 42px;
&.has-large-icon {
font-size: 1.8rem;
}
}
&.is-large {
font-size: 2rem;
height: 58px;
width: 58px;
min-width: 58px;
&.has-large-icon {
font-size: 2.9rem;
}
}
img {
border-radius: var(--radius-rounded);
}
&.is-placeholder {
background-color: color-mix(in oklab, var(--fade-grey), white 2%) !important;
border-color: color-mix(in oklab, var(--fade-grey), black 3%) !important;
color: var(--light-text);
.iconify {
font-size: 1.4rem;
}
}
}
.is-dark {
.icon-wrap {
border-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
export interface VInputProps {
raw?: boolean
trueValue?: boolean
falseValue?: boolean
}
const modelValue = defineModel<any>({
default: '',
})
const props = withDefaults(defineProps<VInputProps>(), {
modelValue: '',
trueValue: true,
falseValue: false,
})
const { field, id } = useVFieldContext({
create: false,
help: 'VInput',
})
const internal = computed({
get() {
if (field?.value) {
return field.value.value
}
else {
return modelValue.value
}
},
set(value: any) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
const classes = computed(() => {
if (props.raw) return []
return ['input', 'v-input']
})
</script>
<template>
<input
:id="id"
v-model="internal"
:class="classes"
:name="id"
:true-value="props.trueValue"
:false-value="props.falseValue"
@change="field?.handleChange"
@blur="field?.handleBlur"
>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
export interface VLabelProps {
id?: string
raw?: boolean
}
const props = withDefaults(defineProps<VLabelProps>(), {
id: undefined,
})
const context = useVFieldContext({
create: false,
help: 'VLabel',
})
const classes = computed(() => {
if (props.raw) return []
return ['label']
})
</script>
<template>
<label
:class="classes"
:for="props.id || context.id.value"
>
<slot v-bind="context" />
</label>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
defineOptions({
inheritAttrs: false,
})
const props = defineProps({
// @ts-ignore
...RouterLink.props,
})
const isExternalLink = computed(() => {
return typeof props.to === 'string' && props.to.startsWith('http')
})
</script>
<template>
<a
v-if="isExternalLink"
v-bind="$attrs"
:href="to"
target="_blank"
>
<slot />
</a>
<RouterLink
v-else
v-slot="{ href, navigate, isActive, isExactActive }"
v-bind="({
...$props,
custom: true,
} as any)"
>
<a
v-bind="$attrs"
:href="href"
:class="[
isActive && 'router-link-active',
isExactActive && 'router-link-exact-active',
]"
@click="navigate"
>
<slot />
</a>
</RouterLink>
</template>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
export type VLoaderSize = 'small' | 'large' | 'xl'
export type VLoaderWrapperRadius = 'regular' | 'smooth' | 'rounded'
export interface VLoaderProps {
size?: VLoaderSize
card?: VLoaderWrapperRadius
active?: boolean
grey?: boolean
translucent?: boolean
}
const props = withDefaults(defineProps<VLoaderProps>(), {
size: undefined,
card: undefined,
})
</script>
<template>
<div
class="has-loader"
:class="[props.active && 'has-loader-active']"
>
<div
v-if="props.active"
class="v-loader-wrapper is-active"
:class="[
grey && 'is-grey',
translucent && 'is-translucent',
card === 'regular' && 's-card',
card === 'smooth' && 'r-card',
card === 'rounded' && 'l-card',
]"
>
<div
class="loader is-loading"
:class="[props.size && `is-${props.size}`]"
/>
</div>
<slot />
</div>
</template>
<style lang="scss">
.has-loader {
position: relative;
&.has-loader-active {
overflow: hidden;
}
.v-loader-wrapper {
position: absolute;
top: 0;
inset-inline-start: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
background: var(--white);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
z-index: 5;
&.is-active {
opacity: 1;
pointer-events: all;
&.is-translucent {
opacity: 0.65;
}
}
&.is-grey {
background: var(--background-grey);
}
.loader {
height: 3rem;
width: 3rem;
&.is-small {
height: 2rem;
width: 2rem;
}
&.is-large {
height: 5rem;
width: 5rem;
}
&.is-xl {
height: 7rem;
width: 7rem;
}
}
}
}
.is-dark {
.has-loader {
.v-loader-wrapper {
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
&.is-grey {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
}
}
}
}
$grey-lighter: hsl(0deg 0% 86%) !default;
$radius-rounded: 290486px !default;
@keyframes spinAroundLoader {
from {
transform: rotate(calc(var(--transform-direction) * 0deg));
}
to {
transform: rotate(calc(var(--transform-direction) * 359deg));
}
}
@mixin loader {
animation: spinAroundLoader 500ms infinite linear;
border: 2px solid $grey-lighter;
border-radius: var(--radius-rounded);
border-inline-end-color: transparent;
border-top-color: transparent;
content: '';
display: block;
height: 1em;
position: relative;
width: 1em;
}
%loader {
@include loader;
}
</style>

View File

@@ -0,0 +1,224 @@
<script setup lang="ts">
export type VMessageColor =
| 'primary'
| 'success'
| 'info'
| 'warning'
| 'danger'
| 'white'
export interface VMessageEmits {
(e: 'close'): void
}
export interface VMessageProps {
color?: VMessageColor
closable?: boolean
}
const emit = defineEmits<VMessageEmits>()
const props = withDefaults(defineProps<VMessageProps>(), {
color: undefined,
})
</script>
<template>
<div
class="message"
:class="[props.color && `is-${props.color}`]"
>
<a
v-if="props.closable"
aria-label="Dismiss"
class="delete"
tabindex="0"
role="button"
@keydown.enter.prevent="emit('close')"
@click.prevent="emit('close')"
/>
<div class="message-body">
<slot />
</div>
</div>
</template>
<style lang="scss">
.message {
position: relative;
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
box-shadow: var(--light-box-shadow);
padding-inline-end: 20px;
&.is-primary {
border-color: color-mix(in oklab, var(--primary), white 24%);
.delete {
&::before,
&::after {
background-color: var(--primary);
}
}
}
&.is-info {
border-color: color-mix(in oklab, var(--info), white 24%);
.delete {
&::before,
&::after {
background-color: var(--info);
}
}
}
&.is-success {
border-color: color-mix(in oklab, var(--success), white 24%);
.delete {
&::before,
&::after {
background-color: var(--success);
}
}
}
&.is-warning {
border-color: color-mix(in oklab, var(--warning), white 24%);
.delete {
&::before,
&::after {
background-color: var(--warning);
}
}
}
&.is-danger {
border-color: color-mix(in oklab, var(--danger), white 24%);
.delete {
&::before,
&::after {
background-color: var(--danger);
}
}
}
.delete {
position: absolute;
background-color: transparent;
top: 6px;
inset-inline-end: 6px;
&::before {
height: 1px;
background-color: var(--light-text);
}
&::after {
width: 1px;
background-color: var(--light-text);
}
}
.message-body {
border: none;
font-family: var(--font);
}
}
.is-dark {
.message {
&:not(.is-primary, .is-info, .is-success, .is-warning, .is-danger) {
background-color: var(--dark-sidebar);
border-color: color-mix(in oklab, var(--dark-sidebar), white 3%);
.message-body {
color: var(--light-text);
}
}
span {
color: var(--white);
}
&.is-primary {
background: var(--primary);
border-color: var(--primary);
.message-body {
color: var(--white);
}
.delete {
&::before,
&::after {
background-color: var(--white);
}
}
}
&.is-success {
background: var(--success);
border-color: var(--success);
.message-body {
color: var(--white);
}
.delete {
&::before,
&::after {
background-color: var(--white);
}
}
}
&.is-info {
background: var(--info);
border-color: var(--info);
.message-body {
color: var(--white);
}
.delete {
&::before,
&::after {
background-color: var(--white);
}
}
}
&.is-warning {
background: var(--warning);
border-color: var(--warning);
.message-body {
color: var(--white);
}
.delete {
&::before,
&::after {
background-color: var(--white);
}
}
}
&.is-danger {
background: var(--danger);
border-color: var(--danger);
.message-body {
color: var(--white);
}
.delete {
&::before,
&::after {
background-color: var(--white);
}
}
}
}
}
</style>

View File

@@ -0,0 +1,409 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { FocusTrap } from 'focus-trap-vue'
export type VModalSize = 'small' | 'medium' | 'large' | 'big' | 'contract-big'
export type VModalAction = 'center' | 'right'
export interface VModalEmits {
(e: 'close'): void
}
export interface VModalProps {
title: string
is?: string | Component
size?: VModalSize
actions?: VModalAction
open?: boolean
rounded?: boolean
noscroll?: boolean
noclose?: boolean
tabs?: boolean
cancelLabel?: string
}
defineOptions({
inheritAttrs: false,
})
const emit = defineEmits<VModalEmits>()
const props = withDefaults(defineProps<VModalProps>(), {
is: 'div',
size: undefined,
actions: undefined,
cancelLabel: undefined,
})
const wasOpen = ref(false)
const cancelLabel = computed(() => props.cancelLabel || '취소')
const checkScroll = () => {
if (props.noscroll && props.open) {
if (!import.meta.env.SSR) {
document.documentElement.classList.add('no-scroll')
}
wasOpen.value = true
}
else if (wasOpen.value && props.noscroll && !props.open) {
if (!import.meta.env.SSR) {
document.documentElement.classList.remove('no-scroll')
}
wasOpen.value = false
}
}
watchEffect(checkScroll)
onUnmounted(() => {
if (!import.meta.env.SSR) {
document.documentElement.classList.remove('no-scroll')
}
})
</script>
<template>
<Teleport
v-if="open"
to="body"
>
<FocusTrap
:initial-focus="() => ($refs.closeButton as any)?.el"
>
<component
:is="is"
role="dialog"
aria-modal="true"
:class="[open && 'is-active', size && `is-${size}`]"
class="modal v-modal"
v-bind="$attrs"
>
<div
class="modal-background v-modal-close"
tabindex="-1"
role="button"
@keydown.enter.prevent="() => noclose === false && emit('close')"
@click="() => noclose === false && emit('close')"
/>
<div class="modal-content">
<div class="modal-card">
<header class="modal-card-head">
<h3>{{ title }}</h3>
<button
ref="closeButton"
class="v-modal-close ml-auto"
aria-label="close"
tabindex="0"
@keydown.enter.prevent="emit('close')"
@click="emit('close')"
>
<VIcon icon="lucide:x" />
</button>
</header>
<div
class="modal-card-body"
:class="[props.tabs && 'has-tabs has-slimscroll']"
>
<div class="inner-content">
<slot name="content" />
</div>
</div>
<div
class="modal-card-foot"
:class="[
actions === 'center' && 'is-centered',
actions === 'right' && 'is-end',
]"
>
<slot
name="cancel"
:close="() => emit('close')"
>
<a
tabindex="0"
role="button"
class="button v-button v-modal-close"
:class="[rounded && 'is-rounded']"
@keydown.enter.prevent="emit('close')"
@click="emit('close')"
>
{{ cancelLabel }}
</a>
</slot>
<slot
name="action"
:close="() => emit('close')"
/>
</div>
</div>
</div>
</component>
</FocusTrap>
</Teleport>
</template>
<style lang="scss">
.modal {
transition: all 0.5s;
&.is-contract-big {
.modal-content {
width: 100%;
max-width: 1500px;
.modal-card {
width: 100%;
}
}
}
&.is-big {
.modal-content {
width: 100%;
max-width: 840px;
.modal-card {
width: 100%;
}
}
}
&.is-large {
.modal-content {
width: 100%;
max-width: 720px;
.modal-card {
width: 100%;
}
}
}
&.is-medium {
.modal-content {
width: 100%;
max-width: 640px;
.modal-card {
width: 100%;
}
}
}
&.is-small {
.modal-content {
width: 100%;
max-width: 420px;
.modal-card {
width: 100%;
}
}
}
.modal-content {
transition: all 0.4s;
}
}
.v-modal {
background: transparent;
border: transparent;
&.is-active {
z-index: 200 !important;
.v-modal-close {
cursor: pointer;
}
}
.v-modal-card {
width: 100%;
background: var(--white);
border: 1px solid var(--fade-grey);
border-radius: 8px;
padding: 40px;
}
&::backdrop {
background: var(--dark-sidebar);
}
.modal-content {
transform: scale(1) !important;
opacity: 1 !important;
max-width: 540px;
overflow-x: hidden;
animation: fadeInDown 0.5s;
margin: 0;
padding: 0 10px;
.modal-card {
max-width: 100%;
margin: 0 auto;
&.is-rounded {
border-radius: 12px;
}
.modal-card-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: var(--white);
border-bottom-color: color-mix(in oklab, var(--fade-grey), black 3%);
&.no-border {
border-bottom-color: transparent;
}
h3 {
font-family: var(--font-alt);
color: var(--dark-text);
font-weight: 600;
font-size: 1rem;
}
.v-modal-close {
display: flex;
justify-content: center;
align-items: center;
background: none;
border-color: transparent;
width: 22px;
height: 22px;
padding: 0;
&:hover,
&:focus {
.iconify {
color: var(--primary);
}
}
&:focus-visible {
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
.iconify {
width: 22px;
height: 22px;
font-size: 20px;
color: var(--muted-grey);
}
}
}
.modal-card-body {
.modal-form {
padding: 10px 0 20px;
}
&.has-tabs {
padding: 0;
overflow-x: hidden;
.tabs {
overflow-x: auto;
&::-webkit-scrollbar {
height: 5px !important;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px !important;
background: rgb(0 0 0 / 20%) !important;
}
a {
padding: 0.75em 1em;
}
}
}
}
.modal-card-foot {
background-color: var(--white);
padding: 15px 20px;
border-top: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
&.no-border {
border-top-color: transparent;
}
&.is-start {
justify-content: flex-start !important;
}
&.is-centered {
justify-content: center !important;
}
&.is-end {
justify-content: flex-end !important;
}
.v-button {
min-width: 110px;
}
}
}
}
}
.is-dark {
.v-modal {
.modal-background {
background: rgb(101 101 104 / 80%) !important;
}
.modal-content {
.modal-card {
.modal-card-head {
background: color-mix(in oklab, var(--dark-sidebar), white 6%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
h3 {
color: var(--dark-dark-text);
}
.v-modal-close {
&:hover {
.iconify {
color: var(--primary);
}
}
}
}
.modal-card-body {
background: color-mix(in oklab, var(--dark-sidebar), white 6%) !important;
}
.modal-card-foot {
background: color-mix(in oklab, var(--dark-sidebar), white 6%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
}
}
}
@media screen and (width >= 769px) {
.modal.modal-lg {
.modal-card,
.modal-content {
width: 800px !important;
}
}
.modal.modal-sm {
.modal-card,
.modal-content {
width: 400px !important;
}
}
}
</style>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
const { field, id } = useVFieldContext({
create: false,
help: 'VOptgroup',
})
</script>
<template>
<optgroup>
<slot v-bind="{ field, id }" />
</optgroup>
</template>
<style scoped lang="scss">
optgroup {
padding: 0.5em 1em;
:deep(option) {
&::before {
content: '' !important;
}
}
}
optgroup[disabled] {
pointer-events: none;
opacity: 0.4;
cursor: default !important;
:deep(option[disabled]) {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
const { field, id } = useVFieldContext({
create: false,
help: 'VOption',
})
</script>
<template>
<option>
<slot v-bind="{ field, id }" />
</option>
</template>
<style lang="scss">
option[disabled] {
pointer-events: none;
opacity: 0.4;
cursor: default !important;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
export interface VPlaceholderPageProps {
title: string
subtitle?: string
larger?: boolean
}
const props = withDefaults(defineProps<VPlaceholderPageProps>(), {
subtitle: undefined,
})
</script>
<template>
<div class="page-placeholder">
<div class="placeholder-content">
<slot name="image" />
<h3 class="dark-inverted">
{{ props.title }}
</h3>
<p
v-if="props.subtitle"
:class="[props.larger && 'is-larger']"
>
{{ props.subtitle }}
</p>
<slot name="action" />
</div>
</div>
</template>
<style lang="scss">
.page-placeholder {
min-height: 400px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 0 20px;
&.is-wider {
.placeholder-content {
> p {
font-size: 1rem;
max-width: 420px;
}
}
}
.placeholder-content {
text-align: center;
img {
display: block;
max-width: 340px;
margin: 0 auto 12px;
&.is-larger {
max-width: 440px;
}
}
h3 {
font-size: 1.3rem;
font-weight: 600;
font-family: var(--font-alt);
color: var(--dark-text);
}
p {
font-size: 1.1rem;
max-width: 440px;
margin: 0 auto 12px;
color: var(--light-text);
&.is-larger {
max-width: 620px;
}
}
.btn {
margin-bottom: 8px;
}
}
}
.is-dark {
.page-placeholder {
.placeholder-content {
h3 {
color: var(--dark-dark-text);
}
}
}
}
@media (width <= 767px) {
.page-placeholder {
.placeholder-content {
img {
max-width: 280px;
}
}
}
}
</style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
export interface VPlaceholderSectionProps {
title: string
subtitle?: string
}
const props = withDefaults(defineProps<VPlaceholderSectionProps>(), {
subtitle: undefined,
})
</script>
<template>
<div class="section-placeholder">
<div class="placeholder-content">
<slot name="image" />
<h3 class="dark-inverted">
{{ props.title }}
</h3>
<p v-if="props.subtitle">
{{ props.subtitle }}
</p>
<slot name="action" />
</div>
</div>
</template>
<style lang="scss">
.section-placeholder {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
.placeholder-content {
text-align: center;
> img {
display: block;
max-width: 280px;
margin: 0 auto 10px;
}
.v-avatar {
margin: 0 auto 10px;
}
h3 {
font-family: var(--font-alt);
font-size: 1.1rem;
font-weight: 600;
color: var(--dark-text);
}
p {
font-family: var(--font);
font-size: 1rem;
color: var(--light-text);
max-width: 280px;
margin: 0 auto 12px;
}
.button {
min-width: 140px;
}
}
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
export type VPlaceloadProps = {
width?: string
height?: string
mobileWidth?: string
mobileHeight?: string
disabled?: boolean
centered?: boolean
}
const props = withDefaults(defineProps<VPlaceloadProps>(), {
width: '100%',
height: '10px',
mobileWidth: undefined,
mobileHeight: undefined,
})
const mobileWidthValue = props.mobileWidth ?? props.width
const mobileHeightValue = props.mobileHeight ?? props.height
if (props.width.match(CssUnitRe) === null) {
console.warn(
`VPlaceload: invalid "${props.width}" width. Should be a valid css unit value.`,
)
}
if (props.height.match(CssUnitRe) === null) {
console.warn(
`VPlaceload: invalid "${props.height}" height. Should be a valid css unit value.`,
)
}
if (mobileWidthValue.match(CssUnitRe) === null) {
console.warn(
`VPlaceload: invalid "${mobileWidthValue}" mobileWidth. Should be a valid css unit value.`,
)
}
if (mobileHeightValue.match(CssUnitRe) === null) {
console.warn(
`VPlaceload: invalid "${mobileHeightValue}" mobileHeight. Should be a valid css unit value.`,
)
}
</script>
<template>
<div
class="content-shape"
:class="[props.centered && 'is-centered', !props.disabled && 'loads']"
/>
</template>
<style lang="scss" scoped>
.content-shape {
width: v-bind('props.width');
height: v-bind('props.height');
}
.content-shape {
&.is-grow-1 {
flex-grow: 1;
}
&.is-grow-2 {
flex-grow: 2;
}
&.is-grow-3 {
flex-grow: 3;
}
&.is-grow-4 {
flex-grow: 4;
}
&.mw-30 {
max-width: 30%;
}
&.mw-60 {
max-width: 60%;
}
&.mw-80 {
max-width: 80%;
}
&.is-centered {
margin-inline-start: auto;
margin-inline-end: auto;
}
}
@media (width <= 767px) {
.content-shape {
width: v-bind(mobileWidthValue);
height: v-bind(mobileHeightValue);
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
export type VPlaceloadAvatarSize = 'small' | 'medium' | 'large' | 'big' | 'xl'
export type VPlaceloadAvatarRounded = 'full' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export interface VPlaceloadAvatarProps {
size?: VPlaceloadAvatarSize
rounded?: VPlaceloadAvatarRounded
centered?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<VPlaceloadAvatarProps>(), {
size: undefined,
rounded: 'full',
})
</script>
<template>
<div
class="placeload-avatar"
:class="[
!props.disabled && `loads`,
props.size && `is-${props.size}`,
props.centered && `is-centered`,
props.rounded && `is-rounded-${props.rounded}`,
]"
/>
</template>
<style lang="scss">
.placeload-avatar {
display: block;
width: 40px;
min-width: 40px;
height: 40px;
&.is-small {
width: 32px;
min-width: 32px;
height: 32px;
}
&.is-medium {
width: 50px;
min-width: 50px;
height: 50px;
}
&.is-large {
width: 68px;
min-width: 68px;
height: 68px;
}
&.is-big {
width: 80px;
min-width: 80px;
height: 80px;
}
&.is-xl {
width: 100px;
min-width: 100px;
height: 100px;
}
&.is-centered {
margin-inline-start: auto;
margin-inline-end: auto;
}
&.is-rounded-xs {
border-radius: 0.25rem;
}
&.is-rounded-sm {
border-radius: 0.5rem;
}
&.is-rounded-md {
border-radius: 0.75rem;
}
&.is-rounded-lg {
border-radius: 1rem;
}
&.is-rounded-xl {
border-radius: 1.25rem;
}
&.is-rounded-full {
border-radius: 50%;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
export interface VPlaceloadTextProps {
width?: string
lastLineWidth?: string
lines?: number
disabled?: boolean
centered?: boolean
}
const props = withDefaults(defineProps<VPlaceloadTextProps>(), {
width: '100%',
lastLineWidth: '100%',
lines: 2,
})
if (props.width.match(CssUnitRe) === null) {
console.warn(
`VPlaceloadText: invalid "${props.width}" width. Should be a valid css unit value.`,
)
}
if (props.lastLineWidth.match(CssUnitRe) === null) {
console.warn(
`VPlaceloadText: invalid "${props.lastLineWidth}" lastLineWidth. Should be a valid css unit value.`,
)
}
</script>
<template>
<div class="content-shape-group">
<VPlaceload
v-for="line of props.lines - 1"
:key="line"
:width="props.width"
:centered="props.centered"
/>
<VPlaceload
:width="props.lastLineWidth"
:centered="props.centered"
/>
</div>
</template>
<style lang="scss">
.content-shape-group {
width: 100%;
max-width: 100%;
.content-shape {
&:not(:last-child) {
margin-bottom: 0.5rem;
}
}
&.is-grow-1 {
flex-grow: 1;
}
&.is-grow-2 {
flex-grow: 2;
}
&.is-grow-3 {
flex-grow: 3;
}
&.is-grow-4 {
flex-grow: 4;
}
&.mw-30 {
max-width: 30%;
}
&.mw-60 {
max-width: 60%;
}
&.mw-80 {
max-width: 80%;
}
&.is-centered {
margin-inline-start: auto;
margin-inline-end: auto;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div class="placeload-wrap is-flex">
<slot />
</div>
</template>
<style lang="scss">
.placeload-wrap {
&.is-flex {
display: flex;
align-items: center;
}
}
@media only screen and (width <= 767px) {
.placeload-wrap {
&.is-flex {
flex-direction: column;
padding: 1rem 0;
.content-shape-group {
margin-top: 0.5rem;
max-width: 70%;
margin-inline-start: auto;
margin-inline-end: auto;
.content-shape {
margin-inline-start: auto;
margin-inline-end: auto;
}
}
> .content-shape {
margin-top: 0.5rem;
max-width: 70%;
margin-inline-start: auto;
margin-inline-end: auto;
}
}
}
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
export type VProgressSize = 'tiny' | 'smaller' | 'small'
export type VProgressColor = 'primary' | 'success' | 'info' | 'warning' | 'danger'
export interface VProgressProps {
value?: number
max?: number
size?: VProgressSize
color?: VProgressColor
}
const props = withDefaults(defineProps<VProgressProps>(), {
value: undefined,
max: 100,
size: undefined,
color: 'primary',
})
</script>
<template>
<progress
class="progress"
:class="[props.size && `is-${props.size}`, props.color && `is-${props.color}`]"
:value="props.value"
:max="props.max"
>
{{ props.value ? `${(props.value / props.max) * 100}%` : '' }}
</progress>
</template>
<style lang="scss">
.progress {
margin-bottom: 0;
&::-webkit-progress-value {
border-radius: 50px;
}
&::-moz-progress-bar {
border-radius: 50px;
}
&::-ms-fill {
border-radius: 50px;
}
&.is-smaller {
height: 0.5rem !important;
}
&.is-tiny {
height: 0.35rem !important;
}
}
.is-dark {
.progress {
background-color: var(--dark-sidebar);
&::-webkit-progress-bar {
background-color: var(--dark-sidebar);
}
&.is-primary {
&::-webkit-progress-value {
background: var(--primary);
}
&::-moz-progress-bar {
background: var(--primary);
}
&::-ms-fill {
background: var(--primary);
}
}
&:indeterminate {
&.is-primary {
background-color: var(--primary);
background-image: linear-gradient(
to right,
var(--dark-sidebar) 30%,
var(--primary) 30%
);
&::-webkit-progress-bar {
background: transparent;
}
&::-moz-progress-bar {
background: transparent;
}
}
&.is-success {
background-color: var(--success);
background-image: linear-gradient(
to right,
var(--dark-sidebar) 30%,
var(--success) 30%
);
&::-webkit-progress-bar {
background: transparent;
}
&::-moz-progress-bar {
background: transparent;
}
}
&.is-info {
background-color: var(--info);
background-image: linear-gradient(
to right,
var(--dark-sidebar) 30%,
var(--info) 30%
);
&::-webkit-progress-bar {
background: transparent;
}
&::-moz-progress-bar {
background: transparent;
}
}
&.is-warning {
background-color: var(--warning);
background-image: linear-gradient(
to right,
var(--dark-sidebar) 30%,
var(--warning) 30%
);
&::-webkit-progress-bar {
background: transparent;
}
&::-moz-progress-bar {
background: transparent;
}
}
&.is-danger {
background-color: var(--danger);
background-image: linear-gradient(
to right,
var(--dark-sidebar) 30%,
var(--danger) 30%
);
&::-webkit-progress-bar {
background: transparent;
}
&::-moz-progress-bar {
background: transparent;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,339 @@
<script setup lang="ts">
export type VRadioColor = 'primary' | 'info' | 'success' | 'warning' | 'danger'
export interface VRadioProps {
id?: string
value: any
name?: string
label?: string
color?: VRadioColor
square?: boolean
solid?: boolean
paddingless?: boolean
}
const modelValue = defineModel<any>({
default: undefined,
})
const props = withDefaults(defineProps<VRadioProps>(), {
id: undefined,
label: undefined,
color: undefined,
name: undefined,
paddingless: false,
})
const { field, id } = useVFieldContext({
id: props.id,
inherit: false,
})
const internal = computed({
get() {
if (field?.value) {
return field.value.value
}
else {
return modelValue.value
}
},
set(value: any) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
</script>
<template>
<VLabel
raw
class="radio"
:class="[
props.solid ? 'is-solid' : 'is-outlined',
props.square && 'is-square',
props.color && `is-${props.color}`,
props.paddingless && 'is-paddingless',
]"
>
<input
:id="id"
v-model="internal"
type="radio"
:value="props.value"
:name="props.name"
v-bind="$attrs"
>
<span />
<slot v-bind="{ field, id }">
{{ props.label }}
</slot>
</VLabel>
</template>
<style lang="scss">
%controller {
position: relative;
font-family: var(--font);
cursor: pointer;
padding: 1em;
&::selection {
background: transparent;
}
input + span {
position: relative;
top: -1px;
background: var(--white);
content: '';
display: inline-block;
margin-inline-end: 0.5rem;
padding: 0;
vertical-align: middle;
width: 1.4em;
height: 1.4em;
border: 1px solid color-mix(in oklab, var(--fade-grey), black 8%);
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
&::after {
content: '';
display: block;
transform: scale(0);
transition: transform 0.2s;
}
}
@media screen and (width >= 768px) {
&:hover input + span {
box-shadow: 0 2px 4px rgba(#000, 0.15);
}
}
input:active + span {
box-shadow: 0 4px 8px rgba(#000, 0.15);
}
input:checked + span::after {
transform: translate(calc(var(--transform-direction) * -50%), -50%) scaleY(1)
scaleX(calc(var(--transform-direction) * 1)) !important;
}
input {
position: absolute;
cursor: pointer;
opacity: 0;
transition: all 0.3s; // transition-all test
}
}
.radio {
@extend %controller;
color: var(--light-text);
+ .radio {
margin-inline-start: 0 !important;
}
&:hover {
color: var(--light-text);
}
&.is-paddingless {
padding: 0 !important;
}
&.is-square {
input + span {
border-radius: var(--radius);
}
}
&.is-solid {
input + span {
background: color-mix(in oklab, var(--fade-grey), white 3%);
}
&.is-primary {
input + span {
border-color: var(--primary);
background: var(--primary);
&::after {
color: var(--white);
}
}
}
&.is-success {
input + span {
border-color: var(--success);
background: var(--success);
&::after {
color: var(--white);
}
}
}
&.is-info {
input + span {
border-color: var(--info);
background: var(--info);
&::after {
color: var(--white);
}
}
}
&.is-warning {
input + span {
border-color: var(--warning);
background: var(--warning);
&::after {
color: var(--white);
}
}
}
&.is-danger {
input + span {
border-color: var(--danger);
background: var(--danger);
&::after {
color: var(--white);
}
}
}
}
&.is-outlined {
&.is-primary {
input:checked + span {
border-color: var(--primary);
}
input + span {
&::after {
color: var(--primary);
}
}
}
&.is-success {
input:checked + span {
border-color: var(--success);
}
input + span {
&::after {
color: var(--success);
}
}
}
&.is-info {
input:checked + span {
border-color: var(--info);
}
input + span {
&::after {
color: var(--info);
}
}
}
&.is-warning {
input:checked + span {
border-color: var(--warning);
}
input + span {
&::after {
color: var(--warning);
}
}
}
&.is-danger {
input:checked + span {
border-color: var(--danger);
}
input + span {
&::after {
color: var(--danger);
}
}
}
}
input + span {
border-radius: 100%;
&::after {
background-size: contain;
position: absolute;
top: 49%;
inset-inline-start: 50%;
transform: translate(-50%, -50%) scale(0);
content: '\f111';
font-family: 'Font Awesome\ 5 Free';
font-weight: 900;
font-size: 0.6rem;
}
}
input:focus + span,
input:active + span {
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-color: var(--accessibility-focus-outline-color);
outline-style: var(--accessibility-focus-outline-style);
}
}
.is-dark {
%controller {
input + span {
background-color: color-mix(in oklab, var(--dark-sidebar), white 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 4%);
&::after {
color: var(--dark-dark-text);
}
}
input + span {
border-color: color-mix(in oklab, var(--dark-sidebar), white 16%);
}
}
.radio {
&.is-solid.is-primary {
input + span {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
}
}
&.is-outlined.is-primary {
input:checked + span {
border-color: var(--primary) !important;
&::after {
color: var(--primary) !important;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
const modelValue = defineModel<number | undefined>({
default: undefined,
})
const props = withDefaults(
defineProps<{
id?: string
max?: number
label?: string
size?: 'small' | 'base' | 'medium' | 'large' | 'xlarge'
readonly?: boolean
disabled?: boolean
}>(),
{
id: undefined,
max: 5,
size: 'base',
label: undefined,
readonly: undefined,
},
)
const { field, id } = useVFieldContext({
help: 'VRangeRating',
id: props.id,
})
const hasValue = computed(
() => field?.value !== undefined || modelValue.value !== undefined,
)
const active = computed(() => !props.readonly && hasValue.value)
const internal = computed({
get() {
if (field?.value) {
return field.value.value ?? 0
}
else {
return modelValue.value ?? 0
}
},
set(value: any) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
const sizeStyle = computed(() => {
switch (props.size) {
case 'small':
return 'is-size-6'
case 'base':
return 'is-size-5'
case 'medium':
return 'is-size-4'
case 'large':
return 'is-size-3'
case 'xlarge':
return 'is-size-2'
}
})
const radiogroup = ref()
function focus() {
if (props.readonly) return
if (props.disabled) return
if (radiogroup.value) {
radiogroup.value.focus()
}
}
const wrapper = ref()
const { focused } = useFocusWithin(wrapper)
onKeyStroke('ArrowLeft', (e) => {
if (!focused.value) return
if (props.disabled) return
e.preventDefault()
if (internal.value > 0) {
internal.value = internal.value - 1
}
})
onKeyStroke('ArrowRight', (e) => {
if (!focused.value) return
if (props.disabled) return
e.preventDefault()
if (internal.value < props.max) {
internal.value = internal.value + 1
}
})
const highlighted = ref<number>()
function highlightIndex(index: number) {
if (props.readonly) return
highlighted.value = index + 1
}
function unhighlight() {
highlighted.value = undefined
}
function selectIndex(index: number) {
if (props.readonly) return
if (props.disabled) return
internal.value = index + 1
}
function isStarSelected(index: number) {
if (!hasValue.value) return 0
if (highlighted.value !== undefined) {
return highlighted.value - index > 0
}
return internal.value - index > 0
}
</script>
<template>
<div
ref="wrapper"
class="rating-wrap"
>
<div
v-if="props.label || 'label' in $slots"
class="rating-label"
>
<slot name="label">
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<label
:id="`${id}-label`"
:for="id"
@click="focus"
@keydown.enter="focus"
>
{{ props.label }}
</label>
</slot>
</div>
<div
:id="id"
ref="radiogroup"
class="rating"
:class="{
'is-active': active,
'is-highlighted': highlighted,
[sizeStyle]: true,
}"
:aria-labelledby="
props.label || ('label' in $slots && active) ? `${id}-label` : undefined
"
:tabindex="active ? 0 : undefined"
:role="active ? 'radiogroup' : undefined"
>
<span
v-for="(star, index) in props.max"
:key="index"
:role="active ? 'radio' : undefined"
:aria-label="String(index + 1)"
:aria-checked="active ? internal - index > 0 : undefined"
aria-hidden="true"
class="rating-star"
:class="{
'is-rating-star-selected': isStarSelected(index),
}"
@pointerenter.passive="() => highlightIndex(index)"
@pointerleave.passive="() => unhighlight()"
@click.passive="() => selectIndex(index)"
>
<slot
v-bind="{
value: index + 1,
isSelected: isStarSelected(index),
isHighlighted: index + 1 === highlighted,
}"
>
<VIcon icon="ph:star-fill" />
</slot>
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.rating-wrap {
text-align: inset-inline-end;
.rating-label {
color: var(--light-text);
}
.rating {
display: inline-flex;
.rating-star {
color: color-mix(in oklab, var(--widget-grey), black 8%);
&.is-rating-star-selected {
color: var(--yellow) !important;
}
}
&.is-active {
[role='radio'] {
cursor: pointer;
}
}
}
}
.is-dark .rating-wrap {
.rating {
.rating-star {
color: color-mix(in oklab, var(--dark-sidebar), white 22%);
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
export interface VSelectProps {
raw?: boolean
multiple?: boolean
}
defineOptions({
inheritAttrs: false,
})
const modelValue = defineModel<any>({
default: '',
})
const props = defineProps<VSelectProps>()
const attrs = useAttrs()
const { field, id } = useVFieldContext({
create: false,
help: 'VSelect',
})
const internal = computed({
get() {
if (field?.value) {
return field.value.value
}
else {
return modelValue.value
}
},
set(value: any) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
const classes = computed(() => {
if (props.raw) return []
return ['select', props.multiple && 'is-multiple']
})
</script>
<template>
<div :class="classes">
<select
:id="id"
v-bind="attrs"
v-model="internal"
:name="id"
:multiple="props.multiple"
@change="field?.handleChange"
@blur="field?.handleBlur"
>
<slot v-bind="{ selected: internal, id }" />
</select>
</div>
</template>

View File

@@ -0,0 +1,397 @@
<script setup lang="ts">
export type VSnackColor = 'primary' | 'success' | 'info' | 'warning' | 'danger'
export type VSnackSize = 'small'
export interface VSnackProps {
title: string
icon?: string
image?: string
placeholder?: string
color?: VSnackColor
size?: VSnackSize
solid?: boolean
white?: boolean
}
const props = withDefaults(defineProps<VSnackProps>(), {
icon: undefined,
image: undefined,
color: undefined,
size: undefined,
placeholder: 'https://via.placeholder.com/50x50',
})
function placeholderHandler(event: Event) {
const target = event.target as HTMLImageElement
target.src = props.placeholder
}
</script>
<template>
<div
class="snack"
:class="[props.white && 'is-white', props.size && `is-${props.size}`]"
>
<div
v-if="props.icon"
class="snack-media is-icon"
:class="[props.color && `is-${props.color}`, props.solid && `is-solid`]"
>
<VIcon :icon="props.icon" class="snack-icon" />
</div>
<div
v-else-if="props.image"
class="snack-media"
>
<img
class="avatar"
:src="props.image"
alt=""
@error.once="placeholderHandler"
>
</div>
<span class="snack-text">
<slot name="title">{{ props.title }}</slot>
</span>
<span class="snack-action">
<slot />
</span>
</div>
</template>
<style lang="scss">
.snacks {
display: flex;
flex-wrap: wrap;
.snack {
margin: 0 8px 16px;
}
}
.snack {
display: inline-block;
background: color-mix(in oklab, var(--fade-grey), white 2%);
height: 38px;
width: auto;
border-radius: 500px;
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
transition: all 0.3s; // transition-all test
&:hover {
box-shadow: var(--light-box-shadow);
}
&.is-white {
background: var(--white);
}
&.is-small {
height: 30px;
.snack-media {
height: 32px;
width: 32px;
margin-inline-end: 4px;
&.is-icon {
height: 30px;
width: 30px;
.iconify {
height: 15px;
width: 15px;
font-size: 15px;
}
.fas,
.far,
.fad,
.fal,
.fab {
font-size: 13px;
}
.lnil,
.lnir {
font-size: 16px;
}
}
img {
height: 30px;
width: 30px;
}
}
.snack-text {
font-size: 0.9rem;
top: -12px;
}
.snack-action {
top: -9px;
margin: 0 10px 0 6px;
}
}
.snack-media {
position: relative;
top: -1px;
height: 40px;
width: 40px;
display: inline-block;
margin-inline-end: 6px;
&.is-icon {
position: relative;
inset-inline-start: -1px;
height: 38px;
width: 38px;
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
border-radius: var(--radius-rounded);
&.is-solid {
.fas,
.far,
.fad,
.fal,
.fab,
.lnil,
.lnir {
color: var(--white) !important;
}
}
&.is-primary {
border-color: var(--primary);
&.is-solid {
background: var(--primary);
.iconify {
color: var(--white);
}
}
.iconify {
color: var(--primary);
}
.fas,
.far,
.fad,
.fal,
.fab,
.lnil,
.lnir {
color: var(--primary);
}
}
&.is-success {
border-color: var(--success);
&.is-solid {
background: var(--success);
.iconify {
color: var(--white);
}
}
.iconify {
color: var(--success);
}
.fas,
.far,
.fad,
.fal,
.fab,
.lnil,
.lnir {
color: var(--success);
}
}
&.is-info {
border-color: var(--info);
&.is-solid {
background: var(--info);
.iconify {
color: var(--white);
}
}
.iconify {
color: var(--info);
}
.fas,
.far,
.fad,
.fal,
.fab,
.lnil,
.lnir {
color: var(--info);
}
}
&.is-warning {
border-color: var(--warning);
&.is-solid {
background: var(--warning);
.iconify {
color: var(--white);
}
}
.iconify {
color: var(--warning);
}
.fas,
.far,
.fad,
.fal,
.fab,
.lnil,
.lnir {
color: var(--warning);
}
}
&.is-danger {
border-color: var(--danger);
&.is-solid {
background: var(--danger);
.iconify {
color: var(--white);
}
}
.iconify {
color: var(--danger);
}
.fas,
.far,
.fad,
.fal,
.fab,
.lnil,
.lnir {
color: var(--danger);
}
}
.snack-icon {
position: absolute;
top: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
}
.iconify {
height: 18px;
width: 18px;
font-size: 18px;
color: var(--light-text);
}
.fas,
.far,
.fad,
.fal,
.fab {
font-size: 15px;
color: var(--light-text);
}
.lnil,
.lnir {
font-size: 18px;
color: var(--light-text);
}
}
img {
display: inline-block;
height: 38px;
width: 38px;
border-radius: var(--radius-rounded);
}
}
.snack-text {
display: inline-block;
position: relative;
top: -15px;
color: var(--dark-text);
}
.snack-action {
position: relative;
top: -14px;
display: inline-block;
margin: 0 16px 0 10px;
cursor: pointer;
.iconify {
height: 14px;
width: 14px;
color: var(--light-text);
}
}
}
.is-dark {
.snack {
background: color-mix(in oklab, var(--dark-sidebar), white 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 4%);
.snack-media {
&.is-icon {
&:not(.is-solid) {
background: color-mix(in oklab, var(--dark-sidebar), white 4%);
}
&.is-primary:not(.is-solid) {
border-color: var(--primary);
.iconify {
color: var(--primary);
}
.fas,
.far,
.fad,
.fab,
.fal,
.lnil,
.lnir {
color: var(--primary);
}
}
&.is-primary.is-solid {
background: var(--primary);
border-color: var(--primary);
}
}
}
.snack-text {
color: var(--dark-dark-text);
}
}
}
</style>

View File

@@ -0,0 +1,541 @@
<script setup lang="ts">
export type VSwitchBlockColor = 'primary' | 'info' | 'success' | 'warning' | 'danger'
export interface VSwitchBlockProps {
label?: string
color?: VSwitchBlockColor
thin?: boolean
}
defineOptions({
inheritAttrs: false,
})
const modelValue = defineModel<boolean>({
default: false,
})
const props = withDefaults(defineProps<VSwitchBlockProps>(), {
label: undefined,
color: undefined,
})
const { field, id } = useVFieldContext({
create: false,
help: 'VSwitchBlock',
})
const internal = computed({
get() {
if (field?.value) {
return field.value.value
}
else {
return modelValue.value
}
},
set(value: any) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
</script>
<template>
<div
:class="[
(props.label || 'default' in $slots) && 'switch-block',
props.thin && (props.label || 'default' in $slots) && 'thin-switch-block',
]"
>
<template v-if="props.thin">
<VLabel
raw
class="thin-switch"
tabindex="0"
:class="[props.color && `is-${props.color}`]"
>
<input
:id="id"
v-model="internal"
:true-value="true"
:false-value="false"
class="input"
type="checkbox"
v-bind="$attrs"
>
<div class="slider" />
</VLabel>
</template>
<template v-else>
<VLabel
raw
class="form-switch"
:class="[props.color && `is-${props.color}`]"
>
<input
:id="id"
v-model="internal"
:true-value="true"
:false-value="false"
type="checkbox"
class="is-switch"
v-bind="$attrs"
>
<i aria-hidden="true" />
</VLabel>
</template>
<div
v-if="props.label || 'default' in $slots"
class="text"
>
<VLabel raw>
<span>
<slot>
{{ props.label }}
</slot>
</span>
</VLabel>
</div>
</div>
</template>
<style lang="scss">
.form-switch {
position: relative;
display: inline-block;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:focus-within {
border-radius: 50px;
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
&.is-primary {
input {
&:checked + i {
background-color: var(--primary);
}
}
}
&.is-success {
input {
&:checked + i {
background-color: var(--success);
}
}
}
&.is-info {
input {
&:checked + i {
background-color: var(--info);
}
}
}
&.is-warning {
input {
&:checked + i {
background-color: var(--warning);
}
}
}
&.is-danger {
input {
&:checked + i {
background-color: var(--danger);
}
}
}
i {
position: relative;
display: inline-block;
width: 46px;
height: 26px;
background-color: #e6e6e6;
border-radius: 23px;
vertical-align: text-bottom;
transition: all 0.3s linear;
&::before {
content: '';
position: absolute;
inset-inline-start: 0;
width: 42px;
height: 22px;
background-color: var(--white);
border-radius: 11px;
transform: translate3d(calc(var(--transform-direction) * 2px), 2px, 0)
scale3d(1, 1, 1);
transition: all 0.25s linear;
}
&::after {
content: '';
position: absolute;
inset-inline-start: 0;
width: 22px;
height: 22px;
background-color: var(--white);
border-radius: 11px;
box-shadow: 0 2px 2px rgb(0 0 0 / 24%);
transform: translate3d(calc(var(--transform-direction) * 2px), 2px, 0);
transition: all 0.2s ease-in-out;
}
}
&:active {
i::after {
width: 28px;
transform: translate3d(calc(var(--transform-direction) * 2px), 2px, 0);
}
input {
&:checked + i::after {
transform: translate3d(calc(var(--transform-direction) * 16px), 2px, 0);
}
}
}
input {
position: absolute;
opacity: 0;
pointer-events: none;
&:checked + i {
background-color: var(--light-text);
&::before {
transform: translate3d(calc(var(--transform-direction) * 18px), 2px, 0)
scale3d(0, 0, 0);
}
&::after {
transform: translate3d(calc(var(--transform-direction) * 22px), 2px, 0);
}
}
}
small {
color: var(--muted-grey);
position: relative;
top: -4px;
}
}
.switch-block {
padding: 10px 0;
display: flex;
align-items: center;
.text {
margin-inline-start: 6px;
span {
display: block;
position: relative;
top: -2px;
color: var(--light-text);
}
}
}
.is-dark {
.form-switch {
&.is-primary {
input {
&:checked + i {
background-color: var(--primary) !important;
&::after {
background: var(--white) !important;
}
}
}
}
&.is-success {
input {
&:checked + i {
background-color: var(--success) !important;
&::after {
background: var(--white) !important;
}
}
}
}
&.is-info {
input {
&:checked + i {
background-color: var(--info) !important;
&::after {
background: var(--white) !important;
}
}
}
}
&.is-warning {
input {
&:checked + i {
background-color: var(--warning) !important;
&::after {
background: var(--white) !important;
}
}
}
}
&.is-danger {
input {
&:checked + i {
background-color: var(--danger) !important;
&::after {
background: var(--white) !important;
}
}
}
}
i {
background: var(--dark-sidebar) !important;
&::before {
background: var(--dark-sidebar) !important;
}
&::after {
background: color-mix(in oklab, var(--dark-sidebar), white 22%) !important;
}
}
input {
&:checked + i {
&::after {
background: color-mix(in oklab, var(--dark-sidebar), white 55%) !important;
}
}
}
}
}
.thin-switch {
display: block;
margin-inline-start: 8px;
&:focus-visible .slider::after {
border-radius: 50px;
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
&:focus-visible {
outline: none !important;
}
&.is-primary {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--primary), white 40%);
&::after {
background: var(--primary);
border-color: var(--primary);
}
}
}
&.is-success {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--success), white 40%);
&::after {
background: var(--success);
border-color: var(--success);
}
}
}
&.is-info {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--info), white 40%);
&::after {
background: var(--info);
border-color: var(--info);
}
}
}
&.is-warning {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--warning), white 40%);
&::after {
background: var(--warning);
border-color: var(--warning);
}
}
}
&.is-danger {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--danger), white 40%);
&::after {
background: var(--danger);
border-color: var(--danger);
}
}
}
.slider {
position: relative;
display: inline-block;
height: 8px;
width: 32px;
border-radius: 8px;
cursor: pointer;
background: #c5c5c5;
transition: all 0.3s; // transition-all test
&::after {
background: var(--light-grey);
position: absolute;
inset-inline-start: -8px;
top: -8.5px;
display: block;
width: 24px;
height: 24px;
border-radius: var(--radius-rounded);
border: 1px solid transparent;
box-shadow: 0 2px 2px rgba(#000, 0.2);
content: '';
transition: all 0.3s; // transition-all test
}
}
label {
margin-inline-end: 7px;
}
.input {
display: none;
~ .label {
margin-inline-start: 8px;
}
&:checked ~ .slider {
&::after {
inset-inline-start: 32px - 24px + 8px;
}
}
}
.input:checked ~ .slider {
&::after {
background: var(--white);
border: 1px solid var(--fade-grey);
}
}
}
.thin-switch-block {
padding: 10px 0;
display: flex;
align-items: center;
.text {
margin-inline-start: 16px;
span {
display: block;
position: relative;
color: var(--light-text);
}
}
}
.is-dark {
.thin-switch {
&.is-primary {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--primary), white 20%);
&::after {
background: var(--primary);
border-color: var(--primary);
}
}
}
&.is-success {
.input:checked ~ .slider {
&::after {
background: var(--success);
border-color: var(--success);
}
}
}
&.is-info {
.input:checked ~ .slider {
&::after {
background: var(--info);
border-color: var(--info);
}
}
}
&.is-warning {
.input:checked ~ .slider {
&::after {
background: var(--warning);
border-color: var(--warning);
}
}
}
&.is-danger {
.input:checked ~ .slider {
&::after {
background: var(--danger);
border-color: var(--danger);
}
}
}
.slider {
background: var(--dark-sidebar);
&::after {
background: color-mix(in oklab, var(--dark-sidebar), white 22%);
}
}
.input:checked ~ .slider {
&::after {
background: color-mix(in oklab, var(--dark-sidebar), white 55%);
border: color-mix(in oklab, var(--dark-sidebar), white 55%);
}
}
}
}
</style>

View File

@@ -0,0 +1,552 @@
<script setup lang="ts">
export type VSwitchSegmentColor = 'primary' | 'info' | 'success' | 'warning' | 'danger'
export interface VSwitchSegmentProps {
labelTrue?: string
labelFalse?: string
color?: VSwitchSegmentColor
}
const modelValue = defineModel<boolean>({
default: false,
})
const props = withDefaults(defineProps<VSwitchSegmentProps>(), {
labelTrue: undefined,
labelFalse: undefined,
color: undefined,
})
const { field, id } = useVFieldContext({
create: false,
help: 'VSwitchSegment',
})
const internal = computed({
get() {
if (field?.value) {
return field.value.value
}
else {
return modelValue.value
}
},
set(value: any) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
</script>
<template>
<div class="switch-segment">
<VLabel
v-if="props.labelFalse || 'label-false' in $slots"
raw
class="is-label"
>
<slot name="label-false">
{{ props.labelFalse }}
</slot>
</VLabel>
<VLabel
raw
class="form-switch"
:class="[props.color && `is-${props.color}`]"
>
<input
:id="id"
v-model="internal"
:true-value="true"
:false-value="false"
v-bind="$attrs"
type="checkbox"
class="is-switch"
>
<i aria-hidden="true" />
</VLabel>
<VLabel
v-if="props.labelTrue || 'label-true' in $slots"
raw
class="is-label"
>
<slot name="label-true">
{{ props.labelTrue }}
</slot>
</VLabel>
</div>
</template>
<style lang="scss">
.form-switch {
position: relative;
display: inline-block;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:focus-within {
border-radius: 50px;
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
&.is-primary {
input {
&:checked + i {
background-color: var(--primary);
}
}
}
&.is-success {
input {
&:checked + i {
background-color: var(--success);
}
}
}
&.is-info {
input {
&:checked + i {
background-color: var(--info);
}
}
}
&.is-warning {
input {
&:checked + i {
background-color: var(--warning);
}
}
}
&.is-danger {
input {
&:checked + i {
background-color: var(--danger);
}
}
}
i {
position: relative;
display: inline-block;
width: 46px;
height: 26px;
background-color: #e6e6e6;
border-radius: 23px;
vertical-align: text-bottom;
transition: all 0.3s linear;
&::before {
content: '';
position: absolute;
inset-inline-start: 0;
width: 42px;
height: 22px;
background-color: var(--white);
border-radius: 11px;
transform: translate3d(calc(var(--transform-direction) * 2px), 2px, 0)
scale3d(calc(var(--transform-direction) * 1), 1, 1);
transition: all 0.25s linear;
}
&::after {
content: '';
position: absolute;
inset-inline-start: 0;
width: 22px;
height: 22px;
background-color: var(--white);
border-radius: 11px;
box-shadow: 0 2px 2px rgb(0 0 0 / 24%);
transform: translate3d(calc(var(--transform-direction) * 2px), 2px, 0);
transition: all 0.2s ease-in-out;
}
}
&:active {
i::after {
width: 28px;
transform: translate3d(calc(var(--transform-direction) * 2px), 2px, 0);
}
input {
&:checked + i::after {
transform: translate3d(calc(var(--transform-direction) * 16px), 2px, 0);
}
}
}
input {
position: absolute;
opacity: 0;
pointer-events: none;
&:checked + i {
background-color: var(--light-text);
&::before {
transform: translate3d(calc(var(--transform-direction) * 18px), 2px, 0)
scale3d(0, 0, 0);
}
&::after {
transform: translate3d(calc(var(--transform-direction) * 22px), 2px, 0);
}
}
}
small {
color: var(--muted-grey);
position: relative;
top: -4px;
}
}
.switch-block {
padding: 10px 0;
display: flex;
align-items: center;
.text {
margin-inline-start: 6px;
span {
display: block;
position: relative;
top: -2px;
color: var(--light-text);
}
}
}
.is-dark {
.form-switch {
&.is-primary {
input {
&:checked + i {
background-color: var(--primary) !important;
&::after {
background: var(--white) !important;
}
}
}
}
&.is-success {
input {
&:checked + i {
background-color: var(--success) !important;
&::after {
background: var(--white) !important;
}
}
}
}
&.is-info {
input {
&:checked + i {
background-color: var(--info) !important;
&::after {
background: var(--white) !important;
}
}
}
}
&.is-warning {
input {
&:checked + i {
background-color: var(--warning) !important;
&::after {
background: var(--white) !important;
}
}
}
}
&.is-danger {
input {
&:checked + i {
background-color: var(--danger) !important;
&::after {
background: var(--white) !important;
}
}
}
}
i {
background: var(--dark-sidebar) !important;
&::before {
background: var(--dark-sidebar) !important;
}
&::after {
background: color-mix(in oklab, var(--dark-sidebar), white 22%) !important;
}
}
input {
&:checked + i {
&::after {
background: color-mix(in oklab, var(--dark-sidebar), white 55%) !important;
}
}
}
}
}
.thin-switch {
display: block;
margin-inline-start: 8px;
&:focus-visible .slider::after {
border-radius: 50px;
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
&:focus-visible {
outline: none !important;
}
&.is-primary {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--primary), white 20%);
&::after {
background: var(--primary);
border-color: var(--primary);
}
}
}
&.is-success {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--success), white 20%);
&::after {
background: var(--success);
border-color: var(--success);
}
}
}
&.is-info {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--info), white 20%);
&::after {
background: var(--info);
border-color: var(--info);
}
}
}
&.is-warning {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--warning), white 20%);
&::after {
background: var(--warning);
border-color: var(--warning);
}
}
}
&.is-danger {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--danger), white 20%);
&::after {
background: var(--danger);
border-color: var(--danger);
}
}
}
.slider {
position: relative;
display: inline-block;
height: 8px;
width: 32px;
border-radius: 8px;
cursor: pointer;
background: #c5c5c5;
transition: all 0.3s; // transition-all test
&::after {
background: var(--light-grey);
position: absolute;
inset-inline-start: -8px;
top: calc((7px - 24px) / 2);
display: block;
width: 24px;
height: 24px;
border-radius: var(--radius-rounded);
border: 1px solid transparent;
box-shadow: 0 2px 2px rgba(#000, 0.2);
content: '';
transition: all 0.3s; // transition-all test
}
}
label {
margin-inline-end: 7px;
}
.input {
display: none;
~ .label {
margin-inline-start: 8px;
}
&:checked ~ .slider {
&::after {
inset-inline-start: 32px - 24px + 8px;
}
}
}
.input:checked ~ .slider {
&::after {
background: var(--white);
border: 1px solid var(--fade-grey);
}
}
}
.thin-switch-block {
padding: 10px 0;
display: flex;
align-items: center;
.text {
margin-inline-start: 16px;
span {
display: block;
position: relative;
color: var(--light-text);
}
}
}
.is-dark {
.thin-switch {
&.is-primary {
.input:checked ~ .slider {
background: color-mix(in oklab, var(--primary), white 20%);
&::after {
background: var(--primary);
border-color: var(--primary);
}
}
}
&.is-success {
.input:checked ~ .slider {
&::after {
background: var(--success);
border-color: var(--success);
}
}
}
&.is-info {
.input:checked ~ .slider {
&::after {
background: var(--info);
border-color: var(--info);
}
}
}
&.is-warning {
.input:checked ~ .slider {
&::after {
background: var(--warning);
border-color: var(--warning);
}
}
}
&.is-danger {
.input:checked ~ .slider {
&::after {
background: var(--danger);
border-color: var(--danger);
}
}
}
.slider {
background: var(--dark-sidebar);
&::after {
background: color-mix(in oklab, var(--dark-sidebar), white 22%);
}
}
.input:checked ~ .slider {
&::after {
background: color-mix(in oklab, var(--dark-sidebar), white 55%);
border: color-mix(in oklab, var(--dark-sidebar), white 55%);
}
}
}
}
.switch-segment {
display: flex;
align-items: center;
justify-content: flex-end;
.is-label {
font-family: var(--font);
font-size: 0.9rem;
&:first-child {
color: var(--dark-text);
}
&:nth-child(3) {
color: var(--light-text);
}
}
.form-switch {
transform: scale(0.7);
margin: 0 0.25rem;
}
}
.is-dark {
.switch-segment {
.is-label {
&:first-child {
color: var(--dark-dark-text);
}
}
}
}
</style>

View File

@@ -0,0 +1,701 @@
<script setup lang="ts">
import type { RouteLocationAsString } from 'unplugin-vue-router'
export type VTabsType = 'boxed' | 'toggle' | 'rounded'
export type VTabsAlign = 'centered' | 'right'
export interface VTabsItem {
label: string
value: string
icon?: string
to?: RouteLocationAsString
}
export interface VTabsProps {
tabs: VTabsItem[]
selected?: string
type?: VTabsType
align?: VTabsAlign
slider?: boolean
slow?: boolean
disabled?: boolean
}
const emit = defineEmits<{
(e: 'update:selected', value: any): void
}>()
const props = withDefaults(defineProps<VTabsProps>(), {
selected: undefined,
type: undefined,
align: undefined,
})
const activeValue = ref(props.selected || props.tabs?.[0]?.value)
const sliderClass = computed(() => {
if (!props.slider) {
return ''
}
if (props.type === 'rounded') {
if (props.tabs.length === 3) {
return 'is-triple-slider'
}
if (props.tabs.length === 2) {
return 'is-slider'
}
return ''
}
if (!props.type) {
if (props.tabs.length === 3) {
return 'is-squared is-triple-slider'
}
if (props.tabs.length === 2) {
return 'is-squared is-slider'
}
}
return ''
})
function toggle(value: string) {
if (props.disabled) return
activeValue.value = value
}
watch(
() => props.selected,
(value) => {
activeValue.value = value ?? ''
},
)
watch(activeValue, (value) => {
emit('update:selected', value)
})
</script>
<template>
<div
class="tabs-wrapper"
:class="[sliderClass]"
>
<div class="tabs-inner">
<div
class="tabs"
:class="[
props.align === 'centered' && 'is-centered',
props.align === 'right' && 'is-right',
props.type === 'rounded' && !props.slider && 'is-toggle is-toggle-rounded',
props.type === 'toggle' && 'is-toggle',
props.type === 'boxed' && 'is-boxed',
props.disabled && 'is-disabled',
]"
>
<ul>
<li
v-for="(tab, key) in tabs"
:key="key"
:class="[activeValue === tab.value && 'is-active']"
>
<slot
name="tab-link"
v-bind="{
activeValue,
tab,
key,
toggle,
}"
>
<a
role="button"
tabindex="0"
@keydown.prevent.enter="() => toggle(tab.value)"
@click.prevent="() => toggle(tab.value)"
>
<VIcon
v-if="tab.icon"
:icon="tab.icon"
/>
<span>
<slot
name="tab-link-label"
v-bind="{
activeValue,
tab,
key,
toggle,
}"
>
{{ tab.label }}
</slot>
</span>
</a>
</slot>
</li>
<li
v-if="sliderClass"
class="tab-naver"
/>
</ul>
</div>
</div>
<div v-if="'tab' in $slots" class="tab-content is-active">
<Transition
:name="props.slow ? 'fade-slow' : 'fade-fast'"
mode="out-in"
>
<slot
name="tab"
v-bind="{
activeValue,
}"
/>
</Transition>
</div>
</div>
</template>
<style scoped lang="scss">
/*! _tabs.scss | Vuero | Css ninja 2020-2024 */
/*
1. Tabs
2. Tabs Dark mode
3. Tab Content
4. Sliding tabs 2X
5. Sliding tabs 3X
6. Sliding tabs Dark mode
*/
/* ==========================================================================
1. Tabs
========================================================================== */
.tabs {
margin-bottom: 20px;
&.is-toggle {
li {
&:first-child {
a {
border-inline-end: none;
}
}
&:last-child {
a {
border-inline-start: none;
}
}
&.is-active {
a {
background: var(--primary);
border-color: var(--primary);
&:hover,
&:focus {
color: var(--white);
}
}
}
a {
transition: all 0.3s; // transition-all test
&:hover {
border-color: #dbdbdb;
}
}
}
}
li {
&.is-active {
a {
border-bottom-color: var(--primary);
color: var(--primary);
&:hover,
&:focus {
border-bottom-color: var(--primary);
color: var(--primary);
}
}
}
a {
font-family: var(--font);
border-bottom-width: 2px;
color: var(--placeholder);
border-bottom-color: transparent;
&:hover,
&:focus {
color: var(--light-text);
border-bottom-color: transparent;
}
.iconify {
height: 16px;
width: 16px;
margin-inline-end: 6px;
}
.fas,
.fal,
.far,
.fad,
.fab {
margin-inline-end: 6px;
}
.lnil,
.lnir {
font-size: 20px;
margin-inline-end: 6px;
}
small {
margin-inline-start: 5px;
}
}
}
}
/* ==========================================================================
2. Tabs Dark mode
========================================================================== */
.is-dark {
.tabs {
&.is-boxed {
li {
&.is-active {
a,
a:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 1%) !important;
}
}
a {
border-color: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
&:hover,
&:focus {
background: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
}
}
}
}
&.is-toggle {
li {
&.is-active {
a,
a:hover {
background: var(--primary) !important;
border-color: var(--primary);
color: var(--white);
}
}
a {
border-color: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
&:hover,
&:focus {
background: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
}
}
}
}
ul {
border-bottom-color: color-mix(in oklab, var(--dark-sidebar), white 16%);
}
li {
&.is-active {
a {
border-bottom-color: var(--primary);
color: var(--primary);
}
}
}
}
}
/* ==========================================================================
3. Tab Content
========================================================================== */
.tab-content {
display: none;
animation-name: fadeInLeft;
animation-duration: 0.5s;
&.is-active {
display: block;
&.is-spaced {
margin-top: 10px !important;
}
}
&.is-spaced {
margin-top: 40px;
}
&.is-spaced-lg {
margin-top: 40px !important;
}
}
/* ==========================================================================
4. Sliding tabs 2X
========================================================================== */
.tabs-wrapper,
.tabs-wrapper-alt {
&.is-slider {
&.is-inverted {
> .tabs-inner > .tabs {
background: var(--white);
}
}
&.is-squared {
> .tabs-inner > .tabs,
.tab-naver {
border-radius: 8px;
}
}
> .tabs-inner > .tabs {
position: relative;
background: color-mix(in oklab, var(--fade-grey), white 2%);
border: 1px solid var(--fade-grey);
max-width: 185px;
height: 35px;
border-bottom: none;
border-radius: 500px;
ul {
border-bottom: none;
&.is-profile {
li {
a {
color: var(--smoke-white) !important;
}
&.is-active a {
color: var(--dark-text) !important;
}
}
}
}
li {
width: 50%;
a {
color: var(--light-text);
font-family: var(--font);
height: 40px;
border-bottom: none;
position: relative;
z-index: 5;
span {
position: relative;
top: -1px;
display: block;
}
}
&.is-active a {
color: var(--white);
font-weight: 400;
}
&:first-child {
&.is-active ~ .tab-naver {
margin-inline-start: 0;
}
}
&:nth-child(2) {
&.is-active ~ .tab-naver {
margin-inline-start: 50% !important;
}
}
}
&.is-centered {
margin-inline-start: auto;
margin-inline-end: auto;
}
}
.tab-naver {
inset-inline-start: 0;
background: var(--primary);
position: absolute;
top: 0.5px;
display: block;
height: 32px;
transition: all 0.3s; // transition-all test
z-index: 4;
border-radius: 50px;
&.is-profile {
background: var(--smoke-white) !important;
}
&.is-active {
margin-inline-start: 50%;
}
}
}
}
/* ==========================================================================
5. Sliding tabs 3X
========================================================================== */
.tabs-wrapper,
.tabs-wrapper-alt {
&.is-triple-slider {
&.is-inverted {
> .tabs-inner > .tabs {
background: var(--white);
}
}
&.is-squared {
> .tabs-inner > .tabs,
.tab-naver {
border-radius: 8px;
}
}
> .tabs-inner > .tabs {
position: relative;
background: color-mix(in oklab, var(--fade-grey), white 2%);
border: 1px solid var(--fade-grey);
max-width: 280px;
height: 35px;
border-bottom: none;
border-radius: 500px;
ul {
border-bottom: none;
&.is-profile {
li {
a {
color: var(--smoke-white) !important;
}
&.is-active a {
color: var(--dark-text) !important;
}
}
}
}
li {
width: 33.3%;
a {
color: var(--light-text);
font-family: var(--font);
font-weight: 400;
height: 40px;
border-bottom: none;
position: relative;
z-index: 5;
span {
position: relative;
top: -1px;
display: block;
}
}
&.is-active a {
color: var(--white);
font-weight: 400;
}
&:first-child {
&.is-active ~ .tab-naver {
margin-inline-start: 0;
}
}
&:nth-child(2) {
&.is-active ~ .tab-naver {
margin-inline-start: 33% !important;
}
}
&:nth-child(3) {
&.is-active ~ .tab-naver {
margin-inline-start: 66.6%;
}
}
}
}
.tab-naver {
position: absolute;
top: 0.5px;
inset-inline-start: 0;
display: block;
width: 33.3% !important;
background: var(--primary);
height: 32px;
transition: all 0.3s; // transition-all test
z-index: 4;
border-radius: 50px;
&.is-profile {
background: var(--smoke-white) !important;
}
&.is-active {
margin-inline-start: 48%;
}
}
}
}
/* ==========================================================================
6. Sliding tabs Dark mode
========================================================================== */
.is-dark {
.tabs-wrapper {
&.is-slider,
&.is-triple-slider {
&.is-inverted {
> .tabs-inner > .tabs {
border: 1px solid color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
background: color-mix(in oklab, var(--dark-sidebar), white 2%) !important;
}
}
> .tabs-inner > .tabs {
border: 1px solid color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
background: color-mix(in oklab, var(--dark-sidebar), white 2%) !important;
.tab-naver {
background: var(--primary) !important;
}
ul {
border: none;
}
li {
&.is-active {
a {
color: var(--white);
}
}
}
}
}
}
}
/* ==========================================================================
4. Vertical tabs
========================================================================== */
@media only screen and (width <= 767px) {
.vertical-tabs-wrapper {
.tabs {
ul {
li {
&.is-active {
a {
color: var(--primary);
border-bottom-color: var(--primary);
}
}
a {
color: var(--light-text);
}
}
}
}
.content-wrap {
.tab-content {
padding-top: 12px;
display: none;
animation: fadeInLeft 0.5s;
&.is-active {
display: block;
}
}
}
}
}
@media only screen and (width >= 768px) {
.vertical-tabs-wrapper {
display: flex;
.tabs {
// min-width: 25%;
// max-width: 25%;
margin-inline-end: 30px;
ul {
display: block;
text-align: inset-inline-start;
border-bottom-color: transparent !important;
li {
display: block;
&.is-active {
a {
color: var(--primary);
border-inline-end-color: var(--primary);
}
}
a {
display: block;
border-bottom-color: transparent !important;
border-inline-end: 2px solid #dbdbdb;
color: var(--light-text);
}
}
}
}
.content-wrap {
flex-grow: 2;
.tab-content {
display: none;
animation: fadeInLeft 0.5s;
&.is-active {
display: block;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,265 @@
<script setup lang="ts">
export type VTagColor =
| 'primary'
| 'secondary'
| 'info'
| 'success'
| 'warning'
| 'danger'
| 'orange'
| 'blue'
| 'green'
| 'purple'
| 'white'
| 'light'
| 'solid'
export type VTagSize = 'tiny'
export interface VTagProps {
label?: string | number
color?: VTagColor
size?: VTagSize
rounded?: boolean
curved?: boolean
outlined?: boolean
elevated?: boolean
remove?: boolean
}
const props = withDefaults(defineProps<VTagProps>(), {
label: undefined,
color: undefined,
size: undefined,
})
</script>
<template>
<small
class="tag"
:class="[
props.color && 'is-' + props.color,
props.size && 'is-' + props.size,
props.rounded && 'is-rounded',
props.curved && 'is-curved',
props.outlined && 'is-outlined',
props.elevated && 'is-elevated',
props.remove && 'is-delete',
]"
><slot>{{ props.label }}</slot></small>
</template>
<style lang="scss">
.tag:not(body) {
display: inline-block;
line-height: 2.3;
height: 2.4em;
font-size: 0.75rem;
&.is-rounded {
padding-inline-start: 1em;
padding-inline-end: 1em;
}
&.is-curved {
padding-inline-start: 0.85em;
padding-inline-end: 0.85em;
line-height: 2.5;
height: 2.6em;
border-radius: 8px;
}
&.is-tiny {
line-height: 1.3;
height: 1.6em;
font-size: 0.7rem;
&.is-curved {
padding-inline-start: 0.55em;
padding-inline-end: 0.55em;
line-height: 1.3;
height: 1.6em;
}
}
&.is-elevated {
box-shadow: var(--light-box-shadow);
}
&.is-solid {
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
color: var(--light-text);
}
&.is-primary {
&.is-elevated {
box-shadow: var(--primary-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--primary);
border: 1px solid var(--primary);
}
}
&.is-success {
&.is-elevated {
box-shadow: var(--success-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--success);
border: 1px solid var(--success);
}
}
&.is-info {
&.is-elevated {
box-shadow: var(--info-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--info);
border: 1px solid var(--info);
}
}
&.is-warning {
&.is-elevated {
box-shadow: var(--warning-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--warning);
border: 1px solid var(--warning);
}
}
&.is-danger {
&.is-elevated {
box-shadow: var(--danger-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--danger);
border: 1px solid var(--danger);
}
}
&.is-secondary {
background: var(--secondary);
color: var(--white);
&.is-elevated {
box-shadow: var(--secondary-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--secondary);
border: 1px solid var(--secondary);
}
}
&.is-green {
background: var(--green);
color: var(--white);
&.is-elevated {
box-shadow: var(--green-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--green);
border: 1px solid var(--green);
}
}
&.is-blue {
background: var(--blue);
color: var(--white);
&.is-elevated {
box-shadow: var(--blue-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--blue);
border: 1px solid var(--blue);
}
}
&.is-purple {
background: var(--purple);
color: var(--white);
&.is-elevated {
box-shadow: var(--purple-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--purple);
border: 1px solid var(--purple);
}
}
&.is-orange {
background: var(--orange);
color: var(--white);
&.is-elevated {
box-shadow: var(--orange-box-shadow);
}
&.is-outlined {
background: none !important;
color: var(--orange);
border: 1px solid var(--orange);
}
}
}
.is-dark {
.tag {
&:not(
.is-primary,
.is-secondary,
.is-success,
.is-info,
.is-warning,
.is-danger,
.is-orange,
.is-green,
.is-blue,
.is-purple
) {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 10%);
color: var(--dark-dark-text);
}
&.is-primary {
background: var(--primary);
&.is-outlined {
border-color: var(--primary);
color: var(--primary);
}
&.is-light {
background: color-mix(in oklab, var(--primary), white 22%);
color: var(--primary);
}
}
}
}
</style>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
export interface VTagsProps {
addons?: boolean
}
const props = defineProps<VTagsProps>()
</script>
<template>
<div
class="tags"
:class="[props.addons && 'has-addons']"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
export interface VTextEllipsisProps {
width?: string
mobileWidth?: string
}
const props = withDefaults(defineProps<VTextEllipsisProps>(), {
width: '150px',
mobileWidth: undefined,
})
const mobileWidthValue = props.mobileWidth ?? props.width
if (props.width.match(CssUnitRe) === null) {
console.warn(
`VTextEllipsis: invalid "${props.width}" width. Should be a valid css unit value.`,
)
}
if (mobileWidthValue.match(CssUnitRe) === null) {
console.warn(
`VTextEllipsis: invalid "${mobileWidthValue}" mobileWidth. Should be a valid css unit value.`,
)
}
</script>
<template>
<span class="text-ellipsis"><slot /></span>
</template>
<style lang="scss" scoped>
.text-ellipsis {
max-width: v-bind('props.width');
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (width <= 767px) {
.text-ellipsis {
max-width: v-bind('mobileWidthValue');
}
}
</style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
export interface VTextareaProps {
raw?: boolean
autogrow?: boolean
}
const modelValue = defineModel<string>({
default: '',
})
const props = defineProps<{
raw?: boolean
autogrow?: boolean
}>()
const { field, id } = useVFieldContext({
create: false,
help: 'VTextarea',
})
const textareaRef = ref<HTMLTextAreaElement>()
const internal = computed({
get() {
if (field?.value) {
return field.value.value
}
else {
return modelValue.value
}
},
set(value: any) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
function fitSize() {
if (!textareaRef.value) {
return
}
if (props.autogrow) {
textareaRef.value.style.height = 'auto'
textareaRef.value.style.height = textareaRef.value.scrollHeight + 'px'
}
}
const classes = computed(() => {
if (props.raw) return []
return ['textarea']
})
</script>
<template>
<textarea
:id="id"
ref="textareaRef"
v-model="internal"
:class="classes"
:name="id"
@change="field?.handleChange"
@blur="field?.handleBlur"
@input="fitSize"
/>
</template>

View File

@@ -0,0 +1,366 @@
<template>
<div class="auth-wrapper">
<slot />
</div>
</template>
<style lang="scss">
.auth-wrapper-inner {
overflow: hidden !important;
height: 100%;
padding: 0;
margin: 0;
&.is-gapless:not(:last-child) {
margin-bottom: 0 !important;
}
&.is-single {
background: var(--widget-grey);
min-height: 100vh;
}
.hero-banner {
background: var(--widget-grey);
img {
max-width: 550px;
margin: 0 auto;
}
}
.hero-heading {
position: relative;
max-width: 360px;
width: 100%;
margin: 0 auto;
padding: 20px 0 0;
.auth-logo {
display: flex;
align-items: center;
justify-content: space-between;
.dark-mode {
transform: scale(0.6);
z-index: 2;
margin-inline-start: 0 !important;
}
.iconify {
height: 42px;
width: 42px;
}
.top-logo {
height: 42px;
}
}
}
.hero {
&.is-white {
background: var(--white);
}
.hero-body {
.login {
padding: 10px 0;
}
.auth-content {
max-width: 320px;
width: 100%;
margin: 0 auto;
margin-top: -40px;
margin-bottom: 40px;
h2 {
font-size: 2rem;
font-family: var(--font);
line-height: 1;
}
p {
font-size: 1rem;
margin-bottom: 8px;
color: var(--muted-grey);
}
a {
font-size: 0.9rem;
font-family: var(--font-alt);
font-weight: 500;
color: var(--primary);
}
}
.auth-form-wrapper {
max-width: 320px;
width: 100%;
margin: 0 auto;
}
}
}
.forgot-link {
margin-top: 10px;
a {
font-family: var(--font-alt);
font-size: 0.9rem;
color: var(--light-text);
transition: color 0.3s;
&:hover,
&:focus {
color: var(--primary);
}
}
}
.setting-item {
display: flex;
align-items: center;
padding: 10px 0;
.setting-meta {
font-family: var(--font);
color: var(--light-text);
margin-inline-start: 8px;
}
}
.v-button {
min-height: 44px;
}
}
.is-dark {
.auth-wrapper-inner {
.hero-banner {
background: color-mix(in oklab, var(--dark-sidebar), white 4%);
}
.hero {
&.is-white {
background: color-mix(in oklab, var(--dark-sidebar), black 4%);
}
.hero-body {
.auth-content {
h2 {
color: var(--dark-dark-text);
}
a {
color: var(--primary);
}
}
}
}
.forgot-link {
a:hover {
color: var(--primary);
}
}
}
}
.auth-nav {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 80px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
z-index: 1;
.left,
.right {
display: flex;
align-items: center;
width: 20%;
}
.right {
justify-content: flex-end;
.dark-mode {
transform: scale(0.7);
}
}
.center {
flex-grow: 2;
a {
display: flex;
justify-content: center;
align-items: center;
img {
display: block;
width: 100%;
max-width: 50px;
}
}
}
}
.auth-wrapper-inner {
.single-form-wrap {
min-height: 690px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: center;
.inner-wrap {
width: 100%;
max-width: 400px;
margin: 40px auto 0;
.auth-head {
max-width: 320px;
width: 100%;
margin: 0 auto;
margin-bottom: 20px;
text-align: center;
h2 {
font-size: 2rem;
font-family: var(--font);
line-height: 1;
}
p {
font-size: 1rem;
margin-bottom: 8px;
color: var(--muted-grey);
}
a {
font-size: 0.9rem;
font-family: var(--font-alt);
font-weight: 500;
color: var(--primary);
}
}
.form-card {
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
border-radius: 10px;
padding: 50px;
margin-bottom: 16px;
.v-button {
margin-top: 10px;
}
}
}
}
}
.is-dark {
.auth-wrapper-inner {
&.is-single {
background: color-mix(in oklab, var(--dark-sidebar), white 4%);
.single-form-wrap {
.inner-wrap {
.auth-head {
h2 {
color: var(--dark-dark-text);
}
a {
color: var(--primary);
}
}
.form-card {
background: color-mix(in oklab, var(--dark-sidebar), black 4%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
}
}
}
}
}
}
@media only screen and (width <= 767px) {
.avatar-carousel {
&.resized-mobile {
max-width: 300px;
}
.slick-custom {
display: none !important;
}
.image-wrapper img {
height: auto;
}
}
.auth-wrapper-inner {
.hero {
.hero-body {
.auth-content {
text-align: center !important;
}
}
}
.single-form-wrap {
.inner-wrap {
.form-card {
padding: 40px;
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.modern-login {
.top-logo {
.iconify {
height: 60px;
width: 60px;
}
}
.dark-mode {
top: -58px;
inset-inline-end: 30%;
}
.columns {
display: flex;
height: 100vh;
}
}
.auth-wrapper-inner {
.hero {
.hero-body {
.auth-content {
text-align: center !important;
}
}
}
}
.signup-columns {
max-width: 460px;
margin: 0 auto;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<script setup lang="ts">
import type { LandingFooterColumn, LandingSocialItem } from './landing.types'
const props = defineProps<{
title?: string
subtitle?: string
links?: LandingFooterColumn[]
social?: LandingSocialItem[]
}>()
</script>
<template>
<footer class="vuero-footer is-relative">
<div class="container">
<div v-if="'action' in $slots || props.title || props.subtitle" class="footer-head">
<div v-if="props.title || props.subtitle" class="head-text">
<h3 v-if="props.title">
{{ props.title }}
</h3>
<p v-if="props.subtitle">
{{ props.subtitle }}
</p>
</div>
<div class="head-action">
<div class="is-flex is-align-items-center">
<slot name="action" />
</div>
</div>
</div>
<div v-if="'default' in $slots || props.social?.length || props.links?.length" class="columns footer-body">
<!-- Column -->
<div v-if="'default' in $slots || props.social?.length" class="column is-4">
<slot />
<div v-if="props.social">
<div class="social-links p-b-10">
<VLink
v-for="item in social"
:key="item.icon"
:to="item.link"
>
<VIcon :icon="item.icon" class="icon" />
</VLink>
</div>
</div>
</div>
<!-- Column -->
<div
class="column"
:class="'default' in $slots || props.social?.length ? 'is-6 is-offset-2' : 'is-12 has-text-centered'"
>
<div class="columns is-flex-tablet-p">
<!-- Column -->
<div
v-for="column in props.links"
:key="column.label"
class="column"
>
<ul class="footer-column">
<li class="column-header">
{{ column.label }}
</li>
<li
v-for="link in column.children"
:key="link.label"
class="column-item"
>
<VLink :to="link.to">
{{ link.label }}
</VLink>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-copyright has-text-centered">
<slot name="copyright" />
</div>
</div>
</footer>
<!-- /Simple light footer -->
</template>
<style lang="scss">
.vuero-footer {
padding-bottom: 0 !important;
padding-top: 4rem !important;
background: var(--body-color);
.footer-head {
padding-bottom: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid color-mix(in oklab, var(--fade-grey), black 4%);
.head-text {
h3 {
font-family: var(--font);
font-size: 1.8rem;
color: var(--dark-text);
}
p {
font-size: 1.1rem;
color: var(--light-text);
}
}
.head-action {
.buttons {
.button {
&.action-button {
height: 36px;
min-width: 140px;
}
&.chat-button {
background: transparent;
border: none;
box-shadow: none;
color: var(--primary);
font-weight: 500;
}
}
}
}
}
.footer-body {
padding-top: 3rem;
.footer-column {
padding-top: 20px;
.column-header {
font-family: var(--font-alt);
text-transform: uppercase;
color: var(--dark-text);
font-size: 1rem;
font-weight: 700;
margin: 10px 0;
}
.column-item {
padding-bottom: 10px;
a {
font-family: var(--font);
color: var(--light-text);
&:hover,
&:focus {
color: var(--primary);
}
}
}
}
.social-links {
display: flex;
justify-content: flex-start;
align-items: center;
.icon {
color: var(--light-text);
font-size: 16px;
margin-inline-end: 0.6rem;
&:hover,
&:focus {
color: var(--primary);
}
}
}
.footer-description {
color: var(--light-text);
}
.small-footer-logo {
height: 36px;
}
}
.footer-copyright {
font-family: var(--font);
color: var(--light-text);
padding: 4rem 0 2rem;
a {
color: var(--light-text);
&:hover,
&:focus {
color: var(--primary);
}
}
}
}
.is-dark {
.landing-page-wrapper {
.vuero-footer {
background: color-mix(in oklab, var(--landing-xxx), white 8%);
.footer-head {
border-color: color-mix(in oklab, var(--landing-xxx), white 18%);
.head-text {
h3 {
color: var(--dark-dark-text);
}
p {
font-size: 1.1rem;
color: var(--light-text);
}
}
.head-action {
.buttons {
.button {
&.action-button {
background: var(--primary);
border-color: var(--primary);
}
&.chat-button {
color: var(--primary);
background: none !important;
}
}
}
}
}
.footer-body {
.footer-column {
.column-header {
color: var(--dark-dark-text);
}
.column-item {
a:hover {
color: var(--primary);
}
}
}
.social-links {
a:hover {
color: var(--primary);
}
}
}
.footer-copyright {
a {
&:hover {
color: var(--primary);
}
}
}
}
}
}
@media (width <= 767px) {
.vuero-footer {
.footer-head {
flex-direction: column;
text-align: center;
.head-text {
padding-bottom: 20px;
}
}
.footer-body {
padding-inline-start: 20px;
padding-inline-end: 20px;
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.vuero-footer {
.footer-head,
.footer-body {
padding-inline-start: 20px;
padding-inline-end: 20px;
}
.footer-description {
max-width: 400px;
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: landscape) {
.vuero-footer {
.footer-head,
.footer-body {
padding-inline-start: 20px;
padding-inline-end: 20px;
}
}
}
</style>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
const { x, y } = useMouse()
const maskPosition = computed(() => `${Math.round(x.value - 220)}px ${Math.round(y.value - 220)}px`)
</script>
<template>
<div class="grids gridlines" />
</template>
<style scoped lang="scss">
.grids {
position: absolute;
inset: 0;
z-index: 0;
will-change: mask-position;
mask-image: radial-gradient(circle 220px at 220px 220px, black 0%, transparent 100%);
mask-position: v-bind(maskPosition);
mask-repeat: no-repeat;
pointer-events: none;
}
.gridlines {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCI+PGcgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBmaWxsPSIjZWJlYmViIj48cGF0aCBvcGFjaXR5PSIuNSIgZD0iTTk2IDk1aDR2MWgtNHY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNGgtOXY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNGgtOXY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNEgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNXYtOUgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNXYtOUgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djl6bS0xIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6IiAvPjxwYXRoIGQ9Ik02IDVWMEg1djVIMHYxaDV2OTRoMVY2aDk0VjVINnoiIC8+PC9nPjwvZz48L3N2Zz4K');
}
.is-dark .gridlines:not(.is-contrasted) {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCI+PGcgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBmaWxsPSIjMjcyZTNlIj48cGF0aCBvcGFjaXR5PSIuNSIgZD0iTTk2IDk1aDR2MWgtNHY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNGgtOXY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNGgtOXY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNEgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNXYtOUgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNXYtOUgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djl6bS0xIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6IiBmaWxsPSIjMjcyZTNlIi8+PHBhdGggZD0iTTYgNVYwSDV2NUgwdjFoNXY5NGgxVjZoOTRWNUg2eiIgZmlsbD0iIzI3MmUzZSIvPjwvZz48L2c+PC9zdmc+');
}
.is-dark .gridlines.is-contrasted {
filter: brightness(0.30);
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { LandingNavItem } from './landing.types'
const props = withDefaults(defineProps<{
links: LandingNavItem[]
}>(), {
links: () => [],
})
</script>
<template>
<MinimalLayout theme="light">
<main class="landing-page-wrapper">
<LandingGrids />
<div class="hero">
<LandingNavigation>
<template #logo>
<slot name="logo" />
</template>
<template #end>
<slot name="nav-end" />
</template>
<slot name="nav-links">
<div
v-for="link in props.links"
:key="link.label"
class="navbar-item"
>
<VLink
:to="link.to"
class="nav-link"
:class="[link.active && 'is-active']"
>
{{ link.label }}
</VLink>
</div>
</slot>
</LandingNavigation>
</div>
<slot />
</main>
</MinimalLayout>
</template>
<style lang="scss">
@import '/@src/scss/abstracts/all';
@import '/@src/scss/layout/landing';
</style>

View File

@@ -0,0 +1,360 @@
<script setup lang="ts">
const isMobileNavOpen = ref(false)
const { y } = useWindowScroll()
const { isLargeScreen } = useScreenSize()
const isScrolling = computed(() => {
return y.value > 30
})
watchEffect(() => {
if (isLargeScreen.value) {
isMobileNavOpen.value = false
}
})
</script>
<template>
<div class="hero">
<nav
class="navbar is-fixed-top"
:class="[!isScrolling && 'is-docked', isMobileNavOpen && 'is-solid']"
aria-label="main navigation"
>
<div class="navbar-brand">
<slot name="logo" />
<MobileBurger v-model="isMobileNavOpen" />
</div>
<div
class="navbar-menu"
:class="[isMobileNavOpen && 'is-active']"
>
<div class="navbar-start">
<slot />
</div>
<div class="navbar-end">
<slot name="end" />
</div>
</div>
</nav>
</div>
</template>
<style lang="scss">
.hero {
.navbar {
top: 15px;
height: 65px;
max-width: 1140px;
margin: 0 auto;
background-color: var(--white);
box-shadow: var(--light-box-shadow);
border: 1px solid var(--fade-grey);
border-radius: 500px;
font-family: var(--font);
z-index: 99;
transition: all 0.3s; // transition-all test
&.is-docked {
&:not(.is-solid) {
top: 0;
border-color: transparent;
height: 110px;
box-shadow: none;
background: transparent;
.navbar-brand {
.brand-icon {
height: 64px;
width: 64px;
background: var(--white);
border-color: color-mix(in oklab, var(--fade-grey), black 3%);
}
}
}
&.is-solid {
height: 65px !important;
}
}
&.is-solid {
background: var(--white) !important;
border-radius: 10px 10px 0 0;
}
.navbar-brand {
img,
.iconify {
position: relative;
display: block;
width: 100%;
max-width: 34px;
max-height: 34px;
margin-inline-start: 10px;
}
.brand-icon {
height: 50px;
width: 50px;
border-radius: var(--radius-rounded);
display: flex;
justify-content: center;
align-items: center;
border: 1px solid transparent;
transition: all 0.3s; // transition-all test
img,
svg {
position: relative;
top: -2px;
margin-inline-start: 0;
}
}
}
.navbar-menu {
.navbar-item {
.dark-mode {
transform: scale(0.6);
}
.nav-link {
position: relative;
font-family: var(--font-alt);
font-weight: 500;
font-size: 0.9rem;
color: var(--light-text);
text-transform: capitalize;
&::before {
content: '';
position: absolute;
top: -4px;
inset-inline-start: 2px;
width: 50%;
transform-origin: right center;
height: 3px;
border-radius: 50px;
background: var(--primary);
transform: scale(0, 1);
transition: -webkit-transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transition:
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1),
-webkit-transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
// Hover state
&:hover,
&.is-active {
color: var(--dark-text);
&::before {
transform-origin: left center;
transform: scale(1, 1);
}
}
&.active {
&::before {
background: var(--primary);
}
}
}
.button {
font-weight: 400 !important;
height: 44px;
min-width: 110px;
}
}
}
}
}
.is-dark {
.navbar {
&:not(.is-docked) {
background: color-mix(in oklab, var(--landing-xxx), white 8%);
border-color: color-mix(in oklab, var(--landing-xxx), white 14%);
}
&.is-docked {
.navbar-brand {
.brand-icon {
background: color-mix(in oklab, var(--landing-yyy), white 8%) !important;
border-color: color-mix(in oklab, var(--landing-yyy), white 18%) !important;
}
}
}
&.is-solid {
background: color-mix(in oklab, var(--landing-xxx), white 8%) !important;
border-color: color-mix(in oklab, var(--landing-xxx), white 14%) !important;
.navbar-brand {
.brand-icon {
border-color: transparent;
background-color: transparent;
}
}
.navbar-menu {
&.is-active {
background: color-mix(in oklab, var(--landing-xxx), white 12%);
border-color: color-mix(in oklab, var(--landing-xxx), white 14%);
}
}
}
.navbar-item {
.nav-link {
&:hover,
&.is-active {
color: var(--white) !important;
}
&::before {
background: var(--primary);
}
}
.button {
&.is-primary {
background: var(--primary);
border-color: var(--primary);
&.is-raised:hover {
box-shadow: var(--primary-box-shadow);
}
}
}
}
}
}
@media (width <= 767px) {
.navbar {
display: flex;
align-items: center;
width: calc(100% - 32px);
margin: 0 16px;
&.is-docked {
height: 80px;
}
&.is-solid {
top: 10px;
box-shadow: var(--light-box-shadow) !important;
.navbar-brand {
.brand-icon {
border-color: transparent;
}
}
.navbar-menu {
box-shadow: var(--light-box-shadow) !important;
top: 73px !important;
}
}
.navbar-brand {
width: 100%;
}
.navbar-menu {
width: calc(100% - 32px);
position: fixed;
top: 78px;
inset-inline-start: 0;
inset-inline-end: 0;
margin: 0 auto;
border-radius: 0 0 10px 10px;
padding: 30px;
text-align: center;
border: 1px solid var(--fade-grey);
box-shadow: none;
.navbar-item {
.button {
width: 100%;
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.navbar {
display: flex;
align-items: center;
width: calc(100% - 32px);
margin: 0 16px;
&.is-docked {
height: 80px;
}
&:not(.is-docked) {
&.is-solid {
.navbar-menu {
top: 73px !important;
}
}
}
&.is-solid {
top: 10px;
box-shadow: var(--light-box-shadow) !important;
.navbar-brand {
.brand-icon {
border-color: transparent;
}
}
.navbar-menu {
box-shadow: var(--light-box-shadow) !important;
}
}
.navbar-brand {
width: 100%;
}
.navbar-menu {
width: calc(100% - 32px);
position: fixed;
top: 78px;
inset-inline-start: 0;
inset-inline-end: 0;
margin: 0 auto;
border-radius: 0 0 10px 10px;
padding: 30px;
text-align: center;
border: 1px solid var(--fade-grey);
box-shadow: none;
.navbar-item {
.button {
width: 100%;
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: landscape) {
.navbar {
width: calc(100% - 40px);
margin: 0 20px;
}
}
</style>

View File

@@ -0,0 +1,18 @@
export interface LandingNavItem {
label: string
to: string
active?: boolean
}
export interface LandingFooterColumn {
label: string
children: {
label: string
to: string
}[]
}
export interface LandingSocialItem {
icon: string
link: string
}

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
export type MinimalTheme = 'darker' | 'light'
const props = withDefaults(
defineProps<{
theme?: MinimalTheme
}>(),
{
theme: 'darker',
},
)
</script>
<template>
<div
class="minimal-wrapper"
:class="[props.theme]"
>
<slot />
</div>
</template>
<style lang="scss">
.minimal-wrapper {
position: relative;
width: 100%;
min-height: 100vh;
background: var(--lighter-grey);
&.light {
background: var(--white);
}
&.lighter {
background: var(--smoke-white);
}
&.darker {
background: var(--background-grey);
}
.minimal-wrap {
min-height: calc(100vh - 60px);
}
}
.is-dark {
.minimal-wrapper {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 10%);
}
}
</style>

View File

@@ -0,0 +1,656 @@
<script setup lang="ts">
import { useNavbarLayoutContext } from './navbar.context'
const { theme } = useNavbarLayoutContext()
const { y } = useWindowScroll()
const isScrolling = computed(() => {
return y.value > 30
})
</script>
<template>
<div
class="navbar-navbar"
:class="[
isScrolling && 'is-scrolled',
theme === 'fade' && 'is-transparent',
theme === 'colored' && 'is-colored',
]"
>
<div class="navbar-navbar-inner">
<div class="left">
<slot name="title" />
</div>
<div class="center">
<slot name="links" />
</div>
<div class="right">
<slot name="toolbar" />
</div>
</div>
</div>
</template>
<style lang="scss">
.navbar-navbar {
position: fixed;
top: 0;
inset-inline-start: 0;
width: 100%;
height: 65px;
background: var(--white);
transition: all 0.3s; // transition-all test
border-bottom: 1px solid var(--fade-grey);
z-index: 100;
&.is-transparent {
background: transparent;
box-shadow: none;
border-bottom-color: transparent;
&.is-solid,
&.is-scrolled {
background: var(--white);
border-bottom-color: var(--fade-grey);
}
&.is-solid {
box-shadow: none !important;
}
&.is-scrolled {
box-shadow: 0 0 8px 0 rgb(0 0 0 / 12%);
}
}
&.is-colored {
background: var(--landing-yyy);
border-bottom-color: var(--landing-yyy);
.navbar-navbar-inner {
.left {
.separator {
border-color: color-mix(in oklab, var(--landing-yyy), white 18%);
}
.title {
color: var(--smoke-white);
}
}
.center {
.centered-links {
.centered-link {
&:hover {
background: color-mix(in oklab, var(--landing-yyy), black 6%);
.iconify {
color: var(--smoke-white);
}
span {
color: var(--smoke-white);
}
}
&.is-active {
// background: color-mix(in oklab, var(--landing-yyy), black 12%);
// border-color: color-mix(in oklab, var(--landing-yyy), white 6%);
&:hover,
&:focus {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
}
.iconify {
color: var(--smoke-white);
}
span {
color: var(--smoke-white);
}
}
.iconify {
color: var(--light-text);
}
span {
color: var(--light-text);
}
}
}
.centered-drops {
.centered-drop {
.dropdown {
&:hover {
.is-trigger {
.button {
background: color-mix(in oklab, var(--landing-yyy), black 6%);
color: var(--smoke-white);
}
}
}
&.is-active {
.is-trigger {
.button {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
border-color: color-mix(in oklab, var(--landing-yyy), white 6%);
}
}
}
.is-trigger {
.button {
background: var(--landing-yyy);
color: var(--light-text);
.caret {
margin-inline-start: 0;
}
}
}
}
}
.centered-button {
.button {
background: var(--landing-yyy);
color: var(--light-text);
&:hover,
&:focus {
background: color-mix(in oklab, var(--landing-yyy), black 6%);
color: var(--smoke-white);
}
}
}
}
.centered-search {
.field {
.control {
.input {
background: color-mix(in oklab, var(--primary), black 10%);
border-color: color-mix(in oklab, var(--primary), black 6%);
color: var(--smoke-white);
&::placeholder {
color: color-mix(in oklab, var(--primary), white 2%);
}
&:focus ~ .form-icon.iconify {
color: var(--smoke-white);
}
}
.form-icon.iconify {
color: color-mix(in oklab, var(--primary), white 6%);
}
}
}
}
}
.right {
.toolbar {
.toolbar-link {
&:hover {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
border-color: color-mix(in oklab, var(--landing-yyy), black 12%);
}
> .iconify {
color: var(--smoke-white);
}
}
.dropdown {
&:hover {
.is-trigger {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
border-color: color-mix(in oklab, var(--landing-yyy), black 12%);
}
}
&.is-dots {
&.is-active {
.is-trigger {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
border-color: color-mix(in oklab, var(--landing-yyy), black 12%);
}
}
}
.is-trigger .iconify {
color: var(--smoke-white);
}
}
}
.icon-link {
background: var(--landing-yyy);
&:hover,
&:focus {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
}
> .iconify {
color: var(--smoke-white);
}
}
}
}
}
.navbar-navbar-inner {
position: relative;
height: 100%;
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 20px;
.left {
display: flex;
align-items: center;
width: 17%;
.brand {
display: flex;
align-items: center;
img {
display: block;
min-width: 38px;
height: 38px;
}
span {
font-family: var(--font);
font-size: 0.95rem;
color: var(--muted-grey);
letter-spacing: 1px;
max-width: 50px;
line-height: 1.2;
margin-inline-start: 8px;
}
}
.separator {
height: 38px;
width: 2px;
border-inline-end: 1px solid color-mix(in oklab, var(--fade-grey), black 4%);
margin: 0 20px 0 16px;
}
}
.center {
display: flex;
align-items: center;
justify-content: center;
flex-grow: 2;
width: 50%;
.left-links {
padding-left: 20px;
justify-content: left !important;
}
.centered-links, .left-links {
display: flex;
justify-content: center;
width: 100%;
gap: 1rem;
// max-width: 580px;
.centered-link {
// flex: 1 1 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.25rem;
text-align: center;
padding: 12px 24px;
//border-radius: 8px;
border: 1px solid transparent;
margin: 0 4px;
transition: all 0.3s; // transition-all test
white-space: nowrap;
&:hover {
background-color: transparent !important;
background: color-mix(in oklab, var(--fade-grey), white 4%);
}
&.is-active {
.iconify {
color: var(--primary);
}
span {
color: color-mix(in oklab, var(--primary), black 8%);
}
}
.iconify {
font-size: 20px;
color: color-mix(in oklab, var(--light-text), white 6%);
stroke-width: 1.6px;
transition: stroke 0.3s;
}
span {
display: block;
font-family: var(--font);
font-size: 1.1rem;
letter-spacing: 0.6px;
font-weight: 500;
//color: var(--muted-grey);
//color: var(--modal-text);
color: black;
text-transform: uppercase;
transition: all 0.3s;
cursor: pointer;
}
&.router-link-exact-active {
border-bottom: 3px solid var(--primary);
span{
color: black;
//color: var(--modal-text) ;
font-weight: bold;
font-size: 1.2rem;
}
}
}
}
}
.right {
display: flex;
align-items: center;
justify-content: flex-end;
width: 25%;
.icon-link {
display: flex;
justify-content: center;
align-items: center;
height: 34px;
width: 34px;
font-size: 18px;
border-radius: var(--radius-rounded);
margin: 0 4px;
transition: all 0.3s; // transition-all test
&:hover {
background: var(--white);
border-color: var(--fade-grey);
box-shadow: var(--light-box-shadow);
}
.iconify {
height: 18px;
width: 18px;
stroke-width: 1.6px;
color: var(--light-text);
transition: stroke 0.3s;
vertical-align: 0;
transform: none;
}
}
}
}
}
/* ==========================================================================
4. Webapp Navbar Dark mode
========================================================================== */
.is-dark {
.navbar-navbar:not(.is-colored) {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
&.is-transparent {
background: transparent;
box-shadow: none;
border-bottom-color: transparent;
&.is-solid,
&.is-scrolled {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
}
}
.navbar-navbar-inner {
.left {
a {
&:hover {
background-color: transparent !important;
}
}
.separator {
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
.center {
.centered-links {
.centered-link {
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 2%);
}
&.is-active {
// background: color-mix(in oklab, var(--dark-sidebar), white 2%);
// border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
// &:hover,
// &:focus {
// background: color-mix(in oklab, var(--dark-sidebar), white 2%);
// }
span {
color: var(--primary);
}
.iconify {
color: var(--primary);
}
}
}
}
}
.right {
.icon-link {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
&:hover,
&:focus {
background: color-mix(in oklab, var(--dark-sidebar), white 2%);
}
}
}
}
}
.navbar-navbar {
&.is-colored {
.navbar-navbar-inner {
.left {
.title {
color: var(--smoke-white) !important;
}
}
.center {
.centered-links {
.centered-link {
&:hover {
.iconify {
color: var(--smoke-white);
}
span {
color: var(--smoke-white);
}
}
&.is-active {
.iconify {
color: var(--smoke-white);
}
span {
color: var(--smoke-white);
}
}
}
}
.centered-drops {
.centered-drop {
.dropdown {
&:hover {
.is-trigger {
.button {
color: var(--smoke-white);
}
}
}
&.is-active {
.is-trigger {
.button {
color: var(--smoke-white);
}
}
}
&.has-mega-dropdown {
.dropdown-menu {
.dropdown-content {
.category-selector {
.title-wrap {
h4 {
color: var(--dark-dark-text);
}
}
.category-selector-inner {
.category-item {
background: color-mix(in oklab, var(--dark-sidebar), white 4%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
&:hover,
&:focus {
i,
span {
color: var(--primary);
}
}
span {
color: var(--dark-dark-text);
}
}
}
}
.mega-menus {
.dropdown-item-group {
.column-heading {
color: var(--dark-dark-text);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
.column-content {
.is-media {
&:hover {
.meta {
span:first-child {
color: var(--smoke-white);
}
}
}
}
}
}
}
}
}
}
}
}
.centered-button {
.button {
&:hover {
color: var(--smoke-white) !important;
}
}
}
}
.centered-search {
.field {
.control {
.input {
color: var(--smoke-white);
&:focus ~ .form-icon .iconify {
color: var(--smoke-white) !important;
}
}
}
}
}
}
.right {
.toolbar {
.toolbar-link {
&:hover {
background: color-mix(in oklab, var(--landing-yyy), black 12%) !important;
border-color: color-mix(in oklab, var(--landing-yyy), black 12%) !important;
}
> .iconify {
color: var(--smoke-white);
}
}
.dropdown {
.is-trigger {
&:hover {
background: color-mix(in oklab, var(--landing-yyy), black 12%) !important;
border-color: color-mix(in oklab, var(--landing-yyy), black 12%) !important;
}
.iconify {
color: var(--smoke-white);
}
}
}
}
}
}
}
}
}
a {
&:hover {
background-color:white !important;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import type { NavbarItemDropdown } from './navbar.types'
import { useNavbarLayoutContext } from './navbar.context'
const props = defineProps<{
link: NavbarItemDropdown
}>()
const tippyRef = ref<any>()
const {
activeSubnavId,
toggleSubnav,
} = useNavbarLayoutContext()
watch(activeSubnavId, () => {
if (activeSubnavId.value === props.link.id) {
tippyRef.value?.show()
}
else {
tippyRef.value?.hide()
}
})
function onHidden() {
if (activeSubnavId.value === props.link.id) {
activeSubnavId.value = undefined
}
}
</script>
<template>
<div class="is-flex">
<Tippy
ref="tippyRef"
:key="`dropdown-${props.link.id}`"
trigger="manual"
class="is-flex mx-1"
content-class="content-tet"
interactive
:offset="[0, 10]"
:duration="[150, 100]"
@hidden="onHidden"
>
<a
:class="[
activeSubnavId === props.link.id && 'is-active',
]"
class="centered-link centered-link-toggle"
tabindex="0"
role="button"
@keydown.enter.prevent="toggleSubnav(props.link.id)"
@click="toggleSubnav(props.link.id)"
>
<VIcon
v-if="props.link.icon"
:icon="props.link.icon"
/>
<span>{{ props.link.label }}</span>
</a>
<template #content>
<ul class="centered-link-dropdown has-slimscroll">
<li v-for="item of props.link.children" :key="item.to">
<VLink :to="item.to">
<VIcon
v-if="item.icon"
:icon="item.icon"
/>
<span>{{ item.label }}</span>
</VLink>
</li>
</ul>
</template>
</Tippy>
</div>
</template>
<style scoped lang="scss">
.centered-link-dropdown {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 300px;
padding: 1rem 0;
a {
display: flex;
padding: 0 4rem 0 1rem;
align-items: center;
gap: 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
font-family: var(--font);
font-size: 0.9rem;
color: color-mix(in oklab, var(--light-text), black 5%);
&:focus,
&:hover {
color: var(--primary);
}
.iconify {
font-size: 1.2rem;
opacity: 0.5;
}
}
}
</style>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { NavbarItem, NavbarItemMegamenu, NavbarItemAction } from './navbar.types'
import { useNavbarLayoutContext } from './navbar.context'
const props = defineProps<{
link: NavbarItem
}>()
const { activeSubnavId, toggleSubnav } = useNavbarLayoutContext()
</script>
<template>
<NavbarDropdown
v-if="props.link.type === 'dropdown'"
:key="`dropdown-${props.link.id}`"
:link="props.link"
/>
<a
v-if="props.link.type === 'megamenu'"
:key="`megamenu-${props.link.id}`"
:class="[
activeSubnavId === props.link.id && 'is-active',
]"
class="centered-link centered-link-toggle"
tabindex="0"
role="button"
@keydown.enter.prevent="toggleSubnav((props.link as NavbarItemMegamenu).id)"
@click="toggleSubnav((props.link as NavbarItemMegamenu).id)"
>
<VIcon
v-if="props.link.icon"
:icon="props.link.icon"
/>
<span v-if="props.link.label">{{ props.link.label }}</span>
</a>
<a
v-else-if="props.link.type === 'action'"
:key="`action-${props.link.label}`"
class="centered-link centered-link-search"
tabindex="0"
role="button"
@keydown.enter.prevent="(props.link as NavbarItemAction).onClick"
@click="(props.link as NavbarItemAction).onClick"
>
<VIcon
v-if="props.link.icon"
:icon="props.link.icon"
/>
<span v-if="props.link.label">{{ props.link.label }}</span>
</a>
<VLink
v-else-if="props.link.type === 'link'"
:key="`link-${props.link.label}`"
class="centered-link centered-link-search"
:to="props.link.to"
>
<VIcon
v-if="props.link.icon"
:icon="props.link.icon"
/>
<span v-if="props.link.label">{{ props.link.label }}</span>
</VLink>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { NavbarItem, NavbarItemDropdown, NavbarItemMegamenu, NavbarItemAction } from './navbar.types'
import { useNavbarLayoutContext } from './navbar.context'
const props = defineProps<{
link: NavbarItem
}>()
const { activeMobileSubsidebarId, toggleMobileSubnav } = useNavbarLayoutContext()
</script>
<template>
<li>
<a
v-if="props.link.type === 'dropdown'"
:class="[activeMobileSubsidebarId === props.link.id && 'is-active']"
tabindex="0"
role="button"
@keydown.enter.prevent="toggleMobileSubnav((props.link as NavbarItemDropdown).id)"
@click="toggleMobileSubnav((props.link as NavbarItemDropdown).id)"
>
<VIcon
:icon="props.link.icon"
/>
</a>
<a
v-if="props.link.type === 'megamenu'"
:class="[activeMobileSubsidebarId === props.link.id && 'is-active']"
tabindex="0"
role="button"
@keydown.enter.prevent="toggleMobileSubnav((props.link as NavbarItemMegamenu).id)"
@click="toggleMobileSubnav((props.link as NavbarItemMegamenu).id)"
>
<VIcon
:icon="props.link.icon"
/>
</a>
<a
v-else-if="props.link.type === 'action'"
tabindex="0"
role="button"
@keydown.enter.prevent="(props.link as NavbarItemAction).onClick"
@click="(props.link as NavbarItemAction).onClick"
>
<VIcon
:icon="props.link.icon"
/>
</a>
<VLink
v-else-if="props.link.type === 'link'"
:to="props.link.to"
>
<VIcon
:icon="props.link.icon"
/>
</VLink>
</li>
</template>

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import type {
NavbarTheme,
NavbarItem,
NavbarItemMegamenu,
NavbarItemDropdown,
NavbarLayoutContext,
} from './navbar.types'
import { injectionKey } from './navbar.context'
const props = withDefaults(
defineProps<{
links?: NavbarItem[]
theme?: NavbarTheme
size?: 'default' | 'large' | 'wide' | 'full'
}>(),
{
links: () => [],
theme: 'default',
size: 'default',
},
)
const pageTitle = useVueroContext<string>('page-title')
const route = useRoute()
const linksWithChildren = computed(() => {
return props.links.filter(link => link.type === 'megamenu' || link.type === 'dropdown') as (NavbarItemMegamenu | NavbarItemDropdown)[]
})
const isMobileSidebarOpen = ref(false)
const activeMobileSubsidebarId = ref<string>(linksWithChildren.value?.[0]?.id)
const activeSubnavId = ref<string | undefined>()
const activeSubnav = computed(() => {
return linksWithChildren.value.find(link => link.id === activeSubnavId.value)
})
const activeMobileSubsidebar = computed(() => {
return linksWithChildren.value.find(link => link.id === activeMobileSubsidebarId.value)
})
function toggleSubnav(id: string) {
if (activeSubnavId.value === id) {
activeSubnavId.value = undefined
}
else {
activeSubnavId.value = id
}
}
function toggleMobileSubnav(id: string) {
if (activeMobileSubsidebarId.value === id) {
isMobileSidebarOpen.value = false
}
else {
activeMobileSubsidebarId.value = id
isMobileSidebarOpen.value = true
}
}
// provide context to children
const context: NavbarLayoutContext = {
links: computed(() => props.links),
theme: computed(() => props.theme),
isMobileSidebarOpen,
activeMobileSubsidebarId,
activeSubnavId,
activeSubnav,
activeMobileSubsidebar,
toggleSubnav,
toggleMobileSubnav,
}
provide(injectionKey, context)
// using reactive context for slots, has better dev experience
const contextRx = reactive(context)
watch(
() => route.fullPath,
() => {
activeSubnavId.value = undefined
isMobileSidebarOpen.value = false
},
)
watch(() => Boolean(activeSubnav.value?.type === 'megamenu' || isMobileSidebarOpen.value), (value) => {
if (value) {
document.documentElement.classList.add('no-scroll')
}
else {
document.documentElement.classList.remove('no-scroll')
}
})
</script>
<template>
<div class="navbar-layout">
<!-- Mobile navigation -->
<MobileNavbar v-model="isMobileSidebarOpen">
<template #logo>
<slot name="logo" v-bind="contextRx" />
<div class="brand-end">
<slot name="toolbar-mobile" v-bind="contextRx" />
</div>
</template>
</MobileNavbar>
<MobileSidebar
:class="[isMobileSidebarOpen && 'is-active']"
>
<template #links>
<slot name="navbar-links-mobile" v-bind="contextRx">
<NavbarItemMobile
v-for="link in props.links"
:key="link.label"
:link
/>
</slot>
</template>
</MobileSidebar>
<Transition name="fade">
<MobileOverlay
v-if="isMobileSidebarOpen"
@click="isMobileSidebarOpen = false"
/>
</Transition>
<Transition name="slide-x">
<KeepAlive>
<NavbarSubsidebarMobile
v-if="isMobileSidebarOpen && activeMobileSubsidebar?.children"
:key="activeMobileSubsidebarId"
:label="activeMobileSubsidebar.label"
:items="activeMobileSubsidebar.children"
/>
</KeepAlive>
</Transition>
<!-- /Mobile navigation -->
<!-- Desktop navigation -->
<Navbar>
<template #title>
<slot name="logo" v-bind="contextRx" />
<div v-if="'logo' in $slots" class="separator" />
<!-- <slot name="navbar-title" v-bind="contextRx">-->
<!-- <h1 class="title is-5">-->
<!-- {{ pageTitle }}-->
<!-- </h1>-->
<!-- </slot>-->
</template>
<template #toolbar>
<div class="toolbar desktop-toolbar">
<slot name="toolbar" v-bind="contextRx" />
</div>
</template>
<template #links>
<slot name="navbar-links" v-bind="contextRx">
<div class="left-links">
<NavbarItem
v-for="link in props.links"
:key="link.label"
:link="link"
/>
</div>
</slot>
</template>
</Navbar>
<div
class="navbar-subnavbar is-hidden-mobile"
:class="[activeSubnav?.type === 'megamenu' && 'is-active']"
>
<NavbarMegamenu
v-if="activeSubnav?.type === 'megamenu'"
:key="activeSubnavId"
:children="activeSubnav.children"
class="is-active"
>
<template v-if="'megamenu-start' in $slots" #start>
<slot name="megamenu-start" v-bind="contextRx" />
</template>
<template v-if="'megamenu-end' in $slots" #end>
<slot name="megamenu-end" v-bind="contextRx" />
</template>
<template v-if="'megamenu-top' in $slots" #top>
<slot name="megamenu-top" v-bind="contextRx" />
</template>
<template v-if="'megamenu-bottom' in $slots" #bottom>
<slot name="megamenu-bottom" v-bind="contextRx" />
</template>
</NavbarMegamenu>
</div>
<!-- /Desktop navigation -->
<ViewWrapper full top-nav>
<template v-if="props.size === 'full'">
<div class="is-navbar-md">
<slot name="page-heading" v-bind="contextRx">
<NavbarPageTitleMobile>
<slot name="navbar-title-mobile" v-bind="contextRx">
<h1 class="title is-4">
{{ pageTitle }}
</h1>
</slot>
<template #toolbar>
<slot name="toolbar" v-bind="contextRx" />
</template>
</NavbarPageTitleMobile>
</slot>
<slot v-bind="contextRx" />
</div>
</template>
<PageContentWrapper v-else :size="props.size">
<PageContent class="is-relative">
<div class="is-navbar-md">
<slot name="page-heading" v-bind="contextRx">
<NavbarPageTitleMobile>
<slot name="navbar-title-mobile" v-bind="contextRx">
<h1 class="title is-4">
{{ pageTitle }}
</h1>
</slot>
<template #toolbar>
<slot name="toolbar" v-bind="contextRx" />
</template>
</NavbarPageTitleMobile>
</slot>
<slot v-bind="contextRx" />
</div>
</PageContent>
</PageContentWrapper>
</ViewWrapper>
<slot name="extra" v-bind="contextRx" />
</div>
</template>
<style lang="scss">
@import '/@src/scss/abstracts/all';
@import '/@src/scss/layout/navbar';
</style>

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import type { NavbarMegamenu } from './navbar.types'
const props = withDefaults(defineProps<{
children: NavbarMegamenu[]
}>(), {
children: () => [],
})
</script>
<template>
<div class="navbar-subnavbar-inner">
<div v-if="'start' in $slots" class="menu-grid-start">
<slot name="start" />
</div>
<div class="menu-grid-wrapper">
<div v-if="'top' in $slots" class="menu-grid-top">
<slot name="top" />
</div>
<div class="menu-grid-container has-slimscroll">
<div class="menu-grid">
<div
v-for="group in props.children"
:key="group.id"
class="menu-block"
>
<h4 class="block-heading">
<VIcon
:icon="group.icon"
/>
<span>{{ group.label }}</span>
</h4>
<ul class="block-links">
<li v-for="link in group.children" :key="link.to">
<VLink :to="link.to">
<span>{{ link.label }}</span>
<VTag
v-if="link.tag"
color="primary"
size="tiny"
outlined
>
{{ link.tag }}
</VTag>
</VLink>
</li>
</ul>
</div>
</div>
</div>
<div v-if="'bottom' in $slots" class="menu-grid-bottom">
<slot name="bottom" />
</div>
</div>
<div v-if="'end' in $slots" class="menu-grid-end">
<slot name="end" />
</div>
</div>
</template>
<style lang="scss">
.navbar-subnavbar-inner {
justify-content: space-between;
&.is-active {
display: flex !important;
}
}
.menu-grid-start,
.menu-grid-end {
min-width: 260px;
}
// .menu-grid-wrapper-wrapper {
// border-bottom: 4px solid green;
// display: flex;
// flex-direction: column;
// flex-grow: 2;
// // flex: 1 1 0;
// > div {
// width: 100%;
// }
// }
.menu-grid-top,
.menu-grid-bottom {
width: 100%;
}
.menu-grid-wrapper {
display: flex;
flex-direction: column;
// flex-wrap: wrap;
width: 100%;
height: 100%;
padding: 0 1.5rem;
}
.menu-grid-container {
height: 100%;
}
.menu-grid {
width: 100%;
max-height: 100%;
display: flex;
// flex-direction: column;
flex-wrap: wrap;
gap: 0 4rem;
padding: 1.5rem 0;
// display: grid;
// grid-template-rows: 1fr 1fr 1fr;
// grid-auto-flow: column dense;
&.is-horizontal {
flex-direction: row;
gap: 1rem 8rem;
}
}
.menu-block {
min-width: 170px;
padding-bottom: 2rem;
.block-heading {
font-family: var(--font-alt);
font-size: 0.8rem;
font-weight: 600;
color: var(--dark-text);
text-transform: uppercase;
margin-bottom: 12px;
display: flex;
align-items: top;
gap: 0.5rem;
.iconify {
font-size: 18px;
opacity: 0.3;
}
}
.block-links {
li {
padding-inline-start: 26px;
transition:
color 0.3s,
background-color 0.3s,
border-color 0.3s,
height 0.3s,
width 0.3s;
margin-bottom: 6px;
&:hover,
&:focus {
a {
color: var(--primary);
}
}
&.is-active {
border-radius: 0;
a {
color: color-mix(in oklab, var(--primary), black 14%);
.iconify {
opacity: 1;
fill: var(--primary);
}
}
}
.router-link-exact-active {
color: color-mix(in oklab, var(--primary), black 14%);
.iconify {
opacity: 1;
fill: var(--primary);
}
}
a {
display: flex;
align-items: center;
color: color-mix(in oklab, var(--light-text), white 5%);
gap: 0.225rem;
span {
font-family: var(--font);
font-size: 0.9rem;
}
.lnil,
.lnir,
.fas,
.fal,
.fab,
.far {
margin-inline-end: 10px;
}
.tag {
line-height: 1.6;
height: 1.7em;
font-size: 0.65rem;
margin-inline-start: 0.25rem;
}
.iconify {
opacity: 0;
position: relative;
top: 0;
margin-inline-start: 12px;
height: 6px;
width: 6px;
stroke-width: 2px;
fill: var(--primary);
transition:
color 0.3s,
background-color 0.3s,
border-color 0.3s,
height 0.3s,
width 0.3s;
}
}
&:hover {
a {
opacity: 1;
color: var(--primary);
}
}
}
}
}
.is-dark .navbar-subnavbar-inner {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
}
</style>

View File

@@ -0,0 +1,11 @@
<template>
<div class="page-title has-text-centered">
<div class="title-wrap">
<slot />
</div>
<div class="toolbar mobile-toolbar">
<slot name="toolbar" />
</div>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { NavbarDropdown, NavbarMegamenu } from './navbar.types'
const props = defineProps<{
label?: string
items: (NavbarDropdown | NavbarMegamenu)[]
}>()
</script>
<template>
<div class="mobile-subsidebar">
<div class="inner">
<div class="sidebar-title">
<slot>
<h3>{{ props.label }}</h3>
</slot>
</div>
<ul
class="submenu has-slimscroll"
>
<template v-for="item of props.items">
<VCollapseLinks
v-if="'children' in item"
:key="item.id"
:links="item.children"
>
{{ item.label }}
</VCollapseLinks>
<li v-else-if="'to' in item" :key="item.label">
<VLink :to="item.to">
{{ item.label }}
</VLink>
</li>
</template>
</ul>
</div>
</div>
</template>
<style lang="scss">
@import '/@src/scss/layout/mobile-subsidebar';
</style>

View File

@@ -0,0 +1,14 @@
import type { InjectionKey } from 'vue'
import type { NavbarLayoutContext } from './navbar.types'
export const injectionKey = Symbol('navbar-layout') as InjectionKey<NavbarLayoutContext>
export function useNavbarLayoutContext() {
const context = inject(injectionKey)
if (!context) {
throw new Error('useNavbarLayoutContext() is called outside of <NavbarLayout> tree.')
}
return context
}

View File

@@ -0,0 +1,73 @@
export type NavbarTheme = 'default' | 'colored' | 'fade'
// -- Item
export interface NavbarItemMegamenu {
type: 'megamenu'
label: string
id: string
icon: string
children: NavbarMegamenu[]
}
export interface NavbarItemDropdown {
type: 'dropdown'
label: string
id: string
icon: string
children: NavbarDropdown[]
}
export interface NavbarItemAction {
type: 'action'
label: string
icon: string
onClick: (event: Event) => void
}
export interface NavbarItemLink {
type: 'link'
label: string
icon: string
to: string
}
export type NavbarItem =
| NavbarItemMegamenu
| NavbarItemDropdown
| NavbarItemAction
| NavbarItemLink
// -- Item Megamenu
export interface NavbarMegamenu {
id: string
label: string
icon: string
children: NavbarMegamenuLink[]
}
export interface NavbarMegamenuLink {
label: string
to: string
tag?: string | number
}
// -- Item Dropdown
export interface NavbarDropdown {
label: string
to: string
icon: string
}
// -- Context
export interface NavbarLayoutContext {
theme: ComputedRef<NavbarTheme>
links: ComputedRef<NavbarItem[]>
isMobileSidebarOpen: Ref<boolean>
activeMobileSubsidebarId: Ref<string>
activeSubnavId: Ref<string | undefined>
activeSubnav: ComputedRef<NavbarItem | undefined>
activeMobileSubsidebar: ComputedRef<NavbarItem | undefined>
toggleSubnav: (id: string) => void
toggleMobileSubnav: (id: string) => void
}

View File

@@ -0,0 +1,338 @@
<template>
<div
class="navbar-navbar-clean"
>
<div class="navbar-navbar-inner">
<div class="left">
<slot name="title" />
</div>
<div class="center">
<slot name="search" />
</div>
<div class="right">
<slot name="toolbar" />
</div>
</div>
<div
class="navbar-navbar-lower"
:class="[
'subtitle' in $slots && 'is-between',
!('subtitle' in $slots) && 'is-centered',
]"
>
<div
v-if="'subtitle' in $slots"
class="left"
>
<slot name="subtitle" />
</div>
<div
:class="[
!('subtitle' in $slots) && 'left',
'subtitle' in $slots && 'center',
]"
>
<slot name="links" />
</div>
<div
v-if="'toolbar-bottom' in $slots"
class="right"
>
<slot name="toolbar-bottom" />
</div>
</div>
</div>
</template>
<style lang="scss">
.navbar-navbar-clean {
position: fixed;
top: 0;
inset-inline-start: 0;
width: 100%;
background: var(--white);
z-index: 15;
transition: all 0.3s; // transition-all test
&.is-transparent {
background: transparent;
box-shadow: none;
border-bottom-color: transparent;
&.is-solid,
&.is-scrolled {
background: var(--white);
border-bottom-color: var(--fade-grey);
}
&.is-solid {
box-shadow: none !important;
}
&.is-scrolled {
box-shadow: 0 0 8px 0 rgb(0 0 0 / 12%);
}
&:not(.is-scrolled) {
.navbar-navbar-lower {
&.is-between,
&.is-centered {
.left,
.center {
.button:not(:hover) {
background: transparent;
border-color: transparent;
}
.button:hover {
background: var(--white);
border-color: var(--white);
}
}
}
}
}
}
.navbar-navbar-inner {
display: flex;
height: 50px;
padding: 0 20px;
.left {
display: flex;
align-items: center;
width: 25%;
.brand {
display: flex;
align-items: center;
img {
display: block;
min-width: 38px;
height: 38px;
}
span {
font-family: var(--font);
font-size: 0.95rem;
color: var(--muted-grey);
letter-spacing: 1px;
max-width: 50px;
line-height: 1.2;
margin-inline-start: 8px;
}
}
.separator {
height: 38px;
width: 2px;
border-inline-end: 1px solid color-mix(in oklab, var(--fade-grey), black 4%);
margin: 0 20px 0 16px;
}
}
.center {
display: flex;
align-items: center;
flex-grow: 2;
}
.right {
display: flex;
align-items: center;
justify-content: flex-end;
width: 25%;
margin-inline-start: auto;
.icon-link {
display: flex;
justify-content: center;
align-items: center;
height: 34px;
width: 34px;
border-radius: var(--radius-rounded);
margin: 0 4px;
transition: all 0.3s; // transition-all test
&:hover {
background: var(--white);
border-color: var(--fade-grey);
box-shadow: var(--light-box-shadow);
}
.iconify {
height: 18px;
width: 18px;
font-size: 18px;
stroke-width: 1.6px;
color: var(--light-text);
transition: stroke 0.3s;
vertical-align: 0;
transform: none;
}
}
}
}
.navbar-navbar-lower {
display: flex;
align-items: center;
height: 50px;
padding: 0 20px;
&.is-between,
&.is-centered {
justify-content: space-between;
.left,
.right, {
display: flex;
align-items: center;
font-size: 0.9rem;
font-weight: 500;
font-family: var(--font);
color: var(--light-text);
}
.left,
.center {
display: flex;
align-items: center;
.button {
font-size: 0.9rem;
font-weight: 500;
border-radius: 0.5rem;
border: none;
color: var(--light-text);
&:hover,
&:focus,
&.router-link-exact-active {
background: color-mix(in oklab, var(--widget-grey), black 2%);
color: var(--dark-text);
box-shadow: none;
}
}
}
.right {
display: flex;
align-items: center;
justify-content: flex-end;
.avatar-stack {
margin-inline-end: 1rem;
}
.dropdown {
.button {
&.is-circle {
min-width: 35px;
}
}
}
}
}
&.is-centered {
.left,
.right {
width: 25%;
}
.center {
justify-content: center;
flex-grow: 2;
}
}
}
}
.is-dark {
.navbar-navbar-clean {
&:not(.is-colored) {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
&.is-transparent {
background: transparent;
box-shadow: none;
border-bottom-color: transparent;
&.is-solid,
&.is-scrolled {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
}
&:not(.is-scrolled) {
.navbar-navbar-lower {
&.is-between,
&.is-centered {
.left,
.center {
.button:not(:hover) {
background: transparent !important;
border-color: transparent !important;
}
.button:hover {
background: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
}
}
}
}
}
}
}
.navbar-navbar-inner {
.left {
.separator {
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
.right {
.icon-link {
background: var(--landing-yyy);
&:hover,
&:focus {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
}
> .iconify {
color: var(--smoke-white);
stroke: var(--smoke-white);
}
}
}
}
.navbar-navbar-lower {
&.is-between,
&.is-centered {
.left,
.center {
.button {
background: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
&:hover,
&:focus {
background: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
color: var(--white) !important;
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts" generic="T">
const props = withDefaults(defineProps<{
suggestions?: T[]
}>(), {
suggestions: () => [],
})
const emits = defineEmits<{
select: [item: T]
}>()
const modelValue = defineModel<string>()
</script>
<template>
<div class="centered-search">
<div class="field">
<div class="control has-icon">
<input
v-model="modelValue"
type="text"
class="input search-input"
placeholder="Search records..."
>
<div class="form-icon">
<VIcon
icon="lucide:search"
/>
</div>
<div
v-if="modelValue"
class="form-icon is-right"
tabindex="0"
role="button"
@keydown.enter.prevent="modelValue = ''"
@click="modelValue = ''"
>
<VIcon
icon="lucide:x"
/>
</div>
<div
v-if="props.suggestions.length"
class="search-results has-slimscroll is-active"
>
<a
v-for="(item, key) in props.suggestions"
:key="key"
role="button"
tabindex="0"
class="search-result"
@click="() => emits('select', item)"
@keydown.enter.prevent="() => emits('select', item)"
>
<slot v-bind="{ item }" />
</a>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.centered-search {
width: 100%;
.field {
margin-bottom: 0;
.control {
.input {
border-radius: 0.5rem;
}
.form-icon {
&.is-right {
inset-inline-start: unset !important;
inset-inline-end: 6px;
cursor: pointer;
}
}
.search-results {
top: 48px;
}
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More