@canonical/react-components 3.5.1 → 3.7.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.
@@ -19,10 +19,31 @@ export type Props = PropsWithSpread<FieldProps, {
19
19
  id?: string | null;
20
20
  name?: string;
21
21
  disabled?: boolean;
22
+ /**
23
+ * Toggle label when no option is selected
24
+ *
25
+ * @default - "Select an option"
26
+ */
27
+ defaultToggleLabel?: string;
22
28
  wrapperClassName?: ClassName;
23
29
  toggleClassName?: ClassName;
24
30
  dropdownClassName?: string;
25
- searchable?: "auto" | "always" | "never";
31
+ /**
32
+ * Whether the select is searchable.
33
+ * - `auto`: the select will be searchable if it has 5 or more options.
34
+ * - `always`: the select will always be searchable if there is at least 1 option.
35
+ * - `async`: the select will always be searchable.
36
+ * - `never`: the select will never be searchable.
37
+ *
38
+ * @default - "auto"
39
+ */
40
+ searchable?: "auto" | "always" | "async" | "never";
41
+ /**
42
+ * Placeholder text for the search input when searchable is enabled.
43
+ *
44
+ * @default - "Search"
45
+ */
46
+ searchPlaceholder?: string;
26
47
  takeFocus?: boolean;
27
48
  header?: ReactNode;
28
49
  selectRef?: SelectRef;
@@ -33,5 +54,5 @@ export type Props = PropsWithSpread<FieldProps, {
33
54
  *
34
55
  * The aim of this component is to provide a select component with customisable options and a dropdown menu, whilst maintaining accessibility and usability.
35
56
  */
36
- declare const CustomSelect: ({ value, options, onChange, onSearch, id, name, disabled, success, error, help, wrapperClassName, toggleClassName, dropdownClassName, searchable, takeFocus, header, selectRef, initialPosition, ...fieldProps }: Props) => React.JSX.Element;
57
+ declare const CustomSelect: ({ value, options, onChange, onSearch, id, name, disabled, success, error, help, wrapperClassName, toggleClassName, dropdownClassName, defaultToggleLabel, searchable, searchPlaceholder, takeFocus, header, selectRef, initialPosition, ...fieldProps }: Props) => React.JSX.Element;
37
58
  export default CustomSelect;
@@ -35,7 +35,9 @@ const CustomSelect = _ref => {
35
35
  wrapperClassName,
36
36
  toggleClassName,
37
37
  dropdownClassName,
38
+ defaultToggleLabel = "Select an option",
38
39
  searchable = "auto",
40
+ searchPlaceholder = "Search",
39
41
  takeFocus,
40
42
  header,
41
43
  selectRef,
@@ -81,7 +83,7 @@ const CustomSelect = _ref => {
81
83
  const selectedOption = options.find(option => option.value === value);
82
84
  const toggleLabel = /*#__PURE__*/_react.default.createElement("span", {
83
85
  className: "toggle-label u-truncate"
84
- }, selectedOption ? selectedOption.selectedLabel || (0, _CustomSelectDropdown.getOptionText)(selectedOption) : "Select an option");
86
+ }, selectedOption ? selectedOption.selectedLabel || (0, _CustomSelectDropdown.getOptionText)(selectedOption) : defaultToggleLabel);
85
87
  const handleSelect = value => {
86
88
  var _document$getElementB3;
87
89
  (_document$getElementB3 = document.getElementById(selectId)) === null || _document$getElementB3 === void 0 || _document$getElementB3.focus();
@@ -128,6 +130,7 @@ const CustomSelect = _ref => {
128
130
  position: initialPosition
129
131
  }, close => /*#__PURE__*/_react.default.createElement(_CustomSelectDropdown.default, {
130
132
  searchable: searchable,
133
+ searchPlaceholder: searchPlaceholder,
131
134
  onSearch: onSearch,
132
135
  name: name || "",
133
136
  options: options || [],
@@ -32,3 +32,8 @@ export declare const AutoSearchable: Story;
32
32
  * Search can be enabled manually by setting `searchable` to `always`.
33
33
  */
34
34
  export declare const ManualSearchable: Story;
35
+ /**
36
+ * Search can be enabled manually by setting `searchable` to `async`.
37
+ * This will always show the search input regardless of the number of options.
38
+ */
39
+ export declare const AsyncSearchable: Story;
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.default = exports.StandardOptions = exports.ManualSearchable = exports.DisabledOptions = exports.CustomOptionsAndSelectedLabel = exports.CustomOptions = exports.AutoSearchable = void 0;
6
+ exports.default = exports.StandardOptions = exports.ManualSearchable = exports.DisabledOptions = exports.CustomOptionsAndSelectedLabel = exports.CustomOptions = exports.AutoSearchable = exports.AsyncSearchable = void 0;
7
7
  var _CustomSelect = _interopRequireDefault(require("./CustomSelect"));
8
8
  var _react = _interopRequireWildcard(require("react"));
9
9
  function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
@@ -150,12 +150,14 @@ const meta = {
150
150
  args: {
151
151
  name: "customSelect",
152
152
  label: "Custom Select",
153
+ defaultToggleLabel: "Select an option",
153
154
  searchable: "auto",
155
+ searchPlaceholder: "Search",
154
156
  initialPosition: "left"
155
157
  },
156
158
  argTypes: {
157
159
  searchable: {
158
- options: ["auto", "always", "never"],
160
+ options: ["auto", "always", "async", "never"],
159
161
  control: {
160
162
  type: "select"
161
163
  }
@@ -231,4 +233,15 @@ const ManualSearchable = exports.ManualSearchable = {
231
233
  options: generateStandardOptions(4),
232
234
  searchable: "always"
233
235
  }
236
+ };
237
+
238
+ /**
239
+ * Search can be enabled manually by setting `searchable` to `async`.
240
+ * This will always show the search input regardless of the number of options.
241
+ */
242
+ const AsyncSearchable = exports.AsyncSearchable = {
243
+ args: {
244
+ options: generateStandardOptions(0),
245
+ searchable: "async"
246
+ }
234
247
  };
@@ -7,7 +7,8 @@ export type CustomSelectOption = LiHTMLAttributes<HTMLLIElement> & {
7
7
  selectedLabel?: ReactNode;
8
8
  };
9
9
  export type Props = {
10
- searchable?: "auto" | "always" | "never";
10
+ searchable?: "auto" | "always" | "async" | "never";
11
+ searchPlaceholder?: string;
11
12
  name: string;
12
13
  options: CustomSelectOption[];
13
14
  onSelect: (value: string) => void;
@@ -97,9 +97,28 @@ const getOptionText = option => {
97
97
  throw new Error("CustomSelect: options must have a string label or a text property");
98
98
  };
99
99
  exports.getOptionText = getOptionText;
100
+ const getIsSearchable = (searchable, numberOfOptions) => {
101
+ if (searchable === "async") {
102
+ return true;
103
+ }
104
+ if (searchable === "never") {
105
+ return false;
106
+ }
107
+ if (numberOfOptions <= 1) {
108
+ return false;
109
+ }
110
+ if (searchable === "always") {
111
+ return true;
112
+ }
113
+ if (searchable === "auto" && numberOfOptions >= 5) {
114
+ return true;
115
+ }
116
+ return false;
117
+ };
100
118
  const CustomSelectDropdown = _ref => {
101
119
  let {
102
120
  searchable,
121
+ searchPlaceholder,
103
122
  name,
104
123
  options,
105
124
  onSelect,
@@ -116,7 +135,7 @@ const CustomSelectDropdown = _ref => {
116
135
  const dropdownRef = (0, _react.useRef)(null);
117
136
  const searchRef = (0, _react.useRef)(null);
118
137
  const dropdownListRef = (0, _react.useRef)(null);
119
- const isSearchable = searchable !== "never" && options.length > 1 && (searchable === "always" || searchable === "auto" && options.length >= 5);
138
+ const isSearchable = getIsSearchable(searchable, options.length);
120
139
  (0, _react.useEffect)(() => {
121
140
  if (dropdownRef.current) {
122
141
  var _toggle$getBoundingCl, _toggle$getBoundingCl2;
@@ -255,6 +274,7 @@ const CustomSelectDropdown = _ref => {
255
274
  name: "select-search-".concat(name),
256
275
  type: "text",
257
276
  "aria-label": "Search for ".concat(name),
277
+ placeholder: searchPlaceholder,
258
278
  className: "u-no-margin--bottom",
259
279
  onChange: handleSearch,
260
280
  value: search,
@@ -266,7 +286,8 @@ const CustomSelectDropdown = _ref => {
266
286
  }, optionItems));
267
287
  };
268
288
  CustomSelectDropdown.propTypes = {
269
- searchable: _propTypes.default.oneOf(["auto", "always", "never"]),
289
+ searchable: _propTypes.default.oneOf(["auto", "always", "async", "never"]),
290
+ searchPlaceholder: _propTypes.default.string,
270
291
  name: _propTypes.default.string.isRequired,
271
292
  options: _propTypes.default.array.isRequired,
272
293
  onSelect: _propTypes.default.func.isRequired,
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import type { HTMLProps, ReactNode } from "react";
2
+ import type { HTMLProps, ReactNode, RefObject } from "react";
3
3
  import { ClassName, PropsWithSpread } from "../../types";
4
4
  export type Props = PropsWithSpread<{
5
5
  /**
@@ -18,6 +18,10 @@ export type Props = PropsWithSpread<{
18
18
  * Function to handle closing the modal.
19
19
  */
20
20
  close?: () => void | null;
21
+ /**
22
+ * The element that will be focused upon opening the modal.
23
+ */
24
+ focusRef?: RefObject<HTMLElement | null>;
21
25
  /**
22
26
  * The title of the modal.
23
27
  */
@@ -36,5 +40,5 @@ export type Props = PropsWithSpread<{
36
40
  *
37
41
  * The modal component can be used to overlay an area of the screen which can contain a prompt, dialog or interaction.
38
42
  */
39
- export declare const Modal: ({ buttonRow, children, className, close, title, shouldPropagateClickEvent, closeOnOutsideClick, ...wrapperProps }: Props) => React.JSX.Element;
43
+ export declare const Modal: ({ buttonRow, children, className, close, focusRef, title, shouldPropagateClickEvent, closeOnOutsideClick, ...wrapperProps }: Props) => React.JSX.Element;
40
44
  export default Modal;
@@ -21,6 +21,7 @@ const Modal = _ref => {
21
21
  children,
22
22
  className,
23
23
  close,
24
+ focusRef,
24
25
  title,
25
26
  shouldPropagateClickEvent = false,
26
27
  closeOnOutsideClick = true,
@@ -33,6 +34,7 @@ const Modal = _ref => {
33
34
  const titleId = (0, _react.useId)();
34
35
  const shouldClose = (0, _react.useRef)(false);
35
36
  const modalRef = (0, _react.useRef)(null);
37
+ const closeButtonRef = (0, _react.useRef)(null);
36
38
  const focusableModalElements = (0, _react.useRef)(null);
37
39
  const handleTabKey = event => {
38
40
  if (focusableModalElements.current.length > 0) {
@@ -60,24 +62,18 @@ const Modal = _ref => {
60
62
  close();
61
63
  }
62
64
  };
63
- const keyListenersMap = new Map([["Escape", handleEscKey], ["Tab", handleTabKey]]);
64
65
  (0, _react.useEffect)(() => {
65
- modalRef.current.focus();
66
- }, [modalRef]);
67
- const hasCloseButton = !!close;
68
- (0, _react.useEffect)(() => {
69
- var _focusableModalElemen;
70
- focusableModalElements.current = modalRef.current.querySelectorAll(focusableElementSelectors);
71
- let focusIndex = 0;
72
- // when the close button is rendered, focus on the 2nd content element and not the close btn.
73
- if (hasCloseButton && focusableModalElements.current.length > 1) {
74
- focusIndex = 1;
66
+ if (focusRef !== null && focusRef !== void 0 && focusRef.current) {
67
+ focusRef.current.focus();
68
+ } else if (closeButtonRef.current) {
69
+ closeButtonRef.current.focus();
70
+ } else {
71
+ modalRef.current.focus();
75
72
  }
76
- (_focusableModalElemen = focusableModalElements.current[focusIndex]) === null || _focusableModalElemen === void 0 || _focusableModalElemen.focus({
77
- preventScroll: true
78
- });
79
- }, [hasCloseButton]);
73
+ focusableModalElements.current = modalRef.current.querySelectorAll(focusableElementSelectors);
74
+ }, [focusRef]);
80
75
  (0, _react.useEffect)(() => {
76
+ const keyListenersMap = new Map([["Escape", handleEscKey], ["Tab", handleTabKey]]);
81
77
  const keyDown = event => {
82
78
  const listener = keyListenersMap.get(event.code);
83
79
  return listener && listener(event);
@@ -130,11 +126,12 @@ const Modal = _ref => {
130
126
  }, /*#__PURE__*/_react.default.createElement("h2", {
131
127
  className: "p-modal__title",
132
128
  id: titleId
133
- }, title), hasCloseButton && /*#__PURE__*/_react.default.createElement("button", {
129
+ }, title), close && /*#__PURE__*/_react.default.createElement("button", {
134
130
  type: "button",
135
131
  className: "p-modal__close",
136
132
  "aria-label": "Close active modal",
137
- onClick: handleClose
133
+ onClick: handleClose,
134
+ ref: closeButtonRef
138
135
  }, "Close")), /*#__PURE__*/_react.default.createElement("div", {
139
136
  id: descriptionId
140
137
  }, children), !!buttonRow && /*#__PURE__*/_react.default.createElement("footer", {
@@ -4,3 +4,4 @@ declare const meta: Meta<typeof Modal>;
4
4
  export default meta;
5
5
  type Story = StoryObj<typeof Modal>;
6
6
  export declare const Default: Story;
7
+ export declare const Focus: Story;
@@ -3,8 +3,9 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.default = exports.Default = void 0;
6
+ exports.default = exports.Focus = exports.Default = void 0;
7
7
  var _react = _interopRequireWildcard(require("react"));
8
+ var _Button = _interopRequireDefault(require("../Button"));
8
9
  var _Modal = _interopRequireDefault(require("./Modal"));
9
10
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
10
11
  function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
@@ -61,4 +62,35 @@ const Default = exports.Default = {
61
62
  }, /*#__PURE__*/_react.default.createElement("p", null, "This will permanently delete the user \"Simon\".", /*#__PURE__*/_react.default.createElement("br", null), "You cannot undo this action.")) : null);
62
63
  },
63
64
  name: "Default"
65
+ };
66
+ const Focus = exports.Focus = {
67
+ render: _ref2 => {
68
+ let {
69
+ closeOnOutsideClick
70
+ } = _ref2;
71
+ /* eslint-disable react-hooks/rules-of-hooks */
72
+ const [modalOpen, setModalOpen] = (0, _react.useState)(true);
73
+ const buttonRef = (0, _react.useRef)(null);
74
+ /* eslint-enable react-hooks/rules-of-hooks */
75
+
76
+ const closeHandler = () => setModalOpen(false);
77
+ return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement("button", {
78
+ onClick: () => setModalOpen(true)
79
+ }, "Open modal"), modalOpen ? /*#__PURE__*/_react.default.createElement(_Modal.default, {
80
+ close: closeHandler,
81
+ title: "Confirm delete",
82
+ closeOnOutsideClick: closeOnOutsideClick,
83
+ buttonRow: /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement("button", {
84
+ className: "u-no-margin--bottom",
85
+ onClick: closeHandler
86
+ }, "Cancel"), /*#__PURE__*/_react.default.createElement("button", {
87
+ className: "p-button--negative u-no-margin--bottom"
88
+ }, "Delete")),
89
+ focusRef: buttonRef
90
+ }, /*#__PURE__*/_react.default.createElement("p", null, "This will permanently delete the user \"Simon\".", /*#__PURE__*/_react.default.createElement("br", null), "You cannot undo this action."), /*#__PURE__*/_react.default.createElement("p", null, /*#__PURE__*/_react.default.createElement(_Button.default, {
91
+ appearance: "link",
92
+ ref: buttonRef
93
+ }, "More information"))) : null);
94
+ },
95
+ name: "Focus"
64
96
  };
@@ -19,10 +19,31 @@ export type Props = PropsWithSpread<FieldProps, {
19
19
  id?: string | null;
20
20
  name?: string;
21
21
  disabled?: boolean;
22
+ /**
23
+ * Toggle label when no option is selected
24
+ *
25
+ * @default - "Select an option"
26
+ */
27
+ defaultToggleLabel?: string;
22
28
  wrapperClassName?: ClassName;
23
29
  toggleClassName?: ClassName;
24
30
  dropdownClassName?: string;
25
- searchable?: "auto" | "always" | "never";
31
+ /**
32
+ * Whether the select is searchable.
33
+ * - `auto`: the select will be searchable if it has 5 or more options.
34
+ * - `always`: the select will always be searchable if there is at least 1 option.
35
+ * - `async`: the select will always be searchable.
36
+ * - `never`: the select will never be searchable.
37
+ *
38
+ * @default - "auto"
39
+ */
40
+ searchable?: "auto" | "always" | "async" | "never";
41
+ /**
42
+ * Placeholder text for the search input when searchable is enabled.
43
+ *
44
+ * @default - "Search"
45
+ */
46
+ searchPlaceholder?: string;
26
47
  takeFocus?: boolean;
27
48
  header?: ReactNode;
28
49
  selectRef?: SelectRef;
@@ -33,5 +54,5 @@ export type Props = PropsWithSpread<FieldProps, {
33
54
  *
34
55
  * The aim of this component is to provide a select component with customisable options and a dropdown menu, whilst maintaining accessibility and usability.
35
56
  */
36
- declare const CustomSelect: ({ value, options, onChange, onSearch, id, name, disabled, success, error, help, wrapperClassName, toggleClassName, dropdownClassName, searchable, takeFocus, header, selectRef, initialPosition, ...fieldProps }: Props) => React.JSX.Element;
57
+ declare const CustomSelect: ({ value, options, onChange, onSearch, id, name, disabled, success, error, help, wrapperClassName, toggleClassName, dropdownClassName, defaultToggleLabel, searchable, searchPlaceholder, takeFocus, header, selectRef, initialPosition, ...fieldProps }: Props) => React.JSX.Element;
37
58
  export default CustomSelect;
@@ -1,4 +1,4 @@
1
- var _excluded = ["value", "options", "onChange", "onSearch", "id", "name", "disabled", "success", "error", "help", "wrapperClassName", "toggleClassName", "dropdownClassName", "searchable", "takeFocus", "header", "selectRef", "initialPosition"];
1
+ var _excluded = ["value", "options", "onChange", "onSearch", "id", "name", "disabled", "success", "error", "help", "wrapperClassName", "toggleClassName", "dropdownClassName", "defaultToggleLabel", "searchable", "searchPlaceholder", "takeFocus", "header", "selectRef", "initialPosition"];
2
2
  function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
3
3
  function _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }
4
4
  function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
@@ -29,7 +29,9 @@ var CustomSelect = _ref => {
29
29
  wrapperClassName,
30
30
  toggleClassName,
31
31
  dropdownClassName,
32
+ defaultToggleLabel = "Select an option",
32
33
  searchable = "auto",
34
+ searchPlaceholder = "Search",
33
35
  takeFocus,
34
36
  header,
35
37
  selectRef,
@@ -75,7 +77,7 @@ var CustomSelect = _ref => {
75
77
  var selectedOption = options.find(option => option.value === value);
76
78
  var toggleLabel = /*#__PURE__*/React.createElement("span", {
77
79
  className: "toggle-label u-truncate"
78
- }, selectedOption ? selectedOption.selectedLabel || getOptionText(selectedOption) : "Select an option");
80
+ }, selectedOption ? selectedOption.selectedLabel || getOptionText(selectedOption) : defaultToggleLabel);
79
81
  var handleSelect = value => {
80
82
  var _document$getElementB3;
81
83
  (_document$getElementB3 = document.getElementById(selectId)) === null || _document$getElementB3 === void 0 || _document$getElementB3.focus();
@@ -122,6 +124,7 @@ var CustomSelect = _ref => {
122
124
  position: initialPosition
123
125
  }, close => /*#__PURE__*/React.createElement(CustomSelectDropdown, {
124
126
  searchable: searchable,
127
+ searchPlaceholder: searchPlaceholder,
125
128
  onSearch: onSearch,
126
129
  name: name || "",
127
130
  options: options || [],
@@ -32,3 +32,8 @@ export declare const AutoSearchable: Story;
32
32
  * Search can be enabled manually by setting `searchable` to `always`.
33
33
  */
34
34
  export declare const ManualSearchable: Story;
35
+ /**
36
+ * Search can be enabled manually by setting `searchable` to `async`.
37
+ * This will always show the search input regardless of the number of options.
38
+ */
39
+ export declare const AsyncSearchable: Story;
@@ -145,12 +145,14 @@ var meta = {
145
145
  args: {
146
146
  name: "customSelect",
147
147
  label: "Custom Select",
148
+ defaultToggleLabel: "Select an option",
148
149
  searchable: "auto",
150
+ searchPlaceholder: "Search",
149
151
  initialPosition: "left"
150
152
  },
151
153
  argTypes: {
152
154
  searchable: {
153
- options: ["auto", "always", "never"],
155
+ options: ["auto", "always", "async", "never"],
154
156
  control: {
155
157
  type: "select"
156
158
  }
@@ -225,4 +227,15 @@ export var ManualSearchable = {
225
227
  options: generateStandardOptions(4),
226
228
  searchable: "always"
227
229
  }
230
+ };
231
+
232
+ /**
233
+ * Search can be enabled manually by setting `searchable` to `async`.
234
+ * This will always show the search input regardless of the number of options.
235
+ */
236
+ export var AsyncSearchable = {
237
+ args: {
238
+ options: generateStandardOptions(0),
239
+ searchable: "async"
240
+ }
228
241
  };
@@ -7,7 +7,8 @@ export type CustomSelectOption = LiHTMLAttributes<HTMLLIElement> & {
7
7
  selectedLabel?: ReactNode;
8
8
  };
9
9
  export type Props = {
10
- searchable?: "auto" | "always" | "never";
10
+ searchable?: "auto" | "always" | "async" | "never";
11
+ searchPlaceholder?: string;
11
12
  name: string;
12
13
  options: CustomSelectOption[];
13
14
  onSelect: (value: string) => void;
@@ -83,9 +83,28 @@ export var getOptionText = option => {
83
83
  }
84
84
  throw new Error("CustomSelect: options must have a string label or a text property");
85
85
  };
86
+ var getIsSearchable = (searchable, numberOfOptions) => {
87
+ if (searchable === "async") {
88
+ return true;
89
+ }
90
+ if (searchable === "never") {
91
+ return false;
92
+ }
93
+ if (numberOfOptions <= 1) {
94
+ return false;
95
+ }
96
+ if (searchable === "always") {
97
+ return true;
98
+ }
99
+ if (searchable === "auto" && numberOfOptions >= 5) {
100
+ return true;
101
+ }
102
+ return false;
103
+ };
86
104
  var CustomSelectDropdown = _ref => {
87
105
  var {
88
106
  searchable,
107
+ searchPlaceholder,
89
108
  name,
90
109
  options,
91
110
  onSelect,
@@ -102,7 +121,7 @@ var CustomSelectDropdown = _ref => {
102
121
  var dropdownRef = useRef(null);
103
122
  var searchRef = useRef(null);
104
123
  var dropdownListRef = useRef(null);
105
- var isSearchable = searchable !== "never" && options.length > 1 && (searchable === "always" || searchable === "auto" && options.length >= 5);
124
+ var isSearchable = getIsSearchable(searchable, options.length);
106
125
  useEffect(() => {
107
126
  if (dropdownRef.current) {
108
127
  var _toggle$getBoundingCl, _toggle$getBoundingCl2;
@@ -241,6 +260,7 @@ var CustomSelectDropdown = _ref => {
241
260
  name: "select-search-".concat(name),
242
261
  type: "text",
243
262
  "aria-label": "Search for ".concat(name),
263
+ placeholder: searchPlaceholder,
244
264
  className: "u-no-margin--bottom",
245
265
  onChange: handleSearch,
246
266
  value: search,
@@ -252,7 +272,8 @@ var CustomSelectDropdown = _ref => {
252
272
  }, optionItems));
253
273
  };
254
274
  CustomSelectDropdown.propTypes = {
255
- searchable: _pt.oneOf(["auto", "always", "never"]),
275
+ searchable: _pt.oneOf(["auto", "always", "async", "never"]),
276
+ searchPlaceholder: _pt.string,
256
277
  name: _pt.string.isRequired,
257
278
  options: _pt.array.isRequired,
258
279
  onSelect: _pt.func.isRequired,
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import type { HTMLProps, ReactNode } from "react";
2
+ import type { HTMLProps, ReactNode, RefObject } from "react";
3
3
  import { ClassName, PropsWithSpread } from "../../types";
4
4
  export type Props = PropsWithSpread<{
5
5
  /**
@@ -18,6 +18,10 @@ export type Props = PropsWithSpread<{
18
18
  * Function to handle closing the modal.
19
19
  */
20
20
  close?: () => void | null;
21
+ /**
22
+ * The element that will be focused upon opening the modal.
23
+ */
24
+ focusRef?: RefObject<HTMLElement | null>;
21
25
  /**
22
26
  * The title of the modal.
23
27
  */
@@ -36,5 +40,5 @@ export type Props = PropsWithSpread<{
36
40
  *
37
41
  * The modal component can be used to overlay an area of the screen which can contain a prompt, dialog or interaction.
38
42
  */
39
- export declare const Modal: ({ buttonRow, children, className, close, title, shouldPropagateClickEvent, closeOnOutsideClick, ...wrapperProps }: Props) => React.JSX.Element;
43
+ export declare const Modal: ({ buttonRow, children, className, close, focusRef, title, shouldPropagateClickEvent, closeOnOutsideClick, ...wrapperProps }: Props) => React.JSX.Element;
40
44
  export default Modal;
@@ -1,4 +1,4 @@
1
- var _excluded = ["buttonRow", "children", "className", "close", "title", "shouldPropagateClickEvent", "closeOnOutsideClick"];
1
+ var _excluded = ["buttonRow", "children", "className", "close", "focusRef", "title", "shouldPropagateClickEvent", "closeOnOutsideClick"];
2
2
  function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
3
3
  function _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }
4
4
  function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
@@ -15,6 +15,7 @@ export var Modal = _ref => {
15
15
  children,
16
16
  className,
17
17
  close,
18
+ focusRef,
18
19
  title,
19
20
  shouldPropagateClickEvent = false,
20
21
  closeOnOutsideClick = true
@@ -27,6 +28,7 @@ export var Modal = _ref => {
27
28
  var titleId = useId();
28
29
  var shouldClose = useRef(false);
29
30
  var modalRef = useRef(null);
31
+ var closeButtonRef = useRef(null);
30
32
  var focusableModalElements = useRef(null);
31
33
  var handleTabKey = event => {
32
34
  if (focusableModalElements.current.length > 0) {
@@ -54,24 +56,18 @@ export var Modal = _ref => {
54
56
  close();
55
57
  }
56
58
  };
57
- var keyListenersMap = new Map([["Escape", handleEscKey], ["Tab", handleTabKey]]);
58
59
  useEffect(() => {
59
- modalRef.current.focus();
60
- }, [modalRef]);
61
- var hasCloseButton = !!close;
62
- useEffect(() => {
63
- var _focusableModalElemen;
64
- focusableModalElements.current = modalRef.current.querySelectorAll(focusableElementSelectors);
65
- var focusIndex = 0;
66
- // when the close button is rendered, focus on the 2nd content element and not the close btn.
67
- if (hasCloseButton && focusableModalElements.current.length > 1) {
68
- focusIndex = 1;
60
+ if (focusRef !== null && focusRef !== void 0 && focusRef.current) {
61
+ focusRef.current.focus();
62
+ } else if (closeButtonRef.current) {
63
+ closeButtonRef.current.focus();
64
+ } else {
65
+ modalRef.current.focus();
69
66
  }
70
- (_focusableModalElemen = focusableModalElements.current[focusIndex]) === null || _focusableModalElemen === void 0 || _focusableModalElemen.focus({
71
- preventScroll: true
72
- });
73
- }, [hasCloseButton]);
67
+ focusableModalElements.current = modalRef.current.querySelectorAll(focusableElementSelectors);
68
+ }, [focusRef]);
74
69
  useEffect(() => {
70
+ var keyListenersMap = new Map([["Escape", handleEscKey], ["Tab", handleTabKey]]);
75
71
  var keyDown = event => {
76
72
  var listener = keyListenersMap.get(event.code);
77
73
  return listener && listener(event);
@@ -124,11 +120,12 @@ export var Modal = _ref => {
124
120
  }, /*#__PURE__*/React.createElement("h2", {
125
121
  className: "p-modal__title",
126
122
  id: titleId
127
- }, title), hasCloseButton && /*#__PURE__*/React.createElement("button", {
123
+ }, title), close && /*#__PURE__*/React.createElement("button", {
128
124
  type: "button",
129
125
  className: "p-modal__close",
130
126
  "aria-label": "Close active modal",
131
- onClick: handleClose
127
+ onClick: handleClose,
128
+ ref: closeButtonRef
132
129
  }, "Close")), /*#__PURE__*/React.createElement("div", {
133
130
  id: descriptionId
134
131
  }, children), !!buttonRow && /*#__PURE__*/React.createElement("footer", {
@@ -4,3 +4,4 @@ declare const meta: Meta<typeof Modal>;
4
4
  export default meta;
5
5
  type Story = StoryObj<typeof Modal>;
6
6
  export declare const Default: Story;
7
+ export declare const Focus: Story;
@@ -1,5 +1,6 @@
1
- import { useState } from "react";
1
+ import { useRef, useState } from "react";
2
2
  import React from "react";
3
+ import Button from "../Button";
3
4
  import Modal from "./Modal";
4
5
  var Template = args => {
5
6
  return /*#__PURE__*/React.createElement("div", {
@@ -53,4 +54,35 @@ export var Default = {
53
54
  }, /*#__PURE__*/React.createElement("p", null, "This will permanently delete the user \"Simon\".", /*#__PURE__*/React.createElement("br", null), "You cannot undo this action.")) : null);
54
55
  },
55
56
  name: "Default"
57
+ };
58
+ export var Focus = {
59
+ render: _ref2 => {
60
+ var {
61
+ closeOnOutsideClick
62
+ } = _ref2;
63
+ /* eslint-disable react-hooks/rules-of-hooks */
64
+ var [modalOpen, setModalOpen] = useState(true);
65
+ var buttonRef = useRef(null);
66
+ /* eslint-enable react-hooks/rules-of-hooks */
67
+
68
+ var closeHandler = () => setModalOpen(false);
69
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("button", {
70
+ onClick: () => setModalOpen(true)
71
+ }, "Open modal"), modalOpen ? /*#__PURE__*/React.createElement(Modal, {
72
+ close: closeHandler,
73
+ title: "Confirm delete",
74
+ closeOnOutsideClick: closeOnOutsideClick,
75
+ buttonRow: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("button", {
76
+ className: "u-no-margin--bottom",
77
+ onClick: closeHandler
78
+ }, "Cancel"), /*#__PURE__*/React.createElement("button", {
79
+ className: "p-button--negative u-no-margin--bottom"
80
+ }, "Delete")),
81
+ focusRef: buttonRef
82
+ }, /*#__PURE__*/React.createElement("p", null, "This will permanently delete the user \"Simon\".", /*#__PURE__*/React.createElement("br", null), "You cannot undo this action."), /*#__PURE__*/React.createElement("p", null, /*#__PURE__*/React.createElement(Button, {
83
+ appearance: "link",
84
+ ref: buttonRef
85
+ }, "More information"))) : null);
86
+ },
87
+ name: "Focus"
56
88
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonical/react-components",
3
- "version": "3.5.1",
3
+ "version": "3.7.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "author": {
@@ -93,7 +93,7 @@
93
93
  "tsc-alias": "1.8.10",
94
94
  "typescript": "5.7.3",
95
95
  "typescript-eslint": "8.24.1",
96
- "vanilla-framework": "4.35.0",
96
+ "vanilla-framework": "4.37.1",
97
97
  "wait-on": "8.0.2",
98
98
  "webpack": "5.98.0"
99
99
  },