-
Notifications
You must be signed in to change notification settings - Fork 141
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
chore: DropdownMenuButton の内部ロジックを整理する #5314
base: master
Are you sure you want to change the base?
Changes from all commits
580a7cf
af5e309
8b44322
99f5553
ffce4fb
dca7bcf
eb7049c
98e6147
bbc2dfa
5676e42
dd087dd
4e3a70b
cf0977c
0590fe2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
import React, { ComponentProps, type PropsWithChildren, type ReactNode } from 'react' | ||
import React, { ComponentProps, type PropsWithChildren, type ReactNode, useMemo } from 'react' | ||
import { tv } from 'tailwind-variants' | ||
|
||
import { Text } from '../../Text' | ||
|
@@ -36,13 +36,28 @@ export const DropdownMenuGroup: React.FC<Props & ElementProps> = ({ | |
name, | ||
children, | ||
className, | ||
}) => ( | ||
<li className={group({ className })}> | ||
{name && ( | ||
<Text as="p" size="S" weight="bold" color="TEXT_GREY" leading="NONE" className={groupName()}> | ||
{name} | ||
}) => { | ||
const styles = useMemo( | ||
() => ({ | ||
group: group({ className }), | ||
groupName: groupName(), | ||
}), | ||
[className], | ||
) | ||
|
||
return ( | ||
<li className={styles.group}> | ||
<NameText className={styles.groupName}>{name}</NameText> | ||
<ul>{renderButtonList(children)}</ul> | ||
</li> | ||
) | ||
} | ||
|
||
const NameText = React.memo<PropsWithChildren<{ className: string }>>( | ||
({ children, className }) => | ||
children && ( | ||
<Text as="p" size="S" weight="bold" color="TEXT_GREY" leading="NONE" className={className}> | ||
{children} | ||
</Text> | ||
Comment on lines
+56
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. childrenに対してReactNodeが渡されますが、たいていはstringになるはずなのでmemo化しました |
||
)} | ||
<ul>{renderButtonList(children)}</ul> | ||
</li> | ||
), | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,35 +4,63 @@ const matchesDisabledState = (element: Element): boolean => | |
element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true' | ||
|
||
const isElementDisabled = (element: Element): boolean => { | ||
if (matchesDisabledState(element)) return true | ||
return Array.from(element.querySelectorAll('*')).some((child) => matchesDisabledState(child)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
if (matchesDisabledState(element)) { | ||
return true | ||
} | ||
|
||
return Array.from(element.querySelectorAll('*')).some(matchesDisabledState) | ||
} | ||
|
||
const moveFocus = ( | ||
direction: number, | ||
enabledItems: Element[], | ||
focusedIndex: number, | ||
hoveredItem: Element | null, | ||
) => { | ||
const calculateNextIndex = () => { | ||
if (focusedIndex > -1) { | ||
// フォーカスされているアイテムが存在する場合 | ||
return (focusedIndex + direction + enabledItems.length) % enabledItems.length | ||
} | ||
const KEY_UP_REGEX = /^(Arrow)?(Up|Left)$/ | ||
const KEY_DOWN_REGEX = /^(Arrow)?(Down|Right)$/ | ||
|
||
const moveFocus = (element: Element, direction: 1 | -1) => { | ||
const { hoveredItem, tabbableItems, focusedIndex } = Array.from( | ||
element.querySelectorAll('li > *'), | ||
).reduce( | ||
( | ||
acc: { | ||
hoveredItem: Element | null | ||
tabbableItems: Element[] | ||
focusedIndex: number | ||
}, | ||
item, | ||
) => { | ||
if (item.matches(':hover') && acc.hoveredItem === null) { | ||
acc.hoveredItem = item | ||
} | ||
|
||
if (hoveredItem) { | ||
// ホバー状態のアイテムが存在する場合 | ||
return ( | ||
(enabledItems.indexOf(hoveredItem) + direction + enabledItems.length) % enabledItems.length | ||
) | ||
} | ||
if (!isElementDisabled(item)) { | ||
acc.tabbableItems.push(item) | ||
|
||
if (document.activeElement === item) { | ||
acc.focusedIndex = acc.tabbableItems.length - 1 | ||
} | ||
} | ||
|
||
// どちらもない場合は最初のアイテムからスタート | ||
return direction > 0 ? 0 : enabledItems.length - 1 | ||
return acc | ||
}, | ||
{ | ||
hoveredItem: null, | ||
tabbableItems: [], | ||
focusedIndex: -1, | ||
}, | ||
) | ||
|
||
let nextIndex = 0 | ||
|
||
if (focusedIndex > -1) { | ||
// フォーカスされているアイテムが存在する場合 | ||
nextIndex = (focusedIndex + direction + tabbableItems.length) % tabbableItems.length | ||
} else if (hoveredItem) { | ||
// ホバー状態のアイテムが存在する場合 | ||
nextIndex = | ||
(tabbableItems.indexOf(hoveredItem) + direction + tabbableItems.length) % tabbableItems.length | ||
} else if (direction === -1) { | ||
nextIndex = tabbableItems.length - 1 | ||
Comment on lines
+50
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 元々は関数内にロジックが隠蔽されていましたが、変数の汚染などもないし、nextIndexに値を入れるだけだったのでただのif-elseにしました |
||
} | ||
|
||
const nextIndex = calculateNextIndex() | ||
const nextItem = enabledItems[nextIndex] | ||
const nextItem = tabbableItems[nextIndex] | ||
|
||
if (nextItem instanceof HTMLElement) { | ||
nextItem.focus() | ||
|
@@ -46,57 +74,18 @@ const useKeyboardNavigation = (containerRef: React.RefObject<HTMLElement>) => { | |
return | ||
} | ||
|
||
const allItems = Array.from(containerRef.current.querySelectorAll('li > *')) | ||
const { | ||
hoveredItem, | ||
tabbableItems: enabledItems, | ||
focusedIndex, | ||
} = allItems.reduce( | ||
( | ||
acc: { | ||
hoveredItem: Element | null | ||
tabbableItems: Element[] | ||
focusedIndex: number | ||
}, | ||
item, | ||
) => { | ||
if (item.matches(':hover') && acc.hoveredItem === null) { | ||
acc.hoveredItem = item | ||
} | ||
|
||
const isDisabled = isElementDisabled(item) | ||
|
||
if (isDisabled) { | ||
return acc | ||
} | ||
|
||
acc.tabbableItems.push(item) | ||
if (document.activeElement === item) { | ||
acc.focusedIndex = acc.tabbableItems.length - 1 | ||
} | ||
|
||
return acc | ||
}, | ||
{ | ||
hoveredItem: null, | ||
tabbableItems: [], | ||
focusedIndex: -1, | ||
}, | ||
) | ||
|
||
if (['Up', 'ArrowUp', 'Left', 'ArrowLeft'].includes(e.key)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 配列に対してincludesを実行するより、正規表現一発でのチェックのほうが高速のため調整しています |
||
moveFocus(-1, enabledItems, focusedIndex, hoveredItem) | ||
} | ||
|
||
if (['Down', 'ArrowDown', 'Right', 'ArrowRight'].includes(e.key)) { | ||
moveFocus(1, enabledItems, focusedIndex, hoveredItem) | ||
Comment on lines
-87
to
-92
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 直前で計算している |
||
if (KEY_UP_REGEX.test(e.key)) { | ||
moveFocus(containerRef.current, -1) | ||
} else if (KEY_DOWN_REGEX.test(e.key)) { | ||
moveFocus(containerRef.current, 1) | ||
} | ||
}, | ||
[containerRef], | ||
) | ||
|
||
useEffect(() => { | ||
document.addEventListener('keydown', handleKeyDown) | ||
|
||
return () => { | ||
document.removeEventListener('keydown', handleKeyDown) | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
比較しかしていないため、初期値代入は不要でした