# SaveStatus

> 立即生效的设置下方的一行，短暂显示 "Saved" 或说明哪里失败了；没有内容时也保留它的空间。

Source: https://raft-design.botiverse.build/zh/components/save-status. Translated from https://raft-design.botiverse.build/components/save-status.md; where the two differ, the English page is right.

SaveStatus 是自己保存的设置下方的一行：选择器、分段控件，或带开关的设置行。改动保存好后，它短暂显示 "Saved"，或者说明哪里失败了。这一行始终存在，什么都没发生时是空的，所以结果出现又消失时，卡片、行和开关都不会移动。

```tsx
import { useState } from "react";
import {
  ListRow,
  ListRowBody,
  ListRowDescription,
  ListRowGroup,
  ListRowTitle,
  SegmentedControl,
  SegmentedControlItem,
  SegmentedControlLabel,
  Select,
  SelectContent,
  SelectIcon,
  SelectItem,
  SelectItemIndicator,
  SelectItemText,
  SelectList,
  SelectTrigger,
  SelectValue,
  Switch,
} from "@botiverse/raft-ui";
import { SaveStatus } from "@botiverse/raft-ui/wip";

const WEEK_STARTS = [
  { value: "monday", label: "Monday" },
  { value: "sunday", label: "Sunday" },
  { value: "saturday", label: "Saturday" },
];

// Each change applies at once and nothing says "Saved": the control showing
// its new value is the confirmation. Each setting's SaveStatus stays empty,
// taking no room, unless its change fails (see the failed example).
export function SaveStatusPreviewContent() {
  const [weekStart, setWeekStart] = useState<string | null>("monday");
  const [timeFormat, setTimeFormat] = useState("24h");
  const [muted, setMuted] = useState(false);

  return (
    <ListRowGroup className="@container w-full max-w-lg">
      <ListRow variant="plain" interactive={false} className="@max-lg:flex-wrap">
        <ListRowBody>
          <ListRowTitle id="week-start-title">Week starts on</ListRowTitle>
          <SaveStatus id="week-start-status" />
        </ListRowBody>
        <div className="shrink-0 @max-lg:basis-full">
          <Select items={WEEK_STARTS} value={weekStart} onValueChange={setWeekStart}>
            <SelectTrigger
              className="w-44"
              aria-labelledby="week-start-title"
              aria-describedby="week-start-status"
            >
              <SelectValue />
              <SelectIcon />
            </SelectTrigger>
            <SelectContent>
              <SelectList>
                {WEEK_STARTS.map((day) => (
                  <SelectItem key={day.value} value={day.value}>
                    <SelectItemText>{day.label}</SelectItemText>
                    <SelectItemIndicator />
                  </SelectItem>
                ))}
              </SelectList>
            </SelectContent>
          </Select>
        </div>
      </ListRow>

      <ListRow variant="plain" interactive={false} className="@max-lg:flex-wrap">
        <ListRowBody>
          <ListRowTitle id="time-format-title">Time format</ListRowTitle>
          <SaveStatus id="time-format-status" />
        </ListRowBody>
        <div className="shrink-0 @max-lg:basis-full">
          <SegmentedControl
            aria-labelledby="time-format-title"
            aria-describedby="time-format-status"
            value={timeFormat}
            onValueChange={setTimeFormat}
          >
            <SegmentedControlItem value="24h">
              <SegmentedControlLabel>24-hour</SegmentedControlLabel>
            </SegmentedControlItem>
            <SegmentedControlItem value="12h">
              <SegmentedControlLabel>12-hour</SegmentedControlLabel>
            </SegmentedControlItem>
          </SegmentedControl>
        </div>
      </ListRow>

      <ListRow variant="plain" interactive={false}>
        <ListRowBody>
          <ListRowTitle id="mute-server-title">Mute this server</ListRowTitle>
          <ListRowDescription id="mute-server-description" wrap>
            Stops notifications from this server on all your devices.
          </ListRowDescription>
          <SaveStatus id="mute-server-status" />
        </ListRowBody>
        <Switch
          checked={muted}
          onCheckedChange={setMuted}
          aria-labelledby="mute-server-title"
          aria-describedby="mute-server-description mute-server-status"
        />
      </ListRow>
    </ListRowGroup>
  );
}
```

## 何时使用
| 情况 | 使用 |
| --- | --- |
| 立即生效的设置的结果 | SaveStatus |
| 保存之前就不对的值：缺少名称、URL 无效 | `FieldError`（[Field](https://raft-design.botiverse.build/zh/components/field.md)） |
| 用 Save 保存的卡片的结果 | Save 按钮，它会短暂变成带对勾的 "Saved"（[表单](https://raft-design.botiverse.build/zh/patterns/forms.md#settings-saved-together)） |
| 涉及不止一项设置的失败 | 按它的范围放到对应的位置（[反馈](https://raft-design.botiverse.build/zh/patterns/feedback.md#errors-by-scope)） |

哪些设置立即生效，见[表单](https://raft-design.botiverse.build/zh/patterns/forms.md#when-a-change-takes-effect)。

## 结构
- `SaveStatus`：这一行，不管有没有内容，都是一行 footnote 文字的高度（18px）。紧接着控件放它：在 `Field` 里放在选择器或分段控件之后、任何 `FieldDescription` 之前；在设置行的 `ListRowBody` 里放在描述之后。必需。
- `SaveStatusSaved`：一个对勾加 "Saved"，用 `foreground-muted`。对勾是 12px 的 `circle-check`，在文字前面，相距 4px：成功用 `circle-check`，而 12px 文字旁边的图标用 11px 到 12px 这一档，和事件行、徽标里的图标一样（[图标](https://raft-design.botiverse.build/zh/foundations/iconography.md#meanings)）。它是 polite 实时区域，所以什么都没发生时把它渲染成空的，改动保存好后再放入文字；对勾只和文字一起显示。
- `SaveStatusError`：说明哪里失败了，用 `danger-strong`，代替 `SaveStatusSaved`。

## 状态
```tsx
import { useCallback, useEffect, useRef, useState } from "react";
import {
  ListRow,
  ListRowBody,
  ListRowDescription,
  ListRowGroup,
  ListRowTitle,
  Switch,
} from "@botiverse/raft-ui";
import { SaveStatus, SaveStatusError } from "@botiverse/raft-ui/wip";

export function SaveStatusFailedContent() {
  const [published, setPublished] = useState(false);
  const [failed, setFailed] = useState(false);
  const request = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearTimeout(request.current), []);

  // The switch moves at once; the save is refused, so it goes back to its old
  // value and a line appears under the description saying what failed, until
  // the next change.
  const handlePublished = useCallback((checked: boolean) => {
    window.clearTimeout(request.current);
    setFailed(false);
    setPublished(checked);
    request.current = window.setTimeout(() => {
      setPublished(!checked);
      setFailed(true);
    }, 600);
  }, []);

  return (
    <ListRowGroup className="w-full max-w-md">
      <ListRow variant="plain" interactive={false}>
        <ListRowBody>
          <ListRowTitle id="public-page-title">Public page</ListRowTitle>
          <ListRowDescription id="public-page-description" wrap>
            Anyone with the link can see the server&apos;s name and channels.
          </ListRowDescription>
          <SaveStatus id="public-page-status">
            {failed ? <SaveStatusError>Couldn&apos;t publish. Try again.</SaveStatusError> : null}
          </SaveStatus>
        </ListRowBody>
        <Switch
          checked={published}
          onCheckedChange={handlePublished}
          aria-labelledby="public-page-title"
          aria-describedby="public-page-description public-page-status"
        />
      </ListRow>
    </ListRowGroup>
  );
}
```

- **空闲和保存中：** `SaveStatusSaved` 里没有内容。这一行是空的，保持它的高度。保存中不显示任何东西：控件已经显示了新的值。
- **已保存：** 带对勾的 "Saved"，显示 2 秒，然后又变空。
- **失败：** 控件回到原来的值，所以它从不显示一个不属实的值；`SaveStatusError` 说明哪里失败了，一直显示到下一次改动。

## 行为
- 这一行在每种状态下都保持高度，所以结果出现时，卡片和行都不会变高，行末的开关也不会移动（stdrc，2026-10-30）。
- 在设置行里，开关（或行末的值、箭头、按钮）以设置的标题和描述居中，而不以这一行居中：这一行为空时会把它拉到文字明显下方。`ListRow` 在两端居中时不算这一行，并保持行高不变，所以不需要设置什么（Jasper 和 Kirby，2026-11-09，#proj-raft-ui:#220）。
- 它在前面那一项下方 4px：`Field` 里的选择器或分段控件，或者设置行里的描述。在 `Field` 里，它从字段 6px 的间隔往上移 2px；在 `ListRowBody` 里，它在主体 2px 的间隔上再加 2px。在其他地方，把控件和这一行一起放进一个没有间隔的独立块里，这一行就在控件下方 4px。
- 它预留一行。超过一行的失败信息会换行，设置随之变高，而不是被截断，设置行里的开关每多一行就下移半行；把它控制在一句短句里，就不会这样。

## 内容
- 只写 "Saved"，不多写：设置本身已经说明了改了什么。
- 失败写成没做成什么、该怎么做：Couldn't mute this server. Try again. 请求返回时带着原因的话，显示这个原因（[写作](https://raft-design.botiverse.build/zh/content/writing.md)）。

## 无障碍
- `SaveStatusSaved` 有 `role="status"` 和 `aria-live="polite"`。什么都没发生时也保持挂载，让它在 "Saved" 到来之前就已经存在：和文字一起插入的实时区域常常不会被播报。
- 新的一次保存开始时清空 `SaveStatusSaved`，让每一次 "Saved" 都是一次变化，再被播报一次。
- `SaveStatusError` 有 `role="alert"`，所以失败在出现时就被播报。把它渲染在 `SaveStatusSaved` 的位置上，绝不要放在它里面。
- 把控件的 `aria-describedby` 指向这一行的 `id`（在 `SaveStatus` 上），和已有的描述放在一起，让结果和控件一起被读出。

## API

### SaveStatus

The line under a setting that applies at once. Render it whatever the setting's state, with `SaveStatusError` inside when the change failed and nothing inside otherwise: empty, it is hidden and takes no height. A `span` (shown as a block), like every ListRow part, so it stays valid inside a row's body; give it an `id` for the control's `aria-describedby`.

Also takes the attributes of its element.

### SaveStatusError

What failed. It is an alert, announced when it appears, and stays until the next change. Its line appears with it, so the setting grows by one line while it shows.

Also takes the attributes of its element.

### SaveStatusSaved

Deprecated: A setting that applies at once no longer says "Saved": the
control showing its new value is the confirmation (Forms, settings that
apply at once). Renders nothing, whatever its children, so its
`SaveStatus` stays empty and takes no height. Leave it out: render
`SaveStatusError` when the change failed and nothing otherwise. It drops
every prop, its `id` too: point a control's `aria-describedby` at the
`SaveStatus` instead.

Also takes the attributes of its element.

## Changes

- 0.83.0 (Fixed): **A settings row's switch centers on its text, not on the SaveStatus line under it**: in a `ListRow variant="plain"` whose `ListRowBody` ends with a `SaveStatus`, both ends (the switch, value, chevron, checkbox, radio or button, and a leading icon, avatar or tile) center on the title and description. The row moves the line's reserved space (4px and one footnote line) into its bottom padding, so its height is unchanged and nothing moves when "Saved" or a one-line error appears. There is no new prop.
- 0.54.0 (Added): **SaveStatus** (wip, + `SaveStatusSaved`, `SaveStatusError`): the result line under a setting that applies at once. It always keeps one footnote line of height, so "Saved" appearing or going never resizes the card or moves a switch, and it sits 4px under a select, a segmented control or a settings row's description alike.
