跳到主要内容
menumenu tanreiexpand_morecontrasttranslate
tanreiexpand_more
开始使用WebReactSwiftUIJetpack ComposeFlutterReact Native预设你自己的设计系统与 AI 代理一起使用 Tanrei品牌设计令牌设计系统与定制Theme Studio颜色字体排印图标形状层级动效交互状态行为布局无障碍文字颜色令牌字阶令牌形状令牌层级令牌动效令牌状态令牌间距与度量令牌组件按钮主操作按钮分段按钮按钮组带菜单的按钮工具栏复制按钮复选框单选按钮开关纸片(Chip)菜单滑块滑块字段日历DateTimePicker字段组单选组复选框组下拉选择框下拉选择字段菜单栏文件上传文件选择器 进阶放置区文本输入框 进阶文本字段纸片字段字段组合框 进阶组合框字段数字输入框 进阶数字字段颜色输入框 进阶颜色字段一次性验证码输入框 进阶一次性验证码字段嵌入式文本输入框 进阶聊天输入栏卡片分隔线模态层 进阶对话框列表列表行底部面板侧边面板轮播代码块表格弹出框步骤折叠面板描述列表图示应用外壳页头树视图时间线图片预览调整手柄可调整大小的框滚动区域徽标进度指示器计量条数字滚动器加载指示器消息条(Snackbar)工具提示键盘快捷键标签(Tag)行内代码提示块(Callout)头像空状态标签组(Tag group)骨架屏文件项色块跑马灯文本标签页标签页视图应用栏导航栏导航抽屉搜索链接分页上一页/下一页链接步骤条面包屑目录链接卡片FAB 菜单状态Tanrei 是如何构建的参与贡献许可与致谢
首页组件图片预览

图片预览

用户按下缩略图时,把一组图片中的一张铺满整个窗口显示,供人仔细查看:可以放大再还原,放大后可以四处移动,也可以移到下一张或上一张,直到关闭为止。

content_copy以 Markdown 格式查看
类别
容器
令牌
comp.image-preview
层级
功能
构成自
Modal layer

概览

Web
tanrei-image-preview
schedule
SwiftUI
tanreiImagePreview
radio_button_unchecked
Jetpack Compose
TanreiImagePreview
radio_button_unchecked
Flutter
TanreiImagePreview
radio_button_unchecked
React Native
ImagePreview
radio_button_unchecked

生成自 docs/status/status.json, specs/image-preview.spec.json

页面上的三张图片:按下其中一张,可以仔细查看

如果页面讲的是别的内容,因而只把图片显示得很小,而用户又需要暂时把它放大来看,就使用图片预览:文章里的照片、待售商品的图片、对话里的附件、指南里的屏幕截图。用户按下缩略图,看完,然后回到原来的位置。

它只显示静态图片,别的都不显示。它不向用户提出任何问题,所以它不是对话框;它的图片是用来看的,不是用来按的,所以它也不是轮播。不过,从轮播的某一项或图示里的图片打开它,是很自然的做法。

用到的组件有按钮(缩小、放大、关闭、上一张和下一张),以及在图片到来之前占位的骨架屏。它们是真正的组件,读取各自的令牌。顶栏上的缩小、放大和关闭是 small 按钮,在默认系统中是 tonal:关闭按钮与 Tanrei 的每个关闭按钮一样,旁边的缩放按钮也画成同样的样子。上一张和下一张是 medium。m3-like 和 cupertino 预设在顶栏上保留不带底色的字形。放大并移动图片的展示区,以及从一张图片翻到下一张的切换,则是图片预览自己的,别处没有。

在 Web 上它是 <tanrei-image-preview>,子元素是 <tanrei-image-preview-item>。其他平台尚未实现。

用法

用来打开它的元素由你提供。它是包着你自己的图片的一个按钮(图片可以单独放着,也可以在图示或网格里),它要说明自己会打开一个对话框,名称要说出它做什么、针对哪张图片:“放大:一间窗外是花园的卧室”。单凭图片的 alt,只能说出是哪张图片,说不出操作。由你为它的按下编写的处理函数,把 index 设为那张图片,把 open 设为 true。上面演示中的缩略图是由 label 命名的可交互卡片;像下面的示例那样用一个原生按钮包住图片,是最朴素的形式,而且可以说明它会打开对话框。

图片预览绝不会自己关闭。它触发 close,并带上原因(button、escape、back 或 swipe),由你把 open 设为 false。随后焦点回到打开它的元素。

每张图片都有一个 src 和一个 alt:src 是预览中显示的图片,通常是比缩略图更大的文件;alt 是必需的。给出 thumbnailSrc(你的页面已经加载好的那张小图),图片预览就会先把它放大绘制,直到 src 到来,这样打开时绝不会一片空白。知道 width 和 height 时也请给出,这样图片加载之前,它的位置就已经留好。无论一共有多少张,只会请求当前显示的那张和它前后各一张。

<button type="button" class="thumbnail" aria-haspopup="dialog"
        aria-label="放大:一间窗外是花园的卧室" data-index="0">
  <img src="room-small.jpg" alt="" width="320" height="200" />
</button>
<button type="button" class="thumbnail" aria-haspopup="dialog"
        aria-label="放大:窗边的书桌" data-index="1">
  <img src="desk-small.jpg" alt="" width="320" height="200" />
</button>

<tanrei-image-preview label="房间的照片"
  close-label="关闭" zoom-in-label="放大" zoom-out-label="缩小"
  previous-label="上一张图片" next-label="下一张图片"
  count-text="第 {position} 张,共 {count} 张"
  loading-label="正在加载图片" error-text="无法加载这张图片">
  <tanrei-image-preview-item src="room-large.jpg" thumbnail-src="room-small.jpg"
    width="1600" height="1000" alt="一间窗外是花园的卧室"
    caption="早晨的东侧卧室。"></tanrei-image-preview-item>
  <tanrei-image-preview-item src="desk-large.jpg" thumbnail-src="desk-small.jpg"
    width="1600" height="1000" alt="窗边的书桌"></tanrei-image-preview-item>
</tanrei-image-preview>

<script type="module">
  import '@tanrei/web/components/image-preview';

  const preview = document.querySelector('tanrei-image-preview');
  for (const thumbnail of document.querySelectorAll('.thumbnail')) {
    thumbnail.addEventListener('click', () => {
      preview.index = Number(thumbnail.dataset.index);
      preview.open = true;
    });
  }
  // 用户要求关闭它:关闭控件、Escape、返回,或者向下拖动。
  preview.addEventListener('close', () => (preview.open = false));
  // 用户移到了另一张图片。preview.index 已经是新的值。
  preview.addEventListener('indexchange', (e) => markAsSeen(e.detail.index));
</script>
import { ImagePreview, ImagePreviewItem } from '@tanrei/react';

const [shown, setShown] = useState<number | null>(null);

{photos.map((photo, at) => (
  <button key={photo.id} type="button" aria-haspopup="dialog"
          aria-label={`放大:${photo.alt}`} onClick={() => setShown(at)}>
    <img src={photo.small} alt="" width={320} height={200} />
  </button>
))}

<ImagePreview label="房间的照片" open={shown !== null} index={shown ?? 0}
  closeLabel="关闭" zoomInLabel="放大" zoomOutLabel="缩小"
  previousLabel="上一张图片" nextLabel="下一张图片" countText="第 {position} 张,共 {count} 张"
  loadingLabel="正在加载图片" errorText="无法加载这张图片"
  onIndexChange={(event) => setShown(event.detail.index)}
  onClose={() => setShown(null)}>
  {photos.map((photo) => (
    <ImagePreviewItem key={photo.id} src={photo.large} thumbnailSrc={photo.small}
      width={photo.width} height={photo.height} alt={photo.alt} caption={photo.caption} />
  ))}
</ImagePreview>

文字由你提供:五个控件的名称、计数的句子,以及图片加载中和加载失败时说的话。把它们写在每个元素上,或者通过产品的文字(setWords())只给一次,供所有图片预览使用。计数是用你的语言写的一个完整句子,带有 {position} 和 {count} 两个占位标记,所以词序可以按你的语言来排,例如日语的 {count}枚中{position}枚目。

每个操作都有三种方式

没有哪个操作只能靠手势完成。每个操作都有一个按一下就能用到的控件,还有一个按键。

目的 用手势 用控件 用按键
放大,以及还原到适应大小 在某一点轻点两下或点击两下;双指张合;在触控板上双指张合;按住 Ctrl 或 Command 滚动滚轮 放大、缩小 + 或 =、−、0
在放大的图片上移动 拖动;滚轮 — 方向键
移到下一张或上一张图片 横向拖动 下一张、上一张 适应大小时按向左键和向右键;Page Up 和 Page Down;Home 和 End 前往第一张和最后一张图片
关闭 适应大小时,用触摸向下拖动 关闭 Escape
隐藏和显示控件 在图片上轻点一下或点击一下 — 按任意键即可显示
  • 缩放是一个数字:1 表示图片完整地显示在窗口的空闲区域内,并且绝不超过图片自身的尺寸。普通图片的缩放级别是 1、2 和 4。很宽或很高的图片在适应大小时显得很小,它的第一级缩放会填满空闲区域。
  • 只有在适应大小时,拖动才会在图片之间切换;或者拖动开始时,放大的图片在那一侧已经到了边缘。拖动途中才碰到边缘的,就停在那里:在一张图片上移动的用户,绝不会不小心离开这张图片。用手指或触控笔时,拖过展示区的一半或者快速一划,就翻到下一张图片;用鼠标时,只有拖过展示区的一半才会翻到那张图片,快速一划不算,移动得再快也一样。
  • 切换图片会让缩放回到 1。 缩放属于某一张图片,而不属于整组图片。
  • 控件绝不会自己隐藏。 用户在图片上轻点一下,才把它们隐藏起来。隐藏之后,它们仍在焦点顺序中,也仍然会告知辅助技术。
  • index、zoom 和 controlsHidden 由你设置;用户改变它们时,图片预览会自己写入新值,然后才触发 indexchange、zoomchange 或 controlshiddenchange。

按需换用更大的图片

更改当前显示的图片的 src 之后,图片预览会按原来的缩放和位置继续绘制先前的图片,直到新图片加载完成。因此,你可以在 zoomchange 时换上一个对那个缩放级别来说足够大的文件,而用户看到的只是细节变多了。

preview.addEventListener('zoomchange', (e) => {
  if (e.detail.zoom > 2) preview.children[preview.index].src = largest(preview.index);
});

没有到来的图片会绘制成一个标记,并带上你的 errorText,loaderror 会告诉你是哪一张。更改它的 src,图片预览就会重新请求。

下一层

图片预览不是由其他组件构成的,所以你无法用它的成员写出同样的东西。下一层是平台自己的模态呈现、Tanrei 的按钮、一个骨架屏,以及你自己对图片的处理:

<dialog class="viewer" aria-label="房间的照片">
  <tanrei-button variant="tonal" size="small" label="缩小"><span slot="icon">zoom_out</span></tanrei-button>
  <tanrei-button variant="tonal" size="small" label="放大"><span slot="icon">zoom_in</span></tanrei-button>
  <tanrei-button variant="tonal" size="small" label="关闭"><span slot="icon">close</span></tanrei-button>
  <tanrei-button variant="tonal" label="上一张图片"><span slot="icon">chevron_left</span></tanrei-button>
  <img alt="一间窗外是花园的卧室" src="room-large.jpg" tabindex="0">
  <tanrei-button variant="tonal" label="下一张图片"><span slot="icon">chevron_right</span></tanrei-button>
  <p>早晨的东侧卧室。</p>
</dialog>

这并不是一个近似的等价物。 你可以单独拿来用的,是五个按钮和骨架屏:同样的元素,同样的令牌。拿不走的,是图片预览之所以是图片预览的一切:缩放及其级别、放大的图片的移动及其边缘、图片之间的切换、判定一次拖动是移动放大的图片、还是切换到另一张图片的规则、控件的隐藏、只加载相邻的图片,以及告知辅助技术的内容。下到这一层的产品要自己写出这一切,包括每个手势对应的控件和按键。

你需要 使用
把一组图片中的一张放大,仔细看过之后离开 图片预览
页面上的一排项,供人浏览和按下 轮播,它的项可以打开图片预览
页面上带说明文字的一张图片 图示,它的图片可以打开图片预览
一个必须回答的问题 对话框
视频、文档、地图,或者旋转、裁剪之类的工具 你自己的查看器,建在平台的模态呈现之上

规格

单独调整某一个图片预览

无论浅色还是深色,表面都用主题自己的表面,并且没有遮罩:图片预览不会让窗口的任何部分露在外面。如果产品的图片在两种主题下都需要深色的展示区,就在它的层里重新指定填充色和内容色。单独调整某一个实例时,写在元素上:

<tanrei-image-preview label="平面图"
  style="--tanrei-comp-image-preview-layout-page-gap: 32px"></tanrei-image-preview>

它的按钮所用的两个变体也是令牌:comp.image-preview.bar.button.variant(text)和 comp.image-preview.navigation.button.variant(tonal)。按钮和骨架屏读取各自的令牌 comp.button.* 和 comp.skeleton.*。

构成

部件 说明
1 container The surface that fills the window and holds everything else. It stands on the modal layer, which makes everything behind it inert, and the layer draws no scrim (its scrim is false), because the container leaves nothing uncovered.
2 stage The whole of the container as the place the image is shown, zoomed and moved. The preview's own part: it takes the gestures and the keys, it is the one focus stop that is not a Button, and it is exposed as the image shown.
3 image The picture shown, at fit or enlarged, and its neighbour beside it while one replaces the other. Never cropped, mirrored or recolored.
4 placeholder · 可选 What stands in the image's place while it is on its way: its thumbnail enlarged, or a Skeleton's block.
5 error · 可选 The mark and the product's sentence in place of an image that did not arrive.
6 bar The row across the top: the count at its start and the zoom-out, zoom-in and close controls at its end. A zoomed image passes under it.
7 count · 可选 The product's sentence that says which image of how many is shown. Drawn only while there are two or more.
8 zoom-out An icon-only Button that takes the zoom one step down; disabled at fit.
9 zoom-in An icon-only Button that takes the zoom one step up; disabled at the greatest zoom.
10 close An icon-only Button at the end of the bar that asks to close the preview.
11 previous · 可选 An icon-only Button at the inline start of the stage that shows the image before; disabled at the first. Drawn only while there are two or more images.
12 next · 可选 An icon-only Button at the inline end of the stage that shows the image after; disabled at the last. Drawn only while there are two or more images.
13 caption · 可选 The text the product gives about the image shown, across the bottom on its own fill; it scrolls inside itself where it is long.
14 focus-ring · 可选 The system's focus indicator, inside the edge of the fit area while the stage has keyboard focus and inside the caption's region while that scrolls and has it.

属性

属性 类型 默认值 说明
open boolean false Whether the preview is presented. The product opens it — from its own thumbnail, with index set to that image — and closes it in answer to close; on SwiftUI, where it is a binding, the preview writes false itself.
index number 0 Which image is shown, counted from 0 in the order the images are given. The person changes it by a drag, the previous and next controls and the keys, and the preview then emits indexChange; a product that sets it emits nothing. A number outside the images is taken as the nearest that exists (IMP-20).
zoom number 1 How far the image shown is enlarged: 1 is its fit in the window, 2 twice that. The person changes it by a double tap, a pinch, the zoom controls and the keys, and the preview then emits zoomChange; it returns to 1 whenever the image shown changes. Kept between 1 and the greatest zoom (IMP-12).
maxZoom number null The greatest zoom. null takes the greater of 4 and twice the zoom at which the image fills the window's free area, so that a very wide or very tall image can still be read (IMP-12). A product whose pictures hold fine detail — a map, a plan — gives more; one whose pictures are small gives less.
controlsHidden boolean false Whether the bar, the count, the previous and next controls and the caption are not drawn, so that nothing stands over the picture. The person changes it by one tap on the picture, and the preview then emits controlsHiddenChange; any key, keyboard or assistive-technology focus on a control, or a moving mouse shows them again (IMP-25). Hidden controls stay in the focus order and exposed to assistive technology. The preview never hides them by itself.
label string "" The accessible name of the preview, in the product's language ("Photos of the room", "Attachment"). Never drawn. Required.
closeLabel string "" The close control's accessible name ("Close"). Required.
previousLabel string "" The previous control's accessible name ("Previous image"). Required while there are two or more images.
nextLabel string "" The next control's accessible name ("Next image"). Required while there are two or more images.
zoomInLabel string "" The zoom-in control's accessible name ("Zoom in"). Required.
zoomOutLabel string "" The zoom-out control's accessible name ("Zoom out"). Required.
countText string "" The sentence that says which image of how many is shown, drawn in the bar and told to assistive technology. The product writes the whole sentence in its own language and marks two places in it, which the preview fills: {position} with the place of the image shown, counted from 1, and {count} with the number of images ("{position} of {count}", "{count}枚中{position}枚目"). The product's: Tanrei has no sentence for it. Empty reads the product's words. Required while there are two or more images.
loadingLabel string "" What assistive technology is told while the image shown is on its way ("Loading image"): the name of the Skeleton that stands in its place. Required where an image may be shown before it has loaded and has no thumbnailSrc.
errorText string "" The sentence drawn, and told to assistive technology, in place of an image that did not arrive ("This image could not be loaded"). The same for every image of the set; what failed and why is the product's to report elsewhere, from loadError. Required.

事件

事件 说明
close The person asked to close the preview: the close control, Escape, the platform's back, or a downward drag by touch. Carries which, named button, escape, back or swipe (in the platform's casing). The product closes it by setting open to false; on SwiftUI the binding is written first.
indexChange The person moved to another image; carries the new index. Not emitted when the product sets index.
zoomChange The zoom changed by the person's gesture, control or key, or returned to 1 because the image changed; carries the new zoom, once the change has ended. Not emitted when the product sets zoom. A product may answer it by giving the image a larger src (IMP-30).
controlsHiddenChange The person hid or showed the controls; carries the new controlsHidden. Not emitted when the product sets it.
loadError The source of an image the preview asked for did not load; carries that image's index. Emitted once for each source that fails.

条目

该组件重复排列的条目种类。测试样例按顺序列出条目;各平台以各自的惯用方式为每个种类命名。

image

One picture of the set: where it is, what it shows in words, and optionally what the product says about it.

属性 类型 默认值 说明
src string "" The address of the picture as the preview shows it — usually a larger file than the thumbnail on the page. Required. Changed while the image is shown, the new picture replaces the old one in place once it has loaded (IMP-30).
alt string "" What the picture shows, in the product's language: the name assistive technology reads for it. Required for every image; a preview holds no decoration (IMP-27). Never drawn.
caption string "" What the product says about the picture, drawn below it and read as its description: a credit, a date, a sentence. Plain text of any length; a long one scrolls in its own region (IMP-26). Empty draws no caption.
thumbnailSrc string "" The address of a smaller picture of the same image that the page has usually loaded already — the thumbnail the person pressed. Drawn enlarged in the image's place until src has loaded, in place of the Skeleton (IMP-29).
width number null The picture's own width in pixels, where the product knows it. With height it lets the preview hold the image's place, and size the thumbnail or the Skeleton, before the picture has loaded. null: learned when it loads.
height number null The picture's own height in pixels; see width.
规格 Web SwiftUI Jetpack Compose Flutter React Native
条目 tanrei-image-preview-item TanreiPreviewImage TanreiPreviewImage TanreiPreviewImage ImagePreviewImage
src · 属性 src url model image source
alt · 属性 alt alt alt semanticLabel alt
caption · 属性 caption caption caption caption caption
thumbnailSrc · 属性 thumbnailSrc thumbnailURL thumbnailModel thumbnail thumbnailSource
width · 属性 width width width width width
height · 属性 height height height height height
备注 A data element, a child of the preview: it draws nothing and holds one image's props as attributes (thumbnail-src). The preview reads its children in order and watches them for change. It is not an <img>, so that the browser asks for no picture the preview has not asked for (IMP-32). A value (struct, Identifiable) in the images array. A data class in the images list. model and thumbnailModel are what the product's image loader takes (a URL string by default). A value in the images list. image and thumbnail are ImageProviders, Flutter's idiom for an address (a NetworkImage); the preview resolves only the shown image's and its neighbours' (IMP-32). An object in the images array. source and thumbnailSource are React Native's image sources ({ uri } or a require).

状态

行为

以 EARS 形式写成的需求。每个平台的实现都按这些 ID 逐条测试。需求原文取自规格文件,原样以英文呈现。

ID 需求
IMP-1 WHEN open becomes true and there is at least one image THE image preview SHALL present its container on its modal layer without the layer's scrim (MDL-1 WHILE scrim is false, MDL-2): above everything in its window and over the whole of it, filled with comp.image-preview.container.color, with everything outside it inert and the content behind it kept from scrolling, until it closes; it SHALL be presented by the platform's own modal presentation, and it draws no scrim, because nothing of the window is left uncovered.
IMP-2 WHEN the image preview opens THE image preview SHALL move focus to its stage, so that assistive technology reads the image shown (IMP-27) first and the keys of IMP-23 act at once; a preview mounted with open true SHALL move focus in as if it had just opened. Focus SHALL move when the preview opens, not when its motion ends.
IMP-3 WHILE the image preview is open THE image preview SHALL keep focus inside it, as its modal layer does (MDL-4): Tab SHALL move through the zoom-out control, the zoom-in control, the close control, the previous control, the stage, the next control and the caption where it scrolls (IMP-26), in that order in either layout direction, leaving out what is not drawn for want of a second image or of a caption; Tab from the last SHALL move to the first and Shift+Tab from the first to the last, and screen-reader navigation SHALL NOT leave it.
IMP-4 WHEN the image preview closes THE image preview SHALL return focus to the element that had it when the preview opened or, where that element no longer exists or cannot take focus, to the platform's default for a closed overlay, and SHALL restore the content behind it to what it was before, as its modal layer does (MDL-2, MDL-5); it SHALL NOT look for the product's thumbnail of the image last shown, which it does not know — a product that wants focus there moves it after the preview has closed. Focus SHALL move when the preview closes, not when its motion ends.
IMP-5 WHEN the user activates the close control, presses Escape, uses the platform's back (as its modal layer answers them, MDL-6) or completes the downward drag of IMP-22 THE image preview SHALL emit close exactly once with the reason button, escape, back or swipe, whatever the zoom is; it SHALL then be closed through its binding where open is one (SwiftUI), and by the product setting open to false where open is a plain value (web, Compose, Flutter, React Native). THE image preview SHALL have no setting that takes these ways out away from the person: it asks nothing of the person.
IMP-6 WHEN the image preview opens while a dialog or a modal sheet is open THE image preview SHALL stack above it, make it inert as well, and on closing return focus to the element inside it that had focus, as modal layers stack (MDL-8); WHEN another overlay opens from the image preview THE image preview SHALL stay below it and receive back the focus that overlay returns; Escape SHALL close only the topmost overlay.
IMP-7 THE image preview SHALL expose its container as its modal layer's modal dialog (MDL-10), named by label; WHILE label is empty it SHALL still open, and in debug builds SHALL report that it has no accessible name. THE image preview SHALL NOT add to, replace or read the window's navigation history or its address: where a product wants its own Back control to close the preview, the product writes that entry and sets open to false when it is left.
IMP-8 THE image preview SHALL draw a bar across the top of its container, inside the window's safe area, at least comp.image-preview.bar.height tall and filled with comp.image-preview.bar.container.color, holding at its start the count (IMP-9) and at its end, in this order toward the end edge, the zoom-out control, the zoom-in control and the close control, comp.image-preview.bar.gap apart, the first and the last control comp.image-preview.bar.padding-inline from the safe area's side edges, all centred on the bar's height. Each control SHALL be an icon-only Button of the small size in the variant comp.image-preview.bar.button.variant names — the three of them alike, the close control as every host's close button and the zoom controls as the close control beside them — given through the channel a Button reads from the component that places it and otherwise unrestyled, keeping its own minimum target (BTN-5), showing the icon set's zoom_out, zoom_in and close glyphs and named by zoomOutLabel, zoomInLabel and closeLabel; WHILE one of those names is empty it SHALL still draw the control, and in debug builds report that the control has no accessible name.
IMP-9 WHILE there are two or more images THE image preview SHALL draw the count at the start of the bar: countText with the place marked {position} filled with the place of the image shown, counted from 1, and the place marked {count} with the number of images, each written as the platform writes a whole number in the language of the image preview, in comp.image-preview.count.typescale and comp.image-preview.count.color, with comp.image-preview.count.padding-inline before and after it; the count SHALL wrap onto more lines, growing the bar, rather than be cut, and SHALL NOT take focus. WHILE there is one image THE image preview SHALL draw no count; WHILE there are two or more and countText is empty it SHALL draw none and in debug builds report that the count has no words. Any other text between braces in countText, and a brace that opens or closes nothing, SHALL be drawn as it is written and in debug builds reported; a brace written twice SHALL be drawn as one brace.
IMP-10 WHILE there are two or more images THE image preview SHALL draw a previous control at the inline start of its stage and a next control at its inline end, each comp.image-preview.navigation.margin-inline from the safe area's side edge and centred on the height of the fit area (IMP-11): icon-only Buttons of the medium size in the variant comp.image-preview.navigation.button.variant names, given through the same channel and otherwise unrestyled, showing the icon set's chevron_left and chevron_right glyphs and named by previousLabel and nextLabel, with the debug report of IMP-8 for an empty name. WHILE the first image is shown the previous control SHALL be disabled, and WHILE the last is shown the next control SHALL be: the set does not go round. WHILE there is one image THE image preview SHALL draw neither.
IMP-11 THE image preview SHALL make its stage the whole of its container, and SHALL call fit area the part of the stage inside the window's safe area, below the bar and above the caption where one is drawn (IMP-26). WHILE zoom is 1 it SHALL draw the image shown at the largest size at which the whole of it lies inside the fit area with its proportions kept, never above the image's own size, centred in the fit area; it SHALL NOT crop, stretch, mirror or recolor an image at any zoom, and where an image has clear parts comp.image-preview.container.color shows through them.
IMP-12 THE image preview SHALL hold a zoom for the image shown: a number, 1 being the size of IMP-11 and every other value that many times it. Its greatest value SHALL be maxZoom or, WHILE maxZoom is null, the greater of 4 and twice the fill zoom — the fill zoom being the least zoom at which the image covers the whole fit area. Its steps SHALL be 1, then the greater of 2 and the fill zoom, then each step twice the one before, none above the greatest value, which is itself the last step: so that an ordinary picture steps 1, 2, 4, and a very wide or very tall one, small at fit, fills the fit area across at its first step. WHEN the product sets zoom to a value outside 1 and the greatest value THE image preview SHALL use the nearer of the two, and WHEN maxZoom is less than 1 it SHALL treat it as null, and in debug builds report either.
IMP-13 WHEN the user activates the zoom-in control or presses + or = THE image preview SHALL move the zoom to the next step above it, and WHEN the user activates the zoom-out control or presses − to the next step below it, and WHEN the user presses 0 to 1, each about the centre of the fit area — the point of the image there stays there, as far as IMP-17 allows — over comp.image-preview.motion.zoom-duration with comp.image-preview.motion.zoom-easing. WHILE zoom is 1 the zoom-out control SHALL be disabled; WHILE zoom is the greatest value, or the image shown has not loaded or has failed (IMP-29, IMP-31), the zoom-in control SHALL be disabled.
IMP-14 WHEN the user taps the stage twice, or presses it twice with a mouse, within the platform's own interval for a double tap THE image preview SHALL, WHILE zoom is 1, move the zoom to its first step above 1 with the point of the image under the tap kept under it, as far as IMP-17 allows, and, WHILE zoom is above 1, move it to 1, over comp.image-preview.motion.zoom-duration with comp.image-preview.motion.zoom-easing; the two taps SHALL NOT also answer as single taps (IMP-24).
IMP-15 WHEN the user pinches the stage with two fingers, pinches on a trackpad, or turns a wheel while holding Ctrl (Command on Apple platforms) THE image preview SHALL change the zoom with the gesture, without a duration of its own, about the point midway between the fingers or under the pointer, never below 1 and never above the greatest value; two fingers that move together SHALL also move the image (IMP-16). WHEN the gesture ends the zoom SHALL stay where it is, not move to a step.
IMP-16 WHILE zoom is above 1, WHEN the user drags the stage with one finger, a pen or a mouse's main button past the platform's touch slop THE image preview SHALL move the image with the pointer, and continue and slow a fling as the platform's scrolling does; a wheel or a two-finger scroll on a trackpad without Ctrl or Command SHALL move it likewise, up and down and, with a sideways wheel or Shift, sideways. WHILE zoom is 1 a wheel SHALL do nothing.
IMP-17 THE image preview SHALL limit where a zoomed image can be moved so that each edge of the image can be brought to the matching edge of the fit area and no farther — so that no part of it stays under the bar or the caption — and SHALL keep the image centred in the fit area along an axis on which it is not larger than the fit area; WHEN a zoom would leave the image outside those limits THE image preview SHALL bring it inside them as part of the same motion.
IMP-18 WHILE there are two or more images and zoom is 1, WHEN the user drags the stage sideways with one finger, a pen or a mouse's main button, the drag having begun more sideways than up or down, THE image preview SHALL move the image with the pointer and draw its neighbour following it comp.image-preview.layout.page-gap away; WHEN the drag is released past half the stage's width it SHALL show that neighbour (IMP-20); WHEN a drag by a finger or a pen is released sooner but flung toward that side (the platform's own fling speed and touch slop, or 0.5 points per millisecond and 8 points where the platform names none) it SHALL show that neighbour likewise; a drag released after the pointer has not moved for longer than the time after which the platform's velocity tracker takes a pointer as stopped (40 milliseconds in Android's VelocityTracker, Jetpack Compose's VelocityTracker and Flutter's VelocityTracker) or, where the platform names none (the web, SwiftUI, React Native), 40 milliseconds has no velocity, and THE image preview SHALL take it as no fling — a page's here, a pan's (IMP-16) and a swipe's downward (IMP-22) alike; a drag by a mouse SHALL NOT show a neighbour for its speed, whatever that is — a mouse's drag shows one by its distance alone — because a mouse moves at a fling's speed in ordinary use and is dragged in repeated strokes; and a drag that shows no neighbour SHALL bring the image back, either over comp.image-preview.motion.page-duration with comp.image-preview.motion.page-easing. Where there is no neighbour on that side the image SHALL follow the pointer by no more than the platform's own overscroll allows, and come back. A drag is a mouse's where the platform says that the pointer that makes it is a mouse — a mouse, or a trackpad that moves a cursor and presses; where the platform does not tell the kind of a pointer the drag SHALL be taken as a finger's.
IMP-19 WHILE zoom is above 1, WHEN a sideways drag begins while the image already stands at its limit (IMP-17) on the side the drag would uncover THE image preview SHALL treat that drag as the drag of IMP-18, with the release rule IMP-18 gives its kind of pointer — a finger's or a pen's past half the stage's width or flung, a mouse's past half the stage's width alone, the distance counted from where the drag began — and move to the neighbour without first returning to fit; a drag that reaches the limit while it is under way SHALL stop the image there and SHALL NOT move to another image, so that a person moving about a picture never leaves it by accident.
IMP-20 WHEN the user activates the previous or the next control, presses a key of IMP-23 that moves between images, or releases the drag of IMP-18, THE image preview SHALL show the image before or after the one shown — the one arriving sliding in from its side as the one leaving slides out, comp.image-preview.layout.page-gap apart, over comp.image-preview.motion.page-duration with comp.image-preview.motion.page-easing — set index to its place and emit indexChange once with the new index. WHEN the image shown changes, by the person or by the product setting index, THE image preview SHALL return the zoom to 1 and the image that was left to its fit, emit zoomChange where the zoom was not 1, and say the new image (IMP-28). WHEN the product sets index it SHALL show that image at once, without the slide, and emit no indexChange; an index outside the images SHALL be taken as the nearest one that exists, with a debug report.
IMP-21 WHEN the person changes the zoom THE image preview SHALL set zoom and emit zoomChange with the new value once the gesture, the step or the return has ended, not for each frame of it; WHEN the person hides or shows the controls (IMP-24, IMP-25) it SHALL set controlsHidden and emit controlsHiddenChange with the new value. WHEN the product sets zoom or controlsHidden THE image preview SHALL take the value at once and emit nothing.
IMP-22 WHILE zoom is 1, WHEN the user drags the stage downward by touch, the drag having begun more downward than sideways, THE image preview SHALL move the image with the finger and make its container, bar, controls and caption clearer in proportion to the distance, so that the page behind shows; WHEN the drag is released past a quarter of the stage's height, or flung downward, it SHALL emit close with the reason swipe (IMP-5), hold the image where it was left for one frame, and then close from there IF open has become false, or bring everything back IF open is still true; a drag released sooner SHALL bring everything back over comp.image-preview.motion.page-duration with comp.image-preview.motion.page-easing. An upward drag, and a downward drag by a mouse, SHALL NOT close it.
IMP-23 WHILE focus is anywhere in the image preview, except in a part that uses the key itself (the scrolling caption of IMP-26), THE image preview SHALL answer these keys: WHILE zoom is 1, Left Arrow and Right Arrow SHALL show the image that lies on that side — the one before on the left and the one after on the right in a left-to-right layout, and the reverse in a right-to-left one — and Up Arrow and Down Arrow SHALL do nothing; WHILE zoom is above 1 the four arrow keys SHALL move the view over the image toward that side by a quarter of the fit area's width or height, within the limits of IMP-17; at any zoom Page Up SHALL show the image before and Page Down the image after, Home the first and End the last; + or = , − and 0 SHALL change the zoom (IMP-13); and Escape SHALL ask to close (IMP-5). A key that has nothing to do — an arrow at the first or the last image — SHALL do nothing and SHALL NOT leave the preview.
IMP-24 WHEN the user taps the stage once, or presses it once with a mouse, without dragging and without a second tap within the platform's interval for a double tap, THE image preview SHALL hide its controls where they are shown and show them where they are hidden (IMP-25). THE image preview SHALL NOT hide its controls by itself: not after a time without input, and not when the zoom changes.
IMP-25 WHILE controlsHidden is true THE image preview SHALL draw neither the bar, the count, the previous and next controls nor the caption, fading them over comp.image-preview.motion.controls-duration with comp.image-preview.motion.controls-easing, and SHALL NOT move or resize the image for it: the fit area stays as IMP-11 measures it with the controls shown. The controls SHALL stay in the focus order of IMP-3 and exposed to assistive technology with their names and states, and the keys of IMP-23 SHALL act as before. WHEN a key is pressed, focus moves to any of the controls by the keyboard or by assistive technology, a pointer that can hover moves over the preview, or the stage is tapped (IMP-24), THE image preview SHALL show them again, fading in over the same duration, before the control answers.
IMP-26 WHILE the image shown has a caption THE image preview SHALL draw it at the bottom of its container, across its width and inside the window's safe area, on a fill of comp.image-preview.caption.container.color: the text in comp.image-preview.caption.typescale and comp.image-preview.caption.color, wrapping and never cut, its lines beginning at the start of a column at most comp.image-preview.caption.max-width wide that is centred in the container, with comp.image-preview.caption.padding-block above and below and at least comp.image-preview.caption.padding-inline before and after. The caption's region SHALL be no taller than a third of the container; WHILE the text needs more it SHALL scroll inside the region, which is then one stop in the focus order, exposed as a group, scrolled by the arrow keys, Page Up, Page Down, Home and End as the platform scrolls a region, and drawn WHILE it has keyboard focus with the system's focus indicator inside its edge — sys.focus.outer-width wide in the color role focus-ring, set in by sys.focus.offset, never animated. WHILE the image shown has no caption THE image preview SHALL draw no region, and the fit area reaches the bottom of the safe area.
IMP-27 THE image preview SHALL expose its stage as one image that takes focus, named by the alt of the image shown and described by its caption, with the count's sentence (IMP-9) as the stage's position where the platform has a place for one and at the beginning of its description where it has none; it SHALL expose no other image of the set, SHALL expose the zoom and the place within the image to no one, and WHILE the stage has keyboard focus SHALL draw the system's focus indicator inside the edge of the fit area as IMP-26 draws the caption's; focus gained by pointer or touch SHALL NOT show it. WHILE an image's alt is empty THE image preview SHALL still show it, and in debug builds report that the image has no accessible name: a picture a person chose to look at closely is never decoration.
IMP-28 WHEN the image shown changes while the image preview is open THE image preview SHALL have assistive technology told, politely and once, the count's sentence and then the new image's alt, whether or not focus is on the stage, and SHALL keep focus where it is — but WHEN the control that has focus becomes disabled by the change (the previous control at the first image, the next at the last, a zoom control at the end of its range) THE image preview SHALL move focus to the stage, so that focus is never on a control that cannot be reached again.
IMP-29 WHILE the source of the image shown has not loaded THE image preview SHALL draw in its place, WHERE the image has a thumbnailSrc, that picture at the size the image will have at fit — from the image's width and height where they are given, and from the thumbnail's own proportions where they are not — and, WHERE it has none, a Skeleton holding one block placeholder of that size, or of the whole fit area where neither width nor height is given, named by loadingLabel and otherwise unrestyled: it reads the Skeleton's own tokens and waits, moves and says that it is busy as a Skeleton does (SKL-2, SKL-4). The zoom SHALL stay 1 while it waits; moving to another image and closing SHALL work as ever.
IMP-30 WHEN the source of the image shown has loaded THE image preview SHALL draw it in place of the thumbnail or the Skeleton at once, without moving what the person sees where the proportions are the same. WHEN the src of the image shown changes after it has loaded THE image preview SHALL keep drawing the earlier picture, at its zoom and its place, until the new one has loaded, and then draw the new one at the same zoom and place — so that a product may answer zoomChange with a larger source and the person sees only more detail.
IMP-31 IF the source of the image shown fails to load THE image preview SHALL draw in the middle of the fit area the icon set's broken_image glyph at comp.image-preview.error.icon.size in comp.image-preview.error.icon.color — with the family and fill of an unselected icon, sys.icon.weight and an optical size clamped between sys.icon.optical-size.min and sys.icon.optical-size.max — and, comp.image-preview.error.gap below it, errorText in comp.image-preview.error.text.typescale and comp.image-preview.error.text.color, centred and wrapping; it SHALL keep the stage named by the image's alt, add errorText to its description, have assistive technology told errorText politely, and emit loadError once for that source with the image's index. The zoom SHALL stay 1; moving to another image and closing SHALL work as ever; WHEN that image's src changes THE image preview SHALL ask for it again. WHILE errorText is empty it SHALL draw the glyph alone and in debug builds report that the failure has no words.
IMP-32 THE image preview SHALL ask for the source of the image shown and of the one before and the one after it, and of no other image of the set, however many there are, so that a neighbour is ready when the person moves to it; it SHALL be free to let go of a source that is no longer one of those three, and SHALL ask again when it becomes one.
IMP-33 WHEN the image preview opens THE image preview SHALL fade its container, bar, controls and caption in, over comp.image-preview.motion.enter-duration with comp.image-preview.motion.enter-easing (its modal layer's tempo, MDL-7), while the image appears as every modal surface appears: from sys.motion.appear.opacity, sys.motion.appear.scale, sys.motion.appear.distance and sys.motion.appear.blur to whole, grown about the centre of its size at fit and travelling up into place, on both axes WHERE sys.behavior.appearance is unfold; WHEN it closes it SHALL fade all of it out over comp.image-preview.motion.exit-duration with comp.image-preview.motion.exit-easing while the image goes back the same way to where it started. A motion turned round SHALL go on from the opacity and the size it has reached. WHEN it is mounted with open true THE image preview SHALL appear without its motion while the document has not yet been first painted, and with it when it is mounted after that. It SHALL NOT fly the image from or to the place of the product's thumbnail.
IMP-34 WHERE motion is reduced THE image preview SHALL open and close by fading, the image over sys.motion.role.reduced with no scale, distance or blur — or at once on a platform whose reduced-motion setting removes every animation (Android's) — SHALL change the zoom of IMP-13 and IMP-14 and the image of IMP-20 at once, without a slide, SHALL show and hide its controls at once, and SHALL still let the image follow a finger or a pointer in a pinch or a drag, which is the person's own motion; the settle of a released drag SHALL then be at once.
IMP-35 WHEN the layout direction is right-to-left THE image preview SHALL mirror the order of its parts: the count at the right of the bar and its controls at the left, the close control leftmost; the previous control at the right of the stage and the next at the left; the image after the one shown lying to the left, so that a drag toward the right brings it and Left Arrow shows it (IMP-23); and the caption's lines beginning at the right. It SHALL NOT mirror an image, the direction a zoomed image moves under a drag or an arrow key, or the point a double tap or a pinch keeps in place.
IMP-36 WHERE the layout direction is right-to-left THE image preview SHALL have the chevron_left and chevron_right glyphs of its previous and next controls drawn mirrored left to right exactly when the system's icon set marks that glyph's name as mirrored or the product forces mirroring for that use, and unmirrored when the product forbids it: the icon helper mirrors the glyph inside its own box, changing neither its place, size, color nor the order IMP-35 gives; THE image preview SHALL NOT mirror a glyph itself, and SHALL NOT mirror the zoom_in, zoom_out, close and broken_image glyphs, which point nowhere.
IMP-37 THE image preview SHALL take its layout direction from where it is used — the direction of the nearest ancestor that sets one, and the platform's own where none does — and SHALL have no direction setting of its own; WHEN that direction changes while the image preview is shown THE image preview SHALL draw itself for the new direction at once, without a motion of its own, keeping its open state, its image, its zoom and its focus.
IMP-38 THE image preview SHALL lay out its count, its caption and its error text as text of its own direction, isolated from the text around it: each takes the base direction of its first strong character, or the layout direction where it has none, so that a left-to-right word, a number with its sign or unit, a telephone number and an address keep their own order in a right-to-left layout and reorder nothing beside them; THE image preview SHALL still place each and align its lines by the layout direction, not by the text's own.
IMP-39 WHILE the text size is enlarged THE image preview SHALL grow its count, its caption and its error text with it, grow the bar where the count wraps and the caption's region up to its limit (IMP-26), measure the fit area anew and draw the image to fit it, and SHALL NOT scale its controls beyond what a Button does itself; in a window 320 wide with text at twice its size nothing SHALL be cut or need scrolling sideways, a zoomed image apart, which is moved by IMP-16 and IMP-23.
IMP-40 WHEN the size of its container or of the safe area changes — a window resized, a device turned, an on-screen bar shown — THE image preview SHALL measure the fit area anew and, WHILE zoom is 1, draw the image to fit it, and, WHILE zoom is above 1, keep the zoom and the point of the image at the middle of the fit area there, within the limits of IMP-17, without a motion.
IMP-41 WHERE the platform draws with a person's own forced colors (the web's forced-colors mode, and a platform's equivalent where it has one) THE image preview SHALL fill its container, bar and caption with the platform's background color, draw its count, caption and error text and the broken_image glyph in the platform's text color and the focus indicators of IMP-26 and IMP-27 in the platform's own color for focus, and SHALL leave every image exactly as it is; its controls are Buttons and adapt as Buttons do.
IMP-42 THE image preview SHALL let each of its controls answer hover, keyboard focus and a press as any Button does — its states, its minimum target, its pressed feedback and its focus indicator are the Button's (BTN-5, BTN-8, BTN-9, BTN-10) — and SHALL draw on its stage no state layer, no ripple and no pressed or hovered look of its own: a pointer over an image, or a press on it, changes nothing that is drawn but what the gesture itself moves.
IMP-43 IF open becomes true while there is no image THE image preview SHALL present nothing, leave the window as it is, and in debug builds report as an error that a preview was opened with nothing to show. WHEN the images change while the image preview is open THE image preview SHALL keep index within them (IMP-20), return the zoom to 1 where the image shown is no longer the same one, and draw or drop the count and the previous and next controls as IMP-9 and IMP-10 say for the new number.
IMP-44 WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the host, by the attribute or by the ARIA property that reflects it, THE image preview SHALL move it to the data-aria- attribute of the same name, so that the host is no second node with its name, description or state, and SHALL expose it on its dialog (IMP-7) — an id reference by the elements it names in the host's tree or a tree around it, a description after its own. WHILE label is not empty it is the dialog's name, over a name given so; WHILE label is empty, a name given so is the dialog's name and SHALL NOT be reported as having none in debug builds. WHEN the product removes the aria- attribute or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaDescribedByElements…) to null, THE image preview SHALL take it off that element.

各平台名称对照

对等并不意味着 API 完全相同。同一份契约,由各平台以自己的惯用方式表达。

规格 Web SwiftUI Jetpack Compose Flutter React Native
组件 tanrei-image-preview tanreiImagePreview TanreiImagePreview TanreiImagePreview ImagePreview
open · 属性 open isPresented visible open open
index · 属性 index index index index index
zoom · 属性 zoom zoom zoom zoom zoom
maxZoom · 属性 maxZoom maxZoom maxZoom maxZoom maxZoom
controlsHidden · 属性 controlsHidden controlsHidden controlsHidden controlsHidden controlsHidden
label · 属性 label label label semanticLabel label
closeLabel · 属性 closeLabel closeLabel closeLabel closeButtonSemanticLabel closeLabel
previousLabel · 属性 previousLabel previousLabel previousLabel previousButtonSemanticLabel previousLabel
nextLabel · 属性 nextLabel nextLabel nextLabel nextButtonSemanticLabel nextLabel
zoomInLabel · 属性 zoomInLabel zoomInLabel zoomInLabel zoomInButtonSemanticLabel zoomInLabel
zoomOutLabel · 属性 zoomOutLabel zoomOutLabel zoomOutLabel zoomOutButtonSemanticLabel zoomOutLabel
countText · 属性 countText countText countText countText countText
loadingLabel · 属性 loadingLabel loadingLabel loadingLabel loadingSemanticLabel loadingLabel
errorText · 属性 errorText errorText errorText errorText errorText
close · 事件 close onClose onCloseRequest onClose onClose
indexChange · 事件 indexchange index onIndexChange onIndexChanged onIndexChange
zoomChange · 事件 zoomchange zoom onZoomChange onZoomChanged onZoomChange
controlsHiddenChange · 事件 controlshiddenchange controlsHidden onControlsHiddenChange onControlsHiddenChanged onControlsHiddenChange
loadError · 事件 loaderror onLoadError onLoadError onLoadError onLoadError
备注 A custom element with a shadow root: class TanreiImagePreview, React ImagePreview. Attributes are the kebab-case of the props (max-zoom, controls-hidden, close-label, previous-label, next-label, zoom-in-label, zoom-out-label, count-text, loading-label, error-text). Inside the shadow root: a native <dialog> opened with showModal() — the same modal layer the Dialog and the modal sheets stand on (primitives/modal.ts, primitives/overlay-focus.ts), not a <tanrei-dialog>, whose surface, headline and actions it has no use for — holding five real <tanrei-button> elements (the channel of BTN-10 gives each its variant), a <tanrei-skeleton> while an image without a thumbnail loads, and the preview's own stage: <img> elements for the image shown and its two neighbours and no other (IMP-32), moved and scaled with a transform. The dialog has max-width and max-height none and fills the viewport (100dvh); ::backdrop is clear. Parts: container, bar, count, stage, image, caption, error; the Buttons' parts are exported as close, zoom-in, zoom-out, previous, next followed by the Button's own part names (exportparts). Events are CustomEvents that bubble and leave the shadow root: close carries detail.reason and is not the native dialog's close; indexchange, zoomchange, controlshiddenchange and loaderror carry detail.index, detail.zoom, detail.hidden and detail.index. open, index, zoom and controlsHidden are plain values the element writes itself on a person's change, before the event, except open, which the product sets to false in answer to close. Gestures: pointer events with touch-action: none on the stage, so that the browser's own pinch zoom and scroll do not take them — which is why the zoom buttons and keys must always work (WCAG 2.5.1, 2.5.7); wheel with ctrlKey is both Ctrl+wheel and a trackpad pinch; a double tap is two pointerups within 300 ms and the touch slop, since dblclick does not come on touch. The kind of a pointer is the pointerType of its pointerdown: mouse is the mouse of IMP-18 and IMP-19 (a trackpad's press and drag arrives as one), touch and pen are the finger and the pen, and an empty or unknown one is taken as touch. The web has no overscroll of its own for a drag past the first or the last image (IMP-18), so the image follows three tenths of the drag beyond that edge, and comes back on release: near what the web's usual image viewers do (PhotoSwipe takes 0.35) and what iOS's edge resistance feels like, enough that the hand feels the end. Android's back closes it through the dialog's own close request (cancel), reported as back where the platform tells it from Escape and as escape where it does not. No history entry is written (IMP-7). The stage is role="img" with tabindex="0", aria-label the alt and aria-describedby the count and the caption; the announcement of IMP-28 is a polite live region. Hidden controls are opacity: 0, never display: none, visibility: hidden or inert. Forced colors: forced-color-adjust: none on the images only. The opener is the product's: a native <button type="button" aria-haspopup="dialog"> around its own <img> — in a <tanrei-figure> or not — named for what it does ("Enlarge: " and the alt, in the product's words), whose click sets index and open; the element ships no opener helper. Snapshot harness: a 640 × 640 viewport, and 360 × 640 for a compact- run of several and caption; the screenshot is the viewport, since the dialog is in the top layer; The fixtures' pictures are the package's own copies under packages/web/test/fixtures/, which the harness gives a page as data: addresses (test/pictures.ts, fixturePicture), so that no fixture asks the network for anything; fixtures/missing.png is the picture that fails at once, and fixtures/pending.png is the picture that never arrives — the harness gives that one name an address that is asked for and never answered, where every other name without a file fails at once. Planned. A view modifier, as .tanreiDialog is: .tanreiImagePreview(_ label:, isPresented: Binding<Bool>, images: [TanreiPreviewImage], index: Binding<Int>, zoom: Binding<Double>?, maxZoom:, controlsHidden: Binding<Bool>?, onClose:, onLoadError:); the word props default to the product's words in the environment. indexChange, zoomChange and controlsHiddenChange are writes to the bindings, SwiftUI's idiom; closing writes false to isPresented and then calls onClose with the reason. Presented with .fullScreenCover with the system transition off (Tanrei draws the fade), which makes the rest of the app unreachable to VoiceOver. The stage is the preview's own view: MagnifyGesture, DragGesture and a double TapGesture composed so that the single tap waits for the double one; a pointer's scroll and pinch on iPadOS and macOS through the platform's own events. The kind of the pointer that drags (IMP-18, IMP-19): DragGesture does not tell it, so the pager's drag reads it from the platform beneath — on iPadOS and visionOS a touch of the type indirectPointer is the mouse, direct the finger and pencil the pen; on macOS every drag is a mouse's; on a phone every drag is a finger's. The stage carries .accessibilityAddTraits(.isImage), the alt as its label, the caption as its hint and the count as its value, with .accessibilityAdjustableAction moving between images and .accessibilityAction(.escape) closing. Images load through AsyncImage's machinery or the product's loader given in the environment. Planned. TanreiImagePreview(visible, onCloseRequest: (TanreiPreviewCloseReason) -> Unit, images: List<TanreiPreviewImage>, index, onIndexChange, zoom, onZoomChange, maxZoom, controlsHidden, onControlsHiddenChange, onLoadError, label, …): state is hoisted, Compose's idiom, so the preview calls the callback and the caller writes the value. visible rather than calling it only while open, so that the exit motion can run. Presented in a Dialog window with usePlatformDefaultWidth = false, decorFitsSystemWindows = false and the window's dim off; dismissOnBackPress reports back. The stage is the preview's own composable: detectTransformGestures, detectTapGestures(onDoubleTap, onTap) and a horizontal drag that it hands over by the rule of IMP-19; it does not use a pager whose scroll would take a zoomed image's drag. The kind of the pointer that drags (IMP-18, IMP-19) is the type of its PointerInputChange: PointerType.Mouse is the mouse, Touch the finger, Stylus and Eraser the pen, and Unknown is taken as the finger. Semantics: Role.Image, contentDescription the alt, stateDescription the count, the caption as its text; paneTitle the label. Tanrei loads no picture itself on Android: the product gives a painter loader (a TanreiImageLoader composition local), since the platform has none; model is what that loader takes. Planned. TanreiImagePreview(open:, onClose: ValueChanged<TanreiPreviewCloseReason>, images: [...], index:, onIndexChanged:, zoom:, onZoomChanged:, maxZoom:, controlsHidden:, onControlsHiddenChanged:, onLoadError:, semanticLabel:, …), controlled: the caller sets each value from its callback. A controlled overlay in a Stack over the page, as the modal side sheet is, with a FocusScope that keeps focus in, the page's semantics blocked, back through PopScope and the keys through Shortcuts/Actions. The stage is the preview's own render object with a ScaleGestureRecognizer, a DoubleTapGestureRecognizer and a horizontal drag recognizer in one arena, resolved by the rule of IMP-19; it is not an InteractiveViewer inside a PageView, which cannot hand a drag at the edge to the page. The kind of the pointer that drags (IMP-18, IMP-19) is the kind of its pointer events: PointerDeviceKind.mouse is the mouse, touch the finger, stylus and invertedStylus the pen, and unknown is taken as the finger; a trackpad pointer drags nothing — its two-finger movement is the scroll of IMP-16. Semantics(image: true, label:, value:, hint:); the announcement is SemanticsService.announce. Planned. Controlled: open, onClose(reason), images, index, onIndexChange, zoom, onZoomChange, controlsHidden, onControlsHiddenChange, onLoadError; the caller sets each value from its callback. Presented in a transparent Modal with animationType="none", statusBarTranslucent and navigationBarTranslucent; onRequestClose is close with the reason back (or escape from a hardware keyboard). The stage is the preview's own view on PanResponder with the two-touch distance for a pinch, on the core library alone, as the package's other gestures are; whether a pinch that follows the fingers at the display's rate needs a gesture library the package does not have today is for the platform's agent to measure before it builds (the device check). The kind of the pointer that drags (IMP-18, IMP-19): PanResponder does not tell it, so on the core library every drag is taken as a finger's, which is what a phone has; where the build runs with a mouse (a tablet or a desktop target) and the platform's pointer events are on, nativeEvent.pointerType of onPointerDown tells it, and mouse is the mouse — which of the two the package can rely on is part of the same device check. accessibilityRole="image", accessibilityLabel the alt, accessibilityValue.text the count, accessibilityHint the caption; AccessibilityInfo.announceForAccessibility for IMP-28.

令牌

令牌 引用 解析值(浅色)
--tanrei-comp-image-preview-bar-button-variant — 1
--tanrei-comp-image-preview-bar-container-color sys.color.surface #ffffff
--tanrei-comp-image-preview-bar-gap sys.space.space150 12px
--tanrei-comp-image-preview-bar-height — 56px
--tanrei-comp-image-preview-bar-padding-inline sys.space.space100 8px
--tanrei-comp-image-preview-caption-color sys.color.on-surface #1b1b1b
--tanrei-comp-image-preview-caption-container-color sys.color.surface #ffffff
--tanrei-comp-image-preview-caption-max-width — 640px
--tanrei-comp-image-preview-caption-padding-block sys.space.space150 12px
--tanrei-comp-image-preview-caption-padding-inline sys.space.space200 16px
--tanrei-comp-image-preview-container-color sys.color.surface #ffffff
--tanrei-comp-image-preview-count-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-image-preview-count-padding-inline sys.space.space100 8px
--tanrei-comp-image-preview-error-gap sys.space.space100 8px
--tanrei-comp-image-preview-error-icon-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-image-preview-error-icon-size sys.icon.large 32px
--tanrei-comp-image-preview-error-text-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-image-preview-layout-page-gap sys.space.space200 16px
--tanrei-comp-image-preview-motion-controls-duration sys.motion.role.state.large 250ms
--tanrei-comp-image-preview-motion-controls-easing sys.motion.easing.state cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-image-preview-motion-enter-duration comp.modal-layer.motion.enter-duration 350ms
--tanrei-comp-image-preview-motion-enter-easing comp.modal-layer.motion.enter-easing cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-image-preview-motion-exit-duration comp.modal-layer.motion.exit-duration 300ms
--tanrei-comp-image-preview-motion-exit-easing comp.modal-layer.motion.exit-easing cubic-bezier(0.3, 0, 1, 1)
--tanrei-comp-image-preview-motion-page-duration sys.motion.duration.medium2 300ms
--tanrei-comp-image-preview-motion-page-easing sys.motion.easing.emphasized-decelerate cubic-bezier(0.05, 0.7, 0.1, 1)
--tanrei-comp-image-preview-motion-zoom-duration sys.motion.duration.medium1 250ms
--tanrei-comp-image-preview-motion-zoom-easing sys.motion.easing.standard cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-image-preview-navigation-button-variant — 1
--tanrei-comp-image-preview-navigation-margin-inline sys.space.space100 8px
--tanrei-comp-image-preview-caption-typescale sys.typescale.body 400 14px/1.5714
--tanrei-comp-image-preview-count-typescale sys.typescale.label 500 14px/1.4286
--tanrei-comp-image-preview-error-text-typescale sys.typescale.body 400 14px/1.5714

测试样例

作为基准的渲染结果。所有平台都以相同的 ID 为每个样例截取快照,以便相互比较。

生成自 specs/image-preview.spec.json, tokens/comp/image-preview.tokens.json, design/tokens/

Web 上绘制方式的不同之处

  • 这个元素是 shadow root 里的一个原生 <dialog>,以模态方式打开:后面的页面是惰性(inert)的,也不滚动。它不写入历史记录条目;如果产品希望浏览器的“后退”能关闭它,就在打开时推入自己的条目,并在离开那个条目时把 open 设为 false。
  • <tanrei-image-preview-item> 是数据,而不是 <img>:图片预览没有请求的图片,浏览器一张也不会请求。没有 src 的项什么都不会请求,只是等着。
  • 它的事件是会冒泡的 CustomEvent:close(detail.reason)、indexchange(detail.index)、zoomchange(detail.zoom)、controlshiddenchange(detail.hidden)、loaderror(detail.index)。按钮自己的 click 不会离开这个元素。
  • 部件:container、bar、count、stage、image、placeholder、error、caption;每个按钮以自己的名称作为部件名,即 zoom-out、zoom-in、close、previous、next;每个按钮自己的部件,则在部件名前面加上按钮的名称:close-container、next-icon。
  • 属性(attribute)的名称是属性(property)名的 kebab-case 写法:max-zoom、controls-hidden、close-label、previous-label、next-label、zoom-in-label、zoom-out-label、count-text、loading-label、error-text;在项上是 thumbnail-src。
  • 展示区自己接管双指张合和滚动手势,所以图片预览打开时,浏览器自己的双指张合不会缩放页面。用键盘进行的浏览器页面缩放仍然有效。关闭的图片预览不会从页面接管任何滚轮事件和触摸。
  • 触控板上的双指张合,会以带 Ctrl 的滚轮事件到来;浏览器有自己的手势事件时,则以那个事件到来。不提供用触控板横向滚动来翻到另一张图片。
  • 图片预览打开时,图片淡入并略微变大。如果浏览器无法在淡出期间保留一个已关闭的对话框,图片预览就会在关闭时立即消失;无论哪种情况,焦点和页面都在同一时刻交还。
  • Android 的返回操作会以 back 为原因关闭它。分辨不出返回和 Escape 的浏览器会报告 escape。

指南

推荐

  • 让打开它的元素是一个包着图片的按钮,说明它会打开对话框,并用操作和图片来给它命名。
  • 先把 index 设为被按下的那张图片,再设置 open。
  • 每次收到 close 都把它关闭。如果想在向下拖动之后让它保持打开,就让 open 保持原样:图片会回到原位。
  • 给每张图片一个 alt,说明图片显示的是什么。caption 用来写你对图片的说明:署名、日期、一句话。
  • 有 thumbnailSrc、width 和 height 时就给出它们。
  • 根据 loaderror,用你自己的方式报告失败;errorText 是所有图片共用的一句话。

避免

  • 不要把它用于提问、表单,或者任何需要用户回应的东西。它没有操作,也没有任何设置能去掉关闭它的途径。
  • 不要用定时器隐藏控件;也不要在控件隐藏的状态下打开它,除非在你的产品里,轻点一下就能显示控件是显而易见的。
  • 不要在它上面叠加你自己的手势,并让这个手势成为做某件事的唯一方式。这里的每个操作都有一个控件和一个按键。
  • 不要在它打开期间写入地址或历史记录条目,除非你也在离开那个条目时把它关闭。
  • 不要把 <img> 用作它的子元素。那样浏览器会一次加载整组图片。

无障碍

角色
dialog
标注
The preview is a modal dialog named by label. Its stage is one image, named by the alt of the image shown and described by its caption; the count's sentence is its position. A change of image is told politely: the count, then the alt (IMP-28). The five controls are Buttons named by closeLabel, previousLabel, nextLabel, zoomInLabel and zoomOutLabel; a control at the end of its range is disabled, and focus on it moves to the stage. The zoom is told to no one: it changes what is seen, not what the picture is. While an image loads the Skeleton says it is busy under loadingLabel; when one fails, errorText is told. Everything outside the preview is hidden from assistive technology while it is open.
最小目标
即使外观尺寸更小,点击区域也满足最小目标尺寸。 minimum 32px · pointer 44px · spacing 8px · touch 48px

键盘操作

按键 动作
Tab / Shift+Tab Move through zoom out, zoom in, close, previous, the stage, next and the caption where it scrolls, wrapping; focus never leaves the preview (IMP-3). Focus starts on the stage (IMP-2). A hidden control shows itself, with the others, when focus reaches it (IMP-25).
Left Arrow / Right Arrow At fit: show the image on that side (the one before is on the left in a left-to-right layout, on the right in a right-to-left one). Zoomed: move the view over the image toward that side (IMP-23).
Up Arrow / Down Arrow Zoomed: move the view over the image up or down. At fit: nothing.
Page Up / Page Down Show the image before / after, at any zoom.
Home / End Show the first / the last image.
+ / = / − / 0 Zoom in one step / zoom out one step / return to fit (IMP-13).
Escape Asks to close (emits close with the reason escape); with other overlays above it, only the topmost closes.
Enter / Space Activate the focused control. On the stage they do nothing.

备注

  • Nothing is done by a gesture alone (WCAG 2.5.1, 2.5.7). A pinch and a double tap have the zoom controls and + / − / 0; a drag between images has the previous and next controls and the arrow, Page and Home / End keys; a drag over a zoomed image has the arrow keys; the downward drag has the close control and Escape. On a touch screen without a keyboard, moving about a zoomed image needs a one-finger drag, which is the single-pointer way, and zooming out brings every part back into view.
  • The opener is the product's, not the preview's: a button around the product's own picture — standing alone, in a Figure's stage, in a grid — that says it opens a dialog and is named for what it does and which picture ("Enlarge: a bedroom with a window onto a garden"), in the product's words. It is a button, not a link: it has no destination. A picture's alt alone names the picture and not the action.
  • Focus returns to the element that opened the preview (IMP-4). Where the person moved to another image and the product's page shows its thumbnail, the product may move focus to it after close; the preview does not know the page.
  • Alt text is each image's own and is required: the preview shows the pictures a person chose to look at. The caption is what the product says about the picture and is not a substitute for the alt.
  • The controls can be hidden by one tap, never by a timer (IMP-24), and never from the keyboard or from assistive technology: hidden, they are only not drawn. This holds WCAG 2.2.1 and 2.4.7 without a setting.
  • The controls are Tanrei Buttons and meet the minimum target themselves (BTN-5); comp.image-preview.bar.gap keeps their targets apart in the default system. The stage is the whole window.
  • Text size: at 200% in a window 320 wide the count wraps and the bar grows, the caption grows to a third of the window and then scrolls, and the image is fitted to what is left (IMP-39). Only a zoomed image is ever moved in two directions.
  • Reduced motion removes the growth on opening, the slide between images and the eased zoom step; an image still follows the finger that moves it (IMP-34).
  • Web: the browser's own page zoom still works on the preview; the stage takes pinch and scroll gestures for itself (`touch-action: none`), so the zoom controls and keys are the guaranteed way to enlarge.

生成自 specs/image-preview.spec.json, tokens/sys/measure.tokens.json

组件负责的部分

  • 它是一个由 label 命名的模态对话框。它打开期间,它之外的一切都对辅助技术隐藏,既不能获得焦点,也不能按下。
  • 打开时焦点移到展示区,并且留在图片预览内部:Tab 依次经过缩小、放大、关闭、上一张、展示区、下一张,以及可滚动的说明文字,过了最后一个就回到第一个。关闭时,焦点回到原先持有它的元素。
  • 展示区是一张图片:它的名称是当前显示的图片的 alt,它的描述先是计数的句子,然后是说明文字。这组图片中的其他图片都不暴露,缩放也不告知任何人。
  • 图片的切换会以礼貌(polite)的方式朗读一次:先是计数的句子,然后是新图片的 alt。
  • 五个控件是按钮,用的是你给的名称。到了范围尽头的那个控件会被禁用,原本在它上面的焦点移到展示区。
  • 每个手势都有一个控件和一个按键。控件可以用轻点一下来隐藏,但绝不会由定时器隐藏;隐藏之后,它们仍然可以获得焦点,也仍然暴露给辅助技术,按任意键、焦点落到其中某一个上,或者移动鼠标,都会让它们显示出来。
  • 图片加载期间,骨架屏以 loadingLabel 为名称说明它正忙。图片加载失败时,errorText 会被绘制、朗读,并加入展示区的描述。
  • 长的说明文字最多占窗口的三分之一,并在它自己的区域里滚动;这时这个区域是一个焦点停靠点,可以用按键滚动。
  • 在从右到左的布局中,顶栏、上一张和下一张所在的两侧、它们的字形、下一张图片所在的一侧,以及向左键和向右键,都会镜像。图片本身,以及放大的图片移动的方向,则不镜像。
  • 在宽 320 的窗口中把文本放大到 200% 时,计数独占一行,顶栏变高,说明文字增高到它的三分之一上限并滚动,图片则适应剩下的空间。在强制颜色模式下,填充和文本采用系统的颜色,图片保持原样。
  • 在减弱动态效果的环境中,图片预览打开和关闭时只淡入淡出,不变大,缩放级别的变化和图片的切换都立即完成,而图片仍然跟随移动它的手指。

Web 上的已知差距:屏幕阅读器会说什么,是从各浏览器的无障碍树里读出来的,还没有用屏幕阅读器实际听过;手势是由测试驱动的,还没有在手机和触控板上用手实际操作过。

需要你做的事

  • 用你的语言给出 label、五个控件的名称、计数的句子、loadingLabel 和 errorText:写在元素上,或者在产品的文字里只给一次。
  • 给每张图片一个 alt。用户特意要仔细看的图片,绝不是装饰。
  • 用操作和图片来给打开它的元素命名,并给它标上 aria-haspopup="dialog"。
  • 如果用户移到了另一张图片,而你的页面显示着那张图片的缩略图,你可以在 close 之后把焦点移到那个缩略图上。图片预览并不了解你的页面。
  • 在没有键盘的触摸屏上,要在放大的图片上移动,就得用单指拖动。请让缩小控件始终够得着:对于无法拖动的用户,不要在控件隐藏的状态下打开图片预览。