@dbcdk/react-components 0.0.62 → 0.0.63

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 (33) hide show
  1. package/dist/components/app-header/AppHeader.d.ts +3 -1
  2. package/dist/components/app-header/AppHeader.js +2 -2
  3. package/dist/components/app-header/AppHeader.module.css +9 -3
  4. package/dist/components/button/Button.js +17 -3
  5. package/dist/components/filter-field/FilterField.module.css +3 -15
  6. package/dist/components/forms/input/Input.module.css +18 -0
  7. package/dist/components/forms/typeahead/Typeahead.js +3 -4
  8. package/dist/components/hyperlink/Hyperlink.js +1 -0
  9. package/dist/components/menu/Menu.d.ts +4 -0
  10. package/dist/components/menu/Menu.js +50 -5
  11. package/dist/components/menu/Menu.module.css +18 -0
  12. package/dist/components/nav-bar/NavBar.d.ts +4 -1
  13. package/dist/components/nav-bar/NavBar.js +22 -12
  14. package/dist/components/nav-bar/NavBar.module.css +101 -1
  15. package/dist/components/overlay/modal/Modal.js +30 -5
  16. package/dist/components/page-layout/PageLayout.d.ts +4 -2
  17. package/dist/components/page-layout/PageLayout.js +12 -5
  18. package/dist/components/page-layout/PageLayout.module.css +46 -6
  19. package/dist/components/page-layout/components/layout-footer/LayoutFooter.d.ts +13 -0
  20. package/dist/components/page-layout/components/layout-footer/LayoutFooter.js +27 -0
  21. package/dist/components/page-layout/components/layout-footer/LayoutFooter.module.css +87 -0
  22. package/dist/components/page-layout/components/page-layout-hero/PageLayoutHero.d.ts +2 -1
  23. package/dist/components/page-layout/components/page-layout-hero/PageLayoutHero.js +9 -2
  24. package/dist/components/search-box/SearchBox.d.ts +3 -0
  25. package/dist/components/search-box/SearchBox.js +50 -9
  26. package/dist/components/search-box/SearchBox.module.css +11 -9
  27. package/dist/hooks/useDeviceSize.d.ts +2 -0
  28. package/dist/hooks/useDeviceSize.js +32 -0
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +2 -0
  31. package/dist/src/styles/styles.css +5 -0
  32. package/dist/styles/styles.css +5 -0
  33. package/package.json +1 -1
@@ -1,6 +1,8 @@
1
1
  import type { ReactNode, JSX } from 'react';
2
+ export type AppHeaderSize = 'md' | 'lg';
2
3
  interface AppHeaderProps {
3
4
  children: ReactNode;
5
+ size?: AppHeaderSize;
4
6
  }
5
- export declare function AppHeader({ children }: AppHeaderProps): JSX.Element;
7
+ export declare function AppHeader({ children, size }: AppHeaderProps): JSX.Element;
6
8
  export {};
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import styles from './AppHeader.module.css';
3
- export function AppHeader({ children }) {
4
- return (_jsx("header", { children: _jsx("div", { className: styles.container, children: children }) }));
3
+ export function AppHeader({ children, size = 'md' }) {
4
+ return (_jsx("header", { className: styles.header, children: _jsx("div", { className: styles.container, "data-size": size, children: children }) }));
5
5
  }
@@ -1,3 +1,8 @@
1
+ .header {
2
+ inline-size: 100%;
3
+ background-color: var(--color-bg-surface);
4
+ }
5
+
1
6
  .container {
2
7
  /* layout */
3
8
  display: flex;
@@ -11,13 +16,14 @@
11
16
  box-sizing: border-box;
12
17
 
13
18
  /* chrome */
14
- background-color: var(--color-bg-surface);
15
19
  color: var(--color-fg-default);
16
- border-block-end: var(--border-width-thin) solid var(--color-border-default);
17
20
 
18
21
  /* density-aware vertical rhythm */
19
22
  padding-block: calc(var(--control-padding-y) + var(--density));
20
- padding-inline: var(--spacing-md);
23
+ }
24
+
25
+ .container[data-size='lg'] {
26
+ min-block-size: 80px;
21
27
  }
22
28
 
23
29
  /* Optional content wrapper */
@@ -20,9 +20,14 @@ function mergeRefs(...refs) {
20
20
  };
21
21
  }
22
22
  export const Button = React.forwardRef(function Button({ variant = 'outlined', shape = 'default', size = 'md', fullWidth, icon, children, loading, active, spinIcon, tooltip, tooltipPlacement = 'top', isLink, type = 'button', ...rest }, ref) {
23
+ var _a;
23
24
  const { className: userClassName, ...buttonProps } = rest;
24
25
  const computedClassName = cx(styles.button, styles[variant], styles[size], fullWidth ? styles.fullWidth : '', active ? styles.active : '', loading ? styles.loading : '', shape !== 'default' ? styles[shape] : '', userClassName);
25
26
  const tooltipEnabled = Boolean(tooltip);
27
+ const childRef = isLink && React.isValidElement(children)
28
+ ? ((_a = children.ref) !== null && _a !== void 0 ? _a : null)
29
+ : null;
30
+ const mergedRef = React.useMemo(() => mergeRefs(childRef, ref), [childRef, ref]);
26
31
  // Tooltip anchored to the actual clickable element (button or link element)
27
32
  const { triggerProps, id: tooltipId } = useTooltipTrigger({
28
33
  content: tooltipEnabled ? tooltip : null,
@@ -42,13 +47,22 @@ export const Button = React.forwardRef(function Button({ variant = 'outlined', s
42
47
  if (isLink && React.isValidElement(children)) {
43
48
  // If this is a link-style button, we need to attach tooltip handlers + ref to the child.
44
49
  const childClassName = typeof children.props.className === 'string' ? children.props.className : '';
45
- const childRef = children.ref;
50
+ const { disabled, onClick, ...linkButtonProps } = buttonProps;
51
+ const handleClick = e => {
52
+ if (disabled) {
53
+ e.preventDefault();
54
+ return;
55
+ }
56
+ onClick === null || onClick === void 0 ? void 0 : onClick(e);
57
+ };
46
58
  buttonEl = React.cloneElement(children, {
47
- ...buttonProps,
48
- ref: mergeRefs(childRef, ref),
59
+ ...linkButtonProps,
60
+ ref: mergedRef,
49
61
  className: cx(childClassName, computedClassName, styles.buttonLink),
50
62
  ...(tooltipEnabled ? triggerProps : {}),
51
63
  'aria-describedby': describedBy,
64
+ 'aria-disabled': disabled ? 'true' : undefined,
65
+ onClick: handleClick,
52
66
  children: (_jsxs(_Fragment, { children: [icon && _jsx("span", { className: cx(styles.icon, spinIcon ? 'spin' : ''), children: icon }), children.props.children, loading && (_jsx("span", { style: { display: 'flex', opacity: 0.5 }, className: "spin", children: _jsx(LoaderCircle, {}) }))] })),
53
67
  });
54
68
  }
@@ -37,11 +37,7 @@
37
37
  background: color-mix(in srgb, var(--color-bg-selected) 45%, var(--color-bg-surface));
38
38
  border-color: var(--color-border-selected);
39
39
  box-shadow: var(--shadow-sm);
40
- --filter-operator-bg: color-mix(
41
- in srgb,
42
- var(--color-bg-selected) 45%,
43
- var(--color-bg-surface)
44
- );
40
+ --filter-operator-bg: color-mix(in srgb, var(--color-bg-selected) 45%, var(--color-bg-surface));
45
41
  }
46
42
 
47
43
  .filterField.outlined {
@@ -59,11 +55,7 @@
59
55
  background: color-mix(in srgb, var(--color-bg-selected) 38%, var(--color-bg-surface));
60
56
  border-color: var(--color-border-selected);
61
57
  box-shadow: none;
62
- --filter-operator-bg: color-mix(
63
- in srgb,
64
- var(--color-bg-selected) 38%,
65
- var(--color-bg-surface)
66
- );
58
+ --filter-operator-bg: color-mix(in srgb, var(--color-bg-selected) 38%, var(--color-bg-surface));
67
59
  }
68
60
 
69
61
  .filterField.subtle {
@@ -83,11 +75,7 @@
83
75
  }
84
76
 
85
77
  .filterField.subtle.active {
86
- background: color-mix(
87
- in srgb,
88
- var(--color-bg-selected) 55%,
89
- var(--color-bg-surface-strong)
90
- );
78
+ background: color-mix(in srgb, var(--color-bg-selected) 55%, var(--color-bg-surface-strong));
91
79
  border-color: var(--color-border-selected);
92
80
  box-shadow: inset 0 0 0 1px transparent;
93
81
  --filter-operator-bg: color-mix(
@@ -168,6 +168,10 @@
168
168
  box-shadow: var(--shadow-xs), var(--shadow-md);
169
169
  }
170
170
 
171
+ .standalone .input {
172
+ padding-inline: var(--spacing-md);
173
+ }
174
+
171
175
  .standalone:hover:not([aria-disabled='true']) {
172
176
  border-color: var(--color-border-strong);
173
177
  box-shadow: var(--shadow-sm), var(--shadow-md);
@@ -393,6 +397,20 @@
393
397
  z-index: 2;
394
398
  }
395
399
 
400
+ /* Standalone variant: pill-shaped trailing button to match the field */
401
+ .withButton:has(.standalone) .trailingButton {
402
+ border-top-right-radius: var(--border-radius-rounded);
403
+ border-bottom-right-radius: var(--border-radius-rounded);
404
+ border-left-color: var(--color-border-default);
405
+ background-color: var(--color-bg-surface);
406
+ box-shadow: var(--shadow-xs), var(--shadow-md);
407
+ }
408
+
409
+ .withButton:has(.standalone) .trailingButton:hover {
410
+ border-color: var(--color-border-strong);
411
+ box-shadow: var(--shadow-sm), var(--shadow-md);
412
+ }
413
+
396
414
  /* Date/time picker indicator (WebKit) */
397
415
  .input[type='datetime-local']::-webkit-calendar-picker-indicator {
398
416
  filter: invert(0.7);
@@ -294,10 +294,9 @@ export function Typeahead({ options, mode = 'single', multiValueDisplayMode = 'c
294
294
  (_c = focusables[nextIndex]) === null || _c === void 0 ? void 0 : _c.focus();
295
295
  return;
296
296
  }
297
- if (activeIndexInScope !== -1 && activeElement === boundaryElement) {
298
- setOpen(false);
299
- setActiveIndex(-1);
300
- }
297
+ // single mode: Tab always closes the dropdown and lets focus move naturally
298
+ setOpen(false);
299
+ setActiveIndex(-1);
301
300
  }, [open, getFocusableElements, mode]);
302
301
  const commitSelection = (option) => {
303
302
  var _a, _b;
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import * as React from 'react';
3
4
  import styles from './Hyperlink.module.css';
@@ -7,6 +7,8 @@ export interface MenuProps extends React.HTMLAttributes<HTMLUListElement> {
7
7
  * - for Select/listbox usage, pass itemRole="option" and role="listbox" on Menu.
8
8
  */
9
9
  itemRole?: 'menuitem' | 'option';
10
+ /** Adds a gap of --spacing-xs between items */
11
+ gap?: boolean;
10
12
  }
11
13
  export type MenuSeparatorProps = React.LiHTMLAttributes<HTMLLIElement>;
12
14
  export interface MenuItemProps extends React.LiHTMLAttributes<HTMLLIElement> {
@@ -19,6 +21,8 @@ export interface MenuItemProps extends React.LiHTMLAttributes<HTMLLIElement> {
19
21
  * If not set, Menu's `itemRole` is used.
20
22
  */
21
23
  itemRole?: 'menuitem' | 'option';
24
+ /** Adds a rounded border around the item */
25
+ variant?: 'default' | 'bordered';
22
26
  }
23
27
  export interface MenuCheckItemProps extends Omit<React.LiHTMLAttributes<HTMLLIElement>, 'onChange'> {
24
28
  label: React.ReactNode;
@@ -4,13 +4,54 @@ import * as React from 'react';
4
4
  import styles from './Menu.module.css';
5
5
  import { Checkbox } from '../forms/checkbox/Checkbox';
6
6
  import { RadioButton } from '../forms/radio-buttons/RadioButton';
7
- const MenuBase = React.forwardRef(({ children, className, itemRole = 'menuitem', ...props }, ref) => (_jsx("ul", { ref: ref, role: "menu", "data-itemrole": itemRole, className: [styles.container, className].filter(Boolean).join(' '), ...props, children: children })));
7
+ const INTERACTIVE_SELECTOR = 'a:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"]):not([aria-disabled="true"]), [role="menuitem"]:not([aria-disabled="true"]), [role="option"]:not([aria-disabled="true"])';
8
+ function getMenuItems(el) {
9
+ return Array.from(el.querySelectorAll(INTERACTIVE_SELECTOR));
10
+ }
11
+ const MenuBase = React.forwardRef(({ children, className, itemRole = 'menuitem', gap, onKeyDown, ...props }, ref) => {
12
+ const internalRef = React.useRef(null);
13
+ const handleKeyDown = (e) => {
14
+ const ul = internalRef.current;
15
+ if (!ul)
16
+ return;
17
+ const items = getMenuItems(ul);
18
+ if (items.length === 0)
19
+ return;
20
+ const focused = document.activeElement;
21
+ const currentIndex = items.indexOf(focused);
22
+ let nextIndex = null;
23
+ if (e.key === 'ArrowDown') {
24
+ nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
25
+ }
26
+ else if (e.key === 'ArrowUp') {
27
+ nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
28
+ }
29
+ else if (e.key === 'Home') {
30
+ nextIndex = 0;
31
+ }
32
+ else if (e.key === 'End') {
33
+ nextIndex = items.length - 1;
34
+ }
35
+ if (nextIndex !== null) {
36
+ e.preventDefault();
37
+ items[nextIndex].focus();
38
+ }
39
+ onKeyDown === null || onKeyDown === void 0 ? void 0 : onKeyDown(e);
40
+ };
41
+ return (_jsx("ul", { ref: node => {
42
+ internalRef.current = node;
43
+ if (typeof ref === 'function')
44
+ ref(node);
45
+ else if (ref)
46
+ ref.current = node;
47
+ }, role: "menu", "data-itemrole": itemRole, className: [styles.container, gap ? styles.gap : '', className].filter(Boolean).join(' '), onKeyDown: handleKeyDown, ...props, children: children }));
48
+ });
8
49
  MenuBase.displayName = 'Menu';
9
50
  const isInteractiveEl = (el) => React.isValidElement(el) &&
10
51
  (typeof el.type === 'string' ? el.type === 'a' || el.type === 'button' : true);
11
52
  function applyMenuItemPropsToElement(child, opts) {
12
53
  var _a, _b, _c, _d;
13
- const { active, selected, disabled, role, tabIndex = -1, className } = opts;
54
+ const { active, selected, disabled, role, tabIndex = 0, className } = opts;
14
55
  const childClass = [styles.item, active ? styles.active : '', selected ? styles.selected : '']
15
56
  .filter(Boolean)
16
57
  .join(' ');
@@ -44,16 +85,20 @@ function applyMenuItemPropsToElement(child, opts) {
44
85
  disabled,
45
86
  });
46
87
  }
47
- const MenuItem = React.forwardRef(({ children, active, selected, disabled, className, itemRole, ...liProps }, ref) => {
88
+ const MenuItem = React.forwardRef(({ children, active, selected, disabled, className, itemRole, variant, ...liProps }, ref) => {
48
89
  // If caller sets itemRole prop, use it; otherwise attempt to inherit from parent Menu via data attr.
49
90
  // (We can’t reliably read parent props here without context; simplest is: caller passes itemRole on Menu.Item when needed.)
50
91
  const resolvedRole = itemRole !== null && itemRole !== void 0 ? itemRole : 'menuitem';
92
+ const isBordered = variant === 'bordered';
93
+ const rowClass = [styles.row, isBordered ? styles.rowBordered : '', className]
94
+ .filter(Boolean)
95
+ .join(' ');
51
96
  if (isInteractiveEl(children)) {
52
97
  const child = children;
53
- return (_jsx("li", { ref: ref, role: "none", className: [styles.row, className].filter(Boolean).join(' '), ...liProps, children: applyMenuItemPropsToElement(child, { active, selected, disabled, role: resolvedRole }) }));
98
+ return (_jsx("li", { ref: ref, role: "none", className: rowClass, ...liProps, children: applyMenuItemPropsToElement(child, { active, selected, disabled, role: resolvedRole }) }));
54
99
  }
55
100
  // Fallback: wrap non-interactive children in a <button>
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: [
101
+ return (_jsx("li", { ref: ref, role: "none", className: rowClass, ...liProps, children: _jsx("button", { role: resolvedRole, tabIndex: 0, "aria-selected": selected || undefined, "aria-disabled": disabled || undefined, className: [
57
102
  styles.interactive,
58
103
  styles.item,
59
104
  active ? styles.active : '',
@@ -173,3 +173,21 @@
173
173
  opacity: 0.8;
174
174
  border-radius: 999px;
175
175
  }
176
+
177
+ /* Gap between items */
178
+ .gap {
179
+ gap: var(--spacing-xs);
180
+ }
181
+
182
+ /* Bordered item variant */
183
+ .rowBordered {
184
+ border: 1px solid var(--color-border-default);
185
+ border-radius: var(--border-radius-default);
186
+ overflow: hidden;
187
+ }
188
+
189
+ /* Inside a bordered row, remove inner border-radius so hover bg fills the full area */
190
+ .rowBordered .interactive,
191
+ .rowBordered .interactiveChild {
192
+ border-radius: 0;
193
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ElementType, ReactNode, JSX } from 'react';
2
+ import { type AppHeaderSize } from '../app-header/AppHeader';
2
3
  export type NavBarItem = NavBarLinkItem | NavBarExpandableItem | NavBarGroupItem;
3
4
  type NavBarBase = {
4
5
  label: string;
@@ -33,6 +34,8 @@ interface NavBarProps {
33
34
  items: NavBarLinkItem[];
34
35
  productName?: string;
35
36
  addition?: ReactNode;
37
+ activeLink?: string;
38
+ size?: AppHeaderSize;
36
39
  }
37
- export declare function NavBar({ logo, items, productName, addition }: NavBarProps): JSX.Element;
40
+ export declare function NavBar({ logo, items, productName, addition, activeLink, size, }: NavBarProps): JSX.Element;
38
41
  export {};
@@ -1,19 +1,29 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Menu as MenuIcon, X } from 'lucide-react';
4
+ import { useRef, useState } from 'react';
3
5
  import styles from './NavBar.module.css';
4
6
  import { Logo } from '../../assets/logo';
7
+ import { useDeviceSize } from '../../hooks/useDeviceSize';
5
8
  import { AppHeader } from '../app-header/AppHeader';
6
9
  import { Headline } from '../headline/Headline';
7
- export function NavBar({ logo, items, productName, addition }) {
8
- return (_jsx(AppHeader, { children: _jsxs("nav", { className: styles.container, "aria-label": productName ? `${productName} navigation` : 'Main navigation', children: [logo, productName && (_jsxs("span", { className: styles.productName, style: { display: 'flex', alignItems: 'center', gap: 16 }, children: [_jsx(Logo, {}), _jsx(Headline, { disableMargin: true, size: 1, children: productName })] })), _jsx("ul", { className: styles.navItems, role: "list", children: items === null || items === void 0 ? void 0 : items.filter(i => i.enabled !== false).map((item, id) => {
9
- const { component: Component, label, icon, href, active, external } = item;
10
- const linkClass = [styles.link, active ? styles.active : ''].filter(Boolean).join(' ');
11
- const commonProps = {
12
- className: linkClass,
13
- href,
14
- ...(active ? { 'aria-current': 'page' } : {}),
15
- ...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
16
- };
17
- return (_jsx("li", { className: styles.navItem, role: "listitem", children: Component ? (_jsxs(Component, { ...commonProps, children: [icon, _jsx("span", { className: styles.label, children: label })] })) : (_jsxs("a", { ...commonProps, children: [icon, _jsx("span", { className: styles.label, children: label })] })) }, id));
18
- }) }), addition] }) }));
10
+ import { Popover } from '../popover/Popover';
11
+ export function NavBar({ logo, items, productName, addition, activeLink, size, }) {
12
+ const [mobileOpen, setMobileOpen] = useState(false);
13
+ const deviceSize = useDeviceSize();
14
+ const isMobile = deviceSize === 'mobile';
15
+ const navRef = useRef(null);
16
+ const navLinks = items === null || items === void 0 ? void 0 : items.filter(i => i.enabled !== false).map((item, id) => {
17
+ const { component: Component, label, icon, href, active, external } = item;
18
+ const isActive = activeLink ? href === activeLink : Boolean(active);
19
+ const linkClass = [styles.link, isActive ? styles.active : ''].filter(Boolean).join(' ');
20
+ const commonProps = {
21
+ className: linkClass,
22
+ href,
23
+ ...(isActive ? { 'aria-current': 'page' } : {}),
24
+ ...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
25
+ };
26
+ return (_jsx("li", { className: styles.navItem, role: "listitem", children: Component ? (_jsxs(Component, { ...commonProps, children: [icon, _jsx("span", { className: styles.label, children: label })] })) : (_jsxs("a", { ...commonProps, children: [icon, _jsx("span", { className: styles.label, children: label })] })) }, id));
27
+ });
28
+ return (_jsx(AppHeader, { size: size, children: _jsxs("nav", { ref: navRef, className: styles.container, "aria-label": productName ? `${productName} navigation` : 'Main navigation', children: [(logo || productName) && (_jsxs("div", { className: styles.logoRow, children: [logo, productName && (_jsxs("span", { className: styles.productName, children: [_jsx(Logo, {}), _jsx(Headline, { disableMargin: true, size: 1, children: productName })] }))] })), _jsx("div", { className: styles.navContent, children: _jsx("ul", { className: styles.navItems, role: "list", children: navLinks }) }), addition && !isMobile && _jsx("div", { className: styles.addition, children: addition }), isMobile && (_jsx(Popover, { open: mobileOpen, onOpenChange: setMobileOpen, matchTriggerWidth: true, fullWidth: false, autoFocusContent: true, anchorRef: navRef, trigger: (toggle, _icon, open) => (_jsx("button", { type: "button", className: styles.burgerButton, "aria-label": open ? 'Close navigation menu' : 'Open navigation menu', "aria-expanded": open, onClick: toggle, children: open ? _jsx(X, { size: 20 }) : _jsx(MenuIcon, { size: 20 }) })), children: close => (_jsxs("div", { className: styles.mobileMenu, children: [_jsx("ul", { className: styles.mobileNavItems, role: "list", onClick: close, children: navLinks }), addition && _jsx("div", { className: styles.mobileAddition, children: addition })] })) }))] }) }));
19
29
  }
@@ -7,6 +7,99 @@
7
7
  flex-grow: 1;
8
8
  }
9
9
 
10
+ .logoRow {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: var(--spacing-xs);
14
+ flex-shrink: 0;
15
+ }
16
+
17
+ .navContent {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: var(--spacing-md);
21
+ min-inline-size: 0;
22
+ flex: 1 1 auto;
23
+ }
24
+
25
+ /* Burger: hidden on desktop, visible on mobile */
26
+ .burger {
27
+ display: none;
28
+ margin-inline-start: auto;
29
+ }
30
+
31
+ .burgerButton {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ justify-content: center;
35
+ inline-size: var(--component-size-md);
36
+ block-size: var(--component-size-md);
37
+ border: none;
38
+ background: transparent;
39
+ color: var(--color-fg-default);
40
+ border-radius: var(--border-radius-default);
41
+ cursor: pointer;
42
+ padding: 0;
43
+ transition: background-color var(--transition-fast) var(--ease-standard);
44
+ }
45
+
46
+ .burgerButton:hover {
47
+ background-color: var(--color-bg-hover-subtle);
48
+ }
49
+
50
+ .burgerButton:focus-visible {
51
+ outline: none;
52
+ box-shadow: var(--focus-ring);
53
+ }
54
+
55
+ /* Mobile dropdown content */
56
+ .mobileMenu {
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: var(--spacing-xs);
60
+ padding: var(--spacing-xs);
61
+ }
62
+
63
+ .mobileNavItems {
64
+ display: flex;
65
+ flex-direction: column;
66
+ list-style: none;
67
+ margin: 0;
68
+ padding: 0;
69
+ }
70
+
71
+ .mobileAddition {
72
+ border-top: var(--border-width-thin) solid var(--color-border-subtle);
73
+ padding-top: var(--spacing-xs);
74
+ }
75
+
76
+ @media (max-width: 640px) {
77
+ .container {
78
+ flex-wrap: nowrap;
79
+ overflow: hidden;
80
+ }
81
+
82
+ .logoRow {
83
+ flex: 1 1 auto;
84
+ min-inline-size: 0;
85
+ overflow: hidden;
86
+ }
87
+
88
+ .navContent {
89
+ display: none;
90
+ }
91
+
92
+ .addition {
93
+ display: none;
94
+ }
95
+
96
+ .burger {
97
+ display: flex;
98
+ flex-shrink: 0;
99
+ margin-inline-start: 0;
100
+ }
101
+ }
102
+
10
103
  .productName {
11
104
  display: inline-flex;
12
105
  align-items: center;
@@ -23,13 +116,20 @@
23
116
  display: flex;
24
117
  align-items: center;
25
118
  gap: var(--spacing-lg);
26
- inline-size: 100%;
27
119
  min-inline-size: 0;
28
120
  list-style: none;
29
121
  margin: 0;
30
122
  padding: 0;
31
123
  }
32
124
 
125
+ .addition {
126
+ display: flex;
127
+ align-items: center;
128
+ flex: 1 1 auto;
129
+ min-inline-size: 0;
130
+ margin-inline-start: auto;
131
+ }
132
+
33
133
  .navItem {
34
134
  list-style: none;
35
135
  min-inline-size: 0;
@@ -19,6 +19,15 @@ export function Modal({ isOpen, onRequestClose, header, content, children, prima
19
19
  useEffect(() => {
20
20
  setMounted(true);
21
21
  }, []);
22
+ useEffect(() => {
23
+ if (!isOpen)
24
+ return;
25
+ const previous = document.body.style.overflow;
26
+ document.body.style.overflow = 'hidden';
27
+ return () => {
28
+ document.body.style.overflow = previous;
29
+ };
30
+ }, [isOpen]);
22
31
  // Track open transition so we only autofocus once per open
23
32
  const wasOpenRef = useRef(false);
24
33
  useEffect(() => {
@@ -32,7 +41,7 @@ export function Modal({ isOpen, onRequestClose, header, content, children, prima
32
41
  lastActiveElementRef.current = document.activeElement;
33
42
  const dialog = dialogRef.current;
34
43
  if (dialog) {
35
- const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
44
+ const focusableSelectors = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
36
45
  const focusable = dialog.querySelectorAll(focusableSelectors);
37
46
  if (focusable.length > 0) {
38
47
  const preferred = (_a = dialog.querySelector('input, select, textarea')) !== null && _a !== void 0 ? _a : focusable[0];
@@ -43,35 +52,51 @@ export function Modal({ isOpen, onRequestClose, header, content, children, prima
43
52
  }
44
53
  }
45
54
  }
55
+ const focusableSelectors = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
56
+ const getFocusable = () => { var _a, _b; return (_b = (_a = dialogRef.current) === null || _a === void 0 ? void 0 : _a.querySelectorAll(focusableSelectors)) !== null && _b !== void 0 ? _b : []; };
46
57
  const handleKeyDown = (event) => {
47
58
  if (event.key === 'Escape') {
48
59
  onRequestCloseRef.current();
49
60
  return;
50
61
  }
51
62
  if (event.key === 'Tab' && dialogRef.current) {
52
- const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
53
- const focusable = dialogRef.current.querySelectorAll(focusableSelectors);
63
+ const focusable = getFocusable();
54
64
  if (focusable.length === 0)
55
65
  return;
56
66
  const first = focusable[0];
57
67
  const last = focusable[focusable.length - 1];
58
68
  if (event.shiftKey) {
59
- if (document.activeElement === first) {
69
+ if (document.activeElement === first ||
70
+ !dialogRef.current.contains(document.activeElement)) {
60
71
  event.preventDefault();
61
72
  last.focus();
62
73
  }
63
74
  }
64
75
  else {
65
- if (document.activeElement === last) {
76
+ if (document.activeElement === last ||
77
+ !dialogRef.current.contains(document.activeElement)) {
66
78
  event.preventDefault();
67
79
  first.focus();
68
80
  }
69
81
  }
70
82
  }
71
83
  };
84
+ // Redirect focus back into the dialog if it escapes (e.g. via mouse click on overlay)
85
+ const handleFocusIn = (event) => {
86
+ const dialog = dialogRef.current;
87
+ if (!dialog || dialog.contains(event.target))
88
+ return;
89
+ const focusable = getFocusable();
90
+ if (focusable.length > 0)
91
+ focusable[0].focus();
92
+ else
93
+ dialog.focus();
94
+ };
72
95
  document.addEventListener('keydown', handleKeyDown);
96
+ document.addEventListener('focusin', handleFocusIn);
73
97
  return () => {
74
98
  document.removeEventListener('keydown', handleKeyDown);
99
+ document.removeEventListener('focusin', handleFocusIn);
75
100
  if (lastActiveElementRef.current) {
76
101
  lastActiveElementRef.current.focus();
77
102
  }
@@ -1,6 +1,7 @@
1
1
  import type { FC, PropsWithChildren, ReactNode } from 'react';
2
2
  import { type PageLayoutHeroProps } from './components/page-layout-hero/PageLayoutHero';
3
3
  type Orientation = 'vertical' | 'horizontal';
4
+ export type PageLayoutMaxWidth = boolean | 'sm' | 'md';
4
5
  export interface PageLayoutProps extends PropsWithChildren {
5
6
  /**
6
7
  * If true, PageLayout becomes a self-contained app shell (100vh) and
@@ -11,14 +12,15 @@ export interface PageLayoutProps extends PropsWithChildren {
11
12
  orientation?: Orientation;
12
13
  }
13
14
  export interface PageLayoutHeaderProps {
14
- maxWidth?: boolean;
15
+ maxWidth?: PageLayoutMaxWidth;
15
16
  children: ReactNode;
16
17
  }
17
18
  export interface PageLayoutContentProps {
18
- maxWidth?: boolean;
19
+ maxWidth?: PageLayoutMaxWidth;
19
20
  children: ReactNode;
20
21
  }
21
22
  export interface PageLayoutFooterProps {
23
+ maxWidth?: PageLayoutMaxWidth;
22
24
  children: ReactNode;
23
25
  }
24
26
  export interface PageLayoutSidebarProps {
@@ -2,6 +2,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Children, isValidElement } from 'react';
3
3
  import { PageLayoutHero, } from './components/page-layout-hero/PageLayoutHero';
4
4
  import styles from './PageLayout.module.css';
5
+ function getMaxWidthClass(value, styles) {
6
+ if (!value)
7
+ return '';
8
+ if (value === 'sm')
9
+ return styles.maxWidthSm;
10
+ return styles.maxWidthMd;
11
+ }
5
12
  function getSlotName(el) {
6
13
  var _a;
7
14
  const t = el.type;
@@ -34,15 +41,15 @@ const PageLayoutSidebar = ({ children, }) => {
34
41
  };
35
42
  PageLayoutSidebar.__PAGE_LAYOUT_SLOT__ = 'Sidebar';
36
43
  const PageLayoutHeader = ({ maxWidth = false, children, }) => {
37
- return (_jsx("div", { className: styles.headerContainer, children: _jsx("div", { className: `${styles.headerContent} ${maxWidth ? styles.maxWidth : ''}`, children: children }) }));
44
+ return (_jsx("div", { className: styles.headerContainer, children: _jsx("div", { className: `${styles.headerContent} ${getMaxWidthClass(maxWidth, styles)}`, children: children }) }));
38
45
  };
39
46
  PageLayoutHeader.__PAGE_LAYOUT_SLOT__ = 'Header';
40
47
  const PageLayoutContent = ({ maxWidth = false, children, }) => {
41
- return (_jsx("div", { className: `${styles.contentInner} ${maxWidth ? styles.maxWidth : ''}`, children: children }));
48
+ return (_jsx("div", { className: `${styles.contentInner} ${getMaxWidthClass(maxWidth, styles)}`, children: children }));
42
49
  };
43
50
  PageLayoutContent.__PAGE_LAYOUT_SLOT__ = 'Content';
44
- const PageLayoutFooter = ({ children, }) => {
45
- return _jsx(_Fragment, { children: children });
51
+ const PageLayoutFooter = ({ maxWidth = false, children, }) => {
52
+ return (_jsx("div", { className: `${styles.footerContent} ${getMaxWidthClass(maxWidth, styles)}`, children: children }));
46
53
  };
47
54
  PageLayoutFooter.__PAGE_LAYOUT_SLOT__ = 'Footer';
48
55
  PageLayoutHero.__PAGE_LAYOUT_SLOT__ = 'Hero';
@@ -50,7 +57,7 @@ const PageLayoutBase = ({ children, containScrolling = false, orientation = 'ver
50
57
  var _a, _b;
51
58
  const { slots, rest } = splitSlots(children);
52
59
  // If no explicit <PageLayout.Content>, we’ll treat remaining children as content.
53
- const content = (_a = slots.Content) !== null && _a !== void 0 ? _a : (rest.length ? _jsx(PageLayoutContent, { maxWidth: true, children: rest }) : undefined);
60
+ const content = (_a = slots.Content) !== null && _a !== void 0 ? _a : (rest.length ? _jsx(PageLayoutContent, { maxWidth: "md", children: rest }) : undefined);
54
61
  const rootClass = [
55
62
  styles.root,
56
63
  orientation === 'vertical' ? styles.vertical : styles.horizontal,
@@ -17,7 +17,8 @@
17
17
  }
18
18
 
19
19
  .documentScrolling {
20
- min-height: 100%;
20
+ min-height: 100vh;
21
+ min-height: 100dvh;
21
22
  overflow: visible;
22
23
  }
23
24
 
@@ -71,6 +72,7 @@
71
72
  .header {
72
73
  min-width: 0;
73
74
  background: var(--color-bg-surface);
75
+ border-bottom: var(--border-width-thin) solid var(--color-border-default);
74
76
  flex: 0 0 auto;
75
77
  }
76
78
 
@@ -115,16 +117,18 @@
115
117
  flex: 1 1 auto; /* take remaining space inside mainScroll */
116
118
  display: flex; /* lets contentInner stretch */
117
119
  flex-direction: column;
120
+ align-items: center;
118
121
 
119
122
  background: var(--color-bg-surface);
120
- padding: var(--spacing-md);
123
+ padding: var(--spacing-lg) var(--spacing-md);
121
124
  overflow: visible;
122
125
  }
123
126
 
124
127
  .footer {
125
128
  min-width: 0;
126
- background: var(--color-bg-surface);
127
- border-top: var(--border-width-thin) solid var(--color-border-subtle);
129
+ background: var(--color-bg-surface-subtle);
130
+ display: flex;
131
+ justify-content: center;
128
132
 
129
133
  /* When there is extra space (content is short), this pushes footer to the bottom.
130
134
  When content is long, footer follows content normally and is reached by scrolling.
@@ -138,6 +142,7 @@
138
142
  display: flex;
139
143
  justify-content: center;
140
144
  width: 100%;
145
+ padding-inline: var(--spacing-md);
141
146
  }
142
147
 
143
148
  .headerContent {
@@ -145,19 +150,54 @@
145
150
  box-sizing: border-box;
146
151
  }
147
152
 
148
- .maxWidth {
153
+ .footerContent {
154
+ width: 100%;
155
+ box-sizing: border-box;
156
+ }
157
+
158
+ .maxWidthMd {
149
159
  max-width: 1600px;
150
160
  margin-inline: auto;
151
161
  width: 100%;
152
162
  box-sizing: border-box;
153
163
  }
154
164
 
165
+ .maxWidthSm {
166
+ margin-inline: auto;
167
+ width: 100%;
168
+ box-sizing: border-box;
169
+ }
170
+
171
+ @media (min-width: 640px) {
172
+ .maxWidthSm {
173
+ max-width: 640px;
174
+ }
175
+ }
176
+
177
+ @media (min-width: 768px) {
178
+ .maxWidthSm {
179
+ max-width: 668px;
180
+ }
181
+ }
182
+
183
+ @media (min-width: 1024px) {
184
+ .maxWidthSm {
185
+ max-width: 924px;
186
+ }
187
+ }
188
+
189
+ @media (min-width: 1280px) {
190
+ .maxWidthSm {
191
+ max-width: 1180px;
192
+ }
193
+ }
194
+
155
195
  /* Content slot inner wrapper (so maxWidth works without interfering with scroll) */
156
196
  .contentInner {
157
197
  display: flex;
158
198
  flex-direction: column;
159
199
  gap: var(--spacing-xl);
160
-
200
+ width: 100%;
161
201
  box-sizing: border-box;
162
202
  min-width: 0;
163
203
 
@@ -0,0 +1,13 @@
1
+ import type { JSX, ReactElement } from 'react';
2
+ export interface LayoutFooterLink {
3
+ label: string;
4
+ href: string;
5
+ external?: boolean;
6
+ }
7
+ export interface LayoutFooterProps {
8
+ links?: LayoutFooterLink[];
9
+ metaParts?: string[];
10
+ /** Extra links rendered before the default links. Pass framework link elements (e.g. Next.js <Link>). */
11
+ extraLinks?: ReactElement[];
12
+ }
13
+ export declare function LayoutFooter({ links, metaParts, extraLinks, }: LayoutFooterProps): JSX.Element;
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Logo } from '../../../../assets/logo';
4
+ import { Hyperlink } from '../../../../components/hyperlink/Hyperlink';
5
+ import styles from './LayoutFooter.module.css';
6
+ const DEFAULT_META_PARTS = [
7
+ 'Tempovej 7-11',
8
+ 'DK-2750 Ballerup',
9
+ '+45 44 86 77 11',
10
+ `© ${new Date().getFullYear()} DBC DIGITAL A/S`,
11
+ ];
12
+ const DEFAULT_LINKS = [
13
+ {
14
+ label: 'Kundeservice',
15
+ href: 'https://kundeservice.dbc.dk',
16
+ external: true,
17
+ },
18
+ {
19
+ label: 'Cookies',
20
+ href: '/cookies',
21
+ },
22
+ ];
23
+ export function LayoutFooter({ links = DEFAULT_LINKS, metaParts = DEFAULT_META_PARTS, extraLinks, }) {
24
+ return (_jsx("footer", { className: styles.footer, children: _jsxs("div", { className: styles.inner, children: [_jsxs("div", { className: styles.brand, children: [_jsx("div", { className: styles.logoRow, children: _jsx(Logo, {}) }), _jsx("address", { className: styles.meta, children: metaParts.map(part => (_jsx("span", { className: styles.part, children: part }, part))) })] }), _jsxs("nav", { className: styles.links, "aria-label": "Footer navigation", children: [extraLinks &&
25
+ extraLinks.length > 0 &&
26
+ (extraLinks === null || extraLinks === void 0 ? void 0 : extraLinks.map((link, index) => _jsx("span", { children: link }, index))), links.map(link => (_jsx("span", { children: _jsx(Hyperlink, { href: link.href, ...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {}), children: link.label }) }, link.label)))] })] }) }));
27
+ }
@@ -0,0 +1,87 @@
1
+ .footer {
2
+ inline-size: 100%;
3
+ background: var(--color-bg-surface-subtle);
4
+ }
5
+
6
+ .inner {
7
+ inline-size: 100%;
8
+ max-inline-size: var(--container-xl);
9
+ margin-inline: auto;
10
+ padding-block: var(--spacing-lg);
11
+ padding-inline: var(--spacing-md);
12
+ display: flex;
13
+ flex-direction: row;
14
+ align-items: flex-start;
15
+ gap: var(--spacing-2xl);
16
+ }
17
+
18
+ .brand {
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--spacing-xs);
22
+ flex-shrink: 0;
23
+ }
24
+
25
+ .logoRow {
26
+ flex-shrink: 0;
27
+ }
28
+
29
+ .logoRow svg {
30
+ height: 24px;
31
+ width: auto;
32
+ color: var(--color-brand);
33
+ display: block;
34
+ }
35
+
36
+ .meta {
37
+ font-style: normal;
38
+ margin: 0;
39
+ color: var(--color-fg-subtle);
40
+ line-height: var(--line-height-tight);
41
+ display: flex;
42
+ flex-direction: column;
43
+ gap: 1px;
44
+ }
45
+
46
+ .part {
47
+ white-space: nowrap;
48
+ }
49
+
50
+ .links {
51
+ display: flex;
52
+ flex-direction: column;
53
+ align-items: flex-start;
54
+ gap: 0;
55
+ }
56
+
57
+ .linkGroup {
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: flex-start;
61
+ gap: var(--spacing-2xs);
62
+ }
63
+
64
+ .linkGroup + .linkGroup {
65
+ margin-block-start: var(--spacing-sm);
66
+ padding-inline: 0;
67
+ border-inline-start: none;
68
+ }
69
+
70
+ .linkItem {
71
+ white-space: nowrap;
72
+ }
73
+
74
+ @media (max-width: 640px) {
75
+ .inner {
76
+ flex-direction: column;
77
+ gap: var(--spacing-md);
78
+ }
79
+
80
+ .linkGroup {
81
+ padding-inline: 0;
82
+ }
83
+
84
+ .linkGroup:first-child {
85
+ padding-inline-start: 0;
86
+ }
87
+ }
@@ -1,11 +1,12 @@
1
1
  import * as React from 'react';
2
+ import type { PageLayoutMaxWidth } from '../../PageLayout';
2
3
  export interface PageLayoutHeroProps {
3
4
  children?: React.ReactNode;
4
5
  link?: (children: React.ReactNode) => React.ReactNode;
5
6
  image?: React.ReactNode;
6
7
  headline?: string | React.ReactNode;
7
8
  metaHeadline?: string | React.ReactNode;
8
- maxWidth?: boolean;
9
+ maxWidth?: PageLayoutMaxWidth;
9
10
  textBgColor?: string;
10
11
  }
11
12
  export declare const PageLayoutHero: React.FC<PageLayoutHeroProps>;
@@ -1,7 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import heroStyles from './PageLayoutHero.module.css';
3
3
  import layoutStyles from '../../PageLayout.module.css';
4
- export const PageLayoutHero = ({ children, link, metaHeadline, headline, image, maxWidth = true, textBgColor = 'var(--color-primary-900)', }) => {
5
- const content = (_jsx("div", { className: `${heroStyles.heroContainer} ${maxWidth ? layoutStyles.maxWidth : ''}`, children: _jsxs("div", { className: heroStyles.splitWrapper, children: [_jsx("div", { className: heroStyles.imageColumn, children: image }), _jsx("div", { className: heroStyles.textColumn, style: { backgroundColor: textBgColor }, children: _jsxs("div", { className: heroStyles.textInner, children: [metaHeadline && _jsx("div", { className: heroStyles.metaHeadline, children: metaHeadline }), headline && (_jsx("div", { className: heroStyles.headline, children: _jsx("h2", { children: headline }) })), children] }) })] }) }));
4
+ function getMaxWidthClass(value) {
5
+ if (!value)
6
+ return '';
7
+ if (value === 'sm')
8
+ return layoutStyles.maxWidthSm;
9
+ return layoutStyles.maxWidthMd;
10
+ }
11
+ export const PageLayoutHero = ({ children, link, metaHeadline, headline, image, maxWidth = 'md', textBgColor = 'var(--color-primary-900)', }) => {
12
+ const content = (_jsx("div", { className: `${heroStyles.heroContainer} ${getMaxWidthClass(maxWidth)}`, children: _jsxs("div", { className: heroStyles.splitWrapper, children: [_jsx("div", { className: heroStyles.imageColumn, children: image }), _jsx("div", { className: heroStyles.textColumn, style: { backgroundColor: textBgColor }, children: _jsxs("div", { className: heroStyles.textInner, children: [metaHeadline && _jsx("div", { className: heroStyles.metaHeadline, children: metaHeadline }), headline && (_jsx("div", { className: heroStyles.headline, children: _jsx("h2", { children: headline }) })), children] }) })] }) }));
6
13
  return link ? _jsx(_Fragment, { children: link(content) }) : _jsx(_Fragment, { children: content });
7
14
  };
@@ -18,7 +18,10 @@ type SearchBoxProps<T extends Record<string, unknown>> = {
18
18
  popoverMinWidth?: string;
19
19
  loading?: boolean;
20
20
  enableHotkey?: boolean;
21
+ fullWidth?: boolean;
21
22
  onButtonClick?: () => void;
23
+ buttonLabel?: string;
24
+ buttonIcon?: React.ReactNode;
22
25
  } & React.InputHTMLAttributes<HTMLInputElement>;
23
26
  type SearchBoxComponent = <T extends Record<string, unknown>>(props: SearchBoxProps<T> & React.RefAttributes<HTMLInputElement>) => React.ReactElement | null;
24
27
  export declare const SearchBox: SearchBoxComponent;
@@ -1,19 +1,19 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Search } from 'lucide-react';
4
4
  import React, { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
5
- import { Button } from '../../components/button/Button';
6
5
  import { Input } from '../../components/forms/input/Input';
7
6
  import { Menu } from '../../components/menu/Menu';
8
7
  import { Popover } from '../../components/popover/Popover';
9
8
  import { SkeletonLoaderItem } from '../../components/skeleton-loader/skeleton-loader-item/SkeletonLoaderItem';
10
9
  import styles from './SearchBox.module.css';
11
- export const SearchBox = forwardRef(function SearchBoxInner({ inputWidth, inputSize, variant, result, debounce = true, debounceMs = 800, onSearch, onSelect, displayPopover, resultKeys, resultTemplate, initialTemplate, popoverMinWidth = '500px', noResultText = 'Ingen resultater', loading, enableHotkey = true, onButtonClick, value, onChange, ...rest }, ref) {
10
+ export const SearchBox = forwardRef(function SearchBoxInner({ inputWidth, inputSize, variant, result, debounce = true, debounceMs = 800, onSearch, onSelect, displayPopover, resultKeys, resultTemplate, initialTemplate, popoverMinWidth = '500px', noResultText = 'Ingen resultater', loading, enableHotkey = true, onButtonClick, buttonLabel, buttonIcon, fullWidth = false, value, onChange, ...rest }, ref) {
12
11
  const isControlled = value !== undefined;
13
12
  // What the user sees immediately in the textbox
14
13
  const [draft, setDraft] = useState(() => (isControlled ? String(value !== null && value !== void 0 ? value : '') : ''));
15
14
  // Used only for UI state ("Indtast søgeord" vs results/no results)
16
15
  const [searchQuery, setSearchQuery] = useState('');
16
+ const [activeIndex, setActiveIndex] = useState(null);
17
17
  const popoverRef = useRef(null);
18
18
  const internalInputRef = useRef(null);
19
19
  // Forward ref to parent
@@ -76,8 +76,13 @@ export const SearchBox = forwardRef(function SearchBoxInner({ inputWidth, inputS
76
76
  var _a;
77
77
  setDraft(''); // always clear UI immediately
78
78
  setSearchQuery('');
79
+ setActiveIndex(null);
79
80
  (_a = popoverRef.current) === null || _a === void 0 ? void 0 : _a.close();
80
81
  }
82
+ // Reset active index when results change
83
+ useEffect(() => {
84
+ setActiveIndex(null);
85
+ }, [result]);
81
86
  // Props for the input are now created inside useMemo to avoid unnecessary dependency changes
82
87
  const inputField = useMemo(() => {
83
88
  var _a;
@@ -86,17 +91,48 @@ export const SearchBox = forwardRef(function SearchBoxInner({ inputWidth, inputS
86
91
  value: draft,
87
92
  onChange: handleChange,
88
93
  };
94
+ const showInputIcon = !onButtonClick || !!buttonLabel || !!buttonIcon;
95
+ const trailingButtonIcon = onButtonClick && !buttonLabel && !buttonIcon ? _jsx(Search, {}) : buttonIcon;
89
96
  if (displayPopover) {
90
- return (_jsx(Popover, { ref: popoverRef, minWidth: popoverMinWidth, trigger: event => {
97
+ return (_jsx(Popover, { ref: popoverRef, minWidth: popoverMinWidth, fullWidth: fullWidth, trigger: event => {
91
98
  var _a;
92
- return (_jsx(Input, { ref: internalInputRef, onFocusCapture: event, onClick: event, minWidth: inputWidth !== null && inputWidth !== void 0 ? inputWidth : '300px', width: inputWidth !== null && inputWidth !== void 0 ? inputWidth : '300px', icon: _jsx(Search, {}), inputSize: inputSize, variant: variant, ...inputProps, placeholder: (_a = rest.placeholder) !== null && _a !== void 0 ? _a : 'Indtast søgeord' }));
93
- }, children: resultTemplate ? (resultTemplate) : (result === null || result === void 0 ? void 0 : result.length) ? (_jsx(Menu, { children: _jsx("table", { children: _jsx("tbody", { children: result.map((item, index) => (_jsx("tr", { onClick: () => handleSelect(item), role: "button", tabIndex: 0, children: resultKeys === null || resultKeys === void 0 ? void 0 : resultKeys.map(key => {
99
+ return (_jsx(Input, { ref: internalInputRef, onFocusCapture: event, onClick: () => {
100
+ var _a, _b;
101
+ if (!((_a = popoverRef.current) === null || _a === void 0 ? void 0 : _a.isOpen()))
102
+ (_b = popoverRef.current) === null || _b === void 0 ? void 0 : _b.open();
103
+ }, minWidth: fullWidth ? undefined : (inputWidth !== null && inputWidth !== void 0 ? inputWidth : '300px'), width: fullWidth ? undefined : (inputWidth !== null && inputWidth !== void 0 ? inputWidth : '300px'), fullWidth: fullWidth, icon: showInputIcon ? _jsx(Search, {}) : undefined, inputSize: inputSize, variant: variant, autoComplete: "off", onButtonClick: onButtonClick, buttonLabel: buttonLabel, buttonIcon: trailingButtonIcon, ...inputProps, onKeyDown: e => {
104
+ var _a;
105
+ if (result === null || result === void 0 ? void 0 : result.length) {
106
+ if (e.key === 'ArrowDown') {
107
+ e.preventDefault();
108
+ setActiveIndex(prev => prev === null || prev === result.length - 1 ? 0 : prev + 1);
109
+ }
110
+ else if (e.key === 'ArrowUp') {
111
+ e.preventDefault();
112
+ setActiveIndex(prev => prev === null || prev === 0 ? result.length - 1 : prev - 1);
113
+ }
114
+ else if (e.key === 'Enter') {
115
+ e.preventDefault();
116
+ if (activeIndex !== null) {
117
+ handleSelect(result[activeIndex]);
118
+ }
119
+ else if (onButtonClick) {
120
+ onButtonClick();
121
+ }
122
+ }
123
+ else if (e.key === 'Escape') {
124
+ reset();
125
+ }
126
+ }
127
+ (_a = inputProps.onKeyDown) === null || _a === void 0 ? void 0 : _a.call(inputProps, e);
128
+ }, placeholder: (_a = rest.placeholder) !== null && _a !== void 0 ? _a : 'Indtast søgeord' }));
129
+ }, children: resultTemplate ? (resultTemplate) : (result === null || result === void 0 ? void 0 : result.length) ? (_jsx(Menu, { children: _jsx("table", { className: styles.suggestionTable, children: _jsx("tbody", { children: result.map((item, index) => (_jsx("tr", { onClick: () => handleSelect(item), role: "button", tabIndex: 0, className: `${styles.suggestionRow}${index === activeIndex ? ` ${styles.suggestionRowActive}` : ''}`, children: resultKeys === null || resultKeys === void 0 ? void 0 : resultKeys.map(key => {
94
130
  const raw = item[key];
95
131
  const cell = raw != null ? String(raw) : '';
96
- return (_jsx("td", { style: { whiteSpace: cell.length < 10 ? 'nowrap' : undefined }, children: cell }, key));
132
+ return (_jsx("td", { className: styles.suggestionCell, style: { whiteSpace: cell.length < 10 ? 'nowrap' : undefined }, children: cell }, key));
97
133
  }) }, index))) }) }) })) : !searchQuery && !loading ? (initialTemplate || _jsx("div", { className: styles.resultContainer, children: "Indtast s\u00F8geord" })) : loading ? (_jsx("table", { style: { width: '100%' }, children: _jsx("tbody", { children: Array.from({ length: 5 }).map((_, index) => (_jsx("tr", { children: resultKeys === null || resultKeys === void 0 ? void 0 : resultKeys.map(key => (_jsx("td", { style: { padding: '8px' }, children: _jsx(SkeletonLoaderItem, { height: 20, width: "100%" }) }, key))) }, index))) }) })) : (_jsx("div", { className: styles.resultContainer, children: noResultText })) }));
98
134
  }
99
- return (_jsx(Input, { ref: internalInputRef, icon: _jsx(Search, {}), minWidth: inputWidth !== null && inputWidth !== void 0 ? inputWidth : '300px', inputSize: inputSize, variant: variant, ...inputProps, placeholder: (_a = rest.placeholder) !== null && _a !== void 0 ? _a : 'Indtast søgeord' }));
135
+ return (_jsx(Input, { ref: internalInputRef, icon: showInputIcon ? _jsx(Search, {}) : undefined, minWidth: fullWidth ? undefined : (inputWidth !== null && inputWidth !== void 0 ? inputWidth : '300px'), fullWidth: fullWidth, inputSize: inputSize, variant: variant, onButtonClick: onButtonClick, buttonLabel: buttonLabel, buttonIcon: trailingButtonIcon, ...inputProps, placeholder: (_a = rest.placeholder) !== null && _a !== void 0 ? _a : 'Indtast søgeord' }));
100
136
  }, [
101
137
  rest,
102
138
  draft,
@@ -114,6 +150,11 @@ export const SearchBox = forwardRef(function SearchBoxInner({ inputWidth, inputS
114
150
  noResultText,
115
151
  resultKeys,
116
152
  handleSelect,
153
+ activeIndex,
154
+ onButtonClick,
155
+ buttonLabel,
156
+ buttonIcon,
157
+ fullWidth,
117
158
  ]);
118
- return onButtonClick ? (_jsxs("div", { className: styles.withButton, children: [inputField, _jsx(Button, { variant: "outlined", icon: _jsx(Search, {}), onClick: onButtonClick })] })) : (_jsx("div", { children: inputField }));
159
+ return _jsx("div", { style: fullWidth ? { width: '100%' } : undefined, children: inputField });
119
160
  });
@@ -2,18 +2,20 @@
2
2
  padding: var(--spacing-sm);
3
3
  }
4
4
 
5
- .withButton {
6
- display: inline-flex;
7
- align-items: center;
5
+ .suggestionTable {
8
6
  border-collapse: collapse;
7
+ width: 100%;
9
8
  }
10
9
 
11
- .withButton input {
12
- border-right-color: transparent;
13
- border-top-right-radius: 0;
14
- border-bottom-right-radius: 0;
10
+ .suggestionRow {
11
+ cursor: pointer;
15
12
  }
16
13
 
17
- .withButton button {
18
- border-radius: 0 var(--border-radius-default) var(--border-radius-default) 0;
14
+ .suggestionRow:hover > .suggestionCell,
15
+ .suggestionRowActive > .suggestionCell {
16
+ background-color: var(--table-row-bg-hover);
17
+ }
18
+
19
+ .suggestionCell {
20
+ padding: var(--spacing-xs) var(--spacing-sm);
19
21
  }
@@ -0,0 +1,2 @@
1
+ export type DeviceSize = 'mobile' | 'tablet' | 'desktop';
2
+ export declare function useDeviceSize(): DeviceSize;
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ // Aligned with --bp-sm (640px), --bp-md (768px), --bp-lg (1024px) from base.css
4
+ const BREAKPOINTS = {
5
+ tablet: 640,
6
+ desktop: 1024,
7
+ };
8
+ function getDeviceSize(width) {
9
+ if (width < BREAKPOINTS.tablet)
10
+ return 'mobile';
11
+ if (width < BREAKPOINTS.desktop)
12
+ return 'tablet';
13
+ return 'desktop';
14
+ }
15
+ export function useDeviceSize() {
16
+ const [deviceSize, setDeviceSize] = useState(() => {
17
+ if (typeof window === 'undefined')
18
+ return 'desktop';
19
+ return getDeviceSize(window.innerWidth);
20
+ });
21
+ useEffect(() => {
22
+ const mediaQueries = [
23
+ window.matchMedia(`(max-width: ${BREAKPOINTS.tablet - 1}px)`),
24
+ window.matchMedia(`(min-width: ${BREAKPOINTS.tablet}px) and (max-width: ${BREAKPOINTS.desktop - 1}px)`),
25
+ window.matchMedia(`(min-width: ${BREAKPOINTS.desktop}px)`),
26
+ ];
27
+ const update = () => setDeviceSize(getDeviceSize(window.innerWidth));
28
+ mediaQueries.forEach(mq => mq.addEventListener('change', update));
29
+ return () => mediaQueries.forEach(mq => mq.removeEventListener('change', update));
30
+ }, []);
31
+ return deviceSize;
32
+ }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from './components/user-display/UserDisplay';
8
8
  export * from './components/tabs/Tabs';
9
9
  export * from './components/headline/Headline';
10
10
  export * from './components/page-layout/PageLayout';
11
+ export * from './components/page-layout/components/layout-footer/LayoutFooter';
11
12
  export * from './components/forms/input/Input';
12
13
  export * from './components/search-box/SearchBox';
13
14
  export * from './hooks/useTheme';
@@ -65,3 +66,4 @@ export * from './components/accordion/Accordion';
65
66
  export * from './components/state-page/StatePage';
66
67
  export * from './components/sticky-footer-layout/StickyFooterLayout';
67
68
  export * from './components/forms/typeahead/Typeahead';
69
+ export * from './hooks/useDeviceSize';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export * from './components/user-display/UserDisplay';
8
8
  export * from './components/tabs/Tabs';
9
9
  export * from './components/headline/Headline';
10
10
  export * from './components/page-layout/PageLayout';
11
+ export * from './components/page-layout/components/layout-footer/LayoutFooter';
11
12
  export * from './components/forms/input/Input';
12
13
  export * from './components/search-box/SearchBox';
13
14
  export * from './hooks/useTheme';
@@ -65,3 +66,4 @@ export * from './components/accordion/Accordion';
65
66
  export * from './components/state-page/StatePage';
66
67
  export * from './components/sticky-footer-layout/StickyFooterLayout';
67
68
  export * from './components/forms/typeahead/Typeahead';
69
+ export * from './hooks/useDeviceSize';
@@ -26,6 +26,11 @@
26
26
  box-sizing: border-box;
27
27
  }
28
28
 
29
+ html,
30
+ body {
31
+ scrollbar-gutter: stable both-edges;
32
+ }
33
+
29
34
  body {
30
35
  color: var(--color-fg-default);
31
36
  background-color: var(--color-bg-page);
@@ -26,6 +26,11 @@
26
26
  box-sizing: border-box;
27
27
  }
28
28
 
29
+ html,
30
+ body {
31
+ scrollbar-gutter: stable both-edges;
32
+ }
33
+
29
34
  body {
30
35
  color: var(--color-fg-default);
31
36
  background-color: var(--color-bg-page);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbcdk/react-components",
3
- "version": "0.0.62",
3
+ "version": "0.0.63",
4
4
  "description": "Reusable React components for DBC projects",
5
5
  "license": "ISC",
6
6
  "author": "",