来源：https://ui.docs.xihanfun.com/components/resizable

# Resizable 可调容器

通过拖拽或键盘调整内容区域的尺寸。

<div class="xh-resource-links">
  <a href="https://github.com/XiHanFun/XiHan.UI/tree/dev/ui/packages/engine/headless/src/resizable" target="_blank" rel="noreferrer">Headless</a>
  <a href="https://github.com/XiHanFun/XiHan.UI/blob/dev/ui/packages/design/styles/css/resizable.css" target="_blank" rel="noreferrer">Styles</a>
  <a href="https://github.com/XiHanFun/XiHan.UI/tree/dev/ui/packages/adapters/vue/src/components/resizable" target="_blank" rel="noreferrer">Vue</a>
  <a href="https://github.com/XiHanFun/XiHan.UI/tree/dev/ui/packages/adapters/react/src/components/resizable" target="_blank" rel="noreferrer">React</a>
  <a href="https://github.com/XiHanFun/XiHan.UI/blob/dev/ui/packages/adapters/web-components/src/elements/resizable.ts" target="_blank" rel="noreferrer">Web Components</a>
</div>

## 用法

从右侧、底部或右下角调整尺寸

```vue
<script setup lang="ts">
import { XhResizableHandle, XhResizableRoot } from "@xihan-ui/vue";
import { ref } from "vue";

const dimensions = ref({ width: 260, height: 140 });
const EDGES = ["e", "s", "se"] as const;
</script>

<template>
  <div style="padding: 12px">
    <XhResizableRoot
      v-model:dimensions="dimensions"
      :min-width="120"
      :min-height="80"
      :max-width="480"
      aria-label="可调整尺寸的占位区块"
      style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px"
    >
      <span data-demo-block data-tone="warning" style="--xh-demo-block-block-size: 100%; --xh-demo-block-min-block-size: 100%" />
      <XhResizableHandle v-for="edge in EDGES" :key="edge" :edge="edge" />
    </XhResizableRoot>
  </div>
</template>
```

```html
<div style="padding: 12px">
  <xh-resizable default-dimensions="260x140" edges="e,s,se" min-width="120" min-height="80" max-width="480" aria-label="可调整尺寸的占位区块">
    <div data-xh-part="root" style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px">
      <span data-demo-block data-tone="warning" style="--xh-demo-block-block-size: 100%; --xh-demo-block-min-block-size: 100%"></span>
      <span data-xh-part="handle" edge="e"></span>
      <span data-xh-part="handle" edge="s"></span>
      <span data-xh-part="handle" edge="se"></span>
    </div>
  </xh-resizable>
</div>
```

## 组件结构

加粗的是必需部件。

`data-scope="resizable"`：**`root`** · `handle`

## 示例

### 全部边缘

从任意边缘或角点调整尺寸

```vue
<script setup lang="ts">
import { XhResizableHandle, XhResizableRoot } from "@xihan-ui/vue";
import { ref } from "vue";

const dimensions = ref({ width: 240, height: 120 });
const EDGES = ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const;
</script>

<template>
  <XhResizableRoot
    v-model:dimensions="dimensions"
    :edges="[...EDGES]"
    :min-width="120"
    :min-height="80"
    aria-label="支持全部边缘的占位区块"
    style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px"
  >
    <span data-demo-block data-tone="info" style="--xh-demo-block-block-size: 100%; --xh-demo-block-min-block-size: 100%" />
    <XhResizableHandle v-for="edge in EDGES" :key="edge" :edge="edge" />
  </XhResizableRoot>
</template>
```

```html
<xh-resizable default-dimensions="240x120" min-width="120" min-height="80" aria-label="支持全部边缘的占位区块">
  <div data-xh-part="root" style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px">
    <span data-demo-block data-tone="info" style="--xh-demo-block-block-size: 100%; --xh-demo-block-min-block-size: 100%"></span>
    <span data-xh-part="handle" edge="n"></span><span data-xh-part="handle" edge="ne"></span>
    <span data-xh-part="handle" edge="e"></span><span data-xh-part="handle" edge="se"></span>
    <span data-xh-part="handle" edge="s"></span><span data-xh-part="handle" edge="sw"></span>
    <span data-xh-part="handle" edge="w"></span><span data-xh-part="handle" edge="nw"></span>
  </div>
</xh-resizable>
```

### 约束

设置宽高比和步进

```vue
<script setup lang="ts">
import { XhResizableHandle, XhResizableRoot } from "@xihan-ui/vue";
import { ref } from "vue";

const ratio = ref({ width: 240, height: 135 });
const snapped = ref({ width: 240, height: 120 });
</script>

<template>
  <div style="display: grid; gap: 24px">
    <div>
      <p style="margin-bottom: 8px">16:9 宽高比</p>
      <XhResizableRoot
        v-model:dimensions="ratio"
        :aspect-ratio="16 / 9"
        :edges="['e', 's', 'se']"
        :min-width="160"
        style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px"
      >
        <span>{{ Math.round(ratio.width) }} × {{ Math.round(ratio.height) }}</span>
        <XhResizableHandle v-for="edge in ['e', 's', 'se']" :key="edge" :edge="edge as never" />
      </XhResizableRoot>
    </div>

    <div>
      <p style="margin-bottom: 8px">40px 步进</p>
      <XhResizableRoot
        v-model:dimensions="snapped"
        :step="40"
        :edges="['e', 's', 'se']"
        :min-width="120"
        :min-height="80"
        style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px"
      >
        <span>{{ snapped.width }} × {{ snapped.height }}</span>
        <XhResizableHandle v-for="edge in ['e', 's', 'se']" :key="edge" :edge="edge as never" />
      </XhResizableRoot>
    </div>
  </div>
</template>
```

```html
<div style="display: grid; gap: 24px">
  <div>
    <p style="margin-bottom: 8px">16:9 宽高比</p>
    <xh-resizable id="rz-ratio" default-dimensions="240x135" edges="e,s,se" min-width="160">
      <div
        data-xh-part="root"
        style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px"
      >
        <span id="rz-ratio-out">240 × 135</span>
        <span data-xh-part="handle" edge="e"></span>
        <span data-xh-part="handle" edge="s"></span>
        <span data-xh-part="handle" edge="se"></span>
      </div>
    </xh-resizable>
  </div>

  <div>
    <p style="margin-bottom: 8px">40px 步进</p>
    <xh-resizable
      id="rz-step"
      default-dimensions="240x120"
      step="40"
      edges="e,s,se"
      min-width="120"
      min-height="80"
    >
      <div
        data-xh-part="root"
        style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px"
      >
        <span id="rz-step-out">240 × 120</span>
        <span data-xh-part="handle" edge="e"></span>
        <span data-xh-part="handle" edge="s"></span>
        <span data-xh-part="handle" edge="se"></span>
      </div>
    </xh-resizable>
  </div>
</div>

<script type="module">
  const ratio = document.getElementById("rz-ratio");
  const step = document.getElementById("rz-step");

  ratio.aspectRatio = 16 / 9;

  ratio.addEventListener("dimensions-change", (event) => {
    const { width, height } = event.detail.dimensions;
    document.getElementById("rz-ratio-out").textContent =
      `${Math.round(width)} × ${Math.round(height)}`;
  });

  step.addEventListener("dimensions-change", (event) => {
    const { width, height } = event.detail.dimensions;
    document.getElementById("rz-step-out").textContent = `${width} × ${height}`;
  });
</script>
```

### 禁用

禁止调整尺寸

```vue
<script setup lang="ts">
import { XhResizableHandle, XhResizableRoot } from "@xihan-ui/vue";
</script>

<template>
  <XhResizableRoot
    disabled
    :default-dimensions="{ width: 240, height: 120 }"
    aria-label="已锁定尺寸的占位区块"
    style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px"
  >
    <span data-demo-block data-tone="neutral" style="--xh-demo-block-block-size: 100%; --xh-demo-block-min-block-size: 100%" />
    <XhResizableHandle v-for="edge in ['e', 's', 'se']" :key="edge" :edge="edge as never" />
  </XhResizableRoot>
</template>
```

```html
<xh-resizable disabled default-dimensions="240x120" aria-label="已锁定尺寸的占位区块">
  <div
    data-xh-part="root"
    style="border-radius: var(--xh-shape-surface); background: var(--xh-bg-subtle); padding: 16px"
  >
    <span data-demo-block data-tone="neutral" style="--xh-demo-block-block-size: 100%; --xh-demo-block-min-block-size: 100%"></span>
    <span data-xh-part="handle" edge="e"></span>
    <span data-xh-part="handle" edge="s"></span>
    <span data-xh-part="handle" edge="se"></span>
  </div>
</xh-resizable>
```

## 设计指引

### 何时使用

- 调整侧栏、卡片或编辑器预览区域。
- 保存用户设置的内容尺寸。

### 何时不用

- 两块区域联动调整时使用[分栏](./splitter)。
- 表格列宽使用[表格](./table)内置调整。
- 可拖动浮窗使用[浮动面板](./floating-panel)。

### 特性

- 支持八个方向的调整把手。
- 把手命中区附着在容器内部，边缘指示条贴住容器边框，角部形状继承容器圆角。
- 支持最小/最大尺寸、宽高比和步进约束。
- 支持方向键、Home、End 和 Escape。
- 调整中和调整结束分别提供回调。

### 组合

- 可在内部放置[滚动区域](./scroll-area)。

### 最佳实践

- 提供合理的最小与最大尺寸，避免内容被压到不可读。
- 记住用户调整后的尺寸，下次打开时还原。
- 把手留足命中区，粗指针下用伪元素扩展。

### 反模式

- 每一帧调整都请求服务端或重排整页。
- 把手只在悬停时出现，键盘用户无法找到入口。

## API 参考

### 产物

| 层 | 值 |
| --- | --- |
| 自定义元素 | `<xh-resizable>` |
| Vue 组件 | `XhResizableHandle` `XhResizableRoot` |
| 组合式函数 | `useResizable` |
| 状态机 | `resizableMachine` |
| 皮肤 | `@xihan-ui/styles/resizable.css` |

### Props

| 属性 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `dimensions` | `ResizableDimensions` |  | 受控尺寸。提供后由外部决定，内部只发意图。 |
| `defaultDimensions` | `ResizableDimensions` |  |  |
| `minWidth` | `number` |  |  |
| `minHeight` | `number` |  |  |
| `maxWidth` | `number` |  |  |
| `maxHeight` | `number` |  |  |
| `aspectRatio` | `number` |  | 宽高比（宽 ÷ 高）。提供后锁定；四条边各按自身的轴计算另一轴，四个角以宽为准。 |
| `step` | `number` |  | 吸附步进：宽高各自落到最近的整数倍。 |
| `keyboardStep` | `number` |  | 方向键一次推动的距离（px），默认 8。 |
| `keyboardLargeStep` | `number` |  | 按住 Shift 时的步长（px），默认 40。 |
| `edges` | `ResizeEdge[]` |  | 允许调整的边，默认八向全部开启。 只提供东南两向即只能向右下角撑大，那是文档流中最常见的形态。 |
| `disabled` | `boolean` |  |  |
| `dir` | `Direction` |  |  |
| `translations` | `Partial<ResizableTranslations>` |  |  |
| `onDimensionsChange` | `(details: ResizableDimensionsChangeDetails) => void` |  | 尺寸变化意图。拖动途中连续发出。 |
| `onDimensionsChangeEnd` | `(details: ResizableDimensionsChangeEndDetails) => void` |  | 一次调整收尾时发出一次。保存尺寸使用它，不使用 onDimensionsChange。 |

### 事件

自定义元素将载荷放在 `detail`；Vue 使用同名 emit。

| 事件 | 载荷 | 说明 |
| --- | --- | --- |
| `dimensions-change` | `ResizableDimensionsChangeDetails` | 尺寸变化（拖动途中连续发出）；detail 为 `{ dimensions }` |
| `dimensions-change-end` | `ResizableDimensionsChangeEndDetails` | 一次调整收尾时发出一次；detail 为 `{ dimensions, edge }` |

### 插槽

仅列出带载荷的插槽。

| Vue 组件 | 插槽 | 载荷 | 说明 |
| --- | --- | --- | --- |
| `XhResizableHandle` | `default` | — |  |
| `XhResizableRoot` | `default` | `ResizableRootSlotProps` |  |

### 状态

以下名称仅用于内部状态机。

**状态**：`idle` · `resizing`

**事件**：`RESIZE.START` · `RESIZE.MOVE` · `RESIZE.END` · `RESIZE.CANCEL` · `RESIZE.NUDGE` · `RESIZE.TO_BOUND` · `DIMENSIONS.SET`

**判据**：`canResize`

### connect API

`getXxxProps()` 返回对应部件的宿主属性。

| 成员 | 类型 | 说明 |
| --- | --- | --- |
| `dimensions` | `ResizableDimensions` |  |
| `offset` | `ResizableOffset` |  |
| `resizing` | `boolean` | 正在调整（拖动中）。键盘推动一步不计。 |
| `activeEdge` | `ResizeEdge \| null` |  |
| `disabled` | `boolean` |  |
| `edgeEnabled` | `(edge: ResizeEdge) => boolean` | 该边是否开放。 |
| `setDimensions` | `(dimensions: ResizableDimensions) => void` | 整份赋值：先经约束再落定。 |
| `getRootProps` | `() => T['element']` |  |
| `getHandleProps` | `(props: { edge: ResizeEdge }) => T['element']` |  |

## 无障碍

### 键盘

规格出处：[W3C APG](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/#keyboardinteraction)

| 按键 | 生效条件 | 行为 |
| --- | --- | --- |
| `ArrowRight` / `ArrowDown` | focus in handle, not disabled | 按屏幕方向推动该边一步（默认 8px）：推东边是变宽、推西边是变窄，与拖动同义。按的是屏幕方向，rtl 下两键不对调：此时改由行尾侧的边落在屏幕左边来体现 |
| `ArrowLeft` / `ArrowUp` | focus in handle, not disabled | 往反方向推一步，规则同上 |
| `Shift+ArrowRight` / `Shift+ArrowLeft` / `Shift+ArrowUp` / `Shift+ArrowDown` | focus in handle, not disabled | 按大步长推（默认 40px） |
| `Home` | focus in handle, not disabled | 把这条边推到它眼下能到的最小尺寸 |
| `End` | focus in handle, not disabled | 推到最大尺寸；未提供上限时不动 |
| `Escape` | 调整中 | 放弃这一次调整，尺寸与位移退回按下那一刻；收尾回调不发 |

### ARIA

以下属性由 `connect` 生成。

| 部件 | 属性 | 值 |
| --- | --- | --- |
| `root` | `aria-label` | translations?.root |
| `root` | `role` | 'group' |
| `handle` | `aria-disabled` | 'false' \| 'true' |
| `handle` | `aria-label` | translations?.handle?.(edge) |
| `handle` | `aria-orientation` | 'horizontal' \| 'vertical' |
| `handle` | `aria-valuenow` | Math.round(edge === 'n' \|\| edge === 's' ? dimensions.… |
| `handle` | `role` | 'separator' |

## 样式参考

### 皮肤

`@xihan-ui/styles/resizable.css` 使用 `[data-scope="resizable"][data-part="root"]` 部件选择器，位于 `xihan.components` 层。覆盖样式使用 `xihan.overrides`。

`forced-colors: active` 下另有一套规则：颜色交给系统，边框与状态标记改用系统色关键字。

### 数据属性

由 `connect` 生成；条件不成立时不输出无值属性。

| 部件 | 属性 | 值 |
| --- | --- | --- |
| `root` | `data-disabled` | ''（条件成立时才出现） |
| `root` | `data-edge` | context.get('activeEdge') |
| `root` | `data-resizing` | ''（条件成立时才出现） |
| `handle` | `data-disabled` | ''（条件成立时才出现） |
| `handle` | `data-edge` | edge |
| `handle` | `data-resizing` | ''（条件成立时才出现） |

<!-- xh-component-tokens:start -->
### CSS 变量

本组件公开覆盖槽由独立皮肤的实际消费位生成；默认来源、作用部件和状态均与 CSS 同源。

| 变量 | 部件 | CSS 属性 | 状态 | 默认来源 | 说明 |
| --- | --- | --- | --- | --- | --- |
| `--xh-resizable-corner` | `handle` | `block-size`<br>`inline-size` | `edge=ne`<br>`edge=nw`<br>`edge=se`<br>`edge=sw` | `--xh-space-4` | resizable 的 handle 部件 block-size、inline-size 覆盖槽。 |
| `--xh-resizable-corner-inset` | `handle` | `inset-block-end`<br>`inset-block-start`<br>`inset-inline-end`<br>`inset-inline-start` | `edge=ne`<br>`edge=nw`<br>`edge=se`<br>`edge=sw` | `0` | resizable 的 handle 部件 inset-block-end、inset-block-start、inset-inline-end、inset-inline-start 覆盖槽。 |
| `--xh-resizable-grip` | `handle` | `block-size`<br>`inline-size`<br>`inset-block`<br>`inset-inline` | `edge=e`<br>`edge=n`<br>`edge=s`<br>`edge=w` | `--xh-space-2` | resizable 的 handle 部件 block-size、inline-size、inset-block、inset-inline 覆盖槽。 |
| `--xh-resizable-handle-bg` | `handle` | `background`<br>`border` | `default`<br>`edge=ne`<br>`edge=nw`<br>`edge=se`<br>`edge=sw`<br>`is([data-edge='ne'], [data-edge='nw'], [data-edge='se'], [data-edge='sw'])` | `--xh-fg-default` | resizable 的 handle 部件 background、border 覆盖槽。 |
| `--xh-resizable-handle-bg-active` | `handle` | `background`<br>`border` | `edge=ne`<br>`edge=nw`<br>`edge=se`<br>`edge=sw`<br>`is([data-edge='ne'], [data-edge='nw'], [data-edge='se'], [data-edge='sw'])`<br>`resizing` | `--xh-bg-brand` | resizable 的 handle 部件 background、border 覆盖槽。 |
| `--xh-resizable-handle-bg-hover` | `handle` | `background`<br>`border` | `edge=ne`<br>`edge=nw`<br>`edge=se`<br>`edge=sw`<br>`hover`<br>`is([data-edge='ne'], [data-edge='nw'], [data-edge='se'], [data-edge='sw'])` | `--xh-fg-default` | resizable 的 handle 部件 background、border 覆盖槽。 |
| `--xh-resizable-handle-radius` | `handle` | `border-radius` | `default` | `--xh-shape-pill` | resizable 的 handle 部件 border-radius 覆盖槽。 |
| `--xh-resizable-indicator-inset` | `handle` | `inset-block-end`<br>`inset-block-start`<br>`inset-inline-end`<br>`inset-inline-start` | `edge=e`<br>`edge=n`<br>`edge=s`<br>`edge=w` | `0` | resizable 的 handle 部件 inset-block-end、inset-block-start、inset-inline-end、inset-inline-start 覆盖槽。 |
| `--xh-resizable-indicator-length` | `handle` | `block-size`<br>`inline-size` | `edge=e`<br>`edge=n`<br>`edge=s`<br>`edge=w`<br>`is([data-edge='e'], [data-edge='w'])`<br>`is([data-edge='n'], [data-edge='s'])` | `--xh-space-8` | resizable 的 handle 部件 block-size、inline-size 覆盖槽。 |
| `--xh-resizable-indicator-thickness` | `handle` | `block-size`<br>`border-block-end-width`<br>`border-block-start-width`<br>`border-inline-end-width`<br>`border-inline-start-width`<br>`inline-size` | `edge=e`<br>`edge=n`<br>`edge=ne`<br>`edge=nw`<br>`edge=s`<br>`edge=se`<br>`edge=sw`<br>`edge=w`<br>`is([data-edge='e'], [data-edge='w'])`<br>`is([data-edge='n'], [data-edge='s'])` | `--xh-stroke-strong` | resizable 的 handle 部件 block-size、border-block-end-width、border-block-start-width、border-inline-end-width、border-inline-start-width、inline-size 覆盖槽。 |
<!-- xh-component-tokens:end -->

### 动效

`background` · `border-color` 走 `transition` 过渡。时长与缓动读[动效令牌](../guide/motion)，改令牌即改全局节奏。

系统开启减弱动效时由令牌层统一收敛，皮肤不另作判断。

### RTL

皮肤用逻辑属性排布（`inline-start` 一族），`dir="rtl"` 下自动镜像。

- 边缘使用逻辑方向，自动适配 RTL。
- 键盘方向始终对应屏幕方向。
