Browse Source

fix(app): render page tree item title as a real anchor

Use Next.js Link for the page tree item title so the browser recognizes
it as a link. This enables Ctrl/Cmd+click and middle-click to open in a
new tab, and the right-click "Open link in new tab" context menu item.

Derive the href inside SimpleItemContent from page.path / page._id and
opt in via an asLink prop, so non-navigation usages (PageSelectModal,
AI assistant tree with checkbox) remain unaffected.

Move the WIP badge out of the anchor so it is not included in the
link's accessible name and is not subject to title truncation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yuki Takei 10 hours ago
parent
commit
7c467cb80a

+ 1 - 0
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -173,6 +173,7 @@ export const PageTreeItem: FC<TreeItemProps> = ({
       isWipPageShown={isWipPageShown}
       isWipPageShown={isWipPageShown}
       isEnableActions={isEnableActions}
       isEnableActions={isEnableActions}
       isReadOnlyUser={isReadOnlyUser}
       isReadOnlyUser={isReadOnlyUser}
+      asLink
       onClick={itemSelectedHandler}
       onClick={itemSelectedHandler}
       onWheelClick={itemSelectedByWheelClickHandler}
       onWheelClick={itemSelectedByWheelClickHandler}
       onToggle={onToggle}
       onToggle={onToggle}

+ 39 - 15
apps/app/src/features/page-tree/components/SimpleItemContent.tsx

@@ -1,4 +1,6 @@
 import { useId } from 'react';
 import { useId } from 'react';
+import Link from 'next/link';
+import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import path from 'pathe';
 import path from 'pathe';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -12,8 +14,10 @@ const moduleClass = styles['simple-item-content'] ?? '';
 
 
 export const SimpleItemContent = ({
 export const SimpleItemContent = ({
   page,
   page,
+  asLink = false,
 }: {
 }: {
   page: IPageForItem;
   page: IPageForItem;
+  asLink?: boolean;
 }): JSX.Element => {
 }): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -24,9 +28,21 @@ export const SimpleItemContent = ({
 
 
   const spanId = `path-recovery-${useId()}`;
   const spanId = `path-recovery-${useId()}`;
 
 
+  // When asLink is true, render the title as an anchor so that the browser
+  // recognizes it as a link (enables Ctrl/Cmd+click to open in new tab,
+  // middle-click, and the right-click "Open link in new tab" context menu).
+  // Otherwise we render a plain div and let the surrounding <li> capture
+  // clicks via JS (existing non-navigation usages such as modals).
+  const href =
+    asLink && page.path != null && page._id != null
+      ? pathUtils.returnPathForURL(page.path, page._id)
+      : undefined;
+
+  const titleClassName = `grw-page-title-anchor flex-grow-1 text-truncate ${page.isEmpty ? 'opacity-75' : ''}`;
+
   return (
   return (
     <div
     <div
-      className={`${moduleClass} flex-grow-1 d-flex align-items-center pe-none`}
+      className={`${moduleClass} flex-grow-1 d-flex align-items-center ${href != null ? '' : 'pe-none'}`}
       style={{ minWidth: 0 }}
       style={{ minWidth: 0 }}
     >
     >
       {shouldShowAttentionIcon && (
       {shouldShowAttentionIcon && (
@@ -42,21 +58,29 @@ export const SimpleItemContent = ({
           </UncontrolledTooltip>
           </UncontrolledTooltip>
         </>
         </>
       )}
       )}
-      {page != null && page.path != null && page._id != null && (
-        <div className="grw-page-title-anchor flex-grow-1">
-          <div className="d-flex align-items-center">
-            <span
-              className={`text-truncate me-1 ${page.isEmpty && 'opacity-75'}`}
-            >
-              {pageName}
-            </span>
-            {page.wip && (
-              <span className="wip-page-badge badge rounded-pill me-1 text-bg-secondary">
-                WIP
-              </span>
-            )}
+      {page != null &&
+        page.path != null &&
+        page._id != null &&
+        (href != null ? (
+          <Link
+            href={href}
+            prefetch={false}
+            className={`${titleClassName} text-reset`}
+            style={{ minWidth: 0 }}
+          >
+            {pageName}
+          </Link>
+        ) : (
+          <div className={titleClassName} style={{ minWidth: 0 }}>
+            {pageName}
           </div>
           </div>
-        </div>
+        ))}
+      {/* WIP is a status indicator — kept outside the link so it is not
+          read as part of the anchor's accessible name, and not truncated. */}
+      {page.wip && (
+        <span className="wip-page-badge badge rounded-pill ms-1 text-bg-secondary flex-shrink-0">
+          WIP
+        </span>
       )}
       )}
     </div>
     </div>
   );
   );

+ 3 - 1
apps/app/src/features/page-tree/components/TreeItemLayout.tsx

@@ -12,6 +12,7 @@ const indentSize = 10; // in px
 
 
 type TreeItemLayoutProps = TreeItemProps & {
 type TreeItemLayoutProps = TreeItemProps & {
   className?: string;
   className?: string;
+  asLink?: boolean;
 };
 };
 
 
 export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
 export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
@@ -24,6 +25,7 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     isReadOnlyUser,
     isReadOnlyUser,
     isWipPageShown = true,
     isWipPageShown = true,
     showAlternativeContent,
     showAlternativeContent,
+    asLink,
     onRenamed,
     onRenamed,
     onClick,
     onClick,
     onClickDuplicateMenuItem,
     onClickDuplicateMenuItem,
@@ -142,7 +144,7 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
           ))
           ))
         ) : (
         ) : (
           <>
           <>
-            <SimpleItemContent page={page} />
+            <SimpleItemContent page={page} asLink={asLink} />
             <div className="d-hover-none">
             <div className="d-hover-none">
               {EndComponents?.map((EndComponent, index) => (
               {EndComponents?.map((EndComponent, index) => (
                 // biome-ignore lint/suspicious/noArrayIndexKey: static component list
                 // biome-ignore lint/suspicious/noArrayIndexKey: static component list