スクロールしてヘッダーの変化を確認
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index c62a303..9c746c5 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -1,4 +1,9 @@
-import { useCallback, useEffect, useState } from 'react';
+import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
+import editScheduleIcon from '@/assets/icons/edit-schedule-white.svg';
+import eyeSolidIcon from '@/assets/icons/eye-solid-white.svg';
+import penToSquareSolidIcon from '@/assets/icons/pen-to-square-solid-white.svg';
+import { AddPageDialog } from '@/dialogs/AddPageDialog';
+import { EditPageDialog } from '@/dialogs/EditPageDialog';
import { cn } from '@/lib/utils';
import type { Page } from '@/types';
import type { Trip } from '@/types/trip';
@@ -11,34 +16,59 @@ type HeaderProps = {
trip: Trip;
pages: Page[];
mode?: 'view' | 'edit';
- selectedPageId?: string;
- onSelectPage: (pageId: string) => void;
+ selectedPageId?: Page['id'];
+ onSelectPage: (pageId: Page['id']) => void;
scrollContainerRef: React.RefObject;
+ setMode: Dispatch>;
className?: string;
};
+const transitionClassNames = 'duration-300 ease-in-out';
+
export function Header({
pages,
trip,
mode = 'view',
selectedPageId,
onSelectPage,
+ setMode,
className,
scrollContainerRef,
}: HeaderProps) {
const [isScrolled, setIsScrolled] = useState(false);
+ const [editPageDialogOpen, setEditPageDialogOpen] = useState(false);
+ const [addPageDialogOpen, setAddPageDialogOpen] = useState(false);
+ const scrollY = useRef(0);
const handleScroll = useCallback(() => {
const container = scrollContainerRef?.current;
if (!container) return;
const scrollTop = container.scrollTop;
- // ヘッダーの大きさを考慮して間を空ける
- if (!isScrolled && scrollTop > 100) {
- setIsScrolled(true);
- return;
- } else if (isScrolled && scrollTop <= 50) {
- setIsScrolled(false);
+ const deltaY = scrollTop - scrollY.current;
+
+ // isScrolledの次の状態を計算する
+ let nextIsScrolled = isScrolled;
+
+ if (scrollTop < 50) {
+ // ページ最上部では常に表示
+ nextIsScrolled = false;
+ } else if (Math.abs(deltaY) > 10) {
+ if (deltaY < 0 && isScrolled) {
+ // 上スクロール and ヘッダーが非表示中 -> 表示させる
+ nextIsScrolled = false;
+ } else if (deltaY > 0 && !isScrolled) {
+ // 下スクロール and ヘッダーが表示中 -> 非表示にさせる
+ nextIsScrolled = true;
+ }
+ }
+
+ if (nextIsScrolled !== isScrolled) {
+ setIsScrolled(nextIsScrolled);
+ } else {
+ // 状態が変化しなかった場合のみ、スクロール位置の基準を更新
+ // レイアウトシフトによる誤作動を防ぐ
+ scrollY.current = scrollTop;
}
}, [isScrolled, scrollContainerRef]);
@@ -56,45 +86,177 @@ export function Header({
-
-
+
+ {/* 左カラム */}
+
{trip.title}
+
+ {/* 中央カラム */}
{isScrolled ? (
selectedPage && (
-
+
{selectedPage.title}
)
) : (
-
+
+ {/* ページ追加ダイアログ */}
+
{
+ onSelectPage(page.id);
+ }}
+ />
+
+ {/* ページ情報編集ダイアログ */}
+ {selectedPage && (
+ {
+ const remainingPages = pages.filter(p => p.id !== pageId);
+ if (remainingPages.length > 0) {
+ onSelectPage(remainingPages[0].id);
+ }
+ }}
+ />
+ )}
);
}
+
+type HeaderButtonBaseProps = React.ComponentProps
& {
+ isScrolled: boolean;
+ iconSrc: string;
+ iconAlt?: string;
+};
+
+const HeaderButtonBase = ({
+ isScrolled,
+ iconSrc,
+ iconAlt,
+ className,
+ children,
+ ...buttonProps
+}: HeaderButtonBaseProps) => {
+ return (
+
+ );
+};
+
+const EditModeButton = ({
+ isScrolled,
+ setMode,
+}: {
+ isScrolled: boolean;
+ setMode: Dispatch>;
+}) => (
+ setMode('edit')}
+ >
+ 編集モード
+
+);
+
+const ViewModeButton = ({
+ isScrolled,
+ setMode,
+}: {
+ isScrolled: boolean;
+ setMode: Dispatch>;
+}) => (
+ setMode('view')}
+ >
+ 閲覧モード
+
+);
+
+const PageInfoEditButton = ({ isScrolled, onClick }: { isScrolled: boolean; onClick?: () => void }) => (
+
+ ページ情報編集
+
+);
diff --git a/frontend/src/components/HeaderSkeleton.stories.tsx b/frontend/src/components/HeaderSkeleton.stories.tsx
new file mode 100644
index 0000000..53123dd
--- /dev/null
+++ b/frontend/src/components/HeaderSkeleton.stories.tsx
@@ -0,0 +1,44 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { MemoryRouter } from 'react-router-dom';
+import { HeaderSkeleton } from './HeaderSkeleton';
+
+const meta = {
+ title: 'Components/HeaderSkeleton',
+ component: HeaderSkeleton,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: 'ヘッダーコンポーネントのローディング状態を表示するスケルトンコンポーネント。',
+ },
+ },
+ },
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+ argTypes: {
+ className: {
+ control: { type: 'text' },
+ description: '追加のCSSクラス',
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {},
+ parameters: {
+ docs: {
+ description: {
+ story: 'デフォルトのスケルトン表示。ヘッダーデータ読み込み中に表示されます。',
+ },
+ },
+ },
+};
diff --git a/frontend/src/components/HeaderSkeleton.tsx b/frontend/src/components/HeaderSkeleton.tsx
new file mode 100644
index 0000000..b9ecb99
--- /dev/null
+++ b/frontend/src/components/HeaderSkeleton.tsx
@@ -0,0 +1,40 @@
+import { cn } from '@/lib/utils';
+import { Logo } from './Logo';
+import { Skeleton } from './ui/skeleton';
+
+interface HeaderSkeletonProps {
+ className?: string;
+}
+
+export function HeaderSkeleton({ className }: HeaderSkeletonProps) {
+ return (
+
+ {/* Logo */}
+
+
+
+
+ {/* グリッドレイアウト */}
+
+ {/* 左カラム: trip.title */}
+
+
+
+
+ {/* 中央カラム: ページセレクト */}
+
+
+ {/* 右カラム: ボタン */}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Logo.stories.tsx b/frontend/src/components/Logo.stories.tsx
index 44dac76..64c28a2 100644
--- a/frontend/src/components/Logo.stories.tsx
+++ b/frontend/src/components/Logo.stories.tsx
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
+import { MemoryRouter } from 'react-router-dom';
import { Logo } from './Logo';
@@ -9,6 +10,13 @@ const meta: Meta = {
layout: 'centered',
},
tags: ['autodocs'],
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
argTypes: {
size: {
control: 'select',
diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx
index a5e76f7..5c01d6a 100644
--- a/frontend/src/components/Logo.tsx
+++ b/frontend/src/components/Logo.tsx
@@ -1,3 +1,4 @@
+import { Link } from 'react-router-dom';
import paperPlaneIcon from '@/assets/icons/paper-plane.svg';
import { cn } from '@/lib/utils';
@@ -8,7 +9,7 @@ interface LogoProps {
export function Logo({ size = 'medium', className }: LogoProps) {
return (
-
+

たびしぇあ
-
+
);
}
diff --git a/frontend/src/components/blocks/edit/BlockScheduleEdit.css b/frontend/src/components/blocks/edit/BlockScheduleEdit.css
new file mode 100644
index 0000000..c6a9c51
--- /dev/null
+++ b/frontend/src/components/blocks/edit/BlockScheduleEdit.css
@@ -0,0 +1,34 @@
+/* frontend/src/components/blocks/edit/BlockScheduleEdit.css */
+.BlockScheduleEdit .schedule-time-wrapper {
+ /* デフォルトは縦並び */
+ flex-direction: column;
+
+ .vertical-divider {
+ display: none;
+ }
+
+ .horizontal-divider {
+ display: block;
+ }
+}
+
+@container (height < 4rem) {
+ .BlockScheduleEdit .schedule-time-wrapper {
+ /* 横並びに変更 */
+ flex-direction: row;
+
+ .vertical-divider {
+ display: block;
+ }
+
+ .horizontal-divider {
+ display: none;
+ }
+ }
+}
+
+@container (height < 2rem) {
+ .BlockScheduleEdit .schedule-time-wrapper {
+ font-size: 12px !important;
+ }
+}
diff --git a/frontend/src/components/blocks/edit/BlockScheduleEdit.stories.ts b/frontend/src/components/blocks/edit/BlockScheduleEdit.stories.ts
index 69b2890..f7cfc2f 100644
--- a/frontend/src/components/blocks/edit/BlockScheduleEdit.stories.ts
+++ b/frontend/src/components/blocks/edit/BlockScheduleEdit.stories.ts
@@ -12,10 +12,11 @@ export default meta;
type Story = StoryObj
;
const baseScheduleBlock = {
- id: '1',
+ id: 1,
type: 'schedule' as const,
startTime: new Date(2024, 0, 1, 9, 0),
endTime: new Date(2024, 0, 1, 21, 30),
+ pageId: 1,
};
export const Default: Story = {
@@ -23,12 +24,12 @@ export const Default: Story = {
block: {
...baseScheduleBlock,
title: '草津温泉入浴',
- details: '湯畑周辺の温泉施設を巡る。特に西の河原公園の露天風呂がおすすめ。',
+ detail: '湯畑周辺の温泉施設を巡る。特に西の河原公園の露天風呂がおすすめ。',
},
},
};
-export const WithoutDetails: Story = {
+export const WithoutDetail: Story = {
args: {
block: {
...baseScheduleBlock,
@@ -37,12 +38,12 @@ export const WithoutDetails: Story = {
},
};
-export const LongTitleAndDetails: Story = {
+export const LongTitleAndDetail: Story = {
args: {
block: {
...baseScheduleBlock,
title: '草津温泉街散策と湯畑見学、お土産購入とカフェタイム',
- details: '温泉街をゆっくり散策しながら、湯畑の見学と地元の名産品を購入。最後にカフェでひと休み。'.repeat(5),
+ detail: '温泉街をゆっくり散策しながら、湯畑の見学と地元の名産品を購入。最後にカフェでひと休み。'.repeat(5),
},
},
};
diff --git a/frontend/src/components/blocks/edit/BlockScheduleEdit.tsx b/frontend/src/components/blocks/edit/BlockScheduleEdit.tsx
index 506e703..27e20bb 100644
--- a/frontend/src/components/blocks/edit/BlockScheduleEdit.tsx
+++ b/frontend/src/components/blocks/edit/BlockScheduleEdit.tsx
@@ -1,8 +1,8 @@
import dayjs from 'dayjs';
import circleInfoIcon from '@/assets/icons/circle-info.svg';
-import gripVerticalIcon from '@/assets/icons/grip-vertical-white.svg';
import { cn } from '@/lib/utils';
import type { ScheduleBlockComponentProps } from '../types';
+import './BlockScheduleEdit.css';
interface BlockScheduleEditProps extends ScheduleBlockComponentProps {}
@@ -10,24 +10,23 @@ export function BlockScheduleEdit({ block, className }: BlockScheduleEditProps)
return (
-
-

-
-
+
{block.endTime ? (
<>
-
{String(dayjs(block.startTime).format('HH:mm'))}
-
|
-
{String(dayjs(block.endTime).format('HH:mm'))}
+
{String(dayjs(block.startTime).format('HH:mm'))}
+
|
+
—
+
{String(dayjs(block.endTime).format('HH:mm'))}
>
) : (
-
{dayjs(block.startTime).format('HH:mm')}
+
{dayjs(block.startTime).format('HH:mm')}
)}
+
{block.title || 'タイトル未設定'}
;
+import { TransportationIcon } from '@/components/blocks/TransportationIcon';
+import type { TransportationBlock } from '@/types/block';
+
+interface BlockTransportationEditProps {
+ block: TransportationBlock;
+}
+
+export function BlockTransportationEdit({ block }: BlockTransportationEditProps) {
+ return (
+
+ {block.transportationType && (
+
+ )}
+ {block.title}
+
+ );
}
diff --git a/frontend/src/components/blocks/view/BlockScheduleView.stories.ts b/frontend/src/components/blocks/view/BlockScheduleView.stories.ts
index ab520f9..e39671e 100644
--- a/frontend/src/components/blocks/view/BlockScheduleView.stories.ts
+++ b/frontend/src/components/blocks/view/BlockScheduleView.stories.ts
@@ -12,10 +12,11 @@ export default meta;
type Story = StoryObj
;
const baseScheduleBlock = {
- id: '1',
+ id: 1,
type: 'schedule' as const,
startTime: new Date(2024, 0, 1, 9, 0),
endTime: new Date(2024, 0, 1, 11, 30),
+ pageId: 1,
};
export const Default: Story = {
@@ -23,12 +24,12 @@ export const Default: Story = {
block: {
...baseScheduleBlock,
title: '草津温泉入浴',
- details: '湯畑周辺の温泉施設を巡る。特に西の河原公園の露天風呂がおすすめ。',
+ detail: '湯畑周辺の温泉施設を巡る。特に西の河原公園の露天風呂がおすすめ。',
},
},
};
-export const WithoutDetails: Story = {
+export const WithoutDetail: Story = {
args: {
block: {
...baseScheduleBlock,
@@ -37,12 +38,12 @@ export const WithoutDetails: Story = {
},
};
-export const LongTitleAndDetails: Story = {
+export const LongTitleAndDetail: Story = {
args: {
block: {
...baseScheduleBlock,
title: '草津温泉街散策と湯畑見学、お土産購入とカフェタイム',
- details: '温泉街をゆっくり散策しながら、湯畑の見学と地元の名産品を購入。最後にカフェでひと休み。'.repeat(5),
+ detail: '温泉街をゆっくり散策しながら、湯畑の見学と地元の名産品を購入。最後にカフェでひと休み。'.repeat(5),
},
},
};
diff --git a/frontend/src/components/blocks/view/BlockScheduleView.tsx b/frontend/src/components/blocks/view/BlockScheduleView.tsx
index 8363a1c..10d09bc 100644
--- a/frontend/src/components/blocks/view/BlockScheduleView.tsx
+++ b/frontend/src/components/blocks/view/BlockScheduleView.tsx
@@ -48,7 +48,7 @@ export function BlockScheduleView({ block, className }: BlockScheduleViewProps)
)}
>
{block.title}
- {block.details && (
+ {block.detail && (
- {block.details}
+ {block.detail}
)}
- {block.details && isOverflowDetail && (
+ {block.detail && isOverflowDetail && (
- {block.details && (
+ {block.detail && (
- {block.details}
+ {block.detail}
)}
- {block.details && isOverflowDetail && (
+ {block.detail && isOverflowDetail && (