From 9ce5953640aa9147943385dc685222baf0490d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 12:19:28 -0400 Subject: [PATCH] WIP feedback 3 --- docs/components/091-alert.md | 51 +++ docs/components/092-toast.md | 58 +++ docs/components/093-progress.md | 64 +++ docs/components/094-spinner.md | 39 ++ docs/components/095-skeleton.md | 45 ++ docs/components/096-empty-state.md | 48 +++ docs/components/097-result.md | 46 ++ docs/components/098-status-indicator.md | 37 ++ docs/components/099-notification-center.md | 51 +++ docs/components/100-loading-overlay.md | 48 +++ example_feedback_test.go | 145 +++++++ examples/gallery/pages_feedback.go | 112 ++++- examples/gallery/pages_feedback_demos.go | 401 ++++++++++++++++++ loading_overlay_test.go | 2 +- progress_test.go | 4 +- .../snapshots/darwin/loading_overlay-dark.png | Bin 8772 -> 9505 bytes .../darwin/loading_overlay-light.png | Bin 8491 -> 9241 bytes 17 files changed, 1148 insertions(+), 3 deletions(-) create mode 100644 docs/components/091-alert.md create mode 100644 docs/components/092-toast.md create mode 100644 docs/components/093-progress.md create mode 100644 docs/components/094-spinner.md create mode 100644 docs/components/095-skeleton.md create mode 100644 docs/components/096-empty-state.md create mode 100644 docs/components/097-result.md create mode 100644 docs/components/098-status-indicator.md create mode 100644 docs/components/099-notification-center.md create mode 100644 docs/components/100-loading-overlay.md create mode 100644 example_feedback_test.go create mode 100644 examples/gallery/pages_feedback_demos.go diff --git a/docs/components/091-alert.md b/docs/components/091-alert.md new file mode 100644 index 0000000..fef8f3d --- /dev/null +++ b/docs/components/091-alert.md @@ -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`。 diff --git a/docs/components/092-toast.md b/docs/components/092-toast.md new file mode 100644 index 0000000..63a6f59 --- /dev/null +++ b/docs/components/092-toast.md @@ -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` 的视图时消息仍在队列中,回来后继续显示。 diff --git a/docs/components/093-progress.md b/docs/components/093-progress.md new file mode 100644 index 0000000..8609972 --- /dev/null +++ b/docs/components/093-progress.md @@ -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` 元素绘制。 diff --git a/docs/components/094-spinner.md b/docs/components/094-spinner.md new file mode 100644 index 0000000..06d612f --- /dev/null +++ b/docs/components/094-spinner.md @@ -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 的分步重绘;只在显示时产生开销。 diff --git a/docs/components/095-skeleton.md b/docs/components/095-skeleton.md new file mode 100644 index 0000000..4e7e7e1 --- /dev/null +++ b/docs/components/095-skeleton.md @@ -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 的说明文字。 diff --git a/docs/components/096-empty-state.md b/docs/components/096-empty-state.md new file mode 100644 index 0000000..4c4d822 --- /dev/null +++ b/docs/components/096-empty-state.md @@ -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。 diff --git a/docs/components/097-result.md b/docs/components/097-result.md new file mode 100644 index 0000000..30d0285 --- /dev/null +++ b/docs/components/097-result.md @@ -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 触发。 + +## 限制 + +- 不含路由;导航由调用方完成。 diff --git a/docs/components/098-status-indicator.md b/docs/components/098-status-indicator.md new file mode 100644 index 0000000..63d659d --- /dev/null +++ b/docs/components/098-status-indicator.md @@ -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 要求不能只靠颜色。 diff --git a/docs/components/099-notification-center.md b/docs/components/099-notification-center.md new file mode 100644 index 0000000..95b16fa --- /dev/null +++ b/docs/components/099-notification-center.md @@ -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 清除当前消息。 + +## 限制 + +- 不分页,也不虚拟化;面向几十到几百条的应用内消息。 +- 时间文字由调用方格式化。 diff --git a/docs/components/100-loading-overlay.md b/docs/components/100-loading-overlay.md new file mode 100644 index 0000000..e94b819 --- /dev/null +++ b/docs/components/100-loading-overlay.md @@ -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 序列。 + +## 限制 + +- 只遮盖容器自身范围;整窗阻塞请使用模态对话框。 diff --git a/example_feedback_test.go b/example_feedback_test.go new file mode 100644 index 0000000..cd49f2e --- /dev/null +++ b/example_feedback_test.go @@ -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) +} diff --git a/examples/gallery/pages_feedback.go b/examples/gallery/pages_feedback.go index f83272f..ea89ade 100644 --- a/examples/gallery/pages_feedback.go +++ b/examples/gallery/pages_feedback.go @@ -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.", + }, +} diff --git a/examples/gallery/pages_feedback_demos.go b/examples/gallery/pages_feedback_demos.go new file mode 100644 index 0000000..f7c6cbd --- /dev/null +++ b/examples/gallery/pages_feedback_demos.go @@ -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") + } + }) + }) +} diff --git a/loading_overlay_test.go b/loading_overlay_test.go index d3adc7b..67afefb 100644 --- a/loading_overlay_test.go +++ b/loading_overlay_test.go @@ -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{}) diff --git a/progress_test.go b/progress_test.go index de54799..78c5bdf 100644 --- a/progress_test.go +++ b/progress_test.go @@ -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() { diff --git a/testdata/snapshots/darwin/loading_overlay-dark.png b/testdata/snapshots/darwin/loading_overlay-dark.png index 2d1454337a8779291f76ba7311199d3fd9c78341..9f0ba2e4b90b348ec4efe3c1a59cb278e9817cda 100644 GIT binary patch delta 6638 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 WUEif6&sI~tYoVs3tylrE4*oB0bBC?~ delta 5990 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

eONczq8F<{GUd?(v-({8cX0gDF436BJ|jP_*)DU z$e*D8fCwwmcEWCHnf*m=8`p`Jmh^(jSw_o5sFD=sZ9wXCG;a1LmG3lj?oyunND5+v)7_GnA!1cu{f zRDoEIgwF5dKffr#rz~yFdH9t%VvmpY^G}O(iysny>{u~iEFnf_Q^$0->qO)m-k)y;U)04ftBd&*Dp7))VaUgtNipb=|(h%G4@8CaQtj$yJA z!4`=g8L1r|9j!n~M}W`Vc$X{P??1Av#ctw(9>oxTS{j_7`n7O&R*z1eM ziR$Wk>u>_iZ*cg;pgAcCkhFW^Q#FBggKx5=uu}Bzx$`HX-V{ZJhDLW;Syw#}a=ste zD)dB$^4`6Y!Xh$~I9%)9z0o$Gq^w-3%ZN{gcpjaR5oVz;FMk{q{y@}-eP|}n&Q5JQ zV#wfz%cbfCsI>ZWsfp=h#n{NJqWVa zRZ(FKwn)=Pe^qdm=TD-K5WyuITg)$O`+H9XGcvIDwj{SG-#(c!^X?YDSb@u1#)KLy zF72JLW%ZB2)=HRxc8o&?>4lE|mv~;*#KgqTep1#a<{!i^6BJu@Q%l3gU}B$gP3pTh z+N1kvRv{}UY}nW)h%GkN9_b+kpbRD<5O~c}kTyBmP1Jfw)!iH6 zQ!{XkuqAJ)^?~<29qZnJ@+MeuD7kziz{jtnr}|M>*V7`%CnW_g9UW(2L?T-x)DHdggO1n5FSeR~9b7b+i<+ zggBk>7~67DQR9!I_$2Qo?v~erL~_X&>b$~vUQJC+*$QsB5y8LYVqnDRSZT~2Zp>^U zD)vS7{nN;?u~dPCc$aWGlN5bEI|$^pt`ksP^kb@hd9&Tt(RPOqTcdLIh=Mp*t5CNX zHo;(IXsA#RLFX>U=6O%;o+r)A_jakSsHYAm}f3-u#H;r7W8X;lFh;u=xxFr zYt!3O{Z_%y|4MC+Z2e#Lb@8O@E}oA1TIdCC#2d?F&rzB6EI zg&2;B0QD7nYK1$2sRot^B~5B2irkv)#l!Rng6aA>2ds&+XyRBn0;c98bw#MuvFupS zIyk@*)v*<+!PMT2+Gb9~6TDp~GbohqbQ2>XXJXWJ-T%25`wY{5^*N9o+ht|^>oy_b zGC*h=FlHYb5n&-9gX?f%M6*%QS@LacQx$$6nFQ$S5R0dq6xNp^eHJin{Xi)j$$mwV zDL|a`yXr+$owT<@9noJ=!2i|U{Sr$aC9$Jp>B4}TbIRsYE-vH~_qt|IJ691PDB6$I zuj^Us$lWV>`&x;O8xx_seo%+^hbDofgVP&dl>46uUH>C6OkfrG_#_l@*I5{VP5zJ5 zkXf%mW2efolxZ++_O~+csfZRqnRn4oIJL5)kA_JlK!323-0O?vOh;n$a`)b_JA={= z4TZhQ|27XiX6O&~m4aP#6ye`@FH#V&hrwISx&D`;nIg$<-@c6{6T}Id&3tqBgQCv3 zGel2q-)Jstwn z6i=6OvPVqMw2C&3QjL4LbT~*}-%~+VT=qC2o>jgZIR8ZTF#3k`@pk3it}%)mF{z;B z4ZJg~d8WySjVk;-X<@q0IFYry1o3k*S3v6WzqN)^>;S3h4f-gO?_1V+L{2cy)lZP3?hhH6$~d znbFeHhEr%24ujmWmzaWuz`$dA;mkCVb?(Cpi(ay2tofhEsa0IFkiU1zSkqrNJp27$176zn27 zdD#2n048;@e%Vo=YWVXV!muhIKw|vEB1XQODz<9rVKq7~Y>e z`xC@<+P<>-g)n`-$WNcF87H}U0P(e|K4}IaHWFk75lfn&^?qwOtx9Yxc(Bji9-kCz$%k`^e|(2(Y#R1<6uJqWAFw zgKfxiu4Mn{3GT3>LZSJmV9nw3ho5td?sWdN5kxclo$nOj$!M6GN==7F>lTYKL|{SB z=}0X2{wT6T6*}*}=-VIfkJyo-=I$;{Sedx(W(w!`NqJ#CG@|c|hy-=i={kvVjT?cJ zcw5%p#C=iEs;hyTPx7+##g)%qTT6+g`x5_AO_vK3I3_1pVnXjw%O`a`*?KEpwF)PP zPJAviBw6bBz|5%N>W$)1JxL#jeT$DK^ z+#qb)7RK~&A(%pojgq29^O)qh5G5Cl;(d~*AE{U7F-YRJ@?Zb9eHOy`a#MIhbLp=4)lGn=2-1Qhi zPL)dgq=0s>2ZekCViL7P7l~A3cJs2k27kHseha=LP=DM)?mG*t6$&`_&Umel0jKCp zV1HLZZHR`}XsdoN$$j?1zG1V$>m+PyH@nu_b$%Zcmt^Xdr|X*9v%Fs3;EZlRQ_sAZ zj8pfUKNy>lxatnt@;Nf5xX~x=zB67)1RU-G#cS&ym@0~IK&?Qsu`j41a@b~(woJnC z`N<|nFYFUFkoRG6I(TJc_WUrZ@i-yhHsS?3PemZ(+1`>%*wKcQN#iBV$>-u;-YML5 zwjR16KG5lTy6E5wN&Q??e!jn3wIbki<=yu}^ZXb4!!pwbOM_)3d8P-tC)2ogA6*Wl zc^WEvt_`o4`7JF#%p&g#2kq-gX`r+f3VilxH?>r@E=(X`#;azc?;NDMViIJXyLS2Z zA=5H69Sp29mMU?U6@yS(AK6b_ffhzuoqXUHM_^+Uzgw`Rw(GVWaZHEpIBy3js)n(e z5Bw97@Up|R`BB_z?XNwK&kL6t7eRpbQY$)BH`V(Kxs&nPBHB#HyDqIuK*IHssJy5l zd2it56=&c*X+Krc&qM16q{s@03h5NPL85}Dak;mhd72;4^epqTZz2a+9zD49298?` zq;d|UeZ#oE^K@esPcRLB9K3moAAju{+hom{flF#TZX@!udbxy2x#bz!;sK$d_r$C+ zb;ZKEpzj(Pcu|(=T2;%dIxJ<-P_SEjH-lOZhq@Lw$I|CD4pKB8%pv6>QH@;E%^2dS zn4_tesvQ)SeK_j+5P15z&B7s%>@^&6(lILC9U_KWiZ2~VIZi9A%- zPS--2NhjoJE1&2?91|f13W;|OUT`&tB%l+W?Sp@MRy2hWASP&!1*?$Vjs?P?&TmU2 z!x@PMwi(urRjedJ^%KtLwHR>&YMZsUKSm8hLs*x(-(d&72Po#W7Qw3%%_6H@{Mpon zV^&f;&+dYA5Ht&?z<~!H4gDyWwQ0^ZXcO5ugciijy#jqL!ObXgmps{a^)tan88sW0 z^rw}$UjwZE@gNVa!u;{UfLvqjyd5m9HP5zx(~z`pM^eqszli-i_UCl0qf!QT|%4yWtB@ z!fvv<$ig^?y3H%trIScKVB#Qs(w&$RG8gI<_lf-g;uxeLhf*3i3udkta~#B z0$BRfu09;vT8B&qvAx+PB>H8XcRqlFM}&nEfMoXzooqf#ZZS;1kRkemX9ExVnb9*W z#JU?JC%pT_V(M>TakIg;9hx7u(BQ@>Z*6C4y8P0^4@>|KAgHMq+VFKu*ZY^xgVk!t z*xy^>)<=&08uWPYsYH5zFLq=pk1v9$Jd3d3%O%uET>~W0`2|>fd$jDr5aO6)HNh{e zszsRj>g>YcL0+`hvMuFHnSOg4TP({nWqQ9TvZk#&m~x-5H3eZ{UT=lgG`*OW`LDDD z0Z)KWyL^v8%(<;R8-8Vji5G4;eRS=l$RCg~*LuR-IjP8G%|DSRHe|!P3|FFsZ>A%XTe^`VognuI%;NNTR|7;ETw|MdY?-Jmj z7Vy9Fu=}q{Bji7;_V!Z delta 5872 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