Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# PagePathNav と SubNavigation の z-index レイヤリング

## 概要

PagePathNav(ページパス表示)と GrowiContextualSubNavigation(PageControls等を含むサブナビゲーション)の
Sticky 状態における z-index の重なり順を修正した際の知見。

## 修正したバグ

### 症状
スクロールしていって PagePathNav がウィンドウ上端に近づいたときに、PageControls のボタンが
PagePathNav の要素の裏側に回ってしまい、クリックできなくなる。

### 原因
z-index 的に以下のように重なっていたため:

**[Before]** 下層から順に:
1. PageView の children - z-0
2. ( GroundGlassBar = PageControls ) ← 同じ層 z-1
3. PagePathNav

PageControls が PagePathNav より下層にいたため、sticky 境界付近でクリック不能になっていた。

## 修正後の構成

**[After]** 下層から順に:
1. PageView の children - z-0
2. GroundGlassBar(磨りガラス背景)- z-1
3. PagePathNav - z-2(通常時)/ z-3(sticky時)
4. PageControls(nav要素)- z-3

### ファイル構成

- `GrowiContextualSubNavigation.tsx` - GroundGlassBar を分離してレンダリング
- 1つ目: GroundGlassBar のみ(`position-fixed`, `z-1`)
- 2つ目: nav 要素(`z-3`)
- `PagePathNavSticky.tsx` - z-index を動的に切り替え
- 通常時: `z-2`
- sticky時: `z-3`

## 実装のポイント

### GroundGlassBar を分離した理由
GroundGlassBar を `position-fixed` で常に固定表示にすることで、
PageControls と切り離して独立した z-index 層として扱えるようにした。

これにより、GroundGlassBar → PagePathNav → PageControls という
理想的なレイヤー構造を実現できた。

## CopyDropdown が z-2 で動作しない理由(解決済み)

### 問題

`PagePathNavSticky.tsx` の sticky 時の z-index について:

```tsx
// これだと CopyDropdown(マウスオーバーで表示されるドロップダウン)が出ない
innerActiveClass="active z-2 mt-1"

// これだと正常に動作する
innerActiveClass="active z-3 mt-1"
```

### 原因

1. `GrowiContextualSubNavigation` の sticky-inner-wrapper は `z-3` かつ横幅いっぱい(Flex アイテム)
2. この要素が PagePathNavSticky(`z-2`)の上に重なる
3. CopyDropdown は `.grw-page-path-nav-layout:hover` で `visibility: visible` になる仕組み
(参照: `PagePathNavLayout.module.scss`)
4. **z-3 の要素が上に被さっているため、hover イベントが PagePathNavSticky に届かない**
5. 結果、CopyDropdown のアイコンが表示されない

### なぜ z-3 で動作するか

- 同じ z-index: 3 になるため、DOM 順序で前後が決まる
- PagePathNavSticky は GrowiContextualSubNavigation より後にレンダリングされるため前面に来る
- hover イベントが正常に届き、CopyDropdown が表示される

### 結論

PagePathNavSticky の sticky 時の z-index は `z-3` である必要がある。
これは GrowiContextualSubNavigation と同じ層に置くことで、DOM 順序による前後関係を利用するため。

## 関連ファイル

- `apps/app/src/client/components/PageView/PageView.tsx`
- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx`
- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss`
- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx`
- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss`
- `apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx`(CopyDropdown を含む)

## ライブラリの注意事項

### react-stickynode の deprecation
`react-stickynode` は **2025-12-31 で deprecated** となる予定。
https://github.com/yahoo/react-stickynode

将来的には CSS `position: sticky` + `IntersectionObserver` への移行を検討する必要がある。

## 注意事項

- z-index の値を変更する際は、上記のレイヤー構造を壊さないよう注意
- Sticky コンポーネントの `innerActiveClass` で z-index を指定する際、
他のコンポーネントとの相互作用を確認すること
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
@use '~/styles/mixins';
@use '@growi/core-styles/scss/bootstrap/init' as bs;

.grw-contextual-sub-navigation {
.grw-min-height-sub-navigation {
min-height: 46px;

@include bs.media-breakpoint-up(lg) {
min-height: 46px;
}
}

@include mixins.at-editing() {
Expand Down
151 changes: 79 additions & 72 deletions apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ import { Skeleton } from '../Skeleton';
import styles from './GrowiContextualSubNavigation.module.scss';
import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';

const moduleClass = styles['grw-contextual-sub-navigation'];
const minHeightSubNavigation = styles['grw-min-height-sub-navigation'];

const PageEditorModeManager = dynamic(
() =>
import('./PageEditorModeManager').then((mod) => mod.PageEditorModeManager),
Expand Down Expand Up @@ -456,90 +459,94 @@ const GrowiContextualSubNavigation = (

return (
<>
{/* for App Title for mobile */}
<GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />

{/* for Sub Navigation */}
<GroundGlassBar
className={`position-fixed z-1 d-edit-none d-print-none w-100 end-0 ${minHeightSubNavigation}`}
/>

<Sticky
className="z-1"
className="z-3"
enabled={!isPrinting}
onStateChange={(status) =>
setStickyActive(status.status === Sticky.STATUS_FIXED)
}
innerActiveClass="w-100 end-0"
>
<GroundGlassBar>
<nav
className={`${styles['grw-contextual-sub-navigation']}
d-flex align-items-center justify-content-end pe-2 pe-sm-3 pe-md-4 py-1 gap-2 gap-md-4 d-print-none
`}
data-testid="grw-contextual-sub-nav"
id="grw-contextual-sub-nav"
>
<PageControls
pageId={pageId}
revisionId={revisionId}
shareLinkId={shareLinkId}
path={path ?? currentPathname} // If the page is empty, "path" is undefined
expandContentWidth={shouldExpandContent}
disableSeenUserInfoPopover={isSharedUser}
hideSubControls={hideSubControls}
showPageControlDropdown={isAbleToShowPageManagement}
additionalMenuItemRenderer={additionalMenuItemsRenderer}
onClickDuplicateMenuItem={duplicateItemClickedHandler}
onClickRenameMenuItem={renameItemClickedHandler}
onClickDeleteMenuItem={deleteItemClickedHandler}
onClickSwitchContentWidth={switchContentWidthHandler}
<nav
className={`${moduleClass} ${minHeightSubNavigation}
d-flex align-items-center justify-content-end pe-2 pe-sm-3 pe-md-4 py-1 gap-2 gap-md-4 d-print-none
`}
data-testid="grw-contextual-sub-nav"
id="grw-contextual-sub-nav"
>
<PageControls
pageId={pageId}
revisionId={revisionId}
shareLinkId={shareLinkId}
path={path ?? currentPathname} // If the page is empty, "path" is undefined
expandContentWidth={shouldExpandContent}
disableSeenUserInfoPopover={isSharedUser}
hideSubControls={hideSubControls}
showPageControlDropdown={isAbleToShowPageManagement}
additionalMenuItemRenderer={additionalMenuItemsRenderer}
onClickDuplicateMenuItem={duplicateItemClickedHandler}
onClickRenameMenuItem={renameItemClickedHandler}
onClickDeleteMenuItem={deleteItemClickedHandler}
onClickSwitchContentWidth={switchContentWidthHandler}
/>

{isAbleToChangeEditorMode && (
<PageEditorModeManager
editorMode={editorMode}
isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
path={path}
/>
)}

{isAbleToChangeEditorMode && (
<PageEditorModeManager
editorMode={editorMode}
isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
path={path}
/>
)}

{isGuestUser && (
<div className="mt-2">
<span>
<span className="d-inline-block" id="sign-up-link">
<Link
href={
!isLocalAccountRegistrationEnabled
? '#'
: '/login#register'
}
className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
style={{
pointerEvents: !isLocalAccountRegistrationEnabled
? 'none'
: undefined,
}}
prefetch={false}
>
<span className="material-symbols-outlined me-1">
person_add
</span>
{t('Sign up')}
</Link>
</span>
{!isLocalAccountRegistrationEnabled && (
<UncontrolledTooltip target="sign-up-link" fade={false}>
{t('tooltip.login_required')}
</UncontrolledTooltip>
)}
{isGuestUser && (
<div>
<span>
<span className="d-inline-block" id="sign-up-link">
<Link
href={
!isLocalAccountRegistrationEnabled
? '#'
: '/login#register'
}
className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
style={{
pointerEvents: !isLocalAccountRegistrationEnabled
? 'none'
: undefined,
}}
prefetch={false}
>
<span className="material-symbols-outlined me-1">
person_add
</span>
{t('Sign up')}
</Link>
</span>
<Link
href="/login#login"
className="btn btn-primary"
prefetch={false}
>
<span className="material-symbols-outlined me-1">login</span>
{t('Sign in')}
</Link>
</div>
)}
</nav>
</GroundGlassBar>
{!isLocalAccountRegistrationEnabled && (
<UncontrolledTooltip target="sign-up-link" fade={false}>
{t('tooltip.login_required')}
</UncontrolledTooltip>
)}
</span>
<Link
href="/login#login"
className="btn btn-primary"
prefetch={false}
>
<span className="material-symbols-outlined me-1">login</span>
{t('Sign in')}
</Link>
</div>
)}
</nav>
</Sticky>

{path != null && currentUser != null && !isReadOnlyUser && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
@use '@growi/core-styles/scss/bootstrap/init' as bs;

.grw-page-path-nav-sticky :global {
.sticky-inner-wrapper {
z-index: bs.$zindex-sticky;
}

// TODO:Responsive font size
// set smaller font-size when sticky
.sticky-inner-wrapper.active {
h1 {
font-size: 1.75rem !important;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const { isTrashPage } = pagePathUtils;


export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element => {
const { pagePath } = props;
const { pagePath, latterLinkClassName, ...rest } = props;

const isPrinting = usePrintMode();

Expand Down Expand Up @@ -84,33 +84,42 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
// Controlling pointer-events
// 1. disable pointer-events with 'pe-none'
<div ref={pagePathNavRef}>
<Sticky className={moduleClass} enabled={!isPrinting} innerClass="pe-none" innerActiveClass="active mt-1">
<Sticky className={moduleClass} enabled={!isPrinting} innerClass="z-2 pe-none" innerActiveClass="active z-3 mt-1">
{({ status }) => {
const isParentsCollapsed = status === Sticky.STATUS_FIXED;

// Controlling pointer-events
// 2. enable pointer-events with 'pe-auto' only against the children
// which width is minimized by 'd-inline-block'
//
if (isParentsCollapsed) {
return (
<div className="d-inline-block pe-auto">
<PagePathNavLayout
{...props}
latterLink={latterLink}
latterLinkClassName="fs-3 text-truncate"
maxWidth={navMaxWidth}
/>
</div>
);
}
const isStatusFixed = status === Sticky.STATUS_FIXED;

return (
// Use 'd-block' to make the children take the full width
// This is to improve UX when opening/closing CopyDropdown
<div className="d-block pe-auto">
<PagePathNav {...props} inline />
</div>
<>
{/*
* Controlling pointer-events
* 2. enable pointer-events with 'pe-auto' only against the children
* which width is minimized by 'd-inline-block'
*/}
{ isStatusFixed && (
<div className="d-inline-block pe-auto position-absolute">
<PagePathNavLayout
pagePath={pagePath}
latterLink={latterLink}
latterLinkClassName={`${latterLinkClassName} text-truncate`}
maxWidth={navMaxWidth}
{...rest}
/>
</div>
)}

{/*
* Use 'd-block' to make the children take the full width
* This is to improve UX when opening/closing CopyDropdown
*/}
<div className={`d-block pe-auto ${isStatusFixed ? 'invisible' : ''}`}>
<PagePathNav
pagePath={pagePath}
latterLinkClassName={latterLinkClassName}
inline
{...rest}
/>
</div>
</>
);
}}
</Sticky>
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/client/components/TrashPageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export const TrashPageList = (): JSX.Element => {
}, [t]);

return (
<div data-testid="trash-page-list" className="mt-5 d-edit-none">
<div data-testid="trash-page-list" className="d-edit-none">
<CustomNavAndContents
navTabMapping={navTabMapping}
navRightElement={emptyTrashButton}
Expand Down
Loading
Loading