Refactor SelectTree and minor UI fixes

This commit is contained in:
IRBorisov 2024-05-20 17:45:37 +03:00
parent eee96aecd1
commit cfd886e107
8 changed files with 171 additions and 157 deletions

View File

@ -5,7 +5,7 @@ import Button from '../components/ui/Button';
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return ( return (
<div className='flex flex-col items-center antialiased clr-app' role='alert'> <div className='flex flex-col gap-3 items-center antialiased clr-app' role='alert'>
<h1>Что-то пошло не так!</h1> <h1>Что-то пошло не так!</h1>
<Button onClick={resetErrorBoundary} text='Попробовать еще раз' /> <Button onClick={resetErrorBoundary} text='Попробовать еще раз' />
<InfoError error={error as Error} /> <InfoError error={error as Error} />

View File

@ -1,11 +1,9 @@
import axios, { type AxiosError } from 'axios'; import axios, { type AxiosError } from 'axios';
import clsx from 'clsx'; import clsx from 'clsx';
import { external_urls } from '@/utils/constants';
import { isResponseHtml } from '@/utils/utils'; import { isResponseHtml } from '@/utils/utils';
import PrettyJson from '../ui/PrettyJSON'; import PrettyJson from '../ui/PrettyJSON';
import TextURL from '../ui/TextURL';
import AnimateFade from '../wrap/AnimateFade'; import AnimateFade from '../wrap/AnimateFade';
export type ErrorData = string | Error | AxiosError | undefined; export type ErrorData = string | Error | AxiosError | undefined;
@ -70,12 +68,12 @@ function InfoError({ error }: InfoErrorProps) {
'select-text' 'select-text'
)} )}
> >
<p className='font-normal clr-text-default'> <div className='font-normal clr-text-default'>
Пожалуйста сделайте скриншот и отправьте вместе с описанием ситуации на почту{' '} <p>Пожалуйста сделайте скриншот и отправьте вместе с описанием ситуации на почту portal@acconcept.ru</p>
<TextURL href={external_urls.mail_portal} text='portal@acconcept.ru' />
<br /> <br />
Для продолжения работы перезагрузите страницу <p>Для продолжения работы перезагрузите страницу</p>
</p> </div>
<DescribeError error={error} /> <DescribeError error={error} />
</AnimateFade> </AnimateFade>
); );

View File

@ -0,0 +1,112 @@
import clsx from 'clsx';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { animateSideAppear } from '@/styling/animations';
import { globals } from '@/utils/constants';
import { IconDropArrow, IconPageRight } from '../Icons';
import { CProps } from '../props';
import MiniButton from './MiniButton';
import Overlay from './Overlay';
interface SelectTreeProps<ItemType> extends CProps.Styling {
items: ItemType[];
value: ItemType;
setValue: (newItem: ItemType) => void;
getParent: (item: ItemType) => ItemType;
getLabel: (item: ItemType) => string;
getDescription: (item: ItemType) => string;
prefix: string;
}
function SelectTree<ItemType>({
items,
value,
getParent,
getLabel,
getDescription,
setValue,
prefix,
...restProps
}: SelectTreeProps<ItemType>) {
const foldable = useMemo(
() => new Set(items.filter(item => getParent(item) !== item).map(item => getParent(item))),
[items, getParent]
);
const [folded, setFolded] = useState<ItemType[]>(items);
useLayoutEffect(() => {
setFolded(items.filter(item => getParent(value) !== item && getParent(getParent(value)) !== item));
}, [value, getParent, items]);
const onFoldItem = useCallback(
(target: ItemType, showChildren: boolean) => {
setFolded(prev =>
items.filter(item => {
if (item === target) {
return !showChildren;
}
if (!showChildren && (getParent(item) === target || getParent(getParent(item)) === target)) {
return true;
} else {
return prev.includes(item);
}
})
);
},
[items, getParent]
);
const handleClickFold = useCallback(
(event: CProps.EventMouse, target: ItemType, showChildren: boolean) => {
event.preventDefault();
event.stopPropagation();
onFoldItem(target, showChildren);
},
[onFoldItem]
);
return (
<div {...restProps}>
<AnimatePresence initial={false}>
{items.map((item, index) =>
getParent(item) === item || !folded.includes(getParent(item)) ? (
<motion.div
tabIndex={-1}
key={`${prefix}${index}`}
className={clsx(
'pr-3 pl-6 py-1',
'cc-scroll-row',
'clr-controls clr-hover',
'cursor-pointer',
value === item && 'clr-selected'
)}
data-tooltip-id={globals.tooltip}
data-tooltip-content={getDescription(item)}
onClick={() => setValue(item)}
initial={{ ...animateSideAppear.initial }}
animate={{ ...animateSideAppear.animate }}
exit={{ ...animateSideAppear.exit }}
>
{foldable.has(item) ? (
<Overlay position='left-[-1.3rem]' className={clsx(!folded.includes(item) && 'top-[0.1rem]')}>
<MiniButton
tabIndex={-1}
noPadding
noHover
icon={!folded.includes(item) ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />}
onClick={event => handleClickFold(event, item, folded.includes(item))}
/>
</Overlay>
) : null}
{getParent(item) === item ? getLabel(item) : `- ${getLabel(item).toLowerCase()}`}
</motion.div>
) : null
)}
</AnimatePresence>
</div>
);
}
export default SelectTree;

View File

@ -32,7 +32,13 @@ function Tooltip({
delayShow={1000} delayShow={1000}
delayHide={100} delayHide={100}
opacity={0.97} opacity={0.97}
className={clsx('overflow-auto sm:overflow-hidden overscroll-contain', 'border shadow-md', layer, className)} className={clsx(
'overflow-auto sm:overflow-hidden overscroll-contain',
'border shadow-md',
'text-balance',
layer,
className
)}
classNameArrow={layer} classNameArrow={layer}
style={{ ...{ paddingTop: '2px', paddingBottom: '2px' }, ...style }} style={{ ...{ paddingTop: '2px', paddingBottom: '2px' }, ...style }}
variant={darkMode ? 'dark' : 'light'} variant={darkMode ? 'dark' : 'light'}

View File

@ -6,12 +6,13 @@ import { useCallback } from 'react';
import { IconMenuFold, IconMenuUnfold } from '@/components/Icons'; import { IconMenuFold, IconMenuUnfold } from '@/components/Icons';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import SelectTree from '@/components/ui/SelectTree';
import { useConceptOptions } from '@/context/OptionsContext'; import { useConceptOptions } from '@/context/OptionsContext';
import useDropdown from '@/hooks/useDropdown'; import useDropdown from '@/hooks/useDropdown';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic, topicParent } from '@/models/miscellaneous';
import { animateSlideLeft } from '@/styling/animations'; import { animateSlideLeft } from '@/styling/animations';
import { prefixes } from '@/utils/constants';
import TopicsTree from './TopicsTree'; import { describeHelpTopic, labelHelpTopic } from '@/utils/labels';
interface TopicsDropdownProps { interface TopicsDropdownProps {
activeTopic: HelpTopic; activeTopic: HelpTopic;
@ -22,7 +23,7 @@ function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) {
const menu = useDropdown(); const menu = useDropdown();
const { noNavigation, calculateHeight } = useConceptOptions(); const { noNavigation, calculateHeight } = useConceptOptions();
const selectTheme = useCallback( const handleSelectTopic = useCallback(
(topic: HelpTopic) => { (topic: HelpTopic) => {
menu.hide(); menu.hide();
onChangeTopic(topic); onChangeTopic(topic);
@ -65,7 +66,15 @@ function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) {
animate={menu.isOpen ? 'open' : 'closed'} animate={menu.isOpen ? 'open' : 'closed'}
variants={animateSlideLeft} variants={animateSlideLeft}
> >
<TopicsTree activeTopic={activeTopic} onChangeTopic={selectTheme} /> <SelectTree
items={Object.values(HelpTopic).map(item => item as HelpTopic)}
value={activeTopic}
setValue={handleSelectTopic}
prefix={prefixes.topic_list}
getParent={item => topicParent.get(item) ?? item}
getLabel={labelHelpTopic}
getDescription={describeHelpTopic}
/>
</motion.div> </motion.div>
</div> </div>
); );

View File

@ -1,9 +1,10 @@
import clsx from 'clsx'; import clsx from 'clsx';
import SelectTree from '@/components/ui/SelectTree';
import { useConceptOptions } from '@/context/OptionsContext'; import { useConceptOptions } from '@/context/OptionsContext';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic, topicParent } from '@/models/miscellaneous';
import { prefixes } from '@/utils/constants';
import TopicsTree from './TopicsTree'; import { describeHelpTopic, labelHelpTopic } from '@/utils/labels';
interface TopicsStaticProps { interface TopicsStaticProps {
activeTopic: HelpTopic; activeTopic: HelpTopic;
@ -13,7 +14,14 @@ interface TopicsStaticProps {
function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps) { function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps) {
const { calculateHeight } = useConceptOptions(); const { calculateHeight } = useConceptOptions();
return ( return (
<div <SelectTree
items={Object.values(HelpTopic).map(item => item as HelpTopic)}
value={activeTopic}
setValue={onChangeTopic}
prefix={prefixes.topic_list}
getParent={item => topicParent.get(item) ?? item}
getLabel={labelHelpTopic}
getDescription={describeHelpTopic}
className={clsx( className={clsx(
'sticky top-0 left-0', 'sticky top-0 left-0',
'w-[14.5rem] cc-scroll-y', 'w-[14.5rem] cc-scroll-y',
@ -24,9 +32,7 @@ function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps) {
'select-none' 'select-none'
)} )}
style={{ maxHeight: calculateHeight('2.25rem + 2px') }} style={{ maxHeight: calculateHeight('2.25rem + 2px') }}
> />
<TopicsTree activeTopic={activeTopic} onChangeTopic={onChangeTopic} />
</div>
); );
} }

View File

@ -1,127 +0,0 @@
'use client';
import clsx from 'clsx';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useLayoutEffect, useState } from 'react';
import { IconDropArrow, IconPageRight } from '@/components/Icons';
import { CProps } from '@/components/props';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import { foldableTopics, HelpTopic, topicParent } from '@/models/miscellaneous';
import { animateSideAppear } from '@/styling/animations';
import { globals, prefixes } from '@/utils/constants';
import { describeHelpTopic, labelHelpTopic } from '@/utils/labels';
interface TopicsTreeProps {
activeTopic: HelpTopic;
onChangeTopic: (newTopic: HelpTopic) => void;
}
function TopicsTree({ activeTopic, onChangeTopic }: TopicsTreeProps) {
const [topicFolded, setFolded] = useState<Map<HelpTopic, boolean>>(
new Map(
Object.values(HelpTopic).map(value => {
const topic = value as HelpTopic;
return [topic, true];
})
)
);
useLayoutEffect(() => {
setFolded(
new Map(
Object.values(HelpTopic).map(value => {
const topic = value as HelpTopic;
return [
topic,
topicParent.get(activeTopic) !== topic && topicParent.get(topicParent.get(activeTopic)!) !== topic
];
})
)
);
}, [activeTopic]);
const onFoldTopic = useCallback(
(target: HelpTopic, showChildren: boolean) => {
if (topicFolded.get(target) === !showChildren) {
return;
}
setFolded(
new Map(
Object.values(HelpTopic).map(value => {
const topic = value as HelpTopic;
if (topic === target) {
return [topic, !showChildren];
}
if (
!showChildren &&
(topicParent.get(topic) === target || topicParent.get(topicParent.get(topic)!) === target)
) {
return [topic, true];
}
const oldValue = topicFolded.get(topic)!;
return [topic, oldValue];
})
)
);
},
[topicFolded]
);
const handleClickFold = useCallback(
(event: CProps.EventMouse, topic: HelpTopic, showChildren: boolean) => {
event.preventDefault();
event.stopPropagation();
onFoldTopic(topic, showChildren);
},
[onFoldTopic]
);
return (
<AnimatePresence initial={false}>
{Object.values(HelpTopic).map((topic, index) => {
const parent = topicParent.get(topic);
if (parent !== topic && topicFolded.get(topicParent.get(topic)!)) {
return null;
}
const isFoldable = !!foldableTopics.find(id => id === topic);
const isFolded = topicFolded.get(topic)!;
return (
<motion.div
tabIndex={-1}
key={`${prefixes.topic_list}${index}`}
className={clsx(
'pr-3 pl-6 py-1',
'cc-scroll-row',
'clr-controls clr-hover',
'cursor-pointer',
activeTopic === topic && 'clr-selected'
)}
data-tooltip-id={globals.tooltip}
data-tooltip-content={describeHelpTopic(topic)}
onClick={() => onChangeTopic(topic)}
initial={{ ...animateSideAppear.initial }}
animate={{ ...animateSideAppear.animate }}
exit={{ ...animateSideAppear.exit }}
>
{isFoldable ? (
<Overlay position='left-[-1.3rem]' className={clsx(!isFolded && 'top-[0.1rem]')}>
<MiniButton
tabIndex={-1}
noPadding
noHover
icon={!isFolded ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />}
onClick={event => handleClickFold(event, topic, isFolded)}
/>
</Overlay>
) : null}
{topicParent.get(topic) === topic ? labelHelpTopic(topic) : `- ${labelHelpTopic(topic).toLowerCase()}`}
</motion.div>
);
})}
</AnimatePresence>
);
}
export default TopicsTree;

View File

@ -210,22 +210,32 @@ export const animateSideView = {
export const animateSideAppear = { export const animateSideAppear = {
initial: { initial: {
clipPath: 'inset(0% 100% 0% 0%)' height: 0,
opacity: 0
}, },
animate: { animate: {
clipPath: 'inset(0% 0% 0% 0%)', height: 'auto',
opacity: 1,
transition: { transition: {
type: 'spring', height: {
bounce: 0, duration: 0.25
duration: 0.3 },
opacity: {
delay: 0.25,
duration: 0
}
} }
}, },
exit: { exit: {
clipPath: 'inset(0% 100% 0% 0%)', height: 0,
opacity: 0,
transition: { transition: {
type: 'spring', height: {
bounce: 0, duration: 0.25
duration: 0.3 },
opacity: {
duration: 0
}
} }
} }
}; };