@dbcdk/react-components 0.0.67 → 0.0.68

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.
@@ -0,0 +1,11 @@
1
+ import type { JSX } from 'react';
2
+ export interface JsonViewerProps {
3
+ value: unknown;
4
+ defaultExpandedDepth?: number;
5
+ expandAll?: boolean;
6
+ helperText?: string;
7
+ searchPlaceholder?: string;
8
+ emptySearchText?: string;
9
+ className?: string;
10
+ }
11
+ export declare function JsonViewer({ value, defaultExpandedDepth, expandAll, helperText, searchPlaceholder, emptySearchText, className, }: JsonViewerProps): JSX.Element;
@@ -0,0 +1,199 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Check, ChevronDown, ChevronRight, Copy, Search, X } from 'lucide-react';
4
+ import { Fragment, useEffect, useId, useMemo, useState } from 'react';
5
+ import styles from './JsonViewer.module.css';
6
+ function isJsonArray(value) {
7
+ return Array.isArray(value);
8
+ }
9
+ function isJsonObject(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ }
12
+ function getValueType(value) {
13
+ if (value === null)
14
+ return 'null';
15
+ if (Array.isArray(value))
16
+ return 'array';
17
+ if (typeof value === 'object')
18
+ return 'object';
19
+ if (typeof value === 'string')
20
+ return 'string';
21
+ if (typeof value === 'number')
22
+ return 'number';
23
+ if (typeof value === 'boolean')
24
+ return 'boolean';
25
+ return 'string';
26
+ }
27
+ function formatPrimitive(value) {
28
+ if (value === null)
29
+ return 'null';
30
+ if (typeof value === 'string')
31
+ return JSON.stringify(value);
32
+ return String(value);
33
+ }
34
+ function getPrimitiveRawValue(value) {
35
+ if (value === null)
36
+ return 'null';
37
+ return String(value);
38
+ }
39
+ function getSummary(value) {
40
+ if (isJsonArray(value)) {
41
+ return `${value.length} element${value.length === 1 ? '' : 'er'}`;
42
+ }
43
+ if (!isJsonObject(value))
44
+ return '0 felter';
45
+ const count = Object.keys(value).length;
46
+ return `${count} felt${count === 1 ? '' : 'er'}`;
47
+ }
48
+ function getNodeId(path) {
49
+ return path.length === 0 ? '$' : `$.${path.join('.')}`;
50
+ }
51
+ function getPathLabel(path) {
52
+ return getNodeId(path);
53
+ }
54
+ function normalizeSearch(value) {
55
+ return value.trim().toLowerCase();
56
+ }
57
+ function valueToSearchString(value) {
58
+ return value === null ? 'null' : String(value).toLowerCase();
59
+ }
60
+ function collectSearchMatches(value, query, path = [], key) {
61
+ const matches = new Map();
62
+ const visit = (current, currentPath, currentKey) => {
63
+ const nodeId = getNodeId(currentPath);
64
+ const pathLabel = getPathLabel(currentPath).toLowerCase();
65
+ const keyLabel = (currentKey !== null && currentKey !== void 0 ? currentKey : '').toLowerCase();
66
+ let self = keyLabel.includes(query) || pathLabel.includes(query);
67
+ let descendant = false;
68
+ if (isJsonArray(current)) {
69
+ current.forEach((item, index) => {
70
+ const childMatches = visit(item, [...currentPath, String(index)], String(index));
71
+ descendant = descendant || childMatches;
72
+ });
73
+ }
74
+ else if (isJsonObject(current)) {
75
+ Object.entries(current).forEach(([childKey, childValue]) => {
76
+ const childMatches = visit(childValue, [...currentPath, childKey], childKey);
77
+ descendant = descendant || childMatches;
78
+ });
79
+ }
80
+ else {
81
+ self = self || valueToSearchString(current).includes(query);
82
+ }
83
+ const any = self || descendant;
84
+ if (any) {
85
+ matches.set(nodeId, { self, descendant });
86
+ }
87
+ return any;
88
+ };
89
+ visit(value, path, key);
90
+ return matches;
91
+ }
92
+ function getHighlightedSegments(text, query) {
93
+ if (!query)
94
+ return [{ text, matched: false }];
95
+ const lower = text.toLowerCase();
96
+ const segments = [];
97
+ let start = 0;
98
+ while (start < text.length) {
99
+ const index = lower.indexOf(query, start);
100
+ if (index === -1) {
101
+ segments.push({ text: text.slice(start), matched: false });
102
+ break;
103
+ }
104
+ if (index > start) {
105
+ segments.push({ text: text.slice(start, index), matched: false });
106
+ }
107
+ segments.push({ text: text.slice(index, index + query.length), matched: true });
108
+ start = index + query.length;
109
+ }
110
+ return segments.length > 0 ? segments : [{ text, matched: false }];
111
+ }
112
+ function HighlightText({ text, query }) {
113
+ return getHighlightedSegments(text, query).map((segment, index) => segment.matched ? (_jsx("mark", { className: styles.highlight, children: segment.text }, `${segment.text}-${index}`)) : (_jsx(Fragment, { children: segment.text }, `${segment.text}-${index}`)));
114
+ }
115
+ function useClipboardStatus() {
116
+ const [copiedId, setCopiedId] = useState(null);
117
+ useEffect(() => {
118
+ if (!copiedId)
119
+ return;
120
+ const timer = window.setTimeout(() => setCopiedId(null), 1400);
121
+ return () => window.clearTimeout(timer);
122
+ }, [copiedId]);
123
+ return { copiedId, setCopiedId };
124
+ }
125
+ function isJsonValue(value) {
126
+ if (value === null)
127
+ return true;
128
+ if (Array.isArray(value))
129
+ return value.every(isJsonValue);
130
+ if (typeof value === 'object') {
131
+ return Object.values(value).every(isJsonValue);
132
+ }
133
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
134
+ }
135
+ export function JsonViewer({ value, defaultExpandedDepth = 2, expandAll = false, helperText, searchPlaceholder = 'Søg i nøgler, stier og værdier', emptySearchText = 'Ingen matchende noder fundet.', className, }) {
136
+ const [query, setQuery] = useState('');
137
+ const [globalExpandMode, setGlobalExpandMode] = useState(expandAll ? 'expanded' : 'default');
138
+ const normalizedQuery = normalizeSearch(query);
139
+ const inputId = useId();
140
+ const { copiedId, setCopiedId } = useClipboardStatus();
141
+ const safeValue = useMemo(() => {
142
+ if (isJsonValue(value))
143
+ return value;
144
+ return {
145
+ unsupported: {
146
+ besked: 'Den angivne værdi kan ikke vises som JSON',
147
+ modtagetType: typeof value,
148
+ },
149
+ };
150
+ }, [value]);
151
+ const matches = useMemo(() => (normalizedQuery ? collectSearchMatches(safeValue, normalizedQuery) : new Map()), [normalizedQuery, safeValue]);
152
+ const hasResults = normalizedQuery ? matches.size > 0 : true;
153
+ const handleCopy = async (nodeId, rawValue) => {
154
+ try {
155
+ await navigator.clipboard.writeText(rawValue);
156
+ setCopiedId(nodeId);
157
+ }
158
+ catch {
159
+ setCopiedId(null);
160
+ }
161
+ };
162
+ return (_jsxs("section", { className: [styles.viewer, className].filter(Boolean).join(' '), children: [_jsxs("div", { className: styles.toolbar, children: [_jsxs("div", { className: styles.toolbarTop, children: [_jsxs("label", { htmlFor: inputId, className: styles.searchField, children: [_jsx(Search, { className: styles.searchIcon, "aria-hidden": "true" }), _jsx("input", { id: inputId, type: "search", value: query, onChange: event => setQuery(event.target.value), placeholder: searchPlaceholder, className: styles.searchInput }), query ? (_jsx("button", { type: "button", className: styles.clearButton, "aria-label": "Ryd s\u00F8gning", onClick: () => setQuery(''), children: _jsx(X, { "aria-hidden": "true" }) })) : null] }), _jsxs("div", { className: styles.actions, children: [_jsx("button", { type: "button", className: styles.toolbarButton, onClick: () => setGlobalExpandMode('expanded'), disabled: Boolean(normalizedQuery), children: "Fold alle ud" }), _jsx("button", { type: "button", className: styles.toolbarButton, onClick: () => setGlobalExpandMode('collapsed'), disabled: Boolean(normalizedQuery), children: "Fold alle sammen" })] })] }), _jsxs("div", { className: styles.metaRow, children: [helperText ? _jsx("span", { className: styles.helperText, children: helperText }) : _jsx("span", {}), _jsx("span", { className: styles.statusText, children: normalizedQuery
163
+ ? `${matches.size} matchende node${matches.size === 1 ? '' : 'r'}`
164
+ : '' })] })] }), _jsx("div", { className: styles.treePane, role: "tree", "aria-label": "JSON-visning", children: hasResults ? (_jsx(JsonNode, { keyName: undefined, path: [], value: safeValue, depth: 0, defaultExpandedDepth: defaultExpandedDepth, query: normalizedQuery, matches: matches, globalExpandMode: globalExpandMode, copiedId: copiedId, onCopy: handleCopy })) : (_jsx("div", { className: styles.emptyState, children: emptySearchText })) })] }));
165
+ }
166
+ function JsonNode({ keyName, path, value, depth, defaultExpandedDepth, query, matches, globalExpandMode, copiedId, onCopy, }) {
167
+ const nodeId = getNodeId(path);
168
+ const nodeType = getValueType(value);
169
+ const match = matches.get(nodeId);
170
+ const shouldRender = query ? Boolean(match) : true;
171
+ const forceOpen = query ? Boolean(match === null || match === void 0 ? void 0 : match.descendant) : false;
172
+ const [isExpanded, setIsExpanded] = useState(() => depth < defaultExpandedDepth);
173
+ if (!shouldRender)
174
+ return null;
175
+ if (nodeType !== 'array' && nodeType !== 'object') {
176
+ const primitive = value;
177
+ const valueLabel = formatPrimitive(primitive);
178
+ const rawValue = getPrimitiveRawValue(primitive);
179
+ const copied = copiedId === nodeId;
180
+ return (_jsxs("div", { className: styles.node, style: { ['--json-indent']: depth }, children: [_jsx("div", { className: styles.row, "data-match": (match === null || match === void 0 ? void 0 : match.self) || undefined, children: _jsxs("button", { type: "button", className: styles.valueButton, onClick: () => onCopy(nodeId, rawValue), title: copied ? 'Kopieret' : 'Kopiér værdi', children: [_jsxs("span", { className: styles.rowLabel, children: [keyName !== undefined ? (_jsxs("span", { className: styles.keyChunk, children: [_jsx("span", { className: styles.key, children: _jsx(HighlightText, { text: keyName, query: query }) }), _jsx("span", { className: styles.punctuation, children: ":" })] })) : (_jsx("span", { className: styles.rootLabel, children: "$" })), _jsx("span", { className: [styles.value, styles[`value${capitalize(nodeType)}`]].join(' '), children: _jsx(HighlightText, { text: valueLabel, query: query }) })] }), _jsxs("span", { className: styles.copyState, "aria-live": "polite", children: [copied ? _jsx(Check, { "aria-hidden": "true" }) : _jsx(Copy, { "aria-hidden": "true" }), _jsx("span", { children: copied ? 'Kopieret' : 'Kopiér' })] })] }) }), query && !(match === null || match === void 0 ? void 0 : match.self) ? (_jsx("div", { className: styles.pathHint, children: _jsx(HighlightText, { text: getPathLabel(path), query: query }) })) : null] }));
181
+ }
182
+ const entries = isJsonArray(value)
183
+ ? value.map((item, index) => [String(index), item])
184
+ : isJsonObject(value)
185
+ ? Object.entries(value)
186
+ : [];
187
+ const summary = getSummary(value);
188
+ const expanded = query || forceOpen
189
+ ? true
190
+ : globalExpandMode === 'expanded'
191
+ ? true
192
+ : globalExpandMode === 'collapsed'
193
+ ? false
194
+ : isExpanded;
195
+ return (_jsxs("div", { className: styles.node, style: { ['--json-indent']: depth }, children: [_jsx("div", { className: styles.row, "data-match": (match === null || match === void 0 ? void 0 : match.self) || undefined, children: _jsxs("button", { type: "button", className: styles.toggle, onClick: () => setIsExpanded(current => !current), "aria-expanded": expanded, children: [expanded ? _jsx(ChevronDown, { "aria-hidden": "true" }) : _jsx(ChevronRight, { "aria-hidden": "true" }), _jsxs("span", { className: styles.rowLabel, children: [keyName !== undefined ? (_jsxs("span", { className: styles.keyChunk, children: [_jsx("span", { className: styles.key, children: _jsx(HighlightText, { text: keyName, query: query }) }), _jsx("span", { className: styles.punctuation, children: ":" })] })) : (_jsx("span", { className: styles.rootLabel, children: "$" })), _jsx("span", { className: styles.bracket, children: isJsonArray(value) ? '[' : '{' }), _jsx("span", { className: styles.summary, children: _jsx(HighlightText, { text: summary, query: query }) }), _jsx("span", { className: styles.bracket, children: isJsonArray(value) ? ']' : '}' })] })] }) }), query && !(match === null || match === void 0 ? void 0 : match.self) ? (_jsx("div", { className: styles.pathHint, children: _jsx(HighlightText, { text: getPathLabel(path), query: query }) })) : null, expanded ? (_jsx("div", { className: styles.children, role: depth === 0 ? 'group' : undefined, children: entries.map(([childKey, childValue]) => (_jsx(JsonNode, { keyName: isJsonArray(value) ? `[${childKey}]` : childKey, path: [...path, childKey], value: childValue, depth: depth + 1, defaultExpandedDepth: defaultExpandedDepth, query: query, matches: matches, globalExpandMode: globalExpandMode, copiedId: copiedId, onCopy: onCopy }, `${nodeId}-${childKey}`))) })) : null] }));
196
+ }
197
+ function capitalize(value) {
198
+ return value.charAt(0).toUpperCase() + value.slice(1);
199
+ }
@@ -0,0 +1,359 @@
1
+ .viewer {
2
+ --json-pane-bg:
3
+ linear-gradient(
4
+ 180deg,
5
+ color-mix(in oklab, var(--color-bg-inverse) 96%, white 4%),
6
+ color-mix(in oklab, var(--color-bg-inverse) 92%, black 8%)
7
+ );
8
+ --json-pane-border: color-mix(in oklab, var(--color-fg-inverse) 14%, transparent);
9
+ --json-pane-border-strong: color-mix(in oklab, var(--color-fg-inverse) 24%, transparent);
10
+ --json-pane-text: var(--color-fg-inverse);
11
+ --json-pane-muted: color-mix(in oklab, var(--color-fg-inverse) 64%, transparent);
12
+ --json-pane-subtle: color-mix(in oklab, var(--color-fg-inverse) 44%, transparent);
13
+ --json-pane-hover: color-mix(in oklab, var(--color-fg-inverse) 8%, transparent);
14
+ --json-pane-selected: color-mix(in oklab, var(--color-brand) 24%, transparent);
15
+ --json-pane-highlight: color-mix(in oklab, var(--color-brand) 68%, var(--dbc-blue-300) 32%);
16
+ --json-pane-key: color-mix(in oklab, var(--color-fg-inverse) 88%, var(--dbc-blue-300) 12%);
17
+ --json-pane-string: color-mix(in oklab, var(--dbc-green-300) 78%, white 22%);
18
+ --json-pane-number: color-mix(in oklab, var(--dbc-amber-400) 76%, white 24%);
19
+ --json-pane-boolean: color-mix(in oklab, var(--dbc-pink-500) 74%, white 26%);
20
+ --json-pane-null: color-mix(in oklab, var(--color-fg-inverse) 54%, transparent);
21
+
22
+ display: flex;
23
+ flex-direction: column;
24
+ gap: var(--spacing-sm);
25
+ background: var(--json-pane-bg);
26
+ color: var(--json-pane-text);
27
+ border: var(--border-width-thin) solid var(--json-pane-border);
28
+ border-radius: var(--border-radius-xl);
29
+ box-shadow: var(--shadow-lg);
30
+ padding: var(--spacing-sm);
31
+ font-family: var(--font-family-mono);
32
+ }
33
+
34
+ .toolbar {
35
+ display: grid;
36
+ gap: var(--spacing-xs);
37
+ }
38
+
39
+ .toolbarTop {
40
+ display: grid;
41
+ grid-template-columns: minmax(0, 1fr) auto;
42
+ gap: var(--spacing-sm);
43
+ align-items: center;
44
+ }
45
+
46
+ .searchField {
47
+ display: grid;
48
+ grid-template-columns: auto minmax(0, 1fr) auto;
49
+ align-items: center;
50
+ gap: var(--spacing-xs);
51
+ min-height: var(--component-size-md);
52
+ padding-inline: var(--spacing-sm);
53
+ border: var(--border-width-thin) solid var(--json-pane-border);
54
+ border-radius: var(--border-radius-lg);
55
+ background: color-mix(in oklab, var(--color-bg-inverse) 85%, var(--color-bg-surface) 15%);
56
+ }
57
+
58
+ .searchField:focus-within {
59
+ border-color: var(--json-pane-border-strong);
60
+ box-shadow: var(--focus-ring);
61
+ }
62
+
63
+ .searchIcon,
64
+ .clearButton svg {
65
+ inline-size: var(--icon-size-sm);
66
+ block-size: var(--icon-size-sm);
67
+ color: var(--json-pane-subtle);
68
+ }
69
+
70
+ .searchInput {
71
+ inline-size: 100%;
72
+ min-inline-size: 0;
73
+ min-block-size: var(--component-size-md);
74
+ background: transparent;
75
+ border: 0;
76
+ color: var(--json-pane-text);
77
+ font: inherit;
78
+ outline: none;
79
+ }
80
+
81
+ .searchInput::placeholder {
82
+ color: var(--json-pane-subtle);
83
+ }
84
+
85
+ .clearButton {
86
+ display: inline-flex;
87
+ align-items: center;
88
+ justify-content: center;
89
+ inline-size: var(--component-size-xs);
90
+ block-size: var(--component-size-xs);
91
+ border: 0;
92
+ border-radius: var(--border-radius-round);
93
+ background: transparent;
94
+ cursor: pointer;
95
+ }
96
+
97
+ .clearButton:hover {
98
+ background: var(--json-pane-hover);
99
+ }
100
+
101
+ .metaRow {
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: space-between;
105
+ gap: var(--spacing-md);
106
+ font-size: var(--font-size-xs);
107
+ color: var(--json-pane-muted);
108
+ }
109
+
110
+ .actions {
111
+ display: inline-flex;
112
+ align-items: center;
113
+ gap: var(--spacing-xs);
114
+ }
115
+
116
+ .toolbarButton {
117
+ min-block-size: var(--component-size-sm);
118
+ padding-inline: var(--spacing-sm);
119
+ border: var(--border-width-thin) solid var(--json-pane-border);
120
+ border-radius: var(--border-radius-md);
121
+ background: color-mix(in oklab, var(--color-bg-inverse) 82%, var(--color-bg-surface) 18%);
122
+ color: var(--json-pane-text);
123
+ font: inherit;
124
+ cursor: pointer;
125
+ }
126
+
127
+ .toolbarButton:hover:not(:disabled) {
128
+ background: var(--json-pane-hover);
129
+ }
130
+
131
+ .toolbarButton:focus-visible {
132
+ outline: none;
133
+ box-shadow: var(--focus-ring);
134
+ }
135
+
136
+ .toolbarButton:disabled {
137
+ opacity: 0.45;
138
+ cursor: default;
139
+ }
140
+
141
+ .helperText,
142
+ .statusText {
143
+ min-inline-size: 0;
144
+ }
145
+
146
+ .treePane {
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: var(--spacing-2xs);
150
+ border: var(--border-width-thin) solid var(--json-pane-border);
151
+ border-radius: var(--border-radius-lg);
152
+ background:
153
+ linear-gradient(
154
+ 180deg,
155
+ color-mix(in oklab, var(--color-bg-inverse) 89%, white 11%),
156
+ color-mix(in oklab, var(--color-bg-inverse) 94%, black 6%)
157
+ );
158
+ padding: var(--spacing-sm);
159
+ overflow: auto;
160
+ }
161
+
162
+ .node {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 1px;
166
+ }
167
+
168
+ .row,
169
+ .pathHint {
170
+ padding-left: calc(var(--json-indent) * var(--spacing-md));
171
+ }
172
+
173
+ .row {
174
+ display: flex;
175
+ align-items: flex-start;
176
+ min-inline-size: 0;
177
+ border-radius: var(--border-radius-md);
178
+ }
179
+
180
+ .row[data-match='true'] {
181
+ background: linear-gradient(90deg, var(--json-pane-selected), transparent 70%);
182
+ }
183
+
184
+ .toggle,
185
+ .valueButton {
186
+ display: inline-flex;
187
+ align-items: center;
188
+ gap: var(--spacing-xs);
189
+ min-inline-size: 0;
190
+ inline-size: 100%;
191
+ padding-block: 6px;
192
+ padding-inline: var(--spacing-xs);
193
+ background: transparent;
194
+ border: 0;
195
+ border-radius: var(--border-radius-md);
196
+ color: inherit;
197
+ text-align: left;
198
+ cursor: pointer;
199
+ font: inherit;
200
+ }
201
+
202
+ .toggle {
203
+ justify-content: flex-start;
204
+ }
205
+
206
+ .valueButton {
207
+ justify-content: flex-start;
208
+ }
209
+
210
+ .toggle:hover,
211
+ .valueButton:hover {
212
+ background: var(--json-pane-hover);
213
+ }
214
+
215
+ .toggle:focus-visible,
216
+ .valueButton:focus-visible,
217
+ .clearButton:focus-visible {
218
+ outline: none;
219
+ box-shadow: var(--focus-ring);
220
+ }
221
+
222
+ .toggle svg,
223
+ .copyState svg {
224
+ inline-size: var(--icon-size-sm);
225
+ block-size: var(--icon-size-sm);
226
+ flex: 0 0 auto;
227
+ }
228
+
229
+ .rowLabel {
230
+ display: inline-flex;
231
+ align-items: baseline;
232
+ flex-wrap: nowrap;
233
+ gap: 6px;
234
+ min-inline-size: 0;
235
+ flex: 1 1 auto;
236
+ }
237
+
238
+ .keyChunk {
239
+ display: inline-flex;
240
+ align-items: baseline;
241
+ gap: 4px;
242
+ flex: 0 0 auto;
243
+ }
244
+
245
+ .rootLabel,
246
+ .key {
247
+ color: var(--json-pane-key);
248
+ font-weight: var(--font-weight-medium);
249
+ }
250
+
251
+ .punctuation,
252
+ .bracket {
253
+ color: var(--json-pane-subtle);
254
+ }
255
+
256
+ .summary,
257
+ .pathHint {
258
+ color: var(--json-pane-muted);
259
+ }
260
+
261
+ .children {
262
+ display: flex;
263
+ flex-direction: column;
264
+ gap: 1px;
265
+ position: relative;
266
+ }
267
+
268
+ .children::before {
269
+ content: '';
270
+ position: absolute;
271
+ left: calc(var(--spacing-md) - 2px);
272
+ top: 0;
273
+ bottom: 0;
274
+ width: 1px;
275
+ background: color-mix(in oklab, var(--color-fg-inverse) 10%, transparent);
276
+ pointer-events: none;
277
+ }
278
+
279
+ .value {
280
+ min-inline-size: 0;
281
+ flex: 0 1 auto;
282
+ word-break: break-word;
283
+ }
284
+
285
+ .valueString {
286
+ color: var(--json-pane-string);
287
+ }
288
+
289
+ .valueNumber {
290
+ color: var(--json-pane-number);
291
+ }
292
+
293
+ .valueBoolean {
294
+ color: var(--json-pane-boolean);
295
+ }
296
+
297
+ .valueNull {
298
+ color: var(--json-pane-null);
299
+ }
300
+
301
+ .copyState {
302
+ display: inline-flex;
303
+ align-items: center;
304
+ gap: var(--spacing-xxs);
305
+ margin-left: auto;
306
+ color: var(--json-pane-subtle);
307
+ font-size: var(--font-size-xs);
308
+ white-space: nowrap;
309
+ opacity: 0;
310
+ transform: translateX(4px);
311
+ transition:
312
+ opacity var(--transition-fast) var(--ease-standard),
313
+ transform var(--transition-fast) var(--ease-standard);
314
+ }
315
+
316
+ .valueButton:hover .copyState,
317
+ .valueButton:focus-visible .copyState,
318
+ .valueButton:focus-within .copyState {
319
+ opacity: 1;
320
+ transform: translateX(0);
321
+ }
322
+
323
+ .valueButton[title='Kopieret'] .copyState {
324
+ opacity: 1;
325
+ transform: translateX(0);
326
+ color: color-mix(in oklab, var(--dbc-green-300) 78%, white 22%);
327
+ }
328
+
329
+ .highlight {
330
+ color: var(--color-fg-inverse);
331
+ background: color-mix(in oklab, var(--json-pane-highlight) 78%, transparent);
332
+ border-radius: var(--border-radius-sm);
333
+ padding-inline: 0.1em;
334
+ }
335
+
336
+ .emptyState {
337
+ padding: var(--spacing-md);
338
+ color: var(--json-pane-muted);
339
+ text-align: center;
340
+ }
341
+
342
+ @media (max-width: 640px) {
343
+ .toolbarTop {
344
+ grid-template-columns: 1fr;
345
+ }
346
+
347
+ .actions {
348
+ flex-wrap: wrap;
349
+ }
350
+
351
+ .metaRow {
352
+ flex-direction: column;
353
+ align-items: flex-start;
354
+ }
355
+
356
+ .copyState span {
357
+ display: none;
358
+ }
359
+ }
@@ -11,6 +11,9 @@ export interface MenuProps extends React.HTMLAttributes<HTMLUListElement> {
11
11
  gap?: boolean;
12
12
  }
13
13
  export type MenuSeparatorProps = React.LiHTMLAttributes<HTMLLIElement>;
14
+ export interface MenuHeaderProps extends React.LiHTMLAttributes<HTMLLIElement> {
15
+ children: React.ReactNode;
16
+ }
14
17
  export interface MenuItemProps extends React.LiHTMLAttributes<HTMLLIElement> {
15
18
  children: React.ReactNode;
16
19
  active?: boolean;
@@ -46,4 +49,5 @@ export declare const Menu: React.ForwardRefExoticComponent<MenuProps & React.Ref
46
49
  CheckItem: React.ForwardRefExoticComponent<MenuCheckItemProps & React.RefAttributes<HTMLLIElement>>;
47
50
  RadioItem: React.ForwardRefExoticComponent<MenuRadioItemProps & React.RefAttributes<HTMLLIElement>>;
48
51
  Separator: React.ForwardRefExoticComponent<MenuSeparatorProps & React.RefAttributes<HTMLLIElement>>;
52
+ Header: React.ForwardRefExoticComponent<MenuHeaderProps & React.RefAttributes<HTMLLIElement>>;
49
53
  };
@@ -137,9 +137,12 @@ const MenuRadioItem = React.forwardRef(({ name, value, checked, disabled, label,
137
137
  MenuRadioItem.displayName = 'Menu.RadioItem';
138
138
  const MenuSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx("li", { ref: ref, role: "separator", className: [styles.separator, className].filter(Boolean).join(' '), ...props })));
139
139
  MenuSeparator.displayName = 'Menu.Separator';
140
+ const MenuHeader = React.forwardRef(({ className, children, ...props }, ref) => (_jsx("li", { ref: ref, role: "presentation", "aria-hidden": "true", className: [styles.header, className].filter(Boolean).join(' '), ...props, children: children })));
141
+ MenuHeader.displayName = 'Menu.Header';
140
142
  export const Menu = Object.assign(MenuBase, {
141
143
  Item: MenuItem,
142
144
  CheckItem: MenuCheckItem,
143
145
  RadioItem: MenuRadioItem,
144
146
  Separator: MenuSeparator,
147
+ Header: MenuHeader,
145
148
  });
@@ -174,6 +174,19 @@
174
174
  border-radius: 999px;
175
175
  }
176
176
 
177
+ .header {
178
+ padding-block: var(--spacing-xs);
179
+ padding-inline: var(--spacing-md);
180
+ color: var(--color-fg-muted);
181
+ font-size: var(--font-size-xs);
182
+ font-weight: 500;
183
+ line-height: var(--line-height-normal);
184
+ text-transform: uppercase;
185
+ letter-spacing: 0.04em;
186
+ cursor: default;
187
+ user-select: none;
188
+ }
189
+
177
190
  /* Gap between items */
178
191
  .gap {
179
192
  gap: var(--spacing-xs);
@@ -43,6 +43,18 @@
43
43
  -webkit-overflow-scrolling: touch;
44
44
  }
45
45
 
46
+ /* In document-scrolling vertical layouts, keep the sidebar pinned to the viewport
47
+ and let it manage its own scroll independently of body scrolling. */
48
+ .documentScrolling.vertical .sidebar {
49
+ position: sticky;
50
+ top: 0;
51
+ align-self: start;
52
+ block-size: 100vh;
53
+ block-size: 100dvh;
54
+ overflow: auto;
55
+ -webkit-overflow-scrolling: touch;
56
+ }
57
+
46
58
  /* In horizontal orientation, sidebar becomes a top block if used */
47
59
  .horizontal .sidebar {
48
60
  grid-column: 1 / -1;
@@ -41,9 +41,10 @@ function removeStoredWidth(key) {
41
41
  }
42
42
  export function SidebarContainer({ logo, productLogo, activeLink, version, hideSearch, footer, resizable, defaultWidth = 240, minWidth = 160, storageKey, }) {
43
43
  const { isSidebarCollapsed, handleSidebarCollapseChange } = useSidebar();
44
- const initialStoredWidth = typeof window !== 'undefined' && storageKey ? readStoredWidth(storageKey) : null;
45
- const [sidebarWidth, setSidebarWidth] = useState(() => initialStoredWidth !== null && initialStoredWidth !== void 0 ? initialStoredWidth : defaultWidth);
46
- const [manualWidth, setManualWidth] = useState(() => initialStoredWidth);
44
+ // Always start from defaultWidth so server and client render identical HTML.
45
+ // localStorage is read in a useEffect after hydration to avoid SSR mismatch.
46
+ const [sidebarWidth, setSidebarWidth] = useState(defaultWidth);
47
+ const [manualWidth, setManualWidth] = useState(null);
47
48
  const [isResizing, setIsResizing] = useState(false);
48
49
  const [ariaMaxWidth, setAriaMaxWidth] = useState(undefined);
49
50
  const containerRef = useRef(null);
@@ -61,6 +62,16 @@ export function SidebarContainer({ logo, productLogo, activeLink, version, hideS
61
62
  : Infinity;
62
63
  return Math.max(minWidth, viewportWidth - minWidth);
63
64
  }, [minWidth]);
65
+ // Read stored width after hydration — never during render to avoid SSR mismatch.
66
+ useEffect(() => {
67
+ if (!storageKey)
68
+ return;
69
+ const stored = readStoredWidth(storageKey);
70
+ if (stored === null)
71
+ return;
72
+ setManualWidth(stored);
73
+ setSidebarWidth(stored);
74
+ }, [storageKey]);
64
75
  useEffect(() => {
65
76
  if (!storageKey)
66
77
  return;
package/dist/index.d.ts CHANGED
@@ -46,6 +46,7 @@ export * from './components/split-pane/SplitPane';
46
46
  export * from './components/pagination/Pagination';
47
47
  export * from './components/meta-bar/MetaBar';
48
48
  export * from './components/code-block/CodeBlock';
49
+ export * from './components/json-viewer/JsonViewer';
49
50
  export * from './hooks/useTimeDuration';
50
51
  export * from './hooks/useSorting';
51
52
  export * from './hooks/usePagination';
package/dist/index.js CHANGED
@@ -46,6 +46,7 @@ export * from './components/split-pane/SplitPane';
46
46
  export * from './components/pagination/Pagination';
47
47
  export * from './components/meta-bar/MetaBar';
48
48
  export * from './components/code-block/CodeBlock';
49
+ export * from './components/json-viewer/JsonViewer';
49
50
  export * from './hooks/useTimeDuration';
50
51
  export * from './hooks/useSorting';
51
52
  export * from './hooks/usePagination';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbcdk/react-components",
3
- "version": "0.0.67",
3
+ "version": "0.0.68",
4
4
  "description": "Reusable React components for DBC projects",
5
5
  "license": "ISC",
6
6
  "author": "",
@@ -57,7 +57,7 @@
57
57
  "version-packages": "changeset version",
58
58
  "typecheck": "tsc -p tsconfig.json --noEmit",
59
59
  "pack:local": "rimraf ./*.tgz && npm run build && npm pack",
60
- "postbuild": "cpy \"src/**/*.css\" dist --parents && cpy \"src/styles/styles.css\" dist && cpy \"src/styles/fonts/**/*\" dist/styles/fonts"
60
+ "postbuild": "node scripts/postbuild.mjs"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@tanstack/react-table": "^8.21.3",
File without changes