refactor: 重构UI组件库
This commit is contained in:
17
src/ui/avatar/index.vue
Normal file
17
src/ui/avatar/index.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang='ts' setup></script>
|
||||
|
||||
<template>
|
||||
<ion-avatar v-bind="$attrs">
|
||||
<img
|
||||
src="https://api.iconify.design/material-icon-theme:remix-light.svg"
|
||||
alt="Avatar"
|
||||
>
|
||||
</ion-avatar>
|
||||
</template>
|
||||
|
||||
<style lang='css' scoped>
|
||||
ion-avatar {
|
||||
background-color: var(--ion-color-success);
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
2
src/ui/collapse/exports.ts
Normal file
2
src/ui/collapse/exports.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Collapse } from "./index.vue";
|
||||
export type * from "./types";
|
||||
138
src/ui/collapse/index.vue
Normal file
138
src/ui/collapse/index.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang='ts' setup>
|
||||
import type { CollapseEmits, CollapseProps } from "./types";
|
||||
import { IonIcon } from "@ionic/vue";
|
||||
import { chevronDownOutline } from "ionicons/icons";
|
||||
import { nextTick, ref } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<CollapseProps>(), {
|
||||
disabled: false,
|
||||
bordered: true,
|
||||
size: "medium",
|
||||
});
|
||||
|
||||
defineEmits<CollapseEmits>();
|
||||
|
||||
const active = defineModel<boolean>("active", { type: Boolean, default: true });
|
||||
|
||||
const contentRef = ref<HTMLElement>();
|
||||
|
||||
async function toggle() {
|
||||
if (props.disabled)
|
||||
return;
|
||||
|
||||
if (!active.value) {
|
||||
// 展开
|
||||
active.value = true;
|
||||
await nextTick();
|
||||
|
||||
if (contentRef.value) {
|
||||
const height = contentRef.value.scrollHeight;
|
||||
contentRef.value.style.height = "0px";
|
||||
requestAnimationFrame(() => {
|
||||
contentRef.value!.style.height = `${height}px`;
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 收起
|
||||
if (contentRef.value) {
|
||||
const height = contentRef.value.scrollHeight;
|
||||
contentRef.value.style.height = `${height}px`;
|
||||
requestAnimationFrame(() => {
|
||||
contentRef.value!.style.height = "0px";
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
active.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (contentRef.value && active.value) {
|
||||
contentRef.value.style.height = "auto";
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function onTransitionEnd() {
|
||||
if (contentRef.value && !active.value) {
|
||||
contentRef.value.style.height = "0px";
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法和属性
|
||||
defineExpose({
|
||||
toggle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full overflow-hidden"
|
||||
:class="{
|
||||
'border border-gray-200 dark:border-gray-600 rounded-lg': bordered,
|
||||
'opacity-60': disabled,
|
||||
}"
|
||||
>
|
||||
<!-- 头部触发区域 -->
|
||||
<div
|
||||
class="flex items-center justify-between cursor-pointer transition-colors duration-200 select-none"
|
||||
:class="[
|
||||
{
|
||||
'border-b border-gray-200 dark:border-gray-600': bordered,
|
||||
},
|
||||
{
|
||||
'p-3 text-sm': size === 'small',
|
||||
'p-4 text-base': size === 'medium',
|
||||
'p-5 text-lg': size === 'large',
|
||||
},
|
||||
{
|
||||
'cursor-not-allowed': disabled,
|
||||
},
|
||||
]"
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="font-medium text-gray-900 dark:text-white flex-1">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<IonIcon
|
||||
:icon="chevronDownOutline"
|
||||
class="text-gray-500 dark:text-gray-400 transition-transform duration-300"
|
||||
:class="[
|
||||
{
|
||||
'w-4 h-4': size === 'small',
|
||||
'w-5 h-5': size === 'medium',
|
||||
'w-6 h-6': size === 'large',
|
||||
},
|
||||
{
|
||||
'rotate-180': active,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="overflow-hidden transition-all duration-300 ease-in-out"
|
||||
:style="{ height: active ? 'auto' : '0' }"
|
||||
@transitionend="onTransitionEnd"
|
||||
>
|
||||
<div
|
||||
class="p-4 text-gray-600 dark:text-gray-300 space-y-5"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='css' scoped>
|
||||
.select-none {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
29
src/ui/collapse/types.ts
Normal file
29
src/ui/collapse/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface CollapseProps {
|
||||
/** 面板标题 */
|
||||
title?: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 是否显示边框 */
|
||||
bordered?: boolean;
|
||||
/** 尺寸大小 */
|
||||
size?: "small" | "medium" | "large";
|
||||
/** 是否展开 (v-model:active) */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface CollapseEmits {
|
||||
/** 展开状态变化时触发 */
|
||||
"update:active": [value: boolean];
|
||||
}
|
||||
|
||||
export interface CollapseSlots {
|
||||
/** 默认插槽 - 面板内容 */
|
||||
default: () => any;
|
||||
/** 标题插槽 - 自定义标题内容 */
|
||||
title: () => any;
|
||||
}
|
||||
|
||||
export interface CollapseInstance {
|
||||
/** 切换展开/收起状态 */
|
||||
toggle: () => void;
|
||||
}
|
||||
55
src/ui/datetime/index.vue
Normal file
55
src/ui/datetime/index.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang='ts' setup>
|
||||
import type { FieldBindingObject } from "vee-validate";
|
||||
|
||||
interface Props extends FieldBindingObject {
|
||||
datetime: string;
|
||||
label?: string;
|
||||
formatterValue?: (value: string) => string;
|
||||
format?: (value: string) => string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(e: "update:value", value: string): void;
|
||||
}>();
|
||||
const model = defineModel({ type: String, required: true });
|
||||
function handleChange(value: string) {
|
||||
const formattedValue = props.formatterValue ? props.formatterValue(value) : new Date(value).toISOString();
|
||||
model.value = formattedValue;
|
||||
props.onChange(formattedValue);
|
||||
emit("update:value", formattedValue);
|
||||
}
|
||||
|
||||
function formatDisplay(value: string) {
|
||||
return props.format ? props.format(value) : useDateFormat(value, "YYYY/MM/DD").value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start">
|
||||
<ion-label class="text-sm font-bold color-(--ion-text-color-secondary) mb-3.5">
|
||||
{{ props.label }}
|
||||
</ion-label>
|
||||
<ion-datetime-button :datetime="datetime" color="primary">
|
||||
<div slot="date-target">
|
||||
{{ formatDisplay(props.value) }}
|
||||
</div>
|
||||
</ion-datetime-button>
|
||||
<ion-modal :keep-contents-mounted="true">
|
||||
<ion-datetime
|
||||
:id="datetime"
|
||||
class="ui-datetime"
|
||||
done-text="Done"
|
||||
presentation="date-time"
|
||||
:show-default-buttons="true"
|
||||
:min="props.min"
|
||||
:max="props.max"
|
||||
@ion-change="handleChange($event.detail.value as string)"
|
||||
/>
|
||||
</ion-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='css' scoped></style>
|
||||
31
src/ui/divider/index.vue
Normal file
31
src/ui/divider/index.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang='ts' setup>
|
||||
defineProps<{ text?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="divider ion-margin-vertical" v-bind="$attrs">
|
||||
<span>{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--ion-color-medium);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 10px;
|
||||
color: var(--ion-color-medium);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
248
src/ui/file-upload/index.vue
Normal file
248
src/ui/file-upload/index.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script lang='ts' setup>
|
||||
import { documentAttachOutline, trashOutline } from "ionicons/icons";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // MB
|
||||
maxFiles?: number;
|
||||
}>(), {
|
||||
modelValue: () => [],
|
||||
accept: "*/*",
|
||||
multiple: true,
|
||||
maxSize: 10,
|
||||
maxFiles: 5,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const files = ref<FileItem[]>([]);
|
||||
|
||||
// 初始化已有文件
|
||||
watchEffect(() => {
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
files.value = props.modelValue.map(url => ({
|
||||
name: url.split("/").pop() || "file",
|
||||
size: 0,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selectedFiles = Array.from(input.files || []);
|
||||
|
||||
if (!selectedFiles.length)
|
||||
return;
|
||||
|
||||
// 检查文件数量限制
|
||||
if (files.value.length + selectedFiles.length > props.maxFiles) {
|
||||
// TODO: 显示错误提示
|
||||
console.warn(`最多只能上传 ${props.maxFiles} 个文件`);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFiles.forEach((file) => {
|
||||
// 检查文件大小
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
console.warn(`文件 ${file.name} 超过 ${props.maxSize}MB 限制`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建预览 URL
|
||||
const url = URL.createObjectURL(file);
|
||||
files.value.push({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
url,
|
||||
});
|
||||
});
|
||||
|
||||
// 更新值
|
||||
updateValue();
|
||||
|
||||
// 重置 input
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
const file = files.value[index];
|
||||
if (file.url && file.url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(file.url);
|
||||
}
|
||||
files.value.splice(index, 1);
|
||||
updateValue();
|
||||
}
|
||||
|
||||
function updateValue() {
|
||||
const urls = files.value.map(f => f.url || "").filter(Boolean);
|
||||
emit("update:modelValue", urls);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0)
|
||||
return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<div v-if="label" class="file-upload-label">
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
class="file-input-hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<ion-button
|
||||
v-if="files.length < maxFiles"
|
||||
expand="block"
|
||||
fill="outline"
|
||||
class="upload-button"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<ion-icon slot="start" :icon="documentAttachOutline" />
|
||||
{{ placeholder || t('common.uploadFile') }}
|
||||
</ion-button>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="files.length > 0" class="file-list">
|
||||
<div v-for="(file, index) in files" :key="index" class="file-item">
|
||||
<div class="file-info">
|
||||
<ion-icon :icon="documentAttachOutline" class="file-icon" />
|
||||
<div class="file-details">
|
||||
<div class="file-name">
|
||||
{{ file.name }}
|
||||
</div>
|
||||
<div v-if="file.size" class="file-size">
|
||||
{{ formatFileSize(file.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" color="danger" @click="removeFile(index)">
|
||||
<ion-icon slot="icon-only" :icon="trashOutline" />
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="files.length > 0" class="file-count">
|
||||
{{ files.length }} / {{ maxFiles }} {{ t('common.files') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-upload-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ion-text-color);
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
--border-style: dashed;
|
||||
--border-width: 2px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--ion-color-light-shade);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 24px;
|
||||
color: var(--ion-color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ion-text-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
color: var(--ion-color-medium);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.file-count {
|
||||
font-size: 12px;
|
||||
color: var(--ion-color-medium);
|
||||
text-align: right;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
34
src/ui/index.ts
Normal file
34
src/ui/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { App, Component } from "vue";
|
||||
|
||||
const modules: Record<string, { default: Component }> = import.meta.glob("./*/*.vue", { eager: true });
|
||||
|
||||
function setupUiGlobalComponents(app: App) {
|
||||
for (const path in modules) {
|
||||
const parts = path.replace(/^\.\//, "").replace(/\.vue$/, "").split("/");
|
||||
const name = parts[parts.length - 1] === "index" ? parts[parts.length - 2] : parts[parts.length - 1];
|
||||
const componentName = `Ui${name.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join("")}`;
|
||||
app.component(componentName, modules[path].default);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
install: setupUiGlobalComponents,
|
||||
};
|
||||
|
||||
// Global component types
|
||||
declare module "vue" {
|
||||
export interface GlobalComponents {
|
||||
UiAvatar: typeof import("./avatar/index.vue")["default"];
|
||||
UiCollapse: typeof import("./collapse/index.vue")["default"];
|
||||
UiDatetime: typeof import("./datetime/index.vue")["default"];
|
||||
UiDivider: typeof import("./divider/index.vue")["default"];
|
||||
UiFileUpload: typeof import("./file-upload/index.vue")["default"];
|
||||
UiInput: typeof import("./input/index.vue")["default"];
|
||||
UiInputLabel: typeof import("./input-label/index.vue")["default"];
|
||||
UiResult: typeof import("./result/index.vue")["default"];
|
||||
UiTabPane: typeof import("./tabs/tab-pane.vue")["default"];
|
||||
UiTabs: typeof import("./tabs/index.vue")["default"];
|
||||
UiTag: typeof import("./tag/index.vue")["default"];
|
||||
UiTextareaLabel: typeof import("./textarea-label/index.vue")["default"];
|
||||
}
|
||||
}
|
||||
34
src/ui/input-label/index.vue
Normal file
34
src/ui/input-label/index.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang='ts' setup>
|
||||
import type { ComponentInstance } from "vue";
|
||||
import UiInput from "@/ui/input/index.vue";
|
||||
|
||||
defineProps<{
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const vm = getCurrentInstance()!;
|
||||
|
||||
function changeRef(exposed: any) {
|
||||
vm.exposed = exposed;
|
||||
}
|
||||
|
||||
defineExpose({} as ComponentInstance<typeof UiInput>);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="label" class="label">
|
||||
{{ label }}
|
||||
</div>
|
||||
<component :is="h(UiInput, { ...$attrs, ref: changeRef })" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 14px;
|
||||
color: var(--ion-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
30
src/ui/input/index.vue
Normal file
30
src/ui/input/index.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang='ts' setup>
|
||||
import type { ComponentInstance } from "vue";
|
||||
import { IonInput } from "@ionic/vue";
|
||||
import { h } from "vue";
|
||||
|
||||
const vm = getCurrentInstance()!;
|
||||
|
||||
function changeRef(exposed: any) {
|
||||
vm.exposed = exposed;
|
||||
}
|
||||
|
||||
defineExpose({} as ComponentInstance<typeof IonInput>);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="h(IonInput, { ...$attrs, ref: changeRef }, { default: () => { return $slots.default ? $slots.default() : null } })" class="ui-input" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-input {
|
||||
--padding-start: 12px;
|
||||
--padding-end: 12px;
|
||||
--background: var(--ui-input-background, #fff);
|
||||
--color: var(--ui-input-color, #000);
|
||||
--border-radius: 8px;
|
||||
}
|
||||
.ui-input + .ui-input {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
22
src/ui/result/index.vue
Normal file
22
src/ui/result/index.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang='ts' setup>
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-center space-y-3">
|
||||
<ion-icon :icon="icon" class="text-5xl text-lime-500" />
|
||||
<div class="text-xl font-semibold">
|
||||
{{ title }}
|
||||
</div>
|
||||
<p class="text-center text-text-400 max-w-xs">
|
||||
{{ description }}
|
||||
</p>
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='css' scoped></style>
|
||||
624
src/ui/tabs/index.vue
Normal file
624
src/ui/tabs/index.vue
Normal file
@@ -0,0 +1,624 @@
|
||||
<script lang='ts' setup>
|
||||
interface TabsProps {
|
||||
modelValue?: string | number;
|
||||
type?: "bar" | "line" | "segment";
|
||||
size?: "small" | "medium" | "large";
|
||||
animated?: boolean;
|
||||
closable?: boolean;
|
||||
addable?: boolean;
|
||||
placement?: "top" | "bottom" | "left" | "right";
|
||||
sticky?: boolean;
|
||||
stickyTop?: string | number;
|
||||
tabStyle?: string | Record<string, any>;
|
||||
tabClass?: string;
|
||||
paneStyle?: string | Record<string, any>;
|
||||
paneClass?: string;
|
||||
}
|
||||
|
||||
interface TabsEmits {
|
||||
(e: "update:modelValue", value: string | number): void;
|
||||
(e: "tabClick", key: string | number, event: Event): void;
|
||||
(e: "tabClose", key: string | number): void;
|
||||
(e: "tabAdd"): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TabsProps>(), {
|
||||
type: "bar",
|
||||
size: "medium",
|
||||
animated: true,
|
||||
closable: false,
|
||||
addable: false,
|
||||
placement: "top",
|
||||
sticky: false,
|
||||
stickyTop: 0,
|
||||
});
|
||||
|
||||
const emit = defineEmits<TabsEmits>();
|
||||
|
||||
// 内部状态
|
||||
const currentTab = ref<string | number>();
|
||||
const tabsRef = ref<HTMLElement>();
|
||||
const activeBarRef = ref<HTMLElement>();
|
||||
|
||||
// 子组件列表
|
||||
const tabPanes = ref<Array<{
|
||||
name: string | number;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
closable?: boolean;
|
||||
instance: any;
|
||||
}>>([]);
|
||||
|
||||
// 使用provide/inject在父子组件间传递数据
|
||||
const tabsContext = {
|
||||
currentTab: readonly(currentTab),
|
||||
type: toRef(props, "type"),
|
||||
size: toRef(props, "size"),
|
||||
closable: toRef(props, "closable"),
|
||||
animated: toRef(props, "animated"),
|
||||
placement: toRef(props, "placement"),
|
||||
tabStyle: toRef(props, "tabStyle"),
|
||||
tabClass: toRef(props, "tabClass"),
|
||||
paneStyle: toRef(props, "paneStyle"),
|
||||
paneClass: toRef(props, "paneClass"),
|
||||
activateTab,
|
||||
closeTab,
|
||||
registerTabPane,
|
||||
unregisterTabPane,
|
||||
};
|
||||
|
||||
provide("tabs-context", tabsContext);
|
||||
|
||||
// 注册tab pane
|
||||
function registerTabPane(pane: any) {
|
||||
const existingIndex = tabPanes.value.findIndex(p => p.name === pane.name);
|
||||
if (existingIndex === -1) {
|
||||
tabPanes.value.push(pane);
|
||||
// 如果当前没有激活的tab且这是第一个可用的tab,则激活它
|
||||
if (!currentTab.value && !pane.disabled) {
|
||||
currentTab.value = pane.name;
|
||||
emit("update:modelValue", pane.name);
|
||||
// 延迟更新active bar,确保DOM完全渲染
|
||||
setTimeout(() => {
|
||||
updateActiveBar();
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterTabPane(pane: any) {
|
||||
const index = tabPanes.value.findIndex(p => p.name === pane.name);
|
||||
if (index > -1) {
|
||||
tabPanes.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 激活tab
|
||||
function activateTab(key: string | number, event?: Event) {
|
||||
const pane = tabPanes.value.find(p => p.name === key);
|
||||
if (!pane || pane.disabled)
|
||||
return;
|
||||
|
||||
if (currentTab.value === key)
|
||||
return;
|
||||
|
||||
currentTab.value = key;
|
||||
emit("update:modelValue", key);
|
||||
|
||||
if (event) {
|
||||
emit("tabClick", key, event);
|
||||
}
|
||||
|
||||
// 更新active bar位置
|
||||
nextTick(updateActiveBar);
|
||||
}
|
||||
|
||||
// 关闭tab
|
||||
function closeTab(key: string | number) {
|
||||
emit("tabClose", key);
|
||||
}
|
||||
|
||||
// 添加tab
|
||||
function addTab() {
|
||||
emit("tabAdd");
|
||||
}
|
||||
|
||||
// 更新active bar位置(用于bar和line类型)
|
||||
function updateActiveBar() {
|
||||
if (props.type !== "bar" && props.type !== "line")
|
||||
return;
|
||||
if (!tabsRef.value || !activeBarRef.value)
|
||||
return;
|
||||
|
||||
const activeTabEl = tabsRef.value.querySelector(`[data-tab-key="${currentTab.value}"]`) as HTMLElement;
|
||||
if (!activeTabEl)
|
||||
return;
|
||||
|
||||
// 获取tab label元素来计算更精确的宽度
|
||||
const labelEl = activeTabEl.querySelector(".ui-tab__label") as HTMLElement;
|
||||
|
||||
if (labelEl) {
|
||||
// 创建一个临时元素来测量文本的实际宽度
|
||||
const tempEl = document.createElement("span");
|
||||
tempEl.style.visibility = "hidden";
|
||||
tempEl.style.position = "absolute";
|
||||
tempEl.style.fontSize = window.getComputedStyle(labelEl).fontSize;
|
||||
tempEl.style.fontFamily = window.getComputedStyle(labelEl).fontFamily;
|
||||
tempEl.style.fontWeight = window.getComputedStyle(labelEl).fontWeight;
|
||||
tempEl.textContent = labelEl.textContent;
|
||||
document.body.appendChild(tempEl);
|
||||
|
||||
const textWidth = tempEl.offsetWidth;
|
||||
document.body.removeChild(tempEl);
|
||||
|
||||
const tabsRect = tabsRef.value.getBoundingClientRect();
|
||||
const labelRect = labelEl.getBoundingClientRect();
|
||||
|
||||
if (props.placement === "top" || props.placement === "bottom") {
|
||||
// 居中对齐文本宽度
|
||||
const leftOffset = labelRect.left - tabsRect.left + (labelRect.width - textWidth) / 2;
|
||||
activeBarRef.value.style.left = `${leftOffset}px`;
|
||||
activeBarRef.value.style.width = `${textWidth}px`;
|
||||
}
|
||||
else {
|
||||
activeBarRef.value.style.top = `${labelRect.top - tabsRect.top}px`;
|
||||
activeBarRef.value.style.height = `${textWidth}px`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 降级到原来的逻辑
|
||||
const tabsRect = tabsRef.value.getBoundingClientRect();
|
||||
const activeRect = activeTabEl.getBoundingClientRect();
|
||||
|
||||
if (props.placement === "top" || props.placement === "bottom") {
|
||||
activeBarRef.value.style.left = `${activeRect.left - tabsRect.left}px`;
|
||||
activeBarRef.value.style.width = `${activeRect.width}px`;
|
||||
}
|
||||
else {
|
||||
activeBarRef.value.style.top = `${activeRect.top - tabsRect.top}px`;
|
||||
activeBarRef.value.style.height = `${activeRect.height}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue !== undefined && newValue !== currentTab.value) {
|
||||
currentTab.value = newValue;
|
||||
nextTick(updateActiveBar);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 监听tabPanes变化,自动设置第一个tab为活跃状态
|
||||
watch(tabPanes, (newPanes) => {
|
||||
if ((!currentTab.value || currentTab.value === "") && newPanes.length > 0) {
|
||||
const firstAvailablePane = newPanes.find(pane => !pane.disabled);
|
||||
if (firstAvailablePane) {
|
||||
currentTab.value = firstAvailablePane.name;
|
||||
emit("update:modelValue", firstAvailablePane.name);
|
||||
nextTick(updateActiveBar);
|
||||
}
|
||||
}
|
||||
}, { flush: "post" });
|
||||
|
||||
// 组件挂载后初始化
|
||||
onMounted(() => {
|
||||
// 如果没有当前激活的tab但有可用的tabs,选择第一个
|
||||
if (!currentTab.value && tabPanes.value.length > 0) {
|
||||
const firstAvailablePane = tabPanes.value.find(pane => !pane.disabled);
|
||||
if (firstAvailablePane) {
|
||||
currentTab.value = firstAvailablePane.name;
|
||||
emit("update:modelValue", firstAvailablePane.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟更新active bar,确保DOM完全渲染
|
||||
setTimeout(() => {
|
||||
updateActiveBar();
|
||||
});
|
||||
});
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
registerTabPane,
|
||||
unregisterTabPane,
|
||||
updateActiveBar,
|
||||
});
|
||||
|
||||
// 计算样式类
|
||||
const tabsClasses = computed(() => [
|
||||
"ui-tabs",
|
||||
`ui-tabs--${props.type}`,
|
||||
`ui-tabs--${props.size}`,
|
||||
`ui-tabs--${props.placement}`,
|
||||
{
|
||||
"ui-tabs--animated": props.animated,
|
||||
"ui-tabs--sticky": props.sticky,
|
||||
},
|
||||
]);
|
||||
|
||||
// 计算sticky样式
|
||||
const stickyStyle = computed(() => {
|
||||
if (!props.sticky)
|
||||
return {};
|
||||
|
||||
const topValue = typeof props.stickyTop === "number"
|
||||
? `${props.stickyTop}px`
|
||||
: props.stickyTop;
|
||||
|
||||
return {
|
||||
position: "sticky" as const,
|
||||
top: topValue,
|
||||
zIndex: 100,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="tabsClasses">
|
||||
<div
|
||||
class="ui-tabs__nav-wrapper"
|
||||
:class="[`ui-tabs__nav-wrapper--${placement}`]"
|
||||
:style="props.sticky ? stickyStyle : {}"
|
||||
>
|
||||
<div
|
||||
ref="tabsRef"
|
||||
class="ui-tabs__nav"
|
||||
:class="[`ui-tabs__nav--${type}`, `ui-tabs__nav--${size}`]"
|
||||
>
|
||||
<!-- 渲染标签列表 -->
|
||||
<div
|
||||
v-for="pane in tabPanes"
|
||||
:key="pane.name"
|
||||
class="ui-tab" :class="[
|
||||
`ui-tab--${type}`,
|
||||
`ui-tab--${size}`,
|
||||
{
|
||||
'ui-tab--active': currentTab === pane.name,
|
||||
'ui-tab--disabled': pane.disabled,
|
||||
'ui-tab--closable': pane.closable || closable,
|
||||
},
|
||||
]"
|
||||
:data-tab-key="pane.name"
|
||||
:style="tabStyle"
|
||||
@click="activateTab(pane.name, $event)"
|
||||
>
|
||||
<span class="ui-tab__label">
|
||||
{{ pane.title }}
|
||||
</span>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
v-if="(pane.closable || closable) && type !== 'segment'"
|
||||
class="ui-tab__close"
|
||||
@click.stop="closeTab(pane.name)"
|
||||
>
|
||||
<ion-icon name="close-outline" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Bar (用于bar和line类型) -->
|
||||
<div
|
||||
v-if="type === 'bar' || type === 'line'"
|
||||
ref="activeBarRef"
|
||||
class="ui-tabs__active-bar"
|
||||
:class="`ui-tabs__active-bar--${type}`"
|
||||
/>
|
||||
|
||||
<!-- Add Button -->
|
||||
<button
|
||||
v-if="addable"
|
||||
class="ui-tabs__add-btn"
|
||||
@click="addTab"
|
||||
>
|
||||
<ion-icon name="add-outline" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签内容区域 -->
|
||||
<div class="ui-tabs__content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
|
||||
.ui-tabs {
|
||||
@apply w-full;
|
||||
--ui-tabs-primary: var(--ion-color-primary);
|
||||
--ui-tabs-primary-rgb: var(--ion-color-primary-rgb);
|
||||
--ui-tabs-background: var(--ion-background-color);
|
||||
--ui-tabs-text: var(--ion-text-color);
|
||||
--ui-tabs-text-rgb: var(--ion-text-color-rgb);
|
||||
}
|
||||
|
||||
/* 导航包装器 */
|
||||
.ui-tabs__nav-wrapper {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
/* Sticky 布局 */
|
||||
.ui-tabs--sticky .ui-tabs__nav-wrapper {
|
||||
background-color: var(--ui-tabs-background);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.ui-tabs--sticky.ui-tabs--animated .ui-tabs__nav-wrapper {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.ui-tabs__nav-wrapper--bottom {
|
||||
border-top: 1px solid var(--ion-border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.ui-tabs__nav-wrapper--left {
|
||||
border-right: 1px solid var(--ion-border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.ui-tabs__nav-wrapper--right {
|
||||
border-left: 1px solid var(--ion-border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
/* 导航容器 */
|
||||
.ui-tabs__nav {
|
||||
@apply relative flex;
|
||||
}
|
||||
|
||||
.ui-tabs__nav--bar,
|
||||
.ui-tabs__nav--line {
|
||||
@apply gap-6;
|
||||
}
|
||||
|
||||
.ui-tabs__nav--segment {
|
||||
@apply gap-1 p-1 rounded-lg;
|
||||
background-color: var(--ion-color-light, #f8f9fa);
|
||||
}
|
||||
|
||||
/* 尺寸 */
|
||||
.ui-tabs__nav--small {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.ui-tabs__nav--medium {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.ui-tabs__nav--large {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
/* Tab Labels */
|
||||
.ui-tab {
|
||||
@apply relative flex items-center justify-between cursor-pointer transition-all duration-200;
|
||||
color: var(--ion-color-medium, #6b7280);
|
||||
}
|
||||
|
||||
.ui-tab:hover:not(.ui-tab--disabled):not(.ui-tab--active) {
|
||||
color: var(--ion-color-primary) !important;
|
||||
}
|
||||
|
||||
.ui-tab--active {
|
||||
@apply font-medium;
|
||||
color: var(--ion-color-primary) !important;
|
||||
}
|
||||
|
||||
.ui-tab--disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
color: var(--ion-color-light-shade, #d1d5db) !important;
|
||||
}
|
||||
|
||||
/* Bar 类型 */
|
||||
.ui-tab--bar {
|
||||
@apply py-3 border-b-2 border-transparent;
|
||||
}
|
||||
|
||||
.ui-tab--bar.ui-tab--active {
|
||||
border-color: var(--ion-color-primary) !important;
|
||||
}
|
||||
|
||||
/* Line 类型 */
|
||||
.ui-tab--line {
|
||||
@apply py-3;
|
||||
}
|
||||
|
||||
/* Segment 类型 */
|
||||
.ui-tab--segment {
|
||||
@apply px-3 py-2 rounded-md;
|
||||
}
|
||||
|
||||
.ui-tab--segment.ui-tab--active {
|
||||
background-color: var(--ui-tabs-background);
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 尺寸变化 */
|
||||
.ui-tab--small {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.ui-tab--small.ui-tab--bar,
|
||||
.ui-tab--small.ui-tab--line {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.ui-tab--small.ui-tab--segment {
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
|
||||
.ui-tab--large {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.ui-tab--large.ui-tab--bar,
|
||||
.ui-tab--large.ui-tab--line {
|
||||
@apply py-4;
|
||||
}
|
||||
|
||||
.ui-tab--large.ui-tab--segment {
|
||||
@apply px-4 py-3;
|
||||
}
|
||||
|
||||
/* Tab Label */
|
||||
.ui-tab__label {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
/* Close Button */
|
||||
.ui-tab__close {
|
||||
@apply ml-2 p-1 rounded-full opacity-60 hover:opacity-100 transition-all duration-200;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ui-tab__close:hover {
|
||||
background-color: rgba(var(--ui-tabs-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.ui-tab__close ion-icon {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
/* Active Bar */
|
||||
.ui-tabs__active-bar {
|
||||
@apply absolute transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.ui-tabs__active-bar--bar {
|
||||
@apply bottom-0;
|
||||
height: 2px;
|
||||
background-color: var(--ui-tabs-primary);
|
||||
}
|
||||
|
||||
.ui-tabs__active-bar--line {
|
||||
@apply bottom-0 h-px;
|
||||
background-color: var(--ui-tabs-primary);
|
||||
}
|
||||
|
||||
/* Add Button */
|
||||
.ui-tabs__add-btn {
|
||||
@apply flex items-center justify-center px-3 py-2 transition-colors duration-200;
|
||||
color: var(--ion-color-medium, #6b7280);
|
||||
}
|
||||
|
||||
.ui-tabs__add-btn:hover {
|
||||
color: var(--ui-tabs-primary);
|
||||
}
|
||||
|
||||
.ui-tabs__add-btn ion-icon {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.ui-tabs__content {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
/* 动画支持 */
|
||||
.ui-tabs--animated .ui-tabs__content {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
/* 垂直布局 */
|
||||
.ui-tabs--left,
|
||||
.ui-tabs--right {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.ui-tabs--left .ui-tabs__nav-wrapper {
|
||||
@apply min-w-48;
|
||||
}
|
||||
|
||||
.ui-tabs--right .ui-tabs__nav-wrapper {
|
||||
@apply min-w-48 order-2;
|
||||
}
|
||||
|
||||
.ui-tabs--left .ui-tabs__nav,
|
||||
.ui-tabs--right .ui-tabs__nav {
|
||||
@apply flex-col;
|
||||
}
|
||||
|
||||
.ui-tabs--left .ui-tabs__content,
|
||||
.ui-tabs--right .ui-tabs__content {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
/* 垂直布局tab调整 */
|
||||
.ui-tabs--left .ui-tab,
|
||||
.ui-tabs--right .ui-tab {
|
||||
@apply justify-start w-full;
|
||||
}
|
||||
|
||||
.ui-tabs--left .ui-tab--bar,
|
||||
.ui-tabs--right .ui-tab--bar {
|
||||
@apply border-b-0 border-r-2;
|
||||
}
|
||||
|
||||
.ui-tabs--left .ui-tab--bar.ui-tab--active,
|
||||
.ui-tabs--right .ui-tab--bar.ui-tab--active {
|
||||
border-color: var(--ui-tabs-primary);
|
||||
}
|
||||
|
||||
/* 垂直布局active bar */
|
||||
.ui-tabs--left .ui-tabs__active-bar--bar,
|
||||
.ui-tabs--right .ui-tabs__active-bar--bar {
|
||||
@apply right-0 w-0.5 h-0.5;
|
||||
}
|
||||
|
||||
.ui-tabs--left .ui-tabs__active-bar--line,
|
||||
.ui-tabs--right .ui-tabs__active-bar--line {
|
||||
@apply right-0 w-px h-0.5;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
.ui-tabs__nav--small .ui-tab--bar,
|
||||
.ui-tabs__nav--small .ui-tab--line {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.ui-tabs__nav--medium .ui-tab--bar,
|
||||
.ui-tabs__nav--medium .ui-tab--line {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.ui-tab__close {
|
||||
@apply ml-1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode 支持 */
|
||||
.ion-palette-dark {
|
||||
.ui-tabs {
|
||||
--ui-tabs-primary: var(--ion-color-primary, #ffffff);
|
||||
--ui-tabs-primary-rgb: var(--ion-color-primary-rgb, 255, 255, 255);
|
||||
--ui-tabs-background: var(--ion-background-color, #1a1a1a);
|
||||
--ui-tabs-text: var(--ion-text-color, #ffffff);
|
||||
}
|
||||
|
||||
.ui-tabs__nav--segment {
|
||||
background-color: var(--ion-color-dark, #2d2d2d);
|
||||
}
|
||||
|
||||
.ui-tab--segment.ui-tab--active {
|
||||
background-color: var(--ion-color-dark-tint, #404040);
|
||||
}
|
||||
}
|
||||
|
||||
/* 触摸设备优化 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.ui-tab--bar,
|
||||
.ui-tab--line,
|
||||
.ui-tab--segment {
|
||||
@apply py-3;
|
||||
}
|
||||
|
||||
.ui-tab__close {
|
||||
@apply p-2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
src/ui/tabs/tab-pane.vue
Normal file
84
src/ui/tabs/tab-pane.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang='ts' setup>
|
||||
interface TabPaneProps {
|
||||
name: string | number;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
closable?: boolean;
|
||||
lazy?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<TabPaneProps>();
|
||||
|
||||
// 注入父组件上下文
|
||||
const tabsContext = inject<any>("tabs-context");
|
||||
if (!tabsContext) {
|
||||
throw new Error("TabPane must be used inside Tabs");
|
||||
}
|
||||
|
||||
// 当前是否为活跃状态
|
||||
const isActive = computed(() => tabsContext.currentTab.value === props.name);
|
||||
|
||||
// 是否已经渲染过(用于lazy加载)
|
||||
const hasRendered = ref(false);
|
||||
|
||||
// 监听active状态变化
|
||||
watch(isActive, (active) => {
|
||||
if (active && !hasRendered.value) {
|
||||
hasRendered.value = true;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 注册到父组件
|
||||
onMounted(() => {
|
||||
tabsContext.registerTabPane({
|
||||
name: props.name,
|
||||
title: props.title,
|
||||
disabled: props.disabled,
|
||||
closable: props.closable,
|
||||
});
|
||||
});
|
||||
|
||||
// 卸载时注销
|
||||
onUnmounted(() => {
|
||||
tabsContext.unregisterTabPane({
|
||||
name: props.name,
|
||||
title: props.title,
|
||||
disabled: props.disabled,
|
||||
closable: props.closable,
|
||||
});
|
||||
});
|
||||
|
||||
// 计算pane样式类
|
||||
const paneClasses = computed(() => [
|
||||
"ui-tab-pane",
|
||||
{
|
||||
"ui-tab-pane--active": isActive.value,
|
||||
"ui-tab-pane--animated": tabsContext.animated.value,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Tab Content -->
|
||||
<div
|
||||
v-if="!lazy || hasRendered"
|
||||
v-show="isActive"
|
||||
:class="paneClasses"
|
||||
:style="tabsContext.paneStyle.value"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
|
||||
/* Tab Panes */
|
||||
.ui-tab-pane {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.ui-tab-pane--animated {
|
||||
@apply transition-opacity duration-200;
|
||||
}
|
||||
</style>
|
||||
97
src/ui/tabs/types.ts
Normal file
97
src/ui/tabs/types.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export interface TabItem {
|
||||
name: string | number;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
closable?: boolean;
|
||||
lazy?: boolean;
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
/** 当前激活的标签页名称 */
|
||||
modelValue?: string | number;
|
||||
/** 标签页类型 */
|
||||
type?: "bar" | "line" | "segment";
|
||||
/** 标签页尺寸 */
|
||||
size?: "small" | "medium" | "large";
|
||||
/** 是否开启切换动画 */
|
||||
animated?: boolean;
|
||||
/** 是否可关闭 */
|
||||
closable?: boolean;
|
||||
/** 是否可添加 */
|
||||
addable?: boolean;
|
||||
/** 标签页位置 */
|
||||
placement?: "top" | "bottom" | "left" | "right";
|
||||
/** 标签样式 */
|
||||
tabStyle?: string | Record<string, any>;
|
||||
/** 标签类名 */
|
||||
tabClass?: string;
|
||||
/** 面板样式 */
|
||||
paneStyle?: string | Record<string, any>;
|
||||
/** 面板类名 */
|
||||
paneClass?: string;
|
||||
}
|
||||
|
||||
export interface TabsEmits {
|
||||
/** 当前激活标签页发生变化时触发 */
|
||||
(e: "update:modelValue", value: string | number): void;
|
||||
/** 点击标签时触发 */
|
||||
(e: "tabClick", key: string | number, event: Event): void;
|
||||
/** 关闭标签时触发 */
|
||||
(e: "tabClose", key: string | number): void;
|
||||
/** 添加标签时触发 */
|
||||
(e: "tabAdd"): void;
|
||||
}
|
||||
|
||||
export interface TabPaneProps {
|
||||
/** 标签页标识,必须唯一 */
|
||||
name: string | number;
|
||||
/** 标签页标题 */
|
||||
title: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 是否可关闭 */
|
||||
closable?: boolean;
|
||||
/** 是否懒加载 */
|
||||
lazy?: boolean;
|
||||
}
|
||||
|
||||
export interface TabsContext {
|
||||
/** 当前激活的标签页 */
|
||||
currentTab: Readonly<Ref<string | number | undefined>>;
|
||||
/** 标签页类型 */
|
||||
type: Readonly<Ref<"bar" | "line" | "segment">>;
|
||||
/** 标签页尺寸 */
|
||||
size: Readonly<Ref<"small" | "medium" | "large">>;
|
||||
/** 是否可关闭 */
|
||||
closable: Readonly<Ref<boolean>>;
|
||||
/** 是否开启动画 */
|
||||
animated: Readonly<Ref<boolean>>;
|
||||
/** 标签页位置 */
|
||||
placement: Readonly<Ref<"top" | "bottom" | "left" | "right">>;
|
||||
/** 标签样式 */
|
||||
tabStyle: Readonly<Ref<string | Record<string, any> | undefined>>;
|
||||
/** 标签类名 */
|
||||
tabClass: Readonly<Ref<string | undefined>>;
|
||||
/** 面板样式 */
|
||||
paneStyle: Readonly<Ref<string | Record<string, any> | undefined>>;
|
||||
/** 面板类名 */
|
||||
paneClass: Readonly<Ref<string | undefined>>;
|
||||
/** 激活标签页方法 */
|
||||
activateTab: (key: string | number, event?: Event) => void;
|
||||
/** 关闭标签页方法 */
|
||||
closeTab: (key: string | number) => void;
|
||||
/** 注册标签页 */
|
||||
registerTabPane: (pane: TabItem) => void;
|
||||
/** 注销标签页 */
|
||||
unregisterTabPane: (pane: TabItem) => void;
|
||||
}
|
||||
|
||||
/** Tabs 组件实例类型 */
|
||||
export interface TabsInstance {
|
||||
/** 注册标签页 */
|
||||
registerTabPane: (pane: TabItem) => void;
|
||||
/** 注销标签页 */
|
||||
unregisterTabPane: (pane: TabItem) => void;
|
||||
/** 更新活跃指示条位置 */
|
||||
updateActiveBar: () => void;
|
||||
}
|
||||
48
src/ui/tag/index.vue
Normal file
48
src/ui/tag/index.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang='ts' setup>
|
||||
const { type = "primary", size = "medium", round = false } = defineProps<{
|
||||
type?: "primary" | "secondary" | "tertiary" | "success" | "warning" | "danger";
|
||||
size?: "small" | "medium" | "large";
|
||||
round?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-tag rounded-md" :class="[type, size, { 'rounded-full': round }]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='css' scoped>
|
||||
@reference "tailwindcss";
|
||||
|
||||
.ui-tag {
|
||||
@apply px-3 py-1 text-xs;
|
||||
}
|
||||
.ui-tag.primary {
|
||||
@apply bg-(--ion-color-primary) text-white;
|
||||
}
|
||||
.ui-tag.secondary {
|
||||
@apply bg-(--ion-color-secondary) text-white;
|
||||
}
|
||||
.ui-tag.tertiary {
|
||||
@apply bg-(--ion-color-tertiary) text-white;
|
||||
}
|
||||
.ui-tag.success {
|
||||
@apply bg-(--ion-color-success) text-white;
|
||||
}
|
||||
.ui-tag.warning {
|
||||
@apply bg-(--ion-color-warning) text-white;
|
||||
}
|
||||
.ui-tag.danger {
|
||||
@apply bg-(--ion-color-danger) text-white;
|
||||
}
|
||||
.ui-tag.small {
|
||||
@apply text-xs px-2 py-0.5;
|
||||
}
|
||||
.ui-tag.medium {
|
||||
@apply text-sm px-3 py-1;
|
||||
}
|
||||
.ui-tag.large {
|
||||
@apply text-base px-4 py-2;
|
||||
}
|
||||
</style>
|
||||
41
src/ui/textarea-label/index.vue
Normal file
41
src/ui/textarea-label/index.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang='ts' setup>
|
||||
import type { ComponentInstance } from "vue";
|
||||
import { IonTextarea } from "@ionic/vue";
|
||||
|
||||
defineProps<{
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const vm = getCurrentInstance()!;
|
||||
|
||||
function changeRef(exposed: any) {
|
||||
vm.exposed = exposed;
|
||||
}
|
||||
|
||||
defineExpose({} as ComponentInstance<typeof UiInput>);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="label" class="label">
|
||||
{{ label }}
|
||||
</div>
|
||||
<component :is="h(IonTextarea, { ...$attrs, ref: changeRef })" class="ui-textarea" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 14px;
|
||||
color: var(--ion-text-color-secondary);
|
||||
}
|
||||
.ui-textarea {
|
||||
--padding-start: 12px;
|
||||
--padding-end: 12px;
|
||||
--background: var(--ui-input-background, #fff);
|
||||
--color: var(--ui-input-color, #000);
|
||||
--border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user