TearOffTabs 可撕离 Tab
Tab 拖拽超过阈值时触发 tear-off 事件;消费方决定是 spawn DetachedPanel 还是新窗口。
基础用法
普通 tabs + 拖拽手势。垂直拖动距离超过 tearThreshold(默认 60px)后触发 tear-off(id, item, x, y)。
组件本身不实现窗口/面板创建,由消费方决定行为。
背景 视口
向下拖动任意 tab 触发 tear-off,示例会在容器内生成浮动面板。
<script setup lang="ts">
import { ref } from 'vue';
import { CfDetachedPanel, CfTearOffTabs } from '@chufix-design/vue';
const tabs = ref([
{ id: 'orders', title: 'orders.ts', closable: true, modified: true },
{ id: 'login', title: 'login.ts', closable: true },
{ id: 'readme', title: 'README.md', closable: true },
]);
const active = ref('orders');
const host = ref<HTMLElement | null>(null);
const detached = ref<{ id: string; title: string } | null>(null);
const detachedX = ref(0);
const detachedY = ref(0);
function tearOff(id: string, item: { id: string; title: string }, x: number, y: number) {
const rect = host.value?.getBoundingClientRect();
detached.value = { id, title: item.title };
detachedX.value = rect ? Math.max(16, x - rect.left - 120) : 24;
detachedY.value = rect ? Math.max(56, y - rect.top - 24) : 64;
tabs.value = tabs.value.filter((t) => t.id !== id);
active.value = tabs.value[0]?.id ?? '';
}
function reattach() {
if (!detached.value) return;
tabs.value = [...tabs.value, { ...detached.value, closable: true }];
active.value = detached.value.id;
detached.value = null;
}
</script>
<template>
<div ref="host" class="demo-floating-host demo-floating-host--tearoff">
<div style="height: 210px; border-bottom: 1px solid var(--line-1); overflow: hidden;">
<CfTearOffTabs
:tabs="tabs"
:model-value="active"
@update:model-value="(v) => active = v"
@tear-off="tearOff"
@close="(id) => tabs = tabs.filter(t => t.id !== id)"
>
<template #content-orders>
<pre style="margin: 0; padding: 12px 16px; font-family: var(--font-mono); font-size: 12px;">// orders.ts</pre>
</template>
<template #content-login>
<pre style="margin: 0; padding: 12px 16px; font-family: var(--font-mono); font-size: 12px;">// login.ts</pre>
</template>
<template #content-readme>
<pre style="margin: 0; padding: 12px 16px; font-family: var(--font-mono); font-size: 12px;"># README</pre>
</template>
</CfTearOffTabs>
</div>
<p class="demo-floating-host__hint">向下拖动任意 tab 触发 tear-off,示例会在容器内生成浮动面板。</p>
<CfDetachedPanel
:open="detached !== null"
:to="host ?? 'body'"
:x="detachedX"
:y="detachedY"
:title="detached?.title"
:width="280"
:height="150"
@update:open="(v) => { if (!v) reattach(); }"
>
<pre style="margin: 0; padding: 0; font-family: var(--font-mono); font-size: 12px;">// {{ detached?.id }}
这个 tab 已经脱离主栏,关闭面板即可恢复。</pre>
</CfDetachedPanel>
</div>
</template> <script setup>
import { ref } from 'vue';
import { CfDetachedPanel, CfTearOffTabs } from '@chufix-design/vue';
const tabs = ref([
{ id: 'orders', title: 'orders.ts', closable, modified: true },
{ id: 'login', title: 'login.ts', closable: true },
{ id: 'readme', title: 'README.md', closable: true },
]);
const active = ref('orders');
const host = ref<HTMLElement | null>(null);
const detached = ref<{ id: string; title: string } | null>(null);
const detachedX = ref(0);
const detachedY = ref(0);
function tearOff(id, item: { id: string; title: string }, x, y) {
const rect = host.value?.getBoundingClientRect();
detached.value = { id, title: item.title };
detachedX.value = rect ? Math.max(16, x - rect.left - 120) : 24;
detachedY.value = rect ? Math.max(56, y - rect.top - 24) : 64;
tabs.value = tabs.value.filter((t) => t.id !== id);
active.value = tabs.value[0]?.id ?? '';
}
function reattach() {
if (!detached.value) return;
tabs.value = [...tabs.value, { ...detached.value, closable: true }];
active.value = detached.value.id;
detached.value = null;
}
</script>
<template>
<div ref="host" class="demo-floating-host demo-floating-host--tearoff">
<div style="height: 210px; border-bottom: 1px solid var(--line-1); overflow: hidden;">
<CfTearOffTabs
:tabs="tabs"
:model-value="active"
@update:model-value="(v) => active = v"
@tear-off="tearOff"
@close="(id) => tabs = tabs.filter(t => t.id !== id)"
>
<template #content-orders>
<pre style="margin: 0; padding: 12px 16px; font-family: var(--font-mono); font-size: 12px;">// orders.ts</pre>
</template>
<template #content-login>
<pre style="margin: 0; padding: 12px 16px; font-family: var(--font-mono); font-size: 12px;">// login.ts</pre>
</template>
<template #content-readme>
<pre style="margin: 0; padding: 12px 16px; font-family: var(--font-mono); font-size: 12px;"># README</pre>
</template>
</CfTearOffTabs>
</div>
<p class="demo-floating-host__hint">向下拖动任意 tab 触发 tear-off,示例会在容器内生成浮动面板。</p>
<CfDetachedPanel
:open="detached !== null"
:to="host ?? 'body'"
:x="detachedX"
:y="detachedY"
:title="detached?.title"
:width="280"
:height="150"
@update:open="(v) => { if (!v) reattach(); }"
>
<pre style="margin: 0; padding: 0; font-family: var(--font-mono); font-size: 12px;">// {{ detached?.id }}
这个 tab 已经脱离主栏,关闭面板即可恢复。</pre>
</CfDetachedPanel>
</div>
</template> import { useState } from 'react';
import { CfTearOffTabs } from '@chufix-design/react';
export default function Demo() {
const [tabs, setTabs] = useState([
{ id: 'orders', title: 'orders.ts', closable: true, modified: true },
{ id: 'login', title: 'login.ts', closable: true },
{ id: 'readme', title: 'README.md', closable: true },
]);
const [active, setActive] = useState('orders');
const [host, setHost] = useState<HTMLElement | null>(null);
const [detached, setDetached] = useState<{ id: string; title: string } | null>(null);
const [detachedX, setDetachedX] = useState(0);
const [detachedY, setDetachedY] = useState(0);
function tearOff(id: string, item: { id: string; title: string }, x: number, y: number) {
const rect = host?.getBoundingClientRect();
setDetached({ id, title: item.title });
setDetachedX(rect ? Math.max(16, x - rect.left - 120) : 24);
setDetachedY(rect ? Math.max(56, y - rect.top - 24) : 64);
setTabs(tabs.filter((t) => t.id !== id));
setActive(tabs[0]?.id ?? '');
}
function reattach() {
if (!detached) return;
setTabs([...tabs, { ...detached, closable: true }]);
setActive(detached.id);
setDetached(null);
}
return (
<>
<div ref="host" className="demo-floating-host demo-floating-host--tearoff">
<div style={{ height: 210, borderBottom: "1px solid var(--line-1)", overflow: "hidden" }}>
<CfTearOffTabs tabs={tabs} modelValue={active} onModelValueChange={(v) => setActive(v)}
onTearOff={tearOff}
onClose={(id) => setTabs(tabs.filter(t => t.id !== id))}
>
<pre style={{ margin: 0, padding: "12px 16px", fontFamily: "var(--font-mono)", fontSize: 12 }}>// orders.ts</pre>
</>
);
} import { useState } from 'react';
import { CfTearOffTabs } from '@chufix-design/react';
export default function Demo() {
const [tabs, setTabs] = useState([
{ id: 'orders', title: 'orders.ts', closable, modified: true },
{ id: 'login', title: 'login.ts', closable: true },
{ id: 'readme', title: 'README.md', closable: true },
]);
const [active, setActive] = useState('orders');
const [host, setHost] = useState<HTMLElement | null>(null);
const [detached, setDetached] = useState<{ id: string; title: string } | null>(null);
const [detachedX, setDetachedX] = useState(0);
const [detachedY, setDetachedY] = useState(0);
function tearOff(id, item: { id: string; title: string }, x, y) {
const rect = host?.getBoundingClientRect();
setDetached({ id, title: item.title });
setDetachedX(rect ? Math.max(16, x - rect.left - 120) : 24);
setDetachedY(rect ? Math.max(56, y - rect.top - 24) : 64);
setTabs(tabs.filter((t) => t.id !== id));
setActive(tabs[0]?.id ?? '');
}
function reattach() {
if (!detached) return;
setTabs([...tabs, { ...detached, closable: true }]);
setActive(detached.id);
setDetached(null);
}
return (
<>
<div ref="host" className="demo-floating-host demo-floating-host--tearoff">
<div style={{ height: 210, borderBottom: "1px solid var(--line-1)", overflow: "hidden" }}>
<CfTearOffTabs tabs={tabs} modelValue={active} onModelValueChange={(v) => setActive(v)}
onTearOff={tearOff}
onClose={(id) => setTabs(tabs.filter(t => t.id !== id))}
>
<pre style={{ margin: 0, padding: "12px 16px", fontFamily: "var(--font-mono)", fontSize: 12 }}>// orders.ts</pre>
</>
);
} Tear-off 接 DetachedPanel
把撕下的 tab 即刻 spawn 成 DetachedPanel;关闭面板时把 tab 还原回去。
背景 视口
向下拖动 tab 可以分离成局部浮动面板,关闭后会重新挂回。
<script setup lang="ts">
import { ref } from 'vue';
import { CfTearOffTabs, CfDetachedPanel } from '@chufix-design/vue';
const tabs = ref([
{ id: 'orders', title: 'orders.ts', closable: true, modified: true },
{ id: 'login', title: 'login.ts', closable: true },
]);
const active = ref('orders');
const detachedId = ref<string | null>(null);
const detachedX = ref(0);
const detachedY = ref(0);
const host = ref<HTMLElement | null>(null);
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function tearOff(id: string, _: any, x: number, y: number) {
detachedId.value = id;
const rect = host.value?.getBoundingClientRect();
if (rect) {
detachedX.value = clamp(x - rect.left - 120, 16, Math.max(16, rect.width - 300));
detachedY.value = clamp(y - rect.top - 24, 56, Math.max(56, rect.height - 188));
} else {
detachedX.value = Math.max(40, x - 100);
detachedY.value = Math.max(40, y - 20);
}
tabs.value = tabs.value.filter((t) => t.id !== id);
}
function reattach() {
if (detachedId.value) {
tabs.value = [
...tabs.value,
{ id: detachedId.value, title: `${detachedId.value}.ts`, closable: true },
];
detachedId.value = null;
}
}
</script>
<template>
<div ref="host" class="demo-floating-host demo-floating-host--tearoff">
<div style="height: 190px; border-bottom: 1px solid var(--line-1); overflow: hidden;">
<CfTearOffTabs
:tabs="tabs"
:model-value="active"
@update:model-value="(v) => active = v"
@tear-off="tearOff"
@close="(id) => tabs = tabs.filter((t) => t.id !== id)"
>
<template #content-orders>
<pre style="margin: 0; padding: 12px; font-family: var(--font-mono); font-size: 12px;">// orders.ts
拖动这个 tab 向下可触发 tear-off →</pre>
</template>
<template #content-login>
<pre style="margin: 0; padding: 12px; font-family: var(--font-mono); font-size: 12px;">// login.ts</pre>
</template>
</CfTearOffTabs>
</div>
<p class="demo-floating-host__hint">向下拖动 tab 可以分离成局部浮动面板,关闭后会重新挂回。</p>
<CfDetachedPanel
:open="detachedId !== null"
:to="host ?? 'body'"
:x="detachedX"
:y="detachedY"
:title="`${detachedId ?? ''}.ts`"
:width="280"
:height="160"
@update:open="(v) => { if (!v) reattach(); }"
>
<pre style="margin: 0; padding: 0; font-family: var(--font-mono); font-size: 12px;">// 已被分离的 {{ detachedId }}
关闭面板会重新挂回 tab 栏。</pre>
</CfDetachedPanel>
</div>
</template> <script setup>
import { ref } from 'vue';
import { CfTearOffTabs, CfDetachedPanel } from '@chufix-design/vue';
const tabs = ref([
{ id: 'orders', title: 'orders.ts', closable, modified: true },
{ id: 'login', title: 'login.ts', closable: true },
]);
const active = ref('orders');
const detachedId = ref<string | null>(null);
const detachedX = ref(0);
const detachedY = ref(0);
const host = ref<HTMLElement | null>(null);
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function tearOff(id, _, x, y) {
detachedId.value = id;
const rect = host.value?.getBoundingClientRect();
if (rect) {
detachedX.value = clamp(x - rect.left - 120, 16, Math.max(16, rect.width - 300));
detachedY.value = clamp(y - rect.top - 24, 56, Math.max(56, rect.height - 188));
} else {
detachedX.value = Math.max(40, x - 100);
detachedY.value = Math.max(40, y - 20);
}
tabs.value = tabs.value.filter((t) => t.id !== id);
}
function reattach() {
if (detachedId.value) {
tabs.value = [
...tabs.value,
{ id: detachedId.value, title: `${detachedId.value}.ts`, closable: true },
];
detachedId.value = null;
}
}
</script>
<template>
<div ref="host" class="demo-floating-host demo-floating-host--tearoff">
<div style="height: 190px; border-bottom: 1px solid var(--line-1); overflow: hidden;">
<CfTearOffTabs
:tabs="tabs"
:model-value="active"
@update:model-value="(v) => active = v"
@tear-off="tearOff"
@close="(id) => tabs = tabs.filter((t) => t.id !== id)"
>
<template #content-orders>
<pre style="margin: 0; padding: 12px; font-family: var(--font-mono); font-size: 12px;">// orders.ts
拖动这个 tab 向下可触发 tear-off →</pre>
</template>
<template #content-login>
<pre style="margin: 0; padding: 12px; font-family: var(--font-mono); font-size: 12px;">// login.ts</pre>
</template>
</CfTearOffTabs>
</div>
<p class="demo-floating-host__hint">向下拖动 tab 可以分离成局部浮动面板,关闭后会重新挂回。</p>
<CfDetachedPanel
:open="detachedId !== null"
:to="host ?? 'body'"
:x="detachedX"
:y="detachedY"
:title="`${detachedId ?? ''}.ts`"
:width="280"
:height="160"
@update:open="(v) => { if (!v) reattach(); }"
>
<pre style="margin: 0; padding: 0; font-family: var(--font-mono); font-size: 12px;">// 已被分离的 {{ detachedId }}
关闭面板会重新挂回 tab 栏。</pre>
</CfDetachedPanel>
</div>
</template> import { useState } from 'react';
import { CfTearOffTabs } from '@chufix-design/react';
export default function Demo() {
const [tabs, setTabs] = useState([
{ id: 'orders', title: 'orders.ts', closable: true, modified: true },
{ id: 'login', title: 'login.ts', closable: true },
]);
const [active, setActive] = useState('orders');
const [detachedId, setDetachedId] = useState<string | null>(null);
const [detachedX, setDetachedX] = useState(0);
const [detachedY, setDetachedY] = useState(0);
const [host, setHost] = useState<HTMLElement | null>(null);
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function tearOff(id: string, _: any, x: number, y: number) {
setDetachedId(id);
const rect = host?.getBoundingClientRect();
if (rect) {
setDetachedX(clamp(x - rect.left - 120, 16, Math.max(16, rect.width - 300)));
setDetachedY(clamp(y - rect.top - 24, 56, Math.max(56, rect.height - 188)));
} else {
setDetachedX(Math.max(40, x - 100));
setDetachedY(Math.max(40, y - 20));
}
setTabs(tabs.filter((t) => t.id !== id));
}
function reattach() {
if (detachedId) {
setTabs([
...tabs,
{ id: detachedId, title: `${detachedId}.ts`, closable: true },
]);
setDetachedId(null);
}
}
return (
<>
<div ref="host" className="demo-floating-host demo-floating-host--tearoff">
<div style={{ height: 190, borderBottom: "1px solid var(--line-1)", overflow: "hidden" }}>
<CfTearOffTabs tabs={tabs} modelValue={active} onModelValueChange={(v) => setActive(v)}
onTearOff={tearOff}
onClose={(id) => setTabs(tabs.filter((t) => t.id !== id))}
>
<pre style={{ margin: 0, padding: 12, fontFamily: "var(--font-mono)", fontSize: 12 }}>// orders.ts
拖动这个 tab 向下可触发 tear-off →</pre>
</>
);
} import { useState } from 'react';
import { CfTearOffTabs } from '@chufix-design/react';
export default function Demo() {
const [tabs, setTabs] = useState([
{ id: 'orders', title: 'orders.ts', closable, modified: true },
{ id: 'login', title: 'login.ts', closable: true },
]);
const [active, setActive] = useState('orders');
const [detachedId, setDetachedId] = useState<string | null>(null);
const [detachedX, setDetachedX] = useState(0);
const [detachedY, setDetachedY] = useState(0);
const [host, setHost] = useState<HTMLElement | null>(null);
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function tearOff(id, _, x, y) {
setDetachedId(id);
const rect = host?.getBoundingClientRect();
if (rect) {
setDetachedX(clamp(x - rect.left - 120, 16, Math.max(16, rect.width - 300)));
setDetachedY(clamp(y - rect.top - 24, 56, Math.max(56, rect.height - 188)));
} else {
setDetachedX(Math.max(40, x - 100));
setDetachedY(Math.max(40, y - 20));
}
setTabs(tabs.filter((t) => t.id !== id));
}
function reattach() {
if (detachedId) {
setTabs([
...tabs,
{ id: detachedId, title: `${detachedId}.ts`, closable: true },
]);
setDetachedId(null);
}
}
return (
<>
<div ref="host" className="demo-floating-host demo-floating-host--tearoff">
<div style={{ height: 190, borderBottom: "1px solid var(--line-1)", overflow: "hidden" }}>
<CfTearOffTabs tabs={tabs} modelValue={active} onModelValueChange={(v) => setActive(v)}
onTearOff={tearOff}
onClose={(id) => setTabs(tabs.filter((t) => t.id !== id))}
>
<pre style={{ margin: 0, padding: 12, fontFamily: "var(--font-mono)", fontSize: 12 }}>// orders.ts
拖动这个 tab 向下可触发 tear-off →</pre>
</>
);
} API
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
tabs | TearOffTabItem[] | — | { id, title, contentKey?, modified?, closable? } |
modelValue / value | string | 第一个 tab | 活动 tab id |
tearThreshold | number | 60 | 触发 tear-off 的垂直拖动距离 |
事件:update:modelValue / tear-off(id, item, x, y) / close(id, item)。
反馈与讨论
TearOffTabs 可撕离 Tab 的讨论