feat: `Sidebar` new components (#32821)
parent
d27cc363a5
commit
cd0d50016e
@ -0,0 +1,13 @@ |
||||
--- |
||||
'@rocket.chat/uikit-playground': minor |
||||
'@rocket.chat/fuselage-ui-kit': minor |
||||
'@rocket.chat/ui-theming': minor |
||||
'@rocket.chat/ui-video-conf': minor |
||||
'@rocket.chat/ui-composer': minor |
||||
'@rocket.chat/gazzodown': minor |
||||
'@rocket.chat/ui-avatar': minor |
||||
'@rocket.chat/ui-client': minor |
||||
'@rocket.chat/meteor': minor |
||||
--- |
||||
|
||||
Replaced new `SidebarV2` components under feature preview |
||||
@ -1,10 +1,11 @@ |
||||
import { SidebarV2ListItem } from '@rocket.chat/fuselage'; |
||||
import type { ForwardedRef, HTMLAttributes } from 'react'; |
||||
import React, { forwardRef } from 'react'; |
||||
|
||||
type RoomListRoomWrapperProps = HTMLAttributes<HTMLDivElement>; |
||||
|
||||
const RoomListRoomWrapper = forwardRef(function RoomListRoomWrapper(props: RoomListRoomWrapperProps, ref: ForwardedRef<HTMLDivElement>) { |
||||
return <div role='listitem' ref={ref} {...props} />; |
||||
return <SidebarV2ListItem ref={ref} {...props} />; |
||||
}); |
||||
|
||||
export default RoomListRoomWrapper; |
||||
|
||||
@ -1,50 +0,0 @@ |
||||
import { Sidebar } from '@rocket.chat/fuselage'; |
||||
import { useEffectEvent, useOutsideClick } from '@rocket.chat/fuselage-hooks'; |
||||
import type { HTMLAttributes } from 'react'; |
||||
import React, { useState, useEffect, useRef } from 'react'; |
||||
import tinykeys from 'tinykeys'; |
||||
|
||||
import SearchList from '../../search/SearchList'; |
||||
|
||||
type SearchProps = Omit<HTMLAttributes<HTMLElement>, 'is'>; |
||||
|
||||
const Search = (props: SearchProps) => { |
||||
const [searchOpen, setSearchOpen] = useState(false); |
||||
|
||||
const ref = useRef<HTMLElement>(null); |
||||
const handleCloseSearch = useEffectEvent(() => { |
||||
setSearchOpen(false); |
||||
}); |
||||
|
||||
useOutsideClick([ref], handleCloseSearch); |
||||
|
||||
const openSearch = useEffectEvent(() => { |
||||
setSearchOpen(true); |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
const unsubscribe = tinykeys(window, { |
||||
'$mod+K': (event) => { |
||||
event.preventDefault(); |
||||
openSearch(); |
||||
}, |
||||
'$mod+P': (event) => { |
||||
event.preventDefault(); |
||||
openSearch(); |
||||
}, |
||||
}); |
||||
|
||||
return (): void => { |
||||
unsubscribe(); |
||||
}; |
||||
}, [openSearch]); |
||||
|
||||
return ( |
||||
<> |
||||
<Sidebar.TopBar.Action icon='magnifier' onClick={openSearch} {...props} /> |
||||
{searchOpen && <SearchList ref={ref} onClose={handleCloseSearch} />} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default Search; |
||||
@ -1,382 +0,0 @@ |
||||
import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; |
||||
import { css } from '@rocket.chat/css-in-js'; |
||||
import { Sidebar, TextInput, Box, Icon } from '@rocket.chat/fuselage'; |
||||
import { useMutableCallback, useDebouncedValue, useAutoFocus, useUniqueId, useMergedRefs } from '@rocket.chat/fuselage-hooks'; |
||||
import { escapeRegExp } from '@rocket.chat/string-helpers'; |
||||
import { useUserPreference, useUserSubscriptions, useSetting, useTranslation, useMethod } from '@rocket.chat/ui-contexts'; |
||||
import type { UseQueryResult } from '@tanstack/react-query'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
import type { |
||||
ReactElement, |
||||
MutableRefObject, |
||||
SetStateAction, |
||||
Dispatch, |
||||
FormEventHandler, |
||||
Ref, |
||||
MouseEventHandler, |
||||
ForwardedRef, |
||||
} from 'react'; |
||||
import React, { forwardRef, useState, useMemo, useEffect, useRef } from 'react'; |
||||
import type { VirtuosoHandle } from 'react-virtuoso'; |
||||
import { Virtuoso } from 'react-virtuoso'; |
||||
import tinykeys from 'tinykeys'; |
||||
|
||||
import { VirtuosoScrollbars } from '../../components/CustomScrollbars'; |
||||
import { getConfig } from '../../lib/utils/getConfig'; |
||||
import { useAvatarTemplate } from '../hooks/useAvatarTemplate'; |
||||
import { usePreventDefault } from '../hooks/usePreventDefault'; |
||||
import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; |
||||
import Row from './Row'; |
||||
|
||||
const mobileCheck = function () { |
||||
let check = false; |
||||
(function (a: string) { |
||||
if ( |
||||
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( |
||||
a, |
||||
) || |
||||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( |
||||
a.substr(0, 4), |
||||
) |
||||
) |
||||
check = true; |
||||
})(navigator.userAgent || navigator.vendor || window.opera || ''); |
||||
return check; |
||||
}; |
||||
|
||||
declare global { |
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Window { |
||||
opera?: string; |
||||
} |
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Navigator { |
||||
userAgentData?: { |
||||
mobile: boolean; |
||||
}; |
||||
} |
||||
} |
||||
|
||||
const shortcut = ((): string => { |
||||
if (navigator.userAgentData?.mobile || mobileCheck()) { |
||||
return ''; |
||||
} |
||||
if (window.navigator.platform.toLowerCase().includes('mac')) { |
||||
return '(\u2318+K)'; |
||||
} |
||||
return '(Ctrl+K)'; |
||||
})(); |
||||
|
||||
const LIMIT = parseInt(String(getConfig('Sidebar_Search_Spotlight_LIMIT', 20))); |
||||
|
||||
const options = { |
||||
sort: { |
||||
lm: -1, |
||||
name: 1, |
||||
}, |
||||
limit: LIMIT, |
||||
} as const; |
||||
|
||||
const useSearchItems = (filterText: string): UseQueryResult<(ISubscription & IRoom)[] | undefined, Error> => { |
||||
const [, mention, name] = useMemo(() => filterText.match(/(@|#)?(.*)/i) || [], [filterText]); |
||||
const query = useMemo(() => { |
||||
const filterRegex = new RegExp(escapeRegExp(name), 'i'); |
||||
|
||||
return { |
||||
$or: [{ name: filterRegex }, { fname: filterRegex }], |
||||
...(mention && { |
||||
t: mention === '@' ? 'd' : { $ne: 'd' }, |
||||
}), |
||||
}; |
||||
}, [name, mention]); |
||||
|
||||
const localRooms = useUserSubscriptions(query, options); |
||||
|
||||
const usernamesFromClient = [...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean) as string[]; |
||||
|
||||
const searchForChannels = mention === '#'; |
||||
const searchForDMs = mention === '@'; |
||||
|
||||
const type = useMemo(() => { |
||||
if (searchForChannels) { |
||||
return { users: false, rooms: true, includeFederatedRooms: true }; |
||||
} |
||||
if (searchForDMs) { |
||||
return { users: true, rooms: false }; |
||||
} |
||||
return { users: true, rooms: true, includeFederatedRooms: true }; |
||||
}, [searchForChannels, searchForDMs]); |
||||
|
||||
const getSpotlight = useMethod('spotlight'); |
||||
|
||||
return useQuery( |
||||
['sidebar/search/spotlight', name, usernamesFromClient, type, localRooms.map(({ _id, name }) => _id + name)], |
||||
async () => { |
||||
if (localRooms.length === LIMIT) { |
||||
return localRooms; |
||||
} |
||||
|
||||
const spotlight = await getSpotlight(name, usernamesFromClient, type); |
||||
|
||||
const filterUsersUnique = ({ _id }: { _id: string }, index: number, arr: { _id: string }[]): boolean => |
||||
index === arr.findIndex((user) => _id === user._id); |
||||
|
||||
const roomFilter = (room: { t: string; uids?: string[]; _id: string; name?: string }): boolean => |
||||
!localRooms.find( |
||||
(item) => |
||||
(room.t === 'd' && room.uids && room.uids.length > 1 && room.uids?.includes(item._id)) || |
||||
[item.rid, item._id].includes(room._id), |
||||
); |
||||
const usersFilter = (user: { _id: string }): boolean => |
||||
!localRooms.find((room) => room.t === 'd' && room.uids && room.uids?.length === 2 && room.uids.includes(user._id)); |
||||
|
||||
const userMap = (user: { |
||||
_id: string; |
||||
name: string; |
||||
username: string; |
||||
avatarETag?: string; |
||||
}): { |
||||
_id: string; |
||||
t: string; |
||||
name: string; |
||||
fname: string; |
||||
avatarETag?: string; |
||||
} => ({ |
||||
_id: user._id, |
||||
t: 'd', |
||||
name: user.username, |
||||
fname: user.name, |
||||
avatarETag: user.avatarETag, |
||||
}); |
||||
|
||||
type resultsFromServerType = { |
||||
_id: string; |
||||
t: string; |
||||
name: string; |
||||
teamMain?: boolean; |
||||
fname?: string; |
||||
avatarETag?: string | undefined; |
||||
uids?: string[] | undefined; |
||||
}[]; |
||||
|
||||
const resultsFromServer: resultsFromServerType = []; |
||||
resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersFilter).map(userMap)); |
||||
resultsFromServer.push(...spotlight.rooms.filter(roomFilter)); |
||||
|
||||
const exact = resultsFromServer?.filter((item) => [item.name, item.fname].includes(name)); |
||||
return Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])); |
||||
}, |
||||
{ |
||||
staleTime: 60_000, |
||||
keepPreviousData: true, |
||||
placeholderData: localRooms, |
||||
}, |
||||
); |
||||
}; |
||||
|
||||
const useInput = (initial: string): { value: string; onChange: FormEventHandler; setValue: Dispatch<SetStateAction<string>> } => { |
||||
const [value, setValue] = useState(initial); |
||||
const onChange = useMutableCallback((e) => { |
||||
setValue(e.currentTarget.value); |
||||
}); |
||||
return { value, onChange, setValue }; |
||||
}; |
||||
|
||||
const toggleSelectionState = (next: HTMLElement, current: HTMLElement | undefined, input: HTMLElement | undefined): void => { |
||||
input?.setAttribute('aria-activedescendant', next.id); |
||||
next.setAttribute('aria-selected', 'true'); |
||||
next.classList.add('rcx-sidebar-item--selected'); |
||||
if (current) { |
||||
current.removeAttribute('aria-selected'); |
||||
current.classList.remove('rcx-sidebar-item--selected'); |
||||
} |
||||
}; |
||||
|
||||
type SearchListProps = { |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, ref: ForwardedRef<any>) { |
||||
const listId = useUniqueId(); |
||||
const t = useTranslation(); |
||||
const { setValue: setFilterValue, ...filter } = useInput(''); |
||||
|
||||
const cursorRef = useRef<HTMLInputElement>(null); |
||||
const autofocus: Ref<HTMLInputElement> = useMergedRefs(useAutoFocus<HTMLInputElement>(), cursorRef); |
||||
|
||||
const listRef = useRef<VirtuosoHandle>(null); |
||||
const boxRef = useRef<HTMLDivElement>(null); |
||||
|
||||
const selectedElement: MutableRefObject<HTMLElement | null | undefined> = useRef(null); |
||||
const itemIndexRef = useRef(0); |
||||
|
||||
const sidebarViewMode = useUserPreference('sidebarViewMode'); |
||||
const useRealName = useSetting('UI_Use_Real_Name'); |
||||
|
||||
const sideBarItemTemplate = useTemplateByViewMode(); |
||||
const avatarTemplate = useAvatarTemplate(); |
||||
|
||||
const extended = sidebarViewMode === 'extended'; |
||||
|
||||
const filterText = useDebouncedValue(filter.value, 100); |
||||
|
||||
const placeholder = [t('Search'), shortcut].filter(Boolean).join(' '); |
||||
|
||||
const { data: items = [], isLoading } = useSearchItems(filterText); |
||||
|
||||
const itemData = useMemo( |
||||
() => ({ |
||||
items, |
||||
t, |
||||
SideBarItemTemplate: sideBarItemTemplate, |
||||
avatarTemplate, |
||||
useRealName, |
||||
extended, |
||||
sidebarViewMode, |
||||
}), |
||||
[avatarTemplate, extended, items, useRealName, sideBarItemTemplate, sidebarViewMode, t], |
||||
); |
||||
|
||||
const changeSelection = useMutableCallback((dir) => { |
||||
let nextSelectedElement = null; |
||||
|
||||
if (dir === 'up') { |
||||
const potentialElement = selectedElement.current?.parentElement?.previousSibling as HTMLElement; |
||||
if (potentialElement) { |
||||
nextSelectedElement = potentialElement.querySelector('a'); |
||||
} |
||||
} else { |
||||
const potentialElement = selectedElement.current?.parentElement?.nextSibling as HTMLElement; |
||||
if (potentialElement) { |
||||
nextSelectedElement = potentialElement.querySelector('a'); |
||||
} |
||||
} |
||||
|
||||
if (nextSelectedElement) { |
||||
toggleSelectionState(nextSelectedElement, selectedElement.current || undefined, cursorRef?.current || undefined); |
||||
return nextSelectedElement; |
||||
} |
||||
return selectedElement.current; |
||||
}); |
||||
|
||||
const resetCursor = useMutableCallback(() => { |
||||
setTimeout(() => { |
||||
itemIndexRef.current = 0; |
||||
listRef.current?.scrollToIndex({ index: itemIndexRef.current }); |
||||
selectedElement.current = boxRef.current?.querySelector('a.rcx-sidebar-item'); |
||||
if (selectedElement.current) { |
||||
toggleSelectionState(selectedElement.current, undefined, cursorRef?.current || undefined); |
||||
} |
||||
}, 0); |
||||
}); |
||||
|
||||
usePreventDefault(boxRef); |
||||
|
||||
useEffect(() => { |
||||
resetCursor(); |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
resetCursor(); |
||||
}, [filterText, resetCursor]); |
||||
|
||||
useEffect(() => { |
||||
if (!cursorRef?.current) { |
||||
return; |
||||
} |
||||
return tinykeys(cursorRef?.current, { |
||||
Escape: (event) => { |
||||
event.preventDefault(); |
||||
setFilterValue((value) => { |
||||
if (!value) { |
||||
onClose(); |
||||
} |
||||
resetCursor(); |
||||
return ''; |
||||
}); |
||||
}, |
||||
Tab: onClose, |
||||
ArrowUp: () => { |
||||
const currentElement = changeSelection('up'); |
||||
itemIndexRef.current = Math.max(itemIndexRef.current - 1, 0); |
||||
listRef.current?.scrollToIndex({ index: itemIndexRef.current }); |
||||
selectedElement.current = currentElement; |
||||
}, |
||||
ArrowDown: () => { |
||||
const currentElement = changeSelection('down'); |
||||
itemIndexRef.current = Math.min(itemIndexRef.current + 1, items.length + 1); |
||||
listRef.current?.scrollToIndex({ index: itemIndexRef.current }); |
||||
selectedElement.current = currentElement; |
||||
}, |
||||
Enter: (event) => { |
||||
event.preventDefault(); |
||||
if (selectedElement.current && items.length > 0) { |
||||
selectedElement.current.click(); |
||||
} else { |
||||
onClose(); |
||||
} |
||||
}, |
||||
}); |
||||
}, [cursorRef, changeSelection, items.length, onClose, resetCursor, setFilterValue]); |
||||
|
||||
const handleClick: MouseEventHandler<HTMLElement> = (e): void => { |
||||
if (e.target instanceof Element && [e.target.tagName, e.target.parentElement?.tagName].includes('BUTTON')) { |
||||
return; |
||||
} |
||||
return onClose(); |
||||
}; |
||||
|
||||
return ( |
||||
<Box |
||||
position='absolute' |
||||
rcx-sidebar |
||||
h='full' |
||||
display='flex' |
||||
flexDirection='column' |
||||
zIndex={99} |
||||
w='full' |
||||
className={css` |
||||
left: 0; |
||||
top: 0; |
||||
`}
|
||||
ref={ref} |
||||
role='search' |
||||
> |
||||
<Sidebar.TopBar.Section {...({ flexShrink: 0 } as any)} is='form'> |
||||
<TextInput |
||||
aria-owns={listId} |
||||
ref={autofocus} |
||||
{...filter} |
||||
placeholder={placeholder} |
||||
role='searchbox' |
||||
addon={<Icon name='cross' size='x20' onClick={onClose} />} |
||||
/> |
||||
</Sidebar.TopBar.Section> |
||||
<Box |
||||
ref={boxRef} |
||||
role='listbox' |
||||
id={listId} |
||||
tabIndex={-1} |
||||
flexShrink={1} |
||||
h='full' |
||||
w='full' |
||||
aria-live='polite' |
||||
aria-atomic='true' |
||||
aria-busy={isLoading} |
||||
onClick={handleClick} |
||||
> |
||||
<Virtuoso |
||||
style={{ height: '100%', width: '100%' }} |
||||
totalCount={items.length} |
||||
data={items} |
||||
components={{ Scroller: VirtuosoScrollbars }} |
||||
computeItemKey={(_, room) => room._id} |
||||
itemContent={(_, data): ReactElement => <Row data={itemData} item={data} />} |
||||
ref={listRef} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
); |
||||
}); |
||||
|
||||
export default SearchList; |
||||
Loading…
Reference in new issue