hd-garden 0.14.5 → 0.14.6

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.
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { TOC_PANEL_ID, TOC_CONTROL_ID, TOC_CLOSED_ID } from "../lib/reading-navigation";
4
+
3
5
  import Link from "next/link";
4
6
  import { usePathname } from "next/navigation";
5
7
  import {
@@ -316,6 +318,7 @@ export function SiteHeader() {
316
318
  <>
317
319
  {/* Targets for the no-JavaScript menu links below. Fixed and outside the sticky header, so
318
320
  following #sh-menu-on or #sh-menu-off never scrolls the page (the TOC drawer works the same way). */}
321
+ <span className="sh-menu-target" id={TOC_CLOSED_ID} aria-hidden="true" />
319
322
  <span className="sh-menu-target" id="sh-menu-on" aria-hidden="true" />
320
323
  <span className="sh-menu-target" id="sh-menu-off" aria-hidden="true" />
321
324
  <header className="sh-header" id="site-header">
@@ -333,6 +336,11 @@ export function SiteHeader() {
333
336
  ))}
334
337
  </nav>
335
338
  <div className="sh-tools">
339
+ <span id={TOC_CONTROL_ID} className="sh-reading-control">
340
+ <a className="toc-header-nojs" href={`#${TOC_PANEL_ID}`} aria-label={t.toc}>
341
+ {t.tocShort}
342
+ </a>
343
+ </span>
336
344
  <button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} onPointerEnter={prefetchSearch} onFocus={prefetchSearch} aria-label={t.searchArticles} aria-expanded={searchOpen} aria-controls="site-search-dialog">
337
345
  <svg viewBox="0 0 20 20" aria-hidden="true">
338
346
  <circle cx="9" cy="9" r="5.8" />
@@ -6,10 +6,13 @@ import {
6
6
  useId,
7
7
  useRef,
8
8
  useState,
9
+ useSyncExternalStore,
9
10
  type MouseEvent,
10
11
  type ReactNode,
11
12
  } from "react";
12
- import { List, X } from "@phosphor-icons/react";
13
+ import { createPortal } from "react-dom";
14
+ import { TOC_PANEL_ID, TOC_CONTROL_ID, TOC_CLOSED_ID } from "../lib/reading-navigation";
15
+ import { X } from "@phosphor-icons/react";
13
16
  import { ui, type UiLang } from "../lib/i18n";
14
17
 
15
18
  type Heading = { depth: number; text: string; id: string; no?: string };
@@ -114,6 +117,20 @@ const scrollToHeading = (id: string) => {
114
117
  html.style.scrollBehavior = previousBehavior;
115
118
  };
116
119
 
120
+ // The server-rendered header owns the slot; this component owns one drawer/control.
121
+ // Observe slot replacement during client navigation, never poll or duplicate the TOC.
122
+ const readControlHost = () => document.getElementById(TOC_CONTROL_ID);
123
+ const serverControlHost = () => null;
124
+ function subscribeControlHost(changed: () => void) {
125
+ let host = readControlHost();
126
+ const observer = new MutationObserver(() => {
127
+ const next = readControlHost();
128
+ if (next !== host) { host = next; changed(); }
129
+ });
130
+ observer.observe(document.body, { childList: true, subtree: true });
131
+ return () => observer.disconnect();
132
+ }
133
+
117
134
  export function TableOfContents({
118
135
  headings,
119
136
  label,
@@ -131,9 +148,11 @@ export function TableOfContents({
131
148
  () => new Set(),
132
149
  );
133
150
  const [mobileOpen, setMobileOpen] = useState(false);
134
- const panelId = useId();
151
+ const panelId = TOC_PANEL_ID;
152
+ const controlHost = useSyncExternalStore(subscribeControlHost, readControlHost, serverControlHost);
135
153
  const listId = useId();
136
154
  const listRef = useRef<HTMLOListElement>(null);
155
+ const panelRef = useRef<HTMLElement>(null);
137
156
  const mobileTriggerRef = useRef<HTMLButtonElement>(null);
138
157
  const mobileCloseRef = useRef<HTMLButtonElement>(null);
139
158
  const restoreMobileTriggerFocusRef = useRef(false);
@@ -283,7 +302,22 @@ export function TableOfContents({
283
302
  mobileCloseRef.current?.focus({ preventScroll: true });
284
303
  });
285
304
  const handleKeyDown = (event: KeyboardEvent) => {
286
- if (event.key === "Escape") closeMobileToc(true);
305
+ if (event.key === "Escape") {
306
+ event.preventDefault();
307
+ closeMobileToc(true);
308
+ }
309
+ if (event.key !== "Tab") return;
310
+ const controls = Array.from(panelRef.current?.querySelectorAll<HTMLElement>("a[href], button:not([disabled])") ?? [])
311
+ .filter((control) => control.getClientRects().length > 0 && getComputedStyle(control).visibility !== "hidden");
312
+ const first = controls[0];
313
+ const last = controls.at(-1);
314
+ if (event.shiftKey && document.activeElement === first && last) {
315
+ event.preventDefault();
316
+ last.focus();
317
+ } else if (!event.shiftKey && document.activeElement === last && first) {
318
+ event.preventDefault();
319
+ first.focus();
320
+ }
287
321
  };
288
322
  const handleViewportChange = () => {
289
323
  if (!mobileQuery.matches) setMobileOpen(false);
@@ -346,6 +380,13 @@ export function TableOfContents({
346
380
  setActiveId(id);
347
381
  closeMobileToc();
348
382
  trackAnchorLayout(id);
383
+ const heading = document.getElementById(id);
384
+ if (heading) {
385
+ const temporaryTabIndex = !heading.hasAttribute("tabindex");
386
+ if (temporaryTabIndex) heading.tabIndex = -1;
387
+ heading.focus({ preventScroll: true });
388
+ if (temporaryTabIndex) heading.addEventListener("blur", () => heading.removeAttribute("tabindex"), { once: true });
389
+ }
349
390
  };
350
391
 
351
392
  const renderNode = (node: TocNode, path: number[]): ReactNode => {
@@ -395,7 +436,7 @@ export function TableOfContents({
395
436
 
396
437
  return (
397
438
  <>
398
- <button
439
+ {controlHost ? createPortal(<button
399
440
  ref={mobileTriggerRef}
400
441
  className="toc-mobile-trigger"
401
442
  type="button"
@@ -406,16 +447,8 @@ export function TableOfContents({
406
447
  data-mobile-open={mobileOpen}
407
448
  onClick={() => setMobileOpen(true)}
408
449
  >
409
- <List size={17} weight="bold" aria-hidden="true" />
410
- <span>{t.tocShort}</span>
411
- </button>
412
- {/* Without JavaScript the button above does nothing. This link opens the
413
- drawer through :target instead; CSS shows it only when scripting is
414
- off, so with JavaScript nothing here is visible. */}
415
- <a className="toc-nojs-trigger" id={`${panelId}-open`} href={`#${panelId}`} aria-label={`${t.open}${label}`}>
416
- <List size={17} weight="bold" aria-hidden="true" />
417
450
  <span>{t.tocShort}</span>
418
- </a>
451
+ </button>, controlHost) : null}
419
452
  <button
420
453
  className="toc-mobile-backdrop"
421
454
  type="button"
@@ -425,6 +458,9 @@ export function TableOfContents({
425
458
  onClick={() => closeMobileToc(true)}
426
459
  />
427
460
  <aside
461
+ ref={panelRef}
462
+ role={mobileOpen ? "dialog" : undefined}
463
+ aria-modal={mobileOpen ? true : undefined}
428
464
  className="toc toc-rail"
429
465
  id={panelId}
430
466
  aria-label={label}
@@ -441,7 +477,7 @@ export function TableOfContents({
441
477
  >
442
478
  <X size={18} aria-hidden="true" />
443
479
  </button>
444
- <a className="toc-nojs-close" href={`#${panelId}-open`} aria-label={`${t.close}${label}`}>
480
+ <a className="toc-nojs-close" href={`#${TOC_CLOSED_ID}`} aria-label={`${t.close}${label}`}>
445
481
  <X size={18} aria-hidden="true" />
446
482
  </a>
447
483
  </div>
package/app/globals.css CHANGED
@@ -3917,62 +3917,10 @@ html[data-theme="dark"] .search-footer {
3917
3917
  overflow: visible;
3918
3918
  }
3919
3919
 
3920
- .toc-mobile-trigger,
3921
- .toc-nojs-trigger {
3922
- position: fixed;
3923
- right: calc(16px + env(safe-area-inset-right));
3924
- bottom: calc(16px + env(safe-area-inset-bottom));
3925
- z-index: 70;
3926
- min-height: 44px;
3927
- padding: 0 18px 0 15px;
3928
- display: inline-flex;
3929
- align-items: center;
3930
- gap: 8px;
3931
- border: 1px solid #d9d5cc;
3932
- border-radius: 999px;
3933
- background: rgb(255 255 255 / 94%);
3934
- box-shadow: 0 2px 10px rgb(27 26 24 / 10%);
3935
- color: var(--ink);
3936
- font: 600 14px/1 var(--sans);
3937
- letter-spacing: 0;
3938
- touch-action: manipulation;
3939
- cursor: pointer;
3940
- transition: opacity 160ms ease, transform 160ms ease, visibility 0s;
3941
- }
3942
-
3943
- /* The floating contents button sits over the bottom 76px of the screen:
3944
- keep that much room after the last line, and keep anchors clear of it. */
3945
- html:has(.toc-mobile-trigger) {
3946
- scroll-padding-bottom: calc(76px + env(safe-area-inset-bottom));
3947
- }
3948
-
3949
- body:has(.toc-mobile-trigger) main {
3950
- padding-bottom: calc(76px + env(safe-area-inset-bottom));
3951
- }
3952
-
3953
- :root[data-theme="dark"] .toc-mobile-trigger,
3954
- html[data-theme="dark"] .toc-mobile-trigger,
3955
- :root[data-theme="dark"] .toc-nojs-trigger,
3956
- html[data-theme="dark"] .toc-nojs-trigger {
3957
- background: rgb(30 29 26 / 94%);
3958
- border-color: var(--rule-strong);
3959
- color: var(--ink);
3960
- }
3961
-
3962
- .toc-mobile-trigger:hover,
3963
- .toc-mobile-trigger:focus-visible,
3964
- .toc-nojs-trigger:hover,
3965
- .toc-nojs-trigger:focus-visible {
3966
- transform: translateY(-2px);
3967
- }
3968
-
3969
3920
  .toc-mobile-trigger:focus-visible,
3921
+ .toc-header-nojs:focus-visible,
3970
3922
  .toc-mobile-close:focus-visible,
3971
- .toc-nojs-trigger:focus-visible,
3972
- .toc-nojs-close:focus-visible {
3973
- outline: 2px solid var(--accent);
3974
- outline-offset: 3px;
3975
- }
3923
+ .toc-nojs-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
3976
3924
 
3977
3925
  .toc-mobile-trigger[data-mobile-open="true"] {
3978
3926
  opacity: 0;
@@ -4054,8 +4002,8 @@ html[data-theme="dark"] .search-footer {
4054
4002
 
4055
4003
  .toc-mobile-close,
4056
4004
  .toc-nojs-close {
4057
- width: 34px;
4058
- height: 34px;
4005
+ width: 44px;
4006
+ height: 44px;
4059
4007
  padding: 0;
4060
4008
  display: grid;
4061
4009
  place-items: center;
@@ -4088,9 +4036,13 @@ html[data-theme="dark"] .search-footer {
4088
4036
  padding on post pages but not on column pages. */
4089
4037
  .toc .toc-header,
4090
4038
  .toc .toc-list {
4091
- padding-inline: 20px;
4039
+ padding-left: max(20px, env(safe-area-inset-left));
4040
+ padding-right: max(20px, env(safe-area-inset-right));
4092
4041
  }
4093
4042
 
4043
+ .toc .toc-item-row > a { display: flex; align-items: center; min-height: 44px; padding-block: 8px; }
4044
+ .toc .toc-section-toggle { min-width: 44px; min-height: 44px; }
4045
+
4094
4046
  .toc li {
4095
4047
  margin: 10px 0;
4096
4048
  font-size: 13px;
@@ -5707,12 +5659,6 @@ html[data-theme="dark"] .search-footer {
5707
5659
  display: none;
5708
5660
  }
5709
5661
 
5710
- .toc-nojs-trigger {
5711
- display: inline-flex;
5712
- color: var(--ink);
5713
- text-decoration: none;
5714
- }
5715
-
5716
5662
  .toc-nojs-close {
5717
5663
  display: grid;
5718
5664
  text-decoration: none;
@@ -5725,7 +5671,7 @@ html[data-theme="dark"] .search-footer {
5725
5671
  transition: opacity 180ms ease, visibility 0s;
5726
5672
  }
5727
5673
 
5728
- body:has(.toc:target) .toc-nojs-trigger {
5674
+ body:has(.toc:target) .toc-header-nojs {
5729
5675
  opacity: 0;
5730
5676
  visibility: hidden;
5731
5677
  pointer-events: none;
package/app/lib/i18n.ts CHANGED
@@ -319,7 +319,7 @@ const en: typeof zh = {
319
319
  columnFallbackDesc: "A series of posts in reading order.",
320
320
  columnNewestFirst: (title: string | undefined) => `${title}, newest first`,
321
321
  toc: "Contents",
322
- tocShort: "Contents",
322
+ tocShort: "TOC",
323
323
  tocPage: "On this page",
324
324
  open: "Open ",
325
325
  close: "Close ",
@@ -0,0 +1,4 @@
1
+ // One primary reading navigation per page; shared by the server header and client drawer.
2
+ export const TOC_PANEL_ID = "reading-contents";
3
+ export const TOC_CONTROL_ID = "reading-contents-control";
4
+ export const TOC_CLOSED_ID = "reading-contents-closed";
package/app/styles/a2.css CHANGED
@@ -1587,13 +1587,6 @@ html[data-theme="dark"] .g-quiz-out.no { color: #f29aab; }
1587
1587
  /* Fullscreen controls own a top band, not a second frame or a narrow text rail. */
1588
1588
  .ux-fs > .ux-fsbar + .g-play { padding: 44px 0 0; border: 0; }
1589
1589
  .g-play > .garden-ui { margin: 0; }
1590
- @media (max-width: 900px) {
1591
- .post-reading-page .toc-mobile-trigger,
1592
- .post-reading-page .toc-nojs-trigger {
1593
- position: static;
1594
- margin: 0 0 16px;
1595
- }
1596
- }
1597
1590
  .g-play-f { margin: 0 0 10px; font-size: 14px; color: var(--soft); }
1598
1591
  .g-range { display: grid; grid-template-columns: 110px minmax(0, 1fr) 64px; gap: 12px; align-items: center; font-size: 14px; color: var(--muted); margin: 6px 0; }
1599
1592
  .g-range input { width: 100%; accent-color: var(--blue); min-height: 32px; }
@@ -2794,3 +2787,11 @@ html[data-theme="dark"] .ref-demo { --rd-s: #262624; --rd-p: #1f1e1d; --rd-i: #e
2794
2787
  display: none;
2795
2788
  }
2796
2789
  }
2790
+
2791
+ /* Desktop rail spacing must not replace the mobile drawer's home-indicator inset. */
2792
+ @media (max-width: 900px) {
2793
+ .post-reading-page .article-reading-layout > .toc,
2794
+ .garden-doc-page > .toc {
2795
+ padding: 0 0 calc(18px + env(safe-area-inset-bottom));
2796
+ }
2797
+ }
@@ -456,3 +456,26 @@ a.sh-lang:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
456
456
  --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB","Noto Sans SC","Noto Sans CJK SC","Microsoft YaHei",system-ui,sans-serif;
457
457
  --serif:"Source Serif 4",Georgia,"Times New Roman","Source Han Serif SC","Noto Serif SC","Noto Serif CJK SC","Songti SC","STSong",serif;
458
458
  }
459
+
460
+ /* Reading navigation lives in the existing sticky header, never over prose.
461
+ The server fallback works without JS; hydration portals the stateful control
462
+ into the same reserved slot. No duplicate article-start button or extra band. */
463
+ .sh-reading-control { display: none; }
464
+ .toc-header-nojs { display: none; }
465
+ @media (max-width: 900px) {
466
+ body:has(.toc-rail) .sh-reading-control { display: inline-flex; flex: 0 0 44px; min-width: 44px; min-height: 44px; align-items: center; justify-content: center; }
467
+ .sh-reading-control .toc-mobile-trigger,
468
+ .sh-reading-control .toc-header-nojs {
469
+ position: static; display: inline-flex; width: 44px; min-width: 44px; height: 44px; margin: 0; padding: 0;
470
+ align-items: center; justify-content: center; border: 0; border-radius: var(--r);
471
+ background: transparent; box-shadow: none; color: var(--ink); font: 600 13px/1 var(--sans); text-decoration: none;
472
+ }
473
+ .sh-reading-control .toc-header-nojs { display: none; }
474
+ .sh-reading-control .toc-mobile-trigger:hover,
475
+ .sh-reading-control .toc-mobile-trigger:focus-visible { transform: none; background: var(--tint); }
476
+ .sh-reading-control .toc-mobile-trigger[data-mobile-open="true"] { visibility: visible; opacity: 1; pointer-events: none; transform: none; }
477
+ .sh-bar { padding-left: max(20px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); }
478
+ }
479
+ @media (max-width: 900px) and (scripting: none) {
480
+ .sh-reading-control .toc-header-nojs { display: inline-flex; }
481
+ }
@@ -5,7 +5,7 @@
5
5
  "author": "hedon",
6
6
  "authorUrl": "https://github.com/hedon954",
7
7
  "repo": "hedon954/garden",
8
- "version": "0.14.5",
8
+ "version": "0.14.6",
9
9
  "minAppVersion": "1.5.0",
10
10
  "minCoreVersion": "2.10.52",
11
11
  "platforms": [
@@ -4351,13 +4351,6 @@ html[data-theme="dark"] {
4351
4351
  /* Fullscreen controls own a top band, not a second frame or a narrow text rail. */
4352
4352
  .garden-ui-preview .ux-fs > .ux-fsbar + .g-play { padding: 44px 0 0; border: 0; }
4353
4353
  .garden-ui-preview .g-play > .garden-ui { margin: 0; }
4354
- @media (max-width: 900px) {
4355
- .garden-ui-preview .post-reading-page .toc-mobile-trigger,
4356
- .garden-ui-preview .post-reading-page .toc-nojs-trigger {
4357
- position: static;
4358
- margin: 0 0 16px;
4359
- }
4360
- }
4361
4354
  .garden-ui-preview .g-play-f { margin: 0 0 10px; font-size: 14px; color: var(--soft); }
4362
4355
  .garden-ui-preview .g-range { display: grid; grid-template-columns: 110px minmax(0, 1fr) 64px; gap: 12px; align-items: center; font-size: 14px; color: var(--muted); margin: 6px 0; }
4363
4356
  .garden-ui-preview .g-range input { width: 100%; accent-color: var(--blue); min-height: 32px; }
@@ -5559,6 +5552,14 @@ html[data-theme="dark"] {
5559
5552
  }
5560
5553
  }
5561
5554
 
5555
+ /* Desktop rail spacing must not replace the mobile drawer's home-indicator inset. */
5556
+ @media (max-width: 900px) {
5557
+ .garden-ui-preview .post-reading-page .article-reading-layout > .toc,
5558
+ .garden-ui-preview .garden-doc-page > .toc {
5559
+ padding: 0 0 calc(18px + env(safe-area-inset-bottom));
5560
+ }
5561
+ }
5562
+
5562
5563
  .garden-ui-preview {font:inherit}.garden-ui-preview .markdown-body {font:inherit}
5563
5564
  .garden-ui-typora { margin: 0.25em 0; text-align: left; white-space: normal; }
5564
5565
  .garden-suggest { display: flex; align-items: center; gap: 8px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.14.5",
3
+ "version": "0.14.6",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -4351,13 +4351,6 @@ html[data-theme="dark"] {
4351
4351
  /* Fullscreen controls own a top band, not a second frame or a narrow text rail. */
4352
4352
  .garden-ui-preview .ux-fs > .ux-fsbar + .g-play { padding: 44px 0 0; border: 0; }
4353
4353
  .garden-ui-preview .g-play > .garden-ui { margin: 0; }
4354
- @media (max-width: 900px) {
4355
- .garden-ui-preview .post-reading-page .toc-mobile-trigger,
4356
- .garden-ui-preview .post-reading-page .toc-nojs-trigger {
4357
- position: static;
4358
- margin: 0 0 16px;
4359
- }
4360
- }
4361
4354
  .garden-ui-preview .g-play-f { margin: 0 0 10px; font-size: 14px; color: var(--soft); }
4362
4355
  .garden-ui-preview .g-range { display: grid; grid-template-columns: 110px minmax(0, 1fr) 64px; gap: 12px; align-items: center; font-size: 14px; color: var(--muted); margin: 6px 0; }
4363
4356
  .garden-ui-preview .g-range input { width: 100%; accent-color: var(--blue); min-height: 32px; }
@@ -5559,4 +5552,12 @@ html[data-theme="dark"] {
5559
5552
  }
5560
5553
  }
5561
5554
 
5555
+ /* Desktop rail spacing must not replace the mobile drawer's home-indicator inset. */
5556
+ @media (max-width: 900px) {
5557
+ .garden-ui-preview .post-reading-page .article-reading-layout > .toc,
5558
+ .garden-ui-preview .garden-doc-page > .toc {
5559
+ padding: 0 0 calc(18px + env(safe-area-inset-bottom));
5560
+ }
5561
+ }
5562
+
5562
5563
  .garden-ui-preview {font:inherit}.garden-ui-preview .markdown-body {font:inherit}