@apify/ui-library 1.128.0 → 1.129.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apify/ui-library",
3
- "version": "1.128.0",
3
+ "version": "1.129.0",
4
4
  "description": "React UI library used by apify.com",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "It's not nice, but helps us to get around the problem of multiple react instances."
28
28
  ],
29
29
  "dependencies": {
30
- "@apify/ui-icons": "^1.29.2",
30
+ "@apify/ui-icons": "^1.31.0",
31
31
  "@floating-ui/react": "^0.26.2",
32
32
  "@radix-ui/react-checkbox": "^1.3.3",
33
33
  "@radix-ui/react-collapsible": "^1.0.0",
@@ -43,6 +43,7 @@
43
43
  "prismjs": "^1.30.0",
44
44
  "query-string": "^8.1.0",
45
45
  "react-markdown": "^10.1.0",
46
+ "react-select": "^5.10.2",
46
47
  "rehype-raw": "^7.0.0",
47
48
  "rehype-sanitize": "^6.0.0",
48
49
  "remark-gfm": "^4.0.1",
@@ -56,7 +57,7 @@
56
57
  "styled-components": "^6.1.19"
57
58
  },
58
59
  "devDependencies": {
59
- "@storybook/react-vite": "^10.1.9",
60
+ "@storybook/react-vite": "^10.3.1",
60
61
  "@types/hast": "^3.0.4",
61
62
  "@types/lodash": "^4.14.200",
62
63
  "@types/react": "19.2.2",
@@ -69,5 +70,5 @@
69
70
  "src",
70
71
  "style"
71
72
  ],
72
- "gitHead": "8d4338c49c97cd1491d3522d888054e55036328e"
73
+ "gitHead": "87c4357f05dcc81e71f9d2c3c53bed7df0c8877d"
73
74
  }
@@ -4,7 +4,7 @@ import styled from 'styled-components';
4
4
  import { CheckboxPrimitive, IndeterminateCheckbox } from './checkbox.js';
5
5
 
6
6
  export default {
7
- title: 'Components/Inputs/CheckboxPrimitive',
7
+ title: 'UI-Library/Inputs/CheckboxPrimitive',
8
8
  };
9
9
 
10
10
  const Grid = styled.div`
@@ -61,6 +61,7 @@ export const CodeBlockWrapper = styled(SyntaxHighlighterBaseStylesWrapper) <{
61
61
  transform: translateY(1px);
62
62
  padding-right: ${theme.space.space32};
63
63
  padding-left: ${theme.space.space8};
64
+ min-height: fit-content;
64
65
  overflow-x: auto;
65
66
  overflow-y: hidden;
66
67
  gap: ${theme.space.space4};
@@ -28,4 +28,5 @@ export * from './spinner.js';
28
28
  export * from './store/index.js';
29
29
  export * from './checkbox/index.js';
30
30
  export * from './collapsible_card/index.js';
31
+ export * from './select/index.js';
31
32
  export * from './switch/index.js';
@@ -0,0 +1 @@
1
+ export * from './select.js';
@@ -0,0 +1,76 @@
1
+ import { useState } from 'react';
2
+ import styled from 'styled-components';
3
+
4
+ import { EyeIcon } from '@apify/ui-icons';
5
+
6
+ import { SelectPrimitive as Select } from './select.js';
7
+
8
+ export default {
9
+ title: 'UI-Library/Inputs/SelectPrimitive',
10
+ };
11
+
12
+ const Grid = styled.div`
13
+ margin: 5rem 3rem;
14
+ display: grid;
15
+ grid-template-columns: repeat(2, auto);
16
+ gap: 1rem 5rem;
17
+
18
+ input{
19
+ width: 100%;
20
+ }
21
+ `;
22
+
23
+ const options = [{ label: 'Ahoj', value: 1 }, { label: 'Ondro!', value: 2 }, { label: '🚲', value: 3 }];
24
+
25
+ export const SelectPrimitive = () => {
26
+ const [multiValue, setMultiValue] = useState([1, 2]);
27
+ const [value, setValue] = useState(1);
28
+ return (
29
+ <Grid>
30
+ <Select options={options} value={value} setValue={setValue}/> <p>singleValue</p>
31
+ <Select isMulti
32
+ options={options}
33
+ value={multiValue}
34
+ setValue={setMultiValue}
35
+ /> <p>multiValue</p>
36
+ <Select isMulti
37
+ error='test Error'
38
+ options={options}
39
+ value={multiValue}
40
+ setValue={setMultiValue}
41
+ /> <p>multiValue error</p>
42
+ <Select isMulti
43
+ disabled
44
+ options={options}
45
+ value={multiValue}
46
+ setValue={setMultiValue}
47
+ /> <p>multiValue disabled</p>
48
+ <Select options={options}
49
+ value={value}
50
+ setValue={setValue}
51
+ indicatorsSlot={<EyeIcon size="16" />}
52
+ /> <p>withIcons</p>
53
+ <Select options={options}
54
+ value={value}
55
+ setValue={setValue}
56
+ disabled={true}
57
+ /> <p>disabled</p>
58
+ <Select options={options}
59
+ value={value}
60
+ setValue={setValue}
61
+ placeholder="Placeholder"
62
+ /> <p>placeholder</p>
63
+ <Select options={options}
64
+ value={value}
65
+ setValue={setValue}
66
+ error={true}
67
+ placeholder='Error select'
68
+ /> <p>error</p>
69
+ <Select options={options}
70
+ value={null}
71
+ setValue={setValue}
72
+ placeholder='Placeholder'
73
+ /> <p>placeholder</p>
74
+ </Grid>
75
+ );
76
+ };
@@ -0,0 +1,402 @@
1
+ import clsx from 'clsx';
2
+ import { type FC, forwardRef, type ReactNode, useCallback, useMemo } from 'react';
3
+ import type {
4
+ ClearIndicatorProps,
5
+ DropdownIndicatorProps,
6
+ IndicatorsContainerProps,
7
+ MultiValueRemoveProps,
8
+ OptionProps,
9
+ StylesConfig,
10
+ } from 'react-select';
11
+ import SelectComponent, { components as selectComponents, defaultTheme } from 'react-select';
12
+ import styled from 'styled-components';
13
+
14
+ import { CaretDownIcon, CaretUpIcon, CrossIcon, XCircleIcon } from '@apify/ui-icons';
15
+
16
+ import { theme } from '../../design_system/theme.js';
17
+ import { IconButton } from '../icon_button.js';
18
+ import { Text } from '../text/index.js';
19
+
20
+ const DEFAULT_Z_INDEX_IN_MODAL = 99;
21
+ const DEFAULT_Z_INDEX_OUTSIDE_MODAL = 9;
22
+
23
+ export type SelectOptionValue = string | number | boolean;
24
+
25
+ export type SelectOption = {
26
+ label: ReactNode;
27
+ value: SelectOptionValue;
28
+ description?: string;
29
+ };
30
+
31
+ // There is a prop collision in styled-components@6 and react-select. react-select uses `theme` prop but
32
+ // styled components do not pass this prop down. Because we only use the default theme, we can manually inject it.
33
+ // All components that just restyle the default component provided by the package MUST wrap it with this HOC.
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select component props are untyped in this context
35
+ export const withReactSelectTheme = (Component: FC<any>) => {
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ const EnhancedComponent = (props: any) => {
38
+ return (
39
+ <Component {...props} theme={defaultTheme} />
40
+ );
41
+ };
42
+ const displayName = Component.name || (Component as { displayName?: string }).displayName;
43
+ EnhancedComponent.displayName = `withReactSelectTheme${displayName}`;
44
+ return EnhancedComponent;
45
+ };
46
+
47
+ // We need to use both styles and components in order to style select as we want.
48
+ // Sadly, not all items of style receive selectProps that are crucial for determining the right style.
49
+ // On the other hand, if styling for example multiValueLabel through its Component, it receives css classes of its parent
50
+ const selectStyles: StylesConfig<SelectOption> = {
51
+ multiValue: (base) => ({
52
+ ...base,
53
+ backgroundColor: theme.color.neutral.chipBackground,
54
+ borderRadius: 6,
55
+ height: 20,
56
+ alignItems: 'center',
57
+ }),
58
+ multiValueLabel: (base) => ({
59
+ ...base,
60
+ color: theme.color.neutral.text,
61
+ fontSize: 12,
62
+ }),
63
+ multiValueRemove: (base) => ({
64
+ ...base,
65
+ backgroundColor: 'transparent !important',
66
+
67
+ '&:hover svg': {
68
+ color: theme.color.neutral.iconSubtle,
69
+ },
70
+ }),
71
+ dropdownIndicator: (base, { selectProps: { menuIsOpen, isDisabled } }) => ({
72
+ ...base,
73
+ svg: {
74
+ transform: menuIsOpen ? 'rotate(180deg)' : '',
75
+ color: isDisabled ? theme.color.neutral.iconDisabled : theme.color.neutral.icon,
76
+ },
77
+ }),
78
+ valueContainer: (base) => ({
79
+ ...base,
80
+ padding: '2px 6px',
81
+ }),
82
+ menu: (base) => ({
83
+ ...base,
84
+ overflow: 'hidden',
85
+ backgroundColor: theme.color.neutral.background,
86
+ border: `1px solid ${theme.color.neutral.border}`,
87
+ boxShadow: 'none',
88
+ zIndex: 3,
89
+ }),
90
+ option: (base, state) => ({
91
+ ...base,
92
+ color: state.isSelected ? theme.color.neutral.textOnPrimary : theme.color.neutral.text,
93
+ backgroundColor: state.isSelected ? theme.color.primary.action : theme.color.neutral.background,
94
+ '& > p': {
95
+ color: state.isSelected ? theme.color.neutral.textOnPrimary : theme.color.neutral.textSubtle,
96
+ a: {
97
+ color: state.isSelected ? theme.color.neutral.textOnPrimary : theme.color.neutral.textSubtle,
98
+ '&:hover': {
99
+ color: state.isSelected ? theme.color.neutral.textOnPrimary : theme.color.neutral.textSubtle,
100
+ },
101
+ textDecoration: 'underline',
102
+ },
103
+ },
104
+
105
+ '&:hover': {
106
+ backgroundColor: state.isSelected ? undefined : theme.color.neutral.hover,
107
+ },
108
+ }),
109
+ noOptionsMessage: (base) => ({
110
+ ...base,
111
+ color: theme.color.neutral.textSubtle,
112
+ }),
113
+ singleValue: (base, { selectProps: { isDisabled } }) => ({
114
+ ...base,
115
+ color: isDisabled ? theme.color.neutral.textDisabled : theme.color.neutral.text,
116
+ }),
117
+ placeholder: (base) => ({
118
+ ...base,
119
+ color: theme.color.neutral.textPlaceholder,
120
+ fontStyle: 'italic',
121
+ }),
122
+ input: (base) => ({
123
+ ...base,
124
+ color: theme.color.neutral.text,
125
+ }),
126
+ };
127
+
128
+ type GetIsSelectedFn = (
129
+ options: SelectOption[],
130
+ value: SelectOptionValue | SelectOptionValue[] | null | undefined,
131
+ isMulti?: boolean,
132
+ ) => SelectOption | SelectOption[] | null;
133
+
134
+ /* eslint-disable @typescript-eslint/no-explicit-any */
135
+ export type SelectPrimitiveProps = {
136
+ options: SelectOption[];
137
+ value?: SelectOptionValue | SelectOptionValue[] | null;
138
+ setValue: (value: SelectOptionValue | SelectOptionValue[]) => void;
139
+ getIsSelected?: GetIsSelectedFn;
140
+ isMulti?: boolean;
141
+ isClearable?: boolean;
142
+ disabled?: boolean;
143
+ readOnly?: boolean;
144
+ error?: boolean | string;
145
+ components?: Record<string, React.ComponentType<any>>;
146
+ suffix?: string;
147
+ menuMinWidth?: number;
148
+ menuPosition?: 'fixed' | 'absolute';
149
+ className?: string;
150
+ as?: React.ElementType;
151
+ instanceId?: string;
152
+ /**
153
+ * Whether the select is rendered inside a modal. When `true`, the menu position defaults to `'fixed'`
154
+ * and scroll blocking is enabled to prevent the menu from scrolling away in large modals.
155
+ * Also affects the z-index of the menu portal (defaults to 99 inside modal, 9 outside).
156
+ */
157
+ isRenderedInsideModal?: boolean;
158
+ /**
159
+ * Custom z-index for the menu portal. When not provided, defaults to 99 when `isRenderedInsideModal`
160
+ * is `true`, and 9 otherwise.
161
+ */
162
+ menuPortalZIndex?: number;
163
+ // Allow additional props to be passed through to react-select
164
+ [key: string]: any;
165
+ };
166
+ /* eslint-enable @typescript-eslint/no-explicit-any */
167
+
168
+ export const selectDefaultGetIsSelected: GetIsSelectedFn = (options, value, isMulti) => {
169
+ if (!isMulti) return options.find((option) => option.value === value) || null;
170
+ // Map the value array to options while keeping order
171
+ return (value as SelectOptionValue[])
172
+ ?.map((val) => options.find((opt) => opt.value === val))
173
+ .filter(Boolean) as SelectOption[];
174
+ };
175
+
176
+ const StyledIndicatorsContainer = styled(withReactSelectTheme(selectComponents.IndicatorsContainer))`
177
+ & > div{
178
+ padding: 6px !important;
179
+ }
180
+
181
+ .indicator-slot {
182
+ padding: 6px;
183
+ display: flex;
184
+ align-items: center;
185
+
186
+ svg:not(.error-svg){
187
+ color: ${theme.color.neutral.icon};
188
+ }
189
+ }
190
+ `;
191
+
192
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
193
+ type ReactSelectStyledProps = Record<string, any>;
194
+
195
+ const getMenuPortalZIndex = (props: ReactSelectStyledProps) => props.selectProps?.menuPortalZIndex;
196
+ const getMenuPortalMinWidth = (props: ReactSelectStyledProps) => (props.selectProps?.menuMinWidth || 0);
197
+
198
+ const StyledMenuPortal = styled(withReactSelectTheme(selectComponents.MenuPortal))`
199
+ z-index: ${getMenuPortalZIndex} !important;
200
+ min-width: ${getMenuPortalMinWidth}px !important;
201
+ `;
202
+
203
+ const StyledControl = styled(withReactSelectTheme(selectComponents.Control))`
204
+ min-height: 36px !important;
205
+ background-color: ${theme.color.neutral.fieldBackground} !important;
206
+ border: 1px solid ${theme.color.neutral.fieldBorder} !important;
207
+ border-radius: 6px !important;
208
+ transition: border-color 120ms ease-out, background-color 120ms ease-out !important;
209
+ padding: 0 6px;
210
+
211
+ &:hover {
212
+ background-color: ${theme.color.neutral.hover} !important;
213
+ }
214
+
215
+ &:focus-within{
216
+ border-color: ${theme.color.primary.fieldBorderActive} !important;
217
+ background-color: ${theme.color.neutral.background} !important;
218
+ box-shadow: var(--shadow-active) !important;
219
+ }
220
+
221
+ .disabled & {
222
+ border-color: ${theme.color.neutral.disabled} !important;
223
+ background-color: ${theme.color.neutral.disabled} !important;
224
+ }
225
+
226
+ .error & {
227
+ background-color: ${theme.color.danger.backgroundSubtle} !important;
228
+ border: 1px solid ${theme.color.danger.fieldBorder} !important;
229
+
230
+ &:hover {
231
+ background-color: ${theme.color.danger.backgroundSubtleHover};
232
+ border: 1px solid ${theme.color.primary.chipText} !important;
233
+ }
234
+ }
235
+ `;
236
+
237
+ const StyledOption = styled(withReactSelectTheme(selectComponents.Option))`
238
+ ${theme.typography.shared.desktop.bodyM}
239
+ `;
240
+
241
+ const Option: FC<OptionProps<SelectOption>> = ({ children, data: { description }, ...props }) => (
242
+ <StyledOption {...props}>
243
+ {children}
244
+ {description && (
245
+ <Text size="small">{description}</Text>
246
+ )}
247
+ </StyledOption>
248
+ );
249
+
250
+ type SelectIndicatorsContainerProps = IndicatorsContainerProps<SelectOption> & {
251
+ selectProps: { indicator?: ReactNode; suffix?: string };
252
+ };
253
+
254
+ const IndicatorsContainer: FC<SelectIndicatorsContainerProps> = ({
255
+ selectProps: { indicator, suffix }, children, ...props
256
+ }) => {
257
+ return (
258
+ <StyledIndicatorsContainer {...props}>
259
+ {(indicator || suffix) && (
260
+ <div className='indicator-slot'>
261
+ <Text color={theme.color.neutral.textSubtle}>{suffix}</Text>
262
+ {indicator}
263
+ </div>
264
+ )}
265
+ {children}
266
+ </StyledIndicatorsContainer>
267
+ );
268
+ };
269
+
270
+ // TODO JK: Use DropdownIcon
271
+ const DropdownIndicator: FC<DropdownIndicatorProps<SelectOption>> = ({ innerProps, selectProps }) => (
272
+ <IconButton
273
+ Icon={selectProps.menuIsOpen ? CaretUpIcon : CaretDownIcon}
274
+ {...innerProps as React.HTMLAttributes<Element>}
275
+ />
276
+ );
277
+
278
+ const ClearIndicator: FC<ClearIndicatorProps<SelectOption>> = ({ innerProps, selectProps }) => {
279
+ return (
280
+ <IconButton
281
+ Icon={CrossIcon}
282
+ data-test={`${selectProps?.id ?? selectProps?.name}--clear`}
283
+ {...innerProps as React.HTMLAttributes<Element>}
284
+ />
285
+ );
286
+ };
287
+
288
+ const MultiValueRemoveButton = styled(IconButton)`
289
+ height: auto;
290
+ :hover {
291
+ color: ${theme.color.neutral.textSubtle};
292
+ background-color: transparent;
293
+ }
294
+ `;
295
+
296
+ export const SelectMultiValueRemove: FC<Pick<MultiValueRemoveProps<SelectOption>, 'innerProps'>> = ({ innerProps }) => (
297
+ <MultiValueRemoveButton
298
+ Icon={CrossIcon}
299
+ {...innerProps as React.HTMLAttributes<Element>}
300
+ />
301
+ );
302
+
303
+ const IndicatorSeparator = () => null; // we don't display anything
304
+
305
+ /**
306
+ * A context-free select component built on `react-select` with Apify styling.
307
+ *
308
+ * Use the `isRenderedInsideModal` prop to control modal-aware behavior (z-index, menu positioning,
309
+ * scroll blocking). Use `menuPortalZIndex` to override the default z-index values.
310
+ *
311
+ * In the console frontend, prefer using the `Select` wrapper from `primitives/select` which
312
+ * automatically injects these props via `ModalContext`.
313
+ */
314
+ export const SelectPrimitive = forwardRef<unknown, SelectPrimitiveProps>(({
315
+ options,
316
+ value,
317
+ setValue,
318
+ disabled,
319
+ readOnly,
320
+ error,
321
+ suffix,
322
+ className,
323
+ components,
324
+ isMulti,
325
+ isClearable = true,
326
+ menuPosition,
327
+ getIsSelected,
328
+ as,
329
+ isRenderedInsideModal = false,
330
+ menuPortalZIndex,
331
+ ...props
332
+ }, ref) => {
333
+ const selectedOptions = useMemo(
334
+ // this enables to override default selection mechanism (which compares option.value)
335
+ () => (getIsSelected
336
+ ? getIsSelected(options, value, isMulti)
337
+ : selectDefaultGetIsSelected(options, value, isMulti)),
338
+ [getIsSelected, options, value, isMulti],
339
+ );
340
+
341
+ const onSelect = useCallback((selected: SelectOption | SelectOption[] | null) => {
342
+ if (!isMulti) {
343
+ // We use empty string instead of null for empty values. The reason is that SimpleSchema field type
344
+ // is usually string so for initial values, we set '' as initial value.
345
+ // If we select & cancel some value, it should go back to ''
346
+ setValue(selected ? (selected as SelectOption).value : '');
347
+ return;
348
+ }
349
+ setValue((selected as SelectOption[]).map((option) => option.value));
350
+ }, [setValue, isMulti]);
351
+
352
+ const effectiveMenuPosition = menuPosition || (isRenderedInsideModal ? 'fixed' : 'absolute');
353
+ const resolvedMenuPortalZIndex = menuPortalZIndex
354
+ ?? (isRenderedInsideModal ? DEFAULT_Z_INDEX_IN_MODAL : DEFAULT_Z_INDEX_OUTSIDE_MODAL);
355
+
356
+ const indicator = error && <XCircleIcon size="16" color={theme.color.danger.icon} className='error-svg' />;
357
+
358
+ const Component = as || SelectComponent;
359
+
360
+ return (
361
+ <Component
362
+ components={{
363
+ DropdownIndicator,
364
+ ClearIndicator,
365
+ IndicatorSeparator,
366
+ MultiValueRemove: SelectMultiValueRemove, // react-select slot name
367
+ IndicatorsContainer,
368
+ Option,
369
+ Control: StyledControl,
370
+ MenuPortal: StyledMenuPortal,
371
+ ...components,
372
+ }}
373
+ ref={ref}
374
+ openMenuOnFocus
375
+ isDisabled={disabled || readOnly}
376
+ error={error}
377
+ options={options}
378
+ closeMenuOnSelect={!isMulti}
379
+ value={selectedOptions}
380
+ isMulti={isMulti}
381
+ isClearable={isClearable}
382
+ onChange={onSelect}
383
+ isRenderedInsideModal={isRenderedInsideModal}
384
+ // Used by getMenuPortalZIndex fnc of StyledMenuPortal to determine z-index of the menu portal
385
+ menuPortalZIndex={resolvedMenuPortalZIndex}
386
+ menuPosition={effectiveMenuPosition}
387
+ // If someone decides to debug this behavior, 'overflow: auto' on .Modal-content has huge effect on how menu renders
388
+ menuShouldBlockScroll={isRenderedInsideModal} // Otherwise menu scrolls away in big modal
389
+ indicator={indicator}
390
+ suffix={suffix}
391
+ styles={selectStyles}
392
+ {...props}
393
+ className={clsx(
394
+ className,
395
+ error && 'error',
396
+ (disabled || readOnly) && 'disabled',
397
+ )}
398
+ />
399
+ );
400
+ });
401
+
402
+ SelectPrimitive.displayName = 'SelectPrimitive';