WIP feedback 3

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:19:28 -04:00
1 parent 036de19d86
commit 9ce5953640
17 files changed
+1148 -3

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
# 091 Alert 提示条
## 用途
在页面内显示一条信息、成功、警告或错误提示,可带标题、一个操作按钮和关闭按钮。严重程度同时由左侧色条、图标和无障碍名称(如“Error”)表达,不只靠颜色。
## 最小示例
```go
open := true
r := mujica.Alert(c, &open, "The archive could not be read.", mujica.AlertOptions{
Severity: mujica.SeverityError,
Title: "Import failed",
Action: "Retry",
})
if r.Action {
// retry the import
}
```
签名:`func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `open` | `*bool` | `nil` | 非 nil 时显示关闭按钮,关闭后置为 `false`;为 `false` 时不构建任何元素。`nil` 表示不可关闭。 |
| `message` | `string` | 必填 | 正文;为空时 panic。 |
| `Severity` | `Severity` | `SeverityInfo` | `SeverityInfo`、`SeveritySuccess`、`SeverityWarning`、`SeverityError`。 |
| `Title` | `string` | `""` | 正文上方的粗体标题。 |
| `Action` | `string` | `""` | 操作按钮文字;为空不显示。 |
返回 `AlertResult{Element, Action, Closed}`;关闭状态下 `Element` 为 nil。
## 状态
四种严重程度;操作与关闭按钮有悬停、按下和 2 DIP 焦点环。警告与错误在出现或文字变化时通过 `Context.Announce` 播报一次。
## 事件
- `Action`:操作按钮被点击或经键盘触发。
- `Closed`:用户关闭;同时 `*open` 变为 `false`。
## 键盘操作
Tab 依次聚焦操作按钮与关闭按钮;Enter 或 Space 触发。
## 限制
- 信息与成功提示不主动播报,避免页面加载时打扰读屏用户。
- 关闭不带退出动画;需要动画时由调用方在外层加 `Transition`。
+58
View File
@@ -0,0 +1,58 @@
# 092 Toast 轻提示
## 用途
在窗口右下角短暂显示操作结果,可带一个操作(如撤销)与关闭按钮。每个窗口一条队列:按 ID 去重,同时最多显示 3 条,其余排队。
## 最小示例
```go
if mujica.Button(c, "Delete note", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
mujica.ShowToast(c, mujica.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"})
}
// Last in the view:
if r := mujica.Toaster(c); r.Action == "note-1" {
// restore the note
}
```
接口:
- `func ShowToast(c *ui.Context, m ToastMessage)`:加入队列,须在界面线程调用(后台任务经 `Window.Update` 回到界面线程)。
- `func DismissToast(c *ui.Context, id string)`:关闭显示中或排队中的消息。
- `func Toaster(c *ui.Context) ToasterResult`:每帧在视图末尾调用一次,绘制消息并报告事件。
## 参数
`ToastMessage`:
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `ID` | `string` | `Message` | 去重键。相同 ID 再次显示时替换内容并重新计时,不新增一条。 |
| `Severity` | `Severity` | `SeverityInfo` | 图标、色条与颜色。 |
| `Message` | `string` | 必填 | 文字;为空时 panic。 |
| `Action` | `string` | `""` | 操作按钮文字。 |
| `Duration` | `time.Duration` | `ToastDuration`(5 秒) | 显示时长;负数 panic。错误消息在为 0 时一直保留到用户关闭。 |
常量:`ToastDuration = 5 * time.Second`,`ToastVisible = 3`。
## 状态
- 排队消息在显示时才开始计时。
- 指针悬停或焦点位于消息内时暂停计时,离开后从剩余时间继续。
- 出现时向读屏播报文字;进出场 160 ms 位移与淡入,减少动画时直接切换。
## 事件
`ToasterResult{Action, Closed}`:本帧被点击操作或被关闭的消息 ID;两者都会移除该消息。到时自动消失不报告。
## 键盘操作
消息从不抢夺焦点,正在输入的文本框保持焦点。Tab 在页面之后到达消息中的按钮;焦点在消息内时 Escape 关闭该消息。
## 复用与限制
MyGo 自带 `c.Toast` / `c.ToastAction`(docs/ui/toast.md),但它按文字去重、没有 ID、不限制条数、无关闭按钮、错误不能常驻、外观不可替换,计时只在悬停时暂停而不在聚焦时暂停。这些都是 PRD §5.5 的要求,因此 MujicaUI 自建队列,只复用 MyGo 的 `Overlay`、`Transition`、`Announce` 与 `After` 定时唤醒。
- 所有消息(不只是含操作的消息)都在悬停或聚焦时暂停,这是 PRD 要求的超集,便于阅读。
- 位置固定在右下角;切换到未调用 `Toaster` 的视图时消息仍在队列中,回来后继续显示。
+64
View File
@@ -0,0 +1,64 @@
# 093 Progress 进度
## 用途
显示任务进度(确定或不确定,线形或环形),以及计量值(如磁盘占用)及其阈值。归并 MyGo 的 Progress bar 与 Meter:`Progress` 为进度模式,`Meter` 为计量模式。两者分别基于 `ui.Progress` 与 `ui.Meter` 元素重绘,保留其无障碍角色与数值。
## 最小示例
```go
mujica.Progress(c, 0.62, mujica.ProgressOptions{Label: "Uploading", ShowValue: true})
mujica.Progress(c, 0, mujica.ProgressOptions{Shape: mujica.ProgressRing, Indeterminate: true, Label: "Connecting"})
mujica.Meter(c, 93, mujica.MeterOptions{
Max: 100, Label: "Disk", ShowValue: true,
Levels: &ui.MeterLevels{Warning: 70, Critical: 90},
})
```
签名:
- `func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element`
- `func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element`
## 参数
`ProgressOptions`:
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `float64` | 必填 | 0 到 1;超出或 NaN 时 panic(不确定模式忽略)。 |
| `Shape` | `ProgressShape` | `ProgressLinear` | `ProgressLinear` 或 `ProgressRing`。 |
| `Indeterminate` | `bool` | `false` | 未知长度的进度。 |
| `Label` | `string` | `""` | 显示在旁边并作为无障碍名称;为空时名称为“Loading”。 |
| `ShowValue` | `bool` | `false` | 显示百分比。 |
| `Size` | `float32` | 环形 48、线形填满 | 环形直径或线形宽度;负数 panic。 |
`MeterOptions`:
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `float64` | 必填 | 须在范围内,否则 panic。 |
| `Min`, `Max` | `float64` | 0, 1(两者都为 0 时) | 范围;`Max <= Min` panic。 |
| `Levels` | `*ui.MeterLevels` | `nil` | 阈值:达到 `Warning` 为偏高、达到 `Critical` 为严重;`Critical < Warning` 时低值为坏(如电量)。须在范围内。 |
| `Label` | `string` | `""` | 名称。 |
| `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 |
| `Width` | `float32` | 填满 | 宽度。 |
## 状态
- 确定进度变化时以 120 ms 过渡,减少动画时直接跳到新值。
- 不确定进度只在绘制时移动(只重绘、不重建视图);减少动画时显示静态填充和“In progress”文字。
- 计量条按级别使用 Success / Warning / Danger 颜色,同时显示“Normal / High / Critical / Low”文字与阈值刻度,并把级别写入无障碍名称。
## 事件
无。
## 键盘操作
不可聚焦;读屏读取名称与数值。
## 限制
- 环形直径小于 44 DIP 时百分比显示在旁边,而不是环内。
- MyGo 不提供自定义无障碍数值的公开接口,因此必须基于 `ui.Progress` / `ui.Meter` 元素绘制。
+39
View File
@@ -0,0 +1,39 @@
# 094 Spinner 加载标记
## 用途
表示未知长度的加载:四个菱形绕中心旋转。可调尺寸,可显示说明文字。Button 的加载状态也使用它。标记基于 MyGo `ui.Spinner` 元素重绘,读屏视为不确定进度。
## 最小示例
```go
mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true})
```
签名:`func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Size` | `float32` | 16 | 标记边长(DIP)。 |
| `Label` | `string` | 内置“Loading / 加载中” | 无障碍名称;`ShowLabel` 时也显示在旁边。 |
| `ShowLabel` | `bool` | `false` | 在标记旁显示说明。 |
| `Color` | `ui.Color` | `AccentText` | 标记颜色。 |
## 状态
- 只在被绘制时请求重绘:不再构建(隐藏或停止加载)或滚出视口后动画立即停止,不留定时器。
- 减少动画模式下静止显示,保留名称与说明。
## 事件
无。
## 键盘操作
不可聚焦。
## 限制
- 每帧重绘,而非 MyGo 自带 spinner 的分步重绘;只在显示时产生开销。
+45
View File
@@ -0,0 +1,45 @@
# 095 Skeleton 骨架屏
## 用途
内容加载前占位,形状为文字行、圆形或矩形。尺寸固定,加载完成后替换为真实内容时不跳动。
## 最小示例
```go
ui.Row(c).Gap(12).Children(func() {
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle})
ui.Column(c).Grow(1).Children(func() {
mujica.Skeleton(c, mujica.SkeletonOptions{Lines: 2})
})
})
```
签名:`func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Shape` | `SkeletonShape` | `SkeletonText` | `SkeletonText`、`SkeletonCircle`、`SkeletonRect`。 |
| `Width` | `float32` | 填满父级;圆形 40 | 宽度;圆形为直径。 |
| `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 |
| `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 |
负值 panic。
## 状态
明暗缓慢脉动(1.6 秒周期),只重绘不重建视图;减少动画模式使用静态填充。
## 事件
无。
## 键盘操作
不可聚焦,不进入无障碍树。
## 限制
- 骨架本身不向读屏报告加载;请在容器上配合 Spinner 或 LoadingOverlay 的说明文字。
+48
View File
@@ -0,0 +1,48 @@
# 096 EmptyState 空状态
## 用途
说明视图为何为空并给出下一步。区分“无数据”(集合本身为空)与“无搜索结果”(筛选没有匹配),两者使用不同的默认图标与文案。
## 最小示例
```go
r := mujica.EmptyState(c, mujica.EmptyStateOptions{
Kind: mujica.EmptyNoResults,
Query: query,
Action: "Clear search",
})
if r.Action {
query = ""
}
```
签名:`func EmptyState(c *ui.Context, opts EmptyStateOptions) EmptyStateResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Kind` | `EmptyKind` | `EmptyNoData` | `EmptyNoData` 或 `EmptyNoResults`。 |
| `Title` | `string` | 按类型的内置文案 | 衬线标题。 |
| `Description` | `string` | 按类型的内置文案 | 说明。 |
| `Query` | `string` | `""` | 仅 `EmptyNoResults`:默认说明写出该关键词。用于 `EmptyNoData` 时 panic。 |
| `Icon` | `*ui.SVG` | inbox / search | 替换图标。 |
| `NoIcon` | `bool` | `false` | 不显示图标;与 `Icon` 同时设置时 panic。 |
| `Action` | `string` | `""` | 次按钮文字。 |
## 状态
静态展示;操作按钮有悬停、按下与焦点状态。
## 事件
`EmptyStateResult.Action`:操作按钮被触发。
## 键盘操作
Tab 聚焦操作按钮;Enter 或 Space 触发。
## 限制
- 只有一个操作;需要多个下一步时使用 Result。
+46
View File
@@ -0,0 +1,46 @@
# 097 Result 结果页
## 用途
任务完成后的整页结果:成功、失败或无权限,附说明与下一步操作。组件只报告选择了哪个操作,不执行跳转。标题与操作之间用旧金色细线与菱形分隔。
## 最小示例
```go
r := mujica.Result(c, mujica.ResultOptions{
Kind: mujica.ResultForbidden,
Description: "You need the Editor role to change billing.",
Actions: []string{"Request access", "Go back"},
})
switch r.Action {
case 0: // send the request
case 1: // navigate back
}
```
签名:`func Result(c *ui.Context, opts ResultOptions) ResultResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Kind` | `ResultKind` | `ResultSuccess` | `ResultSuccess`、`ResultFailure`、`ResultForbidden`;未知值 panic。 |
| `Title` | `string` | 按类型的内置文案 | 衬线标题。 |
| `Description` | `string` | `""` | 说明。 |
| `Actions` | `[]string` | 无 | 下一步操作;第一个为主按钮,其余为次按钮。 |
## 状态
图标(对勾、叉、盾牌)与标题文字共同表达结果,不只靠颜色。按钮有悬停、按下与焦点状态。
## 事件
`ResultResult.Action`:被触发的操作序号,未触发为 -1。
## 键盘操作
Tab 按顺序聚焦操作按钮;Enter 或 Space 触发。
## 限制
- 不含路由;导航由调用方完成。
+37
View File
@@ -0,0 +1,37 @@
# 098 StatusIndicator 状态标记
## 用途
以图形加文字标出状态:成功、警告、错误、离线、处理中。每种状态有独立形状(对勾圆、三角、叉圆、断开的 Wi-Fi、旋转菱形),并总是显示文字,不只靠颜色。
## 最小示例
```go
mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: "API online"})
mujica.StatusIndicator(c, mujica.StatusOffline, mujica.StatusIndicatorOptions{})
```
签名:`func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `status` | `Status` | `StatusSuccess` | `StatusSuccess`、`StatusWarning`、`StatusError`、`StatusOffline`、`StatusBusy`;未知值 panic。 |
| `Label` | `string` | 内置词(Healthy、Degraded、Failing、Offline、Working) | 替换显示的文字。 |
## 状态
处理中使用 Spinner,减少动画时静止。整体为 `RoleStatus`,名称为文字。
## 事件
无。
## 键盘操作
不可聚焦。
## 限制
- 无 Badge 式的纯圆点模式;PRD 要求不能只靠颜色。
@@ -0,0 +1,51 @@
# 099 NotificationCenter 通知中心
## 用途
应用内消息列表:未读标记、已读/未读切换、单条清除、全部已读、全部清除和每条消息的操作。消息列表与持久化由调用方持有;组件就地修改调用方的切片,并报告发生了什么,供调用方保存。
## 最小示例
```go
r := mujica.NotificationCenter(c, &items, mujica.NotificationCenterOptions{})
if r.Action == "a" {
// open the build log
}
```
签名:`func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult`
## 参数
`Notification`:
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `ID` | `string` | 必填 | 列表内唯一;为空或重复时 panic。 |
| `Title` | `string` | `""` | 标题,未读时加粗。 |
| `Body` | `string` | `""` | 正文。 |
| `Time` | `string` | `""` | 按原样显示的时间,如“5 min ago”。 |
| `Read` | `bool` | `false` | 是否已读。 |
| `Action` | `string` | `""` | 操作按钮文字。 |
`NotificationCenterOptions.Title`:面板标题,默认“Notifications / 通知”。`items` 为 nil 时 panic。
## 状态
- 未读:左侧酒红菱形、粗体标题、无障碍名称前缀“Unread”。
- 行悬停加浅底;按钮有悬停、按下与焦点状态;无未读或无消息时头部按钮禁用。
- 空列表显示 EmptyState。
- 面板标题下为旧金细线。列表超出容器高度时滚动。
## 事件
`NotificationCenterResult`:`Action`、`Toggled`(已读状态切换)、`Opened`(点击正文后标为已读)、`Removed` 为对应 ID;`MarkedAllRead`、`ClearedAll` 为布尔值。
## 键盘操作
列表是一个 Tab 停靠点;方向键在各条消息及其按钮之间移动,Home/End 到首尾。焦点在消息正文上时 Enter 或 Space 标为已读;Delete 或 Backspace 清除当前消息。
## 限制
- 不分页,也不虚拟化;面向几十到几百条的应用内消息。
- 时间文字由调用方格式化。
+48
View File
@@ -0,0 +1,48 @@
# 100 LoadingOverlay 加载遮罩
## 用途
在一个容器范围内显示加载遮罩:被覆盖的内容不接收指针、按键和焦点,防止重复操作;窗口其余部分照常可用。显示说明文字与可选取消操作。
## 最小示例
```go
r := mujica.LoadingOverlay(c, loading, mujica.LoadingOverlayOptions{Cancel: "Cancel"}, func() {
ui.Text(c, "Quarterly report")
mujica.Button(c, "Export", mujica.ButtonOptions{})
})
if r.Canceled {
loading = false
}
```
签名:`func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `loading` | `bool` | 必填 | 是否覆盖。 |
| `content` | `func()` | 必填 | 构建被覆盖的内容;nil 时 panic。 |
| `Label` | `string` | 内置“Loading… / 正在加载…” | 说明文字。 |
| `Cancel` | `string` | `""` | 取消按钮文字;为空时不显示。 |
返回 `LoadingOverlayResult{Element, Canceled}`。`Element` 即遮罩所覆盖的范围,可在其上设置宽高。
## 状态
- 加载时内容经 MyGo `Disabled` 阻止输入(MyGo 统一减淡),上方为半透明背景与居中面板。
- 开始加载时播报说明;焦点原在内容内时移到取消按钮(无取消时移到可聚焦的说明面板),焦点不会离开该区域。
- 遮罩淡入淡出,减少动画时直接切换。
## 事件
`Canceled`:点击取消,或焦点在面板内时按 Escape。
## 键盘操作
Tab 到达取消按钮;Enter、Space 或 Escape 取消。被覆盖的控件不进入 Tab 序列。
## 限制
- 只遮盖容器自身范围;整窗阻塞请使用模态对话框。
+145
View File
@@ -0,0 +1,145 @@
package mujica_test
import (
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExampleAlert() {
open := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.Alert(c, &open, "The archive could not be read.", mujica.AlertOptions{
Severity: mujica.SeverityError,
Title: "Import failed",
Action: "Retry",
})
if r.Action {
// retry the import
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleToaster() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.Button(c, "Delete note", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
mujica.ShowToast(c, mujica.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"})
}
if r := mujica.Toaster(c); r.Action == "note-1" {
// restore the note
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleProgress() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Column(c).Padding(16).Gap(16).Children(func() {
mujica.Progress(c, 0.62, mujica.ProgressOptions{Label: "Uploading", ShowValue: true})
mujica.Progress(c, 0, mujica.ProgressOptions{Shape: mujica.ProgressRing, Indeterminate: true, Label: "Connecting"})
mujica.Meter(c, 93, mujica.MeterOptions{
Max: 100, Label: "Disk", ShowValue: true,
Levels: &ui.MeterLevels{Warning: 70, Critical: 90},
})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSpinner() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSkeleton() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Row(c).Padding(16).Gap(12).Children(func() {
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle})
ui.Column(c).Grow(1).Children(func() {
mujica.Skeleton(c, mujica.SkeletonOptions{Lines: 2})
})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleEmptyState() {
query := "quarterly"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.EmptyState(c, mujica.EmptyStateOptions{
Kind: mujica.EmptyNoResults,
Query: query,
Action: "Clear search",
})
if r.Action {
query = ""
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleResult() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.Result(c, mujica.ResultOptions{
Kind: mujica.ResultForbidden,
Description: "You need the Editor role to change billing.",
Actions: []string{"Request access", "Go back"},
})
switch r.Action {
case 0: // send the request
case 1: // navigate back
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleStatusIndicator() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Row(c).Gap(16).Children(func() {
mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: "API online"})
mujica.StatusIndicator(c, mujica.StatusOffline, mujica.StatusIndicatorOptions{})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleNotificationCenter() {
items := []mujica.Notification{
{ID: "a", Title: "Build failed", Body: "3 tests failing", Time: "2 min ago", Action: "View log"},
{ID: "b", Title: "Backup complete", Time: "Yesterday", Read: true},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.NotificationCenter(c, &items, mujica.NotificationCenterOptions{})
if r.Action == "a" {
// open the build log
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleLoadingOverlay() {
loading := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.LoadingOverlay(c, loading, mujica.LoadingOverlayOptions{Cancel: "Cancel"}, func() {
ui.Text(c, "Quarterly report")
mujica.Button(c, "Export", mujica.ButtonOptions{})
})
if r.Canceled {
loading = false
}
}
ui.Render(view, 400, 300, 1)
}
+111 -1
View File
@@ -1,3 +1,113 @@
package main
var feedbackPages = []page{}
var feedbackPages = []page{
{
ID: "091", Name: "Alert", ZhName: "提示条 警告 通知横幅", Category: "Feedback",
Demo: alertDemo,
Code: `open := true
r := mujica.Alert(c, &open, "The archive could not be read.", mujica.AlertOptions{
Severity: mujica.SeverityError,
Title: "Import failed",
Action: "Retry",
})
if r.Action {
// retry the import
}`,
Keys: "Tab moves to the action and the dismiss button; Enter or Space presses them.",
},
{
ID: "092", Name: "Toast", ZhName: "轻提示 消息队列 吐司", Category: "Feedback",
Demo: toastDemo,
Code: `if mujica.Button(c, "Delete note", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
mujica.ShowToast(c, mujica.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"})
}
// Last in the view:
if r := mujica.Toaster(c); r.Action == "note-1" {
// restore the note
}`,
Keys: "Toasts never take the focus. Tab reaches their buttons after the page; Escape closes the focused toast. Hover or focus pauses its timer.",
},
{
ID: "093", Name: "Progress", ZhName: "进度条 环形进度 计量条", Category: "Feedback",
Demo: progressDemo,
Code: `mujica.Progress(c, 0.62, mujica.ProgressOptions{Label: "Uploading", ShowValue: true})
mujica.Progress(c, 0, mujica.ProgressOptions{Shape: mujica.ProgressRing, Indeterminate: true, Label: "Connecting"})
mujica.Meter(c, 93, mujica.MeterOptions{
Max: 100, Label: "Disk", ShowValue: true,
Levels: &ui.MeterLevels{Warning: 70, Critical: 90},
})`,
Keys: "Not focusable; assistive technology reads its name and value.",
},
{
ID: "094", Name: "Spinner", ZhName: "加载标记 加载中 转圈", Category: "Feedback",
Demo: spinnerDemo,
Code: `mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true})`,
Keys: "Not focusable; read as an indeterminate progress indicator named by Label.",
},
{
ID: "095", Name: "Skeleton", ZhName: "骨架屏 占位", Category: "Feedback",
Demo: skeletonDemo,
Code: `ui.Row(c).Gap(12).Children(func() {
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle})
ui.Column(c).Grow(1).Children(func() {
mujica.Skeleton(c, mujica.SkeletonOptions{Lines: 2})
})
})`,
Keys: "Decorative; not focusable and hidden from assistive technology.",
},
{
ID: "096", Name: "EmptyState", ZhName: "空状态 无数据 无搜索结果", Category: "Feedback",
Demo: emptyStateDemo,
Code: `r := mujica.EmptyState(c, mujica.EmptyStateOptions{
Kind: mujica.EmptyNoResults,
Query: query,
Action: "Clear search",
})
if r.Action {
query = ""
}`,
Keys: "Tab reaches the action button; Enter or Space presses it.",
},
{
ID: "097", Name: "Result", ZhName: "结果页 成功 失败 无权限", Category: "Feedback",
Demo: resultDemo,
Code: `r := mujica.Result(c, mujica.ResultOptions{
Kind: mujica.ResultForbidden,
Description: "You need the Editor role to change billing.",
Actions: []string{"Request access", "Go back"},
})
switch r.Action {
case 0: // send the request
case 1: // navigate back
}`,
Keys: "Tab moves through the actions, primary first; Enter or Space presses one.",
},
{
ID: "098", Name: "StatusIndicator", ZhName: "状态标记 在线 离线", Category: "Feedback",
Demo: statusDemo,
Code: `mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: "API online"})
mujica.StatusIndicator(c, mujica.StatusOffline, mujica.StatusIndicatorOptions{})`,
Keys: "Not focusable; read as a status with its word.",
},
{
ID: "099", Name: "NotificationCenter", ZhName: "通知中心 消息列表", Category: "Feedback",
Demo: notificationDemo,
Code: `r := mujica.NotificationCenter(c, &items, mujica.NotificationCenterOptions{})
if r.Action == "a" {
// open the build log
}`,
Keys: "The list is one Tab stop. Arrows move among notifications and their buttons; Enter marks the focused one read; Delete or Backspace removes it.",
},
{
ID: "100", Name: "LoadingOverlay", ZhName: "加载遮罩 区域加载", Category: "Feedback",
Demo: loadingOverlayDemo,
Code: `r := mujica.LoadingOverlay(c, loading, mujica.LoadingOverlayOptions{Cancel: "Cancel"}, func() {
ui.Text(c, "Quarterly report")
mujica.Button(c, "Export", mujica.ButtonOptions{})
})
if r.Canceled {
loading = false
}`,
Keys: "While loading, the covered region takes no input; focus inside moves to Cancel, and Escape there cancels. The rest of the window stays usable.",
},
}
+401
View File
@@ -0,0 +1,401 @@
package main
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/theme"
)
// feedbackSection titles a part of a demo.
func feedbackSection(c *ui.Context, title string, fn func()) {
ui.Column(c).Gap(8).FillWidth().Children(func() {
ui.Text(c, title).FontSize(theme.CaptionSize).TextColor(mujica.Tokens(c).TextMuted)
fn()
})
}
var feedbackSeverities = []mujica.Severity{mujica.SeverityInfo, mujica.SeveritySuccess, mujica.SeverityWarning, mujica.SeverityError}
func alertDemo(c *ui.Context, log func(string)) {
type state struct {
sev int
title, action bool
closable bool
open bool
}
s := ui.Local(c.Root(), "091", func() state { return state{sev: 3, title: true, action: true, closable: true, open: true} })
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Parameters", func() {
ui.Segmented(c, &s.sev, "Info", "Success", "Warning", "Error")
ui.Row(c).Gap(16).Children(func() {
ui.Checkbox(c, &s.title, "Title")
ui.Checkbox(c, &s.action, "Action")
ui.Checkbox(c, &s.closable, "Closable")
})
})
feedbackSection(c, "Live", func() {
opts := mujica.AlertOptions{Severity: feedbackSeverities[s.sev]}
if s.title {
opts.Title = "Import failed"
}
if s.action {
opts.Action = "Retry"
}
var open *bool
if s.closable {
open = &s.open
}
r := mujica.Alert(c, open, "The archive could not be read.", opts)
if r.Action {
log("alert: action Retry")
}
if r.Closed {
log("alert: closed")
}
if s.closable && !s.open && mujica.Button(c, "Show again", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.open = true
log("alert: reopened")
}
})
feedbackSection(c, "Severities", func() {
for _, sev := range feedbackSeverities {
mujica.Alert(c, nil, "Severity "+sev.String()+" uses its own icon, bar and name.", mujica.AlertOptions{Severity: sev}).Element.Key(sev)
}
})
})
}
func toastDemo(c *ui.Context, log func(string)) {
type state struct {
sev int
action bool
seconds float64
n int
note string
}
s := ui.Local(c.Root(), "092", func() state { return state{seconds: 5} })
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Parameters", func() {
ui.Segmented(c, &s.sev, "Info", "Success", "Warning", "Error")
ui.Checkbox(c, &s.action, "Undo action")
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf("Duration %.0f s (errors stay until closed)", s.seconds))
ui.Slider(c, &s.seconds, 1, 10).Width(200)
})
})
feedbackSection(c, "Actions", func() {
ui.Row(c).Gap(12).Wrap().Children(func() {
if mujica.Button(c, "Show toast", mujica.ButtonOptions{}).Clicked() {
s.n++
m := mujica.ToastMessage{ID: fmt.Sprint(s.n), Severity: feedbackSeverities[s.sev], Message: fmt.Sprintf("Message %d", s.n)}
if s.action {
m.Action = "Undo"
}
if m.Severity != mujica.SeverityError {
m.Duration = time.Duration(s.seconds * float64(time.Second))
}
mujica.ShowToast(c, m)
log("toast: show " + m.ID)
}
if mujica.Button(c, "Show five at once", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
for range 5 {
s.n++
mujica.ShowToast(c, mujica.ToastMessage{ID: fmt.Sprint(s.n), Message: fmt.Sprintf("Queued %d", s.n)})
}
log("toast: five queued, three visible")
}
if mujica.Button(c, "Repeat same ID", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.n++
mujica.ShowToast(c, mujica.ToastMessage{ID: "sync", Message: fmt.Sprintf("Synced %d times", s.n)})
log("toast: dedupe id sync")
}
})
ui.Text(c, "Type here while toasts appear; the focus stays:")
ui.TextInput(c, &s.note).Width(320)
})
})
if r := mujica.Toaster(c); r.Action != "" {
log("toast: action " + r.Action)
} else if r.Closed != "" {
log("toast: closed " + r.Closed)
}
}
func progressDemo(c *ui.Context, log func(string)) {
type state struct {
value, meter float64
indet, showValue bool
ring int
}
s := ui.Local(c.Root(), "093", func() state { return state{value: 0.62, meter: 72, showValue: true} })
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Parameters", func() {
ui.Segmented(c, &s.ring, "Linear", "Ring")
ui.Row(c).Gap(16).Children(func() {
if ui.Checkbox(c, &s.indet, "Indeterminate").Changed() {
log(fmt.Sprintf("progress: indeterminate %v", s.indet))
}
ui.Checkbox(c, &s.showValue, "Show value")
})
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
ui.Text(c, "Value")
if ui.Slider(c, &s.value, 0, 1).Width(240).Changed() {
log(fmt.Sprintf("progress: value %.2f", s.value))
}
})
})
feedbackSection(c, "Progress", func() {
mujica.Progress(c, s.value, mujica.ProgressOptions{
Shape: mujica.ProgressShape(s.ring), Indeterminate: s.indet, Label: "Uploading", ShowValue: s.showValue,
})
})
feedbackSection(c, "Meter with thresholds 70 and 90", func() {
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
ui.Text(c, "Level")
if ui.Slider(c, &s.meter, 0, 100).Width(240).Changed() {
log(fmt.Sprintf("meter: value %.0f", s.meter))
}
})
v := float64(int(s.meter))
mujica.Meter(c, v, mujica.MeterOptions{Max: 100, Label: "Disk", ShowValue: true, Levels: &ui.MeterLevels{Warning: 70, Critical: 90}})
mujica.Meter(c, 100-v, mujica.MeterOptions{Max: 100, Label: "Battery (low is bad)", ShowValue: true, Levels: &ui.MeterLevels{Warning: 30, Critical: 10}})
})
})
}
func spinnerDemo(c *ui.Context, log func(string)) {
type state struct {
size float64
showLabel, active bool
}
s := ui.Local(c.Root(), "094", func() state { return state{size: 20, showLabel: true, active: true} })
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Parameters", func() {
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf("Size %.0f", s.size))
ui.Slider(c, &s.size, 12, 48).Width(200)
})
ui.Row(c).Gap(16).Children(func() {
ui.Checkbox(c, &s.showLabel, "Show label")
if ui.Checkbox(c, &s.active, "Loading (unchecked stops the animation)").Changed() {
log(fmt.Sprintf("spinner: loading %v", s.active))
}
})
})
feedbackSection(c, "Live", func() {
if s.active {
mujica.Spinner(c, mujica.SpinnerOptions{Size: float32(s.size), Label: "Syncing", ShowLabel: s.showLabel})
} else {
ui.Text(c, "Done")
}
})
feedbackSection(c, "In a button", func() {
mujica.Button(c, "Saving", mujica.ButtonOptions{Loading: true})
})
})
}
func skeletonDemo(c *ui.Context, log func(string)) {
type state struct {
lines float64
loading bool
}
s := ui.Local(c.Root(), "095", func() state { return state{lines: 3, loading: true} })
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Parameters", func() {
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf("Lines %.0f", s.lines))
ui.Slider(c, &s.lines, 1, 6).Width(200)
})
if ui.Checkbox(c, &s.loading, "Loading").Changed() {
log(fmt.Sprintf("skeleton: loading %v", s.loading))
}
})
feedbackSection(c, "Card (same size loading and loaded)", func() {
ui.Row(c).Gap(12).Padding(16).Width(420).Background(mujica.Tokens(c).Surface).Radius(theme.CardRadius).Children(func() {
if s.loading {
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonCircle})
ui.Column(c).Grow(1).Children(func() {
mujica.Skeleton(c, mujica.SkeletonOptions{Lines: int(s.lines)})
})
return
}
ui.Box(c).Size(40, 40).Radius(20).Background(mujica.Tokens(c).Selection)
ui.Column(c).Grow(1).Children(func() {
for i := range int(s.lines) {
ui.Text(c, fmt.Sprintf("Loaded line %d", i+1)).LineHeight(theme.LineHeight)
}
})
})
})
feedbackSection(c, "Rect", func() {
mujica.Skeleton(c, mujica.SkeletonOptions{Shape: mujica.SkeletonRect, Width: 420, Height: 96})
})
})
}
func emptyStateDemo(c *ui.Context, log func(string)) {
type state struct {
kind int
query string
icon, action bool
}
s := ui.Local(c.Root(), "096", func() state { return state{kind: 1, query: "quarterly", icon: true, action: true} })
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Parameters", func() {
ui.Segmented(c, &s.kind, "No data", "No results")
ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() {
ui.Checkbox(c, &s.icon, "Icon")
ui.Checkbox(c, &s.action, "Action")
if s.kind == 1 {
ui.TextInput(c, &s.query).Width(200)
}
})
})
feedbackSection(c, "Live", func() {
opts := mujica.EmptyStateOptions{Kind: mujica.EmptyKind(s.kind), NoIcon: !s.icon}
if s.kind == 1 {
opts.Query = s.query
}
if s.action {
opts.Action = []string{"New project", "Clear search"}[s.kind]
}
if mujica.EmptyState(c, opts).Action {
log("empty state: action " + opts.Action)
s.query = ""
}
})
})
}
func resultDemo(c *ui.Context, log func(string)) {
type state struct{ kind int }
s := ui.Local(c.Root(), "097", func() state { return state{kind: 2} })
descs := []string{"Your order has been placed.", "The payment was declined by the bank.", "You need the Editor role to change billing."}
actions := [][]string{{"View order", "Keep shopping"}, {"Try again", "Back to cart"}, {"Request access", "Go back"}}
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Parameters", func() {
ui.Segmented(c, &s.kind, "Success", "Failure", "Forbidden")
})
r := mujica.Result(c, mujica.ResultOptions{Kind: mujica.ResultKind(s.kind), Description: descs[s.kind], Actions: actions[s.kind]})
if r.Action >= 0 {
log("result: action " + actions[s.kind][r.Action])
}
})
}
func statusDemo(c *ui.Context, log func(string)) {
type state struct {
status int
custom bool
}
s := ui.Local(c.Root(), "098", func() state { return state{} })
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Parameters", func() {
if ui.Segmented(c, &s.status, "Success", "Warning", "Error", "Offline", "Busy").Changed() {
log("status: " + mujica.Status(s.status).String())
}
ui.Checkbox(c, &s.custom, "Custom label")
})
feedbackSection(c, "Live", func() {
opts := mujica.StatusIndicatorOptions{}
if s.custom {
opts.Label = "Database replica"
}
mujica.StatusIndicator(c, mujica.Status(s.status), opts)
})
feedbackSection(c, "All states (shape and word, not color alone)", func() {
ui.Row(c).Gap(20).Wrap().Children(func() {
for st := mujica.StatusSuccess; st <= mujica.StatusBusy; st++ {
mujica.StatusIndicator(c, st, mujica.StatusIndicatorOptions{}).Key(st)
}
})
})
})
}
func notificationDemo(c *ui.Context, log func(string)) {
type state struct {
items []mujica.Notification
n int
}
s := ui.Local(c.Root(), "099", func() state {
return state{items: []mujica.Notification{
{ID: "a", Title: "Build failed", Body: "main · 3 tests failing", Time: "2 min ago", Action: "View log"},
{ID: "b", Title: "Invite accepted", Body: "Mira joined the workspace", Time: "1 h ago"},
{ID: "c", Title: "Backup complete", Time: "Yesterday", Read: true},
}}
})
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Caller-owned list", func() {
if mujica.Button(c, "Add notification", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.n++
id := fmt.Sprintf("new-%d", s.n)
s.items = append([]mujica.Notification{{ID: id, Title: fmt.Sprintf("Comment %d on the design doc", s.n), Time: "now"}}, s.items...)
log("notifications: added " + id)
}
})
ui.Column(c).Width(520).Height(420).Children(func() {
r := mujica.NotificationCenter(c, &s.items, mujica.NotificationCenterOptions{})
for _, ev := range [][2]string{{"action", r.Action}, {"toggled", r.Toggled}, {"opened", r.Opened}, {"removed", r.Removed}} {
if ev[1] != "" {
log("notifications: " + ev[0] + " " + ev[1])
}
}
if r.MarkedAllRead {
log("notifications: marked all read")
}
if r.ClearedAll {
log("notifications: cleared all")
}
})
})
}
func loadingOverlayDemo(c *ui.Context, log func(string)) {
type state struct {
loading, cancel bool
saves int
}
s := ui.Local(c.Root(), "100", func() state { return state{loading: true, cancel: true} })
ui.Column(c).Gap(16).FillWidth().Children(func() {
feedbackSection(c, "Parameters", func() {
ui.Row(c).Gap(16).Children(func() {
if ui.Checkbox(c, &s.loading, "Loading").Changed() {
log(fmt.Sprintf("overlay: loading %v", s.loading))
}
ui.Checkbox(c, &s.cancel, "Cancel action")
})
})
feedbackSection(c, "Covered region", func() {
opts := mujica.LoadingOverlayOptions{Label: "Generating report…"}
if s.cancel {
opts.Cancel = "Cancel"
}
r := mujica.LoadingOverlay(c, s.loading, opts, func() {
ui.Column(c).Gap(8).Padding(16).FillWidth().AlignItems(ui.Start).Background(mujica.Tokens(c).Surface).Radius(theme.CardRadius).Children(func() {
ui.Text(c, "Quarterly report").Font(theme.SerifFont).FontSize(theme.H3Size)
ui.Text(c, fmt.Sprintf("Exported %d times.", s.saves))
if mujica.Button(c, "Export", mujica.ButtonOptions{}).Clicked() {
s.saves++
log("overlay: export clicked")
}
})
})
r.Element.Width(420)
if r.Canceled {
s.loading = false
log("overlay: canceled")
}
})
feedbackSection(c, "Outside the region (always usable)", func() {
if mujica.Button(c, "Outside button", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() {
log("overlay: outside clicked")
}
})
})
}
+1 -1
View File
@@ -108,7 +108,7 @@ func TestLoadingOverlaySnapshot(t *testing.T) {
snapshot(t, "loading_overlay", 420, 220, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
LoadingOverlay(c, true, LoadingOverlayOptions{Cancel: "Cancel"}, func() {
ui.Column(c).Padding(16).Gap(8).Background(Tokens(c).Surface).Radius(4).Children(func() {
ui.Column(c).Padding(16).Gap(8).AlignItems(ui.Start).Background(Tokens(c).Surface).Radius(4).Children(func() {
ui.Text(c, "Quarterly report")
ui.Text(c, "Revenue, churn and pipeline for Q3.")
Button(c, "Export", ButtonOptions{})
+3 -1
View File
@@ -97,7 +97,9 @@ func TestProgressAndMeterPanics(t *testing.T) {
"progress NaN": func(c *ui.Context) { Progress(c, math.NaN(), ProgressOptions{}) },
"meter empty range": func(c *ui.Context) { Meter(c, 5, MeterOptions{Min: 10, Max: 10}) },
"meter outside": func(c *ui.Context) { Meter(c, 2, MeterOptions{}) },
"meter levels": func(c *ui.Context) { Meter(c, 5, MeterOptions{Max: 10, Levels: &ui.MeterLevels{Warning: 8, Critical: 12}}) },
"meter levels": func(c *ui.Context) {
Meter(c, 5, MeterOptions{Max: 10, Levels: &ui.MeterLevels{Warning: 8, Critical: 12}})
},
} {
func() {
defer func() {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB