From 4804f4479a1173d79a19f1590060131cc67c1c66 Mon Sep 17 00:00:00 2001 From: nabeliwo Date: Tue, 24 Dec 2024 13:36:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AppHeader=20=E3=82=B3=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20=20(#5203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/AppHeader/AppHeader.tsx | 22 ++ .../src/components/AppHeader/README.md | 60 ++++ .../components/common/AppLauncherFeatures.tsx | 71 +++++ .../common/AppLauncherSortDropdown.tsx | 97 ++++++ .../components/common/CommonButton.tsx | 73 +++++ .../AppHeader/components/common/Translate.tsx | 5 + .../components/desktop/AppLauncher.tsx | 211 +++++++++++++ .../components/desktop/DesktopHeader.tsx | 163 ++++++++++ .../components/desktop/Navigation.tsx | 144 +++++++++ .../desktop/ReleaseNotesDropdown.tsx | 85 ++++++ .../AppHeader/components/desktop/UserInfo.tsx | 210 +++++++++++++ .../components/mobile/AppLauncher.tsx | 112 +++++++ .../components/mobile/AppLauncherContext.tsx | 13 + .../mobile/AppLauncherFilterDropdown.tsx | 83 ++++++ .../AppHeader/components/mobile/Help.tsx | 59 ++++ .../components/mobile/LanguageSelector.tsx | 68 +++++ .../AppHeader/components/mobile/Menu.tsx | 122 ++++++++ .../components/mobile/MenuAccordion.tsx | 55 ++++ .../components/mobile/MenuButton.tsx | 23 ++ .../components/mobile/MenuDialog.tsx | 152 ++++++++++ .../components/mobile/MenuSubHeader.tsx | 29 ++ .../components/mobile/MobileHeader.tsx | 93 ++++++ .../components/mobile/Navigation.tsx | 54 ++++ .../components/mobile/NavigationContext.tsx | 9 + .../mobile/NavigationGroupHeader.tsx | 19 ++ .../components/mobile/NavigationItem.tsx | 93 ++++++ .../components/mobile/ReleaseNote.tsx | 71 +++++ .../components/mobile/ReleaseNoteContext.tsx | 13 + .../components/mobile/TenantSelector.tsx | 70 +++++ .../AppHeader/components/mobile/UserInfo.tsx | 115 ++++++++ .../AppHeader/hooks/useAppLauncher.ts | 112 +++++++ .../components/AppHeader/hooks/useLocale.tsx | 18 ++ .../AppHeader/hooks/useMediaQuery.ts | 29 ++ .../AppHeader/hooks/useTranslate.ts | 15 + .../src/components/AppHeader/index.ts | 1 + .../AppHeader/multilingualization/index.ts | 3 + .../multilingualization/localeMap.ts | 10 + .../AppHeader/multilingualization/messages.ts | 278 ++++++++++++++++++ .../multilingualization/translate.ts | 7 + .../AppHeader/multilingualization/types.ts | 4 + .../AppHeader/stories/AppHeader.stories.tsx | 17 ++ .../stories/VRTAppHeader.stories.tsx | 224 ++++++++++++++ .../src/components/AppHeader/stories/args.tsx | 153 ++++++++++ .../src/components/AppHeader/types.ts | 99 +++++++ .../src/components/AppHeader/utils.ts | 33 +++ packages/smarthr-ui/src/index.test.ts | 4 +- packages/smarthr-ui/src/index.ts | 1 + 47 files changed, 3401 insertions(+), 1 deletion(-) create mode 100644 packages/smarthr-ui/src/components/AppHeader/AppHeader.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/README.md create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/common/AppLauncherFeatures.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/common/AppLauncherSortDropdown.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/common/CommonButton.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/common/Translate.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/desktop/AppLauncher.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/desktop/DesktopHeader.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/desktop/Navigation.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/desktop/ReleaseNotesDropdown.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/desktop/UserInfo.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncher.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncherContext.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncherFilterDropdown.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/Help.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/LanguageSelector.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/Menu.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuAccordion.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuButton.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuDialog.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuSubHeader.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/MobileHeader.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/Navigation.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationContext.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationGroupHeader.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationItem.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/ReleaseNote.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/ReleaseNoteContext.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/TenantSelector.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/components/mobile/UserInfo.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/hooks/useAppLauncher.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/hooks/useLocale.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/hooks/useMediaQuery.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/hooks/useTranslate.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/index.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/multilingualization/index.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/multilingualization/localeMap.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/multilingualization/messages.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/multilingualization/translate.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/multilingualization/types.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/stories/AppHeader.stories.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/stories/VRTAppHeader.stories.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/stories/args.tsx create mode 100644 packages/smarthr-ui/src/components/AppHeader/types.ts create mode 100644 packages/smarthr-ui/src/components/AppHeader/utils.ts diff --git a/packages/smarthr-ui/src/components/AppHeader/AppHeader.tsx b/packages/smarthr-ui/src/components/AppHeader/AppHeader.tsx new file mode 100644 index 0000000000..332c5f17c0 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/AppHeader.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react' + +import { DesktopHeader } from './components/desktop/DesktopHeader' +import { MobileHeader } from './components/mobile/MobileHeader' +import { LocaleContextProvider } from './hooks/useLocale' +import { mediaQuery, useMediaQuery } from './hooks/useMediaQuery' +import { HeaderProps } from './types' + +export const AppHeader: FC = ({ locale, children, ...props }) => { + // NOTE: ヘッダーの出し分けは CSS によって行われているので、useMediaQuery による children の出し分けは本来不要ですが、 + // wovn の言語切替カスタム UI の挿入対象となる DOM ("wovn-embedded-widget-anchor" クラスを持った div) が複数描画されていると、 + // wovn のスクリプトの仕様上1つ目の DOM にしか UI が挿入されないため、やむを得ず children のみ React のレンダリングレベルでの出し分けをしています。 + const isDesktop = useMediaQuery(mediaQuery.desktop) + const isMobile = useMediaQuery(mediaQuery.mobile) + + return ( + + {isDesktop && children} + {isMobile && children} + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/README.md b/packages/smarthr-ui/src/components/AppHeader/README.md new file mode 100644 index 0000000000..e8d106eb0f --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/README.md @@ -0,0 +1,60 @@ +## このコンポーネントについて + +props を埋めていくだけで良い感じに共通のヘッダー/ナビゲーションの UI が組み立てられるコンポーネントです。 + +## どのように使うのか + +`types.ts` にある HeaderProps というのが最終的な props なのであり、これらを穴埋めしていくことになります。 +`xxxAdditionalContent` 以外の props は積極的に埋めてください。 + +```ts +export type HeaderProps = ComponentProps & { + locale?: LocaleProps | null + enableNew?: boolean + appName?: ReactNode + schoolUrl?: string | null + helpPageUrl?: string | null + userInfo?: UserInfoProps | null + desktopAdditionalContent?: ReactNode + navigations?: Navigation[] | null + desktopNavigationAdditionalContent?: ReactNode + releaseNote?: ReleaseNoteProps | null + features?: Array + mobileAdditionalContent?: ReactNode +} +``` + +下記に、少し特殊な動きをするものやパッと見分かりづらいであろうと思われる props だけ補足説明を書きます。 + +- `locale` + - 多言語対応に wovn を使っている場合はこの props は不要です。 +- `tenants` + - デスクトップ表示時 + - Header コンポーネントと同じです。 + - モバイル表示時 + - ハンバーガーメニューが表示されている場合はメニューの中に、そうでない場合はデスクトップ表示時と同じ箇所 (ロゴの横) に表示されます。 + - もし既存の独自実装ハンバーガーメニュー内にテナント選択の UI があるなどの理由で「ハンバーガーメニューは表示しないがモバイル表示時にヘッダーにテナント選択の UI を表示したくない」という場合は、ウィンドウサイズが 751px 以下のときに tenants props に undefined を渡すようにしてください。 +- `navigations` + - ヘッダーの下にナビゲーションが表示されるようになります。 + - AppNavi コンポーネントの buttons props とほぼ同じ型のデータを取ります。 + - AppNavi コンポーネントの buttons にはなかった、ドロップダウン内でのナビゲーションのグルーピングができるようになっています。 + - storybook の「VRT Navigation Dropdown Group」を参考にしてください。 + - **navigations props に値が渡されているときのみ、モバイル表示時にハンバーガーメニューが表示されます。独自実装の ハンバーガーメニューが存在する場合は、navigations props を利用するタイミングで移行してください。** +- `desktopAdditionalContent` + - ユーザー名をクリックしたときのドロップダウンの、「個人設定」の下に入れたいものがある場合に使います。 + - 見た目の共通化のため、乱用は避けてください +- `desktopNavigationAdditionalContent` + - ナビゲーション内で右寄せ、かつリリースノートの左側に入れたい物がある場合に使います。 + - 見た目の共通化のため、乱用は避けてください +- `mobileAdditionalContent` + - モバイル表示時に、メニュー内に何か追加で起きたいものがある場合に使います。 + - 見た目の共通化のため、乱用は避けてほしいですが、もし何かしらのパーツを配置する必要がある場合は、デザイナーと相談しながら実装してください。 + +## 多言語対応について + +- wovn を使っているアプリの場合 + - 内部で表示されているテキストに関しては、すべて `woven-enabled="true"` がついています。 + - 外部から渡すテキストは全て `ReactNode` 型で受け取るようになっているので、`ほげ` みたいなものを渡すようにしてください。 +- 辞書を持っているアプリの場合 + - コンポーネント側で辞書を持っているので、`locale` の props を埋めると内部的に持っているテキストは翻訳されます。 + - 外部から渡すテキストはアプリケーション側で翻訳されたものを渡すようにしてください。 diff --git a/packages/smarthr-ui/src/components/AppHeader/components/common/AppLauncherFeatures.tsx b/packages/smarthr-ui/src/components/AppHeader/components/common/AppLauncherFeatures.tsx new file mode 100644 index 0000000000..969585c611 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/common/AppLauncherFeatures.tsx @@ -0,0 +1,71 @@ +import React, { FC } from 'react' +import { tv } from 'tailwind-variants' + +import { AnchorButton } from '../../../Button' +import { FaArrowRightIcon, FaStarIcon } from '../../../Icon' +import { LineClamp } from '../../../LineClamp' +import { Text } from '../../../Text' +import { mediaQuery, useMediaQuery } from '../../hooks/useMediaQuery' +import { useTranslate } from '../../hooks/useTranslate' +import { Launcher } from '../../types' + +import { Translate } from './Translate' + +const appLauncherFeatures = tv({ + slots: { + empty: ['shr-p-1 shr-text-center'], + list: ['shr-list-none', '[&>li]:shr-px-0.5 [&>li]:shr-py-0.25'], + listItem: [ + 'smarthr-ui-AppLauncher-listItem', + 'shr-grid shr-grid-cols-[1rem_1fr_1rem] shr-gap-0.75 shr-min-h-[2.5rem] shr-px-1 shr-py-0 shr-leading-tight shr-text-left shr-whitespace-normal', + ], + }, + variants: { + favorite: { + false: { + listItem: ['shr-grid-cols-[1fr_1rem]'], + }, + }, + }, +}) + +type Props = { + features: Array + page: Launcher['page'] +} + +export const AppLauncherFeatures: FC = ({ features, page }) => { + const isDesktop = useMediaQuery(mediaQuery.desktop) + const translate = useTranslate() + const { empty, list, listItem } = appLauncherFeatures() + + if (features.length === 0) { + return ( +
+ + {translate('Launcher/emptyText')} + +
+ ) + } + + return ( +
    + {features.map((feature) => ( +
  • + } + suffix={} + wide + target="_blank" + > + {isDesktop ? {feature.name} : feature.name} + +
  • + ))} +
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/common/AppLauncherSortDropdown.tsx b/packages/smarthr-ui/src/components/AppHeader/components/common/AppLauncherSortDropdown.tsx new file mode 100644 index 0000000000..253da6e356 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/common/AppLauncherSortDropdown.tsx @@ -0,0 +1,97 @@ +import React, { FC, useRef } from 'react' +import { tv } from 'tailwind-variants' + +import { textColor } from '../../../../themes' +import { Button } from '../../../Button' +import { Dropdown, DropdownContent, DropdownTrigger } from '../../../Dropdown' +import { FaCaretDownIcon, FaCheckIcon } from '../../../Icon' +import { Stack } from '../../../Layout' +import { useTranslate } from '../../hooks/useTranslate' +import { Launcher } from '../../types' +import { Translate } from '../common/Translate' + +const sortDropdown = tv({ + slots: { + trigger: [ + 'smarthr-ui-AppLauncher-SortDropdown-trigger', + 'shr-gap-0.25 shr-text-grey', + '[&[aria-expanded="true"]>.smarthr-ui-Icon]:shr-rotate-180', + ], + stack: ['shr-px-0.25 shr-py-0.5'], + contentButton: ['shr-border-none shr-justify-start shr-py-0.75 shr-font-normal shr-pl-2.5'], + }, + variants: { + selected: { + true: { + contentButton: ['shr-pl-1'], + }, + }, + }, +}) + +type Props = { + sortType: Launcher['sortType'] + onSelectSortType: (sortType: Launcher['sortType']) => void +} + +export const AppLauncherSortDropdown: FC = ({ sortType, onSelectSortType }) => { + const translate = useTranslate() + const triggerRef = useRef(null) + const { trigger, stack, contentButton } = sortDropdown() + + const sortMap: Record = { + default: translate('Launcher/sortDropdownOrderDefault'), + 'name/asc': translate('Launcher/sortDropdownOrderNameAsc'), + 'name/desc': translate('Launcher/sortDropdownOrderNameDesc'), + } + + return ( + + + + + + + {/* eslint-disable-next-line smarthr/best-practice-for-layouts */} + + {Object.entries(sortMap).map(([key, value], i) => ( + + ))} + + + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/common/CommonButton.tsx b/packages/smarthr-ui/src/components/AppHeader/components/common/CommonButton.tsx new file mode 100644 index 0000000000..587f7885ab --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/common/CommonButton.tsx @@ -0,0 +1,73 @@ +import React, { ComponentPropsWithoutRef, FC, ReactNode } from 'react' +import { tv } from 'tailwind-variants' + +export const commonButton = tv({ + base: [ + '[&&]:shr-flex [&&]:shr-items-center [&&]:shr-w-full [&&]:shr-px-1 [&&]:shr-py-0.5 [&&]:shr-box-border [&&]:shr-bg-transparent [&&]:shr-text-base [&&]:shr-text-black [&&]:shr-leading-normal [&&]:shr-no-underline [&&]:shr-rounded-m [&&]:shr-cursor-pointer [&&]:shr-border-none', + '[&&]:hover:shr-bg-white-darken', + '[&&]:focus-visible:shr-bg-white-darken', + ], + variants: { + prefix: { + true: ['[&&]:shr-gap-0.5'], + }, + current: { + true: ['[&&]:shr-bg-white-darken'], + }, + boldWhenCurrent: { + true: null, + false: ['[&&]:shr-font-normal'], + }, + }, + compoundVariants: [ + { + boldWhenCurrent: true, + current: true, + className: ['[&&]:shr-font-bold'], + }, + ], +}) + +type AnchorProps = Omit, 'prefix'> +type ButtonProps = Omit, 'prefix'> + +type Props = (({ elementAs: 'a' } & AnchorProps) | ({ elementAs: 'button' } & ButtonProps)) & { + prefix?: ReactNode + current?: boolean + boldWhenCurrent?: boolean +} + +export const CommonButton: FC = ({ + elementAs, + prefix, + current, + boldWhenCurrent, + className, + ...props +}) => { + const commonButtonStyle = commonButton({ + prefix: Boolean(prefix), + current, + boldWhenCurrent, + className, + }) + + if (elementAs === 'a') { + return ( + + {prefix} + {props.children} + + ) + } else if (elementAs === 'button') { + return ( + // eslint-disable-next-line smarthr/best-practice-for-button-element + + ) + } else { + throw new Error(elementAs satisfies never) + } +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/common/Translate.tsx b/packages/smarthr-ui/src/components/AppHeader/components/common/Translate.tsx new file mode 100644 index 0000000000..1c8da968e9 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/common/Translate.tsx @@ -0,0 +1,5 @@ +import React, { PropsWithChildren, memo } from 'react' + +export const Translate = memo(({ children }) => ( + {children} +)) diff --git a/packages/smarthr-ui/src/components/AppHeader/components/desktop/AppLauncher.tsx b/packages/smarthr-ui/src/components/AppHeader/components/desktop/AppLauncher.tsx new file mode 100644 index 0000000000..ca5ffe7552 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/desktop/AppLauncher.tsx @@ -0,0 +1,211 @@ +import React, { FC, ReactNode } from 'react' +import { tv } from 'tailwind-variants' + +import { textColor } from '../../../../themes' +import { UnstyledButton } from '../../../Button' +import { Heading } from '../../../Heading' +import { FaCircleXmarkIcon, FaStarIcon } from '../../../Icon' +import { SearchInput } from '../../../Input' +import { Cluster } from '../../../Layout' +import { Section } from '../../../SectioningContent' +import { SideNav } from '../../../SideNav' +import { TextLink } from '../../../TextLink' +import { useAppLauncher } from '../../hooks/useAppLauncher' +import { useTranslate } from '../../hooks/useTranslate' +import { Launcher } from '../../types' +import { AppLauncherFeatures } from '../common/AppLauncherFeatures' +import { AppLauncherSortDropdown } from '../common/AppLauncherSortDropdown' +import { Translate } from '../common/Translate' + +type Props = { + features: Array +} + +const appLauncher = tv({ + slots: { + wrapper: [ + 'smarthr-ui-AppLauncher', + 'shr-grid shr-grid-rows-[auto_1fr] shr-w-[38rem] shr-h-[40rem]', + ], + searchArea: [ + 'smarthr-ui-AppLauncher-searchArea', + 'shr-p-1 shr-border-b-shorthand', + '[&_.smarthr-ui-Input]:shr-h-[42px]', + ], + inner: ['smarthr-ui-AppLauncher-inner', 'shr-grid shr-grid-cols-[11rem_1fr] shr-min-h-0'], + side: [ + 'smarthr-ui-AppLauncher-side', + 'shr-flex shr-flex-col shr-pt-0.5 shr-pb-1 shr-border-r-shorthand shr-bg-column', + '[&_hr]:shr-h-[1px] [&_hr]:shr-m-0.5 [&_hr]:shr-border-none [&_hr]:shr-bg-border', + ], + sideNav: [ + '[&_.smarthr-ui-SideNav-item>button]:shr-py-0.75 [&_.smarthr-ui-SideNav-item>button]:shr-px-1', + '[&_.smarthr-ui-SideNav-item>button>span]:shr-flex-nowrap', + '[&_.smarthr-ui-SideNav-item>button_.smarthr-ui-Icon]:shr-shrink-0 [&_.smarthr-ui-SideNav-item>button_.smarthr-ui-Icon]:shr-align-bottom', + ], + sideNavHeading: ['shr-py-0.75 shr-px-1 shr-text-xs shr-text-black'], + help: ['smarthr-ui-AppLauncher-help', 'shr-mt-auto shr-px-1 shr-text-xs'], + main: ['smarthr-ui-AppLauncher-main', 'shr-grid shr-min-h-0'], + mainInner: ['shr-grid shr-grid-rows-[auto_1fr] shr-min-h-0'], + contentHead: [ + 'shr-min-h-[2rem] shr-py-0.75 shr-px-1', + '[&_.smarthr-ui-Heading]:shr-text-black', + ], + scrollArea: ['shr-overflow-y-scroll shr-h-[509px]'], + }, + variants: { + noIcon: { + true: { + sideNav: ['[&_.smarthr-ui-SideNav-item>button]:shr-pl-1.5'], + }, + }, + selected: { + false: { + sideNav: ['[&_.smarthr-ui-SideNav-item>button_.smarthr-ui-Icon]:shr-text-grey'], + }, + }, + }, +}) + +export const AppLauncher: FC = ({ features: baseFeatures }) => { + const translate = useTranslate() + const { + features, + page, + mode, + sortType, + searchQuery, + changePage, + setSortType, + changeSearchQuery, + } = useAppLauncher(baseFeatures) + + const { + wrapper, + searchArea, + inner, + side, + sideNav, + sideNavHeading, + help, + main, + mainInner, + contentHead, + scrollArea, + } = appLauncher() + + const pageMap: Record = { + favorite: {translate('Launcher/favoriteModeText')}, + all: {translate('Launcher/allModeText')}, + } + + return ( +
+
+ {translate('Launcher/searchInputTitle')}} + width="100%" + value={searchQuery} + suffix={ + mode === 'search' && ( + { + // 別のキューにしないとドロップダウンが閉じてしまう + setTimeout(() => { + changeSearchQuery('') + }, 0) + }} + > + + + ) + } + onChange={(e) => changeSearchQuery(e.target.value)} + /> +
+ +
+
+ + ), + isSelected: mode !== 'search' && page === 'favorite', + }, + ]} + onClick={(_, id) => { + changePage(id as Launcher['page']) + }} + /> + +
+ +
+ + {translate('Launcher/listText')} + + + { + changePage(id as Launcher['page']) + }} + /> +
+ +
+ + {translate('Launcher/helpText')} + +
+
+ +
+
+ + + {mode === 'search' ? ( + {translate('Launcher/searchResultText')} + ) : ( + pageMap[page] + )} + + + {(mode === 'search' || page === 'all') && ( + setSortType(value)} + /> + )} + + +
+ +
+
+
+
+
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/desktop/DesktopHeader.tsx b/packages/smarthr-ui/src/components/AppHeader/components/desktop/DesktopHeader.tsx new file mode 100644 index 0000000000..e6528159c4 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/desktop/DesktopHeader.tsx @@ -0,0 +1,163 @@ +import React, { FC } from 'react' +import { tv } from 'tailwind-variants' + +import { Button } from '../../../Button' +import { Dropdown, DropdownContent, DropdownTrigger } from '../../../Dropdown' +import { Header, HeaderLink, LanguageSwitcher } from '../../../Header' +import { + FaCaretDownIcon, + FaCircleQuestionIcon, + FaGraduationCapIcon, + FaRegCircleQuestionIcon, + FaToolboxIcon, +} from '../../../Icon' +import { Cluster } from '../../../Layout' +import { useLocale } from '../../hooks/useLocale' +import { useTranslate } from '../../hooks/useTranslate' +import { localeMap } from '../../multilingualization' +import { HeaderProps } from '../../types' +import { Translate } from '../common/Translate' + +import { AppLauncher } from './AppLauncher' +import { Navigation } from './Navigation' +import { UserInfo } from './UserInfo' + +const desktopHeader = tv({ + slots: { + wrapper: 'max-[751px]:!shr-hidden', + appsButton: [ + 'shr-border-none shr-font-normal shr-text-white shr-bg-transparent shr-px-0.25', + 'hover:shr-border-transparent hover:shr-bg-transparent', + 'focus-visible:shr-border-transparent focus-visible:shr-bg-transparent', + 'forced-colors:shr-border-shorthand', + ], + }, + variants: { + enableNew: { + true: { + appsButton: [ + 'shr-px-0.5 shr-font-bold shr-text-black', + '[&_>_svg]:aria-expanded:shr-rotate-180', + 'hover:shr-bg-white-darken', + 'focus-visible:shr-bg-white-darken', + ], + }, + }, + }, +}) + +export const DesktopHeader: FC = ({ + enableNew, + className = '', + appName, + tenants, + currentTenantId, + schoolUrl, + helpPageUrl, + children, + userInfo, + desktopAdditionalContent, + navigations, + desktopNavigationAdditionalContent, + releaseNote, + features, + ...props +}) => { + const translate = useTranslate() + const { locale } = useLocale() + + const { wrapper, appsButton } = desktopHeader() + + return ( + <> +
+ + {!enableNew && ( + <> + {features && features.length > 0 && ( + + + + + + + + + + )} + + {schoolUrl && ( + } + className="shr-flex shr-items-center shr-py-0.75 shr-leading-none" + > + {translate('common/school')} + + )} + + )} + + {helpPageUrl && ( + : } + className={ + enableNew ? undefined : 'shr-flex shr-items-center shr-py-0.75 shr-leading-none' + } + enableNew={enableNew} + > + {translate('common/help')} + + )} + + {locale && ( + void} + enableNew={enableNew} + /> + )} + + {children} + + {userInfo && ( + + )} + +
+ + {navigations && ( + + )} + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/desktop/Navigation.tsx b/packages/smarthr-ui/src/components/AppHeader/components/desktop/Navigation.tsx new file mode 100644 index 0000000000..c5c38d6e3d --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/desktop/Navigation.tsx @@ -0,0 +1,144 @@ +import React, { ComponentProps, FC, Fragment, ReactNode } from 'react' +import { tv } from 'tailwind-variants' + +import { AppNavi } from '../../../AppNavi' +import { Cluster } from '../../../Layout' +import { Text } from '../../../Text' +import { ChildNavigation, Navigation as NavigationType, ReleaseNoteProps } from '../../types' +import { isChildNavigation, isChildNavigationGroup } from '../../utils' +import { CommonButton, commonButton } from '../common/CommonButton' + +import { ReleaseNotesDropdown } from './ReleaseNotesDropdown' + +const appNavi = tv({ + base: ['shr-overflow-x-auto shr-min-w-[auto]', 'max-[751px]:!shr-hidden'], + variants: { + withReleaseNote: { + true: ['[&&]:shr-pe-0'], + }, + }, +}) + +type Props = { + appName: ReactNode + navigations: NavigationType[] + additionalContent: ReactNode + releaseNote?: ReleaseNoteProps | null + enableNew?: boolean +} + +export const Navigation: FC = ({ + appName, + navigations, + additionalContent, + releaseNote, + enableNew, +}) => { + const buttons = buildButtonsFromNavigations(navigations) + + return ( + + {additionalContent} + {releaseNote && } + + } + /> + ) +} + +const navigationTitle = tv({ + base: ['shr-px-1 shr-pt-0.5 shr-pb-0.25'], +}) + +const separator = tv({ + base: ['[&&]:shr-mx-0 [&&]:shr-my-0.5 [&&]:shr-border-b-shorthand'], +}) + +// TODO smarthr-ui 側でグループ化された Navigation が対応されたら AppNaviDropdownMenuButton を使った実装に変更する +const buildButtonsFromNavigations = ( + navigations: NavigationType[], +): ComponentProps['buttons'] => + navigations.map((navigation) => { + if (isChildNavigation(navigation)) { + // smarthr-ui の buttons props ではカスタムエレメントは elementAs ではなく tag という名前なので変換する必要がある + if ('elementAs' in navigation) { + const { elementAs, ...rest } = navigation + return { + ...rest, + tag: elementAs, + } + } + return navigation + } + + // 子要素に current を持っているものがあるかどうか + const childrenHasCurrent = navigation.childNavigations.some((child) => { + if (isChildNavigation(child)) return child.current + return child.childNavigations.some((c) => c.current) + }) + + return { + ...navigation, + current: navigation.current || childrenHasCurrent, + dropdownContent: ( +
+ {navigation.childNavigations.map((childNavigation, i) => { + if (isChildNavigationGroup(childNavigation)) { + const { childNavigations } = childNavigation + + return ( + +
+ + {childNavigation.title} + + + {childNavigations.map((child) => ( + + {buildDropdownItemFromNavigation(child)} + + ))} +
+ + {i + 1 !== navigation.childNavigations.length &&
} +
+ ) + } + + const nextChildNavigation = navigation.childNavigations[i + 1] + + return ( + +
{buildDropdownItemFromNavigation(childNavigation)}
+ {isChildNavigationGroup(nextChildNavigation) &&
} +
+ ) + })} +
+ ), + } + }) + +const buildDropdownItemFromNavigation = (navigation: ChildNavigation) => { + if ('elementAs' in navigation) { + const { elementAs: Tag, current: isCurrent, ...rest } = navigation + + return ( + + ) + } + + if ('href' in navigation) { + return + } + + return ( + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/desktop/ReleaseNotesDropdown.tsx b/packages/smarthr-ui/src/components/AppHeader/components/desktop/ReleaseNotesDropdown.tsx new file mode 100644 index 0000000000..326e8d4cc7 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/desktop/ReleaseNotesDropdown.tsx @@ -0,0 +1,85 @@ +import React, { FC } from 'react' +import { tv } from 'tailwind-variants' + +import { Button } from '../../../Button' +import { Dropdown, DropdownContent, DropdownTrigger } from '../../../Dropdown' +import { FaCaretDownIcon } from '../../../Icon' +import { Center } from '../../../Layout' +import { Loader } from '../../../Loader' +import { Text } from '../../../Text' +import { TextLink } from '../../../TextLink' +import { useTranslate } from '../../hooks/useTranslate' +import { ReleaseNoteProps } from '../../types' +import { Translate } from '../common/Translate' + +const wrapper = tv({ + base: 'shr-w-[400px]', + variants: { + type: { + content: '', + }, + }, +}) + +export const ReleaseNotesDropdown: FC = ({ indexUrl, links, loading, error }) => { + const translate = useTranslate() + + return ( +
+ + + + + + +
+ {loading ? ( +
+ +
+ ) : error || !links ? ( +
+ + {translate('common/releaseNotesLoadError')} + +
+ ) : ( +
+ {links.slice(0, 5).map(({ title, url }, index) => ( +
+ + {title} + +
+ ))} + +
+ + {translate('common/seeAllReleaseNotes')} + +
+
+ )} +
+
+
+
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/desktop/UserInfo.tsx b/packages/smarthr-ui/src/components/AppHeader/components/desktop/UserInfo.tsx new file mode 100644 index 0000000000..5d046d288b --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/desktop/UserInfo.tsx @@ -0,0 +1,210 @@ +import React, { FC } from 'react' +import { tv } from 'tailwind-variants' + +import { AnchorButton, Button } from '../../../Button' +import { Dropdown, DropdownContent, DropdownMenuButton, DropdownTrigger } from '../../../Dropdown' +import { FaCaretDownIcon, FaGearIcon, FaUserIcon } from '../../../Icon' +import { Cluster, Stack } from '../../../Layout' +import { Text } from '../../../Text' +import { useTranslate } from '../../hooks/useTranslate' +import { HeaderProps, UserInfoProps } from '../../types' +import { buildDisplayName } from '../../utils' +import { CommonButton } from '../common/CommonButton' +import { Translate } from '../common/Translate' + +// HeaderDropdownMenuButton と同じスタイルを適用 +const userInfo = tv({ + slots: { + userSummary: [ + 'shr-relative -shr-mt-0.5 -shr-mx-0.25 shr-p-1', + // FIXME: smarthr-ui で DropdownMenuButton のグルーピングができるようになったら修正しましょう + 'after:shr-absolute after:shr-content-[""] after:shr-block after:shr-inset-x-0.5 after:shr-bottom-0 after:shr-h-px after:shr-bg-border', + ], + dropdownMenuButton: [ + '[&_.smarthr-ui-DropdownMenuButton-trigger]:shr-border-transparent [&_.smarthr-ui-DropdownMenuButton-trigger]:shr-px-0.5 [&_.smarthr-ui-DropdownMenuButton-trigger]:shr-font-normal', + ], + dropdownContentButton: [ + '[&&.smarthr-ui-AnchorButton]:shr-p-0.75 [&&.smarthr-ui-AnchorButton]:shr-py-0.75', + '[&&.smarthr-ui-Button]:shr-p-0.75 [&&.smarthr-ui-Button]:shr-py-0.75', + ], + button: [ + '[&&]:shr-border-transparent [&&]:shr-font-normal [&&]:last-of-type:-shr-me-0.25', + '[&&]:focus-visible:shr-bg-transparent', + "[&[aria-expanded='true']>.smarthr-ui-Icon:last-child]:shr-rotate-180", + ], + dropdownContent: '[&&]:shr-whitespace-pre [&&]:shr-p-0.5', + accountImage: '', + placeholderImage: 'shr-p-0.5', + }, + variants: { + enableNew: { + true: { + button: '[&&]:shr-px-0.5', + }, + false: { + button: + '[&&]:shr-bg-transparent [&&]:hover:shr-bg-transparent [&&]:shr-px-0.25 [&&]:shr-text-white', + }, + }, + }, + compoundSlots: [ + { + slots: ['accountImage', 'placeholderImage'], + className: + 'shr-box-border shr-flex shr-items-center shr-justify-center -shr-my-1 shr-border-shorthand shr-rounded-full shr-bg-white shr-size-2', + }, + ], +}) + +export const UserInfo: FC< + UserInfoProps & Pick +> = ({ + arbitraryDisplayName, + email, + empCode, + firstName, + lastName, + tenants, + currentTenantId, + accountUrl, + accountImageUrl, + desktopAdditionalContent, + enableNew, +}) => { + const translate = useTranslate() + + const displayName = + arbitraryDisplayName ?? + buildDisplayName({ + email, + empCode, + firstName, + lastName, + }) + + if (!displayName) { + return null + } + + if (!accountUrl && !desktopAdditionalContent) { + return {displayName} + } + + const currentTenantName = tenants?.find((tenant) => tenant.id === currentTenantId)?.name + const { + userSummary, + dropdownMenuButton, + dropdownContentButton, + button, + dropdownContent, + accountImage, + placeholderImage, + } = userInfo({ + enableNew, + }) + + if (enableNew) { + return ( + + {accountImageUrl ? ( + // eslint-disable-next-line smarthr/a11y-image-has-alt-attribute, jsx-a11y/alt-text + + ) : ( + + + + )} + + + {/* eslint-disable-next-line smarthr/best-practice-for-layouts */} + + {currentTenantName && ( + + {currentTenantName} + + )} + + {firstName && lastName ? ( + + {firstName} {lastName} + + ) : ( + email && ( + + {email} + + ) + )} + + + + } + > + + + {currentTenantName} + + + {empCode || (firstName && lastName) ? ( + + {empCode && ( + + {empCode} + + )} + {firstName && lastName && {`${lastName} ${firstName}`}} + + ) : ( + {email} + )} + + + {accountUrl && ( + } + className={dropdownContentButton()} + > + {translate('common/userSetting')} + + )} + + {desktopAdditionalContent} + + ) + } + + return ( + + + + + + + {/* eslint-disable-next-line smarthr/best-practice-for-layouts */} + + {accountUrl && ( + } + > + {translate('common/userSetting')} + + )} + + {desktopAdditionalContent} + + + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncher.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncher.tsx new file mode 100644 index 0000000000..f6624a395a --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncher.tsx @@ -0,0 +1,112 @@ +import React, { FC } from 'react' +import { tv } from 'tailwind-variants' + +import { UnstyledButton } from '../../../Button' +import { FaCircleXmarkIcon } from '../../../Icon' +import { SearchInput } from '../../../Input' +import { Cluster } from '../../../Layout' +import { Text } from '../../../Text' +import { TextLink } from '../../../TextLink' +import { useAppLauncher } from '../../hooks/useAppLauncher' +import { useTranslate } from '../../hooks/useTranslate' +import { Launcher } from '../../types' +import { AppLauncherFeatures } from '../common/AppLauncherFeatures' +import { AppLauncherSortDropdown } from '../common/AppLauncherSortDropdown' +import { Translate } from '../common/Translate' + +import { AppLauncherFilterDropdown } from './AppLauncherFilterDropdown' + +type Props = { + features: Array +} + +const appLauncher = tv({ + slots: { + wrapper: ['smarthr-ui-AppLauncher', 'shr-flex shr-flex-col shr-h-full'], + searchArea: [ + 'smarthr-ui-AppLauncher-searchArea', + 'shr-py-0.75 shr-px-1 shr-border-b-shorthand', + '[&_.smarthr-ui-Input]:shr-h-[42px]', + ], + headArea: 'shr-py-0.75 shr-px-1', + scrollArea: 'shr-overflow-y-scroll shr-flex-1 shr-basis-0', + bottomArea: 'shr-py-0.75 shr-px-1 shr-border-t-shorthand', + }, +}) + +export const AppLauncher: FC = ({ features: baseFeatures }) => { + const translate = useTranslate() + const { + features, + page, + mode, + sortType, + searchQuery, + changePage, + setSortType, + changeSearchQuery, + } = useAppLauncher(baseFeatures) + + const { wrapper, searchArea, headArea, scrollArea, bottomArea } = appLauncher() + + return ( +
+
+ {translate('Launcher/searchInputTitle')}} + width="100%" + value={searchQuery} + suffix={ + mode === 'search' && ( + { + // 別のキューにしないとドロップダウンが閉じてしまう + setTimeout(() => { + changeSearchQuery('') + }, 0) + }} + > + + + ) + } + onChange={(e) => changeSearchQuery(e.target.value)} + /> +
+ + + {mode === 'search' ? ( + + {translate('Launcher/searchResultText')} + + ) : ( + changePage(p)} /> + )} + + {(mode === 'search' || page === 'all') && ( + setSortType(value)} + /> + )} + + +
+ +
+ +
+ + + {translate('Launcher/helpText')} + + +
+
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncherContext.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncherContext.tsx new file mode 100644 index 0000000000..9bb4eb50fe --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncherContext.tsx @@ -0,0 +1,13 @@ +import React, { Dispatch, createContext } from 'react' + +import { Launcher } from '../../types' + +export const AppLauncherContext = createContext<{ + features: Array | null | undefined + isAppLauncherSelected: boolean + setIsAppLauncherSelected: Dispatch> +}>({ + features: null, + isAppLauncherSelected: false, + setIsAppLauncherSelected: () => {}, +}) diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncherFilterDropdown.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncherFilterDropdown.tsx new file mode 100644 index 0000000000..22497a3b8f --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/AppLauncherFilterDropdown.tsx @@ -0,0 +1,83 @@ +import React, { FC } from 'react' +import { tv } from 'tailwind-variants' + +import { textColor } from '../../../../themes' +import { Button } from '../../../Button' +import { Dropdown, DropdownContent, DropdownTrigger } from '../../../Dropdown' +import { FaCaretDownIcon, FaCheckIcon } from '../../../Icon' +import { Stack } from '../../../Layout' +import { useTranslate } from '../../hooks/useTranslate' +import { Launcher } from '../../types' +import { Translate } from '../common/Translate' + +type Props = { + page: Launcher['page'] + onSelectPage: (page: Launcher['page']) => void +} + +const filterDropdown = tv({ + slots: { + trigger: [ + 'smarthr-ui-AppLauncher-SortDropdown-trigger', + 'shr-gap-0.25 shr-text-grey', + '[&[aria-expanded="true"]>.smarthr-ui-Icon]:shr-rotate-180', + ], + stack: ['shr-px-0.25 shr-py-0.5'], + contentButton: ['shr-border-none shr-justify-start shr-py-0.75 shr-font-normal shr-pl-2.5'], + }, + variants: { + selected: { + true: { + contentButton: ['shr-pl-1'], + }, + }, + }, +}) + +export const AppLauncherFilterDropdown: FC = ({ page, onSelectPage }) => { + const translate = useTranslate() + const { trigger, stack, contentButton } = filterDropdown() + const filterMap: Record = { + favorite: translate('Launcher/favoriteModeText'), + all: translate('MobileHeader/Menu/allAppButton'), + } + + return ( + + + + + + + {/* eslint-disable-next-line smarthr/best-practice-for-layouts */} + + {Object.entries(filterMap).map(([key, value], i) => { + const isSelected = key === page + + return ( + + ) + })} + + + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/Help.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/Help.tsx new file mode 100644 index 0000000000..c885edb820 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/Help.tsx @@ -0,0 +1,59 @@ +import React, { FC } from 'react' + +import { Button } from '../../../Button' +import { Dropdown, DropdownContent, DropdownTrigger } from '../../../Dropdown' +import { FaCircleQuestionIcon, FaGraduationCapIcon } from '../../../Icon' +import { useTranslate } from '../../hooks/useTranslate' +import { CommonButton } from '../common/CommonButton' +import { Translate } from '../common/Translate' + +type Props = { + helpPageUrl?: string | null + schoolUrl?: string | null +} + +export const Help: FC = ({ helpPageUrl, schoolUrl }) => { + const translate = useTranslate() + + if (!helpPageUrl && !schoolUrl) { + return null + } + + return ( + + + + + + +
+ {helpPageUrl && ( + } + > + {translate('common/help')} + + )} + + {schoolUrl && ( + } + > + {translate('common/school')} + + )} +
+
+
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/LanguageSelector.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/LanguageSelector.tsx new file mode 100644 index 0000000000..ef2fb99748 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/LanguageSelector.tsx @@ -0,0 +1,68 @@ +import React, { FC } from 'react' +import { tv } from 'tailwind-variants' + +import { Button } from '../../../Button' +import { Heading } from '../../../Heading' +import { FaCheckIcon, FaXmarkIcon } from '../../../Icon' +import { Section } from '../../../SectioningContent' +import { Locale, localeMap } from '../../multilingualization' +import { LocaleProps } from '../../types' +import { CommonButton } from '../common/CommonButton' + +const languageSelector = tv({ + slots: { + header: [ + 'shr-flex shr-justify-between shr-gap-1 shr-items-center shr-px-1 shr-py-0.75 shr-border-b-shorthand', + ], + headerTitle: ['[&&]:shr-text-base shr-font-normal'], + buttonWrapper: ['shr-p-0.5'], + button: ['[&&:not(:has(svg))]:shr-ps-2.5'], + }, +}) + +type Props = { + locale: LocaleProps + onClickClose: (isOpen: boolean) => void +} + +export const LanguageSelector: FC = ({ locale, onClickClose }) => { + const { header, headerTitle, buttonWrapper, button } = languageSelector() + + const onClickButton = (selectedLocale: Locale) => { + locale.onSelectLocale(selectedLocale) + } + + return ( +
+
+ Language + + +
+ +
+ {Object.keys(localeMap).map((localeKey) => ( + onClickButton(localeKey as Locale)} + prefix={localeKey === locale.selectedLocale && } + > + {localeMap[localeKey as Locale]} + + ))} +
+
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/Menu.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/Menu.tsx new file mode 100644 index 0000000000..756a9ffab3 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/Menu.tsx @@ -0,0 +1,122 @@ +import React, { FC, ReactNode, useContext, useEffect, useState } from 'react' +import { tv } from 'tailwind-variants' + +import { useHandleEscape } from '../../../../hooks/useHandleEscape' +import { usePortal } from '../../../../hooks/usePortal' +import { Button } from '../../../Button' +import { FaAngleRightIcon, FaBarsIcon, FaToolboxIcon } from '../../../Icon' +import { useTranslate } from '../../hooks/useTranslate' +import { Translate } from '../common/Translate' + +import { AppLauncherContext } from './AppLauncherContext' +import { MenuAccordion } from './MenuAccordion' +import { MenuButton } from './MenuButton' +import { MenuDialog } from './MenuDialog' +import { Navigation } from './Navigation' +import { NavigationContext } from './NavigationContext' +import { ReleaseNoteContext } from './ReleaseNoteContext' + +const menuItemBlock = tv({ + base: ['shr-border-t-shorthand shr-py-1', 'first:shr-border-t-0 first:shr-pt-0'], +}) + +let scrollPosition = 0 + +type Props = { + appName: ReactNode + tenantSelector: ReactNode + additionalContent: ReactNode +} + +export const Menu: FC = ({ appName, tenantSelector, additionalContent }) => { + const [isOpen, setIsOpen] = useState(false) + const [isNavigationOpen, setIsNavigationOpen] = useState(true) + const [isAdditionalContentOpen, setIsAdditionalContentOpen] = useState(true) + + const { navigations } = useContext(NavigationContext) + const { releaseNote, setIsReleaseNoteSelected } = useContext(ReleaseNoteContext) + const { features, setIsAppLauncherSelected } = useContext(AppLauncherContext) + + const translate = useTranslate() + const { createPortal } = usePortal() + + useEffect(() => { + if (isOpen) { + scrollPosition = window.scrollY + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = 'auto' + setTimeout(() => window.scrollTo(0, scrollPosition), 0) + } + }, [isOpen]) + + useHandleEscape(() => setIsOpen(false)) + + const menuItemBlockStyle = menuItemBlock() + + return ( + <> + + + {createPortal( + + {features && features.length > 0 && ( +
+ +
+ )} + + {navigations.length > 0 && appName ? ( +
+ + setIsOpen(false)} /> + +
+ ) : ( +
+ setIsOpen(false)} /> +
+ )} + + {additionalContent && ( +
+ + {additionalContent} + +
+ )} + + {releaseNote && ( +
+ setIsReleaseNoteSelected(true)}> + {translate('common/releaseNote')} + +
+ )} +
, + )} + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuAccordion.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuAccordion.tsx new file mode 100644 index 0000000000..a30e8798c7 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuAccordion.tsx @@ -0,0 +1,55 @@ +import React, { Dispatch, FC, PropsWithChildren, ReactNode, useId } from 'react' + +import { Button } from '../../../Button' +import { Heading } from '../../../Heading' +import { FaCaretDownIcon, FaCaretUpIcon } from '../../../Icon' +import { Cluster } from '../../../Layout' +import { Section } from '../../../SectioningContent' +import { useTranslate } from '../../hooks/useTranslate' +import { Translate } from '../common/Translate' + +type Props = { + isOpen: boolean + setIsOpen: Dispatch<(isOpen: boolean) => boolean> + title: ReactNode +} + +export const MenuAccordion: FC> = ({ + isOpen, + setIsOpen, + title, + children, +}) => { + const translate = useTranslate() + const id = useId() + + if (!title) { + return
{children}
+ } + + return ( +
+ + + {title} + + + + + +
{isOpen &&
{children}
}
+
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuButton.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuButton.tsx new file mode 100644 index 0000000000..d50495c3d1 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuButton.tsx @@ -0,0 +1,23 @@ +import React, { FC, PropsWithChildren } from 'react' + +import { FaAngleRightIcon } from '../../../Icon' +import { CommonButton } from '../common/CommonButton' + +type Props = { + onClick: () => void + isCurrent?: boolean +} + +export const MenuButton: FC> = ({ children, onClick, isCurrent }) => ( + + {children} + + +) diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuDialog.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuDialog.tsx new file mode 100644 index 0000000000..46e6b28043 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuDialog.tsx @@ -0,0 +1,152 @@ +import React, { + Dispatch, + FC, + PropsWithChildren, + ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { CSSTransition } from 'react-transition-group' +import { tv } from 'tailwind-variants' + +import { Button } from '../../../Button' +import { FocusTrap } from '../../../Dialog/FocusTrap' +import { FaXmarkIcon } from '../../../Icon' +import { Cluster } from '../../../Layout' +import { Section } from '../../../SectioningContent' +import { useTranslate } from '../../hooks/useTranslate' + +import { AppLauncher } from './AppLauncher' +import { AppLauncherContext } from './AppLauncherContext' +import { MenuSubHeader } from './MenuSubHeader' +import { Navigation } from './Navigation' +import { NavigationContext } from './NavigationContext' +import { NavigationGroupHeader } from './NavigationGroupHeader' +import { ReleaseNote } from './ReleaseNote' +import { ReleaseNoteContext } from './ReleaseNoteContext' + +const menu = tv({ + slots: { + wrapper: [ + 'shr-fixed shr-top-0 shr-left-0 shr-w-full shr-h-full shr-flex shr-flex-col shr-bg-white', + 'shr-translate-opacity shr-opacity-0 shr-duration-150', + '[&&.shr-sp-menu-enter-active]:shr-opacity-100', + '[&&.shr-sp-menu-enter-done]:shr-opacity-100', + '[&&.shr-sp-menu-exit-active]:shr-opacity-0', + '[&&.shr-sp-menu-exit-done]:shr-opacity-0', + ], + header: 'shr-px-0.75 shr-py-0.5 shr-border-b-shorthand shr-sticky shr-top-0', + content: 'shr-overflow-auto shr-p-1', + }, +}) + +export const MenuDialog: FC< + PropsWithChildren<{ + isOpen: boolean + setIsOpen: Dispatch + tenantSelector: ReactNode + }> +> = ({ children, isOpen, setIsOpen, tenantSelector }) => { + const { selectedNavigationGroup, setSelectedNavigationGroup } = useContext(NavigationContext) + const { isReleaseNoteSelected, setIsReleaseNoteSelected } = useContext(ReleaseNoteContext) + const { features, isAppLauncherSelected, setIsAppLauncherSelected } = + useContext(AppLauncherContext) + + const [contentBuffer, setContentBuffer] = useState(null) + const translate = useTranslate() + const domRef = useRef(null) + + const renderedContent = useMemo(() => { + const { wrapper, header, content } = menu() + + return ( + // eslint-disable-next-line smarthr/a11y-heading-in-sectioning-content +
+
+ + {isAppLauncherSelected ? ( + setIsAppLauncherSelected(false)} + /> + ) : isReleaseNoteSelected ? ( + setIsReleaseNoteSelected(false)} + /> + ) : selectedNavigationGroup ? ( + + ) : ( +
{tenantSelector}
+ )} + + +
+
+ + {isAppLauncherSelected && features && features.length > 0 ? ( + + ) : ( +
+ {isReleaseNoteSelected ? ( + + ) : selectedNavigationGroup ? ( + setIsOpen(false)} + /> + ) : ( + children + )} +
+ )} +
+ ) + }, [ + translate, + children, + features, + isAppLauncherSelected, + isReleaseNoteSelected, + selectedNavigationGroup, + setIsAppLauncherSelected, + setIsOpen, + setIsReleaseNoteSelected, + tenantSelector, + ]) + + useEffect(() => { + if (isOpen) { + setContentBuffer(renderedContent) + } else { + setIsReleaseNoteSelected(false) + setIsAppLauncherSelected(false) + setSelectedNavigationGroup(null) + } + }, [ + isOpen, + renderedContent, + setIsAppLauncherSelected, + setIsReleaseNoteSelected, + setSelectedNavigationGroup, + ]) + + return ( + +
+ {isOpen ? renderedContent : contentBuffer} +
+
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuSubHeader.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuSubHeader.tsx new file mode 100644 index 0000000000..7c54db48b8 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MenuSubHeader.tsx @@ -0,0 +1,29 @@ +import React, { FC, ReactNode } from 'react' + +import { Button } from '../../../Button' +import { Heading } from '../../../Heading' +import { FaArrowLeftIcon } from '../../../Icon' +import { useTranslate } from '../../hooks/useTranslate' +import { Translate } from '../common/Translate' + +type Props = { + title: ReactNode + onClickBack: () => void +} + +export const MenuSubHeader: FC = ({ title, onClickBack }) => { + const translate = useTranslate() + + return ( + <> + + + {/* eslint-disable-next-line smarthr/a11y-heading-in-sectioning-content */} + + {title} + + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/MobileHeader.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MobileHeader.tsx new file mode 100644 index 0000000000..4cb583bc82 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/MobileHeader.tsx @@ -0,0 +1,93 @@ +import React, { FC, useState } from 'react' + +import { Header } from '../../../Header' +import { useLocale } from '../../hooks/useLocale' +import { HeaderProps, NavigationGroup } from '../../types' + +import { AppLauncherContext } from './AppLauncherContext' +import { Help } from './Help' +import { Menu } from './Menu' +import { NavigationContext } from './NavigationContext' +import { ReleaseNoteContext } from './ReleaseNoteContext' +import { TenantSelector } from './TenantSelector' +import { UserInfo } from './UserInfo' + +export const MobileHeader: FC = ({ + navigations, + features, + releaseNote, + className = '', + tenants, + children, + helpPageUrl, + schoolUrl, + userInfo, + appName, + currentTenantId, + onTenantSelect, + mobileAdditionalContent, + ...props +}) => { + const [isAppLauncherSelected, setIsAppLauncherSelected] = useState(false) + const [isReleaseNoteSelected, setIsReleaseNoteSelected] = useState(false) + const [selectedNavigationGroup, setSelectedNavigationGroup] = useState( + null, + ) + + const { locale } = useLocale() + + // navigations の設定をメニューの解放条件とする + const isMenuAvailable = navigations && navigations.length > 0 + + return ( + + + +
+ {children} + + + + + + {isMenuAvailable && ( + + } + additionalContent={mobileAdditionalContent} + /> + )} +
+
+
+
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/Navigation.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/Navigation.tsx new file mode 100644 index 0000000000..726b4b645e --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/Navigation.tsx @@ -0,0 +1,54 @@ +import React, { FC, Fragment } from 'react' +import { tv } from 'tailwind-variants' + +import { Text } from '../../../Text' +import { NavigationGroup, Navigation as NavigationType } from '../../types' +import { isChildNavigationGroup } from '../../utils' + +import { NavigationItem } from './NavigationItem' + +type Props = { + navigations: NavigationType[] | NavigationGroup['childNavigations'] + onClickNavigation: () => void +} + +const separator = tv({ + base: ['[&&]:shr-mx-0 [&&]:shr-my-0.5 [&&]:shr-border-b-shorthand'], +}) + +export const Navigation: FC = ({ navigations, onClickNavigation }) => ( +
+ {navigations.map((navigation, i) => { + if (isChildNavigationGroup(navigation)) { + const { childNavigations } = navigation + + return ( + + + {navigation.title} + + + {childNavigations.map((childNavigation) => ( + + ))} + + {i + 1 !== navigations.length &&
} +
+ ) + } + + const nextNavigation = navigations[i + 1] + + return ( + + + {isChildNavigationGroup(nextNavigation) &&
} +
+ ) + })} +
+) diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationContext.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationContext.tsx new file mode 100644 index 0000000000..c4911d7a54 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationContext.tsx @@ -0,0 +1,9 @@ +import React, { Dispatch, createContext } from 'react' + +import { Navigation, NavigationGroup } from '../../types' + +export const NavigationContext = createContext({ + navigations: [] as Navigation[], + selectedNavigationGroup: null as NavigationGroup | null, + setSelectedNavigationGroup: (() => {}) as Dispatch>, +}) diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationGroupHeader.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationGroupHeader.tsx new file mode 100644 index 0000000000..eb9480c0f0 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationGroupHeader.tsx @@ -0,0 +1,19 @@ +import React, { FC, useContext } from 'react' + +import { NavigationGroup } from '../../types' + +import { MenuSubHeader } from './MenuSubHeader' +import { NavigationContext } from './NavigationContext' + +export const NavigationGroupHeader: FC<{ + currentNavigationGroup: NavigationGroup +}> = ({ currentNavigationGroup }) => { + const { setSelectedNavigationGroup } = useContext(NavigationContext) + + return ( + setSelectedNavigationGroup(null)} + /> + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationItem.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationItem.tsx new file mode 100644 index 0000000000..d682ecfb00 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/NavigationItem.tsx @@ -0,0 +1,93 @@ +import React, { FC, useContext } from 'react' +import { tv } from 'tailwind-variants' + +import { Navigation } from '../../types' +import { isChildNavigation } from '../../utils' +import { CommonButton, commonButton } from '../common/CommonButton' +import { Translate } from '../common/Translate' + +import { MenuButton } from './MenuButton' +import { NavigationContext } from './NavigationContext' + +const navigationItem = tv({ + base: ['[&&]:shr-px-0.5'], +}) + +export const NavigationItem: FC<{ navigation: Navigation; onClickNavigation: () => void }> = ({ + navigation, + onClickNavigation, +}) => { + const { setSelectedNavigationGroup } = useContext(NavigationContext) + const navigationItemStyle = navigationItem() + + if ('elementAs' in navigation) { + const { children, elementAs: Tag, current, ...rest } = navigation + + return ( + <> + {/* eslint-disable-next-line smarthr/a11y-delegate-element-has-role-presentation */} + + {children} + + + ) + } + + if ('href' in navigation) { + return ( + + {navigation.children} + + ) + } + + if ('onClick' in navigation) { + return ( + { + navigation.onClick(e) + onClickNavigation() + }} + current={navigation.current} + boldWhenCurrent + className={navigationItemStyle} + > + {navigation.children} + + ) + } + + // 子要素に current を持っているものがあるかどうか + const childrenHasCurrent = navigation.childNavigations.some((child) => { + if (isChildNavigation(child)) { + return child.current + } + + return child.childNavigations.some((c) => c.current) + }) + + return ( + setSelectedNavigationGroup(navigation)} + isCurrent={navigation.current || childrenHasCurrent} + > + {navigation.children} + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/ReleaseNote.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/ReleaseNote.tsx new file mode 100644 index 0000000000..4de7c0f6f6 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/ReleaseNote.tsx @@ -0,0 +1,71 @@ +import React, { FC, useContext } from 'react' +import { tv } from 'tailwind-variants' + +import { FaUpRightFromSquareIcon } from '../../../Icon' +import { Center, Stack } from '../../../Layout' +import { Loader } from '../../../Loader' +import { Text } from '../../../Text' +import { useTranslate } from '../../hooks/useTranslate' +import { Translate } from '../common/Translate' + +import { ReleaseNoteContext } from './ReleaseNoteContext' + +const releaseNoteStyle = tv({ + slots: { + anchor: ['shr-text-base shr-text-link [&&]:shr-underline', '[&&]:hover:shr-no-underline'], + icon: ['shr-ms-0.5'], + indexLinkWrapper: ['shr-text-end shr-mt-2'], + indexLinkAnchor: [ + 'shr-text-base shr-text-link [&&]:shr-no-underline', + '[&&]:hover:shr-underline', + ], + }, +}) + +export const ReleaseNote: FC = () => { + const translate = useTranslate() + const { releaseNote } = useContext(ReleaseNoteContext) + + if (!releaseNote) { + return null + } + + const { anchor, icon, indexLinkWrapper, indexLinkAnchor } = releaseNoteStyle() + + return ( +
+ {releaseNote.loading ? ( +
+ +
+ ) : releaseNote.error ? ( + + {translate('common/releaseNotesLoadError')} + + ) : ( + + {releaseNote.links.slice(0, 5).map((link) => ( + + ))} + + )} + + +
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/ReleaseNoteContext.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/ReleaseNoteContext.tsx new file mode 100644 index 0000000000..cbc195c583 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/ReleaseNoteContext.tsx @@ -0,0 +1,13 @@ +import React, { Dispatch, createContext } from 'react' + +import { HeaderProps } from '../../types' + +export const ReleaseNoteContext = createContext<{ + releaseNote: HeaderProps['releaseNote'] + isReleaseNoteSelected: boolean + setIsReleaseNoteSelected: Dispatch> +}>({ + releaseNote: null, + isReleaseNoteSelected: false, + setIsReleaseNoteSelected: () => {}, +}) diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/TenantSelector.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/TenantSelector.tsx new file mode 100644 index 0000000000..44868c13e7 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/TenantSelector.tsx @@ -0,0 +1,70 @@ +import React, { ComponentProps, FC } from 'react' +import { tv } from 'tailwind-variants' + +import { Dropdown, DropdownContent, DropdownTrigger } from '../../../Dropdown' +import { Header } from '../../../Header' +import { FaCaretDownIcon } from '../../../Icon' +import { Text } from '../../../Text' +import { CommonButton } from '../common/CommonButton' + +const tenantDropdownTriggerButton = tv({ + base: [ + 'shr-border-none shr-bg-white shr-text-start shr-text-sm shr-rounded-s shr-px-0.5 shr-py-0.25 shr-cursor-pointer', + 'hover:shr-bg-white-darken', + '[&[aria-expanded="true"]>.smarthr-ui-Icon:last-child]:shr-rotate-180', + ], +}) + +type Props = { + tenants?: ComponentProps['tenants'] + currentTenantId?: ComponentProps['currentTenantId'] + onTenantSelect?: ComponentProps['onTenantSelect'] +} + +export const TenantSelector: FC = ({ tenants, currentTenantId, onTenantSelect }) => { + if (!tenants || tenants.length === 0 || !currentTenantId) { + return null + } + + const tenantName = tenants.find((tenant) => tenant.id === currentTenantId)?.name + + if (!tenantName) { + return null + } + + if (tenants.length === 1 || !onTenantSelect) { + return {tenantName} + } + + return ( + + {/* eslint-disable-next-line smarthr/a11y-trigger-has-button */} + + + + + +
+ {tenants.map((tenant) => { + const isCurrent = tenant.id === currentTenantId + + return ( + !isCurrent && onTenantSelect(tenant.id)} + > + {tenant.name} + + ) + })} +
+
+
+ ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/components/mobile/UserInfo.tsx b/packages/smarthr-ui/src/components/AppHeader/components/mobile/UserInfo.tsx new file mode 100644 index 0000000000..0a591a9a3a --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/components/mobile/UserInfo.tsx @@ -0,0 +1,115 @@ +import React, { FC, useState } from 'react' +import { tv } from 'tailwind-variants' + +import { Button } from '../../../Button' +import { Dialog } from '../../../Dialog' +import { Dropdown, DropdownContent, DropdownTrigger } from '../../../Dropdown' +import { FaGearIcon, FaGlobeIcon, FaUserLargeIcon } from '../../../Icon' +import { useLocale } from '../../hooks/useLocale' +import { useTranslate } from '../../hooks/useTranslate' +import { HeaderProps, UserInfoProps } from '../../types' +import { buildDisplayName } from '../../utils' +import { CommonButton } from '../common/CommonButton' +import { Translate } from '../common/Translate' + +import { LanguageSelector } from './LanguageSelector' + +const userInfo = tv({ + slots: { + iconButton: ['[&&&]:shr-border-transparent [&&]:shr-p-0.25'], + iconButtonInner: [ + 'shr-block shr-flex shr-items-center shr-justify-center shr-p-0.25 shr-bg-white shr-border-shorthand shr-rounded-full', + ], + dropdownUserName: ['shr-box-border shr-text-sm shr-px-1 shr-py-0.75 shr-min-w-[246px]'], + dropdownButtonArea: ['shr-border-t-shorthand shr-p-0.5'], + }, +}) + +export const UserInfo: FC> = ({ + arbitraryDisplayName, + email, + empCode, + firstName, + lastName, + accountUrl, +}) => { + const [languageDialogOpen, setLanguageDialogOpen] = useState(false) + const { locale } = useLocale() + const translate = useTranslate() + + const displayName = + arbitraryDisplayName ?? + buildDisplayName({ + email, + empCode, + firstName, + lastName, + }) + + if (!displayName) { + return null + } + + const { iconButton, iconButtonInner, dropdownUserName, dropdownButtonArea } = userInfo() + + return ( + <> + + + + + + +
+

{displayName}

+
+ + {(locale || accountUrl) && ( +
+ {locale && ( + setLanguageDialogOpen(true)} + prefix={} + > + Language + + )} + + {accountUrl && ( + } + > + {translate('common/userSetting')} + + )} +
+ )} +
+
+ + {locale && ( + setLanguageDialogOpen(false)} + width={246} + > + + + )} + + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/hooks/useAppLauncher.ts b/packages/smarthr-ui/src/components/AppHeader/hooks/useAppLauncher.ts new file mode 100644 index 0000000000..6c41739cd8 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/hooks/useAppLauncher.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react' + +import { Launcher } from '../types' + +export const useAppLauncher = (baseFeatures: Array) => { + const [page, setPage] = useState('favorite') + const [mode, setMode] = useState('default') + const [sortType, setSortType] = useState('default') + const [searchQuery, setSearchQuery] = useState('') + const [features, setFeatures] = useState>( + sortFeatures(baseFeatures, { + page, + mode, + sortType, + searchQuery, + }), + ) + + useEffect(() => { + setFeatures( + sortFeatures(baseFeatures, { + page, + sortType, + mode, + searchQuery, + }), + ) + }, [baseFeatures, page, mode, sortType, searchQuery]) + + const changePage = useCallback((newPage: Launcher['page']) => { + setPage(newPage) + setMode('default') + setSearchQuery('') + }, []) + + const changeSearchQuery = useCallback( + (q: string) => { + setSearchQuery(q) + + if (mode !== 'search') { + setMode('search') + } else { + if (q === '') { + setMode('default') + } + } + }, + [mode], + ) + + return { features, page, mode, sortType, searchQuery, changePage, setSortType, changeSearchQuery } +} + +const sortFeatures = ( + features: Array, + { + page, + sortType, + mode, + searchQuery, + }: { + page: Launcher['page'] + sortType: Launcher['sortType'] + mode: Launcher['mode'] + searchQuery: string + }, +) => { + if (mode !== 'search' && page === 'favorite') { + const filtered = features.filter((item) => item.favorite) + + // feature の position の数値の順に並び替える。position が null の場合は最後に並べる + return filtered.sort((a, b) => { + if (a.position === null && b.position === null) { + return 0 + } else if (a.position === null) { + return 1 + } else if (b.position === null) { + return -1 + } else { + return a.position - b.position + } + }) + } + + const featuresRes = + mode === 'search' + ? features.filter((feature) => looseInclude(feature.name, searchQuery)) + : [...features] + + if (sortType === 'name/asc') { + featuresRes.sort((a, b) => a.name.localeCompare(b.name)) + } + + if (sortType === 'name/desc') { + featuresRes.sort((a, b) => b.name.localeCompare(a.name)) + } + + return featuresRes +} + +// 文字列 a が文字列 b を含んでいたら true を返す +export const looseInclude = (a: string, b: string) => { + const normalizedA = normalize(a) + const normalizedB = normalize(b) + return normalizedA.includes(normalizedB) +} + +// アルファベットの大文字小文字は同じものとして扱う。カタカナとひらがなも同じものとして扱う。 +const normalize = (s: string) => + s + .toLowerCase() + .replace(/[\u30a1-\u30f6]/g, (match) => String.fromCharCode(match.charCodeAt(0) - 0x60)) diff --git a/packages/smarthr-ui/src/components/AppHeader/hooks/useLocale.tsx b/packages/smarthr-ui/src/components/AppHeader/hooks/useLocale.tsx new file mode 100644 index 0000000000..15bc2edbe3 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/hooks/useLocale.tsx @@ -0,0 +1,18 @@ +import React, { FC, ReactNode, createContext, useContext } from 'react' + +import { HeaderProps } from '../types' + +const LocaleContext = createContext<{ locale: HeaderProps['locale'] }>({ + locale: null, +}) + +type LocaleContextProviderProps = { + locale: HeaderProps['locale'] + children: ReactNode +} + +export const LocaleContextProvider: FC = ({ locale, children }) => ( + {children} +) + +export const useLocale = () => useContext(LocaleContext) diff --git a/packages/smarthr-ui/src/components/AppHeader/hooks/useMediaQuery.ts b/packages/smarthr-ui/src/components/AppHeader/hooks/useMediaQuery.ts new file mode 100644 index 0000000000..5cf230d739 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/hooks/useMediaQuery.ts @@ -0,0 +1,29 @@ +import { useCallback, useMemo, useSyncExternalStore } from 'react' + +export const mediaQuery = { + desktop: 'min-width: 752px', + mobile: 'max-width: 751px', +} as const + +export const useMediaQuery = (query: string) => { + const mediaQueryList = useMemo( + () => (typeof window === 'undefined' ? null : matchMedia(`(${query})`)), + [query], + ) + + const subscribe = useCallback( + (callback: () => void) => { + mediaQueryList?.addEventListener('change', callback) + return () => { + mediaQueryList?.removeEventListener('change', callback) + } + }, + [mediaQueryList], + ) + + return useSyncExternalStore( + subscribe, + () => mediaQueryList?.matches ?? false, + () => false, + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/hooks/useTranslate.ts b/packages/smarthr-ui/src/components/AppHeader/hooks/useTranslate.ts new file mode 100644 index 0000000000..fdc2255de2 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/hooks/useTranslate.ts @@ -0,0 +1,15 @@ +import { useCallback } from 'react' + +import { translate } from '../multilingualization' +import { Messages } from '../multilingualization/messages' + +import { useLocale } from './useLocale' + +export const useTranslate = () => { + const { locale } = useLocale() + + return useCallback( + (id: ID) => translate(id, locale?.selectedLocale), + [locale?.selectedLocale], + ) +} diff --git a/packages/smarthr-ui/src/components/AppHeader/index.ts b/packages/smarthr-ui/src/components/AppHeader/index.ts new file mode 100644 index 0000000000..8af702b365 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/index.ts @@ -0,0 +1 @@ +export { AppHeader } from './AppHeader' diff --git a/packages/smarthr-ui/src/components/AppHeader/multilingualization/index.ts b/packages/smarthr-ui/src/components/AppHeader/multilingualization/index.ts new file mode 100644 index 0000000000..8e509207c3 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/multilingualization/index.ts @@ -0,0 +1,3 @@ +export { Locale } from './types' +export { translate } from './translate' +export { localeMap } from './localeMap' diff --git a/packages/smarthr-ui/src/components/AppHeader/multilingualization/localeMap.ts b/packages/smarthr-ui/src/components/AppHeader/multilingualization/localeMap.ts new file mode 100644 index 0000000000..f03e3f707e --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/multilingualization/localeMap.ts @@ -0,0 +1,10 @@ +export const localeMap = { + ja: '日本語', + 'id-id': 'Bahasa Indonesia', + 'en-us': 'English', + pt: 'Português', + vi: 'Tiếng Việt', + ko: '한국어', + 'zh-cn': '简体中文', + 'zh-tw': '繁體中文', +} as const diff --git a/packages/smarthr-ui/src/components/AppHeader/multilingualization/messages.ts b/packages/smarthr-ui/src/components/AppHeader/multilingualization/messages.ts new file mode 100644 index 0000000000..f78e46377b --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/multilingualization/messages.ts @@ -0,0 +1,278 @@ +import { Locale } from './types' + +export type Messages = { + 'common/school': string + 'common/help': string + 'common/userSetting': string + 'common/releaseNote': string + 'common/releaseNotesLoadError': string + 'common/seeAllReleaseNotes': string + 'DesktopHeader/DesktopHeader/appLauncherLabel': string + 'MobileHeader/UserInfo/account': string + 'MobileHeader/Menu/openMenu': string + 'MobileHeader/Menu/closeMenu': string + 'MobileHeader/Menu/allAppButton': string + 'MobileHeader/Menu/managementMenu': string + 'MobileHeader/Menu/latestReleaseNotes': string + 'MobileHeader/MenuSubHeader/back': string + 'MobileHeader/MenuAccordion/open': string + 'MobileHeader/MenuAccordion/close': string + 'Launcher/searchInputTitle': string + 'Launcher/favoriteModeText': string + 'Launcher/allModeText': string + 'Launcher/listText': string + 'Launcher/helpText': string + 'Launcher/searchResultText': string + 'Launcher/emptyText': string + 'Launcher/sortDropdownLabel': string + 'Launcher/sortDropdownSelected': string + 'Launcher/sortDropdownOrderDefault': string + 'Launcher/sortDropdownOrderNameAsc': string + 'Launcher/sortDropdownOrderNameDesc': string +} + +export const translation = { + ja: { + 'common/school': 'スクール', + 'common/help': 'ヘルプ', + 'common/userSetting': '個人設定', + 'common/releaseNote': 'リリースノート', + 'common/releaseNotesLoadError': + 'リリースノートの読み込みに失敗しました。\n時間をおいて、やり直してください。', + 'common/seeAllReleaseNotes': 'すべてのリリースノートを見る', + 'DesktopHeader/DesktopHeader/appLauncherLabel': 'アプリ', + 'MobileHeader/UserInfo/account': 'アカウント', + 'MobileHeader/Menu/openMenu': 'メニューを開く', + 'MobileHeader/Menu/closeMenu': 'メニューを閉じる', + 'MobileHeader/Menu/allAppButton': 'すべてのアプリ', + 'MobileHeader/Menu/managementMenu': '管理メニュー', + 'MobileHeader/Menu/latestReleaseNotes': '最新のリリースノート', + 'MobileHeader/MenuSubHeader/back': '戻る', + 'MobileHeader/MenuAccordion/open': '開く', + 'MobileHeader/MenuAccordion/close': '閉じる', + 'Launcher/searchInputTitle': 'アプリ名を入力してください。', + 'Launcher/favoriteModeText': 'よく使うアプリ', + 'Launcher/allModeText': 'すべてのアプリ', + 'Launcher/listText': 'アプリ一覧', + 'Launcher/helpText': 'よく使うアプリとは', + 'Launcher/searchResultText': '検索結果', + 'Launcher/emptyText': '該当するアプリが見つかりませんでした。', + 'Launcher/sortDropdownLabel': '表示順', + 'Launcher/sortDropdownSelected': '選択中', + 'Launcher/sortDropdownOrderDefault': 'デフォルト', + 'Launcher/sortDropdownOrderNameAsc': 'アプリ名の昇順', + 'Launcher/sortDropdownOrderNameDesc': 'アプリ名の降順', + }, + 'id-id': { + 'common/school': 'Sekolah', + 'common/help': 'Bantuan', + 'common/userSetting': 'Personalisasi', + 'common/releaseNote': 'Release Note', + 'common/releaseNotesLoadError': 'Gagal memuat Release Note. \nSilakan coba lagi setelah jam.', + 'common/seeAllReleaseNotes': 'Lihat semua Release Note', + 'DesktopHeader/DesktopHeader/appLauncherLabel': 'Aplikasi', + 'MobileHeader/UserInfo/account': 'Akun', + 'MobileHeader/Menu/openMenu': 'Buka menu', + 'MobileHeader/Menu/closeMenu': 'Tutup menu', + 'MobileHeader/Menu/allAppButton': 'Semua aplikasi', + 'MobileHeader/Menu/managementMenu': 'Menu pengelolaan', + 'MobileHeader/Menu/latestReleaseNotes': 'Release Note terkini', + 'MobileHeader/MenuSubHeader/back': 'Kembali', + 'MobileHeader/MenuAccordion/open': 'Buka', + 'MobileHeader/MenuAccordion/close': 'Tutup', + 'Launcher/searchInputTitle': 'Masukkan nama aplikasi.', + 'Launcher/favoriteModeText': 'Aplikasi yang sering digunakan', + 'Launcher/allModeText': 'Semua aplikasi', + 'Launcher/listText': 'Daftar aplikasi', + 'Launcher/helpText': 'よく使うアプリとは', // TODO: 「よく使うアプリとは」の翻訳 + 'Launcher/searchResultText': 'Hasil penelusuran', + 'Launcher/emptyText': 'Tidak ditemukan aplikasi yang sesuai.', + 'Launcher/sortDropdownLabel': 'Urutan tampilan', + 'Launcher/sortDropdownSelected': 'Sedang dipilih', + 'Launcher/sortDropdownOrderDefault': 'Default', + 'Launcher/sortDropdownOrderNameAsc': 'Urutkan nama aplikasi dari atas ke bawah', + 'Launcher/sortDropdownOrderNameDesc': 'Urutkan nama aplikasi dari bawah ke atas', + }, + 'en-us': { + 'common/school': 'School', + 'common/help': 'Help', + 'common/userSetting': 'Personal Settings', + 'common/releaseNote': 'Release notes', + 'common/releaseNotesLoadError': 'Failed to load release notes.\nTry again later.', + 'common/seeAllReleaseNotes': 'See all release notes', + 'DesktopHeader/DesktopHeader/appLauncherLabel': 'Apps', + 'MobileHeader/UserInfo/account': 'Account', + 'MobileHeader/Menu/openMenu': 'Open menu', + 'MobileHeader/Menu/closeMenu': 'Close menu', + 'MobileHeader/Menu/allAppButton': 'All apps', + 'MobileHeader/Menu/managementMenu': 'Admin Menu', + 'MobileHeader/Menu/latestReleaseNotes': 'Latest release notes', + 'MobileHeader/MenuSubHeader/back': 'Back', + 'MobileHeader/MenuAccordion/open': 'Expand', + 'MobileHeader/MenuAccordion/close': 'Collapse', + 'Launcher/searchInputTitle': 'Input the name of the App', + 'Launcher/favoriteModeText': 'Favorite Apps', + 'Launcher/allModeText': 'All Apps', + 'Launcher/listText': 'App List', + 'Launcher/helpText': 'よく使うアプリとは', // TODO: 「よく使うアプリとは」の翻訳 + 'Launcher/searchResultText': 'Search results', + 'Launcher/emptyText': 'App not found.', + 'Launcher/sortDropdownLabel': 'Sort by', + 'Launcher/sortDropdownSelected': 'Selected', + 'Launcher/sortDropdownOrderDefault': 'Default order', + 'Launcher/sortDropdownOrderNameAsc': 'App name (A→Z)', + 'Launcher/sortDropdownOrderNameDesc': 'App name (Z→A)', + }, + pt: { + 'common/school': 'Escola', + 'common/help': 'Ajuda', + 'common/userSetting': 'Configuração pessoal', + 'common/releaseNote': 'Notas de versão', + 'common/releaseNotesLoadError': + 'Não foi possível carregar as notas de versão. \nPor favor, tente novamente mais tarde.', + 'common/seeAllReleaseNotes': 'Ver todas as notas de versão', + 'DesktopHeader/DesktopHeader/appLauncherLabel': 'Apps', + 'MobileHeader/UserInfo/account': 'Conta', + 'MobileHeader/Menu/openMenu': 'Abrir menu', + 'MobileHeader/Menu/closeMenu': 'Fechar menu', + 'MobileHeader/Menu/allAppButton': 'Todos os Apps', + 'MobileHeader/Menu/managementMenu': 'Menu de administração', + 'MobileHeader/Menu/latestReleaseNotes': 'Notas de versão mais recentes', + 'MobileHeader/MenuSubHeader/back': 'Voltar', + 'MobileHeader/MenuAccordion/open': 'Abrir', + 'MobileHeader/MenuAccordion/close': 'Fechar', + 'Launcher/searchInputTitle': 'Insira o nome do app.', + 'Launcher/favoriteModeText': 'Apps Favoritos', + 'Launcher/allModeText': 'Todos os Apps', + 'Launcher/listText': 'Lista de Apps', + 'Launcher/helpText': 'よく使うアプリとは', // TODO: 「よく使うアプリとは」の翻訳 + 'Launcher/searchResultText': 'Resultados de pesquisa', + 'Launcher/emptyText': 'App não encontrado.', + 'Launcher/sortDropdownLabel': 'Ordenar por', + 'Launcher/sortDropdownSelected': 'Selecionando', + 'Launcher/sortDropdownOrderDefault': 'Ordem padrão', + 'Launcher/sortDropdownOrderNameAsc': 'Nome do App (A→Z)', + 'Launcher/sortDropdownOrderNameDesc': 'Nome do App (Z→A)', + }, + vi: { + 'common/school': 'School', + 'common/help': 'Trợ giúp', + 'common/userSetting': 'Cài đặt cá nhân', + 'common/releaseNote': 'Release Notes', + 'common/releaseNotesLoadError': 'Tải Release Notes thất bại.\nHãy thử lại sau một lúc nữa.', + 'common/seeAllReleaseNotes': 'Xem tất cả Release Notes', + 'DesktopHeader/DesktopHeader/appLauncherLabel': 'Danh mục', + 'MobileHeader/UserInfo/account': 'Tài khoản', + 'MobileHeader/Menu/openMenu': 'Mở menu', + 'MobileHeader/Menu/closeMenu': 'Đóng menu', + 'MobileHeader/Menu/allAppButton': 'Tất cả Tính năng', + 'MobileHeader/Menu/managementMenu': 'Menu Quản lý', + 'MobileHeader/Menu/latestReleaseNotes': 'Ghi chú phát hành mới nhất', + 'MobileHeader/MenuSubHeader/back': 'Quay lại', + 'MobileHeader/MenuAccordion/open': 'Mở', + 'MobileHeader/MenuAccordion/close': 'Đóng', + 'Launcher/searchInputTitle': 'Nhập tên tính năng.', + 'Launcher/favoriteModeText': 'Tính năng thường dùng', + 'Launcher/allModeText': 'Tất cả Tính năng', + 'Launcher/listText': 'Danh sách các tính năng', + 'Launcher/helpText': 'よく使うアプリとは', // TODO: 「よく使うアプリとは」の翻訳 + 'Launcher/searchResultText': 'Kết quả tìm kiếm', + 'Launcher/emptyText': 'Không tìm thấy tính năng tương thích.', + 'Launcher/sortDropdownLabel': 'Thứ tự hiển thị', + 'Launcher/sortDropdownSelected': 'Đang lựa chọn', + 'Launcher/sortDropdownOrderDefault': 'Mặc định', + 'Launcher/sortDropdownOrderNameAsc': 'Tên tính năng (A→Z)', + 'Launcher/sortDropdownOrderNameDesc': 'Tên tính năng (Z→A)', + }, + ko: { + 'common/school': '스쿨', + 'common/help': '도움말', + 'common/userSetting': '개인 설정', + 'common/releaseNote': '리리스 노트', + 'common/releaseNotesLoadError': + '리리스노트의 불러오기를 실패했습니다.\n시간을 두고 다시 시도해 주세요.', + 'common/seeAllReleaseNotes': '모든 리리스 노트를 보기', + 'DesktopHeader/DesktopHeader/appLauncherLabel': '앱', + 'MobileHeader/UserInfo/account': '어카운트', + 'MobileHeader/Menu/openMenu': '메뉴를 열기', + 'MobileHeader/Menu/closeMenu': '메뉴를 닫기', + 'MobileHeader/Menu/allAppButton': '모든 앱', + 'MobileHeader/Menu/managementMenu': '관리메뉴', + 'MobileHeader/Menu/latestReleaseNotes': '최신 리리스 노트', + 'MobileHeader/MenuSubHeader/back': '돌아가기', + 'MobileHeader/MenuAccordion/open': '열기', + 'MobileHeader/MenuAccordion/close': '닫기', + 'Launcher/searchInputTitle': '앱의 이름을 입력해 주세요.', + 'Launcher/favoriteModeText': '자주 사용하는 앱', + 'Launcher/allModeText': '모든 앱', + 'Launcher/listText': '앱 리스트', + 'Launcher/helpText': 'よく使うアプリとは', // TODO: 「よく使うアプリとは」の翻訳 + 'Launcher/searchResultText': '검색결과', + 'Launcher/emptyText': '해당하는 앱을 발견할수 없습니다.', + 'Launcher/sortDropdownLabel': '표시순서', + 'Launcher/sortDropdownSelected': '선택중', + 'Launcher/sortDropdownOrderDefault': '디폴트', + 'Launcher/sortDropdownOrderNameAsc': '앱 이름의 오름차순', + 'Launcher/sortDropdownOrderNameDesc': '앱 이름의 내림차순', + }, + 'zh-cn': { + 'common/school': '学校', + 'common/help': '帮助', + 'common/userSetting': '个人设置', + 'common/releaseNote': '版本说明', + 'common/releaseNotesLoadError': '无法取得版本说明。\n请稍等片刻后再试。', + 'common/seeAllReleaseNotes': '查看全部版本说明', + 'DesktopHeader/DesktopHeader/appLauncherLabel': '应用程序', + 'MobileHeader/UserInfo/account': '账号', + 'MobileHeader/Menu/openMenu': '展开菜单', + 'MobileHeader/Menu/closeMenu': '关闭菜单', + 'MobileHeader/Menu/allAppButton': '所有功能', + 'MobileHeader/Menu/managementMenu': '管理菜单', + 'MobileHeader/Menu/latestReleaseNotes': '最新版本说明', + 'MobileHeader/MenuSubHeader/back': '返回', + 'MobileHeader/MenuAccordion/open': '展开', + 'MobileHeader/MenuAccordion/close': '关闭', + 'Launcher/searchInputTitle': '请输入功能名称。', + 'Launcher/favoriteModeText': '常用功能', + 'Launcher/allModeText': '所有功能', + 'Launcher/listText': '功能一览表', + 'Launcher/helpText': 'よく使うアプリとは', // TODO: 「よく使うアプリとは」の翻訳 + 'Launcher/searchResultText': '搜索结果', + 'Launcher/emptyText': '未找到匹配的功能。', + 'Launcher/sortDropdownLabel': '显示排序', + 'Launcher/sortDropdownSelected': '已选择', + 'Launcher/sortDropdownOrderDefault': '默认', + 'Launcher/sortDropdownOrderNameAsc': '以功能名称升序', + 'Launcher/sortDropdownOrderNameDesc': '以功能名称降序', + }, + 'zh-tw': { + 'common/school': '學校', + 'common/help': '幫助', + 'common/userSetting': '個人設定', + 'common/releaseNote': '版本說明', + 'common/releaseNotesLoadError': '載入版本說明失敗。\n請稍等片刻後再試。', + 'common/seeAllReleaseNotes': '查看全部版本說明', + 'DesktopHeader/DesktopHeader/appLauncherLabel': '應用程式', + 'MobileHeader/UserInfo/account': '帳戶', + 'MobileHeader/Menu/openMenu': '展開選單', + 'MobileHeader/Menu/closeMenu': '關閉選單', + 'MobileHeader/Menu/allAppButton': '所有功能', + 'MobileHeader/Menu/managementMenu': '管理選單', + 'MobileHeader/Menu/latestReleaseNotes': '最新版本說明', + 'MobileHeader/MenuSubHeader/back': '返回以功能名稱遞減', + 'MobileHeader/MenuAccordion/open': '展開', + 'MobileHeader/MenuAccordion/close': '關閉', + 'Launcher/searchInputTitle': '請輸入功能名稱。', + 'Launcher/favoriteModeText': '常用功能', + 'Launcher/allModeText': '所有功能', + 'Launcher/listText': '功能一覽表', + 'Launcher/helpText': 'よく使うアプリとは', // TODO: 「よく使うアプリとは」の翻訳 + 'Launcher/searchResultText': '搜尋結果', + 'Launcher/emptyText': '未找到符合的功能。', + 'Launcher/sortDropdownLabel': '顯示排序', + 'Launcher/sortDropdownSelected': '選擇中', + 'Launcher/sortDropdownOrderDefault': '預設', + 'Launcher/sortDropdownOrderNameAsc': '以功能名稱遞增', + 'Launcher/sortDropdownOrderNameDesc': '以功能名稱遞減', + }, +} as const satisfies Record diff --git a/packages/smarthr-ui/src/components/AppHeader/multilingualization/translate.ts b/packages/smarthr-ui/src/components/AppHeader/multilingualization/translate.ts new file mode 100644 index 0000000000..c3f47404a0 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/multilingualization/translate.ts @@ -0,0 +1,7 @@ +import { Messages, translation } from './messages' +import { DEFAULT_LOCALE, Locale } from './types' + +export const translate = (id: ID, locale?: Locale | null) => + translation[locale ?? DEFAULT_LOCALE][ + id + ] as (typeof translation)[typeof DEFAULT_LOCALE][typeof id] diff --git a/packages/smarthr-ui/src/components/AppHeader/multilingualization/types.ts b/packages/smarthr-ui/src/components/AppHeader/multilingualization/types.ts new file mode 100644 index 0000000000..adcd1c83f0 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/multilingualization/types.ts @@ -0,0 +1,4 @@ +import { localeMap } from './localeMap' +export const DEFAULT_LOCALE = 'ja' + +export type Locale = keyof typeof localeMap diff --git a/packages/smarthr-ui/src/components/AppHeader/stories/AppHeader.stories.tsx b/packages/smarthr-ui/src/components/AppHeader/stories/AppHeader.stories.tsx new file mode 100644 index 0000000000..dfe652cfb1 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/stories/AppHeader.stories.tsx @@ -0,0 +1,17 @@ +import { Meta, StoryObj } from '@storybook/react/*' + +import { AppHeader } from '../AppHeader' + +import { args } from './args' + +const meta = { + title: 'Navigation(ナビゲーション)/AppHeader', + component: AppHeader, + args, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Playground: Story = {} diff --git a/packages/smarthr-ui/src/components/AppHeader/stories/VRTAppHeader.stories.tsx b/packages/smarthr-ui/src/components/AppHeader/stories/VRTAppHeader.stories.tsx new file mode 100644 index 0000000000..985ccd40a8 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/stories/VRTAppHeader.stories.tsx @@ -0,0 +1,224 @@ +import { action } from '@storybook/addon-actions' +import { Meta, StoryObj } from '@storybook/react/*' +import { within } from '@storybook/test' +import React from 'react' + +import { AppHeader } from '../AppHeader' +import { Locale } from '../multilingualization' + +import { args } from './args' + +const meta = { + title: 'Navigation(ナビゲーション)/AppHeader/VRT', + component: AppHeader, + args, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const VRTNameOnly: Story = { + args: { + userInfo: { + email: 'smarthr@example.com', + empCode: null, + firstName: '須磨', + lastName: '栄子', + accountUrl: 'https://exmaple.com', + }, + }, +} + +export const VRTEmpCodeOnly: Story = { + args: { + userInfo: { + email: 'smarthr@example.com', + empCode: '001', + firstName: null, + lastName: null, + accountUrl: 'https://exmaple.com', + }, + }, +} + +export const VRTEmailOnly: Story = { + args: { + userInfo: { + email: 'smarthr@example.com', + empCode: null, + firstName: null, + lastName: null, + accountUrl: 'https://exmaple.com', + }, + }, +} + +export const VRTNoUserInfo: Story = { + args: { + userInfo: { + email: null, + empCode: null, + firstName: null, + lastName: null, + accountUrl: 'https://exmaple.com', + }, + }, +} + +export const VRTSingleTenant: Story = { + args: { + tenants: [ + { + id: 'tenant-1', + name: '株式会社テストテナント壱', + }, + ], + }, +} + +export const VRTNoNavigations: Story = { + args: { + navigations: undefined, + }, +} + +export const VRTTenant: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + canvas.getByRole('button', { name: '株式会社テストテナント壱 候補を開く' }).click() + }, +} + +export const VRTLauncher: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + canvas.getByRole('button', { name: 'アプリ' }).click() + }, +} + +export const VRTReleaseNote: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + canvas.getByRole('button', { name: 'リリースノート' }).click() + }, +} + +export const VRTReleaseNoteLoading: Story = { + args: { + releaseNote: { + loading: true, + links: [], + indexUrl: 'https://exmaple.com', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + canvas.getByRole('button', { name: 'リリースノート' }).click() + }, +} + +export const VRTReleaseNoteError: Story = { + args: { + releaseNote: { + error: true, + links: [], + indexUrl: 'https://exmaple.com', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + canvas.getByRole('button', { name: 'リリースノート' }).click() + }, +} + +export const VRTSetting: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + canvas.getByRole('button', { name: '栄子 須磨(001)' }).click() + }, +} + +export const VRTNavigationDropdown: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + canvas.getByRole('button', { name: 'ドロップダウン' }).click() + }, +} + +export const VRTNavigationDropdownGroup: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + canvas.getByRole('button', { name: 'グループ' }).click() + }, +} + +export const VRTLocaleEnUs: Story = { + args: { + locale: { + selectedLocale: 'en-us', + onSelectLocale: (locale: Locale) => action(locale), + }, + }, +} + +export const VRTLocaleIdId: Story = { + args: { + locale: { + selectedLocale: 'id-id', + onSelectLocale: (locale: Locale) => action(locale), + }, + }, +} + +export const VRTLocalePt: Story = { + args: { + locale: { + selectedLocale: 'pt', + onSelectLocale: (locale: Locale) => action(locale), + }, + }, +} + +export const VRTLocaleVi: Story = { + args: { + locale: { + selectedLocale: 'vi', + onSelectLocale: (locale: Locale) => action(locale), + }, + }, +} + +export const VRTLocaleKo: Story = { + args: { + locale: { + selectedLocale: 'ko', + onSelectLocale: (locale: Locale) => action(locale), + }, + }, +} + +export const VRTLocaleZhCn: Story = { + args: { + locale: { + selectedLocale: 'zh-cn', + onSelectLocale: (locale: Locale) => action(locale), + }, + }, +} + +export const VRTLocaleZhTw: Story = { + args: { + locale: { + selectedLocale: 'zh-tw', + onSelectLocale: (locale: Locale) => action(locale), + }, + }, +} diff --git a/packages/smarthr-ui/src/components/AppHeader/stories/args.tsx b/packages/smarthr-ui/src/components/AppHeader/stories/args.tsx new file mode 100644 index 0000000000..e217aa6285 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/stories/args.tsx @@ -0,0 +1,153 @@ +import { action } from '@storybook/addon-actions' +import React, { ComponentProps, FC, PropsWithChildren } from 'react' + +import { AppHeader } from '../AppHeader' + +const CustomLink: FC> = (props) => ( + + {props.children} + +) + +const AdditionalContent: FC = ({ children }) => ( +
{children}
+) + +const buildFeature = (index: number, name: string, favorite: boolean, position?: number) => ({ + id: `feature-${index}`, + url: 'https://example.com', + name, + favorite, + position: position ?? null, +}) + +export const args: ComponentProps = { + children: children, + appName: '勤怠管理', + tenants: [ + { + id: 'tenant-1', + name: '株式会社テストテナント壱', + }, + { + id: 'tenant-2', + name: '株式会社テストテナント弐', + }, + ], + currentTenantId: 'tenant-1', + onTenantSelect: action('テナント選択'), + schoolUrl: 'https://exmaple.com', + helpPageUrl: 'https://exmaple.com', + locale: { + selectedLocale: 'ja', + onSelectLocale: action('locale'), + }, + userInfo: { + email: 'smarthr@example.com', + empCode: '001', + firstName: '須磨', + lastName: '栄子', + accountUrl: 'https://exmaple.com', + }, + desktopAdditionalContent: desktopAdditionalContent, + navigations: [ + { + children: 'aタグ', + href: 'https://exmaple.com', + }, + { + children: 'カスタムタグ', + elementAs: CustomLink, + to: 'https://exmaple.com', + }, + { + children: 'ボタン', + onClick: action('AppNavボタンクリック'), + }, + { + children: 'ドロップダウン', + childNavigations: [ + { + children: 'aタグ', + href: 'https://exmaple.com', + }, + { + children: 'カスタムタグ', + elementAs: CustomLink, + to: 'https://exmaple.com', + }, + { + children: 'ボタン', + onClick: action('ボタンクリック'), + }, + ], + }, + { + children: 'グループ', + childNavigations: [ + { + title: 'グループ1', + childNavigations: [ + { + children: 'グループ1_アイテム1', + href: 'https://exmaple.com', + current: true, + }, + { + children: 'グループ1_アイテム2', + href: 'https://exmaple.com', + }, + ], + }, + { + title: 'グループ2', + childNavigations: [ + { + children: 'グループ2_アイテム1', + href: 'https://exmaple.com', + }, + { + children: 'グループ2_アイテム2', + href: 'https://exmaple.com', + }, + ], + }, + ], + }, + ], + desktopNavigationAdditionalContent: ( + desktopNavigationAdditionalContent + ), + releaseNote: { + links: [ + { + title: 'リリースノート1', + url: 'https://exmaple.com', + }, + { + title: 'リリースノート2', + url: 'https://exmaple.com', + }, + { + title: 'リリースノート3', + url: 'https://exmaple.com', + }, + ], + indexUrl: 'https://exmaple.com', + }, + features: [ + buildFeature(1, '従業員リスト', false), + buildFeature(2, '共通設定', true, 4), + buildFeature(3, 'お知らせ管理', true, 3), + buildFeature(4, '給与明細', true, 1), + buildFeature(5, '申請', false), + buildFeature(6, '給与明細管理', false), + buildFeature(7, 'マイナンバー管理', false), + buildFeature(8, '源泉徴収票管理', false), + buildFeature(9, '手続き', false), + buildFeature(10, '手続きToDo', false), + buildFeature(11, '文書配付', false), + buildFeature(12, 'IdP', true, 2), + ], + mobileAdditionalContent: mobileAdditionalContent, +} diff --git a/packages/smarthr-ui/src/components/AppHeader/types.ts b/packages/smarthr-ui/src/components/AppHeader/types.ts new file mode 100644 index 0000000000..3b096f882f --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/types.ts @@ -0,0 +1,99 @@ +import { ComponentProps, ComponentType, MouseEvent, ReactElement, ReactNode } from 'react' + +import { Header } from '../Header' + +type Locale = 'ja' | 'en-us' | 'id-id' | 'pt' | 'vi' | 'ko' | 'zh-cn' | 'zh-tw' + +export type LocaleProps = { + selectedLocale: Locale + onSelectLocale: (locale: Locale) => void +} + +export type UserInfoProps = { + /** @deprecated 書式の統一のために、可能な限り使用しないでください */ + arbitraryDisplayName?: string | null + email?: string | null + empCode?: string | null + firstName?: string | null + lastName?: string | null + accountUrl?: string | null + accountImageUrl?: string + enableNew?: boolean +} + +export type HeaderProps = ComponentProps & { + locale?: LocaleProps | null + enableNew?: boolean + appName?: ReactNode + schoolUrl?: string | null + helpPageUrl?: string | null + userInfo?: UserInfoProps | null + desktopAdditionalContent?: ReactNode + navigations?: Navigation[] | null + desktopNavigationAdditionalContent?: ReactNode + releaseNote?: ReleaseNoteProps | null + features?: Array + mobileAdditionalContent?: ReactNode +} + +export type Navigation = NavigationLink | NavigationCustomTag | NavigationButton | NavigationGroup + +type NavigationLink = { + children: ReactElement | string + href: string + current?: boolean +} + +type NavigationCustomTag = { + children: ReactElement | string + elementAs: ComponentType + current?: boolean +} & { [key: string]: any } + +type NavigationButton = { + children: ReactElement | string + onClick: (e: MouseEvent) => void + current?: boolean +} + +export type NavigationGroup = { + children: ReactElement | string + childNavigations: Array + current?: boolean +} + +export type ChildNavigationGroup = { + title: ReactElement | string + childNavigations: ChildNavigation[] +} + +export type ChildNavigation = NavigationLink | NavigationCustomTag | NavigationButton + +export type ReleaseNoteProps = { + indexUrl: string + links: Array<{ + title: string + url: string + }> + loading?: boolean | null + error?: boolean | null +} + +const launcher = { + pages: ['favorite', 'all'], + modes: ['default', 'search'], + sortTypes: ['default', 'name/asc', 'name/desc'], +} as const + +export type Launcher = { + feature: { + id: string + name: string + url: string + favorite: boolean + position: number | null + } + page: (typeof launcher)['pages'][number] + mode: (typeof launcher)['modes'][number] + sortType: (typeof launcher)['sortTypes'][number] +} diff --git a/packages/smarthr-ui/src/components/AppHeader/utils.ts b/packages/smarthr-ui/src/components/AppHeader/utils.ts new file mode 100644 index 0000000000..17c2556e16 --- /dev/null +++ b/packages/smarthr-ui/src/components/AppHeader/utils.ts @@ -0,0 +1,33 @@ +import { ChildNavigation, ChildNavigationGroup, Navigation } from './types' + +export const buildDisplayName = ({ + email, + empCode, + firstName, + lastName, +}: { + email?: string | null + empCode?: string | null + firstName?: string | null + lastName?: string | null +}) => { + const empCodeStr = empCode ? `(${empCode})` : '' + + return ( + (firstName && lastName ? `${lastName} ${firstName}` + empCodeStr : empCode ? empCode : email) ?? + '' + ) +} + +export const isChildNavigation = ( + navigation: Navigation | ChildNavigationGroup, +): navigation is ChildNavigation => + 'href' in navigation || 'elementAs' in navigation || 'onClick' in navigation + +export const isChildNavigationGroup = ( + navigation: Navigation | ChildNavigationGroup, +): navigation is ChildNavigationGroup => + navigation && + 'childNavigations' in navigation && + 'title' in navigation && + !('elementAs' in navigation) diff --git a/packages/smarthr-ui/src/index.test.ts b/packages/smarthr-ui/src/index.test.ts index ed8e55550c..5a63bb1f92 100644 --- a/packages/smarthr-ui/src/index.test.ts +++ b/packages/smarthr-ui/src/index.test.ts @@ -9,10 +9,12 @@ const readdir = util.promisify(fs.readdir) const IGNORE_COMPONENTS = ['Experimental'] const IGNORE_INNER_DIRS = [ - 'FlashMessage/FlashMessageList', 'Input/InputWithTooltip', 'Browser/models', 'stories', + 'AppHeader/components', + 'AppHeader/hooks', + 'AppHeader/multilingualization', ] describe('index', () => { diff --git a/packages/smarthr-ui/src/index.ts b/packages/smarthr-ui/src/index.ts index 3490654c0f..561609c182 100644 --- a/packages/smarthr-ui/src/index.ts +++ b/packages/smarthr-ui/src/index.ts @@ -92,6 +92,7 @@ export * from './components/Stepper' export * from './components/Picker' export * from './components/Browser' export * from './components/WarekiPicker' +export { AppHeader } from './components/AppHeader' // layout components export { Center, Cluster, Reel, Stack, Sidebar } from './components/Layout'