From 201510366331ca70b003b1571f2fc23f25e6e5f5 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 10 Nov 2023 15:52:04 +0300 Subject: [PATCH] feat: added selection feature to settings --- src/components/Settings/Selection/context.ts | 33 +++ src/components/Settings/Selection/index.ts | 2 + src/components/Settings/Selection/types.ts | 5 + src/components/Settings/Selection/utils.ts | 21 ++ src/components/Settings/Settings.scss | 16 + src/components/Settings/Settings.tsx | 278 +++++++++++------- .../Settings/__stories__/SettingsDemo.tsx | 36 ++- src/components/Settings/collect-settings.ts | 57 +++- 8 files changed, 338 insertions(+), 110 deletions(-) create mode 100644 src/components/Settings/Selection/context.ts create mode 100644 src/components/Settings/Selection/index.ts create mode 100644 src/components/Settings/Selection/types.ts create mode 100644 src/components/Settings/Selection/utils.ts diff --git a/src/components/Settings/Selection/context.ts b/src/components/Settings/Selection/context.ts new file mode 100644 index 0000000..4d91eac --- /dev/null +++ b/src/components/Settings/Selection/context.ts @@ -0,0 +1,33 @@ +import React from 'react'; +import {SelectedSettingsPart, SettingsPage, getSelectedSettingsPart} from '../collect-settings'; +import {SettingsSelection} from './types'; + +interface ContextValue extends SelectedSettingsPart { + selectedRef?: React.RefObject; +} + +const defaultValue: ContextValue = {}; + +const context = React.createContext(defaultValue); +context.displayName = 'SettingsSelectionContext'; + +export function useSettingsSelectionProviderValue( + pages: Record, + selection: SettingsSelection | undefined, +): ContextValue { + const selectedRef = React.useRef(null); + + const contextValue: ContextValue = React.useMemo(() => { + if (!selection) return {selectedRef}; + + return {selectedRef, ...getSelectedSettingsPart(pages, selection)}; + }, [pages, selection]); + + return contextValue; +} + +export const SettingsSelectionContextProvider = context.Provider; + +export function useSettingsSelectionContext() { + return React.useContext(context); +} diff --git a/src/components/Settings/Selection/index.ts b/src/components/Settings/Selection/index.ts new file mode 100644 index 0000000..58ab7be --- /dev/null +++ b/src/components/Settings/Selection/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './types'; diff --git a/src/components/Settings/Selection/types.ts b/src/components/Settings/Selection/types.ts new file mode 100644 index 0000000..2e8dd61 --- /dev/null +++ b/src/components/Settings/Selection/types.ts @@ -0,0 +1,5 @@ +export interface SettingsSelection { + page?: string; + section?: {id: string} | {title: string}; + settingId?: string; +} diff --git a/src/components/Settings/Selection/utils.ts b/src/components/Settings/Selection/utils.ts new file mode 100644 index 0000000..8df8d2b --- /dev/null +++ b/src/components/Settings/Selection/utils.ts @@ -0,0 +1,21 @@ +import {SelectedSettingsPart, SettingsPageSection} from '../collect-settings'; + +export function isSectionSelected( + selected: SelectedSettingsPart, + pageId: string, + section: SettingsPageSection, +) { + if (!selected.section || selected.setting) { + return false; + } else if (selected.section.id && selected.section.id === section.id) { + return true; + } else if ( + selected.page?.id === pageId && + selected.section.title && + selected.section.title === section.title + ) { + return true; + } else { + return false; + } +} diff --git a/src/components/Settings/Settings.scss b/src/components/Settings/Settings.scss index 5e434b7..2f01798 100644 --- a/src/components/Settings/Settings.scss +++ b/src/components/Settings/Settings.scss @@ -150,6 +150,14 @@ $block: '.#{variables.$ns}settings'; } &__section { + &-right-adornment_hidden { + opacity: 0; + transition: opacity 0.2s; + } + &-heading:hover &-right-adornment_hidden { + opacity: 1; + } + &-heading { @include text-subheader-2; margin: 0; @@ -212,6 +220,14 @@ $block: '.#{variables.$ns}settings'; } } + &__item_selected, + &__section_selected { + background: var(--g-color-base-selection); + padding: 8px; + border-radius: 8px; + margin-left: -8px; + } + &__found { @include text-accent; background: var(--g-color-base-selection); diff --git a/src/components/Settings/Settings.tsx b/src/components/Settings/Settings.tsx index 76a01ea..0cc9460 100644 --- a/src/components/Settings/Settings.tsx +++ b/src/components/Settings/Settings.tsx @@ -9,11 +9,22 @@ import {SettingsMenuMobile} from './SettingsMenuMobile/SettingsMenuMobile'; import {Title} from '../Title'; import i18n from './i18n'; -import type {SettingsMenu as SettingsMenuType} from './collect-settings'; +import type { + SettingsItem, + SettingsMenu as SettingsMenuType, + SettingsPageSection, +} from './collect-settings'; import {getSettingsFromChildren} from './collect-settings'; import {escapeStringForRegExp} from './helpers'; import './Settings.scss'; +import { + SettingsSelectionContextProvider, + useSettingsSelectionContext, + useSettingsSelectionProviderValue, +} from './Selection/context'; +import {SettingsSelection} from './Selection'; +import {isSectionSelected} from './Selection/utils'; const b = block('settings'); @@ -24,13 +35,15 @@ export interface SettingsProps { emptyPlaceholder?: string; initialPage?: string; initialSearch?: string; + selection?: SettingsSelection; onPageChange?: (page: string | undefined) => void; renderNotFound?: () => React.ReactNode; renderLoading?: () => React.ReactNode; loading?: boolean; view?: 'normal' | 'mobile'; onClose?: () => void; - renderRightAdornment?: (item: Pick) => React.ReactNode; + renderRightAdornment?: (item: SettingsItemProps) => React.ReactNode; + renderSectionRightAdornment?: (section: SettingsPageSection) => React.ReactNode; showRightAdornmentOnHover?: boolean; } @@ -48,6 +61,7 @@ export interface SettingsPageProps { } export interface SettingsSectionProps { + id?: string; title: string; header?: React.ReactNode; children: React.ReactNode; @@ -56,6 +70,7 @@ export interface SettingsSectionProps { } export interface SettingsItemProps { + id?: string; title: string; highlightedTitle?: React.ReactNode | null; renderTitleComponent?: (highlightedTitle: React.ReactNode | null) => React.ReactNode; @@ -67,7 +82,10 @@ export interface SettingsItemProps { } export interface SettingsContextType - extends Pick {} + extends Pick< + SettingsProps, + 'renderRightAdornment' | 'renderSectionRightAdornment' | 'showRightAdornmentOnHover' + > {} const SettingsContext = React.createContext({}); @@ -79,6 +97,7 @@ export function Settings({ children, view = 'normal', renderRightAdornment, + renderSectionRightAdornment, showRightAdornmentOnHover = true, ...props }: SettingsProps) { @@ -95,7 +114,9 @@ export function Settings({ } return ( - + {children} @@ -118,6 +139,7 @@ type SettingsContentProps = Omit; function SettingsContent({ initialPage, initialSearch, + selection, children, renderNotFound, title = i18n('label_title'), @@ -127,11 +149,19 @@ function SettingsContent({ onPageChange, onClose, }: SettingsContentProps) { + const {renderSectionRightAdornment, showRightAdornmentOnHover} = useSettingsContext(); + const [search, setSearch] = React.useState(initialSearch ?? ''); const {menu, pages} = getSettingsFromChildren(children, search); + + const selected = useSettingsSelectionProviderValue(pages, selection); + const pageKeys = Object.keys(pages); + const selectionInitialPage = + selected.page && pageKeys.includes(selected.page.id) ? selected.page.id : undefined; const [selectedPage, setCurrentPage] = React.useState( - initialPage && pageKeys.includes(initialPage) ? initialPage : undefined, + selectionInitialPage || + (initialPage && pageKeys.includes(initialPage) ? initialPage : undefined), ); const searchInputRef = React.useRef(null); const menuRef = React.useRef(null); @@ -171,8 +201,71 @@ function SettingsContent({ } }); - const renderPageContent = () => { - if (!activePage) { + React.useEffect(() => { + if (!selectionInitialPage) return; + setCurrentPage(selectionInitialPage); + }, [selectionInitialPage]); + + React.useEffect(() => { + if (!selected.selectedRef?.current) return; + + selected.selectedRef.current.scrollIntoView(); + }, [selected.selectedRef]); + + const renderSetting = ({title: settingTitle, element}: SettingsItem) => { + return ( +
+ {React.cloneElement(element, { + ...element.props, + highlightedTitle: + search && settingTitle ? prepareTitle(settingTitle, search) : settingTitle, + })} +
+ ); + }; + + const renderSection = (page: string, section: SettingsPageSection) => { + const isSelected = isSectionSelected(selected, page, section); + + return ( +
+ {section.showTitle && ( +

+ {renderSectionRightAdornment ? ( + + {section.title} +
+ {renderSectionRightAdornment(section)} +
+
+ ) : ( + section.title + )} +

+ )} + + {section.header && + (isMobile ? ( +
{section.header}
+ ) : ( + section.header + ))} + + {section.items.map((setting) => (setting.hidden ? null : renderSetting(setting)))} +
+ ); + }; + + const renderPageContent = (page: string | undefined) => { + if (!page) { return typeof renderNotFound === 'function' ? ( renderNotFound() ) : ( @@ -180,104 +273,79 @@ function SettingsContent({ ); } - const filteredSections = pages[activePage].sections.filter((section) => !section.hidden); + const filteredSections = pages[page].sections.filter((section) => !section.hidden); return ( <> {!isMobile && ( - {getPageTitleById(menu, activePage)} + {getPageTitleById(menu, page)} )}
- {filteredSections.map((section) => ( -
- {section.showTitle && ( -

{section.title}

- )} - - {section.header && - (isMobile ? ( -
{section.header}
- ) : ( - section.header - ))} - - {section.items.map(({hidden, title, element}) => - hidden ? null : ( -
- {React.cloneElement(element, { - ...element.props, - highlightedTitle: - search && title - ? prepareTitle(title, search) - : title, - })} -
- ), - )} -
- ))} + {filteredSections.map((section) => renderSection(page, section))}
); }; return ( -
- {isMobile ? ( - <> - - - - ) : ( -
{ - if (searchInputRef.current) { - searchInputRef.current.focus(); - } - }} - onKeyDown={(event) => { - if (menuRef.current) { - if (menuRef.current.handleKeyDown(event)) { - event.preventDefault(); + +
+ {isMobile ? ( + <> + + + + ) : ( +
{ + if (searchInputRef.current) { + searchInputRef.current.focus(); } - } - }} - > - {title} - - -
- )} -
{renderPageContent()}
-
+ }} + onKeyDown={(event) => { + if (menuRef.current) { + if (menuRef.current.handleKeyDown(event)) { + event.preventDefault(); + } + } + }} + > + {title} + + +
+ )} +
{renderPageContent(activePage)}
+
+ ); } @@ -293,16 +361,21 @@ Settings.Section = function SettingsSection({children}: SettingsSectionProps) { return {children}; }; -Settings.Item = function SettingsItem({ - title, - highlightedTitle, - children, - align = 'center', - withBadge, - renderTitleComponent = identity, - mode, - description, -}: SettingsItemProps) { +Settings.Item = function SettingsItem(setting: SettingsItemProps) { + const { + id, + highlightedTitle, + children, + align = 'center', + withBadge, + renderTitleComponent = identity, + mode, + description, + } = setting; + + const selected = useSettingsSelectionContext(); + const isSettingSelected = selected.setting && selected.setting.id === id; + const {renderRightAdornment, showRightAdornmentOnHover} = useSettingsContext(); const titleNode = ( @@ -310,7 +383,10 @@ Settings.Item = function SettingsItem({ ); return ( -
+
) : ( diff --git a/src/components/Settings/__stories__/SettingsDemo.tsx b/src/components/Settings/__stories__/SettingsDemo.tsx index 22db9c2..902bd38 100644 --- a/src/components/Settings/__stories__/SettingsDemo.tsx +++ b/src/components/Settings/__stories__/SettingsDemo.tsx @@ -2,12 +2,13 @@ import React, {useReducer} from 'react'; import {Settings} from '../index'; import {HelpPopover} from '@gravity-ui/components'; -import {Button, Switch, Checkbox, RadioButton, Radio, Select} from '@gravity-ui/uikit'; +import {Button, Switch, Checkbox, RadioButton, Radio, Select, Link} from '@gravity-ui/uikit'; import featureIcon from '../../../../assets/icons/gear.svg'; import {cn} from '../../utils/cn'; import './SettingsDemo.scss'; +import {SettingsSelection} from '../Selection/types'; export interface DemoProps { title: string; @@ -60,17 +61,25 @@ export const SettingsComponent = React.memo( const handleChange = (name: string, value: any) => { dispatch(setSetting(name, value)); }; + + const [selection, setSelection] = React.useState(undefined); + return ( { console.log({page}); + setSelection(undefined); }} onClose={onClose} renderRightAdornment={({title}) => ( )} + renderSectionRightAdornment={({title}) => ( + + )} showRightAdornmentOnHover={true} // true by default + selection={selection} > @@ -91,8 +100,30 @@ export const SettingsComponent = React.memo( }} /> + + + setSelection({settingId: 'arcanum-theme-setting'}) + } + > + Go to «Arcanum/Appearance/Appearance/Theme» + + + + + setSelection({section: {id: 'arcanum-common-section'}}) + } + > + Go to «Arcanum/Features/Common» + + - + (
diff --git a/src/components/Settings/collect-settings.ts b/src/components/Settings/collect-settings.ts index b49ce2e..6c4e0bf 100644 --- a/src/components/Settings/collect-settings.ts +++ b/src/components/Settings/collect-settings.ts @@ -1,6 +1,7 @@ import React from 'react'; import {IconProps} from '@gravity-ui/uikit'; import {escapeStringForRegExp, invariant} from './helpers'; +import {SettingsSelection} from './Selection/types'; export type SettingsMenu = (SettingsMenuGroup | SettingsMenuItem)[]; @@ -24,7 +25,8 @@ export interface SettingsPage { withBadge?: boolean; } -interface SettingsPageSection { +export interface SettingsPageSection { + id?: string; title: string; header?: React.ReactNode; items: SettingsItem[]; @@ -33,7 +35,8 @@ interface SettingsPageSection { showTitle?: boolean; } -interface SettingsItem { +export interface SettingsItem { + id?: string; title: string; element: React.ReactElement; hidden: boolean; @@ -41,6 +44,12 @@ interface SettingsItem { renderTitleComponent?: (highlightedTitle: React.ReactNode | null) => React.ReactNode; } +export interface SelectedSettingsPart { + page?: SettingsPage; + section?: SettingsPageSection; + setting?: SettingsItem; +} + interface SettingsDescription { menu: SettingsMenu; pages: Record; @@ -148,13 +157,12 @@ function getSettingsPageFromChildren(children: React.ReactNode, filterRe: RegExp page.withBadge = withBadge || page.withBadge; page.hidden = hidden && page.hidden; } else { - const {title, header, withBadge, showTitle = true} = element.props; + const {withBadge, showTitle = true} = element.props; const {items, hidden} = getSettingsItemsFromChildren(element.props.children, filterRe); page.withBadge = withBadge || page.withBadge; page.hidden = hidden && page.hidden; page.sections.push({ - title, - header, + ...element.props, withBadge, items, hidden, @@ -180,8 +188,7 @@ function getSettingsItemsFromChildren(children: React.ReactNode, filterRe: RegEx hidden = hidden && fragmentItems.hidden; } else { const item: SettingsItem = { - title: element.props.title, - renderTitleComponent: element.props.renderTitleComponent, + ...element.props, element, hidden: !filterRe.test(element.props.title), }; @@ -191,3 +198,39 @@ function getSettingsItemsFromChildren(children: React.ReactNode, filterRe: RegEx }); return {items, hidden}; } + +export function getSelectedSettingsPart( + pages: Record, + selection: SettingsSelection, +): SelectedSettingsPart { + if (!selection.settingId && !selection.section && !selection.page) { + return {}; + } + + for (const page of Object.values(pages)) { + if (!selection.settingId && !selection.section) { + if (selection.page !== page.id) continue; + + return {page}; + } + + for (const section of page.sections) { + if (selection.settingId) { + for (const setting of section.items) { + if (setting.id === selection.settingId) { + return {page, section, setting}; + } + } + } else if ( + selection.section && + ('id' in selection.section + ? selection.section.id === section.id + : selection.section.title === section.title) + ) { + return {page, section}; + } + } + } + + return {}; +}