dsh-calendar 0.8.1 → 0.8.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2(2026-09-20)
4
+
5
+ - **修主题色误用**:此前把宿主遮罩 token `--dsw-alias-bg-mask-3` 当淡色 hover/底纹用;DSH 0.1.6-alpha.2 里它实测亮/暗主题都是 `rgba(0,0,0,.48)`,导致周视图「今天」整列、月视图格子 hover、加载骨架屏、详情 tag 全部发深灰(浏览器视觉验收抓到,视觉上像一块无字灰板)。现改为 `color-mix`(label-primary 5%~7% / business-primary 7%),mask-3 只保留在 `.dshc-overlay` / `.dshc-modalwrap` 两处真正的遮罩上。
6
+ - **修 Esc 关错层**:详情抽屉/新建表单打开时按 Esc 会连整个日历浮层面板一起关掉。现在 Esc 只关最上面一层,第二次才关面板;焦点在上层宿主 `PRIM.Modal` 里时让行给宿主;输入法组合状态(`isComposing`)不误关;设置页与对话页两个面板并存时一次也只关一层,并按实际打开顺序关。
7
+ - **测试**:render 测试 7 → 15 条(新增抽屉/宿主 Modal 真假路径/宿主高层浮层让行/普通宿主元素兜底/层序/IME 等用例),全量 154 pass。该版经两位独立评审:首轮抓到「宿主 Portal 抢 Esc」「双面板一次连关两层」「IME 误关」,二轮抓到「宿主高层 Modal 盖上来时关错下面那层」,均已修。
8
+
3
9
  ## 0.8.1(2026-09-19)
4
10
 
5
11
  - **新增真 DOM 行为测试层**(`test/render.test.mjs`,devDependencies 里加 `react` / `react-dom` / `jsdom`,不进发布包):用 jsdom + 真 React 渲染周视图,然后真派发 `pointerdown/move/up`、`click`、`keydown`。7 条覆盖:渲染不抛、点事件不会顺手弹「新建」、拖动只提交一次且按视口坐标换算、拖动中 Esc 零请求、卸载后不留监听器且之后的 pointerup 不提交、拖到边缘 rAF 持续滚动、重复日程被面板挡住。
package/lib/client.js CHANGED
@@ -47,7 +47,7 @@ const CSS = [
47
47
  ".dshc-sub{color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}",
48
48
  ".dshc-spacer{flex:1}",
49
49
  ".dshc-btn{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);border-radius:9px;padding:5px 11px;font-size:12px;cursor:pointer;transition:background .12s,border-color .12s}",
50
- ".dshc-btn:hover:not(:disabled){background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.05))}",
50
+ ".dshc-btn:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 6%,transparent)}",
51
51
  ".dshc-btn:disabled{opacity:.5;cursor:not-allowed}",
52
52
  ".dshc-btn.primary{background:var(--dsw-alias-button-primary-fill,#2563eb);border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff)}",
53
53
  ".dshc-btn.primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#1d4ed8)}",
@@ -60,7 +60,7 @@ const CSS = [
60
60
  ".dshc-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}",
61
61
  ".dshc-dow{text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);padding:2px 0 4px}",
62
62
  ".dshc-cell{display:block;width:100%;min-height:88px;box-sizing:border-box;border-radius:10px;padding:5px 5px 6px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03));border:1px solid transparent;cursor:pointer;text-align:left;font:inherit;color:inherit;transition:background .12s,border-color .12s;overflow:hidden}",
63
- ".dshc-cell:hover{background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.07))}",
63
+ ".dshc-cell:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 7%,transparent)}",
64
64
  ".dshc-cell.out{opacity:.45}",
65
65
  ".dshc-cell.today{border-color:var(--dsw-alias-state-business-primary,#2563eb)}",
66
66
  ".dshc-daynum{font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);margin:0 0 3px;display:flex;justify-content:space-between}",
@@ -75,7 +75,7 @@ const CSS = [
75
75
  ".dshc-week{display:grid;grid-template-columns:56px repeat(7,minmax(0,1fr));gap:4px;max-height:520px;overflow:auto}",
76
76
  ".dshc-hour{font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);text-align:right;padding-right:4px;height:44px}",
77
77
  ".dshc-daycol{position:relative;border-left:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.07));min-height:44px}",
78
- ".dshc-daycol.today{background:var(--dsw-alias-bg-mask-3,rgba(37,99,235,.06))}",
78
+ ".dshc-daycol.today{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#2563eb) 7%,transparent)}",
79
79
  ".dshc-dayhead{position:sticky;top:0;background:var(--dsw-alias-bg-layer-1,#fff);z-index:2;text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);padding-bottom:4px}",
80
80
  ".dshc-dayhead b{color:var(--dsw-alias-label-primary,#111);font-weight:600;font-size:13px}",
81
81
  ".dshc-slot{position:absolute;left:2px;right:2px;border-radius:7px;padding:3px 5px;font-size:11px;line-height:1.3;overflow:hidden;cursor:grab;background:var(--dsw-alias-state-business-primary,#2563eb);color:var(--dsw-alias-label-primary-foreground,#fff);border:0;text-align:left;touch-action:none;user-select:none}",
@@ -98,22 +98,22 @@ const CSS = [
98
98
  ".dshc-daylabel{display:flex;align-items:baseline;gap:8px;position:sticky;top:0;background:var(--dsw-alias-bg-layer-1,#fff);padding:2px 0}",
99
99
  ".dshc-daylabel b{font-size:13px}",
100
100
  ".dshc-row{display:flex;gap:10px;align-items:flex-start;padding:8px 10px;border-radius:10px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03));cursor:pointer;border-left:3px solid var(--dsw-alias-state-business-primary,#2563eb)}",
101
- ".dshc-row:hover{background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.07))}",
101
+ ".dshc-row:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 7%,transparent)}",
102
102
  ".dshc-row.allday{border-left-color:var(--dsw-alias-state-success-primary,#16a34a)}",
103
103
  ".dshc-row.recurring{border-left-color:var(--dsw-alias-state-warn-primary,#f59e0b)}",
104
104
  ".dshc-time{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#6b7280);min-width:96px;font-size:12px;padding-top:1px}",
105
105
  ".dshc-main{flex:1;min-width:0}",
106
106
  ".dshc-name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
107
107
  ".dshc-meta{color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
108
- ".dshc-tag{display:inline-block;font-size:11px;padding:1px 6px;border-radius:999px;background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.07));color:var(--dsw-alias-label-tertiary,#6b7280);margin-left:6px}",
108
+ ".dshc-tag{display:inline-block;font-size:11px;padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 7%,transparent);color:var(--dsw-alias-label-tertiary,#6b7280);margin-left:6px}",
109
109
  ".dshc-empty{padding:26px 18px;text-align:center;color:var(--dsw-alias-label-tertiary,#6b7280)}",
110
110
  ".dshc-empty h3{color:var(--dsw-alias-label-primary,#111);margin:0 0 6px;font-size:14px}",
111
111
  ".dshc-empty code{background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.05));padding:1px 5px;border-radius:5px;font-size:12px}",
112
- ".dshc-alert{display:flex;gap:8px;align-items:flex-start;border-radius:10px;padding:8px 10px;font-size:12px;margin-bottom:10px;background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.05))}",
112
+ ".dshc-alert{display:flex;gap:8px;align-items:flex-start;border-radius:10px;padding:8px 10px;font-size:12px;margin-bottom:10px;background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 5%,transparent)}",
113
113
  ".dshc-alert.error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#dc2626) 12%,transparent);color:var(--dsw-alias-state-error-primary,#dc2626)}",
114
114
  ".dshc-alert.warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 16%,transparent)}",
115
115
  ".dshc-alert.demo{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#2563eb) 12%,transparent)}",
116
- ".dshc-skel{height:88px;border-radius:10px;background:linear-gradient(90deg,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 25%,var(--dsw-alias-bg-mask-3,rgba(0,0,0,.08)) 37%,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 63%);background-size:400% 100%;animation:dshc-shimmer 1.4s ease infinite}",
116
+ ".dshc-skel{height:88px;border-radius:10px;background:linear-gradient(90deg,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 25%,color-mix(in srgb,var(--dsw-alias-label-primary,#111) 10%,transparent) 37%,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 63%);background-size:400% 100%;animation:dshc-shimmer 1.4s ease infinite}",
117
117
  "@keyframes dshc-shimmer{0%{background-position:100% 0}100%{background-position:0 0}}",
118
118
  ".dshc-overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-mask-3,rgba(15,23,42,.42));display:flex;justify-content:flex-end;z-index:60}",
119
119
  ".dshc-drawer{width:min(420px,92vw);height:100%;background:var(--dsw-alias-bg-layer-1,#fff);border-left:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.08));padding:18px;overflow:auto;box-shadow:var(--dsw-elevation-prominent,0 10px 30px rgba(0,0,0,.2))}",
@@ -557,7 +557,7 @@ function Dlg(props) {
557
557
  onClose: props.onClose,
558
558
  title: props.title,
559
559
  closeLabel: props.closeLabel || props.title,
560
- children: props.children,
560
+ children: h("div", { "data-dshc-scope": "1" }, props.children),
561
561
  footer: props.footer
562
562
  });
563
563
  }
@@ -1505,6 +1505,82 @@ function ConnectionForm(props) {
1505
1505
 
1506
1506
  // ───────────────────────────── 主面板 ─────────────────────────────
1507
1507
 
1508
+ // ───────────────────────────── 浮层 Esc 的模块级栈 ─────────────────────────────
1509
+ // 一个页面可能同时有多个 CalendarPanel(设置页一节 + 对话页悬浮面板)。如果各自往
1510
+ // document 挂捕获监听,stopPropagation 拦不住同阶段的其他监听 —— 一次 Esc 会连关
1511
+ // 两层;用全局 querySelector 兜底也会被另一个面板的浮层误触发。这里共用一个捕获
1512
+ // 监听,按「焦点所在 root / 最近打开的层」只关一层。
1513
+ const LAYER_ESCAPE_STACK = [];
1514
+ let layerEscapeListener = null;
1515
+
1516
+ function panelRootOf(node) {
1517
+ if (node === null || node === undefined || typeof node.closest !== "function") return null;
1518
+ return node.closest(".dshc-root");
1519
+ }
1520
+
1521
+ /** 目标是否位于别的宿主浮层内(那类浮层会自己处理 Esc)。 */
1522
+ function isForeignHostLayer(node) {
1523
+ if (node === null || node === undefined || typeof node.closest !== "function") return false;
1524
+ const layer = node.closest('[role="dialog"], [role="alertdialog"]');
1525
+ if (layer === null) return false;
1526
+ if (layer.closest(".dshc-root") !== null) return false;
1527
+ if (layer.querySelector('[data-dshc-scope="1"]') !== null) return false;
1528
+ return true;
1529
+ }
1530
+
1531
+ /** 是否有别的宿主浮层盖在上面:有就把 Esc 让给宿主,别关下面的日历层。 */
1532
+ function hasForeignHostDialog() {
1533
+ const dialogs = document.querySelectorAll('[role="dialog"], [role="alertdialog"]');
1534
+ for (let i = 0; i < dialogs.length; i++) {
1535
+ const layer = dialogs[i];
1536
+ if (layer.closest(".dshc-root") !== null) continue;
1537
+ if (layer.querySelector('[data-dshc-scope="1"]') !== null) continue;
1538
+ if (layer.hidden === true || layer.getAttribute("aria-hidden") === "true") continue;
1539
+ if (typeof window !== "undefined" && window !== null && typeof window.getComputedStyle === "function") {
1540
+ const style = window.getComputedStyle(layer);
1541
+ if (style.display === "none" || style.visibility === "hidden") continue;
1542
+ }
1543
+ return true;
1544
+ }
1545
+ return false;
1546
+ }
1547
+
1548
+ function registerLayerEscape(root, closeTop) {
1549
+ const entry = { root, closeTop };
1550
+ LAYER_ESCAPE_STACK.push(entry);
1551
+ if (layerEscapeListener === null) {
1552
+ layerEscapeListener = (event) => {
1553
+ if (event.key !== "Escape" || event.isComposing === true) return;
1554
+ const target = event.target;
1555
+ // 上层有别的宿主浮层(不是本插件渲染的)时,Esc 归它;
1556
+ // 普通宿主元素(不在任何浮层里)仍然兜底关我们自己的层。
1557
+ if (hasForeignHostDialog() || isForeignHostLayer(target)) return;
1558
+ const root = panelRootOf(target);
1559
+ let owner = null;
1560
+ if (root !== null) {
1561
+ for (let i = LAYER_ESCAPE_STACK.length - 1; i >= 0; i--) {
1562
+ const candidate = LAYER_ESCAPE_STACK[i];
1563
+ if (candidate.root !== null && candidate.root.contains(target)) { owner = candidate; break; }
1564
+ }
1565
+ }
1566
+ if (owner === null) owner = LAYER_ESCAPE_STACK[LAYER_ESCAPE_STACK.length - 1] || null;
1567
+ if (owner === null) return;
1568
+ event.preventDefault();
1569
+ event.stopPropagation();
1570
+ owner.closeTop();
1571
+ };
1572
+ document.addEventListener("keydown", layerEscapeListener, true);
1573
+ }
1574
+ return () => {
1575
+ const index = LAYER_ESCAPE_STACK.indexOf(entry);
1576
+ if (index >= 0) LAYER_ESCAPE_STACK.splice(index, 1);
1577
+ if (LAYER_ESCAPE_STACK.length === 0 && layerEscapeListener !== null) {
1578
+ document.removeEventListener("keydown", layerEscapeListener, true);
1579
+ layerEscapeListener = null;
1580
+ }
1581
+ };
1582
+ }
1583
+
1508
1584
  function CalendarPanel(props) {
1509
1585
  const compact = props.compact === true;
1510
1586
  const [view, setView] = useState(loadStoredView);
@@ -1521,6 +1597,48 @@ function CalendarPanel(props) {
1521
1597
  const [configured, setConfigured] = useState(null);
1522
1598
  const [conn, setConn] = useState(null);
1523
1599
  const [connOpen, setConnOpen] = useState(false);
1600
+
1601
+ const layerOpen = draft !== null || selected !== null || connOpen === true;
1602
+ const rootRef = useRef(null);
1603
+
1604
+ /** 让 Launcher 知道本面板里有没有浮层:宿主 Portal 的 Esc 不能被面板级监听抢走。 */
1605
+ useEffect(() => {
1606
+ const notify = typeof props.onLayerChange === "function" ? props.onLayerChange : null;
1607
+ if (notify !== null) notify(layerOpen);
1608
+ return () => { if (notify !== null) notify(false); };
1609
+ }, [layerOpen]);
1610
+
1611
+ /** 面板内浮层的打开顺序:Esc 关最后打开的那个,而不是按类型硬编码。 */
1612
+ const layerOrderRef = useRef([]);
1613
+ useEffect(() => {
1614
+ const openKeys = [];
1615
+ if (selected !== null) openKeys.push("selected");
1616
+ if (draft !== null) openKeys.push("draft");
1617
+ if (connOpen === true) openKeys.push("conn");
1618
+ const kept = layerOrderRef.current.filter((key) => openKeys.indexOf(key) !== -1);
1619
+ const added = openKeys.filter((key) => kept.indexOf(key) === -1);
1620
+ layerOrderRef.current = kept.concat(added);
1621
+ }, [draft, connOpen, selected]);
1622
+
1623
+ /**
1624
+ * Esc 分层关闭:有浮层时只关最上面一层(表单/连接设置 > 详情抽屉)。
1625
+ * 监听走模块级的层栈 —— 多个 CalendarPanel 并存时一次 Esc 只关一层;
1626
+ * 焦点在上层宿主 Portal 里时直接让行,交给宿主自己的 Esc 语义。
1627
+ */
1628
+ useEffect(() => {
1629
+ if (layerOpen !== true) return () => {};
1630
+ const root = rootRef.current;
1631
+ if (root === null) return () => {};
1632
+ const closeTop = () => {
1633
+ const order = layerOrderRef.current;
1634
+ const top = order.length === 0 ? null : order[order.length - 1];
1635
+ if (top === "draft") { setDraft(null); setFormError(null); }
1636
+ else if (top === "conn") setConnOpen(false);
1637
+ else if (top === "selected") { setSelected(null); setConfirmDelete(false); }
1638
+ };
1639
+ return registerLayerEscape(root, closeTop);
1640
+ }, [draft, selected, connOpen]);
1641
+
1524
1642
  /** 乐观改期:uid → { start, end }。等服务器回读到同样的时间再撤掉,避免闪回。 */
1525
1643
  const [overrides, setOverrides] = useState({});
1526
1644
  const [undo, setUndo] = useState(null);
@@ -1742,7 +1860,7 @@ function CalendarPanel(props) {
1742
1860
  const openEvent = (event) => { setConfirmDelete(false); setSelected(event); };
1743
1861
  const createAt = (day, hour) => setDraft(draftAt(day, hour));
1744
1862
 
1745
- return h("div", { className: "dshc-root" }, [
1863
+ return h("div", { ref: rootRef, className: "dshc-root" }, [
1746
1864
  h("div", { className: "dshc-shell" }, [
1747
1865
  banner !== null ? h("div", { className: "dshc-alert demo", role: "status", "aria-live": "polite" }, banner) : null,
1748
1866
  undo !== null
@@ -1817,12 +1935,18 @@ function CalendarSection() {
1817
1935
  /** 对话页右下角的小按钮 + 悬浮面板(自己挂 root,不占用宿主槽位)。 */
1818
1936
  function Launcher() {
1819
1937
  const [open, setOpen] = useState(false);
1938
+ const [layerOpen, setLayerOpen] = useState(false);
1820
1939
  useEffect(() => {
1821
1940
  if (open !== true) return () => {};
1822
- const onKey = (event) => { if (event.key === "Escape") setOpen(false); };
1941
+ const onKey = (event) => {
1942
+ if (event.key !== "Escape" || event.isComposing === true) return;
1943
+ // 浮层交给 CalendarPanel 的层栈;宿主 Portal 的 Esc 交给宿主组件。
1944
+ if (layerOpen === true) return;
1945
+ setOpen(false);
1946
+ };
1823
1947
  document.addEventListener("keydown", onKey);
1824
1948
  return () => document.removeEventListener("keydown", onKey);
1825
- }, [open]);
1949
+ }, [open, layerOpen]);
1826
1950
  const calendarIcon = h("svg", { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" }, [
1827
1951
  h("rect", { key: "r", x: 3, y: 5, width: 18, height: 16, rx: 3 }),
1828
1952
  h("path", { key: "l", d: "M3 10h18M8 3v4M16 3v4" })
@@ -1835,7 +1959,7 @@ function Launcher() {
1835
1959
  "aria-label": t("nav.title"),
1836
1960
  onClick: () => setOpen((value) => !value)
1837
1961
  }, calendarIcon),
1838
- open === true ? h("div", { className: "dshc-float" }, h(CalendarPanel, { compact: true })) : null
1962
+ open === true ? h("div", { className: "dshc-float" }, h(CalendarPanel, { compact: true, onLayerChange: setLayerOpen })) : null
1839
1963
  ]);
1840
1964
  }
1841
1965
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-calendar",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "DSH 日历插件:CalDAV 日程查询、创建、修改、删除与搜索,支持 Google OAuth2、iCloud、Nextcloud 与自建服务。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",