@reykjavik/hanna-react 0.10.156 → 0.10.158

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/CHANGELOG.md CHANGED
@@ -4,18 +4,30 @@
4
4
 
5
5
  - ... <!-- Add new lines here. -->
6
6
 
7
- ## 0.10.156
7
+ ## 0.10.158
8
8
 
9
- _2025-06-23_
9
+ _2025-09-16_
10
+
11
+ - `Multiselect`
12
+ - feat: Add prop `onDropdown` prop
13
+ - fix: Scope "global" keyboard event listener to the component's element
14
+
15
+ ## 0.10.157
16
+
17
+ _2025-09-16_
10
18
 
11
- - feat: Allow `MainMenu2` items to be `null` or `false` also
19
+ - `Multiselect`
20
+ - fix: Sync `.checked` prop of keyboard-toggled `input`s with visual state
21
+ - `Selectbox`:
22
+ - fix: Remove stray `modifier` prop from `SelectboxProps`
23
+ - docs: Add JSDoc comments for `FormfieldProps.renderInput` parameters
12
24
 
13
- ## 0.10.155
25
+ ## 0.10.155 – 0.10.156
14
26
 
15
27
  _2025-06-23_
16
28
 
17
- - feat: Allow `MainMenu2` items to be `undefined` for easier conditional item
18
- array population
29
+ - feat: Allow `MainMenu2` items to be `undefined`, `null` or `false` for
30
+ easier conditional item array population
19
31
 
20
32
  ## 0.10.154
21
33
 
package/FormField.d.ts CHANGED
@@ -5,7 +5,7 @@ import { SSRSupportProps, WrapperElmProps } from './utils.js';
5
5
  type InputClassNames = {
6
6
  /** Basic/raw FormField BEM name */
7
7
  bem: string;
8
- /** Standard name for "<input/>"-styled controls */
8
+ /** Standard name for "<input/>"-styled controls or container elements */
9
9
  input: string;
10
10
  /** Standard name for radio/checkbox group conotrols */
11
11
  options: string;
@@ -77,9 +77,30 @@ type FormFieldProps = FormFieldGroupWrappingProps & {
77
77
  group?: boolean | 'inputlike';
78
78
  empty?: boolean;
79
79
  filled?: boolean;
80
- renderInput(className: InputClassNames, inputProps: FormFieldInputProps & {
80
+ renderInput(
81
+ /**
82
+ * Object with standard classNames for various input-related elements
83
+ */
84
+ className: InputClassNames,
85
+ /**
86
+ * The input's managed props, including `id`, a bunch of `aria-*` attributes,
87
+ * `disabled`, `readOnly`, and `required`.
88
+ */
89
+ inputProps: FormFieldInputProps & {
81
90
  id: string;
82
- }, addFocusProps: FocusPropMaker, isBrowser?: boolean): JSX.Element;
91
+ },
92
+ /**
93
+ * Function that generates `onFocus` and `onBlur` event handlers for the
94
+ * `___input` element, capturing bubbled `FocusEvent`s and emulating
95
+ * `onFocusOut` and `onFocusIn` behavior. This is useful for multi-input
96
+ * controls.
97
+ */
98
+ addFocusProps: FocusPropMaker,
99
+ /**
100
+ * Sugar flag indicating whether the component is being rendered in a browser
101
+ * environment or not.
102
+ */
103
+ isBrowser?: boolean): JSX.Element;
83
104
  };
84
105
  export declare const FormField: (props: FormFieldProps) => JSX.Element;
85
106
  export default FormField;
package/Multiselect.d.ts CHANGED
@@ -55,6 +55,8 @@ export type MultiselectProps = TogglerGroupFieldProps<string, {
55
55
  forceSearchable?: boolean;
56
56
  texts?: MultiselectI18n;
57
57
  lang?: HannaLang;
58
+ /** Fires whenever the dropdown menu is opened or closed */
59
+ onDropdown?: (isOpen: boolean) => void;
58
60
  };
59
61
  export declare const Multiselect: {
60
62
  (props: MultiselectProps): JSX.Element;
package/Multiselect.js CHANGED
@@ -58,7 +58,7 @@ const defaultTexts = {
58
58
  },
59
59
  };
60
60
  const Multiselect = (props) => {
61
- const { onSelected, options: _options, disabled: _disabled, readOnly } = props;
61
+ const { onSelected, onDropdown, options: _options, disabled: _disabled, readOnly, } = props;
62
62
  const disabled = _disabled === true;
63
63
  const disableds = !disabled && _disabled;
64
64
  const name = (0, useDomid_js_1.useDomid)(props.name);
@@ -80,6 +80,9 @@ const Multiselect = (props) => {
80
80
  setSearchQuery('');
81
81
  setActiveItemIndex(-1);
82
82
  }
83
+ if (onDropdown && newIsOpen !== isOpen) {
84
+ onDropdown(newIsOpen);
85
+ }
83
86
  return newIsOpen;
84
87
  });
85
88
  };
@@ -131,48 +134,53 @@ const Multiselect = (props) => {
131
134
  toggleOpen(activeItemIndex > -1 ? true : !isOpen);
132
135
  }
133
136
  };
134
- // When the dropdown is open, add keydown handlers
135
- (0, react_1.useEffect)(() => {
136
- if (!isOpen) {
137
+ const handleWrapperKeyDown = (e) => {
138
+ var _a;
139
+ if (!isOpen || !((_a = inputWrapperRef.current) === null || _a === void 0 ? void 0 : _a.contains(e.target))) {
137
140
  return;
138
141
  }
139
- const handleKeyDown = (e) => {
140
- const inputElm = inputRef.current;
141
- if (e.key === 'ArrowUp') {
142
- e.preventDefault();
143
- inputElm.focus();
144
- setActiveItemIndex((prevIndex) => prevIndex === 0 ? filteredOptions.length - 1 : prevIndex - 1);
145
- }
146
- else if (e.key === 'ArrowDown') {
147
- e.preventDefault();
148
- inputElm.focus();
149
- setActiveItemIndex((prevIndex) => prevIndex === filteredOptions.length - 1 ? 0 : prevIndex + 1);
142
+ const inputElm = inputRef.current;
143
+ if (e.key === 'ArrowUp') {
144
+ e.preventDefault();
145
+ inputElm.focus();
146
+ setActiveItemIndex((prevIndex) => prevIndex === 0 ? filteredOptions.length - 1 : prevIndex - 1);
147
+ }
148
+ else if (e.key === 'ArrowDown') {
149
+ e.preventDefault();
150
+ inputElm.focus();
151
+ setActiveItemIndex((prevIndex) => prevIndex === filteredOptions.length - 1 ? 0 : prevIndex + 1);
152
+ }
153
+ else if (e.key === 'Escape') {
154
+ e.preventDefault();
155
+ inputElm.blur();
156
+ inputElm.focus();
157
+ toggleOpen(false);
158
+ }
159
+ else if (e.key === 'Enter' || e.key === ' ') {
160
+ if (e.target.closest('.Multiselect__currentvalues')) {
161
+ return;
150
162
  }
151
- else if (e.key === 'Escape') {
163
+ const focusInRange = activeItemIndex >= 0 && activeItemIndex < filteredOptions.length;
164
+ if (focusInRange) {
152
165
  e.preventDefault();
153
- inputElm.blur();
154
- inputElm.focus();
155
- toggleOpen(false);
156
- }
157
- else if (e.key === 'Enter' || e.key === ' ') {
158
- if (e.target.closest('.Multiselect__currentvalues')) {
159
- return;
160
- }
161
- const focusInRange = activeItemIndex >= 0 && activeItemIndex < filteredOptions.length;
162
- if (focusInRange) {
163
- e.preventDefault();
164
- const selItem = filteredOptions[activeItemIndex];
165
- if (selItem) {
166
- handleCheckboxSelection(selItem);
167
- }
166
+ const selItem = filteredOptions[activeItemIndex];
167
+ if (selItem) {
168
+ // Manually toggle the checkbox, to ensure that uncontrolled
169
+ // components (e.g. screen readers) are in sync with the visual state.
170
+ let input;
171
+ e.currentTarget
172
+ .querySelectorAll(`input[type="checkbox"]`)
173
+ .forEach((elm) => {
174
+ if (elm.value === selItem.value) {
175
+ input = elm;
176
+ }
177
+ });
178
+ input.checked = !input.checked;
179
+ handleCheckboxSelection(selItem);
168
180
  }
169
181
  }
170
- };
171
- document.addEventListener('keydown', handleKeyDown);
172
- return () => {
173
- document.removeEventListener('keydown', handleKeyDown);
174
- };
175
- }, [activeItemIndex, filteredOptions, isOpen, handleCheckboxSelection, inputRef]);
182
+ }
183
+ };
176
184
  // Auto-close the dropdown when focus has left the building
177
185
  (0, react_1.useEffect)(() => {
178
186
  const wrapperDiv = inputWrapperRef.current;
@@ -200,7 +208,7 @@ const Multiselect = (props) => {
200
208
  }, [activeItemIndex]);
201
209
  return (react_1.default.createElement(FormField_js_1.default, Object.assign({ extraClassName: (0, hanna_utils_1.modifiedClass)('Multiselect', props.nowrap && 'nowrap'), group: "inputlike", filled: filled, empty: empty }, (0, FormField_js_1.getFormFieldWrapperProps)(props), { renderInput: (className, inputProps, addFocusProps, isBrowser) => {
202
210
  const { id } = inputProps;
203
- return (react_1.default.createElement("div", Object.assign({ className: (0, hanna_utils_1.modifiedClass)('Multiselect__input', [isOpen && 'open'], className.input) }, addFocusProps(), { "data-sprinkled": isBrowser, ref: inputWrapperRef }),
211
+ return (react_1.default.createElement("div", Object.assign({ className: (0, hanna_utils_1.modifiedClass)('Multiselect__input', [isOpen && 'open'], className.input) }, addFocusProps(), { "data-sprinkled": isBrowser, ref: inputWrapperRef, onKeyDown: handleWrapperKeyDown }),
204
212
  !isBrowser ? null : isSearchable ? (react_1.default.createElement("input", { className: "Multiselect__search", id: `toggler:${id}`, "aria-label": texts.search, "aria-controls": id, "data-expanded": isOpen || undefined, onChange: handleInputChange, onKeyDown: handleInputKeyDown, onClick: () => toggleOpen(), value: searchQuery,
205
213
  // onFocus={handleInputFocus}
206
214
  placeholder: placeholderText, disabled: disabled, ref: inputRef })) : (react_1.default.createElement("button", { className: "Multiselect__toggler", id: `toggler:${id}`, type: "button", "aria-label": texts.buttonShow, "aria-controls": id, "aria-expanded": isOpen, onClick: () => toggleOpen(), disabled: disabled,
package/Selectbox.d.ts CHANGED
@@ -3,7 +3,7 @@ import { FormFieldWrappingProps } from './FormField.js';
3
3
  export { type SelectboxOption, type SelectboxOptions as SelectboxOptionList, } from '@hugsmidjan/react/Selectbox';
4
4
  /** @deprecated Use `SelectboxOptionList` instead (Will be removed in v0.11) */
5
5
  export type SelectboxOptions = _SelectboxOptions;
6
- export type SelectboxProps<O extends OptionOrValue = OptionOrValue> = FormFieldWrappingProps & Omit<_SelectboxProps<O>, 'bem'> & {
6
+ export type SelectboxProps<O extends OptionOrValue = OptionOrValue> = FormFieldWrappingProps & Omit<_SelectboxProps<O>, 'bem' | 'modifier'> & {
7
7
  small?: boolean;
8
8
  };
9
9
  export declare const Selectbox: <O extends OptionOrValue>(props: SelectboxProps<O>) => JSX.Element;
@@ -5,7 +5,7 @@ import { SSRSupportProps, WrapperElmProps } from './utils.js';
5
5
  type InputClassNames = {
6
6
  /** Basic/raw FormField BEM name */
7
7
  bem: string;
8
- /** Standard name for "<input/>"-styled controls */
8
+ /** Standard name for "<input/>"-styled controls or container elements */
9
9
  input: string;
10
10
  /** Standard name for radio/checkbox group conotrols */
11
11
  options: string;
@@ -77,9 +77,30 @@ type FormFieldProps = FormFieldGroupWrappingProps & {
77
77
  group?: boolean | 'inputlike';
78
78
  empty?: boolean;
79
79
  filled?: boolean;
80
- renderInput(className: InputClassNames, inputProps: FormFieldInputProps & {
80
+ renderInput(
81
+ /**
82
+ * Object with standard classNames for various input-related elements
83
+ */
84
+ className: InputClassNames,
85
+ /**
86
+ * The input's managed props, including `id`, a bunch of `aria-*` attributes,
87
+ * `disabled`, `readOnly`, and `required`.
88
+ */
89
+ inputProps: FormFieldInputProps & {
81
90
  id: string;
82
- }, addFocusProps: FocusPropMaker, isBrowser?: boolean): JSX.Element;
91
+ },
92
+ /**
93
+ * Function that generates `onFocus` and `onBlur` event handlers for the
94
+ * `___input` element, capturing bubbled `FocusEvent`s and emulating
95
+ * `onFocusOut` and `onFocusIn` behavior. This is useful for multi-input
96
+ * controls.
97
+ */
98
+ addFocusProps: FocusPropMaker,
99
+ /**
100
+ * Sugar flag indicating whether the component is being rendered in a browser
101
+ * environment or not.
102
+ */
103
+ isBrowser?: boolean): JSX.Element;
83
104
  };
84
105
  export declare const FormField: (props: FormFieldProps) => JSX.Element;
85
106
  export default FormField;
@@ -55,6 +55,8 @@ export type MultiselectProps = TogglerGroupFieldProps<string, {
55
55
  forceSearchable?: boolean;
56
56
  texts?: MultiselectI18n;
57
57
  lang?: HannaLang;
58
+ /** Fires whenever the dropdown menu is opened or closed */
59
+ onDropdown?: (isOpen: boolean) => void;
58
60
  };
59
61
  export declare const Multiselect: {
60
62
  (props: MultiselectProps): JSX.Element;
@@ -54,7 +54,7 @@ const defaultTexts = {
54
54
  },
55
55
  };
56
56
  export const Multiselect = (props) => {
57
- const { onSelected, options: _options, disabled: _disabled, readOnly } = props;
57
+ const { onSelected, onDropdown, options: _options, disabled: _disabled, readOnly, } = props;
58
58
  const disabled = _disabled === true;
59
59
  const disableds = !disabled && _disabled;
60
60
  const name = useDomid(props.name);
@@ -76,6 +76,9 @@ export const Multiselect = (props) => {
76
76
  setSearchQuery('');
77
77
  setActiveItemIndex(-1);
78
78
  }
79
+ if (onDropdown && newIsOpen !== isOpen) {
80
+ onDropdown(newIsOpen);
81
+ }
79
82
  return newIsOpen;
80
83
  });
81
84
  };
@@ -127,48 +130,53 @@ export const Multiselect = (props) => {
127
130
  toggleOpen(activeItemIndex > -1 ? true : !isOpen);
128
131
  }
129
132
  };
130
- // When the dropdown is open, add keydown handlers
131
- useEffect(() => {
132
- if (!isOpen) {
133
+ const handleWrapperKeyDown = (e) => {
134
+ var _a;
135
+ if (!isOpen || !((_a = inputWrapperRef.current) === null || _a === void 0 ? void 0 : _a.contains(e.target))) {
133
136
  return;
134
137
  }
135
- const handleKeyDown = (e) => {
136
- const inputElm = inputRef.current;
137
- if (e.key === 'ArrowUp') {
138
- e.preventDefault();
139
- inputElm.focus();
140
- setActiveItemIndex((prevIndex) => prevIndex === 0 ? filteredOptions.length - 1 : prevIndex - 1);
141
- }
142
- else if (e.key === 'ArrowDown') {
143
- e.preventDefault();
144
- inputElm.focus();
145
- setActiveItemIndex((prevIndex) => prevIndex === filteredOptions.length - 1 ? 0 : prevIndex + 1);
138
+ const inputElm = inputRef.current;
139
+ if (e.key === 'ArrowUp') {
140
+ e.preventDefault();
141
+ inputElm.focus();
142
+ setActiveItemIndex((prevIndex) => prevIndex === 0 ? filteredOptions.length - 1 : prevIndex - 1);
143
+ }
144
+ else if (e.key === 'ArrowDown') {
145
+ e.preventDefault();
146
+ inputElm.focus();
147
+ setActiveItemIndex((prevIndex) => prevIndex === filteredOptions.length - 1 ? 0 : prevIndex + 1);
148
+ }
149
+ else if (e.key === 'Escape') {
150
+ e.preventDefault();
151
+ inputElm.blur();
152
+ inputElm.focus();
153
+ toggleOpen(false);
154
+ }
155
+ else if (e.key === 'Enter' || e.key === ' ') {
156
+ if (e.target.closest('.Multiselect__currentvalues')) {
157
+ return;
146
158
  }
147
- else if (e.key === 'Escape') {
159
+ const focusInRange = activeItemIndex >= 0 && activeItemIndex < filteredOptions.length;
160
+ if (focusInRange) {
148
161
  e.preventDefault();
149
- inputElm.blur();
150
- inputElm.focus();
151
- toggleOpen(false);
152
- }
153
- else if (e.key === 'Enter' || e.key === ' ') {
154
- if (e.target.closest('.Multiselect__currentvalues')) {
155
- return;
156
- }
157
- const focusInRange = activeItemIndex >= 0 && activeItemIndex < filteredOptions.length;
158
- if (focusInRange) {
159
- e.preventDefault();
160
- const selItem = filteredOptions[activeItemIndex];
161
- if (selItem) {
162
- handleCheckboxSelection(selItem);
163
- }
162
+ const selItem = filteredOptions[activeItemIndex];
163
+ if (selItem) {
164
+ // Manually toggle the checkbox, to ensure that uncontrolled
165
+ // components (e.g. screen readers) are in sync with the visual state.
166
+ let input;
167
+ e.currentTarget
168
+ .querySelectorAll(`input[type="checkbox"]`)
169
+ .forEach((elm) => {
170
+ if (elm.value === selItem.value) {
171
+ input = elm;
172
+ }
173
+ });
174
+ input.checked = !input.checked;
175
+ handleCheckboxSelection(selItem);
164
176
  }
165
177
  }
166
- };
167
- document.addEventListener('keydown', handleKeyDown);
168
- return () => {
169
- document.removeEventListener('keydown', handleKeyDown);
170
- };
171
- }, [activeItemIndex, filteredOptions, isOpen, handleCheckboxSelection, inputRef]);
178
+ }
179
+ };
172
180
  // Auto-close the dropdown when focus has left the building
173
181
  useEffect(() => {
174
182
  const wrapperDiv = inputWrapperRef.current;
@@ -196,7 +204,7 @@ export const Multiselect = (props) => {
196
204
  }, [activeItemIndex]);
197
205
  return (React.createElement(FormField, Object.assign({ extraClassName: modifiedClass('Multiselect', props.nowrap && 'nowrap'), group: "inputlike", filled: filled, empty: empty }, getFormFieldWrapperProps(props), { renderInput: (className, inputProps, addFocusProps, isBrowser) => {
198
206
  const { id } = inputProps;
199
- return (React.createElement("div", Object.assign({ className: modifiedClass('Multiselect__input', [isOpen && 'open'], className.input) }, addFocusProps(), { "data-sprinkled": isBrowser, ref: inputWrapperRef }),
207
+ return (React.createElement("div", Object.assign({ className: modifiedClass('Multiselect__input', [isOpen && 'open'], className.input) }, addFocusProps(), { "data-sprinkled": isBrowser, ref: inputWrapperRef, onKeyDown: handleWrapperKeyDown }),
200
208
  !isBrowser ? null : isSearchable ? (React.createElement("input", { className: "Multiselect__search", id: `toggler:${id}`, "aria-label": texts.search, "aria-controls": id, "data-expanded": isOpen || undefined, onChange: handleInputChange, onKeyDown: handleInputKeyDown, onClick: () => toggleOpen(), value: searchQuery,
201
209
  // onFocus={handleInputFocus}
202
210
  placeholder: placeholderText, disabled: disabled, ref: inputRef })) : (React.createElement("button", { className: "Multiselect__toggler", id: `toggler:${id}`, type: "button", "aria-label": texts.buttonShow, "aria-controls": id, "aria-expanded": isOpen, onClick: () => toggleOpen(), disabled: disabled,
@@ -3,7 +3,7 @@ import { FormFieldWrappingProps } from './FormField.js';
3
3
  export { type SelectboxOption, type SelectboxOptions as SelectboxOptionList, } from '@hugsmidjan/react/Selectbox';
4
4
  /** @deprecated Use `SelectboxOptionList` instead (Will be removed in v0.11) */
5
5
  export type SelectboxOptions = _SelectboxOptions;
6
- export type SelectboxProps<O extends OptionOrValue = OptionOrValue> = FormFieldWrappingProps & Omit<_SelectboxProps<O>, 'bem'> & {
6
+ export type SelectboxProps<O extends OptionOrValue = OptionOrValue> = FormFieldWrappingProps & Omit<_SelectboxProps<O>, 'bem' | 'modifier'> & {
7
7
  small?: boolean;
8
8
  };
9
9
  export declare const Selectbox: <O extends OptionOrValue>(props: SelectboxProps<O>) => JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reykjavik/hanna-react",
3
- "version": "0.10.156",
3
+ "version": "0.10.158",
4
4
  "author": "Reykjavík (http://www.reykjavik.is)",
5
5
  "contributors": [
6
6
  "Hugsmiðjan ehf (http://www.hugsmidjan.is)",