@apify/ui-library 0.68.0 → 0.68.1-feattabs-4feb6d.94

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apify/ui-library",
3
- "version": "0.68.0",
3
+ "version": "0.68.1-feattabs-4feb6d.94+0bf4ae961f9",
4
4
  "description": "React UI library used by apify.com",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,6 +28,7 @@
28
28
  "dependencies": {
29
29
  "@apify/ui-icons": "^0.10.0",
30
30
  "@floating-ui/react": "^0.26.2",
31
+ "@react-hook/resize-observer": "^2.0.2",
31
32
  "clsx": "^2.0.0",
32
33
  "dayjs": "1.11.9",
33
34
  "fast-average-color": "^9.4.0",
@@ -65,5 +66,5 @@
65
66
  "typescript": "^5.1.6",
66
67
  "typescript-eslint": "^8.24.0"
67
68
  },
68
- "gitHead": "1ff75e97e8b7ec3b8005449deef90b4b416dc4f1"
69
+ "gitHead": "0bf4ae961f9aee815b791932332847b9864bffd7"
69
70
  }
@@ -9,14 +9,21 @@ import { Box, type MarginSpacingProps, type RegularBoxProps } from './box.js';
9
9
  import { Text } from './text/index.js';
10
10
  import type { SharedTextSize, SharedTextType } from './text/text_shared.js';
11
11
 
12
- type BadgeSize = Exclude<SharedTextSize, 'big'>;
12
+ export type BadgeSize = 'regular' | 'small' | 'extra_small';
13
13
  export const BADGE_SIZES: BadgeSize[] = ['regular', 'small'];
14
14
 
15
15
  const BADGE_ICON_SIZES = {
16
16
  regular: '16',
17
17
  small: '12',
18
+ extra_small: '12',
18
19
  } satisfies Record<BadgeSize, IconSize>;
19
20
 
21
+ const BADGE_TEXT_SIZES = {
22
+ regular: 'regular',
23
+ small: 'small',
24
+ extra_small: 'small',
25
+ } satisfies Record<BadgeSize, SharedTextSize>;
26
+
20
27
  export const BADGE_VARIANTS = ['neutral', 'neutral_muted', 'neutral_subtle', 'primary_black', 'primary_blue', 'success', 'warning', 'danger'] as const;
21
28
  type BadgeVariant = typeof BADGE_VARIANTS[number];
22
29
 
@@ -39,6 +46,11 @@ const badgeSizeStyle = {
39
46
  padding: ${theme.space.space2} ${theme.space.space6};
40
47
  border-radius: ${theme.radius.radius4};
41
48
  `,
49
+ extra_small: css`
50
+ height: 1.6rem;
51
+ padding: ${theme.space.space2} ${theme.space.space4};
52
+ border-radius: ${theme.radius.radius4};
53
+ `,
42
54
  } satisfies Record<BadgeSize, FlattenSimpleInterpolation>;
43
55
 
44
56
  const badgeVariantStyle = {
@@ -127,7 +139,7 @@ export const Badge = forwardRef(
127
139
  {...props}
128
140
  >
129
141
  {LeadingIcon && <LeadingIcon size={BADGE_ICON_SIZES[size]} />}
130
- {children && (<Text size={size} type={type} weight="medium">{children}</Text>)}
142
+ {children && (<Text size={BADGE_TEXT_SIZES[size]} type={type} weight="medium">{children}</Text>)}
131
143
  </StyledBadge>
132
144
  );
133
145
  },
@@ -19,3 +19,4 @@ export * from './image.js';
19
19
  export * from './rating.js';
20
20
  export * from './badge.js';
21
21
  export * from './tag.js';
22
+ export * from './tabs/index.js';
@@ -0,0 +1,2 @@
1
+ export * from './tabs.js';
2
+ export * from './tab.js';
@@ -0,0 +1,195 @@
1
+ import clsx from 'clsx';
2
+ import { createPath } from 'history';
3
+ import React from 'react';
4
+ import type { FlattenSimpleInterpolation } from 'styled-components';
5
+ import styled, { css } from 'styled-components';
6
+
7
+ import type { IconComponent } from '@apify/ui-icons';
8
+
9
+ import { theme } from '../../design_system/theme.js';
10
+ import type { WithTransientProps } from '../../type_utils.js';
11
+ import type { BadgeSize } from '../badge.js';
12
+ import { Badge } from '../badge.js';
13
+ import type { MarginSpacingProps, RegularBoxProps } from '../box.js';
14
+ import type { RegularLinkProps } from '../link.js';
15
+ import { Link } from '../link.js';
16
+ import { Text } from '../text/index.js';
17
+ import type { SharedTextSize } from '../text/text_shared.js';
18
+
19
+ type SharedTabProps = Omit<RegularBoxProps, 'as' | 'onClick'> & MarginSpacingProps & Omit<RegularLinkProps, 'hideExternalIcon' | 'showExternalIcon'>;
20
+
21
+ export type TabVariant = 'default' | 'boxed' | 'buttoned';
22
+
23
+ export type TabData = SharedTabProps & {
24
+ id: string;
25
+ title: string;
26
+ Icon?: IconComponent;
27
+ chip?: number | string;
28
+ rollout?: 'alpha' | 'beta';
29
+ disabled?: boolean;
30
+ };
31
+
32
+ export type TabProps = TabData & {
33
+ variant?: TabVariant;
34
+ active?: boolean;
35
+ onSelect?: (data: { id: string, href: string, event: React.MouseEvent }) => void;
36
+ };
37
+
38
+ const tabVariantTextSize = {
39
+ default: 'regular',
40
+ boxed: 'regular',
41
+ buttoned: 'small',
42
+ } satisfies Record<TabVariant, SharedTextSize>;
43
+
44
+ const tabVariantBadgeSize = {
45
+ default: 'small',
46
+ boxed: 'small',
47
+ buttoned: 'extra_small',
48
+ } satisfies Record<TabVariant, BadgeSize>;
49
+
50
+ const tabVariantStyle = {
51
+ default: css`
52
+ height: 3.6rem;
53
+ padding: 0 ${theme.space.space8};
54
+ color: ${theme.color.neutral.textSubtle};
55
+
56
+ &:hover {
57
+ color: ${theme.color.primaryBlack.actionHover}
58
+ }
59
+
60
+ &.active {
61
+ color: ${theme.color.neutral.text};
62
+
63
+ &::after {
64
+ bottom: -${theme.space.space4};
65
+ right: 0;
66
+ left: 0;
67
+ height: 2px;
68
+ background-color: ${theme.color.primaryBlack.action};
69
+ border-radius: ${theme.radius.radiusFull};
70
+ content: '';
71
+ display: block;
72
+ pointer-events: none;
73
+ position: absolute;
74
+ }
75
+
76
+ &.disabled::after {
77
+ background-color: ${theme.color.neutral.textDisabled};
78
+ }
79
+ }
80
+ `,
81
+ boxed: css`
82
+ height: 3.6rem;
83
+ border: 1px solid transparent;
84
+ border-top-right-radius: ${theme.radius.radius6};
85
+ border-top-left-radius: ${theme.radius.radius6};
86
+ padding: 0 ${theme.space.space12};
87
+ color: ${theme.color.neutral.textMuted};
88
+
89
+ &::after {
90
+ inset: ${theme.space.space4};
91
+ background-color: transparent;
92
+ border-radius: ${theme.radius.radius6};
93
+ content: '';
94
+ display: block;
95
+ pointer-events: none;
96
+ position: absolute;
97
+ transition: background-color ${theme.transition.fastEaseOut};
98
+ z-index: -1;
99
+ }
100
+
101
+ &:hover {
102
+ color: ${theme.color.neutral.text};
103
+
104
+ &::after {
105
+ background-color: ${theme.color.neutral.hover};
106
+ }
107
+ }
108
+
109
+ &.active {
110
+ color: ${theme.color.neutral.text};
111
+ border-color: ${theme.color.neutral.border};
112
+ border-bottom-color: ${theme.color.neutral.background};
113
+
114
+ &::after {
115
+ background-color: transparent;
116
+ }
117
+ }
118
+ `,
119
+ buttoned: css`
120
+ height: 3.2rem;
121
+ margin: 0 ${theme.space.space4};
122
+ padding: 0 ${theme.space.space8};
123
+ background-color: transparent;
124
+ border: 1px solid transparent;
125
+ border-radius: ${theme.radius.radius8};
126
+ color: ${theme.color.neutral.textMuted};
127
+
128
+ &:hover {
129
+ background-color: ${theme.color.neutral.hover};
130
+ color: ${theme.color.neutral.text};
131
+ }
132
+
133
+ &.active {
134
+ background-color: transparent;
135
+ border-color: ${theme.color.neutral.border};
136
+ color: ${theme.color.neutral.text};
137
+ }
138
+ `,
139
+ } satisfies Record<TabVariant, FlattenSimpleInterpolation>;
140
+
141
+ type TabWrapperProps = WithTransientProps<Required<Pick<TabProps, 'variant'>>> & {
142
+ role?: React.AriaRole;
143
+ };
144
+
145
+ const TabWrapper = styled(Link)<TabWrapperProps>`
146
+ display: inline-flex;
147
+ align-items: center;
148
+ justify-content: center;
149
+ gap: ${theme.space.space4};
150
+ cursor: pointer;
151
+ position: relative;
152
+ transition: background-color ${theme.transition.fastEaseOut}, border-color ${theme.transition.fastEaseOut}, color ${theme.transition.fastEaseOut};
153
+ z-index: 1;
154
+
155
+
156
+ ${({ $variant }) => tabVariantStyle[$variant]};
157
+
158
+ &.disabled {
159
+ color: ${theme.color.neutral.textDisabled};
160
+ pointer-events: none;
161
+ }
162
+
163
+ .Tab-icon {
164
+ color: inherit;
165
+ flex-shrink: 0;
166
+ transition: color ${theme.transition.fastEaseOut};
167
+ }
168
+
169
+ .Tab-badge {
170
+ flex-shrink: 0;
171
+ text-transform: uppercase;
172
+ }
173
+ `;
174
+
175
+ export const Tab = ({ variant = 'default', id, to, Icon, title, chip, rollout, className, onSelect, active = false, disabled = false, ...props }: TabProps) => {
176
+ const href = typeof (to) === 'string' ? to : createPath(to);
177
+
178
+ return (
179
+ <TabWrapper
180
+ {...props}
181
+ id={id}
182
+ to={to}
183
+ role="tab"
184
+ data-test="tab"
185
+ className={clsx(className, { active, disabled })}
186
+ onClick={onSelect ? (event) => onSelect({ id, href, event }) : undefined}
187
+ $variant={variant}
188
+ >
189
+ {Icon && <Icon size="16" className="Tab-icon" />}
190
+ <Text size={tabVariantTextSize[variant]} weight="bold" as="div">{title}</Text>
191
+ {chip && <Badge size={tabVariantBadgeSize[variant]} variant={active ? 'primary_black' : 'neutral_subtle'} className="Tab-badge">{chip}</Badge>}
192
+ {rollout && <Badge size={tabVariantBadgeSize[variant]} variant="primary_blue" className="Tab-badge">{rollout}</Badge>}
193
+ </TabWrapper>
194
+ );
195
+ };
@@ -0,0 +1,169 @@
1
+ import useResizeObserver from '@react-hook/resize-observer';
2
+ import React, { useCallback, useRef, useState } from 'react';
3
+ import type { FlattenSimpleInterpolation } from 'styled-components';
4
+ import styled, { css } from 'styled-components';
5
+
6
+ import { theme } from '../../design_system/theme.js';
7
+ import type { WithTransientProps } from '../../type_utils.js';
8
+ import type { MarginSpacingProps, RegularBoxProps } from '../box.js';
9
+ import { Box } from '../box.js';
10
+ import type { TabData, TabVariant } from './tab.js';
11
+ import { Tab } from './tab.js';
12
+
13
+ type SharedTabsProps = Omit<RegularBoxProps, 'as' | 'onClick'> & MarginSpacingProps;
14
+ type TabsProps = SharedTabsProps & {
15
+ variant?: TabVariant;
16
+ tabs: TabData[];
17
+ activeTab?: string;
18
+ onSelect?: (data: { id: string, href: string, event: React.MouseEvent }) => void;
19
+ };
20
+
21
+ const tabsVariantStyle = {
22
+ default: css`
23
+ align-items: flex-start;
24
+ gap: ${theme.space.space8};
25
+
26
+ &::after {
27
+ bottom: 0;
28
+ right: 0;
29
+ left: 0;
30
+ height: 1px;
31
+ background-color: ${theme.color.neutral.separatorSubtle};
32
+ border-radius: ${theme.radius.radiusFull};
33
+ content: '';
34
+ display: block;
35
+ pointer-events: none;
36
+ position: absolute;
37
+ }
38
+ `,
39
+ boxed: css`
40
+ align-items: flex-end;
41
+
42
+ &::after {
43
+ bottom: 0;
44
+ right: 0;
45
+ left: 0;
46
+ height: 1px;
47
+ background-color: ${theme.color.neutral.border};
48
+ content: '';
49
+ display: block;
50
+ pointer-events: none;
51
+ position: absolute;
52
+ }
53
+ `,
54
+ buttoned: css`
55
+ align-items: center;
56
+ `,
57
+ } satisfies Record<TabVariant, FlattenSimpleInterpolation>;
58
+
59
+ type TabsWrapperProps = WithTransientProps<Required<Pick<TabsProps, 'variant'>>> & {
60
+ role?: React.AriaRole;
61
+ };
62
+
63
+ const TabsWrapper = styled(Box)<TabsWrapperProps>`
64
+ height: 4rem;
65
+ display: flex;
66
+ position: relative;
67
+
68
+ [role='tabpanel'] {
69
+ min-width: 0;
70
+ display: flex;
71
+ flex-grow: 1;
72
+ overflow-x: auto;
73
+ ${({ $variant }) => tabsVariantStyle[$variant]};
74
+
75
+ /* Scrollbar */
76
+ -ms-overflow-style: none;
77
+ scrollbar-width: none;
78
+
79
+ &::-webkit-scrollbar {
80
+ display: none; /* Chrome, Safari, Opera */
81
+ }
82
+ }
83
+
84
+ & > [role="cell"] {
85
+ position: sticky;
86
+ top: 0;
87
+ height: 100%;
88
+ width: 0;
89
+ margin-left: auto;
90
+ opacity: 0;
91
+ transition: opacity ${theme.transition.fastEaseOut};
92
+ z-index: 2;
93
+
94
+ &[aria-hidden="false"] {
95
+ opacity: 1;
96
+ }
97
+
98
+ &::after {
99
+ height: 100%;
100
+ width: ${theme.space.space32};
101
+ content: ' ';
102
+ pointer-events: none;
103
+ position: absolute;
104
+ }
105
+
106
+ &:first-of-type {
107
+ left: 0;
108
+
109
+ &::after {
110
+ left: 0;
111
+ background: linear-gradient(90deg, ${theme.color.neutral.background} 0%, rgba(255, 255, 255, 0) 100%);
112
+ }
113
+ }
114
+
115
+ &:last-of-type {
116
+ right: 0;
117
+
118
+ &::after {
119
+ right: 0;
120
+ background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, ${theme.color.neutral.background} 100%);
121
+ }
122
+ }
123
+ }
124
+ `;
125
+
126
+ type TabOverflowState = {
127
+ right: boolean;
128
+ left: boolean;
129
+ }
130
+
131
+ const isTabsOverflowing = (node: HTMLDivElement): TabOverflowState => {
132
+ if (node.scrollWidth > node.clientWidth) {
133
+ return {
134
+ right: node.clientWidth + node.scrollLeft < node.scrollWidth,
135
+ left: node.scrollLeft > 0,
136
+ };
137
+ }
138
+
139
+ return {
140
+ right: false,
141
+ left: false,
142
+ };
143
+ };
144
+
145
+ export const Tabs = ({ variant = 'default', tabs, activeTab, onSelect, ...props }: TabsProps) => {
146
+ const ref = useRef<HTMLDivElement>(null);
147
+ const [overflowState, setOveflowState] = useState<TabOverflowState>();
148
+
149
+ useResizeObserver(ref, (entry) => setOveflowState(isTabsOverflowing(entry.target as HTMLDivElement)));
150
+ const scrollHandler = useCallback((event: React.SyntheticEvent<HTMLDivElement>) => {
151
+ setOveflowState(isTabsOverflowing(event.currentTarget as HTMLDivElement));
152
+ }, []);
153
+
154
+ return <TabsWrapper {...props} role="tablist" data-test="tabs-wrapper" $variant={variant}>
155
+ <div role="cell" aria-hidden={!overflowState?.left} />
156
+ <div ref={ref} role="tabpanel" onScroll={scrollHandler}>
157
+ {tabs.map((tab) => (
158
+ <Tab
159
+ {...tab}
160
+ key={tab.id}
161
+ variant={variant}
162
+ active={tab.id === activeTab}
163
+ onSelect={onSelect}
164
+ />
165
+ ))}
166
+ </div>
167
+ <div role="cell" aria-hidden={!overflowState?.right} />
168
+ </TabsWrapper>;
169
+ };