@bitrise/bitkit 10.6.0 → 10.9.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.
@@ -0,0 +1,101 @@
1
+ const DropdownTheme = {
2
+ baseStyle: ({ disabled }: { disabled?: boolean }) => ({
3
+ field: {
4
+ bgGradient: 'linear(to-b, neutral.100, neutral.93)',
5
+ border: '0.0625rem solid',
6
+ borderColor: 'neutral.90',
7
+ borderRadius: '4',
8
+ color: disabled ? 'neutral.70' : 'purple.10',
9
+ position: 'relative',
10
+ display: 'flex',
11
+ alignItems: 'center',
12
+ textAlign: 'start',
13
+ width: '100%',
14
+ _hover: disabled
15
+ ? undefined
16
+ : {
17
+ bgGradient: 'linear(to-b, neutral.93, neutral.93)',
18
+ },
19
+ _active: disabled
20
+ ? undefined
21
+ : {
22
+ bgGradient: 'linear(to-b, neutral.90, neutral.90)',
23
+ borderColor: 'neutral.80',
24
+ },
25
+ pointerEvents: disabled && 'none',
26
+ },
27
+ icon: {
28
+ opacity: disabled && '0.2',
29
+ },
30
+ search: {
31
+ mx: 16,
32
+ marginTop: '12',
33
+ width: 'auto',
34
+ },
35
+ group: {
36
+ display: 'flex',
37
+ flexDir: 'row',
38
+ gap: '12',
39
+ paddingY: '8',
40
+ paddingX: '16',
41
+ alignItems: 'center',
42
+ },
43
+ item: {
44
+ cursor: 'pointer',
45
+ userSelect: 'none',
46
+ color: 'purple.10',
47
+ paddingY: '8',
48
+ paddingX: '16',
49
+ textAlign: 'start',
50
+ w: '100%',
51
+ '&.bitkit-select__option-hover': {
52
+ backgroundColor: 'neutral.95',
53
+ },
54
+ '&.bitkit-select__option-active': {
55
+ backgroundColor: 'purple.95',
56
+ '&.bitkit-select__option-hover': {
57
+ backgroundColor: 'purple.93',
58
+ },
59
+ },
60
+ },
61
+ options: {
62
+ paddingY: '12',
63
+ overflowY: 'auto',
64
+ flexShrink: 1,
65
+ position: 'relative',
66
+ },
67
+ list: {
68
+ backgroundColor: 'neutral.100',
69
+ border: '1px solid',
70
+ borderColor: 'neutral.93',
71
+ borderRadius: '4',
72
+ boxShadow: 'large',
73
+ zIndex: '1',
74
+ display: 'flex',
75
+ minH: 0,
76
+ maxH: 'min(calc(2.5rem * 6 + 1.5rem), var(--floating-available-height))',
77
+ flexShrink: 1,
78
+ flexDir: 'column',
79
+ },
80
+ }),
81
+ sizes: {
82
+ small: {
83
+ field: {
84
+ fontSize: '2',
85
+ height: '32',
86
+ paddingLeft: '12',
87
+ paddingRight: '12',
88
+ },
89
+ },
90
+ medium: {
91
+ field: {
92
+ fontSize: '3',
93
+ height: '48',
94
+ paddingLeft: '16',
95
+ paddingRight: '16',
96
+ },
97
+ },
98
+ },
99
+ };
100
+
101
+ export default DropdownTheme;
@@ -0,0 +1,306 @@
1
+ import React, {
2
+ cloneElement,
3
+ forwardRef,
4
+ ReactNode,
5
+ useCallback,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
12
+ import {
13
+ chakra,
14
+ ChakraProps,
15
+ Input,
16
+ InputGroup,
17
+ InputLeftElement,
18
+ useControllableState,
19
+ useMultiStyleConfig,
20
+ } from '@chakra-ui/react';
21
+ import { FloatingFocusManager } from '@floating-ui/react-dom-interactions';
22
+ import Icon from '../Icon/Icon';
23
+ import { DropdownEventArgs, DropdownProvider, useDropdownContext, useDropdownStyles } from './Dropdown.context';
24
+ import { DropdownOption, DropdownGroup, DropdownDetailedOption } from './DropdownOption';
25
+ import DropdownButton from './DropdownButton';
26
+ import useFloatingDropdown from './hooks/useFloatingDropdown';
27
+ import { useSimpleSearch, NoResultsFound } from './hooks/useSimpleSearch';
28
+ import { isSearchable } from './isNodeMatch';
29
+
30
+ type DropdownSearchProps =
31
+ | { placeholder?: string }
32
+ | {
33
+ placeholder?: string;
34
+ value: string;
35
+ onChange: (newValue: string) => void;
36
+ };
37
+ const DropdownSearch = ({ placeholder = 'Start typing to filter options', ...rest }: DropdownSearchProps) => {
38
+ const { search } = useDropdownStyles();
39
+ const { searchValue, searchOnSubmit, searchOnChange, searchRef } = useDropdownContext();
40
+ const { value, onChange } = 'onChange' in rest ? rest : { value: searchValue, onChange: searchOnChange };
41
+
42
+ const onChangeCB = useCallback(
43
+ (ev: React.ChangeEvent<HTMLInputElement>) => {
44
+ onChange(ev.target.value);
45
+ },
46
+ [onChange],
47
+ );
48
+ const onKeyDown = useCallback(
49
+ (ev: React.KeyboardEvent) => {
50
+ if (ev.key === 'Enter') {
51
+ ev.preventDefault();
52
+ searchOnSubmit();
53
+ }
54
+ },
55
+ [searchOnSubmit],
56
+ );
57
+ return (
58
+ <InputGroup sx={search}>
59
+ <InputLeftElement pointerEvents="none">
60
+ <Icon aria-hidden color="neutral.60" size="24" name="Magnifier" />
61
+ </InputLeftElement>
62
+ <Input
63
+ onKeyDown={onKeyDown}
64
+ ref={searchRef}
65
+ role="search"
66
+ aria-label={placeholder}
67
+ width="100%"
68
+ placeholder={placeholder}
69
+ value={value}
70
+ onChange={onChangeCB}
71
+ />
72
+ </InputGroup>
73
+ );
74
+ };
75
+ export { DropdownOption, DropdownGroup, DropdownSearch, NoResultsFound, DropdownDetailedOption };
76
+
77
+ type DropdownInstance = { value: string; name?: string };
78
+ type DropdownChangeEventHandler = (ev: { target: DropdownInstance }) => void;
79
+ export interface DropdownProps extends ChakraProps {
80
+ name?: string;
81
+ onChange?: DropdownChangeEventHandler;
82
+ onBlur?: DropdownChangeEventHandler;
83
+ value?: string;
84
+ defaultValue?: string;
85
+ size?: 'small' | 'medium';
86
+ readOnly?: boolean;
87
+ disabled?: boolean;
88
+ placeholder?: string;
89
+ 'aria-label'?: string;
90
+ search?: ReactNode;
91
+ children?: ReactNode;
92
+ }
93
+
94
+ function useOptionListWithRefs({
95
+ children,
96
+ listRef,
97
+ getItemProps,
98
+ }: {
99
+ children: ReactNode;
100
+ listRef: React.MutableRefObject<(HTMLElement | null)[]>;
101
+ getItemProps: (userProps?: React.HTMLProps<HTMLElement> | undefined) => Record<string, unknown>;
102
+ }) {
103
+ return useMemo(() => {
104
+ const childList = React.Children.toArray(children);
105
+ let idx = 0;
106
+ const transform = (ch: ReactNode): ReactNode => {
107
+ if (React.isValidElement(ch)) {
108
+ if (ch.type === DropdownOption) {
109
+ const index = idx;
110
+ idx += 1;
111
+ return cloneElement(ch, {
112
+ ...getItemProps({
113
+ ...ch.props,
114
+ index,
115
+ ref(node) {
116
+ listRef.current[index] = node;
117
+ },
118
+ }),
119
+ });
120
+ }
121
+ if ('children' in ch.props) {
122
+ return cloneElement(ch, {
123
+ ...ch.props,
124
+ children: React.Children.toArray(ch.props.children).map(transform),
125
+ });
126
+ }
127
+ }
128
+ return ch;
129
+ };
130
+ return childList.map(transform);
131
+ }, [children]);
132
+ }
133
+
134
+ type UseDropdownProps = {
135
+ ref: React.Ref<DropdownInstance>;
136
+ optionsRef: React.RefObject<HTMLDivElement>;
137
+ };
138
+
139
+ function findOption(children: ReactNode, value: string): { label: ReactNode; index: number } | null {
140
+ const list = React.Children.toArray(children);
141
+ for (let i = 0; i < list.length; i++) {
142
+ const elem = list[i];
143
+ if (React.isValidElement(elem)) {
144
+ if (elem.type === DropdownOption && (elem.props.value || '') === value) {
145
+ return { label: elem.props.children, index: elem.props.index };
146
+ }
147
+ const ch =
148
+ findOption(elem.props.children, value) || (isSearchable(elem.type) && findOption(elem.type(elem.props), value));
149
+ if (ch) {
150
+ return ch;
151
+ }
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+
157
+ function useDropdown({
158
+ name,
159
+ onChange,
160
+ value,
161
+ optionsRef,
162
+ defaultValue,
163
+ ref,
164
+ children,
165
+ readOnly,
166
+ ...rest
167
+ }: DropdownProps & UseDropdownProps) {
168
+ const searchRef = useRef(null);
169
+ const {
170
+ onClose,
171
+ isOpen,
172
+ referenceProps,
173
+ floatingProps,
174
+ context: floatingContext,
175
+ activeIndex,
176
+ setSelectedIndex,
177
+ setActiveIndex,
178
+ getItemProps,
179
+ listRef,
180
+ } = useFloatingDropdown({ enabled: !readOnly, optionsRef });
181
+ const [formValue, setFormValue] = useControllableState({
182
+ onChange: (newValue) => onChange?.({ target: { value: newValue, name } }),
183
+ defaultValue,
184
+ value,
185
+ });
186
+
187
+ const [formLabel, setFormLabel] = useState<ReactNode>();
188
+ useImperativeHandle(ref, () => ({ value: formValue, name }), [formValue, name]);
189
+ const refdChildren = useOptionListWithRefs({ children, listRef, getItemProps });
190
+
191
+ const searchOnSubmit = useCallback(() => {
192
+ if (activeIndex !== null) {
193
+ listRef.current[activeIndex]?.click();
194
+ }
195
+ }, [activeIndex]);
196
+ const { searchValue, searchOnChange, ...searchResults } = useSimpleSearch({
197
+ children: refdChildren,
198
+ onSearch: () => setActiveIndex(null),
199
+ isOpen,
200
+ });
201
+
202
+ const onOptionSelected = useCallback(
203
+ (args: DropdownEventArgs) => {
204
+ setFormValue(args.value);
205
+ onClose();
206
+ },
207
+ [onClose, setFormValue],
208
+ );
209
+ const context = useMemo(
210
+ () => ({
211
+ formValue,
212
+ onOptionSelected,
213
+ getItemProps,
214
+ activeIndex,
215
+ searchOnChange,
216
+ searchValue,
217
+ searchRef,
218
+ searchOnSubmit,
219
+ }),
220
+ [formValue, onOptionSelected, activeIndex, getItemProps, searchOnChange, searchValue, searchRef, searchOnSubmit],
221
+ );
222
+
223
+ useEffect(() => {
224
+ const currentOption = findOption(refdChildren, formValue);
225
+ if (currentOption) {
226
+ setFormLabel(currentOption.label);
227
+ setSelectedIndex(currentOption.index);
228
+ }
229
+ }, [refdChildren, formValue]);
230
+ return {
231
+ isOpen,
232
+ referenceProps,
233
+ floatingProps,
234
+ floatingContext,
235
+ searchOnSubmit,
236
+ searchRef,
237
+ context,
238
+ formLabel,
239
+ formValue,
240
+ ...searchResults,
241
+ ...rest,
242
+ };
243
+ }
244
+
245
+ const Dropdown = forwardRef<DropdownInstance, DropdownProps>(
246
+ ({ readOnly, onBlur, placeholder, name, search, size = 'medium', ...props }, ref) => {
247
+ const optionsRef = useRef(null);
248
+ const {
249
+ context: dropdownCtx,
250
+ floatingContext,
251
+ isOpen,
252
+ referenceProps,
253
+ floatingProps,
254
+ formValue,
255
+ formLabel,
256
+ searchRef,
257
+ children,
258
+ ...rest
259
+ } = useDropdown({
260
+ name,
261
+ readOnly,
262
+ optionsRef,
263
+ ref,
264
+ ...props,
265
+ });
266
+ const dropdownStyles = useMultiStyleConfig('Dropdown', { disabled: readOnly, size, ...rest });
267
+
268
+ const blurHandler = () => {
269
+ if (!onBlur) {
270
+ return;
271
+ }
272
+ if (!isOpen) {
273
+ onBlur({ target: { value: formValue, name } });
274
+ }
275
+ };
276
+ const searchElement = search === false ? null : search || <DropdownSearch />;
277
+ return (
278
+ <>
279
+ {name && formValue && <input type="hidden" name={name} value={formValue} />}
280
+ <DropdownProvider context={dropdownCtx} styles={dropdownStyles}>
281
+ <DropdownButton
282
+ {...referenceProps}
283
+ aria-label={props['aria-label']}
284
+ placeholder={placeholder}
285
+ size={size}
286
+ formLabel={formLabel}
287
+ blurHandler={blurHandler}
288
+ readOnly={readOnly}
289
+ />
290
+ {isOpen && (
291
+ <FloatingFocusManager initialFocus={searchRef} context={floatingContext}>
292
+ <chakra.div {...floatingProps} sx={dropdownStyles.list}>
293
+ {searchElement}
294
+ <chakra.div tabIndex={-1} ref={optionsRef} __css={dropdownStyles.options}>
295
+ {children}
296
+ </chakra.div>
297
+ </chakra.div>
298
+ </FloatingFocusManager>
299
+ )}
300
+ </DropdownProvider>
301
+ </>
302
+ );
303
+ },
304
+ );
305
+
306
+ export default Dropdown;
@@ -0,0 +1,27 @@
1
+ import { forwardRef, ReactNode } from 'react';
2
+ import { chakra, ChakraProps } from '@chakra-ui/react';
3
+ import Icon from '../Icon/Icon';
4
+ import { useDropdownStyles } from './Dropdown.context';
5
+
6
+ interface DropdownButtonProps extends ChakraProps {
7
+ blurHandler: () => void;
8
+ readOnly?: boolean;
9
+ placeholder?: string;
10
+ formLabel?: ReactNode;
11
+ size: 'medium' | 'small';
12
+ children?: ReactNode;
13
+ }
14
+ const DropdownButton = forwardRef<HTMLButtonElement, DropdownButtonProps>(
15
+ ({ size, blurHandler, formLabel, readOnly, placeholder, children, ...rest }, ref) => {
16
+ const { field, icon } = useDropdownStyles();
17
+ const iconSize = size === 'medium' ? '24' : '16';
18
+ return (
19
+ <chakra.button aria-readonly={readOnly} type="button" ref={ref} onBlur={blurHandler} __css={field} {...rest}>
20
+ <chakra.span flexGrow={1}>{formLabel || placeholder}</chakra.span>
21
+ <Icon aria-hidden="true" __css={icon} name="DropdownArrows" fontSize={iconSize} size={iconSize} />
22
+ </chakra.button>
23
+ );
24
+ },
25
+ );
26
+
27
+ export default DropdownButton;
@@ -0,0 +1,83 @@
1
+ import { forwardRef, ReactNode, useId } from 'react';
2
+ import { chakra } from '@chakra-ui/react';
3
+ import { cx } from '@chakra-ui/utils';
4
+ import Text from '../Text/Text';
5
+ import Divider from '../Divider/Divider';
6
+ import { useDropdownContext, useDropdownStyles } from './Dropdown.context';
7
+
8
+ const DropdownOption = forwardRef<
9
+ HTMLDivElement,
10
+ { value?: string; children?: ReactNode; index?: number; 'aria-label'?: string }
11
+ >(({ value = '', children, index, ...rest }, ref) => {
12
+ const { item } = useDropdownStyles();
13
+ const ctx = useDropdownContext();
14
+ return (
15
+ <chakra.div
16
+ role="option"
17
+ id={useId()}
18
+ ref={ref}
19
+ {...rest}
20
+ className={cx(
21
+ value === ctx.formValue && 'bitkit-select__option-active',
22
+ index === ctx.activeIndex && 'bitkit-select__option-hover',
23
+ )}
24
+ __css={item}
25
+ {...ctx.getItemProps({
26
+ onClick() {
27
+ ctx.onOptionSelected({ value: value || '', index, label: children });
28
+ },
29
+ })}
30
+ >
31
+ {children}
32
+ </chakra.div>
33
+ );
34
+ });
35
+
36
+ const DropdownGroup = ({ children, label }: { children: ReactNode; label: string }) => {
37
+ const { group } = useDropdownStyles();
38
+ const textId = useId();
39
+ return (
40
+ <div role="group" aria-labelledby={textId}>
41
+ <chakra.div __css={group}>
42
+ <Text size="1" id={textId} fontWeight="bold" color="neutral.60">
43
+ {label}
44
+ </Text>
45
+ <Divider color="neutral.93" />
46
+ </chakra.div>
47
+ {children}
48
+ </div>
49
+ );
50
+ };
51
+
52
+ const DropdownDetailedOption = ({
53
+ icon,
54
+ title,
55
+ subtitle,
56
+ value,
57
+ index,
58
+ }: {
59
+ icon: ReactNode;
60
+ title: string;
61
+ subtitle: string;
62
+ value?: string;
63
+ index?: number;
64
+ }) => {
65
+ return (
66
+ <DropdownOption value={value} index={index} aria-label={title}>
67
+ <chakra.div alignItems="center" gap="12" display="flex" flexDir="row">
68
+ {icon}
69
+ <chakra.div>
70
+ <Text size="3" color="purple.10">
71
+ {title}
72
+ </Text>
73
+ <Text size="2" color="neutral.60">
74
+ {subtitle}
75
+ </Text>
76
+ </chakra.div>
77
+ </chakra.div>
78
+ </DropdownOption>
79
+ );
80
+ };
81
+ DropdownDetailedOption.searchable = true;
82
+
83
+ export { DropdownOption, DropdownGroup, DropdownDetailedOption };
@@ -0,0 +1,61 @@
1
+ import { useLayoutEffect, useRef } from 'react';
2
+
3
+ const useAutoScroll = ({
4
+ optionsRef,
5
+ listRef,
6
+ activeIndex,
7
+ selectedIndex,
8
+ keyboardControlled,
9
+ }: {
10
+ optionsRef: React.RefObject<HTMLDivElement>;
11
+ listRef: React.MutableRefObject<HTMLElement[]>;
12
+ activeIndex: number | null;
13
+ selectedIndex: number | null;
14
+ keyboardControlled: boolean;
15
+ }) => {
16
+ const prevActiveIndexRef = useRef<number | null>();
17
+ useLayoutEffect(() => {
18
+ prevActiveIndexRef.current = activeIndex;
19
+ }, [activeIndex]);
20
+ const prevActiveIndex = prevActiveIndexRef.current;
21
+
22
+ useLayoutEffect(() => {
23
+ const options = optionsRef.current;
24
+
25
+ if (options && keyboardControlled) {
26
+ let activeItem = null;
27
+ if (activeIndex != null) {
28
+ activeItem = listRef.current[activeIndex];
29
+ } else if (selectedIndex != null) {
30
+ activeItem = listRef.current[selectedIndex];
31
+ }
32
+ if (activeItem && prevActiveIndex != null) {
33
+ const activeItemHeight = listRef.current[prevActiveIndex]?.offsetHeight ?? 0;
34
+
35
+ const floatingHeight = options.offsetHeight;
36
+ const top = activeItem.offsetTop;
37
+ const bottom = top + activeItemHeight;
38
+
39
+ if (top < options.scrollTop) {
40
+ options.scrollTop -= options.scrollTop - top + 5;
41
+ } else if (bottom > floatingHeight + options.scrollTop) {
42
+ options.scrollTop += bottom - floatingHeight - options.scrollTop + 5;
43
+ }
44
+ }
45
+ }
46
+ }, [prevActiveIndex, activeIndex, optionsRef, selectedIndex, keyboardControlled]);
47
+
48
+ useLayoutEffect(() => {
49
+ requestAnimationFrame(() => {
50
+ const options = optionsRef.current;
51
+ if (options && options.clientHeight < options.scrollHeight) {
52
+ const selectedItem = selectedIndex && listRef.current[selectedIndex];
53
+ if (selectedItem) {
54
+ options.scrollTop = selectedItem.offsetTop - options.offsetHeight / 2 + selectedItem.offsetHeight / 2;
55
+ }
56
+ }
57
+ });
58
+ }, [selectedIndex, optionsRef]);
59
+ };
60
+
61
+ export default useAutoScroll;
@@ -0,0 +1,92 @@
1
+ import { RefObject, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ autoUpdate,
4
+ useFloating,
5
+ useInteractions,
6
+ size,
7
+ useClick,
8
+ useListNavigation,
9
+ useRole,
10
+ useDismiss,
11
+ } from '@floating-ui/react-dom-interactions';
12
+ import useAutoScroll from './useAutoScroll';
13
+
14
+ const useFloatingDropdown = ({ enabled, optionsRef }: { enabled: boolean; optionsRef: RefObject<HTMLDivElement> }) => {
15
+ const listRef = useRef<HTMLElement[]>([]);
16
+ const [isOpen, setOpen] = useState(false);
17
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
18
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
19
+ const [keyboardControlled, setKeyboardControlled] = useState(true);
20
+ useEffect(() => {
21
+ setKeyboardControlled(true);
22
+ }, [isOpen]);
23
+ const keyboardControlledHandlers = {
24
+ onPointerEnter() {
25
+ setKeyboardControlled(false);
26
+ },
27
+ onPointerMove() {
28
+ setKeyboardControlled(false);
29
+ },
30
+ onKeyDown() {
31
+ setKeyboardControlled(true);
32
+ },
33
+ };
34
+
35
+ const { context, reference, floating, strategy, x, y } = useFloating({
36
+ open: enabled && isOpen,
37
+ onOpenChange: setOpen,
38
+ whileElementsMounted(aRef, aFloat, update) {
39
+ return autoUpdate(aRef, aFloat, update, { elementResize: false });
40
+ },
41
+ middleware: [
42
+ size({
43
+ apply({ elements, availableHeight, rects }) {
44
+ Object.assign(elements.floating.style, {
45
+ width: `${rects.reference.width}px`,
46
+ });
47
+ elements.floating.style.setProperty('--floating-available-height', `${availableHeight - 5}px`);
48
+ },
49
+ }),
50
+ ],
51
+ });
52
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
53
+ useClick(context, { enabled }),
54
+ useRole(context, { role: 'listbox' }),
55
+ useDismiss(context),
56
+ useListNavigation(context, {
57
+ enabled,
58
+ listRef,
59
+ activeIndex,
60
+ selectedIndex,
61
+ virtual: true,
62
+ loop: true,
63
+ onNavigate: setActiveIndex,
64
+ }),
65
+ ]);
66
+ useAutoScroll({
67
+ listRef,
68
+ optionsRef,
69
+ activeIndex,
70
+ selectedIndex,
71
+ keyboardControlled,
72
+ });
73
+
74
+ return {
75
+ listRef,
76
+ onClose: () => setOpen(false),
77
+ isOpen,
78
+ getItemProps,
79
+ activeIndex,
80
+ setActiveIndex,
81
+ setSelectedIndex,
82
+ referenceProps: getReferenceProps({ ref: reference }),
83
+ floatingProps: getFloatingProps({
84
+ ref: floating,
85
+ style: { position: strategy, top: y ?? 0, left: x ?? 0 },
86
+ ...keyboardControlledHandlers,
87
+ }),
88
+ context,
89
+ };
90
+ };
91
+
92
+ export default useFloatingDropdown;