@dbcdk/react-components 0.0.8 → 0.0.9

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.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # DBC React Components
2
+
3
+ Reusable React components for DBC projects.
4
+
5
+ This library provides a shared, themeable component system used across DBC’s internal (and selected external) applications. It is designed to promote consistency, speed up development, and improve overall quality and accessibility.
6
+
7
+ ---
8
+
9
+ ## Purpose of the library
10
+
11
+ The goals of this component library are:
12
+
13
+ - **Consistency**
14
+ Provide a unified look and feel across DBC’s digital products, especially internal tools.
15
+
16
+ - **Development speed**
17
+ Reduce development and maintenance time by reusing well-tested components instead of rebuilding UI from scratch.
18
+
19
+ - **Quality & accessibility**
20
+ Components are reviewed and built according to best practices, with accessibility and robustness in mind, ensuring a strong baseline quality.
21
+
22
+ - **Reduced third-party dependency**
23
+ Increase digital independence by building and sharing our own components across the organisation, reducing reliance on external NPM packages.
24
+
25
+ ---
26
+
27
+ ## Getting started
28
+
29
+ ### 1) Install the package
30
+
31
+ ```bash
32
+ npm install @dbcdk/react-components
33
+ ```
34
+
35
+ ---
36
+
37
+ ### 2) Import global styles
38
+
39
+ > **Important:** The component library requires global styles to be imported once in your application.
40
+
41
+ ```ts
42
+ import '@dbcdk/react-components/styles.css'
43
+ ```
44
+
45
+ ---
46
+
47
+ ### 3) Add the theme `<link>` in your root layout (Next.js example)
48
+
49
+ The library uses theme CSS files that are dynamically loaded via a `<link>` tag in `<head>`.
50
+ You **must** use the exported `LINK_ID` so the `useTheme()` hook can update the active theme at runtime.
51
+
52
+ ```tsx
53
+ import { ReactNode } from 'react'
54
+ import { cookies } from 'next/headers'
55
+
56
+ import { LINK_ID } from '@dbcdk/react-components'
57
+ import '@dbcdk/react-components/styles.css'
58
+
59
+ export default async function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
60
+ const cookieStore = await cookies()
61
+ const themeId = cookieStore.get('dbc_theme')?.value || 'light'
62
+
63
+ return (
64
+ <html lang="da">
65
+ <head>
66
+ <link id={LINK_ID} rel="stylesheet" href={`/themes/${themeId}.css`} />
67
+ </head>
68
+ <body>{children}</body>
69
+ </html>
70
+ )
71
+ }
72
+ ```
73
+
74
+ > Theme files are expected to be served from `/themes/<theme>.css`.
75
+
76
+ ---
77
+
78
+ ### 4) Switching theme in your application
79
+
80
+ Example using `useTheme()`:
81
+
82
+ ```tsx
83
+ 'use client'
84
+
85
+ import { AppHeader, Button, useTheme } from '@dbcdk/react-components'
86
+ import { Moon, Sun } from 'lucide-react'
87
+
88
+ export default function Header() {
89
+ const { theme, switchTheme } = useTheme()
90
+
91
+ return (
92
+ <AppHeader>
93
+ <Button variant="outlined" onClick={() => switchTheme(theme === 'light' ? 'dark' : 'light')}>
94
+ {theme === 'light' ? <Moon /> : <Sun />}
95
+ </Button>
96
+ </AppHeader>
97
+ )
98
+ }
99
+ ```
100
+
101
+ The hook updates the `<link>` tag automatically and persists the selected theme.
102
+
103
+ ---
104
+
105
+ ## Using Storybook
106
+
107
+ Storybook is the primary documentation and exploration tool for this library.
108
+
109
+ ### Local Storybook
110
+
111
+ ```bash
112
+ npm run storybook
113
+ ```
114
+
115
+ Storybook runs on `http://localhost:6006`.
116
+
117
+ ### In Storybook you can:
118
+
119
+ 1. Browse components in the left-hand navigation
120
+ 2. Open a story to see variants and states
121
+ 3. Adjust props via **Controls**
122
+ 4. Read guidelines and usage notes in the **Docs** tab
123
+
124
+ ---
125
+
126
+ ## Themes
127
+
128
+ Use the 🎨 **theme selector** in the Storybook toolbar to switch between available themes (e.g. light and dark).
129
+
130
+ All components are styled using CSS variables defined in the theme files.
131
+
132
+ ---
133
+
134
+ ## Accessibility (a11y)
135
+
136
+ Accessibility is a first-class concern in this library.
137
+
138
+ We aim to ensure that components:
139
+
140
+ - Are usable with keyboard navigation
141
+ - Have visible and consistent focus states
142
+ - Work with screen readers
143
+ - Follow common ARIA and semantic HTML best practices
144
+
145
+ Storybook includes the a11y addon to help identify issues during development.
146
+
147
+ ---
148
+
149
+ ## Contributing
150
+
151
+ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for detailed guidelines on:
152
+
153
+ - Folder structure
154
+ - Styling and theming rules
155
+ - Storybook requirements
156
+ - TypeScript conventions
157
+ - Versioning and changesets
158
+
159
+ ---
160
+
161
+ ## License
162
+
163
+ ISC
164
+
165
+ ```
166
+
167
+ ```
@@ -0,0 +1,9 @@
1
+ import { ReactNode } from 'react';
2
+ import { TabItem } from '../../../components/tabs/Tabs';
3
+ export declare const tabItems: TabItem[];
4
+ export declare const tabArgs: {
5
+ tabs: TabItem[];
6
+ variant: 'filled' | 'outlined';
7
+ header: string;
8
+ addition: ReactNode;
9
+ };
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Hourglass, Undo2 } from 'lucide-react';
3
+ import { Button } from '../../../components/button/Button';
4
+ import { Icon } from '../../../components/icon/Icon';
5
+ import { SampleTable } from './table';
6
+ export const tabItems = [
7
+ {
8
+ header: 'Afvist af anmelder',
9
+ id: 1,
10
+ headerIcon: _jsx(Icon, { severity: "error" }),
11
+ content: _jsx(SampleTable, {}),
12
+ },
13
+ {
14
+ header: 'Afventer godkendelse',
15
+ id: 2,
16
+ headerIcon: _jsx(Hourglass, {}),
17
+ content: _jsx(SampleTable, {}),
18
+ },
19
+ {
20
+ header: 'Retur til redigering',
21
+ id: 3,
22
+ headerIcon: _jsx(Undo2, {}),
23
+ content: _jsx(SampleTable, {}),
24
+ },
25
+ ];
26
+ export const tabArgs = {
27
+ tabs: tabItems,
28
+ variant: 'filled',
29
+ header: 'Sagsoversigt',
30
+ addition: _jsx(Button, { children: "Se historik" }),
31
+ };
@@ -13,7 +13,7 @@
13
13
  }
14
14
 
15
15
  .container.sm {
16
- padding: var(--spacing-xxs);
16
+ padding: var(--spacing-xs);
17
17
  padding-inline-end: calc(var(--spacing-xxs) + 40px);
18
18
  min-height: 0;
19
19
  }
@@ -1,10 +1,11 @@
1
1
  import React from 'react';
2
2
  import { Input } from '../../components/forms/input/Input';
3
+ import { DateOnly } from './dateTimeHelpers';
3
4
  type Mode = 'single' | 'range';
4
5
  type WeekStart = 0 | 1;
5
- export type DateValue = Date | null | {
6
- start: Date | null;
7
- end: Date | null;
6
+ export type DateValue = number | DateOnly | null | {
7
+ start: DateOnly | null;
8
+ end: DateOnly | null;
8
9
  };
9
10
  type InputProps = React.ComponentProps<typeof Input>;
10
11
  export interface DateTimePickerProps {
@@ -5,8 +5,8 @@ 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
9
  import styles from './DateTimePicker.module.css';
9
- import { maskRange, maskSingle, parseLooseDateOrDateTime, parseLooseRange, toMaskedFromDate, toMaskedRange, } from './dateTimeHelpers';
10
10
  /* ---------- Date grid helpers (UTC) ---------- */
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);
@@ -33,13 +33,6 @@ const isBetweenUTC = (d, a, b) => {
33
33
  const t = +d, s = +a, e = +b;
34
34
  return t >= Math.min(s, e) && t <= Math.max(s, e);
35
35
  };
36
- function composeLocalDateTimeISO(utcDateOnly, hh, mm) {
37
- const y = utcDateOnly.getUTCFullYear();
38
- const m = utcDateOnly.getUTCMonth();
39
- const d = utcDateOnly.getUTCDate();
40
- const local = new Date(y, m, d, hh, mm, 0, 0);
41
- return local.toISOString();
42
- }
43
36
  /* ---------- Formatting (exposed but input uses mask) ---------- */
44
37
  function defaultFormatDate(d, { locale, enableTime }) {
45
38
  const opts = enableTime
@@ -57,37 +50,79 @@ function defaultFormatRange(s, e, opts) {
57
50
  return '';
58
51
  }
59
52
  const cx = (...classes) => classes.filter(Boolean).join(' ');
60
- export const DateTimePicker = forwardRef(function DateTimePicker({ mode = 'single', value, onChange, enableTime = false, timeStep = 15, min, max, locale = typeof navigator !== 'undefined' ? navigator.language : 'da-DK', weekStartsOn = 1, presets, inputProps, formatDate = defaultFormatDate, // still exposed, not used for input text
61
- formatRange = defaultFormatRange, // still exposed, not used for input text
62
- }, _ref) {
53
+ export const DateTimePicker = forwardRef(function DateTimePicker({ mode = 'single', value, onChange, enableTime = false, timeStep = 15, min, max, locale = typeof navigator !== 'undefined' ? navigator.language : 'da-DK', weekStartsOn = 1, presets, inputProps, formatDate = defaultFormatDate, formatRange = defaultFormatRange, }, _ref) {
63
54
  void formatDate;
64
55
  void formatRange;
65
56
  const popRef = useRef(null);
66
57
  const todayLocal = useMemo(() => new Date(), []);
58
+ // ---- Derive a local anchor from the controlled value ----
67
59
  const initialAnchor = useMemo(() => {
68
- if (mode === 'single' && value instanceof Date && value)
69
- return value;
70
- if (mode === 'range' && value && typeof value === 'object' && 'start' in value && value.start)
71
- return value.start;
60
+ var _a, _b;
61
+ 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;
66
+ return todayLocal;
67
+ }
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;
70
+ }
72
71
  return todayLocal;
73
- }, [mode, value, todayLocal]);
72
+ }, [mode, value, enableTime, todayLocal]);
74
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]);
75
78
  const [timeHH, setTimeHH] = useState(todayLocal.getHours());
76
79
  const [timeMM, setTimeMM] = useState(Math.floor(todayLocal.getMinutes() / timeStep) * timeStep);
80
+ // If value is a datetime and changes externally, keep dropdowns in sync
81
+ useEffect(() => {
82
+ if (mode === 'single' && enableTime && typeof value === 'number') {
83
+ const d = new Date(value);
84
+ setTimeHH(d.getHours());
85
+ setTimeMM(Math.floor(d.getMinutes() / timeStep) * timeStep);
86
+ }
87
+ }, [mode, enableTime, value, timeStep]);
77
88
  const [hoverUTC, setHoverUTC] = useState(null);
78
89
  const cellsUTC = useMemo(() => buildMonthGrid(monthAnchor, weekStartsOn), [monthAnchor, weekStartsOn]);
79
90
  const monthStartUTC = useMemo(() => startOfMonthUTC(toUTCDateOnly(monthAnchor)), [monthAnchor]);
80
91
  const monthEndUTC = useMemo(() => endOfMonthUTC(toUTCDateOnly(monthAnchor)), [monthAnchor]);
81
92
  const weekdayFmt = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: 'short' }), [locale]);
82
93
  const monthFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }), [locale]);
83
- const selectedUTC_single = mode === 'single' && value instanceof Date && value ? toUTCDateOnly(value) : null;
84
- const selectedUTC_start = mode === 'range' && value && typeof value === 'object' && 'start' in value && value.start
85
- ? toUTCDateOnly(value.start)
86
- : null;
87
- const selectedUTC_end = mode === 'range' && value && typeof value === 'object' && 'end' in value && value.end
88
- ? toUTCDateOnly(value.end)
89
- : null;
94
+ // ---- Selection state for the grid (always compared in UTC date-only space) ----
95
+ const selectedUTC_single = useMemo(() => {
96
+ if (mode !== 'single' || !value)
97
+ return null;
98
+ if (enableTime) {
99
+ if (typeof value !== 'number')
100
+ return null;
101
+ return toUTCDateOnly(new Date(value));
102
+ }
103
+ if (typeof value !== 'string')
104
+ return null;
105
+ const local = localDateFromYMD(value);
106
+ return local ? toUTCDateOnly(local) : null;
107
+ }, [mode, value, enableTime]);
108
+ const selectedUTC_start = useMemo(() => {
109
+ if (mode !== 'range' || !value || typeof value !== 'object' || !('start' in value))
110
+ return null;
111
+ if (!value.start)
112
+ return null;
113
+ const local = localDateFromYMD(value.start);
114
+ return local ? toUTCDateOnly(local) : null;
115
+ }, [mode, value]);
116
+ const selectedUTC_end = useMemo(() => {
117
+ if (mode !== 'range' || !value || typeof value !== 'object' || !('end' in value))
118
+ return null;
119
+ if (!value.end)
120
+ return null;
121
+ const local = localDateFromYMD(value.end);
122
+ return local ? toUTCDateOnly(local) : null;
123
+ }, [mode, value]);
90
124
  const isDisabledUTC = useCallback((utcDay) => {
125
+ // min/max are Dates (instants). Treat them as local-day constraints for UI.
91
126
  if (min && utcDay < toUTCDateOnly(min))
92
127
  return true;
93
128
  if (max && utcDay > toUTCDateOnly(max))
@@ -100,27 +135,35 @@ formatRange = defaultFormatRange, // still exposed, not used for input text
100
135
  return;
101
136
  if (mode === 'single') {
102
137
  if (enableTime) {
103
- const iso = composeLocalDateTimeISO(utcDay, timeHH, timeMM);
104
- onChange(new Date(iso));
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());
105
144
  }
106
145
  else {
107
- onChange(new Date(utcDay.getTime()));
146
+ // Date-only: emit timezone-free day label
147
+ onChange(ymdFromUTCDateOnly(utcDay));
108
148
  }
109
149
  (_a = popRef.current) === null || _a === void 0 ? void 0 : _a.close();
110
150
  return;
111
151
  }
112
- const curr = value && typeof value === 'object' && 'start' in value ? value : { start: null, end: null };
152
+ // RANGE: date-only
153
+ const curr = value && typeof value === 'object' && 'start' in value
154
+ ? value
155
+ : { start: null, end: null };
156
+ const picked = ymdFromUTCDateOnly(utcDay);
113
157
  if (!curr.start || (curr.start && curr.end)) {
114
- onChange({ start: new Date(utcDay.getTime()), end: null });
115
- }
116
- else {
117
- const startUTC = toUTCDateOnly(curr.start);
118
- const endUTC = utcDay;
119
- const s = new Date(Math.min(+startUTC, +endUTC));
120
- const e = new Date(Math.max(+startUTC, +endUTC));
121
- onChange({ start: s, end: e });
122
- (_b = popRef.current) === null || _b === void 0 ? void 0 : _b.close();
158
+ onChange({ start: picked, end: null });
159
+ return;
123
160
  }
161
+ const a = utcMillisFromYMD(curr.start);
162
+ const b = utcMillisFromYMD(picked);
163
+ const start = a <= b ? curr.start : picked;
164
+ const end = a <= b ? picked : curr.start;
165
+ onChange({ start, end });
166
+ (_b = popRef.current) === null || _b === void 0 ? void 0 : _b.close();
124
167
  };
125
168
  const gridRef = useRef(null);
126
169
  useEffect(() => {
@@ -177,20 +220,37 @@ formatRange = defaultFormatRange, // still exposed, not used for input text
177
220
  }, [monthAnchor]);
178
221
  const hours = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []);
179
222
  const minutes = useMemo(() => Array.from({ length: Math.floor(60 / (timeStep || 1)) }, (_, i) => i * (timeStep || 1)), [timeStep]);
180
- // ---- Input display: follow mask format (not Intl) ----
223
+ // ---- Input display: always local ----
181
224
  const formatted = useMemo(() => {
182
- var _a, _b;
183
225
  if (mode === 'single') {
184
- return value instanceof Date && value ? toMaskedFromDate(value, enableTime) : '';
226
+ if (!value)
227
+ return '';
228
+ if (enableTime) {
229
+ if (typeof value !== 'number')
230
+ return '';
231
+ return toMaskedFromDate(new Date(value), true);
232
+ }
233
+ if (typeof value !== 'string')
234
+ return '';
235
+ return toMaskedFromYMD(value);
185
236
  }
237
+ // range (date-only)
186
238
  const v = value;
187
- return toMaskedRange((_a = v === null || v === void 0 ? void 0 : v.start) !== null && _a !== void 0 ? _a : null, (_b = v === null || v === void 0 ? void 0 : v.end) !== null && _b !== void 0 ? _b : null, enableTime);
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}`;
247
+ return '';
188
248
  }, [mode, value, enableTime]);
189
249
  const [text, setText] = useState(formatted);
190
250
  const [dirty, setDirty] = useState(false); // while user is typing
191
251
  useEffect(() => {
192
252
  if (!dirty)
193
- setText(formatted); // keep in sync when external value changes
253
+ setText(formatted);
194
254
  }, [formatted, dirty]);
195
255
  const commitTypedValue = useCallback(() => {
196
256
  if (!text.trim()) {
@@ -203,20 +263,30 @@ formatRange = defaultFormatRange, // still exposed, not used for input text
203
263
  }
204
264
  if (mode === 'single') {
205
265
  const dLocal = parseLooseDateOrDateTime(text);
206
- if (dLocal) {
207
- onChange(dLocal);
208
- setMonthAnchor(dLocal);
209
- setDirty(false);
266
+ if (!dLocal)
267
+ return;
268
+ if (enableTime) {
269
+ // Emit UTC instant millis
270
+ onChange(dLocal.getTime());
210
271
  }
272
+ else {
273
+ // Emit date-only string (local calendar day)
274
+ onChange(ymdFromLocalDate(dLocal));
275
+ }
276
+ setMonthAnchor(dLocal);
277
+ setDirty(false);
211
278
  return;
212
279
  }
213
280
  const r = parseLooseRange(text);
214
281
  if (r) {
215
- onChange({ start: r.start, end: r.end });
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 });
216
286
  setMonthAnchor(r.start);
217
287
  setDirty(false);
218
288
  }
219
- }, [text, mode, onChange]);
289
+ }, [text, mode, onChange, enableTime]);
220
290
  const clear = useCallback(() => {
221
291
  if (mode === 'single')
222
292
  onChange(null);
@@ -241,7 +311,7 @@ formatRange = defaultFormatRange, // still exposed, not used for input text
241
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 => {
242
312
  setDirty(true);
243
313
  const raw = e.target.value;
244
- const masked = mode === 'single' ? maskSingle(raw, enableTime) : maskRange(raw, enableTime);
314
+ const masked = mode === 'single' ? maskSingle(raw, enableTime) : maskRange(raw, false);
245
315
  setText(masked);
246
316
  }, onBlur: commitTypedValue, onKeyDown: e => {
247
317
  var _a;
@@ -256,9 +326,12 @@ formatRange = defaultFormatRange, // still exposed, not used for input text
256
326
  }, 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: () => {
257
327
  var _a;
258
328
  const r = p.getRange();
259
- onChange({ start: r.start, end: r.end });
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 });
260
333
  setDirty(false);
261
- setText(toMaskedRange(r.start, r.end, enableTime));
334
+ setText(`${toMaskedFromYMD(start)} – ${toMaskedFromYMD(end)}`);
262
335
  setMonthAnchor(r.start);
263
336
  (_a = popRef.current) === null || _a === void 0 ? void 0 : _a.close();
264
337
  }, 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) => {
@@ -4,8 +4,18 @@ export declare function maskTimeHM(text: string): string;
4
4
  export declare function maskSingle(text: string, enableTime: boolean): string;
5
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
17
  export declare function toMaskedFromDate(d: Date, enableTime: boolean): string;
8
- export declare function toMaskedRange(start: Date | null, end: Date | null, enableTime: boolean): string;
18
+ export declare function toMaskedFromYMD(ymd: DateOnly): string;
9
19
  export declare function parseLooseDateOrDateTime(input: string): Date | null;
10
20
  export declare function parseLooseRange(input: string): {
11
21
  start: Date;
@@ -48,6 +48,45 @@ export function maskRange(text, enableTime) {
48
48
  }
49
49
  // Pad helper
50
50
  export const pad2 = (n) => String(n).padStart(2, '0');
51
+ export function parseYMD(ymd) {
52
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
53
+ if (!m)
54
+ return null;
55
+ const y = +m[1];
56
+ const mo = +m[2];
57
+ const d = +m[3];
58
+ if (mo < 1 || mo > 12 || d < 1 || d > 31)
59
+ return null;
60
+ return { y, m: mo, d };
61
+ }
62
+ export function ymdFromLocalDate(dLocal) {
63
+ const y = dLocal.getFullYear();
64
+ const m = dLocal.getMonth() + 1;
65
+ const d = dLocal.getDate();
66
+ return `${y}-${pad2(m)}-${pad2(d)}`;
67
+ }
68
+ export function ymdFromUTCDateOnly(utcDay) {
69
+ const y = utcDay.getUTCFullYear();
70
+ const m = utcDay.getUTCMonth() + 1;
71
+ const d = utcDay.getUTCDate();
72
+ return `${y}-${pad2(m)}-${pad2(d)}`;
73
+ }
74
+ // Used only for ordering/comparing date-only values.
75
+ export function utcMillisFromYMD(ymd) {
76
+ const p = parseYMD(ymd);
77
+ if (!p)
78
+ return NaN;
79
+ return Date.UTC(p.y, p.m - 1, p.d);
80
+ }
81
+ // For anchoring the calendar grid safely from a date-only value.
82
+ export function localDateFromYMD(ymd) {
83
+ const p = parseYMD(ymd);
84
+ if (!p)
85
+ return null;
86
+ // local noon avoids DST edge cases for date-only anchors
87
+ return new Date(p.y, p.m - 1, p.d, 12, 0, 0, 0);
88
+ }
89
+ /* ---------- Formatting (UI shows local) ---------- */
51
90
  // From Date → "DD-MM-YYYY" or "DD-MM-YYYY HH:mm" (local time)
52
91
  export function toMaskedFromDate(d, enableTime) {
53
92
  const dd = pad2(d.getDate());
@@ -58,15 +97,11 @@ export function toMaskedFromDate(d, enableTime) {
58
97
  out += ` ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
59
98
  return out;
60
99
  }
61
- // From start/end → "DD-MM-YYYY – DD-MM-YYYY" (+ optional time)
62
- export function toMaskedRange(start, end, enableTime) {
63
- if (start && end)
64
- return `${toMaskedFromDate(start, enableTime)} – ${toMaskedFromDate(end, enableTime)}`;
65
- if (start)
66
- return `${toMaskedFromDate(start, enableTime)} –`;
67
- if (end)
68
- return `– ${toMaskedFromDate(end, enableTime)}`;
69
- return '';
100
+ export function toMaskedFromYMD(ymd) {
101
+ const p = parseYMD(ymd);
102
+ if (!p)
103
+ return '';
104
+ return `${pad2(p.d)}-${pad2(p.m)}-${p.y}`;
70
105
  }
71
106
  /* ---------- Parsing helpers (no deps) ---------- */
72
107
  // Accepts: YYYY-MM-DD, DD-MM-YYYY, DD/MM/YYYY, DD.MM.YYYY (+ optional HH:mm)
@@ -1,5 +1,5 @@
1
- import React, { ReactNode } from 'react';
2
- import { ModalProps } from '../Modal';
1
+ import React, { type ReactNode } from 'react';
2
+ import { type ModalProps } from '../Modal';
3
3
  type ModalConfig = Omit<ModalProps, 'isOpen' | 'onRequestClose'> & {
4
4
  onRequestClose?: () => void;
5
5
  };
@@ -1,11 +1,14 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { createContext, useCallback, useContext, useState, useRef } from 'react';
3
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react';
4
+ import { createPortal } from 'react-dom';
4
5
  import { Modal } from '../Modal';
5
6
  const ModalContext = createContext(undefined);
6
7
  export function ModalProvider({ children }) {
7
8
  const [isOpen, setIsOpen] = useState(false);
8
9
  const [config, setConfig] = useState(null);
10
+ const [mounted, setMounted] = useState(false);
11
+ useEffect(() => setMounted(true), []);
9
12
  // Holds the resolver for the current "confirm" call, if any
10
13
  const pendingResolverRef = useRef(null);
11
14
  const resolvePending = useCallback((value) => {
@@ -24,47 +27,43 @@ export function ModalProvider({ children }) {
24
27
  setIsOpen(true);
25
28
  }, [resolvePending]);
26
29
  const handleRequestClose = useCallback(() => {
27
- if (config === null || config === void 0 ? void 0 : config.onRequestClose) {
28
- config.onRequestClose();
29
- }
30
- // Any non-explicit confirm click counts as "false"
30
+ var _a;
31
+ (_a = config === null || config === void 0 ? void 0 : config.onRequestClose) === null || _a === void 0 ? void 0 : _a.call(config);
31
32
  resolvePending(false);
32
33
  closeModal();
33
34
  }, [config, closeModal, resolvePending]);
34
35
  const confirm = useCallback((confirmConfig) => {
35
36
  return new Promise(resolve => {
36
- // cancel any previous pending confirm
37
37
  resolvePending(false);
38
38
  pendingResolverRef.current = resolve;
39
39
  const { confirmLabel = 'Ok', cancelLabel = 'Annuller', ...rest } = confirmConfig;
40
- const primaryAction = {
41
- label: confirmLabel,
42
- onClick: () => {
43
- resolvePending(true);
44
- closeModal();
45
- },
46
- };
47
- const secondaryAction = {
48
- label: cancelLabel,
49
- onClick: () => {
50
- resolvePending(false);
51
- closeModal();
52
- },
53
- };
54
40
  setConfig({
55
41
  ...rest,
56
- primaryAction,
57
- secondaryAction,
42
+ primaryAction: {
43
+ label: confirmLabel,
44
+ onClick: () => {
45
+ resolvePending(true);
46
+ closeModal();
47
+ },
48
+ },
49
+ secondaryAction: {
50
+ label: cancelLabel,
51
+ onClick: () => {
52
+ resolvePending(false);
53
+ closeModal();
54
+ },
55
+ },
58
56
  });
59
57
  setIsOpen(true);
60
58
  });
61
59
  }, [closeModal, resolvePending]);
62
- return (_jsxs(ModalContext.Provider, { value: { openModal, closeModal, confirm }, children: [children, _jsx(Modal, { ...(config !== null && config !== void 0 ? config : {}), isOpen: isOpen, onRequestClose: handleRequestClose, children: config === null || config === void 0 ? void 0 : config.children })] }));
60
+ const modalNode = (_jsxs(ModalContext.Provider, { value: { openModal, closeModal, confirm }, children: [children, mounted &&
61
+ createPortal(_jsx(Modal, { ...(config !== null && config !== void 0 ? config : {}), isOpen: isOpen, onRequestClose: handleRequestClose, children: config === null || config === void 0 ? void 0 : config.children }), document.body)] }));
62
+ return modalNode;
63
63
  }
64
64
  export function useModal() {
65
65
  const ctx = useContext(ModalContext);
66
- if (!ctx) {
66
+ if (!ctx)
67
67
  throw new Error('useModal must be used within a ModalProvider');
68
- }
69
68
  return ctx;
70
69
  }
@@ -1,37 +1,49 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { ChevronDown, ChevronUp } from 'lucide-react';
4
- import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from 'react';
5
6
  import styles from './Popover.module.css';
6
7
  export const Popover = forwardRef(function Popover({ trigger: Trigger, children, minWidth = '200px', matchTriggerWidth = true, viewportPadding = 8, edgeBuffer = 100, dataCy, }, ref) {
7
8
  const [pos, setPos] = useState({ top: 0, left: 0, width: 0, visible: false });
8
9
  const containerRef = useRef(null);
9
10
  const contentRef = useRef(null);
11
+ // avoid SSR/hydration mismatch
12
+ const [mounted, setMounted] = useState(false);
13
+ useEffect(() => setMounted(true), []);
10
14
  const computeAndSetPosition = useCallback((show) => {
11
15
  const container = containerRef.current;
12
16
  const content = contentRef.current;
13
17
  if (!container || !content)
14
18
  return;
15
19
  const triggerRect = container.getBoundingClientRect();
20
+ // Temporarily measure content size by forcing it into the layout.
16
21
  const prevVis = content.style.visibility;
17
22
  const prevDisp = content.style.display;
18
23
  const prevMinWidth = content.style.minWidth;
19
24
  const prevWidth = content.style.width;
25
+ const prevTop = content.style.top;
26
+ const prevLeft = content.style.left;
20
27
  content.style.visibility = 'hidden';
21
28
  content.style.display = 'block';
29
+ content.style.top = '0px';
30
+ content.style.left = '0px';
22
31
  content.style.minWidth = minWidth;
23
32
  content.style.width = 'auto';
24
33
  const minWidthPx = content.offsetWidth;
25
34
  const desiredWidthPx = Math.max(matchTriggerWidth ? triggerRect.width : 0, minWidthPx);
26
- // Now apply desired width and re-measure final size (height may depend on width).
35
+ // Apply desired width and re-measure final size (height may depend on width).
27
36
  content.style.minWidth = `${desiredWidthPx}px`;
28
37
  content.style.width = `${desiredWidthPx}px`;
29
38
  const contentWidth = content.offsetWidth;
30
39
  const contentHeight = content.offsetHeight;
40
+ // Restore previous inline styles
31
41
  content.style.visibility = prevVis;
32
42
  content.style.display = prevDisp;
33
43
  content.style.minWidth = prevMinWidth;
34
44
  content.style.width = prevWidth;
45
+ content.style.top = prevTop;
46
+ content.style.left = prevLeft;
35
47
  const vw = window.innerWidth;
36
48
  const vh = window.innerHeight;
37
49
  const spaceAbove = Math.max(0, triggerRect.top);
@@ -75,6 +87,12 @@ export const Popover = forwardRef(function Popover({ trigger: Trigger, children,
75
87
  open: () => computeAndSetPosition(true),
76
88
  isOpen: () => !!pos.visible,
77
89
  }), [closePopover, computeAndSetPosition, pos.visible]);
90
+ // Recompute position after open to account for content becoming visible / measured.
91
+ useLayoutEffect(() => {
92
+ if (pos.visible)
93
+ computeAndSetPosition(true);
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ }, [pos.visible]);
78
96
  useEffect(() => {
79
97
  if (!pos.visible)
80
98
  return;
@@ -102,17 +120,18 @@ export const Popover = forwardRef(function Popover({ trigger: Trigger, children,
102
120
  window.removeEventListener('scroll', handleReposition, true);
103
121
  };
104
122
  }, [pos.visible, closePopover, computeAndSetPosition]);
105
- return (_jsxs("div", { className: styles.container, ref: containerRef, "data-cy": dataCy !== null && dataCy !== void 0 ? dataCy : 'popover-content', children: [Trigger(openPopover, pos.visible ? _jsx(ChevronUp, { size: 20 }) : _jsx(ChevronDown, { size: 20 })), _jsx("div", { ref: contentRef, className: styles.content, style: {
106
- top: pos.top,
107
- left: pos.left,
108
- visibility: pos.visible ? 'visible' : 'hidden',
109
- minWidth: pos.width ? `${pos.width}px` : minWidth,
110
- width: pos.width ? `${pos.width}px` : undefined,
111
- maxWidth: `calc(100vw - ${viewportPadding * 2}px)`,
112
- maxHeight: `clamp(100px, calc(100vh - ${viewportPadding * 2}px), 400px)`,
113
- overflow: 'auto',
114
- }, role: "dialog", "aria-hidden": !pos.visible, children: typeof children === 'function'
115
- ? children(closePopover)
116
- : children })] }));
123
+ return (_jsxs("div", { className: styles.container, ref: containerRef, children: [Trigger(openPopover, pos.visible ? _jsx(ChevronUp, { size: 20 }) : _jsx(ChevronDown, { size: 20 })), mounted &&
124
+ createPortal(_jsx("div", { ref: contentRef, className: styles.content, style: {
125
+ top: pos.top,
126
+ left: pos.left,
127
+ visibility: pos.visible ? 'visible' : 'hidden',
128
+ minWidth: pos.width ? `${pos.width}px` : minWidth,
129
+ width: pos.width ? `${pos.width}px` : undefined,
130
+ maxWidth: `calc(100vw - ${viewportPadding * 2}px)`,
131
+ maxHeight: `clamp(100px, calc(100vh - ${viewportPadding * 2}px), 400px)`,
132
+ overflow: 'auto',
133
+ }, role: "dialog", "aria-hidden": !pos.visible, "data-cy": dataCy !== null && dataCy !== void 0 ? dataCy : 'popover-content', children: typeof children === 'function'
134
+ ? children(closePopover)
135
+ : children }), document.body)] }));
117
136
  });
118
137
  Popover.displayName = 'Popover';
@@ -4,15 +4,11 @@
4
4
 
5
5
  .content {
6
6
  position: fixed;
7
- top: 100%;
8
- left: 0;
9
7
  border: 1px solid var(--color-border-default);
10
8
  background-color: var(--color-bg-surface);
11
9
  border-radius: var(--border-radius-default);
12
10
  padding: var(--spacing-sm) 0;
13
11
  z-index: var(--z-popover);
14
- max-width: 80vw;
15
- max-height: 80vh;
16
12
  overflow: auto;
17
13
  box-shadow: var(--shadow-md);
18
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbcdk/react-components",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Reusable React components for DBC projects",
5
5
  "license": "ISC",
6
6
  "author": "",