@reykjavik/hanna-react 0.10.92 → 0.10.93

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.
Files changed (62) hide show
  1. package/ArticleCarousel/_ArticleCarouselCard.d.ts +2 -1
  2. package/CHANGELOG.md +16 -0
  3. package/ContactBubble.d.ts +2 -1
  4. package/ContactBubble.js +4 -6
  5. package/Datepicker.d.ts +31 -3
  6. package/Datepicker.js +25 -6
  7. package/FormField.d.ts +11 -2
  8. package/FormField.js +5 -5
  9. package/MainMenu/_PrimaryPanel.d.ts +2 -2
  10. package/MainMenu.d.ts +2 -1
  11. package/MainMenu.js +5 -6
  12. package/Multiselect/_Multiselect.search.d.ts +19 -0
  13. package/Multiselect/_Multiselect.search.js +80 -0
  14. package/Multiselect.d.ts +64 -0
  15. package/Multiselect.js +236 -0
  16. package/RelatedLinks.d.ts +2 -1
  17. package/Selectbox.d.ts +3 -3
  18. package/TextInput.d.ts +0 -1
  19. package/_abstract/_CardList.d.ts +2 -1
  20. package/_abstract/_CardList.js +2 -2
  21. package/_abstract/_FocusTrap.d.ts +14 -0
  22. package/_abstract/_FocusTrap.js +24 -0
  23. package/_abstract/_TogglerGroup.d.ts +11 -7
  24. package/_abstract/_TogglerGroup.js +11 -3
  25. package/_abstract/_TogglerGroupField.d.ts +4 -4
  26. package/_abstract/_TogglerGroupField.js +2 -2
  27. package/_abstract/_TogglerInput.d.ts +3 -1
  28. package/_abstract/_TogglerInput.js +7 -4
  29. package/esm/ArticleCarousel/_ArticleCarouselCard.d.ts +2 -1
  30. package/esm/ContactBubble.d.ts +2 -1
  31. package/esm/ContactBubble.js +4 -6
  32. package/esm/Datepicker.d.ts +31 -3
  33. package/esm/Datepicker.js +25 -6
  34. package/esm/FormField.d.ts +11 -2
  35. package/esm/FormField.js +5 -5
  36. package/esm/MainMenu/_PrimaryPanel.d.ts +2 -2
  37. package/esm/MainMenu.d.ts +2 -1
  38. package/esm/MainMenu.js +5 -6
  39. package/esm/Multiselect/_Multiselect.search.d.ts +19 -0
  40. package/esm/Multiselect/_Multiselect.search.js +75 -0
  41. package/esm/Multiselect.d.ts +64 -0
  42. package/esm/Multiselect.js +231 -0
  43. package/esm/RelatedLinks.d.ts +2 -1
  44. package/esm/Selectbox.d.ts +3 -3
  45. package/esm/TextInput.d.ts +0 -1
  46. package/esm/_abstract/_CardList.d.ts +2 -1
  47. package/esm/_abstract/_CardList.js +2 -2
  48. package/esm/_abstract/_FocusTrap.d.ts +14 -0
  49. package/esm/_abstract/_FocusTrap.js +19 -0
  50. package/esm/_abstract/_TogglerGroup.d.ts +11 -7
  51. package/esm/_abstract/_TogglerGroup.js +11 -3
  52. package/esm/_abstract/_TogglerGroupField.d.ts +4 -4
  53. package/esm/_abstract/_TogglerGroupField.js +2 -2
  54. package/esm/_abstract/_TogglerInput.d.ts +3 -1
  55. package/esm/_abstract/_TogglerInput.js +7 -4
  56. package/esm/index.d.ts +1 -0
  57. package/esm/utils/useFormatMonitor.d.ts +4 -2
  58. package/esm/utils/useFormatMonitor.js +4 -2
  59. package/index.d.ts +1 -0
  60. package/package.json +7 -3
  61. package/utils/useFormatMonitor.d.ts +4 -2
  62. package/utils/useFormatMonitor.js +4 -2
package/esm/Datepicker.js CHANGED
@@ -6,11 +6,16 @@ import is from 'date-fns/locale/is/index.js';
6
6
  import pl from 'date-fns/locale/pl/index.js';
7
7
  import { ReactDatePicker, registerLocale, } from './_mixed_export_resolution_/ReactDatepicker.js'; // Docs: https://reactdatepicker.com/
8
8
  import { FormField } from './FormField.js';
9
+ import { useMixedControlState } from './utils.js';
9
10
  registerLocale('is', is);
10
11
  registerLocale('pl', pl);
11
- export const getDateDiff = (date, diff) => {
12
- const newDate = new Date(date);
13
- newDate.setDate(newDate.getDate() + diff);
12
+ /**
13
+ * Dumb utility function that returns a new Date that's `dayOffset` days away
14
+ * from the input `date`.
15
+ */
16
+ export const getDateDiff = (refDate, dayOffset) => {
17
+ const newDate = new Date(refDate);
18
+ newDate.setDate(newDate.getDate() + dayOffset);
14
19
  return newDate;
15
20
  };
16
21
  const i18n = {
@@ -47,8 +52,19 @@ const i18n = {
47
52
  disabledDayAriaLabelPrefix: 'Data niedostępna',
48
53
  },
49
54
  };
55
+ /**
56
+ * A compo
57
+ *
58
+ * Internally, this component uses the [`react-datepicker`](https://reactdatepicker.com/) component.
59
+ */
50
60
  export const Datepicker = (props) => {
51
- const { className, id, label, hideLabel, assistText, disabled, readOnly, invalid, errorMessage, required, reqText, placeholder, small, localeCode = 'is', dateFormat = 'd.M.yyyy', initialDate, value = initialDate, name, startDate, endDate, minDate, maxDate, isStartDate = false, isEndDate = false, onChange, datepickerExtraProps, ssr, inputRef, } = props;
61
+ const { className, id, label, hideLabel, assistText, disabled, readOnly, invalid, errorMessage, required, reqText, placeholder, small, localeCode = 'is', dateFormat = 'd.M.yyyy', name, startDate, endDate, minDate, maxDate, isStartDate = false, isEndDate = false, onChange, datepickerExtraProps, ssr, inputRef, isoMode, } = props;
62
+ const [value, setValue] = useMixedControlState.raw(props.value || props.initialDate, props.defaultValue, 'value');
63
+ /*
64
+ TODO: Revert to this simpler pattern once we hit v0.11
65
+ and `props.initialDate` is removed:
66
+ */
67
+ // const [value, setValue] = useMixedControlState(props, 'value');
52
68
  const domid = useDomid(id);
53
69
  const txts = i18n[localeCode] || {};
54
70
  const filled = !!value;
@@ -60,7 +76,8 @@ export const Datepicker = (props) => {
60
76
  (elm === null || elm === void 0 ? void 0 : elm.querySelector('input')) || undefined;
61
77
  return elm;
62
78
  }) }, addFocusProps()),
63
- React.createElement(ReactDatePicker, Object.assign({ id: domid, required: inputProps.required, disabled: inputProps.disabled, readOnly: inputProps.readOnly, selected: value, name: name, locale: localeCode, dateFormat:
79
+ isoMode && (React.createElement("input", { type: "hidden", name: name, value: value === null || value === void 0 ? void 0 : value.toISOString().slice(0, 10) })),
80
+ React.createElement(ReactDatePicker, Object.assign({ id: domid, required: inputProps.required, disabled: inputProps.disabled, readOnly: inputProps.readOnly, selected: value, name: isoMode ? undefined : name, locale: localeCode, dateFormat:
64
81
  // NOTE: Force all dateFormat values into Array<string> to temporarily work around
65
82
  // a bug in the current version of react-datepicker where invalid **string** values
66
83
  // are re-parsed with `new Date()`, causing surprising (weird) false positives
@@ -71,7 +88,9 @@ export const Datepicker = (props) => {
71
88
  typeof dateFormat === 'string'
72
89
  ? [dateFormat]
73
90
  : dateFormat.slice(0).reverse(), onChange: (date) => {
74
- onChange(date || undefined);
91
+ date = date || undefined;
92
+ setValue(date);
93
+ onChange && onChange(date);
75
94
  const inputElm = inputRef === null || inputRef === void 0 ? void 0 : inputRef.current;
76
95
  if (inputElm) {
77
96
  inputElm.dispatchEvent(new Event('change', { bubbles: true }));
@@ -24,6 +24,10 @@ type FocusEvents = {
24
24
  onBlur?: (e: any) => void;
25
25
  };
26
26
  type FocusPropMaker = <P extends FocusEvents>(ownProps?: P) => P & Required<FocusEvents>;
27
+ /**
28
+ * Mixin base props type for components using the `FormField` to contain
29
+ * simple, singular input widgets. E.g. `TextInput`, `Seleccbox`, etc.
30
+ */
27
31
  export type FormFieldWrappingProps = {
28
32
  /** Container className - alongside "FormField" */
29
33
  className?: string;
@@ -49,14 +53,19 @@ export type FormFieldWrappingProps = {
49
53
  wrapperRef?: RefObject<HTMLElement>;
50
54
  ssr?: SSRSupport;
51
55
  };
56
+ /**
57
+ * Mixin base props type for components using `FormField` to contain
58
+ * more complex multi-element, grouped choices, that require a Heading
59
+ * E.g. `RadioGroup`, `CheckboxGroup`, etc.
60
+ */
52
61
  export type FormFieldGroupWrappingProps = FormFieldWrappingProps & {
53
62
  LabelTag?: 'h3' | 'h4' | 'h5';
54
63
  };
55
- export type FormFieldProps = FormFieldGroupWrappingProps & {
64
+ type FormFieldProps = FormFieldGroupWrappingProps & {
56
65
  /** Container className - alongside "FormField" */
57
66
  className: string;
58
67
  small?: boolean;
59
- group?: boolean;
68
+ group?: boolean | 'inputlike';
60
69
  empty?: boolean;
61
70
  filled?: boolean;
62
71
  renderInput(className: InputClassNames, inputProps: FormFieldInputProps, addFocusProps: FocusPropMaker, isBrowser?: boolean): JSX.Element;
package/esm/FormField.js CHANGED
@@ -48,12 +48,12 @@ export const FormField = (props) => {
48
48
  }
49
49
  return focusProps;
50
50
  }, []);
51
- const errorId = errorMessage ? 'error:' + domid : undefined;
52
- const assistTextId = assistText ? 'assist:' + domid : undefined;
53
- const labelId = LabelTag ? 'label:' + domid : undefined;
51
+ const errorId = errorMessage ? `error:${domid}` : undefined;
52
+ const assistTextId = assistText ? `assist:${domid}` : undefined;
53
+ const labelId = LabelTag ? `label:${domid}` : undefined;
54
54
  const reqStar = required && reqText !== false && (React.createElement("abbr", { className: "FormField__label__reqstar",
55
55
  // TODO: add mo-better i18n thinking
56
- title: (reqText || 'Þarf að fylla út') + ': ' }, "*"));
56
+ title: `${reqText || 'Þarf að fylla út'}: ` }, "*"));
57
57
  const inputProps = {
58
58
  id: domid,
59
59
  disabled: disabled,
@@ -73,7 +73,7 @@ export const FormField = (props) => {
73
73
  isBrowser && filled && 'filled',
74
74
  isBrowser && focused && 'focused',
75
75
  ], className), ref: props.wrapperRef },
76
- LabelTag ? (React.createElement(LabelTag, { className: "FormField__label", id: labelId },
76
+ LabelTag ? (React.createElement(LabelTag, { className: "FormField__label", "data-inputlabel": group === 'inputlike' || undefined, id: labelId },
77
77
  ' ',
78
78
  reqStar,
79
79
  " ",
@@ -1,4 +1,4 @@
1
- import { RefObject } from 'react';
1
+ import React, { RefObject } from 'react';
2
2
  export type PrimaryPanelI18n = {
3
3
  backToMenu: string;
4
4
  backToMenuLong?: string;
@@ -9,7 +9,7 @@ export type MegaMenuItem = {
9
9
  href: string;
10
10
  lang?: string;
11
11
  current?: boolean;
12
- target?: string;
12
+ target?: React.HTMLAttributeAnchorTarget;
13
13
  };
14
14
  export type MegaMenuPanel = {
15
15
  title: string;
package/esm/MainMenu.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  import { Cleanup } from '@reykjavik/hanna-utils';
2
3
  import { DefaultTexts } from '@reykjavik/hanna-utils/i18n';
3
4
  import { AuxilaryPanelIllustration, AuxiliaryPanelProps } from './MainMenu/_Auxiliary.js';
@@ -34,7 +35,7 @@ export type MainMenuItem = {
34
35
  */
35
36
  onClick?: (index: number, item: MainMenuItem) => void | boolean;
36
37
  controlsId?: string;
37
- target?: JSX.IntrinsicElements['a']['target'];
38
+ target?: React.HTMLAttributeAnchorTarget;
38
39
  };
39
40
  export type MainMenuSeparator = '---';
40
41
  export type MainMenuItemList = Array<MainMenuItem | MainMenuSeparator>;
package/esm/MainMenu.js CHANGED
@@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { focusElm } from '@hugsmidjan/qj/focusElm';
3
3
  import useShortState from '@hugsmidjan/react/hooks/useShortState';
4
4
  import getBemClass from '@hugsmidjan/react/utils/getBemClass';
5
- import { getPageScrollElm } from '@reykjavik/hanna-utils';
6
5
  import { getTexts } from '@reykjavik/hanna-utils/i18n';
7
6
  import { Link } from './_abstract/_Link.js';
8
7
  import { AuxiliaryPanel, } from './MainMenu/_Auxiliary.js';
@@ -94,20 +93,20 @@ export const MainMenu = (props) => {
94
93
  const [laggyActivePanel, setLaggyActivePanel] = useShortState();
95
94
  const setActivePanel = useMemo(() => isBrowser
96
95
  ? (newActive, setFocus = true) => {
97
- const htmlElmDataset = document.documentElement.dataset;
96
+ const htmlElm = document.documentElement;
97
+ const htmlElmDataset = htmlElm.dataset;
98
98
  // const menuElm = menuElmRef.current as HTMLElement;
99
99
  _setActivePanel((activePanel) => {
100
- const scrollElm = getPageScrollElm();
101
100
  if (!newActive) {
102
101
  activePanel && setLaggyActivePanel(activePanel, 1000);
103
- scrollElm.scrollTop = parseInt(htmlElmDataset.scrollTop || '') || 0;
102
+ htmlElm.scrollTop = parseInt(htmlElmDataset.scrollTop || '') || 0;
104
103
  delete htmlElmDataset.scrollTop;
105
104
  delete htmlElmDataset.megaPanelActive;
106
105
  }
107
106
  else {
108
107
  setLaggyActivePanel(undefined, 0);
109
- htmlElmDataset.scrollTop = String(scrollElm.scrollTop);
110
- scrollElm.scrollTop = 0;
108
+ htmlElmDataset.scrollTop = String(htmlElm.scrollTop);
109
+ htmlElm.scrollTop = 0;
111
110
  htmlElmDataset.megaPanelActive = '';
112
111
  }
113
112
  if (setFocus) {
@@ -0,0 +1,19 @@
1
+ import { TogglerGroupOptions } from '../_abstract/_TogglerGroup.js';
2
+ import { TogglerGroupFieldOption } from '../_abstract/_TogglerGroupField.js';
3
+ export declare const _weights: {
4
+ WHOLE_WORD: number;
5
+ STARTS_WITH: number;
6
+ CONTAINS: number;
7
+ VALUE_WEIGHT: number;
8
+ wordWeight: (wordIndex: number) => number;
9
+ };
10
+ export type SearchScoringfn = (
11
+ /** The Multiselect item object to calculate search score for */
12
+ item: TogglerGroupFieldOption<string>,
13
+ /** Trimmed list of `toLowerCase`d query words */
14
+ queryWords: Array<string>,
15
+ /** The raw, untouched search query as typed by the user */
16
+ rawQuery: string) => number;
17
+ export declare const defaultSearchScoring: SearchScoringfn;
18
+ /** Returns a normalized, filtered list of options */
19
+ export declare const filterItems: (options: TogglerGroupOptions<string>, searchQuery: string, searchScoringFn?: SearchScoringfn) => TogglerGroupOptions<string>;
@@ -0,0 +1,75 @@
1
+ const WHOLE_WORD = 10000;
2
+ const STARTS_WITH = 100;
3
+ const CONTAINS = 1;
4
+ const VALUE_WEIGHT = 1 / 10;
5
+ /** Scoring weight modifier based on a word's positional index within the value */
6
+ const wordWeight = (wordIndex) => 10 / (10 + wordIndex);
7
+ // Exported for testing purposes
8
+ export const _weights = {
9
+ WHOLE_WORD,
10
+ STARTS_WITH,
11
+ CONTAINS,
12
+ VALUE_WEIGHT,
13
+ wordWeight,
14
+ };
15
+ /**
16
+ * Calculates a score based on how well an item string (either label or value)
17
+ * matches a given list of query words.
18
+ * Splits the item string into words and scores each word.
19
+ * Favors full matches and matches at the start of the word.
20
+ * Weighs earlier words higher than words near the end.
21
+ *
22
+ * Limitation: Does currently not give extra points when query words
23
+ * appear in the correct order in the item string.
24
+ */
25
+ const calcScore = (itemString, queryWords) => {
26
+ let score = 0;
27
+ queryWords.forEach((queryWord) => {
28
+ itemString.split(/\s+/).forEach((word, wi) => {
29
+ let wordScore = 0;
30
+ if (word === queryWord) {
31
+ wordScore += WHOLE_WORD;
32
+ }
33
+ else {
34
+ const pos = word.indexOf(queryWord);
35
+ if (pos === 0) {
36
+ wordScore += STARTS_WITH;
37
+ }
38
+ else if (pos > 0) {
39
+ wordScore += CONTAINS;
40
+ }
41
+ }
42
+ score += wordScore * wordWeight(wi);
43
+ });
44
+ });
45
+ return score;
46
+ };
47
+ export const defaultSearchScoring = (item, queryWords) => {
48
+ var _a;
49
+ if (!item.value) {
50
+ return 0;
51
+ }
52
+ const value = item.value.toLowerCase().trim();
53
+ const label = ((_a = item.label) === null || _a === void 0 ? void 0 : _a.toLowerCase().trim()) || value;
54
+ let score = calcScore(label, queryWords);
55
+ if (!score) {
56
+ score = VALUE_WEIGHT * calcScore(value, queryWords);
57
+ }
58
+ return score;
59
+ };
60
+ // ---------------------------------------------------------------------------
61
+ /** Returns a normalized, filtered list of options */
62
+ export const filterItems = (options, searchQuery, searchScoringFn = defaultSearchScoring) => {
63
+ if (!searchQuery.trim()) {
64
+ return [...options];
65
+ }
66
+ const queryWords = searchQuery.toLowerCase().trim().split(/\s+/);
67
+ return options
68
+ .map((item) => ({
69
+ item,
70
+ score: searchScoringFn(item, queryWords, searchQuery),
71
+ }))
72
+ .filter(({ score }) => score > 0)
73
+ .sort((a, b) => (a.score === b.score ? 0 : a.score < b.score ? 1 : -1))
74
+ .map(({ item }) => item);
75
+ };
@@ -0,0 +1,64 @@
1
+ import { TogglerGroupFieldProps } from './_abstract/_TogglerGroupField.js';
2
+ import { SearchScoringfn } from './Multiselect/_Multiselect.search.js';
3
+ export type MultiselectI18n = {
4
+ search: string;
5
+ buttonShow: string;
6
+ currentValues: string;
7
+ noneFoundMsg: string;
8
+ };
9
+ export type MultiselectProps = TogglerGroupFieldProps<string> & {
10
+ value?: Array<string>;
11
+ defaultValue?: Array<string>;
12
+ placeholder?: string;
13
+ small?: boolean;
14
+ /**
15
+ * Prevent the selected items from wrapping into multiple lines.
16
+ * Use this option when vertical space is limited.
17
+ */
18
+ nowrap?: boolean;
19
+ /**
20
+ * Custom function to calculate a search score for a given option item.
21
+ * Higher scores mean better matches.
22
+ *
23
+ * A score of zero (or less) means the item is not a valid match.
24
+ */
25
+ searchScoring?: SearchScoringfn;
26
+ /**
27
+ * Force display the current values at the top of the dropdown,
28
+ * even when the total options are fewer than
29
+ * `Multiselect.meta.summaryLimit`.
30
+ *
31
+ * NOTE: Using this option is generally not recommended.
32
+ */
33
+ forceSummary?: boolean;
34
+ /**
35
+ * Force the options to be searchable, even when they're
36
+ * fewer than `Multiselect.meta.searchableLimit`.
37
+ *
38
+ * NOTE: Using this option is generally not recommended.
39
+ */
40
+ forceSearchable?: boolean;
41
+ texts?: MultiselectI18n;
42
+ lang?: string;
43
+ };
44
+ export type MultiSelectOption = Exclude<MultiselectProps['options'][number], string>;
45
+ export declare const Multiselect: {
46
+ (props: MultiselectProps): JSX.Element;
47
+ /** Configuration constants for the Multiselect components */
48
+ meta: {
49
+ /**
50
+ * The item-count where the list becomes searchable.
51
+ *
52
+ * (The search UI, including the on-screen keyboard, takes up a lot of space
53
+ * on mobile devices, so there's a balance that we want to strike.)
54
+ */
55
+ searchableLimit: number;
56
+ /**
57
+ * The item-count above which we display a summary of "current" values
58
+ * at the top of the drop-down list.
59
+ *
60
+ * (This summary just gets in the way with ultra short option lists.)
61
+ */
62
+ summaryLimit: number;
63
+ };
64
+ };
@@ -0,0 +1,231 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import domId from '@hugsmidjan/qj/domid';
3
+ import { useDomid, useOnClickOutside } from '@hugsmidjan/react/hooks';
4
+ import getBemClass from '@hugsmidjan/react/utils/getBemClass';
5
+ import { notNully } from '@reykjavik/hanna-utils';
6
+ import { getTexts } from '@reykjavik/hanna-utils/i18n';
7
+ import { FocusTrap } from './_abstract/_FocusTrap.js';
8
+ import { filterItems } from './Multiselect/_Multiselect.search.js';
9
+ import Checkbox from './Checkbox.js';
10
+ import FormField from './FormField.js';
11
+ import TagPill from './TagPill.js';
12
+ import { useMixedControlState } from './utils.js';
13
+ const metaData = {
14
+ /**
15
+ * The item-count where the list becomes searchable.
16
+ *
17
+ * (The search UI, including the on-screen keyboard, takes up a lot of space
18
+ * on mobile devices, so there's a balance that we want to strike.)
19
+ */
20
+ searchableLimit: 20,
21
+ /**
22
+ * The item-count above which we display a summary of "current" values
23
+ * at the top of the drop-down list.
24
+ *
25
+ * (This summary just gets in the way with ultra short option lists.)
26
+ */
27
+ summaryLimit: 10,
28
+ };
29
+ const { searchableLimit, summaryLimit } = metaData;
30
+ const defaultTexts = {
31
+ pl: {
32
+ search: 'Wyszukaj opcje',
33
+ buttonShow: 'Pokaż opcje',
34
+ // buttonHide: 'Ukryj opcje',
35
+ currentValues: 'Wybrane wartości',
36
+ noneFoundMsg: 'Brak dopasowań',
37
+ },
38
+ en: {
39
+ search: 'Search options',
40
+ buttonShow: 'Show options',
41
+ // buttonHide: 'Hide options',
42
+ currentValues: 'Currently selected',
43
+ noneFoundMsg: 'No matches',
44
+ },
45
+ is: {
46
+ search: 'Leita í valkostum',
47
+ buttonShow: 'Birta valkosti',
48
+ // buttonHide: 'Fela valkosti',
49
+ currentValues: 'Valin gildi',
50
+ noneFoundMsg: 'Ekkert passar',
51
+ },
52
+ };
53
+ export const Multiselect = (props) => {
54
+ const { onSelected, options: _options, disabled: _disabled, readOnly } = props;
55
+ const disabled = _disabled === true;
56
+ const disableds = !disabled && _disabled;
57
+ const name = useDomid(props.name);
58
+ const [values, setValues] = useMixedControlState(props, 'value', []);
59
+ const filled = values.length > 0;
60
+ const empty = !filled && !props.placeholder;
61
+ const placeholderText = !values.length ? props.placeholder : undefined;
62
+ const texts = getTexts(props, defaultTexts);
63
+ const inputRef = useRef(null);
64
+ const wrapperRef = useRef(null);
65
+ const [activeItemIndex, setActiveItemIndex] = useState(-1);
66
+ const [searchQuery, setSearchQuery] = useState('');
67
+ const [isOpen, setIsOpen] = useState(false);
68
+ const toggleOpen = (newIsOpen) => {
69
+ setIsOpen((isOpen) => {
70
+ newIsOpen = typeof newIsOpen === 'boolean' ? newIsOpen : !isOpen;
71
+ if (!newIsOpen) {
72
+ wrapperRef.current.querySelector('.Multiselect__choices').scrollTo(0, 0);
73
+ setSearchQuery('');
74
+ setActiveItemIndex(-1);
75
+ }
76
+ return newIsOpen;
77
+ });
78
+ };
79
+ useOnClickOutside(wrapperRef, () => toggleOpen(false));
80
+ const options = useMemo(() => _options.map((item) => (typeof item === 'string' ? { value: item } : item)), [_options]);
81
+ const isSearchable = props.forceSearchable || options.length >= searchableLimit;
82
+ /*
83
+ NOTE: he `.MultiSelect__currentvalues` should only be visible when
84
+ there are some items selected, and multiselect is either collapsed,
85
+ or the dropdown has reached `summaryLimit` number of items.
86
+ (For fewer items, the "summary" is just in the way.)
87
+ The `forceSummary` prop overrides this default.
88
+ */
89
+ const showCurrentValues = values.length > 0 &&
90
+ (props.forceSummary || !isOpen || options.length >= summaryLimit);
91
+ const filteredOptions = useMemo(() => filterItems(options, searchQuery, props.searchScoring), [searchQuery, options, props.searchScoring]);
92
+ const handleCheckboxSelection = useCallback((selectedItem) => {
93
+ const selValue = selectedItem.value;
94
+ const isAdding = values.indexOf(selValue) === -1;
95
+ const _newValues = isAdding
96
+ ? [...values, selValue]
97
+ : values.filter((value) => value !== selValue);
98
+ const selectedValues = options
99
+ .filter((item) => _newValues.includes(item.value))
100
+ .map((item) => item.value);
101
+ setValues(selectedValues);
102
+ if (onSelected) {
103
+ onSelected({
104
+ value: selectedItem.value,
105
+ checked: isAdding,
106
+ option: selectedItem,
107
+ selectedValues,
108
+ });
109
+ }
110
+ }, [values, options, onSelected, setValues]);
111
+ const handleInputChange = (event) => {
112
+ const val = event.target.value;
113
+ const fixVal = val === ' ' ? '' : val;
114
+ setSearchQuery(fixVal);
115
+ toggleOpen(true);
116
+ };
117
+ const handleInputKeyDown = (event) => {
118
+ if (searchQuery.length === 0 && [' ', 'Delete', 'Backspace'].includes(event.key)) {
119
+ // setSearchQuery('');
120
+ toggleOpen(activeItemIndex > -1 ? true : !isOpen);
121
+ }
122
+ };
123
+ // When the dropdown is open, add keydown handlers
124
+ useEffect(() => {
125
+ if (!isOpen) {
126
+ return;
127
+ }
128
+ const handleKeyDown = (e) => {
129
+ const inputElm = inputRef.current;
130
+ if (e.key === 'ArrowUp') {
131
+ e.preventDefault();
132
+ inputElm.focus();
133
+ setActiveItemIndex((prevIndex) => prevIndex === 0 ? filteredOptions.length - 1 : prevIndex - 1);
134
+ }
135
+ else if (e.key === 'ArrowDown') {
136
+ e.preventDefault();
137
+ inputElm.focus();
138
+ setActiveItemIndex((prevIndex) => prevIndex === filteredOptions.length - 1 ? 0 : prevIndex + 1);
139
+ }
140
+ else if (e.key === 'Escape') {
141
+ e.preventDefault();
142
+ inputElm.blur();
143
+ inputElm.focus();
144
+ toggleOpen(false);
145
+ }
146
+ else if (e.key === 'Enter' || e.key === ' ') {
147
+ if (e.target.closest('.MultiSelect__currentvalues')) {
148
+ return;
149
+ }
150
+ const focusInRange = activeItemIndex >= 0 && activeItemIndex < filteredOptions.length;
151
+ if (focusInRange) {
152
+ e.preventDefault();
153
+ const selItem = filteredOptions[activeItemIndex];
154
+ if (selItem) {
155
+ handleCheckboxSelection(selItem);
156
+ }
157
+ }
158
+ }
159
+ };
160
+ document.addEventListener('keydown', handleKeyDown);
161
+ return () => {
162
+ document.removeEventListener('keydown', handleKeyDown);
163
+ };
164
+ }, [activeItemIndex, filteredOptions, isOpen, handleCheckboxSelection, inputRef]);
165
+ // Auto-close the dropdown when focus has left the building
166
+ useEffect(() => {
167
+ const wrapperDiv = wrapperRef.current;
168
+ if (!wrapperDiv) {
169
+ return;
170
+ }
171
+ let closing;
172
+ const cancelClose = () => clearTimeout(closing);
173
+ const closeDropdown = () => {
174
+ closing = setTimeout(() => toggleOpen(false), 200);
175
+ };
176
+ wrapperDiv.addEventListener('focusin', cancelClose);
177
+ wrapperDiv.addEventListener('focusout', closeDropdown);
178
+ return () => {
179
+ wrapperDiv.removeEventListener('focusin', cancelClose);
180
+ wrapperDiv.removeEventListener('focusout', closeDropdown);
181
+ };
182
+ }, []);
183
+ useEffect(() => {
184
+ var _a, _b;
185
+ (_b = (_a = wrapperRef.current) === null || _a === void 0 ? void 0 : _a.querySelectorAll('.Multiselect__option')[activeItemIndex]) === null || _b === void 0 ? void 0 : _b.scrollIntoView({
186
+ behavior: 'smooth',
187
+ block: 'nearest',
188
+ });
189
+ }, [activeItemIndex]);
190
+ return (React.createElement(FormField, { className: getBemClass('Multiselect', props.nowrap && 'nowrap', props.className), ssr: props.ssr, group: "inputlike", label: props.label, LabelTag: props.LabelTag, hideLabel: props.hideLabel, small: props.small, filled: filled, empty: empty, disabled: disabled, invalid: props.invalid, errorMessage: props.errorMessage, assistText: props.assistText, readOnly: readOnly, required: props.required, reqText: props.reqText, id: props.id, renderInput: (className, inputProps, addFocusProps, isBrowser) => {
191
+ const { id } = inputProps;
192
+ return (React.createElement("div", Object.assign({ className: getBemClass('Multiselect__input', [isOpen && 'open'], className.input) }, addFocusProps(), { "data-sprinkled": isBrowser, ref: wrapperRef }),
193
+ !isBrowser ? null : isSearchable ? (React.createElement("input", { className: "Multiselect__search", id: `toggler:${id}`, "aria-label": texts.search, "aria-controls": domId(), "data-expanded": isOpen || undefined, onChange: handleInputChange, onKeyDown: handleInputKeyDown, onClick: () => toggleOpen(), value: searchQuery,
194
+ // onFocus={handleInputFocus}
195
+ placeholder: placeholderText, disabled: disabled, ref: inputRef })) : (React.createElement("button", { className: "Multiselect__toggler", id: `toggler:${id}`, type: "button", "aria-label": texts.buttonShow, "aria-controls": domId(), "aria-expanded": isOpen, onClick: () => toggleOpen(), disabled: disabled,
196
+ // Seems like an innocent hack for visible "placeholder" value.
197
+ // For scren-readers aria-label should take precedence.
198
+ ref: inputRef }, placeholderText || ' ')),
199
+ React.createElement("div", { className: "Multiselect__choices", tabIndex: -1 },
200
+ isBrowser && showCurrentValues && (React.createElement("div", { className: "Multiselect__currentvalues", onClick: isOpen || disabled
201
+ ? undefined
202
+ : () => {
203
+ var _a;
204
+ toggleOpen();
205
+ (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
206
+ }, "aria-label": `${texts.currentValues}:` }, values
207
+ .map((value) => options.find((opt) => opt.value === value))
208
+ .filter(notNully)
209
+ .map((item, idx) => (React.createElement(TagPill, Object.assign({ key: idx, large: true, label: item.label || item.value }, (isOpen && !readOnly
210
+ ? {
211
+ removable: true,
212
+ onRemove: () => {
213
+ handleCheckboxSelection(item);
214
+ },
215
+ }
216
+ : { removable: false }))))))),
217
+ React.createElement("ul", { id: id, className: "Multiselect__options", "aria-expanded": isBrowser ? isOpen : undefined, hidden: isBrowser && !isOpen, role: "group", "aria-labelledby": inputProps['aria-labelledby'], "aria-describedby": inputProps['aria-describedby'], "aria-required": props.required },
218
+ filteredOptions.length ? (filteredOptions.map((item, idx) => {
219
+ const isDisabled = item.disabled != null
220
+ ? item.disabled
221
+ : disableds && disableds.includes(idx);
222
+ const isChecked = values.includes(item.value);
223
+ return (React.createElement(Checkbox, Object.assign({ key: idx, className: getBemClass('Multiselect__option', activeItemIndex === idx && 'focused'), disabled: isDisabled, readOnly: readOnly, required: props.required, Wrapper: "li", name: name }, item, { checked: isChecked, "aria-invalid": props.invalid, label: item.label || item.value, onChange: () => handleCheckboxSelection(item), onFocus: () => setActiveItemIndex(idx), wrapperProps: {
224
+ onMouseEnter: () => setActiveItemIndex(idx),
225
+ } })));
226
+ })) : searchQuery ? (React.createElement("li", { className: "Multiselect__noresults" }, texts.noneFoundMsg)) : undefined,
227
+ React.createElement(FocusTrap, { Tag: "li" })))));
228
+ } }));
229
+ };
230
+ /** Configuration constants for the Multiselect components */
231
+ Multiselect.meta = metaData;
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  declare const types: {
2
3
  readonly external: 1;
3
4
  readonly document: 1;
@@ -8,7 +9,7 @@ export type RelatedLinkType = keyof typeof types;
8
9
  export type RelatedLinkItem = {
9
10
  href: string;
10
11
  label: string;
11
- target?: string;
12
+ target?: React.HTMLAttributeAnchorTarget;
12
13
  type?: RelatedLinkType;
13
14
  };
14
15
  export type RelatedLinksProps = {
@@ -1,8 +1,8 @@
1
- import type { OptionOrValue, SelectboxProps as _SelectboxProps } from '@hugsmidjan/react/Selectbox';
1
+ import type { OptionOrValue, SelectboxOptions as _SelectboxOptions, SelectboxProps as _SelectboxProps } from '@hugsmidjan/react/Selectbox';
2
2
  import { FormFieldWrappingProps } from './FormField.js';
3
- export { type SelectboxOption, type SelectboxOptions as SelectboxOptionList,
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
- type SelectboxOptions, } from '@hugsmidjan/react/Selectbox';
5
+ export type SelectboxOptions = _SelectboxOptions;
6
6
  export type SelectboxProps<O extends OptionOrValue = OptionOrValue> = FormFieldWrappingProps & Omit<_SelectboxProps<O>, 'bem'> & {
7
7
  small?: boolean;
8
8
  };
@@ -4,7 +4,6 @@ type InputElmProps = JSX.IntrinsicElements['input'];
4
4
  type TextareaElmProps = JSX.IntrinsicElements['textarea'];
5
5
  export type TextInputProps = {
6
6
  small?: boolean;
7
- children?: never;
8
7
  } & FormFieldWrappingProps & (({
9
8
  type?: 'text' | 'email' | 'tel' | 'number' | 'date' | 'url' | 'password' | 'search';
10
9
  inputRef?: RefObject<HTMLInputElement>;
@@ -1,4 +1,4 @@
1
- import { ReactElement, ReactNode } from 'react';
1
+ import React, { ReactElement, ReactNode } from 'react';
2
2
  import { EitherObj } from '@reykjavik/hanna-utils';
3
3
  import { ImageProps } from './_Image.js';
4
4
  type BaseCardProps = {
@@ -12,6 +12,7 @@ export type ImageCardProps = BaseCardProps & {
12
12
  };
13
13
  export type TextCardProps = BaseCardProps & {
14
14
  summary?: string;
15
+ target?: React.HTMLAttributeAnchorTarget;
15
16
  };
16
17
  export type CardListProps<T> = {
17
18
  cards: Array<T>;
@@ -2,10 +2,10 @@ import React from 'react';
2
2
  import { Button } from './_Button.js';
3
3
  import { Image } from './_Image.js';
4
4
  const Card = (props) => {
5
- const { bem, href, title, imgPlaceholder, image, meta, summary } = props;
5
+ const { bem, href, title, imgPlaceholder, image, meta, summary, target } = props;
6
6
  const cardClass = `${bem}__card`;
7
7
  return (React.createElement(React.Fragment, null,
8
- React.createElement(Button, { bem: cardClass, href: href },
8
+ React.createElement(Button, { bem: cardClass, href: href, target: target },
9
9
  ' ',
10
10
  React.createElement(Image, Object.assign({ className: `${bem}__image` }, image, { placeholder: imgPlaceholder })),
11
11
  React.createElement("span", { className: `${cardClass}__title` }, title),