Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: AppHeader コンポーネントを追加 #5203

Merged
merged 9 commits into from
Dec 24, 2024
22 changes: 22 additions & 0 deletions packages/smarthr-ui/src/components/AppHeader/AppHeader.tsx
Original file line number Diff line number Diff line change
@@ -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<HeaderProps> = ({ 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 (
<LocaleContextProvider locale={locale}>
<DesktopHeader {...props}>{isDesktop && children}</DesktopHeader>
<MobileHeader {...props}>{isMobile && children}</MobileHeader>
</LocaleContextProvider>
)
}
60 changes: 60 additions & 0 deletions packages/smarthr-ui/src/components/AppHeader/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
## このコンポーネントについて

props を埋めていくだけで良い感じに共通のヘッダー/ナビゲーションの UI が組み立てられるコンポーネントです。

## どのように使うのか

`types.ts` にある HeaderProps というのが最終的な props なのであり、これらを穴埋めしていくことになります。
`xxxAdditionalContent` 以外の props は積極的に埋めてください。

```ts
export type HeaderProps = ComponentProps<typeof Header> & {
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<Launcher['feature']>
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` 型で受け取るようになっているので、`<span woven-enabled="true">ほげ</span>` みたいなものを渡すようにしてください。
- 辞書を持っているアプリの場合
- コンポーネント側で辞書を持っているので、`locale` の props を埋めると内部的に持っているテキストは翻訳されます。
- 外部から渡すテキストはアプリケーション側で翻訳されたものを渡すようにしてください。
Original file line number Diff line number Diff line change
@@ -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<Launcher['feature']>
page: Launcher['page']
}

export const AppLauncherFeatures: FC<Props> = ({ features, page }) => {
const isDesktop = useMediaQuery(mediaQuery.desktop)
const translate = useTranslate()
const { empty, list, listItem } = appLauncherFeatures()

if (features.length === 0) {
return (
<div className={empty()}>
<Text size="S">
<Translate>{translate('Launcher/emptyText')}</Translate>
</Text>
</div>
)
}

return (
<ul className={list()}>
{features.map((feature) => (
<li key={feature.id}>
<AnchorButton
className={listItem({ favorite: page === 'favorite' })}
variant="text"
href={feature.url}
prefix={page === 'favorite' && <FaStarIcon />}
suffix={<FaArrowRightIcon />}
wide
target="_blank"
>
{isDesktop ? <LineClamp maxLines={2}>{feature.name}</LineClamp> : feature.name}
</AnchorButton>
</li>
))}
</ul>
)
}
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ sortType, onSelectSortType }) => {
const translate = useTranslate()
const triggerRef = useRef<HTMLButtonElement>(null)
const { trigger, stack, contentButton } = sortDropdown()

const sortMap: Record<Launcher['sortType'], string> = {
default: translate('Launcher/sortDropdownOrderDefault'),
'name/asc': translate('Launcher/sortDropdownOrderNameAsc'),
'name/desc': translate('Launcher/sortDropdownOrderNameDesc'),
}

return (
<Dropdown>
<DropdownTrigger>
<Button
className={trigger()}
size="s"
variant="text"
suffix={<FaCaretDownIcon />}
ref={triggerRef}
>
<Translate>{translate('Launcher/sortDropdownLabel')}</Translate>
</Button>
</DropdownTrigger>

<DropdownContent controllable>
{/* eslint-disable-next-line smarthr/best-practice-for-layouts */}
<Stack className={stack()} gap={0} align="stretch">
{Object.entries(sortMap).map(([key, value], i) => (
<Button
key={i}
className={contentButton({ selected: key === sortType })}
prefix={
key === sortType && (
<FaCheckIcon
color={textColor.main}
alt={<Translate>{translate('Launcher/sortDropdownSelected')}</Translate>}
/>
)
}
onClick={() => {
onSelectSortType(key as Launcher['sortType'])

// Dropdown がネストしており、この Dropdown のみ閉じて親の Dropdown は開いたままというのができない
// そのため、無理矢理クリックイベントを発生させて実現している
setTimeout(() => {
if (triggerRef.current) {
triggerRef.current.click()
triggerRef.current.focus()
}
}, 0)
}}
>
<Translate>{value}</Translate>
</Button>
))}
</Stack>
</DropdownContent>
</Dropdown>
)
}
Original file line number Diff line number Diff line change
@@ -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<ComponentPropsWithoutRef<'a'>, 'prefix'>
type ButtonProps = Omit<ComponentPropsWithoutRef<'button'>, 'prefix'>

type Props = (({ elementAs: 'a' } & AnchorProps) | ({ elementAs: 'button' } & ButtonProps)) & {
prefix?: ReactNode
current?: boolean
boldWhenCurrent?: boolean
}

export const CommonButton: FC<Props> = ({
elementAs,
prefix,
current,
boldWhenCurrent,
className,
...props
}) => {
const commonButtonStyle = commonButton({
prefix: Boolean(prefix),
current,
boldWhenCurrent,
className,
})

if (elementAs === 'a') {
return (
<a {...(props as AnchorProps)} className={commonButtonStyle}>
{prefix}
{props.children}
</a>
)
} else if (elementAs === 'button') {
return (
// eslint-disable-next-line smarthr/best-practice-for-button-element
<button {...(props as ButtonProps)} className={commonButtonStyle}>
{prefix}
{props.children}
</button>
)
} else {
throw new Error(elementAs satisfies never)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React, { PropsWithChildren, memo } from 'react'

export const Translate = memo<PropsWithChildren>(({ children }) => (
<span data-wovn-enable="true">{children}</span>
))
Loading
Loading