@leafygreen-ui/combobox 1.2.2 → 2.0.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/src/Combobox.tsx CHANGED
@@ -7,9 +7,7 @@ import React, {
7
7
  } from 'react';
8
8
  import { clone, isArray, isEqual, isNull, isString, isUndefined } from 'lodash';
9
9
  import { Description, Label } from '@leafygreen-ui/typography';
10
- import Popover from '@leafygreen-ui/popover';
11
10
  import {
12
- useAvailableSpace,
13
11
  useDynamicRefs,
14
12
  useEventListener,
15
13
  useIdAllocator,
@@ -17,8 +15,8 @@ import {
17
15
  } from '@leafygreen-ui/hooks';
18
16
  import Icon from '@leafygreen-ui/icon';
19
17
  import IconButton from '@leafygreen-ui/icon-button';
20
- import { css, cx } from '@leafygreen-ui/emotion';
21
- import { uiColors } from '@leafygreen-ui/palette';
18
+ import { cx } from '@leafygreen-ui/emotion';
19
+ import { palette } from '@leafygreen-ui/palette';
22
20
  import { consoleOnce, isComponentType, keyMap } from '@leafygreen-ui/lib';
23
21
  import {
24
22
  ComboboxProps,
@@ -28,28 +26,8 @@ import {
28
26
  OptionObject,
29
27
  ComboboxElement,
30
28
  ComboboxSize,
29
+ State,
31
30
  } from './Combobox.types';
32
- import { ComboboxContext } from './ComboboxContext';
33
- import { InternalComboboxOption } from './ComboboxOption';
34
- import { Chip } from './Chip';
35
- import {
36
- clearButtonStyle,
37
- clearButtonFocusOverrideStyles,
38
- comboboxFocusStyle,
39
- comboboxParentStyle,
40
- comboboxStyle,
41
- endIcon,
42
- errorMessageStyle,
43
- inputElementStyle,
44
- inputWrapperStyle,
45
- loadingIconStyle,
46
- menuList,
47
- menuMessage,
48
- menuStyle,
49
- menuWrapperStyle,
50
- _tempLabelDescriptionOverrideStyle,
51
- } from './Combobox.styles';
52
- import { InternalComboboxGroup } from './ComboboxGroup';
53
31
  import {
54
32
  flattenChildren,
55
33
  getOptionObjectFromValue,
@@ -57,6 +35,32 @@ import {
57
35
  getValueForDisplayName,
58
36
  getNameAndValue,
59
37
  } from './utils';
38
+ import { ComboboxContext, useDarkMode } from './ComboboxContext';
39
+ import { InternalComboboxGroup } from './ComboboxGroup';
40
+ import { InternalComboboxOption } from './ComboboxOption';
41
+ import { Chip } from './Chip';
42
+ import {
43
+ comboboxFocusStyle,
44
+ inputWrapperStyle,
45
+ baseComboboxStyles,
46
+ comboboxThemeStyles,
47
+ comboboxSizeStyles,
48
+ comboboxDisabledStyles,
49
+ comboboxErrorStyles,
50
+ comboboxParentStyle,
51
+ baseInputElementStyle,
52
+ inputElementSizeStyle,
53
+ inputElementTransitionStyles,
54
+ multiselectInputElementStyle,
55
+ clearButtonStyle,
56
+ endIconStyle,
57
+ errorMessageThemeStyle,
58
+ errorMessageSizeStyle,
59
+ multiselectInputElementPadding,
60
+ labelDescriptionContainerStyle,
61
+ inputElementThemeStyle,
62
+ } from './Combobox.styles';
63
+ import { ComboboxMenu } from './ComboboxMenu/ComboboxMenu';
60
64
 
61
65
  /**
62
66
  * Combobox is a combination of a Select and TextInput,
@@ -97,6 +101,7 @@ export default function Combobox<M extends boolean>({
97
101
  popoverZIndex,
98
102
  ...rest
99
103
  }: ComboboxProps<M>) {
104
+ const theme = useDarkMode(darkMode);
100
105
  const getOptionRef = useDynamicRefs<HTMLLIElement>({ prefix: 'option' });
101
106
  const getChipRef = useDynamicRefs<HTMLSpanElement>({ prefix: 'chip' });
102
107
 
@@ -357,6 +362,8 @@ export default function Combobox<M extends boolean>({
357
362
  const [focusedElementName, trackFocusedElement] = useState<
358
363
  ComboboxElement | undefined
359
364
  >();
365
+ const isElementFocused = (elementName: ComboboxElement) =>
366
+ elementName === focusedElementName;
360
367
 
361
368
  type Direction = 'next' | 'prev' | 'first' | 'last';
362
369
 
@@ -595,74 +602,75 @@ export default function Combobox<M extends boolean>({
595
602
  */
596
603
 
597
604
  /**
598
- * Callback to render the children as <InternalComboboxOption> elements
605
+ * Callback to render a child as an <InternalComboboxOption> element
599
606
  */
600
- const renderInternalOptions = useCallback(
601
- (_children: React.ReactNode) => {
602
- return React.Children.map(_children, child => {
603
- if (isComponentType(child, 'ComboboxOption')) {
604
- const { value, displayName } = getNameAndValue(child.props);
605
-
606
- if (shouldOptionBeVisible(value)) {
607
- const { className, glyph, disabled } = child.props;
608
- const index = allOptions.findIndex(opt => opt.value === value);
609
-
610
- const isFocused = highlightedOption === value;
611
- const isSelected = isMultiselect(selection)
612
- ? selection.includes(value)
613
- : selection === value;
614
-
615
- const setSelected = () => {
616
- sethighlightedOption(value);
617
- updateSelection(value);
618
- setInputFocus();
619
-
620
- if (value === selection) {
621
- closeMenu();
622
- }
623
- };
624
-
625
- const optionRef = getOptionRef(value);
626
-
627
- return (
628
- <InternalComboboxOption
629
- value={value}
630
- displayName={displayName}
631
- isFocused={isFocused}
632
- isSelected={isSelected}
633
- disabled={disabled}
634
- setSelected={setSelected}
635
- glyph={glyph}
636
- className={className}
637
- index={index}
638
- ref={optionRef}
639
- />
640
- );
641
- }
642
- } else if (isComponentType(child, 'ComboboxGroup')) {
643
- const nestedChildren = renderInternalOptions(child.props.children);
644
-
645
- if (nestedChildren && nestedChildren?.length > 0) {
646
- return (
647
- <InternalComboboxGroup
648
- label={child.props.label}
649
- className={child.props.className}
650
- >
651
- {renderInternalOptions(nestedChildren)}
652
- </InternalComboboxGroup>
653
- );
654
- }
607
+ const renderOption = useCallback(
608
+ (child: React.ReactNode) => {
609
+ if (isComponentType(child, 'ComboboxOption')) {
610
+ const { value, displayName } = getNameAndValue(child.props);
611
+
612
+ if (shouldOptionBeVisible(value)) {
613
+ const { className, glyph, disabled } = child.props;
614
+ const index = allOptions.findIndex(opt => opt.value === value);
615
+
616
+ const isFocused = highlightedOption === value;
617
+ const isSelected = isMultiselect(selection)
618
+ ? selection.includes(value)
619
+ : selection === value;
620
+
621
+ const setSelected = () => {
622
+ sethighlightedOption(value);
623
+ updateSelection(value);
624
+ setInputFocus();
625
+
626
+ if (value === selection) {
627
+ closeMenu();
628
+ }
629
+ };
630
+
631
+ const optionRef = getOptionRef(value);
632
+
633
+ return (
634
+ <InternalComboboxOption
635
+ value={value}
636
+ displayName={displayName}
637
+ isFocused={isFocused}
638
+ isSelected={isSelected}
639
+ disabled={disabled}
640
+ setSelected={setSelected}
641
+ glyph={glyph}
642
+ className={className}
643
+ index={index}
644
+ ref={optionRef}
645
+ />
646
+ );
655
647
  }
656
- });
648
+ } else if (isComponentType(child, 'ComboboxGroup')) {
649
+ const nestedChildren = React.Children.map(
650
+ child.props.children,
651
+ renderOption,
652
+ );
653
+
654
+ if (nestedChildren && nestedChildren?.length > 0) {
655
+ return (
656
+ <InternalComboboxGroup
657
+ label={child.props.label}
658
+ className={child.props.className}
659
+ >
660
+ {React.Children.map(nestedChildren, renderOption)}
661
+ </InternalComboboxGroup>
662
+ );
663
+ }
664
+ }
657
665
  },
658
666
  [
659
667
  allOptions,
660
- highlightedOption,
661
668
  getOptionRef,
669
+ highlightedOption,
662
670
  isMultiselect,
663
- shouldOptionBeVisible,
664
671
  selection,
665
672
  setInputFocus,
673
+ shouldOptionBeVisible,
666
674
  updateSelection,
667
675
  ],
668
676
  );
@@ -671,8 +679,8 @@ export default function Combobox<M extends boolean>({
671
679
  * The rendered JSX elements for the options
672
680
  */
673
681
  const renderedOptionsJSX = useMemo(
674
- () => renderInternalOptions(children),
675
- [children, renderInternalOptions],
682
+ () => React.Children.map(children, renderOption),
683
+ [children, renderOption],
676
684
  );
677
685
 
678
686
  /**
@@ -752,15 +760,20 @@ export default function Combobox<M extends boolean>({
752
760
  ref={clearButtonRef}
753
761
  onClick={handleClearButtonClick}
754
762
  onFocus={handleClearButtonFocus}
755
- className={cx(clearButtonStyle, clearButtonFocusOverrideStyles)}
763
+ className={cx(clearButtonStyle)}
764
+ darkMode={darkMode}
756
765
  >
757
766
  <Icon glyph="XWithCircle" />
758
767
  </IconButton>
759
768
  )}
760
769
  {state === 'error' ? (
761
- <Icon glyph="Warning" color={uiColors.red.base} className={endIcon} />
770
+ <Icon
771
+ glyph="Warning"
772
+ color={darkMode ? palette.red.light1 : palette.red.base}
773
+ className={endIconStyle(size)}
774
+ />
762
775
  ) : (
763
- <Icon glyph="CaretDown" className={endIcon} />
776
+ <Icon glyph="CaretDown" className={endIconStyle(size)} />
764
777
  )}
765
778
  </>
766
779
  );
@@ -769,6 +782,8 @@ export default function Combobox<M extends boolean>({
769
782
  doesSelectionExist,
770
783
  disabled,
771
784
  state,
785
+ darkMode,
786
+ size,
772
787
  updateSelection,
773
788
  onClear,
774
789
  onFilter,
@@ -906,62 +921,11 @@ export default function Combobox<M extends boolean>({
906
921
  setMenuWidth(comboboxRef.current?.clientWidth ?? 0);
907
922
  }, [comboboxRef, isOpen, highlightedOption, selection]);
908
923
 
909
- // Handler fired when the manu has finished transitioning in/out
924
+ // Handler fired when the menu has finished transitioning in/out
910
925
  const handleTransitionEnd = () => {
911
926
  setMenuWidth(comboboxRef.current?.clientWidth ?? 0);
912
927
  };
913
928
 
914
- /**
915
- * The rendered menu JSX contents
916
- * Includes error, empty, search and default states
917
- */
918
- const renderedMenuContents = useMemo((): JSX.Element => {
919
- switch (searchState) {
920
- case 'loading': {
921
- return (
922
- <span className={menuMessage}>
923
- <Icon
924
- glyph="Refresh"
925
- color={uiColors.blue.base}
926
- className={loadingIconStyle}
927
- />
928
- {searchLoadingMessage}
929
- </span>
930
- );
931
- }
932
-
933
- case 'error': {
934
- return (
935
- <span className={menuMessage}>
936
- <Icon glyph="Warning" color={uiColors.red.base} />
937
- {searchErrorMessage}
938
- </span>
939
- );
940
- }
941
-
942
- case 'unset':
943
- default: {
944
- if (renderedOptionsJSX && renderedOptionsJSX.length > 0) {
945
- return <ul className={menuList}>{renderedOptionsJSX}</ul>;
946
- }
947
-
948
- return <span className={menuMessage}>{searchEmptyMessage}</span>;
949
- }
950
- }
951
- }, [
952
- renderedOptionsJSX,
953
- searchEmptyMessage,
954
- searchErrorMessage,
955
- searchLoadingMessage,
956
- searchState,
957
- ]);
958
-
959
- /** The max height of the menu element */
960
- const availableSpace = useAvailableSpace(comboboxRef);
961
- const maxHeightValue = !isUndefined(availableSpace)
962
- ? `${Math.min(availableSpace, 256)}px`
963
- : 'unset';
964
-
965
929
  /**
966
930
  *
967
931
  * Event Handlers
@@ -1214,32 +1178,26 @@ export default function Combobox<M extends boolean>({
1214
1178
  size,
1215
1179
  withIcons,
1216
1180
  disabled,
1181
+ isOpen,
1182
+ state,
1183
+ searchState,
1217
1184
  chipTruncationLocation,
1218
1185
  chipCharacterLimit,
1219
1186
  inputValue,
1220
1187
  }}
1221
1188
  >
1222
1189
  <div
1223
- className={cx(
1224
- comboboxParentStyle({ darkMode, size, overflow }),
1225
- className,
1226
- )}
1190
+ className={cx(comboboxParentStyle(size, overflow), className)}
1227
1191
  {...rest}
1228
1192
  >
1229
- <div>
1193
+ <div className={labelDescriptionContainerStyle}>
1230
1194
  {label && (
1231
- <Label
1232
- id={labelId}
1233
- htmlFor={inputId}
1234
- className={_tempLabelDescriptionOverrideStyle}
1235
- >
1195
+ <Label id={labelId} htmlFor={inputId} darkMode={darkMode}>
1236
1196
  {label}
1237
1197
  </Label>
1238
1198
  )}
1239
1199
  {description && (
1240
- <Description className={_tempLabelDescriptionOverrideStyle}>
1241
- {description}
1242
- </Description>
1200
+ <Description darkMode={darkMode}>{description}</Description>
1243
1201
  )}
1244
1202
  </div>
1245
1203
 
@@ -1252,25 +1210,29 @@ export default function Combobox<M extends boolean>({
1252
1210
  aria-controls={menuId}
1253
1211
  aria-owns={menuId}
1254
1212
  tabIndex={-1}
1255
- className={cx(comboboxStyle, {
1256
- [comboboxFocusStyle]: focusedElementName === ComboboxElement.Input,
1257
- })}
1258
1213
  onMouseDown={handleInputWrapperMousedown}
1259
1214
  onClick={handleComboboxClick}
1260
1215
  onFocus={handleComboboxFocus}
1261
1216
  onKeyDown={handleKeyDown}
1262
1217
  onTransitionEnd={handleTransitionEnd}
1263
- data-disabled={disabled}
1264
- data-state={state}
1218
+ className={cx(
1219
+ baseComboboxStyles,
1220
+ comboboxThemeStyles[theme],
1221
+ comboboxSizeStyles(size),
1222
+ {
1223
+ [comboboxDisabledStyles[theme]]: disabled,
1224
+ [comboboxErrorStyles[theme]]: state === State.error,
1225
+ [comboboxFocusStyle[theme]]: isElementFocused(
1226
+ ComboboxElement.Input,
1227
+ ),
1228
+ },
1229
+ )}
1265
1230
  >
1266
1231
  <div
1267
1232
  ref={inputWrapperRef}
1268
1233
  className={inputWrapperStyle({
1269
- overflow,
1270
- isOpen,
1271
- selection,
1272
1234
  size,
1273
- value: inputValue,
1235
+ overflow,
1274
1236
  })}
1275
1237
  >
1276
1238
  {renderedChips}
@@ -1281,7 +1243,18 @@ export default function Combobox<M extends boolean>({
1281
1243
  aria-labelledby={labelId}
1282
1244
  ref={inputRef}
1283
1245
  id={inputId}
1284
- className={inputElementStyle}
1246
+ className={cx(
1247
+ baseInputElementStyle,
1248
+ inputElementSizeStyle[size],
1249
+ inputElementThemeStyle[theme],
1250
+ inputElementTransitionStyles(isOpen, overflow),
1251
+ {
1252
+ [multiselectInputElementStyle(size, inputValue)]:
1253
+ isMultiselect(selection),
1254
+ [multiselectInputElementPadding(selection)]:
1255
+ isMultiselect(selection),
1256
+ },
1257
+ )}
1285
1258
  placeholder={placeholderValue}
1286
1259
  disabled={disabled ?? undefined}
1287
1260
  onChange={handleInputChange}
@@ -1293,39 +1266,33 @@ export default function Combobox<M extends boolean>({
1293
1266
  </div>
1294
1267
 
1295
1268
  {state === 'error' && errorMessage && (
1296
- <div className={errorMessageStyle}>{errorMessage}</div>
1269
+ <div
1270
+ className={cx(
1271
+ errorMessageThemeStyle[theme],
1272
+ errorMessageSizeStyle[size],
1273
+ )}
1274
+ >
1275
+ {errorMessage}
1276
+ </div>
1297
1277
  )}
1298
1278
 
1299
1279
  {/******* /
1300
1280
  * Menu *
1301
1281
  / *******/}
1302
- <Popover
1303
- active={isOpen && !disabled}
1304
- spacing={4}
1305
- align="bottom"
1306
- justify="middle"
1282
+
1283
+ <ComboboxMenu
1284
+ id={menuId}
1285
+ labelId={labelId}
1307
1286
  refEl={comboboxRef}
1308
- adjustOnMutation={true}
1309
- className={menuWrapperStyle({ darkMode, size, width: menuWidth })}
1287
+ ref={menuRef}
1288
+ menuWidth={menuWidth}
1289
+ searchLoadingMessage={searchLoadingMessage}
1290
+ searchErrorMessage={searchErrorMessage}
1291
+ searchEmptyMessage={searchEmptyMessage}
1310
1292
  {...popoverProps}
1311
1293
  >
1312
- <div
1313
- id={menuId}
1314
- role="listbox"
1315
- aria-labelledby={labelId}
1316
- aria-expanded={isOpen}
1317
- ref={menuRef}
1318
- className={cx(
1319
- menuStyle,
1320
- css`
1321
- max-height: ${maxHeightValue};
1322
- `,
1323
- )}
1324
- onMouseDownCapture={e => e.preventDefault()}
1325
- >
1326
- {renderedMenuContents}
1327
- </div>
1328
- </Popover>
1294
+ {renderedOptionsJSX}
1295
+ </ComboboxMenu>
1329
1296
  </div>
1330
1297
  </ComboboxContext.Provider>
1331
1298
  );
@@ -1,6 +1,13 @@
1
1
  import { ReactElement, ReactNode } from 'react';
2
2
  import { Either } from '@leafygreen-ui/lib';
3
3
 
4
+ export const Theme = {
5
+ Dark: 'dark',
6
+ Light: 'light',
7
+ } as const;
8
+
9
+ export type Theme = typeof Theme[keyof typeof Theme];
10
+
4
11
  /**
5
12
  * Prop Enums & Types
6
13
  */
@@ -1,5 +1,11 @@
1
1
  import { createContext } from 'react';
2
- import { ComboboxSize, TrunctationLocation } from './Combobox.types';
2
+ import {
3
+ ComboboxSize,
4
+ SearchState,
5
+ State,
6
+ Theme,
7
+ TrunctationLocation,
8
+ } from './Combobox.types';
3
9
 
4
10
  interface ComboboxData {
5
11
  multiselect: boolean;
@@ -7,6 +13,9 @@ interface ComboboxData {
7
13
  size: ComboboxSize;
8
14
  withIcons: boolean;
9
15
  disabled: boolean;
16
+ isOpen: boolean;
17
+ state: State;
18
+ searchState: SearchState;
10
19
  chipTruncationLocation?: TrunctationLocation;
11
20
  chipCharacterLimit?: number;
12
21
  inputValue?: string;
@@ -18,4 +27,10 @@ export const ComboboxContext = createContext<ComboboxData>({
18
27
  size: ComboboxSize.Default,
19
28
  withIcons: false,
20
29
  disabled: false,
30
+ isOpen: false,
31
+ state: State.none,
32
+ searchState: SearchState.unset,
21
33
  });
34
+
35
+ export const useDarkMode = (darkMode: boolean) =>
36
+ darkMode ? Theme.Dark : Theme.Light;
@@ -1,20 +1,18 @@
1
1
  import { css, cx } from '@leafygreen-ui/emotion';
2
2
  import { useIdAllocator } from '@leafygreen-ui/hooks';
3
- import { uiColors } from '@leafygreen-ui/palette';
3
+ import { palette } from '@leafygreen-ui/palette';
4
4
  import React, { useContext } from 'react';
5
- import { ComboboxGroupProps } from './Combobox.types';
6
- import { ComboboxContext } from './ComboboxContext';
7
-
8
- const comboboxGroupStyle = (darkMode: boolean) => css`
9
- --lg-combobox-group-label-color: ${darkMode
10
- ? uiColors.gray.light1
11
- : uiColors.gray.dark1};
12
- --lg-combobox-group-border-color: ${darkMode
13
- ? uiColors.gray.dark1
14
- : uiColors.gray.light1};
15
- padding-top: 8px;
16
- border-bottom: 1px solid var(--lg-combobox-group-border-color);
17
- `;
5
+ import { ComboboxGroupProps, Theme } from './Combobox.types';
6
+ import { ComboboxContext, useDarkMode } from './ComboboxContext';
7
+
8
+ const comboboxGroupStyle: Record<Theme, string> = {
9
+ [Theme.Light]: css`
10
+ padding-top: 8px;
11
+ `,
12
+ [Theme.Dark]: css`
13
+ padding-top: 8px;
14
+ `,
15
+ };
18
16
 
19
17
  const comboboxGroupLabel = css`
20
18
  cursor: default;
@@ -27,22 +25,34 @@ const comboboxGroupLabel = css`
27
25
  font-weight: bold;
28
26
  text-transform: uppercase;
29
27
  letter-spacing: 0.4px;
30
- color: var(--lg-combobox-group-label-color);
31
28
  `;
32
29
 
30
+ const comboboxGroupLabelThemeStyle: Record<Theme, string> = {
31
+ [Theme.Light]: css`
32
+ color: ${palette.gray.dark1};
33
+ `,
34
+ [Theme.Dark]: css`
35
+ color: ${palette.gray.light1};
36
+ `,
37
+ };
38
+
33
39
  export function InternalComboboxGroup({
34
40
  label,
35
41
  className,
36
42
  children,
37
43
  }: ComboboxGroupProps): JSX.Element {
38
44
  const { darkMode } = useContext(ComboboxContext);
45
+ const theme = useDarkMode(darkMode);
39
46
 
40
47
  const groupId = useIdAllocator({ prefix: 'combobox-group' });
41
48
  const childCount = React.Children.count(children);
42
49
 
43
50
  return childCount > 0 ? (
44
- <div className={cx(comboboxGroupStyle(darkMode), className)}>
45
- <div className={comboboxGroupLabel} id={groupId}>
51
+ <div className={cx(comboboxGroupStyle[theme], className)}>
52
+ <div
53
+ className={cx(comboboxGroupLabel, comboboxGroupLabelThemeStyle[theme])}
54
+ id={groupId}
55
+ >
46
56
  {label}
47
57
  </div>
48
58
  <div role="group" aria-labelledby={groupId}>