@alfalab/core-components-navigation-bar 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/Component.js +7 -11
  2. package/components/back-arrow-addon/Component.js +3 -3
  3. package/components/back-arrow-addon/index.css +12 -12
  4. package/components/back-arrow-addon/index.js +1 -1
  5. package/components/closer/Component.js +1 -1
  6. package/components/closer/index.css +5 -5
  7. package/cssm/Component.js +6 -10
  8. package/cssm/components/back-arrow-addon/Component.js +2 -2
  9. package/cssm/components/back-arrow-addon/index.js +1 -1
  10. package/cssm/index.js +2 -1
  11. package/esm/Component.js +3 -7
  12. package/esm/components/back-arrow-addon/Component.js +3 -3
  13. package/esm/components/back-arrow-addon/index.css +12 -12
  14. package/esm/components/back-arrow-addon/index.js +1 -1
  15. package/esm/components/closer/Component.js +1 -1
  16. package/esm/components/closer/index.css +5 -5
  17. package/esm/index.css +27 -27
  18. package/esm/index.js +2 -1
  19. package/index.css +27 -27
  20. package/index.js +2 -1
  21. package/modern/Component.js +3 -7
  22. package/modern/components/back-arrow-addon/Component.js +3 -3
  23. package/modern/components/back-arrow-addon/index.css +12 -12
  24. package/modern/components/back-arrow-addon/index.js +1 -1
  25. package/modern/components/closer/Component.js +1 -1
  26. package/modern/components/closer/index.css +5 -5
  27. package/modern/index.css +27 -27
  28. package/modern/index.js +2 -1
  29. package/package.json +5 -4
  30. package/src/Component.tsx +282 -0
  31. package/src/components/back-arrow-addon/Component.tsx +82 -0
  32. package/src/components/back-arrow-addon/index.module.css +66 -0
  33. package/src/components/back-arrow-addon/index.ts +1 -0
  34. package/src/components/closer/Component.tsx +80 -0
  35. package/src/components/closer/index.module.css +29 -0
  36. package/src/components/closer/index.ts +1 -0
  37. package/src/index.module.css +118 -0
  38. package/src/index.ts +3 -0
  39. package/src/types.ts +137 -0
  40. package/src/vars.css +6 -0
@@ -0,0 +1,282 @@
1
+ /* eslint-disable complexity */
2
+ import React, { forwardRef, useEffect, useRef, useState } from 'react';
3
+ import mergeRefs from 'react-merge-refs';
4
+ import cn from 'classnames';
5
+
6
+ import { getDataTestId } from '@alfalab/core-components-shared';
7
+ import { useLayoutEffect_SAFE_FOR_SSR } from '@alfalab/hooks';
8
+
9
+ import { BackArrowAddon } from './components/back-arrow-addon';
10
+ import { Closer } from './components/closer';
11
+ import type { ContentParams, NavigationBarProps } from './types';
12
+
13
+ import styles from './index.module.css';
14
+
15
+ const ADDONS_HEIGHT = 48;
16
+
17
+ export const NavigationBar = forwardRef<HTMLDivElement, NavigationBarProps>(
18
+ (
19
+ {
20
+ addonClassName,
21
+ className,
22
+ contentClassName,
23
+ closerClassName,
24
+ leftAddons,
25
+ rightAddons,
26
+ bottomAddons,
27
+ bottomAddonsClassName,
28
+ children,
29
+ align = 'left',
30
+ trim = true,
31
+ title,
32
+ titleSize = 'default',
33
+ subtitle,
34
+ hasCloser,
35
+ hasBackButton,
36
+ backButtonClassName,
37
+ dataTestId,
38
+ imageUrl,
39
+ closerIcon,
40
+ onClose,
41
+ view,
42
+ scrollableParentRef,
43
+ sticky,
44
+ onBack,
45
+ },
46
+ ref,
47
+ ) => {
48
+ const [scrollTop, setScrollTop] = useState(0);
49
+ const [titleMargin, setTitleMargin] = useState({ left: 0, right: 0 });
50
+ const bottomContentRef = useRef<HTMLDivElement>(null);
51
+ const headerRef = useRef<HTMLDivElement>(null);
52
+ const mainLinePaddingTopRef = useRef<string>('0px');
53
+ const leftAddonsRef = useRef<HTMLDivElement>(null);
54
+ const rightAddonsRef = useRef<HTMLDivElement>(null);
55
+
56
+ const compactTitle = view === 'mobile' && titleSize === 'compact';
57
+ const hasLeftPart = Boolean(leftAddons || hasBackButton);
58
+ const hasRightPart = Boolean(rightAddons || hasCloser);
59
+ const hasContent = Boolean(title || children);
60
+ const withAnimation = Boolean(view === 'mobile' && hasLeftPart && sticky && !compactTitle);
61
+ const showContentOnTop = hasContent && (compactTitle || !hasLeftPart);
62
+ const showContentOnBot = hasContent && !compactTitle && hasLeftPart;
63
+ const showStaticContentOnTop = !withAnimation && showContentOnTop;
64
+ const showStaticContentOnBot = !withAnimation && showContentOnBot;
65
+ const showAnimatedContentOnTop =
66
+ withAnimation && showContentOnBot && scrollTop > ADDONS_HEIGHT;
67
+ const showAnimatedContentOnBot = withAnimation && showContentOnBot;
68
+ const headerPaddingTop = mainLinePaddingTopRef.current;
69
+
70
+ useLayoutEffect_SAFE_FOR_SSR(() => {
71
+ if (align === 'center' && (showStaticContentOnTop || showAnimatedContentOnTop)) {
72
+ const leftAddonsWidth = leftAddonsRef.current?.offsetWidth || 0;
73
+ const rightAddonsWidth = rightAddonsRef.current?.offsetWidth || 0;
74
+
75
+ const marginSize = Math.abs(rightAddonsWidth - leftAddonsWidth);
76
+ const shouldAddLeftMargin = rightAddonsWidth - leftAddonsWidth > 0;
77
+
78
+ setTitleMargin((prev) => {
79
+ const newState = shouldAddLeftMargin
80
+ ? { left: marginSize, right: 0 }
81
+ : { left: 0, right: marginSize };
82
+
83
+ const isStateChanged =
84
+ prev.left !== newState.left || prev.right !== newState.right;
85
+
86
+ return isStateChanged ? newState : prev;
87
+ });
88
+ }
89
+ }, [
90
+ align,
91
+ showStaticContentOnTop,
92
+ showAnimatedContentOnTop,
93
+ leftAddons,
94
+ rightAddons,
95
+ hasBackButton,
96
+ hasCloser,
97
+ ]);
98
+
99
+ useEffect(() => {
100
+ const parent = scrollableParentRef?.current;
101
+
102
+ const handleScroll = (ev: Event) => {
103
+ const divElement = ev.target as HTMLDivElement;
104
+
105
+ setScrollTop(divElement.scrollTop);
106
+ };
107
+
108
+ if (withAnimation && headerRef.current) {
109
+ mainLinePaddingTopRef.current = getComputedStyle(headerRef.current).paddingTop;
110
+ }
111
+
112
+ if (withAnimation && parent) {
113
+ parent.addEventListener('scroll', handleScroll);
114
+ }
115
+
116
+ return () => parent?.removeEventListener('scroll', handleScroll);
117
+ }, [scrollableParentRef, withAnimation]);
118
+
119
+ const renderBackButton = () => {
120
+ let textOpacity = 1;
121
+
122
+ if (withAnimation) {
123
+ const height = hasContent ? ADDONS_HEIGHT : ADDONS_HEIGHT / 2;
124
+
125
+ textOpacity = Math.max(0, 1 - scrollTop / height);
126
+ } else if (compactTitle) {
127
+ textOpacity = 0;
128
+ }
129
+
130
+ return (
131
+ <div className={cn(styles.addon, backButtonClassName)}>
132
+ <BackArrowAddon
133
+ textOpacity={textOpacity}
134
+ view={view}
135
+ onClick={onBack}
136
+ data-test-id={getDataTestId(dataTestId, 'back-button')}
137
+ />
138
+ </div>
139
+ );
140
+ };
141
+
142
+ const renderContent = (args: ContentParams = {}) => {
143
+ const { extraClassName, wrapperRef, style, hidden } = args;
144
+
145
+ return (
146
+ <div
147
+ style={{ ...style, visibility: hidden ? 'hidden' : 'visible' }}
148
+ ref={wrapperRef}
149
+ className={cn(styles.content, extraClassName, contentClassName, styles[align], {
150
+ [styles.trim]: trim,
151
+ [styles.withCompactTitle]: view === 'mobile' && compactTitle && hasContent,
152
+ })}
153
+ aria-hidden={hidden}
154
+ >
155
+ {children && <div className={styles.children}>{children}</div>}
156
+ {title && (
157
+ <div
158
+ className={styles.title}
159
+ data-test-id={hidden ? undefined : getDataTestId(dataTestId, 'title')}
160
+ >
161
+ {title}
162
+ </div>
163
+ )}
164
+ {compactTitle && subtitle && <div className={styles.subtitle}>{subtitle}</div>}
165
+ </div>
166
+ );
167
+ };
168
+
169
+ const renderCloser = () => (
170
+ <div className={cn(styles.addon, styles.closer, closerClassName)}>
171
+ <Closer
172
+ view={view}
173
+ icon={closerIcon}
174
+ dataTestId={getDataTestId(dataTestId, 'closer')}
175
+ onClose={onClose}
176
+ />
177
+ </div>
178
+ );
179
+
180
+ return (
181
+ <div
182
+ ref={mergeRefs([ref, headerRef])}
183
+ className={cn(styles.header, className, { [styles.backgroundImage]: imageUrl })}
184
+ data-test-id={getDataTestId(dataTestId)}
185
+ style={{
186
+ ...(imageUrl && { backgroundImage: `url(${imageUrl})` }),
187
+ ...(withAnimation &&
188
+ bottomContentRef.current && {
189
+ top: -bottomContentRef.current.scrollHeight,
190
+ }),
191
+ }}
192
+ >
193
+ <div
194
+ className={cn(styles.mainLine, {
195
+ [styles.mainLineSticky]: withAnimation,
196
+ [styles.mainLineWithImageBg]: imageUrl,
197
+ })}
198
+ style={{
199
+ ...(withAnimation
200
+ ? {
201
+ marginTop: `-${headerPaddingTop}`,
202
+ paddingTop: headerPaddingTop,
203
+ }
204
+ : null),
205
+ }}
206
+ >
207
+ {hasLeftPart && (
208
+ <div className={styles.addonsWrapper} ref={leftAddonsRef}>
209
+ {hasBackButton && renderBackButton()}
210
+ {leftAddons && (
211
+ <div className={cn(styles.addon, addonClassName)}>{leftAddons}</div>
212
+ )}
213
+ </div>
214
+ )}
215
+
216
+ {showStaticContentOnTop &&
217
+ renderContent({
218
+ ...(align === 'center'
219
+ ? {
220
+ style: {
221
+ marginLeft: titleMargin.left,
222
+ marginRight: titleMargin.right,
223
+ },
224
+ }
225
+ : null),
226
+ })}
227
+
228
+ {showAnimatedContentOnTop &&
229
+ renderContent({
230
+ extraClassName: styles.withBothAddons,
231
+ style: {
232
+ opacity: Math.min(1, (scrollTop - ADDONS_HEIGHT) / ADDONS_HEIGHT),
233
+ ...(align === 'center'
234
+ ? {
235
+ marginLeft: titleMargin.left,
236
+ marginRight: titleMargin.right,
237
+ }
238
+ : null),
239
+ },
240
+ })}
241
+
242
+ {hasRightPart && (
243
+ <div
244
+ className={cn(styles.addonsWrapper, styles.rightAddons)}
245
+ ref={rightAddonsRef}
246
+ >
247
+ {rightAddons && (
248
+ <div className={cn(styles.addon, addonClassName)}>
249
+ {rightAddons}
250
+ </div>
251
+ )}
252
+
253
+ {hasCloser && renderCloser()}
254
+ </div>
255
+ )}
256
+ </div>
257
+
258
+ {showAnimatedContentOnBot &&
259
+ renderContent({
260
+ wrapperRef: bottomContentRef,
261
+ extraClassName: styles.underAddons,
262
+ style: { opacity: Math.max(0, 1 - scrollTop / ADDONS_HEIGHT) },
263
+ hidden: scrollTop / ADDONS_HEIGHT > 1,
264
+ })}
265
+
266
+ {showStaticContentOnBot &&
267
+ renderContent({
268
+ extraClassName: cn({
269
+ [styles.contentOnBotDesktop]: view === 'desktop',
270
+ [styles.contentOnBotMobile]: view === 'mobile',
271
+ }),
272
+ })}
273
+
274
+ {bottomAddons && (
275
+ <div className={cn(styles.bottomAddons, bottomAddonsClassName)}>
276
+ {bottomAddons}
277
+ </div>
278
+ )}
279
+ </div>
280
+ );
281
+ },
282
+ );
@@ -0,0 +1,82 @@
1
+ import React from 'react';
2
+ import cn from 'classnames';
3
+
4
+ import { ButtonDesktop as Button } from '@alfalab/core-components-button/desktop';
5
+ import { Typography } from '@alfalab/core-components-typography';
6
+ import { ArrowLeftMediumMIcon } from '@alfalab/icons-glyph/ArrowLeftMediumMIcon';
7
+ import { ArrowLeftMIcon } from '@alfalab/icons-glyph/ArrowLeftMIcon';
8
+
9
+ import styles from './index.module.css';
10
+
11
+ export interface BackArrowAddonProps extends React.HTMLAttributes<HTMLButtonElement> {
12
+ /**
13
+ * Текст после иконки
14
+ */
15
+ text?: string;
16
+
17
+ /**
18
+ * Дополнительный класс
19
+ */
20
+ className?: string;
21
+
22
+ /**
23
+ * Вид компонента
24
+ */
25
+ view: 'mobile' | 'desktop';
26
+
27
+ /**
28
+ * Прозрачность текста
29
+ */
30
+ textOpacity?: number;
31
+
32
+ /**
33
+ * Обработчик клика
34
+ */
35
+ onClick?: () => void;
36
+ }
37
+
38
+ export const BackArrowAddon: React.FC<BackArrowAddonProps> = ({
39
+ text = 'Назад',
40
+ onClick,
41
+ className,
42
+ textOpacity = 1,
43
+ view,
44
+ ...htmlAttributes
45
+ }) => {
46
+ const Icon = view === 'desktop' ? ArrowLeftMediumMIcon : ArrowLeftMIcon;
47
+
48
+ return (
49
+ <Button
50
+ view='ghost'
51
+ size={view === 'mobile' ? 'xxs' : 's'}
52
+ onClick={onClick}
53
+ aria-label='назад'
54
+ className={cn(
55
+ styles.component,
56
+ { [styles.mobileComponent]: view === 'mobile' },
57
+ className,
58
+ )}
59
+ {...htmlAttributes}
60
+ >
61
+ <div className={styles.flex}>
62
+ <div
63
+ className={cn(styles.iconWrapper, {
64
+ [styles.mobileWrapper]: view === 'mobile',
65
+ })}
66
+ >
67
+ <Icon />
68
+ </div>
69
+ {textOpacity > 0 && (
70
+ <Typography.Text
71
+ className={styles.text}
72
+ view={view === 'desktop' ? 'primary-large' : 'component'}
73
+ weight='medium'
74
+ style={{ opacity: textOpacity }}
75
+ >
76
+ {text}
77
+ </Typography.Text>
78
+ )}
79
+ </div>
80
+ </Button>
81
+ );
82
+ };
@@ -0,0 +1,66 @@
1
+ @import '@alfalab/core-components-themes/src/default.css';
2
+
3
+ .component {
4
+ height: 100%;
5
+ background: var(--color-light-bg-primary-alpha-40);
6
+ backdrop-filter: blur(10px);
7
+ border-radius: var(--border-radius-pill);
8
+
9
+ & svg > path {
10
+ transition: fill 0.2s ease;
11
+ fill: var(--color-light-graphic-primary);
12
+ }
13
+
14
+ &:hover {
15
+ & svg > path {
16
+ fill: var(--color-light-graphic-primary-tint-20);
17
+ }
18
+ }
19
+
20
+ &:active {
21
+ & svg > path {
22
+ fill: var(--color-light-graphic-primary-tint-30);
23
+ }
24
+ }
25
+ }
26
+
27
+ .mobileComponent {
28
+ height: 32px;
29
+ margin: 0 var(--gap-xs);
30
+ backdrop-filter: none;
31
+ background: none;
32
+ }
33
+
34
+ .flex {
35
+ display: flex;
36
+ align-items: center;
37
+ }
38
+
39
+ .iconWrapper {
40
+ display: inline-flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ height: 48px;
44
+ margin: 0 var(--gap-xs) 0 var(--gap-s);
45
+ border-radius: var(--border-radius-circle);
46
+
47
+ & + .text {
48
+ margin-right: var(--gap-s);
49
+ }
50
+ }
51
+
52
+ .mobileWrapper {
53
+ width: 32px;
54
+ height: 32px;
55
+ background: var(--color-light-specialbg-secondary-transparent);
56
+ backdrop-filter: blur(10px);
57
+ margin: 0;
58
+
59
+ & + .text {
60
+ margin: 0 var(--gap-s) 0 var(--gap-xs);
61
+ }
62
+
63
+ & svg > path {
64
+ fill: var(--color-light-graphic-secondary);
65
+ }
66
+ }
@@ -0,0 +1 @@
1
+ export * from './Component';
@@ -0,0 +1,80 @@
1
+ import React, { ButtonHTMLAttributes, ElementType, FC } from 'react';
2
+ import cn from 'classnames';
3
+
4
+ import { IconButton } from '@alfalab/core-components-icon-button';
5
+ import { CrossHeavyMIcon } from '@alfalab/icons-glyph/CrossHeavyMIcon';
6
+ import { CrossMIcon } from '@alfalab/icons-glyph/CrossMIcon';
7
+
8
+ import styles from './index.module.css';
9
+
10
+ export interface CloserProps extends ButtonHTMLAttributes<HTMLButtonElement> {
11
+ /**
12
+ * Вид компонента
13
+ */
14
+ view: 'desktop' | 'mobile';
15
+
16
+ /**
17
+ * Дополнительный класс
18
+ */
19
+ className?: string;
20
+
21
+ /**
22
+ * Позиция крестика
23
+ */
24
+ align?: 'left' | 'right';
25
+
26
+ /**
27
+ * Фиксирует крестик
28
+ */
29
+ sticky?: boolean;
30
+
31
+ /**
32
+ * Иконка
33
+ */
34
+ icon?: ElementType;
35
+
36
+ /**
37
+ * Идентификатор для систем автоматизированного тестирования
38
+ */
39
+ dataTestId?: string;
40
+
41
+ /**
42
+ * Коллбэк закрытия.
43
+ */
44
+ onClose?: (
45
+ event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>,
46
+ reason?: 'backdropClick' | 'escapeKeyDown' | 'closerClick',
47
+ ) => void;
48
+ }
49
+
50
+ export const Closer: FC<CloserProps> = ({
51
+ view,
52
+ className,
53
+ sticky,
54
+ icon = view === 'desktop' ? CrossHeavyMIcon : CrossMIcon,
55
+ dataTestId,
56
+ onClose,
57
+ ...restProps
58
+ }) => {
59
+ const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
60
+ onClose?.(event, 'closerClick');
61
+ };
62
+
63
+ return (
64
+ <div
65
+ className={cn(styles.closer, className, {
66
+ [styles.sticky]: sticky,
67
+ })}
68
+ >
69
+ <IconButton
70
+ size={view === 'desktop' ? 's' : 'xs'}
71
+ className={cn(styles.button, { [styles.mobile]: view === 'mobile' })}
72
+ aria-label='закрыть'
73
+ onClick={handleClick}
74
+ icon={icon}
75
+ dataTestId={dataTestId}
76
+ {...restProps}
77
+ />
78
+ </div>
79
+ );
80
+ };
@@ -0,0 +1,29 @@
1
+ @import '@alfalab/core-components-themes/src/default.css';
2
+ @import '../../vars.css';
3
+
4
+ .closer {
5
+ flex-shrink: 0;
6
+ width: 48px;
7
+ height: 48px;
8
+ margin-left: auto;
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ }
13
+
14
+ .button {
15
+ background: var(--color-light-bg-primary-alpha-40);
16
+ backdrop-filter: blur(10px);
17
+ border-radius: 50px;
18
+ color: var(--color-light-graphic-primary);
19
+
20
+ &.mobile {
21
+ background: var(--color-light-specialbg-secondary-transparent);
22
+ color: var(--navigation-bar-closer-mobile-color);
23
+ }
24
+ }
25
+
26
+ .sticky {
27
+ position: sticky;
28
+ top: 0;
29
+ }
@@ -0,0 +1 @@
1
+ export * from './Component';
@@ -0,0 +1,118 @@
1
+ @import '@alfalab/core-components-themes/src/default.css';
2
+
3
+ .header {
4
+ width: 100%;
5
+ box-sizing: border-box;
6
+ transition: box-shadow 0.2s ease, background 0.2s ease;
7
+
8
+ &.header.backgroundImage {
9
+ background-repeat: no-repeat;
10
+ background-position: center;
11
+ background-size: cover;
12
+ }
13
+ }
14
+
15
+ .mainLine {
16
+ display: flex;
17
+ align-items: stretch;
18
+ justify-content: space-between;
19
+ z-index: 1;
20
+ background-color: inherit;
21
+ }
22
+
23
+ .mainLineSticky {
24
+ position: sticky;
25
+ top: 0;
26
+ }
27
+
28
+ .mainLineWithImageBg {
29
+ background-color: initial;
30
+ }
31
+
32
+ .content {
33
+ color: var(--color-light-text-primary);
34
+ display: flex;
35
+ flex-flow: column nowrap;
36
+ justify-content: center;
37
+ flex-grow: 1;
38
+ align-self: baseline;
39
+ box-sizing: border-box;
40
+ min-height: 48px;
41
+
42
+ &.withBothAddons,
43
+ &.withCompactTitle {
44
+ @mixin action_component;
45
+ align-self: center;
46
+ padding-top: var(--gap-2xs);
47
+ padding-bottom: var(--gap-2xs);
48
+
49
+ & > .children,
50
+ & > .title {
51
+ -webkit-line-clamp: 1;
52
+ word-break: break-all;
53
+ }
54
+ }
55
+
56
+ &.contentOnBotDesktop.contentOnBotDesktop {
57
+ padding-top: var(--gap-s);
58
+ }
59
+
60
+ &.contentOnBotMobile.contentOnBotMobile {
61
+ padding-top: var(--gap-s);
62
+ }
63
+ }
64
+
65
+ .title {
66
+ word-break: break-word;
67
+ }
68
+
69
+ .subtitle {
70
+ @mixin paragraph_primary_small;
71
+ @mixin row_limit 1;
72
+
73
+ color: var(--color-light-text-secondary);
74
+ word-break: break-all;
75
+ }
76
+
77
+ .addonsWrapper {
78
+ display: flex;
79
+ }
80
+
81
+ .rightAddons {
82
+ margin-left: auto;
83
+ }
84
+
85
+ .addon {
86
+ min-width: 48px;
87
+ height: 48px;
88
+ display: flex;
89
+ justify-content: center;
90
+ align-items: center;
91
+ flex-shrink: 0;
92
+ pointer-events: all;
93
+ }
94
+
95
+ .bottomAddons {
96
+ pointer-events: all;
97
+ }
98
+
99
+ .closer {
100
+ margin-left: auto;
101
+ }
102
+
103
+ .left {
104
+ text-align: left;
105
+ }
106
+
107
+ .center {
108
+ text-align: center;
109
+ }
110
+
111
+ .trim {
112
+ overflow: hidden;
113
+
114
+ & .title,
115
+ & .children {
116
+ @mixin row_limit 2;
117
+ }
118
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './Component';
2
+ export * from './components/closer';
3
+ export type { NavigationBarProps } from './types';