@dbcdk/react-components 0.0.44 → 0.0.46

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.
@@ -10,8 +10,10 @@ const isInteractiveEl = (el) => React.isValidElement(el) &&
10
10
  (typeof el.type === 'string' ? el.type === 'a' || el.type === 'button' : true);
11
11
  function applyMenuItemPropsToElement(child, opts) {
12
12
  var _a, _b, _c, _d;
13
- const { active, disabled, role, tabIndex = -1, className } = opts;
14
- const childClass = [styles.item, active ? styles.active : ''].filter(Boolean).join(' ');
13
+ const { active, selected, disabled, role, tabIndex = -1, className } = opts;
14
+ const childClass = [styles.item, active ? styles.active : '', selected ? styles.selected : '']
15
+ .filter(Boolean)
16
+ .join(' ');
15
17
  const nextImmediate = React.cloneElement(child, {
16
18
  className: [child.props.className, styles.interactiveChild, className]
17
19
  .filter(Boolean)
@@ -22,7 +24,7 @@ function applyMenuItemPropsToElement(child, opts) {
22
24
  return React.cloneElement(child, {
23
25
  role: (_a = child.props.role) !== null && _a !== void 0 ? _a : role,
24
26
  tabIndex: (_b = child.props.tabIndex) !== null && _b !== void 0 ? _b : tabIndex,
25
- 'aria-selected': active || undefined,
27
+ 'aria-selected': selected || undefined,
26
28
  'aria-disabled': disabled || undefined,
27
29
  className: [child.props.className, styles.interactive, childClass, className]
28
30
  .filter(Boolean)
@@ -34,7 +36,7 @@ function applyMenuItemPropsToElement(child, opts) {
34
36
  return React.cloneElement(nextImmediate, {
35
37
  role: (_c = nextImmediate.props.role) !== null && _c !== void 0 ? _c : role,
36
38
  tabIndex: (_d = nextImmediate.props.tabIndex) !== null && _d !== void 0 ? _d : tabIndex,
37
- 'aria-selected': active || undefined,
39
+ 'aria-selected': selected || undefined,
38
40
  'aria-disabled': disabled || undefined,
39
41
  className: [nextImmediate.props.className, styles.interactive, childClass]
40
42
  .filter(Boolean)
@@ -42,16 +44,21 @@ function applyMenuItemPropsToElement(child, opts) {
42
44
  disabled,
43
45
  });
44
46
  }
45
- const MenuItem = React.forwardRef(({ children, active, disabled, className, itemRole, ...liProps }, ref) => {
47
+ const MenuItem = React.forwardRef(({ children, active, selected, disabled, className, itemRole, ...liProps }, ref) => {
46
48
  // If caller sets itemRole prop, use it; otherwise attempt to inherit from parent Menu via data attr.
47
49
  // (We can’t reliably read parent props here without context; simplest is: caller passes itemRole on Menu.Item when needed.)
48
50
  const resolvedRole = itemRole !== null && itemRole !== void 0 ? itemRole : 'menuitem';
49
51
  if (isInteractiveEl(children)) {
50
52
  const child = children;
51
- return (_jsx("li", { ref: ref, role: "none", className: [styles.row, className].filter(Boolean).join(' '), ...liProps, children: applyMenuItemPropsToElement(child, { active, disabled, role: resolvedRole }) }));
53
+ return (_jsx("li", { ref: ref, role: "none", className: [styles.row, className].filter(Boolean).join(' '), ...liProps, children: applyMenuItemPropsToElement(child, { active, selected, disabled, role: resolvedRole }) }));
52
54
  }
53
55
  // Fallback: wrap non-interactive children in a <button>
54
- return (_jsx("li", { ref: ref, role: "none", className: [styles.row, className].filter(Boolean).join(' '), ...liProps, children: _jsx("button", { role: resolvedRole, tabIndex: -1, "aria-selected": active || undefined, "aria-disabled": disabled || undefined, className: [styles.interactive, styles.item, active ? styles.active : '']
56
+ return (_jsx("li", { ref: ref, role: "none", className: [styles.row, className].filter(Boolean).join(' '), ...liProps, children: _jsx("button", { role: resolvedRole, tabIndex: -1, "aria-selected": selected || undefined, "aria-disabled": disabled || undefined, className: [
57
+ styles.interactive,
58
+ styles.item,
59
+ active ? styles.active : '',
60
+ selected ? styles.selected : '',
61
+ ]
55
62
  .filter(Boolean)
56
63
  .join(' '), type: "button", disabled: disabled, children: children }) }));
57
64
  });
@@ -1,4 +1,3 @@
1
- /* Menu.module.css */
2
1
  .container {
3
2
  list-style: none;
4
3
  margin: 0;
@@ -11,11 +10,11 @@
11
10
  }
12
11
 
13
12
  .container > li + li {
14
- margin-block-start: var(--spacing-2xs);
13
+ margin-block-start: var(--spacing-xxs);
15
14
  }
16
15
 
17
16
  .row {
18
- display: contents;
17
+ display: block;
19
18
  }
20
19
 
21
20
  /* Applied to actual interactive elements (button/a/custom that forwards className) */
@@ -29,7 +28,7 @@
29
28
  text-decoration: none;
30
29
 
31
30
  /* choose your density */
32
- padding-block: var(--spacing-xs);
31
+ padding-block: var(--spacing-xxs);
33
32
  padding-inline: var(--spacing-md);
34
33
 
35
34
  background: transparent;
@@ -70,7 +69,7 @@
70
69
  display: flex;
71
70
  align-items: flex-start;
72
71
  inline-size: 100%;
73
- padding-block: var(--spacing-xxs);
72
+ padding-block: 2px;
74
73
  padding-inline: var(--spacing-md);
75
74
  border-radius: var(--border-radius-sm);
76
75
  }
@@ -90,8 +89,8 @@
90
89
  }
91
90
 
92
91
  /* Hover: support both cases (interactive element, or wrapper child) */
93
- .interactive:hover,
94
- .row:hover > .interactiveChild {
92
+ .interactive:hover:not(.selected),
93
+ .row:hover > .interactiveChild:not(.selected) {
95
94
  background-color: var(--color-bg-hover-subtle);
96
95
  }
97
96
 
@@ -107,10 +106,17 @@
107
106
  box-shadow: var(--focus-ring);
108
107
  }
109
108
 
110
- /* Selected/active (legacy + item variant) */
109
+ /* Keyboard active/current item */
111
110
  .active,
111
+ .interactive.active,
112
+ .row > .interactiveChild.active {
113
+ background-color: var(--color-bg-hover-subtle);
114
+ }
115
+
116
+ /* Selected item */
117
+ .selected,
112
118
  .interactive[aria-selected='true'],
113
- .row > .interactiveChild.active,
119
+ .row > .interactiveChild.selected,
114
120
  .row > .interactiveChild[aria-selected='true'] {
115
121
  background-color: var(--color-bg-selected);
116
122
  color: var(--color-fg-default);
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { X } from 'lucide-react';
4
- import { useEffect, useId, useRef } from 'react';
4
+ import { useEffect, useId, useRef, useState } from 'react';
5
+ import { createPortal } from 'react-dom';
5
6
  import { Button } from '../../../components/button/Button';
6
7
  import { Headline } from '../../../components/headline/Headline';
7
8
  import styles from './Modal.module.css';
@@ -9,11 +10,15 @@ export function Modal({ isOpen, onRequestClose, header, content, children, prima
9
10
  const titleId = useId();
10
11
  const dialogRef = useRef(null);
11
12
  const lastActiveElementRef = useRef(null);
13
+ const [mounted, setMounted] = useState(false);
12
14
  // Always call the latest onRequestClose without re-running effects
13
15
  const onRequestCloseRef = useRef(onRequestClose);
14
16
  useEffect(() => {
15
17
  onRequestCloseRef.current = onRequestClose;
16
18
  }, [onRequestClose]);
19
+ useEffect(() => {
20
+ setMounted(true);
21
+ }, []);
17
22
  // Track open transition so we only autofocus once per open
18
23
  const wasOpenRef = useRef(false);
19
24
  useEffect(() => {
@@ -72,7 +77,7 @@ export function Modal({ isOpen, onRequestClose, header, content, children, prima
72
77
  }
73
78
  };
74
79
  }, [isOpen]);
75
- if (!isOpen)
80
+ if (!isOpen || !mounted)
76
81
  return null;
77
82
  const handleOverlayClick = () => {
78
83
  if (closeOnOverlayClick) {
@@ -86,7 +91,7 @@ export function Modal({ isOpen, onRequestClose, header, content, children, prima
86
91
  const shouldRenderFooter = Boolean(primaryAction || resolvedSecondaryAction);
87
92
  const body = children !== null && children !== void 0 ? children : content;
88
93
  const resolvedWidth = typeof width === 'number' ? `${width}px` : typeof width === 'string' ? width : undefined;
89
- return (_jsx("div", { className: styles.overlay, onClick: handleOverlayClick, children: _jsxs("div", { "data-cy": dataCy, ref: dialogRef, className: `${styles.modal} ${disableContentSpacing ? '' : styles.contentSpacing}`, style: resolvedWidth
94
+ return createPortal(_jsx("div", { className: styles.overlay, onClick: handleOverlayClick, children: _jsxs("div", { "data-cy": dataCy, ref: dialogRef, className: `${styles.modal} ${disableContentSpacing ? '' : styles.contentSpacing}`, style: resolvedWidth
90
95
  ? { ['--modal-width']: resolvedWidth }
91
- : undefined, onClick: stopPropagation, role: "dialog", "aria-modal": "true", "aria-labelledby": header ? titleId : undefined, tabIndex: -1, children: [_jsxs("div", { className: styles.header, children: [header && (_jsx(Headline, { severity: severity, size: 3, disableMargin: true, children: header })), _jsx(Button, { type: "button", variant: "inline", onClick: () => onRequestCloseRef.current(), "aria-label": "Luk", shape: "round", icon: _jsx(X, {}) })] }), _jsx("div", { className: styles.body, children: body }), shouldRenderFooter && (_jsxs("div", { className: styles.footer, children: [resolvedSecondaryAction && (_jsxs(Button, { type: "button", onClick: resolvedSecondaryAction.onClick, disabled: isLoading, children: [resolvedSecondaryAction.icon && (_jsx("span", { className: styles.icon, children: resolvedSecondaryAction.icon })), _jsx("span", { children: resolvedSecondaryAction.label })] })), primaryAction && (_jsxs(Button, { type: "button", variant: primaryAction.severity || 'primary', onClick: primaryAction.onClick, disabled: primaryAction.disabled || isLoading, loading: isLoading, children: [primaryAction.icon && _jsx("span", { className: styles.icon, children: primaryAction.icon }), _jsx("span", { children: primaryAction.label })] }))] }))] }) }));
96
+ : undefined, onClick: stopPropagation, role: "dialog", "aria-modal": "true", "aria-labelledby": header ? titleId : undefined, tabIndex: -1, children: [_jsxs("div", { className: styles.header, children: [header && (_jsx(Headline, { severity: severity, size: 3, disableMargin: true, children: header })), _jsx(Button, { type: "button", variant: "inline", onClick: () => onRequestCloseRef.current(), "aria-label": "Luk", shape: "round", icon: _jsx(X, {}) })] }), _jsx("div", { className: styles.body, children: body }), shouldRenderFooter && (_jsxs("div", { className: styles.footer, children: [resolvedSecondaryAction && (_jsxs(Button, { type: "button", onClick: resolvedSecondaryAction.onClick, disabled: isLoading, children: [resolvedSecondaryAction.icon && (_jsx("span", { className: styles.icon, children: resolvedSecondaryAction.icon })), _jsx("span", { children: resolvedSecondaryAction.label })] })), primaryAction && (_jsxs(Button, { type: "button", variant: primaryAction.severity || 'primary', onClick: primaryAction.onClick, disabled: primaryAction.disabled || isLoading, loading: isLoading, children: [primaryAction.icon && _jsx("span", { className: styles.icon, children: primaryAction.icon }), _jsx("span", { children: primaryAction.label })] }))] }))] }) }), document.body);
92
97
  }
@@ -0,0 +1,24 @@
1
+ import { Dispatch, SetStateAction } from 'react';
2
+ import type { KeyboardEvent, RefObject } from 'react';
3
+ interface UseListNavigationProps<T> {
4
+ options: T[];
5
+ getLabel: (option: T) => string;
6
+ isOpen: boolean;
7
+ onOpenChange: (open: boolean) => void;
8
+ searchInputRef?: RefObject<HTMLInputElement | null>;
9
+ searchable?: boolean;
10
+ focusActiveOptionOnOpen?: boolean;
11
+ typeaheadTimeoutMs?: number;
12
+ getInitialActiveIndex?: (options: T[]) => number;
13
+ }
14
+ interface UseListNavigationResult {
15
+ activeIndex: number;
16
+ setActiveIndex: Dispatch<SetStateAction<number>>;
17
+ optionRefs: RefObject<(HTMLButtonElement | null)[]>;
18
+ resetActiveIndex: () => void;
19
+ clearTypeahead: () => void;
20
+ handleKeyDown: (e: KeyboardEvent) => void;
21
+ focusActiveOption: () => void;
22
+ }
23
+ export declare function useListNavigation<T>({ options, getLabel, isOpen, onOpenChange, searchInputRef, searchable, focusActiveOptionOnOpen, typeaheadTimeoutMs, getInitialActiveIndex, }: UseListNavigationProps<T>): UseListNavigationResult;
24
+ export {};
@@ -0,0 +1,234 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ export function useListNavigation({ options, getLabel, isOpen, onOpenChange, searchInputRef, searchable = false, focusActiveOptionOnOpen = true, typeaheadTimeoutMs = 500, getInitialActiveIndex, }) {
3
+ const optionRefs = useRef([]);
4
+ const typeaheadRef = useRef('');
5
+ const typeaheadTimeoutRef = useRef(null);
6
+ const normalizedLabels = useMemo(() => options.map(option => getLabel(option).trim().toLocaleLowerCase()), [options, getLabel]);
7
+ const getDefaultInitialIndex = useCallback((items) => {
8
+ if (items.length === 0)
9
+ return -1;
10
+ if (getInitialActiveIndex) {
11
+ const nextIndex = getInitialActiveIndex(items);
12
+ if (nextIndex < 0)
13
+ return -1;
14
+ return Math.min(nextIndex, items.length - 1);
15
+ }
16
+ return searchable ? -1 : 0;
17
+ }, [getInitialActiveIndex, searchable]);
18
+ const [activeIndex, setActiveIndex] = useState(() => getDefaultInitialIndex(options));
19
+ const clearTypeahead = useCallback(() => {
20
+ typeaheadRef.current = '';
21
+ if (typeaheadTimeoutRef.current) {
22
+ clearTimeout(typeaheadTimeoutRef.current);
23
+ typeaheadTimeoutRef.current = null;
24
+ }
25
+ }, []);
26
+ const focusActiveOption = useCallback(() => {
27
+ var _a;
28
+ if (activeIndex < 0 || options.length === 0)
29
+ return;
30
+ (_a = optionRefs.current[activeIndex]) === null || _a === void 0 ? void 0 : _a.focus();
31
+ }, [activeIndex, options.length]);
32
+ const resetActiveIndex = useCallback(() => {
33
+ setActiveIndex(getDefaultInitialIndex(options));
34
+ }, [getDefaultInitialIndex, options]);
35
+ useEffect(() => {
36
+ return () => {
37
+ if (typeaheadTimeoutRef.current)
38
+ clearTimeout(typeaheadTimeoutRef.current);
39
+ };
40
+ }, []);
41
+ useEffect(() => {
42
+ setActiveIndex(current => {
43
+ if (options.length === 0)
44
+ return -1;
45
+ if (current < 0)
46
+ return current;
47
+ return Math.min(current, options.length - 1);
48
+ });
49
+ }, [options]);
50
+ useEffect(() => {
51
+ optionRefs.current = optionRefs.current.slice(0, options.length);
52
+ }, [options.length]);
53
+ useEffect(() => {
54
+ if (!isOpen || !focusActiveOptionOnOpen)
55
+ return;
56
+ if (searchable && document.activeElement === (searchInputRef === null || searchInputRef === void 0 ? void 0 : searchInputRef.current))
57
+ return;
58
+ focusActiveOption();
59
+ }, [activeIndex, focusActiveOption, focusActiveOptionOnOpen, isOpen, searchable, searchInputRef]);
60
+ const moveNext = useCallback(() => {
61
+ if (options.length === 0)
62
+ return;
63
+ setActiveIndex(index => {
64
+ if (index < 0)
65
+ return 0;
66
+ return Math.min(index + 1, options.length - 1);
67
+ });
68
+ }, [options.length]);
69
+ const movePrev = useCallback(() => {
70
+ if (options.length === 0)
71
+ return;
72
+ setActiveIndex(index => {
73
+ if (index < 0)
74
+ return options.length - 1;
75
+ return Math.max(index - 1, 0);
76
+ });
77
+ }, [options.length]);
78
+ const moveFirst = useCallback(() => {
79
+ if (options.length === 0)
80
+ return;
81
+ setActiveIndex(0);
82
+ }, [options.length]);
83
+ const moveLast = useCallback(() => {
84
+ if (options.length === 0)
85
+ return;
86
+ setActiveIndex(options.length - 1);
87
+ }, [options.length]);
88
+ const findTypeaheadMatch = useCallback((query, startIndex) => {
89
+ if (!query || options.length === 0)
90
+ return -1;
91
+ const normalizedQuery = query.trim().toLocaleLowerCase();
92
+ if (!normalizedQuery)
93
+ return -1;
94
+ const safeStartIndex = startIndex < 0 ? -1 : startIndex;
95
+ for (let step = 1; step <= options.length; step += 1) {
96
+ const index = (safeStartIndex + step + options.length) % options.length;
97
+ const label = normalizedLabels[index];
98
+ if (label === null || label === void 0 ? void 0 : label.startsWith(normalizedQuery))
99
+ return index;
100
+ }
101
+ return -1;
102
+ }, [normalizedLabels, options.length]);
103
+ const handleTypeahead = useCallback((key) => {
104
+ const nextBuffer = `${typeaheadRef.current}${key.toLocaleLowerCase()}`;
105
+ const repeatedChar = new Set(nextBuffer).size === 1;
106
+ let nextIndex = findTypeaheadMatch(nextBuffer, activeIndex);
107
+ let appliedBuffer = nextBuffer;
108
+ if (nextIndex < 0 && repeatedChar) {
109
+ appliedBuffer = key.toLocaleLowerCase();
110
+ nextIndex = findTypeaheadMatch(appliedBuffer, activeIndex);
111
+ }
112
+ if (typeaheadTimeoutRef.current)
113
+ clearTimeout(typeaheadTimeoutRef.current);
114
+ typeaheadRef.current = appliedBuffer;
115
+ typeaheadTimeoutRef.current = setTimeout(() => {
116
+ typeaheadRef.current = '';
117
+ typeaheadTimeoutRef.current = null;
118
+ }, typeaheadTimeoutMs);
119
+ if (nextIndex < 0)
120
+ return;
121
+ setActiveIndex(nextIndex);
122
+ if (!isOpen)
123
+ onOpenChange(true);
124
+ }, [activeIndex, findTypeaheadMatch, isOpen, onOpenChange, typeaheadTimeoutMs]);
125
+ const handleKeyDown = useCallback((e) => {
126
+ var _a;
127
+ const isSearchInputTarget = searchable && e.target === (searchInputRef === null || searchInputRef === void 0 ? void 0 : searchInputRef.current);
128
+ if (isSearchInputTarget) {
129
+ switch (e.key) {
130
+ case 'ArrowDown':
131
+ e.preventDefault();
132
+ if (!isOpen) {
133
+ onOpenChange(true);
134
+ return;
135
+ }
136
+ moveNext();
137
+ return;
138
+ case 'ArrowUp':
139
+ e.preventDefault();
140
+ if (!isOpen) {
141
+ onOpenChange(true);
142
+ return;
143
+ }
144
+ movePrev();
145
+ return;
146
+ case 'Home':
147
+ if (!isOpen)
148
+ return;
149
+ e.preventDefault();
150
+ moveFirst();
151
+ return;
152
+ case 'End':
153
+ if (!isOpen)
154
+ return;
155
+ e.preventDefault();
156
+ moveLast();
157
+ return;
158
+ case 'Escape':
159
+ e.preventDefault();
160
+ onOpenChange(false);
161
+ return;
162
+ }
163
+ return;
164
+ }
165
+ if (e.key.length === 1 && !e.altKey && !e.ctrlKey && !e.metaKey && !/\s/.test(e.key)) {
166
+ e.preventDefault();
167
+ handleTypeahead(e.key);
168
+ return;
169
+ }
170
+ switch (e.key) {
171
+ case 'ArrowDown':
172
+ e.preventDefault();
173
+ if (!isOpen) {
174
+ onOpenChange(true);
175
+ return;
176
+ }
177
+ moveNext();
178
+ break;
179
+ case 'ArrowUp':
180
+ e.preventDefault();
181
+ if (!isOpen) {
182
+ onOpenChange(true);
183
+ return;
184
+ }
185
+ if (searchable &&
186
+ activeIndex === 0 &&
187
+ optionRefs.current[activeIndex] === document.activeElement) {
188
+ (_a = searchInputRef === null || searchInputRef === void 0 ? void 0 : searchInputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
189
+ setActiveIndex(-1);
190
+ return;
191
+ }
192
+ movePrev();
193
+ break;
194
+ case 'Home':
195
+ if (!isOpen)
196
+ return;
197
+ e.preventDefault();
198
+ moveFirst();
199
+ break;
200
+ case 'End':
201
+ if (!isOpen)
202
+ return;
203
+ e.preventDefault();
204
+ moveLast();
205
+ break;
206
+ case 'Escape':
207
+ if (!isOpen)
208
+ return;
209
+ e.preventDefault();
210
+ onOpenChange(false);
211
+ break;
212
+ }
213
+ }, [
214
+ activeIndex,
215
+ handleTypeahead,
216
+ isOpen,
217
+ moveFirst,
218
+ moveLast,
219
+ moveNext,
220
+ movePrev,
221
+ onOpenChange,
222
+ searchInputRef,
223
+ searchable,
224
+ ]);
225
+ return {
226
+ activeIndex,
227
+ setActiveIndex,
228
+ optionRefs,
229
+ resetActiveIndex,
230
+ clearTypeahead,
231
+ handleKeyDown,
232
+ focusActiveOption,
233
+ };
234
+ }
package/dist/index.d.ts CHANGED
@@ -67,3 +67,4 @@ export * from './components/interval-select/IntervalSelect';
67
67
  export * from './components/accordion/Accordion';
68
68
  export * from './components/state-page/StatePage';
69
69
  export * from './components/sticky-footer-layout/StickyFooterLayout';
70
+ export * from './components/forms/typeahead/Typeahead';
package/dist/index.js CHANGED
@@ -67,3 +67,4 @@ export * from './components/interval-select/IntervalSelect';
67
67
  export * from './components/accordion/Accordion';
68
68
  export * from './components/state-page/StatePage';
69
69
  export * from './components/sticky-footer-layout/StickyFooterLayout';
70
+ export * from './components/forms/typeahead/Typeahead';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbcdk/react-components",
3
- "version": "0.0.44",
3
+ "version": "0.0.46",
4
4
  "description": "Reusable React components for DBC projects",
5
5
  "license": "ISC",
6
6
  "author": "",