@dbcdk/react-components 0.0.27 → 0.0.29

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.
@@ -2,14 +2,14 @@ import type { ReactNode, JSX } from 'react';
2
2
  import { Severity } from '../../constants/severity.types';
3
3
  interface ChipProps {
4
4
  children: ReactNode;
5
- severity?: Severity | null;
5
+ severity?: Severity;
6
6
  disableIcon?: boolean;
7
7
  customIcon?: ReactNode;
8
8
  loading?: boolean;
9
9
  fullWidth?: boolean;
10
10
  onClose?: () => void;
11
11
  size?: 'sm' | 'md' | 'lg';
12
- type?: 'default' | 'rounded' | 'outlined' | 'subtle';
12
+ type?: 'default' | 'rounded' | 'outlined';
13
13
  selected?: boolean;
14
14
  }
15
15
  export declare function Chip({ children, severity, loading, disableIcon, fullWidth, size, customIcon, type, selected, onClose, }: ChipProps): JSX.Element;
@@ -5,7 +5,7 @@ import { useState, useRef, useEffect } from 'react';
5
5
  import styles from './Chip.module.css';
6
6
  import { Icon } from '../icon/Icon';
7
7
  import { SkeletonLoaderItem } from '../skeleton-loader/skeleton-loader-item/SkeletonLoaderItem';
8
- export function Chip({ children, severity = 'neutral', loading, disableIcon = true, fullWidth = false, size = 'md', customIcon, type = 'rounded', selected = false, onClose, }) {
8
+ export function Chip({ children, severity = 'neutral', loading, disableIcon = true, fullWidth = false, size = 'sm', customIcon, type = 'rounded', selected = false, onClose, }) {
9
9
  const [chipWidth, setChipWidth] = useState(undefined);
10
10
  const chipRef = useRef(null);
11
11
  useEffect(() => {
@@ -4,9 +4,9 @@
4
4
  gap: var(--spacing-xs);
5
5
  white-space: nowrap;
6
6
 
7
- color: var(--chip-fg-default, var(--color-fg-default));
8
- background: transparent;
9
- border: 1px solid transparent;
7
+ color: var(--chip-fg, var(--color-fg-default));
8
+ background: var(--chip-bg, transparent);
9
+ border: 1px solid var(--chip-border, transparent);
10
10
  border-radius: var(--border-radius-rounded);
11
11
 
12
12
  padding-inline: var(--spacing-sm);
@@ -39,17 +39,17 @@
39
39
  /* Types */
40
40
 
41
41
  .outlined {
42
- background-color: var(--color-bg-surface);
43
- border: 1px solid var(--color-border-subtle);
42
+ --chip-bg: var(--color-bg-surface);
43
+ --chip-border: var(--chip-outlined-border, var(--color-border-subtle));
44
+ --chip-fg: var(--chip-outlined-fg, var(--color-fg-muted));
44
45
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
45
- color: var(--color-fg-muted);
46
46
  }
47
47
 
48
48
  .outlined:hover {
49
49
  background-color: var(--color-bg-surface);
50
- border-color: var(--color-border-default);
50
+ border-color: var(--chip-outlined-border-hover, var(--color-border-default));
51
51
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
52
- color: var(--color-fg-default);
52
+ color: var(--chip-outlined-fg-hover, var(--color-fg-default));
53
53
  }
54
54
 
55
55
  .outlined:focus-visible {
@@ -58,27 +58,7 @@
58
58
  box-shadow:
59
59
  0 1px 2px rgba(0, 0, 0, 0.03),
60
60
  inset 0 0 0 1px var(--color-border-selected);
61
- color: var(--color-fg-default);
62
- }
63
-
64
- .subtle {
65
- background-color: var(--color-bg-toolbar);
66
- border: 1px solid transparent;
67
- box-shadow: inset 0 0 0 1px transparent;
68
- color: var(--color-fg-muted);
69
- }
70
-
71
- .subtle:hover {
72
- background-color: var(--color-bg-toolbar-hover);
73
- border-color: transparent;
74
- color: var(--color-fg-default);
75
- }
76
-
77
- .subtle:focus-visible {
78
- outline: none;
79
- border-color: var(--color-border-selected);
80
- box-shadow: inset 0 0 0 1px var(--color-border-selected);
81
- color: var(--color-fg-default);
61
+ color: var(--chip-outlined-fg-hover, var(--color-fg-default));
82
62
  }
83
63
 
84
64
  /* Content */
@@ -103,6 +83,11 @@
103
83
  color: currentColor;
104
84
  }
105
85
 
86
+ .sm .leading svg {
87
+ inline-size: var(--icon-size-xs);
88
+ block-size: var(--icon-size-xs);
89
+ }
90
+
106
91
  /* Sizes */
107
92
 
108
93
  .sm {
@@ -122,44 +107,68 @@
122
107
  /* Status variants */
123
108
 
124
109
  .neutral {
125
- background-color: var(--color-bg-toolbar);
126
- color: var(--color-fg-default);
127
- border-color: transparent;
128
- box-shadow: inset 0 0 0 1px var(--color-border-subtle);
110
+ --chip-bg: var(--color-bg-toolbar);
111
+ --chip-fg: var(--color-fg-default);
112
+ --chip-border: transparent;
113
+ --chip-outlined-border: var(--color-border-subtle);
114
+ --chip-outlined-border-hover: var(--color-border-default);
115
+ --chip-outlined-fg: var(--color-fg-muted);
116
+ --chip-outlined-fg-hover: var(--color-fg-default);
117
+ box-shadow: none;
129
118
  }
130
119
 
131
120
  .success {
132
- background-color: var(--color-status-success-bg);
133
- color: var(--color-status-success-fg);
134
- border-color: transparent;
121
+ --chip-bg: var(--color-status-success-bg);
122
+ --chip-fg: var(--color-status-success-fg);
123
+ --chip-border: transparent;
124
+ --chip-outlined-border: var(--color-status-success-border);
125
+ --chip-outlined-border-hover: var(--color-status-success-border);
126
+ --chip-outlined-fg: var(--color-status-success-fg);
127
+ --chip-outlined-fg-hover: var(--color-status-success-fg);
135
128
  box-shadow: inset 0 0 0 1px var(--color-status-success-border);
136
129
  }
137
130
 
138
131
  .warning {
139
- background-color: var(--color-status-warning-bg);
140
- color: var(--color-status-warning-fg);
141
- border-color: transparent;
132
+ --chip-bg: var(--color-status-warning-bg);
133
+ --chip-fg: var(--color-status-warning-fg);
134
+ --chip-border: transparent;
135
+ --chip-outlined-border: var(--color-status-warning-border);
136
+ --chip-outlined-border-hover: var(--color-status-warning-border);
137
+ --chip-outlined-fg: var(--color-status-warning-fg);
138
+ --chip-outlined-fg-hover: var(--color-status-warning-fg);
142
139
  box-shadow: inset 0 0 0 1px var(--color-status-warning-border);
143
140
  }
144
141
 
145
142
  .error {
146
- background-color: var(--color-status-error-bg);
147
- color: var(--color-status-error-fg);
148
- border-color: transparent;
143
+ --chip-bg: var(--color-status-error-bg);
144
+ --chip-fg: var(--color-status-error-fg);
145
+ --chip-border: transparent;
146
+ --chip-outlined-border: var(--color-status-error-border);
147
+ --chip-outlined-border-hover: var(--color-status-error-border);
148
+ --chip-outlined-fg: var(--color-status-error-fg);
149
+ --chip-outlined-fg-hover: var(--color-status-error-fg);
149
150
  box-shadow: inset 0 0 0 1px var(--color-status-error-border);
150
151
  }
151
152
 
152
153
  .info {
153
- background-color: var(--color-status-info-bg);
154
- color: var(--color-status-info-fg);
155
- border-color: transparent;
154
+ --chip-bg: var(--color-status-info-bg);
155
+ --chip-fg: var(--color-status-info-fg);
156
+ --chip-border: transparent;
157
+ --chip-outlined-border: var(--color-status-info-border);
158
+ --chip-outlined-border-hover: var(--color-status-info-border);
159
+ --chip-outlined-fg: var(--color-status-info-fg);
160
+ --chip-outlined-fg-hover: var(--color-status-info-fg);
156
161
  box-shadow: inset 0 0 0 1px var(--color-status-info-border);
157
162
  }
158
163
 
159
164
  .brand {
160
- background-color: var(--color-brand);
161
- color: var(--color-fg-on-brand);
162
- border-color: transparent;
165
+ --chip-bg: var(--color-brand);
166
+ --chip-fg: var(--color-fg-on-brand);
167
+ --chip-border: transparent;
168
+ --chip-outlined-border: color-mix(in srgb, var(--color-brand) 50%, transparent);
169
+ --chip-outlined-border-hover: var(--color-brand);
170
+ --chip-outlined-fg: var(--color-brand);
171
+ --chip-outlined-fg-hover: var(--color-brand);
163
172
  box-shadow: inset 0 0 0 1px transparent;
164
173
  }
165
174
 
@@ -143,12 +143,5 @@ export function FilterField({ field, control, operator, value, onChange, operato
143
143
  clearTimeout(debounceRef.current);
144
144
  setLocalValue('');
145
145
  emit({ value: '' });
146
- } })) : single ? (_jsx(Select, { options: options, selectedValue: (_b = value) !== null && _b !== void 0 ? _b : null, onChange: v => emit({ value: v }), placeholder: placeholder, size: size, variant: "inline", onClear: () => emit({ value: '' }) })) : (_jsx(MultiSelect, { options: options, size: size, variant: "inline", selectedValues: (Array.isArray(value) ? value : []), onChange: v => {
147
- const current = new Set((Array.isArray(value) ? value : []));
148
- if (current.has(v))
149
- current.delete(v);
150
- else
151
- current.add(v);
152
- emit({ value: Array.from(current) });
153
- }, onClear: () => emit({ value: [] }), children: placeholder })) })] }));
146
+ } })) : single ? (_jsx(Select, { options: options, selectedValue: (_b = value) !== null && _b !== void 0 ? _b : null, onChange: v => emit({ value: v }), placeholder: placeholder, size: size, variant: "inline", onClear: () => emit({ value: '' }), disabled: disabled })) : (_jsx(MultiSelect, { options: options, size: size, variant: "inline", selectedValues: (Array.isArray(value) ? value : []), onChange: nextSelectedValues => emit({ value: nextSelectedValues }), onClear: () => emit({ value: [] }), fullWidth: true, disabled: disabled, children: placeholder })) })] }));
154
147
  }
@@ -296,10 +296,6 @@
296
296
  min-width: 0;
297
297
  }
298
298
 
299
- .filterField.active .valueWrapper {
300
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border-selected) 18%, transparent);
301
- }
302
-
303
299
  /* =========================
304
300
  TEXT
305
301
  ========================= */
@@ -18,7 +18,7 @@ interface ChipMultiToggleProps {
18
18
  allLabel?: string;
19
19
  allIcon?: React.ReactNode;
20
20
  dataCy?: string;
21
- type?: 'default' | 'outlined' | 'subtle' | 'rounded';
21
+ type?: 'default' | 'outlined' | 'rounded';
22
22
  /**
23
23
  * If true, clicking "All" toggles between:
24
24
  * - All (no filtering) => []
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import React from 'react';
4
4
  import { Chip } from '../../../components/chip/Chip';
5
5
  import styles from './ChipMultiToggle.module.css';
6
- export function ChipMultiToggle({ label, options, selectedValues = [], onChange, onToggle, size = 'sm', fullWidth = false, disabled = false, showAllOption = false, allLabel = 'Alle', allIcon, allTogglesNone = false, noneValue = '__none__', type = 'subtle', dataCy, }) {
6
+ export function ChipMultiToggle({ label, options, selectedValues = [], onChange, onToggle, size = 'sm', fullWidth = false, disabled = false, showAllOption = false, allLabel = 'Alle', allIcon, allTogglesNone = false, noneValue = '__none__', type = 'rounded', dataCy, }) {
7
7
  const selectedSet = React.useMemo(() => new Set(selectedValues), [selectedValues]);
8
8
  const isNoneSelected = allTogglesNone && selectedSet.has(noneValue);
9
9
  const isAllSelected = showAllOption && !isNoneSelected && selectedSet.size === 0;
@@ -44,9 +44,9 @@ export function ChipMultiToggle({ label, options, selectedValues = [], onChange,
44
44
  onChange([]);
45
45
  }
46
46
  };
47
- return (_jsxs("div", { className: `${styles.wrapper} ${fullWidth ? styles.fullWidth : ''}`, "data-cy": dataCy, children: [label && _jsx("span", { className: styles.label, children: label }), _jsxs("div", { className: styles.container, children: [showAllOption && (_jsx("button", { type: "button", className: styles.chipButton, onClick: toggleAll, "aria-pressed": isAllSelected || isNoneSelected, disabled: disabled, children: _jsx(Chip, { type: type, size: size, severity: null, customIcon: allIcon, selected: isAllSelected || isNoneSelected, children: isNoneSelected ? `${allLabel} (ingen)` : allLabel }) }, "__all__")), options.map(opt => {
47
+ return (_jsxs("div", { className: `${styles.wrapper} ${fullWidth ? styles.fullWidth : ''}`, "data-cy": dataCy, children: [label && _jsx("span", { className: styles.label, children: label }), _jsxs("div", { className: styles.container, children: [showAllOption && (_jsx("button", { type: "button", className: styles.chipButton, onClick: toggleAll, "aria-pressed": isAllSelected || isNoneSelected, disabled: disabled, children: _jsx(Chip, { type: type, size: size, severity: "neutral", customIcon: allIcon, selected: isAllSelected || isNoneSelected, children: isNoneSelected ? `${allLabel} (ingen)` : allLabel }) }, "__all__")), options.map(opt => {
48
48
  // If none-state is active, everything else should look unselected
49
49
  const isSelected = !isNoneSelected && selectedSet.has(opt.value);
50
- return (_jsx("button", { type: "button", className: styles.chipButton, onClick: () => toggleValue(opt.value), "aria-pressed": isSelected, disabled: disabled, children: _jsx(Chip, { size: size, customIcon: opt.icon, severity: null, type: type, selected: isSelected, children: opt.label }) }, opt.value));
50
+ return (_jsx("button", { type: "button", className: styles.chipButton, onClick: () => toggleValue(opt.value), "aria-pressed": isSelected, disabled: disabled, children: _jsx(Chip, { size: size, customIcon: opt.icon, severity: "neutral", type: type, selected: isSelected, children: opt.label }) }, opt.value));
51
51
  })] })] }));
52
52
  }
@@ -36,7 +36,6 @@
36
36
  }
37
37
 
38
38
  .chipButton:disabled {
39
- cursor: not-allowed;
40
39
  opacity: 1;
41
40
  color: var(--color-disabled-fg);
42
41
  }
@@ -8,13 +8,15 @@ export interface MultiselectOption<T> {
8
8
  interface MultiSelectProps<T> {
9
9
  options: MultiselectOption<T>[];
10
10
  selectedValues: T[];
11
- onChange: (selectedValue: T) => void;
11
+ onChange: (nextSelectedValues: T[]) => void;
12
12
  placeholder?: string;
13
13
  children?: ReactNode;
14
14
  variant?: ButtonVariant;
15
15
  size?: 'sm' | 'md' | 'lg';
16
16
  onClear?: () => void;
17
17
  dataCy?: string;
18
+ fullWidth?: boolean;
19
+ disabled?: boolean;
18
20
  }
19
- export declare function MultiSelect<T extends string | number>({ options, selectedValues, onChange, children, variant, size, onClear, dataCy, }: MultiSelectProps<T>): ReactNode;
21
+ export declare function MultiSelect<T extends string | number>({ options, selectedValues, onChange, placeholder, children, variant, size, onClear, dataCy, fullWidth, disabled, }: MultiSelectProps<T>): ReactNode;
20
22
  export {};
@@ -1,19 +1,157 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, Square } from 'lucide-react';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
4
  import { Button } from '../../../components/button/Button';
4
5
  import { Chip } from '../../../components/chip/Chip';
5
6
  import { ClearButton } from '../../../components/clear-button/ClearButton';
6
7
  import { Menu } from '../../../components/menu/Menu';
7
8
  import { Popover } from '../../../components/popover/Popover';
8
- export function MultiSelect({ options, selectedValues = [], onChange, children, variant = 'outlined', size = 'md', onClear, dataCy, }) {
9
- return (_jsx(Popover, { dataCy: dataCy, trigger: (onClick, icon) => (_jsxs(Button, { variant: variant, onClick: onClick, size: size, children: [_jsxs("span", { children: [children || 'Vælg', ' ', selectedValues.length > 0 ? (_jsx(Chip, { size: "sm", severity: "info", children: `${selectedValues.length}` })) : null] }), onClear && selectedValues.length > 0 && _jsx(ClearButton, { onClick: onClear }), icon] })), children: _jsx(Menu, { children: options.map(option => (_jsx(Menu.Item, { active: selectedValues === null || selectedValues === void 0 ? void 0 : selectedValues.includes(option.value), children: _jsxs("button", { onClick: () => {
10
- onChange(option.value);
9
+ export function MultiSelect({ options, selectedValues = [], onChange, placeholder = 'Vælg', children, variant = 'outlined', size = 'md', onClear, dataCy, fullWidth = false, disabled = false, }) {
10
+ const selectedSet = useMemo(() => new Set(selectedValues), [selectedValues]);
11
+ const popoverRef = useRef(null);
12
+ const optionRefs = useRef([]);
13
+ const typeaheadRef = useRef('');
14
+ const typeaheadTimeoutRef = useRef(null);
15
+ const [open, setOpen] = useState(false);
16
+ const [activeIndex, setActiveIndex] = useState(() => {
17
+ const selectedIndex = options.findIndex(option => selectedSet.has(option.value));
18
+ return selectedIndex >= 0 ? selectedIndex : 0;
19
+ });
20
+ useEffect(() => {
21
+ var _a;
22
+ if (!open)
23
+ return;
24
+ (_a = optionRefs.current[activeIndex]) === null || _a === void 0 ? void 0 : _a.focus();
25
+ }, [activeIndex, open]);
26
+ useEffect(() => {
27
+ return () => {
28
+ if (typeaheadTimeoutRef.current)
29
+ clearTimeout(typeaheadTimeoutRef.current);
30
+ };
31
+ }, []);
32
+ const resetActiveIndex = () => {
33
+ const selectedIndex = options.findIndex(option => selectedSet.has(option.value));
34
+ setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
35
+ };
36
+ const toggleValue = (value) => {
37
+ if (disabled)
38
+ return;
39
+ const next = new Set(selectedSet);
40
+ if (next.has(value))
41
+ next.delete(value);
42
+ else
43
+ next.add(value);
44
+ onChange(Array.from(next));
45
+ };
46
+ const clearTypeahead = () => {
47
+ typeaheadRef.current = '';
48
+ if (typeaheadTimeoutRef.current) {
49
+ clearTimeout(typeaheadTimeoutRef.current);
50
+ typeaheadTimeoutRef.current = null;
51
+ }
52
+ };
53
+ const findTypeaheadMatch = (query, startIndex) => {
54
+ var _a, _b;
55
+ if (!query || options.length === 0)
56
+ return -1;
57
+ const normalizedQuery = query.trim().toLocaleLowerCase();
58
+ if (!normalizedQuery)
59
+ return -1;
60
+ for (let step = 1; step <= options.length; step += 1) {
61
+ const index = (startIndex + step + options.length) % options.length;
62
+ const label = (_b = (_a = options[index]) === null || _a === void 0 ? void 0 : _a.label) === null || _b === void 0 ? void 0 : _b.trim().toLocaleLowerCase();
63
+ if (label === null || label === void 0 ? void 0 : label.startsWith(normalizedQuery))
64
+ return index;
65
+ }
66
+ return -1;
67
+ };
68
+ const handleTypeahead = (key) => {
69
+ const nextBuffer = `${typeaheadRef.current}${key.toLocaleLowerCase()}`;
70
+ const repeatedChar = new Set(nextBuffer).size === 1;
71
+ let nextIndex = findTypeaheadMatch(nextBuffer, activeIndex);
72
+ let appliedBuffer = nextBuffer;
73
+ if (nextIndex < 0 && repeatedChar) {
74
+ appliedBuffer = key.toLocaleLowerCase();
75
+ nextIndex = findTypeaheadMatch(appliedBuffer, activeIndex);
76
+ }
77
+ if (typeaheadTimeoutRef.current)
78
+ clearTimeout(typeaheadTimeoutRef.current);
79
+ typeaheadRef.current = appliedBuffer;
80
+ typeaheadTimeoutRef.current = setTimeout(() => {
81
+ typeaheadRef.current = '';
82
+ typeaheadTimeoutRef.current = null;
83
+ }, 500);
84
+ if (nextIndex < 0)
85
+ return;
86
+ setActiveIndex(nextIndex);
87
+ if (!open)
88
+ setOpen(true);
89
+ };
90
+ const handleKeyDown = (e) => {
91
+ var _a;
92
+ if (e.key.length === 1 && !e.altKey && !e.ctrlKey && !e.metaKey && !/\s/.test(e.key)) {
93
+ e.preventDefault();
94
+ handleTypeahead(e.key);
95
+ return;
96
+ }
97
+ switch (e.key) {
98
+ case 'ArrowDown':
99
+ e.preventDefault();
100
+ if (!open) {
101
+ setOpen(true);
102
+ return;
103
+ }
104
+ setActiveIndex(i => Math.min(i + 1, options.length - 1));
105
+ break;
106
+ case 'ArrowUp':
107
+ e.preventDefault();
108
+ if (!open) {
109
+ setOpen(true);
110
+ return;
111
+ }
112
+ setActiveIndex(i => Math.max(i - 1, 0));
113
+ break;
114
+ case 'Home':
115
+ if (!open)
116
+ return;
117
+ e.preventDefault();
118
+ setActiveIndex(0);
119
+ break;
120
+ case 'End':
121
+ if (!open)
122
+ return;
123
+ e.preventDefault();
124
+ setActiveIndex(options.length - 1);
125
+ break;
126
+ case 'Enter':
127
+ case ' ':
128
+ e.preventDefault();
129
+ clearTypeahead();
130
+ if (!open) {
131
+ setOpen(true);
132
+ return;
133
+ }
134
+ if (options[activeIndex])
135
+ toggleValue(options[activeIndex].value);
136
+ break;
137
+ case 'Escape':
138
+ if (!open)
139
+ return;
140
+ e.preventDefault();
141
+ (_a = popoverRef.current) === null || _a === void 0 ? void 0 : _a.close();
142
+ break;
143
+ }
144
+ };
145
+ return (_jsx(Popover, { ref: popoverRef, open: open, onOpenChange: next => {
146
+ setOpen(next);
147
+ if (next)
148
+ resetActiveIndex();
149
+ }, dataCy: dataCy, fullWidth: fullWidth, autoFocusContent: false, returnFocus: true, trigger: (onClick, icon, isOpen) => (_jsx(Button, { variant: variant, onClick: onClick, onKeyDown: handleKeyDown, size: size, fullWidth: fullWidth, disabled: disabled, "aria-haspopup": "menu", "aria-expanded": !!isOpen, children: _jsxs("span", { className: "dbc-flex dbc-justify-between dbc-items-center dbc-gap-xxs", style: { width: '100%' }, children: [_jsxs("span", { className: "dbc-flex dbc-items-center dbc-gap-xxs", children: [_jsx("span", { children: children !== null && children !== void 0 ? children : placeholder }), selectedValues.length > 0 ? (_jsx(Chip, { size: "sm", children: `${selectedValues.length}` })) : null] }), _jsxs("span", { className: "dbc-flex dbc-items-center dbc-gap-xxs", children: [onClear && selectedValues.length > 0 && _jsx(ClearButton, { onClick: onClear }), icon] })] }) })), children: _jsx(Menu, { onKeyDown: handleKeyDown, children: options.map((option, index) => (_jsx(Menu.Item, { active: index === activeIndex, children: _jsxs("button", { ref: el => (optionRefs.current[index] = el), type: "button", role: "menuitemcheckbox", "aria-checked": selectedSet.has(option.value), tabIndex: index === activeIndex ? 0 : -1, disabled: disabled, onFocus: () => setActiveIndex(index), onClick: () => {
150
+ toggleValue(option.value);
11
151
  }, children: [_jsx("span", { style: {
12
152
  pointerEvents: 'none',
13
153
  display: 'flex',
14
154
  alignItems: 'center',
15
- color: !(selectedValues === null || selectedValues === void 0 ? void 0 : selectedValues.includes(option.value))
16
- ? 'var(--color-border-strong)'
17
- : 'inherit',
18
- }, children: (selectedValues === null || selectedValues === void 0 ? void 0 : selectedValues.includes(option.value)) ? _jsx(Check, {}) : _jsx(Square, {}) }), option.label] }) }, option.value))) }) }));
155
+ color: !selectedSet.has(option.value) ? 'var(--color-border-strong)' : 'inherit',
156
+ }, children: selectedSet.has(option.value) ? _jsx(Check, {}) : _jsx(Square, {}) }), option.label] }) }, option.value))) }) }));
19
157
  }
@@ -15,9 +15,17 @@ export function Select({ label, error, helpText, orientation = 'vertical', label
15
15
  const listboxId = `${controlId}-listbox`;
16
16
  const popoverRef = useRef(null);
17
17
  const optionRefs = useRef([]);
18
+ const typeaheadRef = useRef('');
19
+ const typeaheadTimeoutRef = useRef(null);
18
20
  const selectedIndex = useMemo(() => options.findIndex(o => o.value === selectedValue), [options, selectedValue]);
19
21
  const [activeIndex, setActiveIndex] = useState(selectedIndex >= 0 ? selectedIndex : 0);
20
22
  const [open, setOpen] = useState(false);
23
+ useEffect(() => {
24
+ return () => {
25
+ if (typeaheadTimeoutRef.current)
26
+ clearTimeout(typeaheadTimeoutRef.current);
27
+ };
28
+ }, []);
21
29
  // Only move focus when open
22
30
  useEffect(() => {
23
31
  var _a;
@@ -27,8 +35,51 @@ export function Select({ label, error, helpText, orientation = 'vertical', label
27
35
  }, [activeIndex, open]);
28
36
  // keep activeIndex aligned when opening
29
37
  const resetActiveToSelected = () => setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
38
+ const findTypeaheadMatch = (query, startIndex) => {
39
+ var _a, _b;
40
+ if (!query || options.length === 0)
41
+ return -1;
42
+ const normalizedQuery = query.trim().toLocaleLowerCase();
43
+ if (!normalizedQuery)
44
+ return -1;
45
+ for (let step = 1; step <= options.length; step += 1) {
46
+ const index = (startIndex + step + options.length) % options.length;
47
+ const label = (_b = (_a = options[index]) === null || _a === void 0 ? void 0 : _a.label) === null || _b === void 0 ? void 0 : _b.trim().toLocaleLowerCase();
48
+ if (label === null || label === void 0 ? void 0 : label.startsWith(normalizedQuery))
49
+ return index;
50
+ }
51
+ return -1;
52
+ };
53
+ const handleTypeahead = (key) => {
54
+ const startIndex = open ? activeIndex : selectedIndex;
55
+ const nextBuffer = `${typeaheadRef.current}${key.toLocaleLowerCase()}`;
56
+ const repeatedChar = new Set(nextBuffer).size === 1;
57
+ let nextIndex = findTypeaheadMatch(nextBuffer, startIndex);
58
+ let appliedBuffer = nextBuffer;
59
+ if (nextIndex < 0 && repeatedChar) {
60
+ appliedBuffer = key.toLocaleLowerCase();
61
+ nextIndex = findTypeaheadMatch(appliedBuffer, startIndex);
62
+ }
63
+ if (typeaheadTimeoutRef.current)
64
+ clearTimeout(typeaheadTimeoutRef.current);
65
+ typeaheadRef.current = appliedBuffer;
66
+ typeaheadTimeoutRef.current = setTimeout(() => {
67
+ typeaheadRef.current = '';
68
+ typeaheadTimeoutRef.current = null;
69
+ }, 500);
70
+ if (nextIndex < 0)
71
+ return;
72
+ setActiveIndex(nextIndex);
73
+ if (!open)
74
+ setOpen(true);
75
+ };
30
76
  const handleKeyDown = (e) => {
31
77
  var _a, _b;
78
+ if (e.key.length === 1 && !e.altKey && !e.ctrlKey && !e.metaKey && !/\s/.test(e.key)) {
79
+ e.preventDefault();
80
+ handleTypeahead(e.key);
81
+ return;
82
+ }
32
83
  switch (e.key) {
33
84
  case 'ArrowDown': {
34
85
  e.preventDefault();
@@ -52,6 +103,11 @@ export function Select({ label, error, helpText, orientation = 'vertical', label
52
103
  case ' ': {
53
104
  // Space on a button should open/select depending on state
54
105
  e.preventDefault();
106
+ typeaheadRef.current = '';
107
+ if (typeaheadTimeoutRef.current) {
108
+ clearTimeout(typeaheadTimeoutRef.current);
109
+ typeaheadTimeoutRef.current = null;
110
+ }
55
111
  if (!open) {
56
112
  setOpen(true);
57
113
  return;
@@ -112,14 +168,12 @@ export function Select({ label, error, helpText, orientation = 'vertical', label
112
168
  returnFocus: true, trigger: (toggle, icon, isOpen) => (_jsx(Button, { disabled: disabled, ...(tooltipEnabled ? triggerProps : {}), id: controlId, "data-cy": dataCy !== null && dataCy !== void 0 ? dataCy : 'select-button', onKeyDown: handleKeyDown, fullWidth: fullWidth, variant: variant, onClick: e => {
113
169
  resetActiveToSelected();
114
170
  toggle(e);
115
- }, size: size, type: "button", "data-forminput": true, "aria-haspopup": "listbox", "aria-expanded": !!isOpen, "aria-controls": listboxId, "aria-invalid": Boolean(error) || undefined, "aria-describedby": describedBy, children: _jsxs("span", { className: "dbc-flex dbc-justify-between dbc-items-center dbc-gap-xxs", style: { width: '100%' }, children: [_jsx("span", { children: selected ? selected.label : _jsx("span", { className: "dbc-muted-text", children: placeholder }) }), onClear && selected && _jsx(ClearButton, { onClick: onClear }), icon] }) })), children: _jsx(Menu, { onKeyDown: handleKeyDown, role: "listbox", children: options.map((opt, index) => {
171
+ }, size: size, type: "button", "data-forminput": true, "aria-haspopup": "listbox", "aria-expanded": !!isOpen, "aria-controls": listboxId, "aria-invalid": Boolean(error) || undefined, "aria-describedby": describedBy, children: _jsxs("span", { className: "dbc-flex dbc-justify-between dbc-items-center dbc-gap-xxs", style: { width: '100%' }, children: [_jsx("span", { children: selected ? selected.label : _jsx("span", { className: "dbc-muted-text", children: placeholder }) }), _jsxs("span", { className: "dbc-flex dbc-items-center dbc-gap-xxs", children: [onClear && selected && _jsx(ClearButton, { onClick: onClear }), icon] })] }) })), children: _jsx(Menu, { onKeyDown: handleKeyDown, role: "listbox", children: options.map((opt, index) => {
116
172
  const isSelected = typeof opt.value === 'object' && typeof selectedValue === 'object' && datakey
117
173
  ? (selectedValue === null || selectedValue === void 0 ? void 0 : selectedValue[datakey]) === opt.value[datakey]
118
174
  : opt.value === selectedValue;
119
175
  const isActive = index === activeIndex;
120
- return (_jsx(Menu.Item, { active: isActive,
121
- // IMPORTANT: listbox uses role="option"
122
- itemRole: "option", "aria-selected": isSelected, children: _jsxs("button", { ref: el => (optionRefs.current[index] = el), type: "button", tabIndex: isActive ? 0 : -1, onClick: () => {
176
+ return (_jsx(Menu.Item, { active: isActive, itemRole: "option", "aria-selected": isSelected, children: _jsxs("button", { ref: el => (optionRefs.current[index] = el), type: "button", tabIndex: isActive ? 0 : -1, onClick: () => {
123
177
  var _a;
124
178
  onChange(opt.value);
125
179
  (_a = popoverRef.current) === null || _a === void 0 ? void 0 : _a.close();
@@ -19,6 +19,7 @@ export interface PopoverProps {
19
19
  viewportPadding?: number;
20
20
  edgeBuffer?: number;
21
21
  dataCy?: string;
22
+ fullWidth?: boolean;
22
23
  autoFocusContent?: boolean;
23
24
  returnFocus?: boolean;
24
25
  }
@@ -37,7 +37,7 @@ function parseMinWidthPx(minWidth, elForEm) {
37
37
  }
38
38
  return 0;
39
39
  }
40
- export const Popover = forwardRef(function Popover({ trigger: Trigger, children, open, defaultOpen = false, onOpenChange, contentId, minWidth = '200px', matchTriggerWidth = true, viewportPadding = 8, edgeBuffer = 100, dataCy, autoFocusContent = false, returnFocus = true, }, ref) {
40
+ export const Popover = forwardRef(function Popover({ trigger: Trigger, children, open, defaultOpen = false, onOpenChange, contentId, minWidth = '200px', matchTriggerWidth = true, viewportPadding = 8, edgeBuffer = 100, dataCy, fullWidth = false, autoFocusContent = false, returnFocus = true, }, ref) {
41
41
  const internalId = useId();
42
42
  const resolvedContentId = contentId !== null && contentId !== void 0 ? contentId : `popover-${internalId}`;
43
43
  const isControlled = open !== undefined;
@@ -201,7 +201,7 @@ export const Popover = forwardRef(function Popover({ trigger: Trigger, children,
201
201
  (_b = (_a = triggerElRef.current) === null || _a === void 0 ? void 0 : _a.focus) === null || _b === void 0 ? void 0 : _b.call(_a);
202
202
  }, [isOpen, returnFocus]);
203
203
  const icon = isOpen ? _jsx(ChevronUp, { size: 20 }) : _jsx(ChevronDown, { size: 20 });
204
- return (_jsxs("div", { className: styles.container, ref: containerRef, children: [Trigger(togglePopover, icon, isOpen), mounted &&
204
+ return (_jsxs("div", { className: [styles.container, fullWidth ? styles.fullWidth : ''].filter(Boolean).join(' '), ref: containerRef, children: [Trigger(togglePopover, icon, isOpen), mounted &&
205
205
  isOpen &&
206
206
  createPortal(_jsx("div", { id: resolvedContentId, ref: contentRef, className: styles.content, style: {
207
207
  top: pos.top,
@@ -6,6 +6,11 @@
6
6
  min-width: 0;
7
7
  }
8
8
 
9
+ .fullWidth {
10
+ display: flex;
11
+ width: 100%;
12
+ }
13
+
9
14
  .content {
10
15
  position: fixed;
11
16
  border: 1px solid var(--color-border-default);
@@ -4,7 +4,7 @@ import { Button } from '../button/Button';
4
4
  import { Menu } from '../menu/Menu';
5
5
  import { Popover } from '../popover/Popover';
6
6
  export function SplitButton({ children, options, onClick, icon, ...rest }) {
7
- return (_jsxs("div", { className: styles.container, style: { display: 'inline-flex', alignItems: 'center' }, children: [_jsxs(Button, { ...rest, onClick: onClick, children: [icon, children] }), _jsx(Popover, { trigger: (handleClick, icon) => (_jsx("span", { className: styles.triggerContainer, children: _jsx(Button, { ...rest, onClick: handleClick, children: icon }) })), children: close => (_jsx(Menu, { children: options.map(option => (_jsx(Menu.Item, { active: option.active, children: _jsxs("button", { onClick: e => {
7
+ return (_jsxs("div", { className: styles.container, style: { display: 'inline-flex', alignItems: 'center' }, children: [_jsxs(Button, { ...rest, onClick: onClick, children: [icon, children] }), _jsx(Popover, { trigger: (handleClick, icon) => (_jsx("span", { className: styles.triggerContainer, children: _jsx(Button, { ...rest, onClick: handleClick, children: icon }) })), children: close => (_jsx(Menu, { children: options.map(option => (_jsx(Menu.Item, { active: option.active, children: _jsxs("button", { onClick: () => {
8
8
  option.onClick(close);
9
9
  }, children: [option.icon, option.label] }) }, option.label))) })) })] }));
10
10
  }
@@ -117,7 +117,7 @@ export function Tabs({ header, variant, panelStyle = false, tabs, value, default
117
117
  const selected = index === activeIndex;
118
118
  const tabDomId = `${uid}-tab-${String(tab.id)}`;
119
119
  const panelDomId = `${uid}-panel-${String(tab.id)}`;
120
- return (_jsx("div", { className: `${styles.tab} ${selected ? styles.active : ''}`, children: _jsxs("button", { id: tabDomId, type: "button", className: styles.tabButton, role: "tab", "aria-selected": selected, "aria-controls": panelDomId, tabIndex: selected ? 0 : -1, disabled: tab.disabled, onClick: () => setValue(tab.id), onKeyDown: e => onKeyDownTab(e, index), children: [tab.headerIcon ? _jsx("span", { className: styles.icon, children: tab.headerIcon }) : null, _jsx("span", { className: styles.label, children: tab.header }), tab.badge !== undefined && tab.badge > 0 ? (_jsx("span", { className: styles.badge, children: _jsx(Chip, { type: "subtle", severity: null, size: "sm", children: tab.badge.toLocaleString('da-DK') }) })) : null] }) }, tab.id));
120
+ return (_jsx("div", { className: `${styles.tab} ${selected ? styles.active : ''}`, children: _jsxs("button", { id: tabDomId, type: "button", className: styles.tabButton, role: "tab", "aria-selected": selected, "aria-controls": panelDomId, tabIndex: selected ? 0 : -1, disabled: tab.disabled, onClick: () => setValue(tab.id), onKeyDown: e => onKeyDownTab(e, index), children: [tab.headerIcon ? _jsx("span", { className: styles.icon, children: tab.headerIcon }) : null, _jsx("span", { className: styles.label, children: tab.header }), tab.badge !== undefined && tab.badge > 0 ? (_jsx("span", { className: styles.badge, children: _jsx(Chip, { size: "sm", children: tab.badge.toLocaleString('da-DK') }) })) : null] }) }, tab.id));
121
121
  }) }), _jsx("div", { id: activeTab ? `${uid}-panel-${String(activeTab.id)}` : undefined, role: "tabpanel", "aria-labelledby": activeTab ? `${uid}-tab-${String(activeTab.id)}` : undefined, className: styles.tabContent, children: activeTab === null || activeTab === void 0 ? void 0 : activeTab.content })] })] }));
122
122
  }
123
123
  Tabs.Item = TabsItem;
@@ -34,6 +34,7 @@
34
34
  --container-xl: 1280px;
35
35
 
36
36
  /* ===== Sizing ===== */
37
+ --icon-size-xs: 12px;
37
38
  --icon-size-sm: 16px;
38
39
  --icon-size-md: 20px;
39
40
  --icon-size-lg: 24px;
@@ -5,7 +5,7 @@
5
5
  * Examples:
6
6
  * - formatDate(new Date(), { userFriendlyLabel: true }) => "I dag"
7
7
  * - formatDate(new Date(), { userFriendlyLabel: true, showTime: true }) => "I dag, kl. 14:30"
8
- * - formatDate(date, { showTime: true }) => "10/03/2026 14:30"
8
+ * - formatDate(date, { showTime: true }) => "10.03.2026 14:30"
9
9
  *
10
10
  * @param date - The Date or date-parsable value to format
11
11
  * @param options.showTime - If true, append time "hh:mm" or "hh:mm:ss"
@@ -5,7 +5,7 @@
5
5
  * Examples:
6
6
  * - formatDate(new Date(), { userFriendlyLabel: true }) => "I dag"
7
7
  * - formatDate(new Date(), { userFriendlyLabel: true, showTime: true }) => "I dag, kl. 14:30"
8
- * - formatDate(date, { showTime: true }) => "10/03/2026 14:30"
8
+ * - formatDate(date, { showTime: true }) => "10.03.2026 14:30"
9
9
  *
10
10
  * @param date - The Date or date-parsable value to format
11
11
  * @param options.showTime - If true, append time "hh:mm" or "hh:mm:ss"
@@ -44,7 +44,7 @@ export function formatDate(date, options = {}) {
44
44
  const day = pad(d.getDate());
45
45
  const month = pad(d.getMonth() + 1);
46
46
  const year = d.getFullYear();
47
- const base = `${day}/${month}/${year}`;
47
+ const base = `${day}.${month}.${year}`;
48
48
  if (!showTime)
49
49
  return base;
50
50
  return `${base} ${formatTime()}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbcdk/react-components",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "Reusable React components for DBC projects",
5
5
  "license": "ISC",
6
6
  "author": "",