Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions statshouse-ui/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default tseslint.config({
'react/no-unescaped-entities': 'off',
'no-console': 'warn',
'no-empty': ['error', { allowEmptyCatch: true }],
'no-empty-pattern': ['error', { allowObjectPatternsAsParameters: true }],
'@typescript-eslint/no-unused-vars': [
'warn',
{
Expand Down
60 changes: 60 additions & 0 deletions statshouse-ui/src/components/UI/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright 2025 V Kontakte LLC
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import cn from 'classnames';
import css from './style.module.css';
import { ReactNode, useEffect, useState } from 'react';
import { Portal } from '@/components/UI/Portal';
import { useRectObserver } from '@/hooks';

const dialogId = 'popper-group';

export type DialogProps = {
open?: boolean;
children?: ReactNode | ((size: { width: number; height: number; maxWidth: number; maxHeight: number }) => ReactNode);
onClose?: () => void;
className?: string;
};

export function Dialog({ children, className, open, onClose }: DialogProps) {
const [wrapper, setWrapper] = useState<HTMLElement | null>(null);
const [targetRect, updateTargetRect] = useRectObserver(wrapper, false, open, false);

useEffect(() => {
updateTargetRect();
}, [open, updateTargetRect]);

useEffect(() => {
if (open) {
document.documentElement.classList.add('modal');
}
return () => {
if (!document.querySelector(`.${css.dialogWrapper}`)) {
document.documentElement.classList.remove('modal');
}
};
}, [open]);

return (
<Portal id={dialogId} className={cn(css.popperGroup)}>
{open && (
<div ref={setWrapper} className={cn(css.dialogWrapper)}>
<div className={css.dialogBackground} onClick={onClose}></div>
<div className={cn(className)}>
{typeof children === 'function'
? children({
height: targetRect.height,
width: targetRect.width,
maxWidth: targetRect.width,
maxHeight: targetRect.height,
})
: children}
</div>
</div>
)}
</Portal>
);
}
37 changes: 37 additions & 0 deletions statshouse-ui/src/components/UI/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2025 V Kontakte LLC
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import { memo } from 'react';
import { Tooltip } from '@/components/UI/Tooltip';
import cn from 'classnames';
import { POPPER_HORIZONTAL, POPPER_VERTICAL } from '@/components/UI/Popper';
import { useStateBoolean } from '@/hooks';

import { DropdownContextProvider } from '@/contexts/DropdownContextProvider';

export type DropdownProps = { className?: string; caption?: React.ReactNode; children?: React.ReactNode };

export const Dropdown = memo(function Dropdown({ className, children, caption }: DropdownProps) {
const [dropdown, setDropdown] = useStateBoolean(false);

return (
<Tooltip
as="button"
type="button"
className={cn(className, 'overflow-auto')}
title={<DropdownContextProvider value={setDropdown}>{children}</DropdownContextProvider>}
open={dropdown}
vertical={POPPER_VERTICAL.outBottom}
horizontal={POPPER_HORIZONTAL.right}
onClick={setDropdown.toggle}
onClickOuter={setDropdown.off}
titleClassName={'p-0 m-0'}
noStyle
>
{caption}
</Tooltip>
);
});
45 changes: 27 additions & 18 deletions statshouse-ui/src/components/UI/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { Popper, POPPER_HORIZONTAL, POPPER_VERTICAL, PopperHorizontal, PopperVertical } from './Popper';
import type { JSX } from 'react/jsx-runtime';
import { TooltipTitleContent } from './TooltipTitleContent';
import { useOnClickOutside } from '@/hooks';
import { useOnClickOutside, useStateToRef } from '@/hooks';

import cn from 'classnames';
import css from './style.module.css';
Expand All @@ -27,6 +27,7 @@ export type TooltipProps<T extends keyof JSX.IntrinsicElements> = {
delay?: number;
delayClose?: number;
onClickOuter?: () => void;
noStyle?: boolean;
} & Omit<JSX.IntrinsicElements[T], 'title'>;

declare function _TooltipFn<T extends keyof JSX.IntrinsicElements>(props: TooltipProps<T>): JSX.Element;
Expand All @@ -52,32 +53,36 @@ export const Tooltip = React.forwardRef<Element, TooltipProps<'div'>>(function T
onMouseOut,
onMouseMove,
onClick,
noStyle,
...props
},
ref
) {
const timeoutDelayRef = useRef<NodeJS.Timeout | null>(null);
const [localRef, setLocalRef] = useState<Element | null>(null);
const [open, setOpen] = useState(false);

const targetRef = useRef<Element | null>(null);
const openRef = useStateToRef(open);
const targetRef = useStateToRef(localRef);

useImperativeHandle<Element | null, Element | null>(ref, () => localRef, [localRef]);

const portalRef = useRef(null);
useOnClickOutside(portalRef, () => {
if (outerOpen == null) {
timeoutDelayRef.current = setTimeout(() => {
setOpen(false);
}, delayClose);
}
onClickOuter?.();
});
const innerRef = useMemo(() => [portalRef, targetRef], [targetRef]);

useEffect(() => {
targetRef.current = localRef;
}, [localRef]);

const [open, setOpen] = useState(false);
useOnClickOutside(
innerRef,
useCallback(() => {
if (outerOpen == null) {
timeoutDelayRef.current = setTimeout(() => {
setOpen(false);
}, delayClose);
}
if (openRef.current) {
onClickOuter?.();
}
}, [delayClose, onClickOuter, openRef, outerOpen])
);

useEffect(() => {
if (outerOpen != null) {
Expand Down Expand Up @@ -163,8 +168,12 @@ export const Tooltip = React.forwardRef<Element, TooltipProps<'div'>>(function T
vertical={vertical}
show={open}
>
<div ref={portalRef} className={cn(titleClassName, 'card overflow-auto')} onClick={stopPropagation}>
<div className="card-body p-1" style={{ minHeight, minWidth, maxHeight, maxWidth }}>
<div
ref={portalRef}
className={cn(titleClassName, !noStyle && 'card overflow-auto')}
onClick={stopPropagation}
>
<div className={cn(!noStyle && 'card-body p-1')} style={{ minHeight, minWidth, maxHeight, maxWidth }}>
<TooltipTitleContent>{title}</TooltipTitleContent>
</div>
</div>
Expand Down
23 changes: 23 additions & 0 deletions statshouse-ui/src/components/UI/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,26 @@
.selectCursor .hoverVisible {
visibility: visible;
}

.dialogWrapper{
position: fixed;
display: flex;
top: 0;
left: 0;
bottom: 0;
right: 0;
pointer-events: auto;
overflow: hidden;
justify-content: center;
align-items: center;
}

.dialogBackground{
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.3);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright 2025 V Kontakte LLC
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import { Button, InputText } from '@/components/UI';
import { Dialog } from '@/components/UI/Dialog';
import { useCallback, useState } from 'react';

export type EditCustomNameDialogProps = {
open?: boolean;
onClose?: () => void;
onChange?: (value?: string) => void;
value: string;
placeholder?: string;
};

export function EditCustomNameDialog({ open, value, placeholder, onClose, onChange }: EditCustomNameDialogProps) {
const [localCustomName, setLocalCustomName] = useState(value || placeholder);

const saveCustomName = useCallback(() => {
onChange?.(localCustomName);
onClose?.();
}, [localCustomName, onChange, onClose]);

return (
<Dialog open={open} onClose={onClose}>
<div className="card">
<div className="card-header">Edit custom name</div>
<div className="card-body">
<div>
<label htmlFor="inputCustomName" className="form-label">
Custom name:
</label>
<InputText
id="inputCustomName"
size={50}
style={{ maxWidth: '80wv' }}
value={localCustomName}
onInput={setLocalCustomName}
placeholder={placeholder}
/>
</div>
</div>
<div className="card-footer d-flex gap-2 justify-content-end">
<Button className="btn btn-outline-primary" type="button" onClick={saveCustomName}>
Save
</Button>
<Button className="btn btn-outline-primary" type="button" onClick={onClose}>
Cancel
</Button>
</div>
</div>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright 2025 V Kontakte LLC
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

export * from './EditCustomNameDialog';
Loading