@canonical/react-components 1.1.0 → 1.2.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.
@@ -1,17 +1,13 @@
1
1
  import React from "react";
2
- import type { HTMLProps } from "react";
2
+ import type { HTMLProps, ReactNode } from "react";
3
3
  import type { ButtonProps } from "../Button";
4
4
  import type { ContextualMenuDropdownProps } from "./ContextualMenuDropdown";
5
5
  import type { MenuLink, Position } from "./ContextualMenuDropdown";
6
- import { ClassName, PropsWithSpread, SubComponentProps } from "../../types";
6
+ import { ClassName, ExclusiveProps, PropsWithSpread, SubComponentProps } from "../../types";
7
7
  export declare enum Label {
8
8
  Toggle = "Toggle menu"
9
9
  }
10
- /**
11
- * The props for the ContextualMenu component.
12
- * @template L - The type of the link props.
13
- */
14
- export type Props<L> = PropsWithSpread<{
10
+ export type BaseProps<L> = PropsWithSpread<{
15
11
  /**
16
12
  * Whether the menu should adjust its horizontal position to fit on the screen.
17
13
  */
@@ -44,10 +40,6 @@ export type Props<L> = PropsWithSpread<{
44
40
  * Additional props to pass to the dropdown.
45
41
  */
46
42
  dropdownProps?: SubComponentProps<ContextualMenuDropdownProps>;
47
- /**
48
- * Whether the toggle should display a chevron icon.
49
- */
50
- hasToggleIcon?: boolean;
51
43
  /**
52
44
  * A list of links to display in the menu (if the children prop is not supplied.)
53
45
  */
@@ -68,6 +60,20 @@ export type Props<L> = PropsWithSpread<{
68
60
  * Whether the dropdown should scroll if it is too long to fit on the screen.
69
61
  */
70
62
  scrollOverflow?: boolean;
63
+ /**
64
+ * Whether the menu should be visible.
65
+ */
66
+ visible?: boolean;
67
+ }, HTMLProps<HTMLSpanElement>>;
68
+ /**
69
+ * The props for the ContextualMenu component.
70
+ * @template L - The type of the link props.
71
+ */
72
+ export type Props<L> = BaseProps<L> & ExclusiveProps<{
73
+ /**
74
+ * Whether the toggle should display a chevron icon.
75
+ */
76
+ hasToggleIcon?: boolean;
71
77
  /**
72
78
  * The appearance of the toggle button.
73
79
  */
@@ -92,15 +98,13 @@ export type Props<L> = PropsWithSpread<{
92
98
  * Additional props to pass to the toggle button.
93
99
  */
94
100
  toggleProps?: SubComponentProps<ButtonProps>;
95
- /**
96
- * Whether the menu should be visible.
97
- */
98
- visible?: boolean;
99
- }, HTMLProps<HTMLSpanElement>>;
101
+ }, {
102
+ toggle: ReactNode;
103
+ }>;
100
104
  /**
101
105
  * This is a [React](https://reactjs.org/) component for the Vanilla [Contextual menu](https://docs.vanillaframework.io/patterns//contextual-menu/).
102
106
  *
103
107
  * A contextual menu can be used in conjunction with any page element to provide a contextual menu.
104
108
  */
105
- declare const ContextualMenu: <L>({ autoAdjust, children, className, closeOnEsc, closeOnOutsideClick, constrainPanelWidth, dropdownClassName, dropdownProps, hasToggleIcon, links, onToggleMenu, position, positionNode, scrollOverflow, toggleAppearance, toggleClassName, toggleDisabled, toggleLabel, toggleLabelFirst, toggleProps, visible, ...wrapperProps }: Props<L>) => JSX.Element;
109
+ declare const ContextualMenu: <L>({ autoAdjust, children, className, closeOnEsc, closeOnOutsideClick, constrainPanelWidth, dropdownClassName, dropdownProps, hasToggleIcon, links, onToggleMenu, position, positionNode, scrollOverflow, toggle, toggleAppearance, toggleClassName, toggleDisabled, toggleLabel, toggleLabelFirst, toggleProps, visible, ...wrapperProps }: Props<L>) => JSX.Element;
106
110
  export default ContextualMenu;
@@ -69,6 +69,7 @@ const ContextualMenu = _ref => {
69
69
  position = "right",
70
70
  positionNode,
71
71
  scrollOverflow,
72
+ toggle,
72
73
  toggleAppearance,
73
74
  toggleClassName,
74
75
  toggleDisabled,
@@ -82,7 +83,6 @@ const ContextualMenu = _ref => {
82
83
  const wrapper = (0, _react.useRef)(null);
83
84
  const [positionCoords, setPositionCoords] = (0, _react.useState)();
84
85
  const [adjustedPosition, setAdjustedPosition] = (0, _react.useState)(position);
85
- const hasToggle = hasToggleIcon || toggleLabel;
86
86
  (0, _react.useEffect)(() => {
87
87
  setAdjustedPosition(position);
88
88
  }, [position, autoAdjust]);
@@ -106,14 +106,15 @@ const ContextualMenu = _ref => {
106
106
  isOpen: visible,
107
107
  onOpen: () => {
108
108
  // Call the toggle callback, if supplied.
109
- onToggleMenu && onToggleMenu(true);
109
+ onToggleMenu === null || onToggleMenu === void 0 || onToggleMenu(true);
110
110
  // When the menu opens then update the coordinates of the parent.
111
111
  updatePositionCoords();
112
112
  },
113
113
  onClose: () => {
114
114
  // Call the toggle callback, if supplied.
115
- onToggleMenu && onToggleMenu(false);
116
- }
115
+ onToggleMenu === null || onToggleMenu === void 0 || onToggleMenu(false);
116
+ },
117
+ programmaticallyOpen: true
117
118
  });
118
119
  const previousVisible = (0, _hooks.usePrevious)(visible);
119
120
  const labelNode = toggleLabel && typeof toggleLabel === "string" ? /*#__PURE__*/_react.default.createElement("span", null, toggleLabel) : /*#__PURE__*/_react.default.isValidElement(toggleLabel) ? toggleLabel : null;
@@ -166,32 +167,38 @@ const ContextualMenu = _ref => {
166
167
  }, [positionNode, updatePositionCoords]);
167
168
  (0, _hooks.useListener)(window, onResize, "resize", true, isOpen);
168
169
  (0, _hooks.useListener)(window, onScroll, "scroll", false, isOpen, true);
170
+ let toggleNode = null;
171
+ if (toggle) {
172
+ toggleNode = toggle;
173
+ } else if (hasToggleIcon || toggleLabel) {
174
+ toggleNode = /*#__PURE__*/_react.default.createElement(_Button.default, _extends({
175
+ appearance: toggleAppearance,
176
+ "aria-controls": id,
177
+ "aria-expanded": isOpen ? "true" : "false",
178
+ "aria-label": toggleLabel ? null : Label.Toggle,
179
+ "aria-pressed": isOpen ? "true" : "false",
180
+ "aria-haspopup": "true",
181
+ className: (0, _classnames.default)("p-contextual-menu__toggle", toggleClassName),
182
+ disabled: toggleDisabled,
183
+ hasIcon: hasToggleIcon,
184
+ onClick: evt => {
185
+ if (!isOpen) {
186
+ openPortal(evt);
187
+ } else {
188
+ closePortal(evt);
189
+ }
190
+ },
191
+ type: "button"
192
+ }, toggleProps), toggleLabelFirst ? labelNode : null, hasToggleIcon ? /*#__PURE__*/_react.default.createElement("i", {
193
+ className: (0, _classnames.default)("p-icon--chevron-down p-contextual-menu__indicator", {
194
+ "is-light": ["negative", "positive"].includes(toggleAppearance)
195
+ })
196
+ }) : null, toggleLabelFirst ? null : labelNode);
197
+ }
169
198
  return /*#__PURE__*/_react.default.createElement("span", _extends({
170
199
  className: contextualMenuClassName,
171
200
  ref: wrapperRef
172
- }, wrapperProps), hasToggle ? /*#__PURE__*/_react.default.createElement(_Button.default, _extends({
173
- appearance: toggleAppearance,
174
- "aria-controls": id,
175
- "aria-expanded": isOpen ? "true" : "false",
176
- "aria-label": toggleLabel ? null : Label.Toggle,
177
- "aria-pressed": isOpen ? "true" : "false",
178
- "aria-haspopup": "true",
179
- className: (0, _classnames.default)("p-contextual-menu__toggle", toggleClassName),
180
- disabled: toggleDisabled,
181
- hasIcon: hasToggleIcon,
182
- onClick: evt => {
183
- if (!isOpen) {
184
- openPortal(evt);
185
- } else {
186
- closePortal(evt);
187
- }
188
- },
189
- type: "button"
190
- }, toggleProps), toggleLabelFirst ? labelNode : null, hasToggleIcon ? /*#__PURE__*/_react.default.createElement("i", {
191
- className: (0, _classnames.default)("p-icon--chevron-down p-contextual-menu__indicator", {
192
- "is-light": ["negative", "positive"].includes(toggleAppearance)
193
- })
194
- }) : null, toggleLabelFirst ? null : labelNode) : null, isOpen && /*#__PURE__*/_react.default.createElement(Portal, null, /*#__PURE__*/_react.default.createElement(_ContextualMenuDropdown.default, _extends({
201
+ }, wrapperProps), toggleNode, isOpen && /*#__PURE__*/_react.default.createElement(Portal, null, /*#__PURE__*/_react.default.createElement(_ContextualMenuDropdown.default, _extends({
195
202
  adjustedPosition: adjustedPosition,
196
203
  autoAdjust: autoAdjust,
197
204
  handleClose: closePortal,
@@ -40,7 +40,6 @@ type MultiSelectDropdownProps = {
40
40
  footer?: ReactNode;
41
41
  groupFn?: GroupFn;
42
42
  sortFn?: SortFn;
43
- shouldPinSelectedItems?: boolean;
44
43
  } & React.HTMLAttributes<HTMLDivElement>;
45
44
  declare const sortAlphabetically: (a: MultiSelectItem, b: MultiSelectItem) => number;
46
45
  declare const getGroupedItems: (items: MultiSelectItem[]) => {
@@ -155,8 +155,7 @@ MultiSelectDropdown.propTypes = {
155
155
  onSelectItem: _propTypes.default.func,
156
156
  footer: _propTypes.default.node,
157
157
  groupFn: _propTypes.default.func,
158
- sortFn: _propTypes.default.any,
159
- shouldPinSelectedItems: _propTypes.default.bool
158
+ sortFn: _propTypes.default.any
160
159
  };
161
160
  const MultiSelect = _ref4 => {
162
161
  let {
@@ -176,21 +175,9 @@ const MultiSelect = _ref4 => {
176
175
  showDropdownFooter = true,
177
176
  variant = "search"
178
177
  } = _ref4;
179
- const wrapperRef = (0, _index.useClickOutside)(() => {
180
- setIsDropdownOpen(false);
181
- setFilter("");
182
- });
183
- (0, _index.useOnEscapePressed)(() => {
184
- setIsDropdownOpen(false);
185
- setFilter("");
186
- });
178
+ const buttonRef = (0, _react.useRef)();
187
179
  const [isDropdownOpen, setIsDropdownOpen] = (0, _react.useState)(false);
188
180
  const [filter, setFilter] = (0, _react.useState)("");
189
- (0, _react.useEffect)(() => {
190
- if (!isDropdownOpen) {
191
- setFilter("");
192
- }
193
- }, [isDropdownOpen]);
194
181
  const [internalSelectedItems, setInternalSelectedItems] = (0, _react.useState)([]);
195
182
  const selectedItems = externalSelectedItems || internalSelectedItems;
196
183
  const updateItems = newItems => {
@@ -219,43 +206,69 @@ const MultiSelect = _ref4 => {
219
206
  type: "button"
220
207
  }, "Clear"));
221
208
  }
222
- return /*#__PURE__*/_react.default.createElement("div", {
223
- ref: wrapperRef
224
- }, /*#__PURE__*/_react.default.createElement("div", {
225
- className: "multi-select"
226
- }, variant === "search" ? /*#__PURE__*/_react.default.createElement(_index.SearchBox, {
227
- externallyControlled: true,
228
- "aria-controls": dropdownId,
229
- "aria-expanded": isDropdownOpen,
230
- id: inputId,
231
- role: "combobox",
232
- "aria-label": label || placeholder || "Search",
233
- disabled: disabled,
234
- autoComplete: "off",
235
- onChange: value => {
236
- setFilter(value);
237
- // reopen if dropdown has been closed via ESC
238
- setIsDropdownOpen(true);
209
+ return /*#__PURE__*/_react.default.createElement(_index.ContextualMenu, {
210
+ className: "multi-select",
211
+ onToggleMenu: isOpen => {
212
+ if (!isOpen) {
213
+ setFilter("");
214
+ }
215
+ // Handle syncing the state when toggling the menu from within the
216
+ // contextual menu component e.g. when clicking outside.
217
+ if (isOpen !== isDropdownOpen) {
218
+ setIsDropdownOpen(isOpen);
219
+ }
239
220
  },
240
- onFocus: () => setIsDropdownOpen(true),
241
- placeholder: placeholder !== null && placeholder !== void 0 ? placeholder : "Search",
242
- required: required,
243
- type: "text",
244
- value: filter,
245
- className: "multi-select__input"
246
- }) : /*#__PURE__*/_react.default.createElement("button", {
247
- role: "combobox",
248
- type: "button",
249
- "aria-label": label || placeholder || "Select items",
250
- "aria-controls": dropdownId,
251
- "aria-expanded": isDropdownOpen,
252
- className: "multi-select__select-button",
253
- onClick: () => {
254
- setIsDropdownOpen(isOpen => !isOpen);
255
- }
256
- }, /*#__PURE__*/_react.default.createElement("span", {
257
- className: "multi-select__condensed-text"
258
- }, listSelected && selectedItems.length > 0 ? selectedItemsLabel : placeholder !== null && placeholder !== void 0 ? placeholder : "Select items")), /*#__PURE__*/_react.default.createElement(MultiSelectDropdown, {
221
+ position: "left",
222
+ constrainPanelWidth: true,
223
+ toggle: variant === "search" ? /*#__PURE__*/_react.default.createElement(_index.SearchBox, {
224
+ externallyControlled: true,
225
+ "aria-controls": dropdownId,
226
+ "aria-expanded": isDropdownOpen,
227
+ id: inputId,
228
+ role: "combobox",
229
+ "aria-label": label || placeholder || "Search",
230
+ disabled: disabled,
231
+ autoComplete: "off",
232
+ onChange: value => {
233
+ setFilter(value);
234
+ // reopen if dropdown has been closed via ESC
235
+ setIsDropdownOpen(true);
236
+ },
237
+ onFocus: () => setIsDropdownOpen(true),
238
+ placeholder: placeholder !== null && placeholder !== void 0 ? placeholder : "Search",
239
+ required: required,
240
+ type: "text",
241
+ value: filter,
242
+ className: "multi-select__input"
243
+ }) : /*#__PURE__*/_react.default.createElement("button", {
244
+ role: "combobox",
245
+ type: "button",
246
+ "aria-label": label || placeholder || "Select items",
247
+ "aria-controls": dropdownId,
248
+ "aria-expanded": isDropdownOpen,
249
+ className: "multi-select__select-button",
250
+ onClick: () => {
251
+ setIsDropdownOpen(!isDropdownOpen);
252
+ },
253
+ onMouseDown: event => {
254
+ // If the dropdown is open when this button is clicked the
255
+ // click-outside event will fire which will close the dropdown, but
256
+ // then the button click event will fire which will immediately
257
+ // reopen the dropdown.
258
+ // To prevent this we can stop the propagation to the click event
259
+ // while `isDropdownOpen` is still set to `true` (by the time we
260
+ // get to the `onClick` event `isDropdownOpen` will already be `false`,
261
+ // hence having to do this on mouse down).
262
+ if (isDropdownOpen) {
263
+ event.stopPropagation();
264
+ }
265
+ },
266
+ ref: buttonRef
267
+ }, /*#__PURE__*/_react.default.createElement("span", {
268
+ className: "multi-select__condensed-text"
269
+ }, listSelected && selectedItems.length > 0 ? selectedItemsLabel : placeholder !== null && placeholder !== void 0 ? placeholder : "Select items")),
270
+ visible: isDropdownOpen
271
+ }, /*#__PURE__*/_react.default.createElement(MultiSelectDropdown, {
259
272
  id: dropdownId,
260
273
  isOpen: isDropdownOpen,
261
274
  items: filter.length > 0 ? items.filter(item => item.label.toLowerCase().includes(filter.toLowerCase())) : items,
@@ -266,7 +279,7 @@ const MultiSelect = _ref4 => {
266
279
  onSelectItem: onSelectItem,
267
280
  onDeselectItem: onDeselectItem,
268
281
  footer: footer
269
- })));
282
+ }));
270
283
  };
271
284
  exports.MultiSelect = MultiSelect;
272
285
  MultiSelect.propTypes = {
@@ -10,7 +10,9 @@ $dropdown-max-height: 20rem;
10
10
  // XXX: This is a workaround for https://github.com/canonical/vanilla-framework/issues/5030
11
11
  @include vf-b-forms;
12
12
 
13
+ margin-bottom: $input-margin-bottom;
13
14
  position: relative;
15
+ width: 100%;
14
16
  }
15
17
 
16
18
  .multi-select .p-form-validation__message {
@@ -26,6 +28,7 @@ $dropdown-max-height: 20rem;
26
28
 
27
29
  .multi-select__input {
28
30
  cursor: pointer;
31
+ margin-bottom: 0;
29
32
  position: relative;
30
33
 
31
34
  &.items-selected {
@@ -40,19 +43,6 @@ $dropdown-max-height: 20rem;
40
43
  }
41
44
  }
42
45
 
43
- .multi-select__dropdown {
44
- background-color: $colors--theme--background-default;
45
- box-shadow: $box-shadow;
46
- color: $colors--theme--text-default;
47
- left: 0;
48
- max-height: $dropdown-max-height;
49
- overflow: auto;
50
- padding-top: $spv--small;
51
- position: absolute;
52
- right: 0;
53
- top: calc(100% - #{$input-margin-bottom});
54
- }
55
-
56
46
  .multi-select__dropdown--side-by-side {
57
47
  display: flex;
58
48
  flex-wrap: wrap;
@@ -132,6 +122,10 @@ $dropdown-max-height: 20rem;
132
122
  position: relative;
133
123
  z-index: 0;
134
124
 
125
+ .multi-select & {
126
+ margin-bottom: 0;
127
+ }
128
+
135
129
  &::after {
136
130
  content: "";
137
131
  margin-left: $sph--large;
@@ -28,7 +28,7 @@ export type Props = PropsWithSpread<{
28
28
  /**
29
29
  * A function that is called when the user clicks the search icon or presses enter
30
30
  */
31
- onSearch?: () => void;
31
+ onSearch?: (inputValue: string) => void;
32
32
  /**
33
33
  * A function that is called when the user clicks the reset icon
34
34
  */
@@ -37,6 +37,10 @@ export type Props = PropsWithSpread<{
37
37
  * A search input placeholder message.
38
38
  */
39
39
  placeholder?: string;
40
+ /**
41
+ * Whether the search input should lose focus when searching.
42
+ */
43
+ shouldBlurOnSearch?: boolean;
40
44
  /**
41
45
  * Whether the search input should receive focus after pressing the reset button
42
46
  */
@@ -31,6 +31,7 @@ const SearchBox = /*#__PURE__*/_react.default.forwardRef((_ref, forwardedRef) =>
31
31
  onSearch,
32
32
  onClear,
33
33
  placeholder = "Search",
34
+ shouldBlurOnSearch = true,
34
35
  shouldRefocusAfterReset,
35
36
  value,
36
37
  ...props
@@ -45,11 +46,13 @@ const SearchBox = /*#__PURE__*/_react.default.forwardRef((_ref, forwardedRef) =>
45
46
  }
46
47
  };
47
48
  const triggerSearch = () => {
48
- onSearch && onSearch();
49
+ onSearch === null || onSearch === void 0 || onSearch(externallyControlled ? value : internalRef.current.value);
49
50
  };
50
51
  const onKeyDown = e => {
51
52
  if (e.key === "Enter" && internalRef.current.checkValidity()) {
52
- internalRef.current.blur();
53
+ if (shouldBlurOnSearch) {
54
+ internalRef.current.blur();
55
+ }
53
56
  triggerSearch();
54
57
  }
55
58
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonical/react-components",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "author": {