@dbcdk/react-components 0.0.9 → 0.0.10

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 (80) hide show
  1. package/dist/components/accordion/Accordion.d.ts +27 -0
  2. package/dist/components/accordion/Accordion.js +66 -0
  3. package/dist/components/accordion/Accordion.module.css +87 -0
  4. package/dist/components/button/Button.module.css +1 -0
  5. package/dist/components/circle/Circle.d.ts +4 -1
  6. package/dist/components/circle/Circle.js +2 -2
  7. package/dist/components/circle/Circle.module.css +54 -2
  8. package/dist/components/datetime-picker/DateTimePicker.d.ts +4 -8
  9. package/dist/components/datetime-picker/DateTimePicker.js +72 -92
  10. package/dist/components/datetime-picker/dateTimeHelpers.d.ts +14 -12
  11. package/dist/components/datetime-picker/dateTimeHelpers.js +25 -45
  12. package/dist/components/forms/checkbox/Checkbox.d.ts +2 -8
  13. package/dist/components/forms/checkbox/Checkbox.js +3 -5
  14. package/dist/components/forms/input/Input.d.ts +1 -0
  15. package/dist/components/forms/input/Input.js +2 -4
  16. package/dist/components/forms/input/Input.module.css +9 -11
  17. package/dist/components/forms/input-container/InputContainer.d.ts +2 -1
  18. package/dist/components/forms/input-container/InputContainer.js +3 -3
  19. package/dist/components/forms/input-container/InputContainer.module.css +65 -0
  20. package/dist/components/forms/radio-buttons/RadioButton.d.ts +36 -0
  21. package/dist/components/forms/radio-buttons/RadioButton.js +26 -0
  22. package/dist/components/forms/radio-buttons/RadioButtonGroup.d.ts +25 -0
  23. package/dist/components/forms/radio-buttons/RadioButtonGroup.js +19 -0
  24. package/dist/components/forms/radio-buttons/RadioButtons.module.css +117 -0
  25. package/dist/components/forms/select/Select.d.ts +1 -1
  26. package/dist/components/forms/select/Select.js +3 -3
  27. package/dist/components/forms/text-area/Textarea.js +3 -3
  28. package/dist/components/forms/text-area/Textarea.module.css +8 -1
  29. package/dist/components/headline/Headline.d.ts +2 -7
  30. package/dist/components/headline/Headline.js +5 -2
  31. package/dist/components/headline/Headline.module.css +61 -2
  32. package/dist/components/hyperlink/Hyperlink.d.ts +1 -0
  33. package/dist/components/hyperlink/Hyperlink.js +5 -1
  34. package/dist/components/icon/Icon.module.css +1 -0
  35. package/dist/components/interval-select/IntervalSelect.js +1 -1
  36. package/dist/components/nav-bar/NavBar.d.ts +24 -6
  37. package/dist/components/overlay/side-panel/SidePanel.d.ts +12 -4
  38. package/dist/components/overlay/side-panel/SidePanel.js +60 -4
  39. package/dist/components/overlay/side-panel/SidePanel.module.css +151 -28
  40. package/dist/components/overlay/side-panel/useSidePanel.d.ts +1 -1
  41. package/dist/components/overlay/side-panel/useSidePanel.js +2 -2
  42. package/dist/components/page-layout/PageLayout.js +0 -2
  43. package/dist/components/sidebar/components/expandable-sidebar-item/ExpandableSidebarItem.d.ts +5 -5
  44. package/dist/components/sidebar/components/expandable-sidebar-item/ExpandableSidebarItem.js +16 -8
  45. package/dist/components/sidebar/components/expandable-sidebar-item/ExpandableSidebarItem.module.css +0 -3
  46. package/dist/components/sidebar/components/sidebar-container/SidebarContainer.d.ts +3 -1
  47. package/dist/components/sidebar/components/sidebar-container/SidebarContainer.js +4 -3
  48. package/dist/components/sidebar/components/sidebar-container/SidebarContainer.module.css +109 -79
  49. package/dist/components/sidebar/components/sidebar-items/SidebarItems.js +16 -3
  50. package/dist/components/sidebar/components/sidebar-items/SidebarItems.module.css +20 -0
  51. package/dist/components/sidebar/providers/SidebarProvider.js +25 -46
  52. package/dist/components/skeleton-loader/SkeletonLoader.d.ts +1 -1
  53. package/dist/components/skeleton-loader/SkeletonLoader.js +15 -12
  54. package/dist/components/state-page/StatePage.d.ts +9 -0
  55. package/dist/components/state-page/StatePage.js +20 -0
  56. package/dist/components/state-page/StatePage.module.css +9 -0
  57. package/dist/components/state-page/empty.d.ts +2 -0
  58. package/dist/components/state-page/empty.js +2 -0
  59. package/dist/components/state-page/error.d.ts +2 -0
  60. package/dist/components/state-page/error.js +2 -0
  61. package/dist/components/state-page/notFound.d.ts +2 -0
  62. package/dist/components/state-page/notFound.js +2 -0
  63. package/dist/components/sticky-footer-layout/StickyFooterLayout.d.ts +19 -0
  64. package/dist/components/sticky-footer-layout/StickyFooterLayout.js +27 -0
  65. package/dist/components/table/Table.js +4 -4
  66. package/dist/components/table/Table.module.css +168 -60
  67. package/dist/components/table/components/empty-state/EmptyState.d.ts +1 -1
  68. package/dist/components/table/components/empty-state/EmptyState.js +6 -7
  69. package/dist/components/toast/Toast.js +5 -1
  70. package/dist/components/toast/Toast.module.css +40 -15
  71. package/dist/components/toast/provider/ToastProvider.js +1 -0
  72. package/dist/hooks/useTimeDuration.js +9 -3
  73. package/dist/hooks/useViewportFill.js +1 -0
  74. package/dist/index.d.ts +6 -0
  75. package/dist/index.js +6 -1
  76. package/dist/src/styles/styles.css +22 -3
  77. package/dist/styles/styles.css +22 -3
  78. package/dist/styles/themes/dbc/dark.css +1 -1
  79. package/dist/styles/themes/dbc/light.css +2 -1
  80. package/package.json +1 -1
@@ -0,0 +1,27 @@
1
+ import type { ReactNode, JSX } from 'react';
2
+ import { Severity } from '../../constants/severity.types';
3
+ export interface AccordionItem {
4
+ header: string;
5
+ headerIcon?: ReactNode;
6
+ severity?: Severity;
7
+ children: ReactNode;
8
+ disabled?: boolean;
9
+ }
10
+ type Size = 'sm' | 'md' | 'lg';
11
+ type Mode = 'single' | 'multiple';
12
+ export interface AccordionProps {
13
+ items: AccordionItem[];
14
+ mode?: Mode;
15
+ size?: Size;
16
+ /** Uncontrolled defaults */
17
+ defaultOpenIndex?: number | null;
18
+ defaultOpenIndexes?: number[];
19
+ /** Controlled state */
20
+ openIndex?: number | null;
21
+ openIndexes?: number[];
22
+ /** Change callbacks */
23
+ onOpenIndexChange?: (index: number | null) => void;
24
+ onOpenIndexesChange?: (indexes: number[]) => void;
25
+ }
26
+ export declare function Accordion({ items, mode, size, defaultOpenIndex, defaultOpenIndexes, openIndex, openIndexes, onOpenIndexChange, onOpenIndexesChange, }: AccordionProps): JSX.Element;
27
+ export {};
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useMemo, useState } from 'react';
4
+ import styles from './Accordion.module.css';
5
+ import { Headline } from '../headline/Headline';
6
+ function uniqSorted(nums) {
7
+ return Array.from(new Set(nums)).sort((a, b) => a - b);
8
+ }
9
+ export function Accordion({ items, mode = 'single', size = 'md', defaultOpenIndex = null, defaultOpenIndexes = [], openIndex, openIndexes, onOpenIndexChange, onOpenIndexesChange, }) {
10
+ const uid = useId();
11
+ const isControlledSingle = mode === 'single' && openIndex !== undefined;
12
+ const isControlledMultiple = mode === 'multiple' && openIndexes !== undefined;
13
+ const [internalOpenIndex, setInternalOpenIndex] = useState(mode === 'single' ? defaultOpenIndex : null);
14
+ const [internalOpenIndexes, setInternalOpenIndexes] = useState(mode === 'multiple' ? uniqSorted(defaultOpenIndexes) : []);
15
+ const currentOpenIndex = mode === 'single' ? (isControlledSingle ? openIndex : internalOpenIndex) : null;
16
+ const currentOpenIndexes = useMemo(() => mode === 'multiple'
17
+ ? isControlledMultiple
18
+ ? uniqSorted(openIndexes)
19
+ : internalOpenIndexes
20
+ : [], [mode, isControlledMultiple, openIndexes, internalOpenIndexes]);
21
+ const openSet = useMemo(() => new Set(mode === 'single'
22
+ ? currentOpenIndex !== null
23
+ ? [currentOpenIndex]
24
+ : []
25
+ : currentOpenIndexes), [mode, currentOpenIndex, currentOpenIndexes]);
26
+ function setSingle(next) {
27
+ if (isControlledSingle)
28
+ onOpenIndexChange === null || onOpenIndexChange === void 0 ? void 0 : onOpenIndexChange(next);
29
+ else {
30
+ setInternalOpenIndex(next);
31
+ onOpenIndexChange === null || onOpenIndexChange === void 0 ? void 0 : onOpenIndexChange(next);
32
+ }
33
+ }
34
+ function setMultiple(next) {
35
+ const normalized = uniqSorted(next);
36
+ if (isControlledMultiple)
37
+ onOpenIndexesChange === null || onOpenIndexesChange === void 0 ? void 0 : onOpenIndexesChange(normalized);
38
+ else {
39
+ setInternalOpenIndexes(normalized);
40
+ onOpenIndexesChange === null || onOpenIndexesChange === void 0 ? void 0 : onOpenIndexesChange(normalized);
41
+ }
42
+ }
43
+ function toggle(index) {
44
+ const item = items[index];
45
+ if (!item || item.disabled)
46
+ return;
47
+ if (mode === 'single') {
48
+ const isOpen = openSet.has(index);
49
+ setSingle(isOpen ? null : index);
50
+ return;
51
+ }
52
+ // multiple
53
+ const isOpen = openSet.has(index);
54
+ if (isOpen)
55
+ setMultiple(currentOpenIndexes.filter(i => i !== index));
56
+ else
57
+ setMultiple([...currentOpenIndexes, index]);
58
+ }
59
+ return (_jsx("div", { className: `${styles.container} ${styles[size]}`, children: items.map((item, i) => {
60
+ const isOpen = openSet.has(i);
61
+ const isDisabled = !!item.disabled;
62
+ const buttonId = `${uid}-acc-btn-${i}`;
63
+ const panelId = `${uid}-acc-panel-${i}`;
64
+ return (_jsxs("section", { className: `${styles.item} ${isOpen ? styles.open : ''} ${isDisabled ? styles.disabled : ''}`, children: [_jsxs("button", { type: "button", id: buttonId, className: styles.trigger, "aria-expanded": isOpen, "aria-controls": panelId, onClick: () => toggle(i), disabled: isDisabled, children: [_jsx("span", { className: styles.title, children: _jsx(Headline, { disableMargin: true, size: 4, weight: 500, severity: item.severity, allowWrap: isOpen, children: item.header }) }), _jsx("span", { className: styles.chevron, "aria-hidden": "true", children: _jsx("svg", { viewBox: "0 0 20 20", focusable: "false", children: _jsx("path", { d: "M5.5 7.5L10 12l4.5-4.5", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }) }) })] }), _jsx("div", { id: panelId, role: "region", "aria-labelledby": buttonId, className: styles.panel, "data-open": isOpen ? 'true' : 'false', children: _jsx("div", { className: styles.content, children: item.children }) })] }, i));
65
+ }) }));
66
+ }
@@ -0,0 +1,87 @@
1
+ .container {
2
+ border-radius: var(--border-radius-default);
3
+ background-color: var(--color-bg-surface);
4
+ box-sizing: border-box;
5
+ display: flex;
6
+ flex-direction: column;
7
+ overflow: hidden;
8
+ gap: var(--spacing-xxs);
9
+ }
10
+
11
+ .trigger {
12
+ all: unset;
13
+ box-sizing: border-box;
14
+ width: 100%;
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: space-between;
18
+ gap: var(--spacing-sm);
19
+ cursor: pointer;
20
+ user-select: none;
21
+ padding: var(--spacing-xs) var(--spacing-md);
22
+ background: var(--color-bg-contextual);
23
+
24
+ /* IMPORTANT: allow flex children to actually shrink */
25
+ min-width: 0;
26
+ }
27
+
28
+ .trigger:focus-visible {
29
+ outline: none;
30
+ box-shadow: var(--focus-ring);
31
+ }
32
+
33
+ .disabled .trigger {
34
+ cursor: not-allowed;
35
+ color: var(--color-disabled-fg);
36
+ }
37
+
38
+ .title {
39
+ /* IMPORTANT: this is the shrinking area that contains Headline */
40
+ display: flex;
41
+ align-items: center;
42
+ min-width: 0;
43
+ flex: 1 1 auto;
44
+
45
+ /* ensures any overflow is clipped so ellipsis can show */
46
+ overflow: hidden;
47
+ }
48
+
49
+ .chevron {
50
+ width: var(--icon-size-md);
51
+ height: var(--icon-size-md);
52
+ flex: 0 0 auto;
53
+ transition: transform var(--transition-normal) var(--ease-standard);
54
+ }
55
+
56
+ .open .chevron {
57
+ transform: rotate(180deg);
58
+ }
59
+
60
+ /* Collapsible panel using max-height */
61
+ .panel {
62
+ overflow: hidden;
63
+ max-height: 0;
64
+ transition: max-height var(--transition-slow) var(--ease-decelerate);
65
+ }
66
+
67
+ .panel[data-open='true'] {
68
+ max-height: 999px;
69
+ }
70
+
71
+ .content {
72
+ padding: var(--spacing-md) 0;
73
+ }
74
+
75
+ /* Sizes */
76
+ .sm .trigger,
77
+ .sm .content {
78
+ padding: var(--spacing-sm) var(--spacing-md);
79
+ }
80
+
81
+ .md .trigger {
82
+ padding: var(--spacing-sm) var(--spacing-md);
83
+ }
84
+
85
+ .lg .trigger {
86
+ padding: var(--spacing-md) var(--spacing-md);
87
+ }
@@ -47,6 +47,7 @@
47
47
  background-color: var(--color-disabled-bg);
48
48
  border-color: transparent;
49
49
  color: var(--color-disabled-fg);
50
+ opacity: 0.5;
50
51
  }
51
52
 
52
53
  /* ==========================================================================
@@ -1,9 +1,12 @@
1
1
  import type { ReactNode, JSX } from 'react';
2
2
  import { Severity } from '../../constants/severity.types';
3
+ type CircleSize = 'xs' | 'sm' | 'md' | 'lg';
3
4
  interface CircleProps {
4
5
  severity: Severity;
5
6
  children?: ReactNode;
6
7
  glow?: boolean;
8
+ pulse?: boolean;
9
+ size?: CircleSize;
7
10
  }
8
- export declare function Circle({ severity, children, glow }: CircleProps): JSX.Element;
11
+ export declare function Circle({ severity, children, glow, pulse, size }: CircleProps): JSX.Element;
9
12
  export {};
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import styles from './Circle.module.css';
3
- export function Circle({ severity, children, glow }) {
4
- return (_jsxs("span", { className: styles.container, children: [_jsx("span", { "data-glow": glow, className: `${styles.circle} ${styles[severity]}` }), children, ' '] }));
3
+ export function Circle({ severity, children, glow, pulse, size = 'sm' }) {
4
+ return (_jsxs("span", { className: styles.container, children: [_jsx("span", { "data-glow": glow, "data-pulse": pulse, "data-size": size, className: `${styles.circle} ${styles[severity]}` }), children] }));
5
5
  }
@@ -10,9 +10,10 @@
10
10
  }
11
11
 
12
12
  .circle {
13
+ position: relative;
13
14
  display: inline-block;
14
- inline-size: var(--component-size-xs);
15
- block-size: var(--component-size-xs);
15
+ inline-size: var(--component-size-sm);
16
+ block-size: var(--component-size-sm);
16
17
  border-radius: var(--border-radius-round);
17
18
  flex-shrink: 0;
18
19
 
@@ -60,3 +61,54 @@
60
61
  .info[data-glow='true'] {
61
62
  box-shadow: 0 0 0 2px var(--color-status-info-bg);
62
63
  }
64
+
65
+ .circle[data-size='xs'] {
66
+ inline-size: var(--component-size-xxs);
67
+ block-size: var(--component-size-xxs);
68
+ }
69
+
70
+ .circle[data-size='sm'] {
71
+ inline-size: 14px;
72
+ block-size: 14px;
73
+ }
74
+
75
+ .circle[data-size='md'] {
76
+ inline-size: 18px;
77
+ block-size: 18px;
78
+ }
79
+
80
+ .circle[data-size='lg'] {
81
+ inline-size: 22px;
82
+ block-size: 22px;
83
+ }
84
+
85
+ .circle[data-pulse='true']::after {
86
+ content: '';
87
+ position: absolute;
88
+ inset: 0;
89
+ border-radius: inherit;
90
+ background-color: inherit;
91
+ animation: circle-pulse 1.6s ease-out infinite;
92
+ pointer-events: none;
93
+ }
94
+
95
+ @keyframes circle-pulse {
96
+ 0% {
97
+ transform: scale(1);
98
+ opacity: 0.6;
99
+ }
100
+ 60% {
101
+ transform: scale(2);
102
+ opacity: 0;
103
+ }
104
+ 100% {
105
+ transform: scale(2);
106
+ opacity: 0;
107
+ }
108
+ }
109
+
110
+ @media (prefers-reduced-motion: reduce) {
111
+ .circle[data-pulse='true']::after {
112
+ animation: none;
113
+ }
114
+ }
@@ -1,11 +1,11 @@
1
1
  import React from 'react';
2
2
  import { Input } from '../../components/forms/input/Input';
3
- import { DateOnly } from './dateTimeHelpers';
3
+ import { type UtcIsoString } from './dateTimeHelpers';
4
4
  type Mode = 'single' | 'range';
5
5
  type WeekStart = 0 | 1;
6
- export type DateValue = number | DateOnly | null | {
7
- start: DateOnly | null;
8
- end: DateOnly | null;
6
+ export type DateValue = UtcIsoString | null | {
7
+ start: UtcIsoString | null;
8
+ end: UtcIsoString | null;
9
9
  };
10
10
  type InputProps = React.ComponentProps<typeof Input>;
11
11
  export interface DateTimePickerProps {
@@ -25,10 +25,6 @@ export interface DateTimePickerProps {
25
25
  end: Date;
26
26
  };
27
27
  }[];
28
- /**
29
- * Forwarded to the internal <Input />.
30
- * DateTimePicker controls: value, onInput/onBlur/onKeyDown, icon, onClear.
31
- */
32
28
  inputProps?: Omit<InputProps, 'value' | 'onInput' | 'onBlur' | 'icon' | 'onClear' | 'type'>;
33
29
  formatDate?: (d: Date, opts: {
34
30
  locale: string;
@@ -5,9 +5,9 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'r
5
5
  import { Button } from '../../components/button/Button';
6
6
  import { Input } from '../../components/forms/input/Input';
7
7
  import { Popover } from '../../components/popover/Popover';
8
- import { localDateFromYMD, maskRange, maskSingle, pad2, parseLooseDateOrDateTime, parseLooseRange, toMaskedFromDate, toMaskedFromYMD, utcMillisFromYMD, ymdFromLocalDate, ymdFromUTCDateOnly, } from './dateTimeHelpers';
8
+ import { isoFromLocalDate, isoFromLocalParts, localDateFromIso, maskRange, maskSingle, parseLooseDateOrDateTime, parseLooseRange, utcMillisFromIso, toMaskedFromDate, } from './dateTimeHelpers';
9
9
  import styles from './DateTimePicker.module.css';
10
- /* ---------- Date grid helpers (UTC) ---------- */
10
+ /* ---------- Date grid helpers (UTC date-only cells) ---------- */
11
11
  const dUTC = (y, m, day) => new Date(Date.UTC(y, m, day));
12
12
  const addDaysUTC = (utcDate, n) => dUTC(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate() + n);
13
13
  const startOfMonthUTC = (utcDate) => dUTC(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), 1);
@@ -15,14 +15,16 @@ const endOfMonthUTC = (utcDate) => dUTC(utcDate.getUTCFullYear(), utcDate.getUTC
15
15
  const sameDayUTC = (a, b) => a.getUTCFullYear() === b.getUTCFullYear() &&
16
16
  a.getUTCMonth() === b.getUTCMonth() &&
17
17
  a.getUTCDate() === b.getUTCDate();
18
- const toUTCDateOnly = (local) => dUTC(local.getFullYear(), local.getMonth(), local.getDate());
18
+ // IMPORTANT: treat local calendar date as the source of truth for the grid.
19
+ // Build an equivalent "UTC date-only" for comparisons.
20
+ const toUTCDateOnlyFromLocal = (local) => dUTC(local.getFullYear(), local.getMonth(), local.getDate());
19
21
  const startOfWeekUTC = (utcDate, weekStartsOn) => {
20
22
  const dow = utcDate.getUTCDay();
21
23
  const diff = (dow - weekStartsOn + 7) % 7;
22
24
  return addDaysUTC(utcDate, -diff);
23
25
  };
24
26
  function buildMonthGrid(anchorLocalDate, weekStartsOn) {
25
- const anchorUTC = toUTCDateOnly(anchorLocalDate);
27
+ const anchorUTC = toUTCDateOnlyFromLocal(anchorLocalDate);
26
28
  const firstUTC = startOfWeekUTC(startOfMonthUTC(anchorUTC), weekStartsOn);
27
29
  const cells = [];
28
30
  for (let i = 0; i < 42; i++)
@@ -55,77 +57,72 @@ export const DateTimePicker = forwardRef(function DateTimePicker({ mode = 'singl
55
57
  void formatRange;
56
58
  const popRef = useRef(null);
57
59
  const todayLocal = useMemo(() => new Date(), []);
58
- // ---- Derive a local anchor from the controlled value ----
60
+ // ---- local anchor from controlled value ----
59
61
  const initialAnchor = useMemo(() => {
60
62
  var _a, _b;
61
63
  if (mode === 'single') {
62
- if (enableTime && typeof value === 'number')
63
- return new Date(value); // local rendering
64
- if (!enableTime && typeof value === 'string')
65
- return (_a = localDateFromYMD(value)) !== null && _a !== void 0 ? _a : todayLocal;
64
+ if (typeof value === 'string')
65
+ return (_a = localDateFromIso(value)) !== null && _a !== void 0 ? _a : todayLocal;
66
66
  return todayLocal;
67
67
  }
68
68
  if (mode === 'range' && value && typeof value === 'object' && 'start' in value && value.start) {
69
- return (_b = localDateFromYMD(value.start)) !== null && _b !== void 0 ? _b : todayLocal;
69
+ return (_b = localDateFromIso(value.start)) !== null && _b !== void 0 ? _b : todayLocal;
70
70
  }
71
71
  return todayLocal;
72
- }, [mode, value, enableTime, todayLocal]);
72
+ }, [mode, value, todayLocal]);
73
73
  const [monthAnchor, setMonthAnchor] = useState(initialAnchor);
74
- // Keep month anchor in sync when external value changes (but don’t fight while typing)
75
- useEffect(() => {
76
- setMonthAnchor(initialAnchor);
77
- }, [initialAnchor]);
74
+ useEffect(() => setMonthAnchor(initialAnchor), [initialAnchor]);
75
+ // time defaults (local)
78
76
  const [timeHH, setTimeHH] = useState(todayLocal.getHours());
79
77
  const [timeMM, setTimeMM] = useState(Math.floor(todayLocal.getMinutes() / timeStep) * timeStep);
80
- // If value is a datetime and changes externally, keep dropdowns in sync
78
+ // If datetime value changes externally, keep HH/MM in sync
81
79
  useEffect(() => {
82
- if (mode === 'single' && enableTime && typeof value === 'number') {
83
- const d = new Date(value);
80
+ if (mode === 'single' && enableTime && typeof value === 'string') {
81
+ const d = localDateFromIso(value);
82
+ if (!d)
83
+ return;
84
84
  setTimeHH(d.getHours());
85
85
  setTimeMM(Math.floor(d.getMinutes() / timeStep) * timeStep);
86
86
  }
87
87
  }, [mode, enableTime, value, timeStep]);
88
88
  const [hoverUTC, setHoverUTC] = useState(null);
89
89
  const cellsUTC = useMemo(() => buildMonthGrid(monthAnchor, weekStartsOn), [monthAnchor, weekStartsOn]);
90
- const monthStartUTC = useMemo(() => startOfMonthUTC(toUTCDateOnly(monthAnchor)), [monthAnchor]);
91
- const monthEndUTC = useMemo(() => endOfMonthUTC(toUTCDateOnly(monthAnchor)), [monthAnchor]);
90
+ const monthStartUTC = useMemo(() => startOfMonthUTC(toUTCDateOnlyFromLocal(monthAnchor)), [monthAnchor]);
91
+ const monthEndUTC = useMemo(() => endOfMonthUTC(toUTCDateOnlyFromLocal(monthAnchor)), [monthAnchor]);
92
92
  const weekdayFmt = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: 'short' }), [locale]);
93
93
  const monthFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }), [locale]);
94
- // ---- Selection state for the grid (always compared in UTC date-only space) ----
94
+ // ---- selection state for grid (compare in UTC date-only) ----
95
95
  const selectedUTC_single = useMemo(() => {
96
- if (mode !== 'single' || !value)
96
+ if (mode !== 'single')
97
97
  return null;
98
- if (enableTime) {
99
- if (typeof value !== 'number')
100
- return null;
101
- return toUTCDateOnly(new Date(value));
102
- }
103
98
  if (typeof value !== 'string')
104
99
  return null;
105
- const local = localDateFromYMD(value);
106
- return local ? toUTCDateOnly(local) : null;
107
- }, [mode, value, enableTime]);
100
+ const d = localDateFromIso(value);
101
+ if (!d)
102
+ return null;
103
+ return toUTCDateOnlyFromLocal(d);
104
+ }, [mode, value]);
108
105
  const selectedUTC_start = useMemo(() => {
109
106
  if (mode !== 'range' || !value || typeof value !== 'object' || !('start' in value))
110
107
  return null;
111
108
  if (!value.start)
112
109
  return null;
113
- const local = localDateFromYMD(value.start);
114
- return local ? toUTCDateOnly(local) : null;
110
+ const d = localDateFromIso(value.start);
111
+ return d ? toUTCDateOnlyFromLocal(d) : null;
115
112
  }, [mode, value]);
116
113
  const selectedUTC_end = useMemo(() => {
117
114
  if (mode !== 'range' || !value || typeof value !== 'object' || !('end' in value))
118
115
  return null;
119
116
  if (!value.end)
120
117
  return null;
121
- const local = localDateFromYMD(value.end);
122
- return local ? toUTCDateOnly(local) : null;
118
+ const d = localDateFromIso(value.end);
119
+ return d ? toUTCDateOnlyFromLocal(d) : null;
123
120
  }, [mode, value]);
124
121
  const isDisabledUTC = useCallback((utcDay) => {
125
- // min/max are Dates (instants). Treat them as local-day constraints for UI.
126
- if (min && utcDay < toUTCDateOnly(min))
122
+ // Interpret min/max as local-day constraints for UI
123
+ if (min && utcDay < toUTCDateOnlyFromLocal(min))
127
124
  return true;
128
- if (max && utcDay > toUTCDateOnly(max))
125
+ if (max && utcDay > toUTCDateOnlyFromLocal(max))
129
126
  return true;
130
127
  return false;
131
128
  }, [min, max]);
@@ -133,33 +130,29 @@ export const DateTimePicker = forwardRef(function DateTimePicker({ mode = 'singl
133
130
  var _a, _b;
134
131
  if (isDisabledUTC(utcDay))
135
132
  return;
133
+ // utcDay's UTC Y/M/D corresponds to the *local calendar day* shown in UI.
134
+ const y = utcDay.getUTCFullYear();
135
+ const m0 = utcDay.getUTCMonth();
136
+ const d = utcDay.getUTCDate();
136
137
  if (mode === 'single') {
137
- if (enableTime) {
138
- // User picked a local wall time; emit UTC instant as millis
139
- const y = utcDay.getUTCFullYear();
140
- const m = utcDay.getUTCMonth();
141
- const d = utcDay.getUTCDate();
142
- const local = new Date(y, m, d, timeHH, timeMM, 0, 0);
143
- onChange(local.getTime());
144
- }
145
- else {
146
- // Date-only: emit timezone-free day label
147
- onChange(ymdFromUTCDateOnly(utcDay));
148
- }
138
+ const iso = enableTime
139
+ ? isoFromLocalParts(y, m0, d, timeHH, timeMM)
140
+ : isoFromLocalParts(y, m0, d, 0, 0);
141
+ onChange(iso);
149
142
  (_a = popRef.current) === null || _a === void 0 ? void 0 : _a.close();
150
143
  return;
151
144
  }
152
- // RANGE: date-only
145
+ // range (date-only in UI, but emitted as ISO instants at local midnight)
153
146
  const curr = value && typeof value === 'object' && 'start' in value
154
147
  ? value
155
148
  : { start: null, end: null };
156
- const picked = ymdFromUTCDateOnly(utcDay);
149
+ const picked = isoFromLocalParts(y, m0, d, 0, 0);
157
150
  if (!curr.start || (curr.start && curr.end)) {
158
151
  onChange({ start: picked, end: null });
159
152
  return;
160
153
  }
161
- const a = utcMillisFromYMD(curr.start);
162
- const b = utcMillisFromYMD(picked);
154
+ const a = utcMillisFromIso(curr.start);
155
+ const b = utcMillisFromIso(picked);
163
156
  const start = a <= b ? curr.start : picked;
164
157
  const end = a <= b ? picked : curr.start;
165
158
  onChange({ start, end });
@@ -186,7 +179,7 @@ export const DateTimePicker = forwardRef(function DateTimePicker({ mode = 'singl
186
179
  ].includes(e.key)) {
187
180
  e.preventDefault();
188
181
  }
189
- const anchor = toUTCDateOnly(monthAnchor);
182
+ const anchor = toUTCDateOnlyFromLocal(monthAnchor);
190
183
  const move = (days) => setMonthAnchor(prev => addDaysLocal(prev, days));
191
184
  switch (e.key) {
192
185
  case 'ArrowLeft':
@@ -223,31 +216,26 @@ export const DateTimePicker = forwardRef(function DateTimePicker({ mode = 'singl
223
216
  // ---- Input display: always local ----
224
217
  const formatted = useMemo(() => {
225
218
  if (mode === 'single') {
226
- if (!value)
227
- return '';
228
- if (enableTime) {
229
- if (typeof value !== 'number')
230
- return '';
231
- return toMaskedFromDate(new Date(value), true);
232
- }
233
219
  if (typeof value !== 'string')
234
220
  return '';
235
- return toMaskedFromYMD(value);
221
+ const d = localDateFromIso(value);
222
+ return d ? toMaskedFromDate(d, enableTime) : '';
236
223
  }
237
- // range (date-only)
238
224
  const v = value;
239
- const s = typeof (v === null || v === void 0 ? void 0 : v.start) === 'string' ? toMaskedFromYMD(v.start) : '';
240
- const e = typeof (v === null || v === void 0 ? void 0 : v.end) === 'string' ? toMaskedFromYMD(v.end) : '';
241
- if (s && e)
242
- return `${s} ${e}`;
243
- if (s)
244
- return `${s} –`;
245
- if (e)
246
- return `– ${e}`;
225
+ const s = typeof (v === null || v === void 0 ? void 0 : v.start) === 'string' ? localDateFromIso(v.start) : null;
226
+ const e = typeof (v === null || v === void 0 ? void 0 : v.end) === 'string' ? localDateFromIso(v.end) : null;
227
+ const ss = s ? toMaskedFromDate(s, false) : '';
228
+ const ee = e ? toMaskedFromDate(e, false) : '';
229
+ if (ss && ee)
230
+ return `${ss} – ${ee}`;
231
+ if (ss)
232
+ return `${ss} –`;
233
+ if (ee)
234
+ return `– ${ee}`;
247
235
  return '';
248
236
  }, [mode, value, enableTime]);
249
237
  const [text, setText] = useState(formatted);
250
- const [dirty, setDirty] = useState(false); // while user is typing
238
+ const [dirty, setDirty] = useState(false);
251
239
  useEffect(() => {
252
240
  if (!dirty)
253
241
  setText(formatted);
@@ -265,28 +253,21 @@ export const DateTimePicker = forwardRef(function DateTimePicker({ mode = 'singl
265
253
  const dLocal = parseLooseDateOrDateTime(text);
266
254
  if (!dLocal)
267
255
  return;
268
- if (enableTime) {
269
- // Emit UTC instant millis
270
- onChange(dLocal.getTime());
271
- }
272
- else {
273
- // Emit date-only string (local calendar day)
274
- onChange(ymdFromLocalDate(dLocal));
275
- }
256
+ // If enableTime=false, parseLooseDateOrDateTime returns 00:00 local -> still OK.
257
+ onChange(isoFromLocalDate(dLocal));
276
258
  setMonthAnchor(dLocal);
277
259
  setDirty(false);
278
260
  return;
279
261
  }
280
262
  const r = parseLooseRange(text);
281
263
  if (r) {
282
- // Range is date-only, based on local calendar parts
283
- const start = `${r.start.getFullYear()}-${pad2(r.start.getMonth() + 1)}-${pad2(r.start.getDate())}`;
284
- const end = `${r.end.getFullYear()}-${pad2(r.end.getMonth() + 1)}-${pad2(r.end.getDate())}`;
285
- onChange({ start, end });
264
+ const startIso = isoFromLocalParts(r.start.getFullYear(), r.start.getMonth(), r.start.getDate(), 0, 0);
265
+ const endIso = isoFromLocalParts(r.end.getFullYear(), r.end.getMonth(), r.end.getDate(), 0, 0);
266
+ onChange({ start: startIso, end: endIso });
286
267
  setMonthAnchor(r.start);
287
268
  setDirty(false);
288
269
  }
289
- }, [text, mode, onChange, enableTime]);
270
+ }, [text, mode, onChange]);
290
271
  const clear = useCallback(() => {
291
272
  if (mode === 'single')
292
273
  onChange(null);
@@ -308,7 +289,7 @@ export const DateTimePicker = forwardRef(function DateTimePicker({ mode = 'singl
308
289
  const fallbackPlaceholder = mode === 'single' ? 'Vælg dato' : 'Vælg interval';
309
290
  return (_jsx(Popover, { ref: popRef, trigger: toggle => {
310
291
  var _a, _b;
311
- return (_jsx("div", { onClick: toggle, className: styles.triggerWrap, children: _jsx(Input, { ...inputProps, placeholder: (_a = inputProps === null || inputProps === void 0 ? void 0 : inputProps.placeholder) !== null && _a !== void 0 ? _a : fallbackPlaceholder, value: dirty ? text : formatted, onInput: e => {
292
+ return (_jsx("div", { onClick: toggle, className: styles.triggerWrap, children: _jsx(Input, { ...inputProps, autoComplete: "off", autoCorrect: "off", autoCapitalize: "off", spellCheck: "false", placeholder: (_a = inputProps === null || inputProps === void 0 ? void 0 : inputProps.placeholder) !== null && _a !== void 0 ? _a : fallbackPlaceholder, value: dirty ? text : formatted, onInput: e => {
312
293
  setDirty(true);
313
294
  const raw = e.target.value;
314
295
  const masked = mode === 'single' ? maskSingle(raw, enableTime) : maskRange(raw, false);
@@ -326,17 +307,16 @@ export const DateTimePicker = forwardRef(function DateTimePicker({ mode = 'singl
326
307
  }, viewportPadding: 8, children: _jsxs("div", { className: cx(styles.panel, !!(presets === null || presets === void 0 ? void 0 : presets.length) && styles.panelWithPresets), children: [(presets === null || presets === void 0 ? void 0 : presets.length) ? (_jsxs("div", { className: styles.presetsCol, children: [_jsx("div", { className: styles.presetsLabel, children: "Forvalg" }), _jsxs("div", { className: styles.presetsList, children: [presets.map(p => (_jsx(Button, { variant: "outlined", size: "sm", onClick: () => {
327
308
  var _a;
328
309
  const r = p.getRange();
329
- // Presets -> date-only range
330
- const start = `${r.start.getFullYear()}-${pad2(r.start.getMonth() + 1)}-${pad2(r.start.getDate())}`;
331
- const end = `${r.end.getFullYear()}-${pad2(r.end.getMonth() + 1)}-${pad2(r.end.getDate())}`;
332
- onChange({ start, end });
310
+ const startIso = isoFromLocalParts(r.start.getFullYear(), r.start.getMonth(), r.start.getDate(), 0, 0);
311
+ const endIso = isoFromLocalParts(r.end.getFullYear(), r.end.getMonth(), r.end.getDate(), 0, 0);
312
+ onChange({ start: startIso, end: endIso });
333
313
  setDirty(false);
334
- setText(`${toMaskedFromYMD(start)} – ${toMaskedFromYMD(end)}`);
314
+ setText(`${toMaskedFromDate(r.start, false)} – ${toMaskedFromDate(r.end, false)}`);
335
315
  setMonthAnchor(r.start);
336
316
  (_a = popRef.current) === null || _a === void 0 ? void 0 : _a.close();
337
317
  }, children: p.label }, p.label))), mode === 'range' && (_jsx(Button, { variant: "danger", size: "sm", onClick: clear, icon: _jsx(X, { size: 14 }), children: "Ryd" }))] })] })) : null, _jsxs("div", { className: styles.calendarArea, children: [_jsxs("div", { className: styles.header, children: [_jsx(Button, { variant: "outlined", size: "sm", "aria-label": "Forrige m\u00E5ned", icon: _jsx(ChevronLeft, { size: 16 }), onClick: () => setMonthAnchor(addMonthsLocal(monthAnchor, -1)) }), _jsx("div", { "aria-live": "polite", className: styles.headerTitle, children: monthFmt.format(monthAnchor) }), _jsx(Button, { variant: "outlined", size: "sm", "aria-label": "N\u00E6ste m\u00E5ned", icon: _jsx(ChevronRight, { size: 16 }), onClick: () => setMonthAnchor(addMonthsLocal(monthAnchor, 1)) })] }), _jsx("div", { className: styles.weekRow, "aria-hidden": true, children: Array.from({ length: 7 }, (_, i) => (i + weekStartsOn) % 7).map(dow => (_jsx("div", { className: styles.weekCell, children: weekdayFmt.format(dUTC(2024, 8, dow + 1)).slice(0, 2) }, dow))) }), _jsx("div", { ref: gridRef, role: "grid", "aria-label": "Kalender", tabIndex: 0, className: styles.grid, onMouseLeave: () => setHoverUTC(null), children: cellsUTC.map((utcDay, idx) => {
338
318
  const inThisMonth = utcDay >= monthStartUTC && utcDay <= monthEndUTC;
339
- const isToday = sameDayUTC(utcDay, toUTCDateOnly(todayLocal));
319
+ const isToday = sameDayUTC(utcDay, toUTCDateOnlyFromLocal(todayLocal));
340
320
  const disabledDay = isDisabledUTC(utcDay);
341
321
  let selected = false;
342
322
  let inRange = false;
@@ -2,20 +2,22 @@ export declare const digits: (s: string) => string;
2
2
  export declare function maskDateEU(text: string): string;
3
3
  export declare function maskTimeHM(text: string): string;
4
4
  export declare function maskSingle(text: string, enableTime: boolean): string;
5
- export declare function maskRange(text: string, enableTime: boolean): string;
5
+ export declare function maskRange(text: string, _enableTime: boolean): string;
6
6
  export declare const pad2: (n: number) => string;
7
- export type DateOnly = string;
8
- export declare function parseYMD(ymd: string): {
9
- y: number;
10
- m: number;
11
- d: number;
12
- } | null;
13
- export declare function ymdFromLocalDate(dLocal: Date): DateOnly;
14
- export declare function ymdFromUTCDateOnly(utcDay: Date): DateOnly;
15
- export declare function utcMillisFromYMD(ymd: DateOnly): number;
16
- export declare function localDateFromYMD(ymd: DateOnly): Date | null;
7
+ export type UtcIsoString = string;
8
+ export declare function isUtcIsoString(v: unknown): v is UtcIsoString;
9
+ export declare function utcMillisFromIso(iso: UtcIsoString): number;
10
+ /**
11
+ * Build a *local* Date from y/m/d/hh/mm and return UTC ISO string (Z).
12
+ * This keeps the UI meaning "local wall time", while emitting a stable UTC instant.
13
+ */
14
+ export declare function isoFromLocalParts(y: number, m0: number, // 0-based
15
+ d: number, hh?: number, mm?: number): UtcIsoString;
16
+ /** Convert a local Date to a UTC ISO string (Z). */
17
+ export declare function isoFromLocalDate(dLocal: Date): UtcIsoString;
18
+ /** For anchoring the calendar safely from a UTC ISO string. */
19
+ export declare function localDateFromIso(iso: UtcIsoString): Date | null;
17
20
  export declare function toMaskedFromDate(d: Date, enableTime: boolean): string;
18
- export declare function toMaskedFromYMD(ymd: DateOnly): string;
19
21
  export declare function parseLooseDateOrDateTime(input: string): Date | null;
20
22
  export declare function parseLooseRange(input: string): {
21
23
  start: Date;