diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 12d95b33a6..9afa963229 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7584,11 +7584,29 @@ button.module-image__border-overlay:focus { flex-direction: row; width: 100%; - &:hover { - @include light-theme { - background-color: $color-gray-15; + @include light-theme { + &:hover { + background-color: $color-gray-05; } - @include dark-theme { + } + @include dark-theme { + &:hover { + background-color: $color-gray-60; + } + } + @include keyboard-mode { + &:hover { + background-color: inherit; + } + &:focus { + background-color: $color-gray-05; + } + } + @include dark-keyboard-mode { + &:hover { + background-color: inherit; + } + &:focus { background-color: $color-gray-60; } } diff --git a/ts/components/AvatarPreview.tsx b/ts/components/AvatarPreview.tsx index fcc609a9e2..2c316be759 100644 --- a/ts/components/AvatarPreview.tsx +++ b/ts/components/AvatarPreview.tsx @@ -134,7 +134,7 @@ export const AvatarPreview = ({ const isLoading = imageStatus === ImageStatus.Loading; - const clickProps = onClick ? { role: 'button', onClick } : {}; + const clickProps = onClick ? { role: 'button', onClick, tabIndex: 0 } : {}; const componentStyle = { ...style, }; diff --git a/ts/components/ChatColorPicker.tsx b/ts/components/ChatColorPicker.tsx index 672624eb76..0853732c54 100644 --- a/ts/components/ChatColorPicker.tsx +++ b/ts/components/ChatColorPicker.tsx @@ -18,6 +18,8 @@ import { SampleMessageBubbles } from './SampleMessageBubbles'; import { PanelRow } from './conversation/conversation-details/PanelRow'; import { getCustomColorStyle } from '../util/getCustomColorStyle'; +import { useDelayedRestoreFocus } from '../hooks/useRestoreFocus'; + type CustomColorDataType = { id?: string; value?: CustomColorType; @@ -84,6 +86,8 @@ export const ChatColorPicker = ({ CustomColorDataType | undefined >(undefined); + const [focusRef] = useDelayedRestoreFocus(); + const onSelectColor = ( conversationColor: ConversationColorType, customColorData?: { id: string; value: CustomColorType } @@ -172,7 +176,7 @@ export const ChatColorPicker = ({ />
- {ConversationColors.map(color => ( + {ConversationColors.map((color, i) => (
))} {Object.keys(customColors).map(colorId => { diff --git a/ts/components/Select.tsx b/ts/components/Select.tsx index 29c967b225..527deec395 100644 --- a/ts/components/Select.tsx +++ b/ts/components/Select.tsx @@ -19,41 +19,40 @@ export type PropsType = Readonly<{ value?: string | number; }>; -export function Select({ - disabled, - moduleClassName, - name, - onChange, - options, - value, -}: PropsType): JSX.Element { - const onSelectChange = (event: ChangeEvent) => { - onChange(event.target.value); - }; +export const Select = React.forwardRef( + ( + { disabled, moduleClassName, name, onChange, options, value }: PropsType, + ref: React.Ref + ): JSX.Element => { + const onSelectChange = (event: ChangeEvent) => { + onChange(event.target.value); + }; - return ( -
- -
- ); -} + return ( +
+ +
+ ); + } +); diff --git a/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx b/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx index 14f5e5eca7..2168d73d3c 100644 --- a/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx +++ b/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx @@ -88,3 +88,9 @@ story.add('On (Non-admin)', () => { return ; }); + +story.add('Off (Non-admin) - user cannot get here', () => { + const props = createProps(undefined, false); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/GroupLinkManagement.tsx b/ts/components/conversation/conversation-details/GroupLinkManagement.tsx index cb8b8fc30c..bfee5873c7 100644 --- a/ts/components/conversation/conversation-details/GroupLinkManagement.tsx +++ b/ts/components/conversation/conversation-details/GroupLinkManagement.tsx @@ -11,6 +11,8 @@ import { PanelRow } from './PanelRow'; import { PanelSection } from './PanelSection'; import { Select } from '../../Select'; +import { useDelayedRestoreFocus } from '../../../hooks/useRestoreFocus'; + const AccessControlEnum = Proto.AccessControl.AccessRequired; export type PropsType = { @@ -36,6 +38,8 @@ export const GroupLinkManagement: React.ComponentType = ({ throw new Error('GroupLinkManagement rendered without a conversation'); } + const [focusRef] = useDelayedRestoreFocus(); + const createEventHandler = (handleEvent: (x: boolean) => void) => { return (value: string) => { handleEvent(value === 'true'); @@ -72,6 +76,7 @@ export const GroupLinkManagement: React.ComponentType = ({ value: 'false', }, ]} + ref={focusRef} value={String(Boolean(hasGroupLink))} /> ) : null @@ -90,6 +95,7 @@ export const GroupLinkManagement: React.ComponentType = ({ /> } label={i18n('GroupLinkManagement--share')} + ref={!isAdmin ? focusRef : undefined} onClick={() => { if (conversation.groupLink) { copyGroupLink(conversation.groupLink); diff --git a/ts/components/conversation/conversation-details/PanelRow.tsx b/ts/components/conversation/conversation-details/PanelRow.tsx index f1cd725a10..99d084b297 100644 --- a/ts/components/conversation/conversation-details/PanelRow.tsx +++ b/ts/components/conversation/conversation-details/PanelRow.tsx @@ -19,43 +19,55 @@ export type Props = { const bem = bemGenerator('ConversationDetails-panel-row'); -export const PanelRow: React.ComponentType = ({ - alwaysShowActions, - className, - disabled, - icon, - label, - info, - right, - actions, - onClick, -}) => { - const content = ( - <> - {icon !== undefined ?
{icon}
: null} -
-
{label}
- {info !== undefined ?
{info}
: null} -
- {right !== undefined ?
{right}
: null} - {actions !== undefined ? ( -
{actions}
- ) : null} - - ); - - if (onClick) { - return ( - +export const PanelRow = React.forwardRef( + ( + { + alwaysShowActions, + className, + disabled, + icon, + label, + info, + right, + actions, + onClick, + }: Props, + ref: React.Ref + ) => { + const content = ( + <> + {icon !== undefined ?
{icon}
: null} +
+
{label}
+ {info !== undefined ? ( +
{info}
+ ) : null} +
+ {right !== undefined ? ( +
{right}
+ ) : null} + {actions !== undefined ? ( +
+ {actions} +
+ ) : null} + ); - } - return
{content}
; -}; + if (onClick) { + return ( + + ); + } + + return
{content}
; + } +); diff --git a/ts/hooks/useRestoreFocus.ts b/ts/hooks/useRestoreFocus.ts index 02f82db0c7..a5d92fe5ee 100644 --- a/ts/hooks/useRestoreFocus.ts +++ b/ts/hooks/useRestoreFocus.ts @@ -45,3 +45,51 @@ export const useRestoreFocus = (): Array => { return [setFocusRef]; }; + +// Panels are initially rendered outside the DOM, and then added to it. We need to +// delay our attempts to set focus. +// Just like the above hook, but with a debounce. +export const useDelayedRestoreFocus = (): Array => { + const toFocusRef = React.useRef(null); + const lastFocusedRef = React.useRef(null); + + const setFocusRef = React.useCallback( + (toFocus: HTMLElement | null | undefined) => { + function setFocus() { + if (!toFocus) { + return; + } + + // We only want to do this once. + if (toFocusRef.current) { + return; + } + toFocusRef.current = toFocus; + + // Remember last-focused element, focus this new target element. + lastFocusedRef.current = document.activeElement as HTMLElement; + toFocus.focus(); + } + + const timeout = setTimeout(setFocus, 250); + + return () => { + clearTimeout(timeout); + }; + }, + [] + ); + + React.useEffect(() => { + return () => { + // On unmount, returned focus to element focused before we set the focus + setTimeout(() => { + if (lastFocusedRef.current && lastFocusedRef.current.focus) { + lastFocusedRef.current.focus(); + } + }); + }; + }, []); + + return [setFocusRef]; +}; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 8debd74577..4ddc8f756b 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -13022,6 +13022,20 @@ "reasonCategory": "usageTrusted", "updated": "2021-09-17T17:37:46.279Z" }, + { + "rule": "React-useRef", + "path": "ts/hooks/useRestoreFocus.js", + "line": " const toFocusRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-10-22T00:52:39.251Z" + }, + { + "rule": "React-useRef", + "path": "ts/hooks/useRestoreFocus.js", + "line": " const lastFocusedRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-10-22T00:52:39.251Z" + }, { "rule": "React-useRef", "path": "ts/hooks/useRestoreFocus.ts", @@ -13036,6 +13050,20 @@ "reasonCategory": "usageTrusted", "updated": "2021-07-30T16:57:33.618Z" }, + { + "rule": "React-useRef", + "path": "ts/hooks/useRestoreFocus.ts", + "line": " const toFocusRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-10-22T00:52:39.251Z" + }, + { + "rule": "React-useRef", + "path": "ts/hooks/useRestoreFocus.ts", + "line": " const lastFocusedRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-10-22T00:52:39.251Z" + }, { "rule": "jQuery-append(", "path": "ts/logging/debuglogs.js",