@mdbase-dev/ui 0.1.0-beta.126 → 0.1.0-beta.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +168 -0
  2. package/css/collections.css +978 -0
  3. package/css/controls.css +33 -1
  4. package/css/screens.css +44 -2
  5. package/dist/AppSwitcher.js +187 -33
  6. package/dist/Checkbox.d.ts +14 -0
  7. package/dist/Checkbox.js +32 -0
  8. package/dist/CollectionModeControl.d.ts +11 -0
  9. package/dist/CollectionModeControl.js +290 -0
  10. package/dist/CommandPalette.d.ts +4 -2
  11. package/dist/CommandPalette.js +275 -103
  12. package/dist/Dialog.js +72 -25
  13. package/dist/Feedback.js +763 -229
  14. package/dist/FeedbackMarkup.d.ts +1 -1
  15. package/dist/FeedbackMarkup.js +326 -125
  16. package/dist/FeedbackVerification.js +115 -68
  17. package/dist/SaveNotice.js +71 -28
  18. package/dist/Select.js +183 -135
  19. package/dist/SelectList.js +59 -14
  20. package/dist/ThemeSelect.js +55 -4
  21. package/dist/apps.d.ts +1 -0
  22. package/dist/apps.js +50 -29
  23. package/dist/brand.js +251 -68
  24. package/dist/codemirror.js +116 -87
  25. package/dist/collections/CollectionGate.d.ts +52 -0
  26. package/dist/collections/CollectionGate.js +456 -0
  27. package/dist/collections/CollectionSaveWarning.d.ts +10 -0
  28. package/dist/collections/CollectionSaveWarning.js +28 -0
  29. package/dist/collections/CollectionSwitcher.d.ts +51 -0
  30. package/dist/collections/CollectionSwitcher.js +598 -0
  31. package/dist/collections/SetupReview.d.ts +9 -0
  32. package/dist/collections/SetupReview.js +216 -0
  33. package/dist/collections/copy.d.ts +100 -0
  34. package/dist/collections/copy.js +937 -0
  35. package/dist/collections/dialogs.d.ts +27 -0
  36. package/dist/collections/dialogs.js +169 -0
  37. package/dist/collections/model.d.ts +146 -0
  38. package/dist/collections/model.js +337 -0
  39. package/dist/collections/mount.d.ts +19 -0
  40. package/dist/collections/mount.js +56 -0
  41. package/dist/collections/parts.d.ts +86 -0
  42. package/dist/collections/parts.js +439 -0
  43. package/dist/collections/testing.d.ts +19 -0
  44. package/dist/collections/testing.js +39 -0
  45. package/dist/collections/use-collection-ui.d.ts +140 -0
  46. package/dist/collections/use-collection-ui.js +236 -0
  47. package/dist/collections.d.ts +19 -0
  48. package/dist/collections.js +13 -0
  49. package/dist/feedback-data.d.ts +13 -12
  50. package/dist/feedback-data.js +365 -168
  51. package/dist/flock.d.ts +63 -0
  52. package/dist/flock.js +140 -0
  53. package/dist/i18n.d.ts +6 -0
  54. package/dist/i18n.js +71 -0
  55. package/dist/locales/de/messages.json +1 -0
  56. package/dist/locales/en/messages.json +1 -0
  57. package/dist/locales/es/messages.json +1 -0
  58. package/dist/locales/fr/messages.json +1 -0
  59. package/dist/locales/it/messages.json +1 -0
  60. package/dist/locales/ja/messages.json +1 -0
  61. package/dist/locales/ko/messages.json +1 -0
  62. package/dist/locales/pt/messages.json +1 -0
  63. package/dist/locales/ru/messages.json +1 -0
  64. package/dist/locales/zh/messages.json +1 -0
  65. package/dist/mark-activity.js +82 -75
  66. package/dist/permission-selection.js +27 -17
  67. package/dist/popover.js +138 -130
  68. package/dist/screens.d.ts +20 -3
  69. package/dist/screens.js +275 -6
  70. package/dist/select-model.js +34 -28
  71. package/dist/text-diff.d.ts +1 -0
  72. package/dist/text-diff.js +106 -74
  73. package/dist/theme.js +34 -40
  74. package/package.json +56 -7
@@ -0,0 +1 @@
1
+ { "messages": { "-HLPDJ": ["正在检查集合设置…"], "-JeNKB": ["管理访问权限"], "-ujXuR": ["浏览器、操作系统、视口和近期错误代码。不包含笔记内容、路径或凭据。"], "06ZW-I": ["屏幕截图大于 3 MB。请选择较小的图片。"], "0LOjOR": ["无法在此浏览器中清理屏幕截图。"], "0MeszW": [["app"], " 无法在此处打开集合"], "0VE881": ["你和其他所有人都将立即无法在任何设备或应用中访问此集合。此操作无法撤销。"], "13DGsZ": ["正在完成切换。请保持所有者的设备在线。"], "15fVx1": ["已丢弃标记,未包含屏幕截图。"], "1AZHba": ["重置"], "1AjSFS": ["正在打开你的集合"], "1CWcjd": ["无法发送反馈。请检查网络连接后重试。"], "1MMyGb": ["可查看"], "1P-z0J": ["取消"], "25QKdy": ["已截取屏幕截图。共享已停止。"], "2UA-qq": ["移动"], "2apdee": ["正在打开本地集合…"], "2cqK5k": ["打开"], "2ulUVC": ["登录"], "2vTp2v": ["绘制"], "37unnc": ["无法保存屏幕截图。"], "38fGkV": ["在其他 mdbase 应用中打开"], "3FYCb9": ["请在所有者的一台设备上打开 mdbase connect,以完成切换。"], "3Yf9Gk": ["正在应用集合更新…"], "3w1RWH": ["正在等待设备更新。"], "3xE-Bk": ["正在处理…"], "45MZFs": ["命令"], "45a64b": ["在 ", ["app"], " 中打开你的集合"], "480CfR": ["这比平时花费的时间更长。"], "4GiuPT": ["集合存储模式"], "52vV7a": ["集合"], "54P3Y1": ["mdbase ", ["current"], ":在其他应用中打开此集合"], "5GwjfZ": ["选择其他集合"], "5u4cOF": ["无法截取屏幕截图。请重试。"], "6Qrxry": ["正在连接你的集合…"], "6rFO9K": ["已以 ", ["account"], " 身份登录"], "6vUPNB": ["无法截取屏幕截图。"], "7-61s4": ["包含技术诊断信息"], "75iTY6": ["(在新标签页中打开)"], "7INsea": ["你已在此设备上退出 ", ["app"], "。"], "7qrUoz": ["发生了问题。你可以通过“发送反馈”进行报告。"], "88OB58": ["请选择小于 3 MB 的屏幕截图。"], "88u4WE": ["反馈类型"], "8FvMqa": ["无法继续"], "8wcYeu": ["你的集合"], "9fWts8": ["删除 ", ["collection"], "?"], "9mrfxN": ["无法读取屏幕截图。"], "9uI_rE": ["撤销"], "9yK4bJ": ["返回工作"], "A--pCj": ["包含集合名称:"], "A3CVZb": ["要从此列表中移除 ", ["collection"], " 吗?"], "AA2dPS": ["在那里选择或创建集合。"], "AbNeva": ["搜索命令"], "AiGGIB": ["添加集合…"], "Arxv23": ["附加屏幕截图"], "B5WwSc": ["选择集合"], "B5v8nx": ["将发送的内容"], "BCJqT2": ["请选择小于 1600 万像素的屏幕截图。"], "BOOryO": ["选择图片"], "BcddXE": [["app"], " 需要先向此集合添加一些内容,才能使用它。"], "BfJnph": ["最多可以添加 100 处标记。撤销或重置后可继续添加。"], "CRx2Ry": ["重新截取屏幕截图"], "DJxqCW": ["正在截取屏幕截图…"], "DViXWV": ["没有匹配项"], "DWBQ45": ["此浏览器不支持屏幕捕获。你可以改为附加图片。"], "DXe-Ku": ["无法创建屏幕截图。"], "Dg50Vr": ["什么带来了改变?"], "Ds8lGj": ["所有者"], "EIDHh1": ["私有"], "EbTBqo": ["浅色"], "EfTCex": ["未截取屏幕截图。你仍可以发送反馈。"], "FHz4md": ["切换完成后,只有你的设备能够读取新内容。mdbase 可能仍能读取旧内容。"], "FKgWWQ": ["在 mdbase 窗口中继续"], "FPIkyE": ["深色"], "FZqxYo": ["正在打开 mdbase…"], "GaeNkI": ["分享你喜欢的内容"], "GnOlBF": ["包含屏幕截图"], "GtzbT7": ["感谢你的反馈。"], "H-19b6": ["正在等待集合密钥…"], "HVAS1o": ["你的消息、回复邮箱、附加的屏幕截图,以及这些应用和构建信息。"], "HfGSuv": ["无法打开 ", ["collection"]], "Hp1l6f": ["当前"], "IMgxe8": ["请重新加载以继续。"], "ITLqil": [["count", "plural", { "other": ["已应用 ", "#", " 处标记。"] }]], "IcLToz": ["正在同步你的集合…"], "IrbugM": ["保存在你的电脑上,不由 mdbase 存储"], "IseQAg": ["浏览和编辑记录"], "IspNTR": ["颜色主题"], "Iu3ZPh": ["无法确认进度。请刷新状态后重试。中断的切换可能已经开始。"], "IxyZXD": ["屏幕截图"], "J2kszk": ["添加私有集合"], "J9remp": ["在账户中移除此设备之前,它仍会显示在设备列表中。"], "JKLDUc": ["在此设备上继续"], "KDw4GX": ["重试"], "KZ2qhj": ["切换集合,当前:", ["collection"]], "LLAa_9": ["可选"], "LSW6J8": ["在那里查看对 ", ["collection"], " 的访问权限。"], "LTtPi0": ["关闭"], "LgBzMv": ["集合中的内容不会发生变化。"], "MSpXh1": ["正在加载集合快照…"], "MuHIRH": ["正在删除…"], "NNvuBg": [["collection"], " 的更多内容"], "Nj4zfV": ["已将屏幕截图附加到反馈"], "OFWAjq": ["集合"], "OQBR5K": ["发送超时。请重试。"], "P71euV": ["拖动以标记。键盘操作:使用方向键移动;按空格键开始或完成。遮盖会移除像素。"], "P74TOi": ["未保存"], "PCi-MK": ["正在退出登录…"], "PWh9Ii": ["应用更改"], "Qymvdk": ["无法在此浏览器中创建屏幕截图。"], "Rg_OK9": [["app"], " 需要先在 mdbase 中获得你的批准,才能打开集合。"], "RoafuO": ["发送反馈"], "Sad2tK": ["正在发送…"], "TKM0Sf": ["你希望改进什么?"], "UE2Ue9": ["删除集合…"], "URmyfc": ["详情"], "UbVgIu": ["关闭"], "UfEFtR": ["发送给 mdbase 团队的私密反馈。"], "UoMXZp": ["感谢你的赞许。"], "V8XQf0": ["未授予访问权限。你可以重试或选择其他集合。"], "VBhfTn": ["发送前请检查是否包含私人信息。"], "VON31G": ["取消切换"], "VSywLq": ["正在连接同步服务…"], "Vij4PB": ["需要决定"], "Vt4P0G": ["从此列表中移除"], "VyuLii": ["屏幕截图工具"], "WbUtOC": ["关闭 ", ["label"]], "WtBLy2": ["屏幕截图不是有效的 PNG 或 JPEG 图片。"], "XAe57G": ["系统"], "XDzAnp": ["在新标签页中打开"], "Y1f4bJ": ["退出登录"], "YN0Hkf": ["允许 ", ["app"], " 访问 ", ["collection"], "?"], "YQ2gus": ["请先更新以下设备或撤销其访问权限,再继续:"], "YZNdEb": ["正在设置…"], "YdeEye": ["正在打开 ", ["collection"]], "ZZ4v5y": [["0", "plural", { "other": ["还有 ", "#", " 行更改"] }]], "ZyjhMF": ["图片格式:PNG 或 JPEG,最大 3 MB。遮盖会替换像素;仅附加最终图片。"], "_Bhy0W": ["移除"], "_CVuTO": ["未更改的行"], "_qM4x2": ["重新打开 mdbase 窗口"], "aPgjJq": ["提出改进建议"], "ak4q13": ["重试验证"], "alKzE8": ["允许 ", ["app"], " 访问此集合?"], "bDIp4a": ["没有匹配“", ["query"], "”的集合。"], "bRZJdX": ["请选择 PNG 或 JPEG 屏幕截图。"], "bcDV8T": ["mdbase 将能够读取此集合及其现有历史记录,并在你的设备关闭时提供访问。"], "c3AIR_": ["ID ", ["id"]], "cM8Pei": ["屏幕截图标注画布"], "dEgA5A": ["取消"], "dirPmM": ["更改内容"], "eeDGOj": ["输入 ", ["collection"], " 以确认"], "ejJPcY": ["请在 ", ["0"], " 之后继续。"], "elJq5_": [["app"], " 已停止"], "fAUSZy": ["需要处理"], "fF_d4m": ["使用 mdbase 账户登录。你可以选择 ", ["app"], " 可使用的集合。"], "fnoot2": ["使用 ", ["app"], " 的版本"], "gtEbeW": ["高亮"], "gvuZjZ": ["无法加载验证。请检查网络连接后重试。"], "h3I7av": ["可编辑"], "hV3yAb": ["报告问题"], "hhTpZ9": ["查看访问权限"], "i5ZjUk": ["我们无法保证会回复。请勿用于安全问题报告或紧急支持。"], "iXx1Vf": ["正在检查设置将带来的更改…"], "idD8Ev": ["已保存"], "jRkyhM": ["删除集合"], "kC9O29": ["无法完成验证。请重试。"], "kHiGeJ": ["阅读和注释来源"], "kQhT09": ["为 ", ["app"], " 授予集合访问权限"], "ktO6Lq": ["这会如何帮助你?"], "kx0s-n": ["结果"], "lnc6-d": ["无法打开屏幕截图以添加标记。"], "lnj9gP": ["关闭反馈"], "lx5IrT": ["正在等待旧设备连接过期。切换完成之前,云端副本将保持启用。"], "lxkOy-": ["仍要退出登录"], "mRd7qL": ["无法发送反馈。请重试。"], "nVYD2-": ["启用云端副本"], "n_oBJu": ["重试"], "nb4xuL": [["app"], " 将不再在此设备上显示该集合。集合内容不会被删除,且 ", ["app"], " 仍保有访问权限。如需撤销访问权限,请使用“管理访问权限”。"], "ngIpWJ": ["感谢你的想法。"], "njBr8W": ["例如:you@example.com"], "nne584": ["哪些方面对你很有帮助?"], "o7QK4c": ["遮盖"], "oKQdii": ["云端副本"], "oU3FgQ": ["查找集合"], "pM91Yr": ["继续"], "pqfLKM": ["重新加载"], "qOfTOd": ["正在启动 ", ["app"], "…"], "r4xubm": ["正在打开…"], "rO1a9-": ["设为私有"], "rqijZ-": ["正在检查集合是否可读…"], "sHfOYg": ["登录并在那里选择集合。"], "semCbT": ["未添加集合。"], "t7D5Ym": ["为 ", ["app"], " 设置 ", ["collection"]], "tHHSXx": ["刷新状态"], "tHdrmM": ["已私密发送给 mdbase 团队。"], "t_YqKh": ["移除"], "uF9ruK": ["正在保存"], "uby1H2": ["私有"], "vAr5Dp": ["存储模式"], "vDwCOr": ["mdbase 应用"], "vTX5bg": ["未授予访问权限。"], "vYhHlc": ["设置"], "wdMwII": ["立即打开"], "wdRxTD": ["正在应用…"], "wsx-_Y": ["标记屏幕截图"], "wv1f8v": ["撰写引用来源的手稿"], "xGv9ZK": [["app"], " 尚无权访问任何集合。请选择已有集合,或新建一个。"], "xxbaLR": ["在此处打开此集合"], "y93Tfg": ["此集合"], "yObBTM": ["GitHub 上的 ", ["0"]], "y_K0Dz": ["发生了什么?"], "yz7wBu": ["关闭"], "zDJycW": ["你当时想做什么?"], "zJfG-M": ["屏幕共享在截取屏幕截图前已结束。"], "zO1gmR": ["Esc"], "zVYLbq": ["回复邮箱"] } }
@@ -1,9 +1,16 @@
1
1
  import { useEffect, useRef, useSyncExternalStore } from "react";
2
2
  /** Matches the signal animations in `motion.css`. */
3
- const SIGNAL_MS = { saved: 900, error: 1500 };
3
+ const SIGNAL_MS = {
4
+ saved: 900,
5
+ error: 1500
6
+ };
4
7
  /** Several notices often report the same save; one reaction is enough. */
5
8
  const REPEAT_MS = 400;
6
- const IDLE = { signal: null, progress: null, busy: null };
9
+ const IDLE = {
10
+ signal: null,
11
+ progress: null,
12
+ busy: null
13
+ };
7
14
  let snapshot = IDLE;
8
15
  let signal = null;
9
16
  let signalAt = 0;
@@ -13,109 +20,109 @@ const tasks = new Map();
13
20
  const holds = [];
14
21
  const listeners = new Set();
15
22
  function publish() {
16
- const progress = tasks.size
17
- ? [...tasks.values()].reduce((sum, fraction) => sum + fraction, 0) / tasks.size
18
- : null;
19
- snapshot = { signal, progress, busy: holds.at(-1)?.loop ?? null };
20
- for (const listener of listeners)
21
- listener();
23
+ const progress = tasks.size ? [...tasks.values()].reduce((sum, fraction) => sum + fraction, 0) / tasks.size : null;
24
+ snapshot = {
25
+ signal,
26
+ progress,
27
+ busy: holds.at(-1)?.loop ?? null
28
+ };
29
+ for (const listener of listeners) listener();
22
30
  }
23
31
  function clamp(fraction) {
24
- return Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
32
+ return Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
25
33
  }
26
34
  /** Plays a one-shot reaction on the mark. A fresh error is never covered by a save. */
27
35
  export function signalMdbaseMark(kind) {
28
- const now = Date.now();
29
- if (signal && now - signalAt < REPEAT_MS && (signal.kind === kind || signal.kind === "error"))
30
- return;
31
- signal = { kind, id: nextId++ };
32
- signalAt = now;
33
- clearTimeout(signalTimer);
34
- signalTimer = setTimeout(() => {
35
- signal = null;
36
- publish();
37
- }, SIGNAL_MS[kind]);
36
+ const now = Date.now();
37
+ if (signal && now - signalAt < REPEAT_MS && (signal.kind === kind || signal.kind === "error")) return;
38
+ signal = {
39
+ kind,
40
+ id: nextId++
41
+ };
42
+ signalAt = now;
43
+ clearTimeout(signalTimer);
44
+ signalTimer = setTimeout(() => {
45
+ signal = null;
38
46
  publish();
47
+ }, SIGNAL_MS[kind]);
48
+ publish();
39
49
  }
40
50
  /** Shows an operation's known progress on the mark until it finishes, fails or is cancelled. */
41
51
  export function trackMdbaseMarkProgress(initial = 0) {
42
- const id = nextId++;
43
- tasks.set(id, clamp(initial));
52
+ const id = nextId++;
53
+ tasks.set(id, clamp(initial));
54
+ publish();
55
+ const end = kind => {
56
+ if (!tasks.delete(id)) return;
44
57
  publish();
45
- const end = (kind) => {
46
- if (!tasks.delete(id))
47
- return;
48
- publish();
49
- if (kind)
50
- signalMdbaseMark(kind);
51
- };
52
- return {
53
- update(fraction) {
54
- if (!tasks.has(id))
55
- return;
56
- tasks.set(id, clamp(fraction));
57
- publish();
58
- },
59
- finish: () => end("saved"),
60
- fail: () => end("error"),
61
- cancel: () => end()
62
- };
58
+ if (kind) signalMdbaseMark(kind);
59
+ };
60
+ return {
61
+ update(fraction) {
62
+ if (!tasks.has(id)) return;
63
+ tasks.set(id, clamp(fraction));
64
+ publish();
65
+ },
66
+ finish: () => end("saved"),
67
+ fail: () => end("error"),
68
+ cancel: () => end()
69
+ };
63
70
  }
64
71
  /** Loops the mark until the returned release is called. Releasing twice is harmless. */
65
72
  export function holdMdbaseMarkBusy(loop) {
66
- const id = nextId++;
67
- holds.push({ id, loop });
73
+ const id = nextId++;
74
+ holds.push({
75
+ id,
76
+ loop
77
+ });
78
+ publish();
79
+ return () => {
80
+ const index = holds.findIndex(hold => hold.id === id);
81
+ if (index < 0) return;
82
+ holds.splice(index, 1);
68
83
  publish();
69
- return () => {
70
- const index = holds.findIndex((hold) => hold.id === id);
71
- if (index < 0)
72
- return;
73
- holds.splice(index, 1);
74
- publish();
75
- };
84
+ };
76
85
  }
77
86
  function subscribe(listener) {
78
- listeners.add(listener);
79
- return () => listeners.delete(listener);
87
+ listeners.add(listener);
88
+ return () => listeners.delete(listener);
80
89
  }
81
90
  export function getMdbaseMarkActivity() {
82
- return snapshot;
91
+ return snapshot;
83
92
  }
84
93
  export function useMdbaseMarkActivity() {
85
- return useSyncExternalStore(subscribe, getMdbaseMarkActivity, () => IDLE);
94
+ return useSyncExternalStore(subscribe, getMdbaseMarkActivity, () => IDLE);
86
95
  }
87
96
  /**
88
97
  * Shows `fraction` on the mark while it is a number. Becoming null or unmounting ends the
89
98
  * operation quietly; call `signalMdbaseMark` separately if the end deserves a reaction.
90
99
  */
91
100
  export function useMdbaseMarkProgress(fraction) {
92
- const handle = useRef(null);
93
- const tracking = typeof fraction === "number";
94
- useEffect(() => {
95
- if (!tracking)
96
- return;
97
- const progress = trackMdbaseMarkProgress();
98
- handle.current = progress;
99
- return () => {
100
- progress.cancel();
101
- handle.current = null;
102
- };
103
- }, [tracking]);
104
- useEffect(() => {
105
- if (typeof fraction === "number")
106
- handle.current?.update(fraction);
107
- }, [fraction]);
101
+ const handle = useRef(null);
102
+ const tracking = typeof fraction === "number";
103
+ useEffect(() => {
104
+ if (!tracking) return;
105
+ const progress = trackMdbaseMarkProgress();
106
+ handle.current = progress;
107
+ return () => {
108
+ progress.cancel();
109
+ handle.current = null;
110
+ };
111
+ }, [tracking]);
112
+ useEffect(() => {
113
+ if (typeof fraction === "number") handle.current?.update(fraction);
114
+ }, [fraction]);
108
115
  }
109
116
  /** Loops the mark while `loop` is set. */
110
117
  export function useMdbaseMarkBusy(loop) {
111
- useEffect(() => (loop ? holdMdbaseMarkBusy(loop) : undefined), [loop]);
118
+ useEffect(() => loop ? holdMdbaseMarkBusy(loop) : undefined, [loop]);
112
119
  }
113
120
  /** For tests: forget every signal, operation and hold. */
114
121
  export function resetMdbaseMarkActivity() {
115
- clearTimeout(signalTimer);
116
- signal = null;
117
- signalAt = 0;
118
- tasks.clear();
119
- holds.length = 0;
120
- publish();
122
+ clearTimeout(signalTimer);
123
+ signal = null;
124
+ signalAt = 0;
125
+ tasks.clear();
126
+ holds.length = 0;
127
+ publish();
121
128
  }
@@ -1,21 +1,31 @@
1
1
  import { useEffect, useState } from "react";
2
2
  /** A permission review survives polling, but never silently rebases onto changed access. */
3
3
  export function usePermissionSelection(id, allowed) {
4
- const key = JSON.stringify([id, [...allowed].sort()]);
5
- const [review, setReview] = useState(() => ({ key, selected: [...allowed], needsReview: false }));
6
- const changedExternally = review.key !== key;
7
- const needsReview = changedExternally && JSON.stringify([id, [...review.selected].sort()]) !== key;
8
- useEffect(() => {
9
- if (changedExternally)
10
- setReview({ key, selected: [...allowed], needsReview });
11
- }, [key, changedExternally, needsReview]);
12
- return {
13
- selected: changedExternally ? [...allowed] : review.selected,
14
- needsReview: changedExternally ? needsReview : review.needsReview,
15
- setSelected: (selected) => setReview((current) => ({
16
- ...current,
17
- selected: typeof selected === "function" ? selected(current.selected) : selected
18
- })),
19
- acknowledge: () => setReview((current) => ({ ...current, needsReview: false }))
20
- };
4
+ const key = JSON.stringify([id, [...allowed].sort()]);
5
+ const [review, setReview] = useState(() => ({
6
+ key,
7
+ selected: [...allowed],
8
+ needsReview: false
9
+ }));
10
+ const changedExternally = review.key !== key;
11
+ const needsReview = changedExternally && JSON.stringify([id, [...review.selected].sort()]) !== key;
12
+ useEffect(() => {
13
+ if (changedExternally) setReview({
14
+ key,
15
+ selected: [...allowed],
16
+ needsReview
17
+ });
18
+ }, [key, changedExternally, needsReview]);
19
+ return {
20
+ selected: changedExternally ? [...allowed] : review.selected,
21
+ needsReview: changedExternally ? needsReview : review.needsReview,
22
+ setSelected: selected => setReview(current => ({
23
+ ...current,
24
+ selected: typeof selected === "function" ? selected(current.selected) : selected
25
+ })),
26
+ acknowledge: () => setReview(current => ({
27
+ ...current,
28
+ needsReview: false
29
+ }))
30
+ };
21
31
  }
package/dist/popover.js CHANGED
@@ -3,160 +3,168 @@ import { useEffect, useEffectEvent, useLayoutEffect } from "react";
3
3
  * Where a popover opens beside its trigger: below when it fits, otherwise on whichever side
4
4
  * has more room, lined up with the trigger's start (or end) edge and kept inside the viewport.
5
5
  */
6
- export function anchoredPlacement(trigger, popover, viewport, { gap = 4, margin = 8, align = "start" } = {}) {
7
- const below = viewport.height - trigger.bottom - gap - margin;
8
- const above = trigger.top - gap - margin;
9
- const opensUp = popover.height > below && above > below;
10
- const room = Math.max(120, opensUp ? above : below);
11
- const height = Math.min(popover.height, room);
12
- const width = Math.max(popover.width, trigger.width);
13
- const start = align === "end" ? trigger.left + trigger.width - width : trigger.left;
14
- return {
15
- top: opensUp ? trigger.top - gap - height : trigger.bottom + gap,
16
- left: Math.max(margin, Math.min(start, viewport.width - width - margin)),
17
- maxHeight: room
18
- };
6
+ export function anchoredPlacement(trigger, popover, viewport, {
7
+ gap = 4,
8
+ margin = 8,
9
+ align = "start"
10
+ } = {}) {
11
+ const below = viewport.height - trigger.bottom - gap - margin;
12
+ const above = trigger.top - gap - margin;
13
+ const opensUp = popover.height > below && above > below;
14
+ const room = Math.max(120, opensUp ? above : below);
15
+ const height = Math.min(popover.height, room);
16
+ const width = Math.max(popover.width, trigger.width);
17
+ const start = align === "end" ? trigger.left + trigger.width - width : trigger.left;
18
+ return {
19
+ top: opensUp ? trigger.top - gap - height : trigger.bottom + gap,
20
+ left: Math.max(margin, Math.min(start, viewport.width - width - margin)),
21
+ maxHeight: room
22
+ };
19
23
  }
20
24
  function place(trigger, popover, options) {
21
- const box = trigger.getBoundingClientRect();
22
- if (options.matchWidth)
23
- popover.style.minWidth = `${box.width}px`;
24
- popover.style.maxHeight = "";
25
- const placement = anchoredPlacement(box, { width: popover.offsetWidth, height: popover.scrollHeight }, { width: window.innerWidth, height: window.innerHeight }, options);
26
- popover.style.top = `${placement.top}px`;
27
- popover.style.left = `${placement.left}px`;
28
- popover.style.maxHeight = `${placement.maxHeight}px`;
25
+ const box = trigger.getBoundingClientRect();
26
+ if (options.matchWidth) popover.style.minWidth = `${box.width}px`;
27
+ popover.style.maxHeight = "";
28
+ const placement = anchoredPlacement(box, {
29
+ width: popover.offsetWidth,
30
+ height: popover.scrollHeight
31
+ }, {
32
+ width: window.innerWidth,
33
+ height: window.innerHeight
34
+ }, options);
35
+ popover.style.top = `${placement.top}px`;
36
+ popover.style.left = `${placement.left}px`;
37
+ popover.style.maxHeight = `${placement.maxHeight}px`;
29
38
  }
30
39
  // Browsers without the Popover API (and jsdom) keep the element in place instead.
31
40
  function showInTopLayer(element) {
32
- if (typeof element.showPopover === "function")
33
- element.showPopover();
41
+ if (typeof element.showPopover === "function") element.showPopover();
34
42
  }
35
43
  function hideFromTopLayer(element) {
36
- if (typeof element.hidePopover === "function" && element.matches(":popover-open"))
37
- element.hidePopover();
44
+ if (typeof element.hidePopover === "function" && element.matches(":popover-open")) element.hidePopover();
38
45
  }
39
46
  function onScreen(element) {
40
- const box = element.getBoundingClientRect();
41
- return box.width > 0 && box.bottom > 0 && box.top < window.innerHeight
42
- && box.right > 0 && box.left < window.innerWidth;
47
+ const box = element.getBoundingClientRect();
48
+ return box.width > 0 && box.bottom > 0 && box.top < window.innerHeight && box.right > 0 && box.left < window.innerWidth;
43
49
  }
44
50
  /**
45
51
  * Shows a select's list in the top layer beside its trigger, focuses it, and dismisses it when
46
52
  * the pointer lands elsewhere or the page scrolls or resizes underneath it.
47
53
  */
48
54
  export function useSelectPopover(open, triggerRef, listRef, onDismiss) {
49
- const dismiss = useEffectEvent(onDismiss);
50
- useLayoutEffect(() => {
51
- const list = listRef.current;
52
- const trigger = triggerRef.current;
53
- if (!open || !list || !trigger)
54
- return undefined;
55
- showInTopLayer(list);
56
- place(trigger, list, { matchWidth: true });
57
- list.focus({ preventScroll: true });
58
- return () => {
59
- hideFromTopLayer(list);
60
- };
61
- }, [open, triggerRef, listRef]);
62
- useEffect(() => {
63
- const list = listRef.current;
64
- const trigger = triggerRef.current;
65
- if (!open || !list || !trigger)
66
- return undefined;
67
- const outside = (event) => {
68
- const target = event.target;
69
- if (target && !list.contains(target) && !trigger.contains(target))
70
- dismiss();
71
- };
72
- // Content under the list can move (a panel scrolls, a list re-renders): the list follows
73
- // its trigger, and closes only once the trigger has left the screen.
74
- const follow = (event) => {
75
- if (list.contains(event.target))
76
- return;
77
- if (onScreen(trigger))
78
- place(trigger, list, { matchWidth: true });
79
- else
80
- dismiss();
81
- };
82
- const onResize = () => dismiss();
83
- document.addEventListener("pointerdown", outside, true);
84
- document.addEventListener("scroll", follow, true);
85
- window.addEventListener("resize", onResize);
86
- return () => {
87
- document.removeEventListener("pointerdown", outside, true);
88
- document.removeEventListener("scroll", follow, true);
89
- window.removeEventListener("resize", onResize);
90
- };
91
- }, [open, triggerRef, listRef]);
55
+ const dismiss = useEffectEvent(onDismiss);
56
+ useLayoutEffect(() => {
57
+ const list = listRef.current;
58
+ const trigger = triggerRef.current;
59
+ if (!open || !list || !trigger) return undefined;
60
+ showInTopLayer(list);
61
+ place(trigger, list, {
62
+ matchWidth: true
63
+ });
64
+ list.focus({
65
+ preventScroll: true
66
+ });
67
+ return () => {
68
+ hideFromTopLayer(list);
69
+ };
70
+ }, [open, triggerRef, listRef]);
71
+ useEffect(() => {
72
+ const list = listRef.current;
73
+ const trigger = triggerRef.current;
74
+ if (!open || !list || !trigger) return undefined;
75
+ const outside = event => {
76
+ const target = event.target;
77
+ if (target && !list.contains(target) && !trigger.contains(target)) dismiss();
78
+ };
79
+ // Content under the list can move (a panel scrolls, a list re-renders): the list follows
80
+ // its trigger, and closes only once the trigger has left the screen.
81
+ const follow = event => {
82
+ if (list.contains(event.target)) return;
83
+ if (onScreen(trigger)) place(trigger, list, {
84
+ matchWidth: true
85
+ });else dismiss();
86
+ };
87
+ const onResize = () => dismiss();
88
+ document.addEventListener("pointerdown", outside, true);
89
+ document.addEventListener("scroll", follow, true);
90
+ window.addEventListener("resize", onResize);
91
+ return () => {
92
+ document.removeEventListener("pointerdown", outside, true);
93
+ document.removeEventListener("scroll", follow, true);
94
+ window.removeEventListener("resize", onResize);
95
+ };
96
+ }, [open, triggerRef, listRef]);
92
97
  }
93
98
  /**
94
99
  * Keeps a mounted menu in the top layer beside its trigger. It closes on an outside press,
95
100
  * Escape (returning focus to the trigger), Tab or a resize.
96
101
  */
97
- export function useMenuPopover(menuRef, triggerRef, onClose, { width, align = "start", focus = '[aria-checked="true"], [role^="menuitem"]', busy = false }) {
98
- const close = useEffectEvent(onClose);
99
- const locked = useEffectEvent(() => busy);
100
- useLayoutEffect(() => {
101
- const menu = menuRef.current;
102
- const trigger = triggerRef.current;
103
- if (!menu || !trigger)
104
- return undefined;
105
- showInTopLayer(menu);
106
- if (width !== undefined)
107
- menu.style.width = `${Math.min(width, window.innerWidth - 16)}px`;
108
- place(trigger, menu, { gap: 6, align, matchWidth: false });
109
- (menu.querySelector(focus) ?? menu).focus({ preventScroll: true });
110
- return () => {
111
- hideFromTopLayer(menu);
112
- };
113
- }, [menuRef, triggerRef, width, align, focus]);
114
- useEffect(() => {
115
- const onPointerDown = (event) => {
116
- const target = event.target;
117
- if (target && !locked() && !menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
118
- close(false);
119
- }
120
- };
121
- const onKeyDown = (event) => {
122
- if ((event.key === "Escape" || event.key === "Tab") && !locked()) {
123
- if (event.key === "Escape") {
124
- event.preventDefault();
125
- event.stopPropagation();
126
- }
127
- close(event.key === "Escape");
128
- }
129
- };
130
- const onResize = () => close(false);
131
- document.addEventListener("pointerdown", onPointerDown, true);
132
- document.addEventListener("keydown", onKeyDown, true);
133
- window.addEventListener("resize", onResize);
134
- return () => {
135
- document.removeEventListener("pointerdown", onPointerDown, true);
136
- document.removeEventListener("keydown", onKeyDown, true);
137
- window.removeEventListener("resize", onResize);
138
- };
139
- }, [menuRef, triggerRef]);
102
+ export function useMenuPopover(menuRef, triggerRef, onClose, {
103
+ width,
104
+ align = "start",
105
+ focus = '[aria-checked="true"], [role^="menuitem"]',
106
+ busy = false
107
+ }) {
108
+ const close = useEffectEvent(onClose);
109
+ const locked = useEffectEvent(() => busy);
110
+ useLayoutEffect(() => {
111
+ const menu = menuRef.current;
112
+ const trigger = triggerRef.current;
113
+ if (!menu || !trigger) return undefined;
114
+ showInTopLayer(menu);
115
+ if (width !== undefined) menu.style.width = `${Math.min(width, window.innerWidth - 16)}px`;
116
+ place(trigger, menu, {
117
+ gap: 6,
118
+ align,
119
+ matchWidth: false
120
+ });
121
+ (menu.querySelector(focus) ?? menu).focus({
122
+ preventScroll: true
123
+ });
124
+ return () => {
125
+ hideFromTopLayer(menu);
126
+ };
127
+ }, [menuRef, triggerRef, width, align, focus]);
128
+ useEffect(() => {
129
+ const onPointerDown = event => {
130
+ const target = event.target;
131
+ if (target && !locked() && !menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
132
+ close(false);
133
+ }
134
+ };
135
+ const onKeyDown = event => {
136
+ if ((event.key === "Escape" || event.key === "Tab") && !locked()) {
137
+ if (event.key === "Escape") {
138
+ event.preventDefault();
139
+ event.stopPropagation();
140
+ }
141
+ close(event.key === "Escape");
142
+ }
143
+ };
144
+ const onResize = () => close(false);
145
+ document.addEventListener("pointerdown", onPointerDown, true);
146
+ document.addEventListener("keydown", onKeyDown, true);
147
+ window.addEventListener("resize", onResize);
148
+ return () => {
149
+ document.removeEventListener("pointerdown", onPointerDown, true);
150
+ document.removeEventListener("keydown", onKeyDown, true);
151
+ window.removeEventListener("resize", onResize);
152
+ };
153
+ }, [menuRef, triggerRef]);
140
154
  }
141
155
  /**
142
156
  * Arrow keys, Home and End move between a menu's enabled items. From a text field inside the
143
157
  * menu (a filter), Home and End stay in the field and the arrows enter the list.
144
158
  */
145
159
  export function moveMenuFocus(event, menu) {
146
- if (!menu || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key))
147
- return;
148
- const items = [...menu.querySelectorAll('[role^="menuitem"]:not(:disabled)')];
149
- const inField = document.activeElement instanceof HTMLInputElement && menu.contains(document.activeElement);
150
- if (inField && (event.key === "Home" || event.key === "End"))
151
- return;
152
- event.preventDefault();
153
- const current = items.indexOf(document.activeElement);
154
- const next = event.key === "Home"
155
- ? 0
156
- : event.key === "End"
157
- ? items.length - 1
158
- : inField
159
- ? 0
160
- : (current + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
161
- items[next]?.focus({ preventScroll: true });
160
+ if (!menu || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
161
+ const items = [...menu.querySelectorAll('[role^="menuitem"]:not(:disabled)')];
162
+ const inField = document.activeElement instanceof HTMLInputElement && menu.contains(document.activeElement);
163
+ if (inField && (event.key === "Home" || event.key === "End")) return;
164
+ event.preventDefault();
165
+ const current = items.indexOf(document.activeElement);
166
+ const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : inField ? 0 : (current + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
167
+ items[next]?.focus({
168
+ preventScroll: true
169
+ });
162
170
  }
package/dist/screens.d.ts CHANGED
@@ -1,16 +1,27 @@
1
- import type { JSX, ReactNode } from "react";
1
+ import { type JSX, type ReactNode } from "react";
2
2
  import type { MdbaseAppId } from "./apps.js";
3
3
  /**
4
4
  * What an app shows while it opens a collection: its wordmark, scanning, and a quiet line
5
5
  * saying what it is waiting for. A failure keeps the same place, shakes the mark once and
6
6
  * offers a retry.
7
+ *
8
+ * With `entrance="flock"` it is the launch screen: a 42px mark that the flock flies onto
9
+ * from the edges of the screen, once per page, before it starts scanning. Pair it with
10
+ * `useLaunchEntrancePlaying()` so a fast open does not cut the entrance off.
7
11
  */
8
- export declare function OpeningScreen({ app, title, detail, error, onRetry }: {
9
- readonly app: MdbaseAppId;
12
+ export declare function OpeningScreen(props: {
13
+ /** A built-in app wordmark, or the name of an independent collection app. */
14
+ readonly app: MdbaseAppId | {
15
+ readonly name: string;
16
+ readonly mark?: ReactNode;
17
+ };
18
+ /** Embedded gates must not add a second main landmark. */
19
+ readonly layout?: "page" | "panel" | undefined;
10
20
  readonly title: string;
11
21
  readonly detail?: string | undefined;
12
22
  readonly error?: string | null | undefined;
13
23
  readonly onRetry?: (() => void) | undefined;
24
+ readonly entrance?: "flock" | undefined;
14
25
  }): JSX.Element;
15
26
  /**
16
27
  * The screen before a collection is open: the app's wordmark, a headline, what the app will
@@ -28,3 +39,9 @@ export declare function ConnectLayout({ app, title, lead, status, error, detail,
28
39
  readonly footnote?: ReactNode | undefined;
29
40
  readonly children?: ReactNode | undefined;
30
41
  }): JSX.Element;
42
+ /**
43
+ * True while the launch entrance is flying. An app that opens quickly keeps its
44
+ * opening screen until this is false, so the entrance is never cut off mid-flight;
45
+ * it lasts at most two seconds.
46
+ */
47
+ export declare function useLaunchEntrancePlaying(): boolean;