开发预览 更新于 2026-05-10

TearOffTabs 可撕离 Tab

Tab 拖拽超过阈值时触发 tear-off 事件;消费方决定是 spawn DetachedPanel 还是新窗口。

基础用法

普通 tabs + 拖拽手势。垂直拖动距离超过 tearThreshold(默认 60px)后触发 tear-off(id, item, x, y)。 组件本身不实现窗口/面板创建,由消费方决定行为。

背景 视口
// orders.ts

向下拖动任意 tab 触发 tear-off,示例会在容器内生成浮动面板。

src/App.vue
<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 还原回去。

背景 视口
// orders.ts
拖动这个 tab 向下可触发 tear-off →

向下拖动 tab 可以分离成局部浮动面板,关闭后会重新挂回。

src/App.vue
<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

属性类型默认值说明
tabsTearOffTabItem[]{ id, title, contentKey?, modified?, closable? }
modelValue / valuestring第一个 tab活动 tab id
tearThresholdnumber60触发 tear-off 的垂直拖动距离

事件:update:modelValue / tear-off(id, item, x, y) / close(id, item)

反馈与讨论

TearOffTabs 可撕离 Tab 的讨论

0
0 / 600
正在加载评论...