@diwauhris/ui 1.7.42 → 1.7.44
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/lib/assets/ui.css +12 -0
- package/lib/index.cjs +321 -120
- package/lib/index.mjs +322 -121
- package/lib/types/ui-library/gallery/org-chart/OrgChart.d.ts +27 -11
- package/package.json +1 -1
package/lib/assets/ui.css
CHANGED
|
@@ -514,6 +514,11 @@
|
|
|
514
514
|
margin-right: auto;
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
.my-0\.5 {
|
|
518
|
+
margin-top: 0.125rem;
|
|
519
|
+
margin-bottom: 0.125rem;
|
|
520
|
+
}
|
|
521
|
+
|
|
517
522
|
.-mr-1 {
|
|
518
523
|
margin-right: -0.25rem;
|
|
519
524
|
}
|
|
@@ -1207,6 +1212,10 @@
|
|
|
1207
1212
|
min-width: 240px;
|
|
1208
1213
|
}
|
|
1209
1214
|
|
|
1215
|
+
.min-w-\[3rem\] {
|
|
1216
|
+
min-width: 3rem;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1210
1219
|
.min-w-\[400px\] {
|
|
1211
1220
|
min-width: 400px;
|
|
1212
1221
|
}
|
|
@@ -5136,6 +5145,9 @@ body {
|
|
|
5136
5145
|
--tw-shadow-colored: 0 0 #0000;
|
|
5137
5146
|
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
|
5138
5147
|
}
|
|
5148
|
+
.disabled\:hover\:bg-transparent:hover:disabled {
|
|
5149
|
+
background-color: transparent;
|
|
5150
|
+
}
|
|
5139
5151
|
.disabled\:active\:scale-100:active:disabled {
|
|
5140
5152
|
--tw-scale-x: 1;
|
|
5141
5153
|
--tw-scale-y: 1;
|
package/lib/index.cjs
CHANGED
|
@@ -747,12 +747,18 @@ function CustomDropdown({ items, value, placeholder, status, disabled, renderOpt
|
|
|
747
747
|
(0, react.useEffect)(() => {
|
|
748
748
|
if (!open || !triggerRef.current) return;
|
|
749
749
|
const rect = triggerRef.current.getBoundingClientRect();
|
|
750
|
+
const MARGIN = 8;
|
|
751
|
+
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
|
752
|
+
const spaceAbove = rect.top - MARGIN;
|
|
753
|
+
const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
|
|
754
|
+
const maxHeight = Math.min(240, Math.max(120, openUp ? spaceAbove : spaceBelow));
|
|
750
755
|
setPanelStyle({
|
|
751
756
|
position: "fixed",
|
|
752
|
-
top: rect.bottom + 4,
|
|
757
|
+
...openUp ? { bottom: window.innerHeight - rect.top + 4 } : { top: rect.bottom + 4 },
|
|
753
758
|
left: rect.left,
|
|
754
759
|
zIndex: 9999,
|
|
755
|
-
width: rect.width
|
|
760
|
+
width: rect.width,
|
|
761
|
+
maxHeight
|
|
756
762
|
});
|
|
757
763
|
}, [open]);
|
|
758
764
|
const handleSelect = (0, react.useCallback)((opt) => {
|
|
@@ -855,7 +861,7 @@ function CustomDropdown({ items, value, placeholder, status, disabled, renderOpt
|
|
|
855
861
|
role: "listbox",
|
|
856
862
|
"aria-labelledby": effectiveTriggerId,
|
|
857
863
|
style: panelStyle,
|
|
858
|
-
className: "overflow-y-auto
|
|
864
|
+
className: "overflow-y-auto rounded-xl border border-slate-200 bg-white py-1 shadow-xl",
|
|
859
865
|
children: flatItems.map((fi, i) => {
|
|
860
866
|
if (fi.type === "group") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
861
867
|
role: "presentation",
|
|
@@ -6206,25 +6212,30 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
|
|
|
6206
6212
|
* - 250 × 84 px rounded rectangles (rx=14)
|
|
6207
6213
|
* - Root (depth 0): dark navy fill (#0f172a), white text
|
|
6208
6214
|
* - Children: white fill (#fff), slate border (#e2e8f0)
|
|
6215
|
+
* - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
|
|
6209
6216
|
* - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
|
|
6210
|
-
* - Name: 13px, font-weight 800, centered
|
|
6217
|
+
* - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
|
|
6211
6218
|
* - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
|
|
6212
|
-
* - Selected: indigo border overlay ring
|
|
6219
|
+
* - Hover: indigo-300 border; Selected: indigo border + overlay ring
|
|
6213
6220
|
* Connectors:
|
|
6214
6221
|
* - Stepped orthogonal path: M→V→H→V
|
|
6215
6222
|
* - Stroke: #cbd5e1, stroke-width 1.5
|
|
6216
6223
|
* Canvas:
|
|
6217
|
-
* -
|
|
6218
|
-
*
|
|
6219
|
-
*
|
|
6224
|
+
* - Fits the tree to the viewport until the user zooms or pans; after that
|
|
6225
|
+
* the user's view is kept across re-renders, data refreshes and resizes
|
|
6226
|
+
* (e.g. a modal hiding the page scrollbar). "Fit to screen" or a new
|
|
6227
|
+
* `fitKey` hands control back to auto-fit.
|
|
6228
|
+
* - Zoom: scaleExtent [0.05, 1.8]; wheel / pinch / buttons / keyboard
|
|
6229
|
+
* - Pan: mouse/touch drag (a drag never counts as a node click)
|
|
6220
6230
|
* - Layout: nodeSize [280, 185] — horizontal × vertical spacing
|
|
6221
6231
|
* Controls:
|
|
6222
|
-
* -
|
|
6232
|
+
* - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
|
|
6223
6233
|
*
|
|
6224
6234
|
* Accessibility:
|
|
6225
|
-
* - SVG has role="img"
|
|
6226
|
-
* -
|
|
6227
|
-
* - Keyboard:
|
|
6235
|
+
* - SVG has an aria-label; role="img" when read-only, role="group" when nodes are selectable
|
|
6236
|
+
* - Selectable nodes are focusable buttons (Tab, Enter / Space to select)
|
|
6237
|
+
* - Keyboard on the chart: + / = zoom in, - zoom out, 0 fit to screen
|
|
6238
|
+
* - Respects prefers-reduced-motion for button zoom transitions
|
|
6228
6239
|
* - Note: full ARIA tree semantics are not possible in an SVG diagram;
|
|
6229
6240
|
* use OrgUnitTree for a fully keyboard-accessible list-based alternative
|
|
6230
6241
|
*
|
|
@@ -6236,6 +6247,13 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
|
|
|
6236
6247
|
* aria-label="Company organization chart"
|
|
6237
6248
|
* />
|
|
6238
6249
|
*/
|
|
6250
|
+
var SYNTHETIC_ROOT_ID = "__synthetic_root__";
|
|
6251
|
+
var NODE_W = 250;
|
|
6252
|
+
var NODE_H = 84;
|
|
6253
|
+
var TEXT_MAX_W = 218;
|
|
6254
|
+
var MIN_SCALE = .05;
|
|
6255
|
+
var MAX_SCALE = 1.8;
|
|
6256
|
+
var BUTTON_ZOOM_STEP = 1.2;
|
|
6239
6257
|
function buildD3Tree(nodes) {
|
|
6240
6258
|
function convert(node, depth) {
|
|
6241
6259
|
return {
|
|
@@ -6244,161 +6262,344 @@ function buildD3Tree(nodes) {
|
|
|
6244
6262
|
description: node.description ?? "",
|
|
6245
6263
|
isRoot: depth === 0,
|
|
6246
6264
|
isSyntheticRoot: false,
|
|
6265
|
+
isMuted: node.tone === "muted",
|
|
6247
6266
|
originalId: node.id,
|
|
6248
6267
|
children: node.children?.map((c) => convert(c, depth + 1))
|
|
6249
6268
|
};
|
|
6250
6269
|
}
|
|
6251
6270
|
if (nodes.length === 1) return convert(nodes[0], 0);
|
|
6252
6271
|
return {
|
|
6253
|
-
id:
|
|
6272
|
+
id: SYNTHETIC_ROOT_ID,
|
|
6254
6273
|
name: "",
|
|
6255
6274
|
description: "",
|
|
6256
6275
|
isRoot: true,
|
|
6257
6276
|
isSyntheticRoot: true,
|
|
6258
|
-
|
|
6277
|
+
isMuted: false,
|
|
6278
|
+
originalId: SYNTHETIC_ROOT_ID,
|
|
6259
6279
|
children: nodes.map((n) => convert(n, 0))
|
|
6260
6280
|
};
|
|
6261
6281
|
}
|
|
6262
|
-
|
|
6282
|
+
/** Writes `text` into an SVG text element, truncating with an ellipsis to fit `maxWidth`. */
|
|
6283
|
+
function fitText(el, text, maxWidth, fontSize) {
|
|
6284
|
+
el.textContent = text;
|
|
6285
|
+
if (!text) return;
|
|
6286
|
+
const measured = typeof el.getComputedTextLength === "function" ? el.getComputedTextLength() : 0;
|
|
6287
|
+
if (!measured) {
|
|
6288
|
+
const maxChars = Math.max(1, Math.floor(maxWidth / (fontSize * .6)));
|
|
6289
|
+
if (text.length > maxChars) el.textContent = `${text.slice(0, maxChars - 1).trimEnd()}…`;
|
|
6290
|
+
return;
|
|
6291
|
+
}
|
|
6292
|
+
if (measured <= maxWidth) return;
|
|
6293
|
+
let lo = 0;
|
|
6294
|
+
let hi = text.length - 1;
|
|
6295
|
+
while (lo < hi) {
|
|
6296
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
6297
|
+
el.textContent = `${text.slice(0, mid).trimEnd()}…`;
|
|
6298
|
+
if (el.getComputedTextLength() <= maxWidth) lo = mid;
|
|
6299
|
+
else hi = mid - 1;
|
|
6300
|
+
}
|
|
6301
|
+
el.textContent = `${text.slice(0, lo).trimEnd()}…`;
|
|
6302
|
+
}
|
|
6303
|
+
var prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
6304
|
+
function OrgChart({ nodes, selectedId, onSelect, fitKey, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
|
|
6263
6305
|
const svgRef = (0, react.useRef)(null);
|
|
6264
6306
|
const zoomRef = (0, react.useRef)(null);
|
|
6265
|
-
const initialTransformRef = (0, react.useRef)(null);
|
|
6266
6307
|
const treeBoundsRef = (0, react.useRef)(null);
|
|
6267
|
-
const
|
|
6308
|
+
const autoFitRef = (0, react.useRef)(true);
|
|
6309
|
+
const programmaticRef = (0, react.useRef)(false);
|
|
6310
|
+
const pendingFitRef = (0, react.useRef)(false);
|
|
6311
|
+
const lastSizeRef = (0, react.useRef)(null);
|
|
6312
|
+
const onSelectRef = (0, react.useRef)(onSelect);
|
|
6313
|
+
const selectedIdRef = (0, react.useRef)(selectedId);
|
|
6314
|
+
const scaleFrameRef = (0, react.useRef)(0);
|
|
6315
|
+
const [scale, setScale] = (0, react.useState)(1);
|
|
6316
|
+
const uid = `oc-${(0, react.useId)().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
6317
|
+
const interactive = Boolean(onSelect);
|
|
6318
|
+
const isEmpty = nodes.length === 0;
|
|
6319
|
+
const getSize = () => {
|
|
6320
|
+
const rect = svgRef.current?.getBoundingClientRect();
|
|
6321
|
+
return {
|
|
6322
|
+
width: rect?.width ?? 0,
|
|
6323
|
+
height: rect?.height ?? 0
|
|
6324
|
+
};
|
|
6325
|
+
};
|
|
6326
|
+
/** Applies a transform without handing control away from auto-fit. */
|
|
6327
|
+
const applyTransform = (t) => {
|
|
6328
|
+
if (!svgRef.current || !zoomRef.current) return;
|
|
6329
|
+
const svg = d3.select(svgRef.current);
|
|
6330
|
+
svg.interrupt();
|
|
6331
|
+
programmaticRef.current = true;
|
|
6332
|
+
try {
|
|
6333
|
+
svg.call(zoomRef.current.transform, t);
|
|
6334
|
+
} finally {
|
|
6335
|
+
programmaticRef.current = false;
|
|
6336
|
+
}
|
|
6337
|
+
};
|
|
6268
6338
|
const fitToView = () => {
|
|
6269
6339
|
if (!svgRef.current || !zoomRef.current || !treeBoundsRef.current) return;
|
|
6270
|
-
const { width: W, height: H } =
|
|
6271
|
-
if (W === 0 || H === 0)
|
|
6340
|
+
const { width: W, height: H } = getSize();
|
|
6341
|
+
if (W === 0 || H === 0) {
|
|
6342
|
+
pendingFitRef.current = true;
|
|
6343
|
+
return;
|
|
6344
|
+
}
|
|
6345
|
+
pendingFitRef.current = false;
|
|
6272
6346
|
const { minX, maxX, minY, maxY } = treeBoundsRef.current;
|
|
6273
|
-
const contentLeft = minX -
|
|
6274
|
-
const contentRight = maxX +
|
|
6275
|
-
const contentTop = minY -
|
|
6276
|
-
const contentBottom = maxY +
|
|
6347
|
+
const contentLeft = minX - NODE_W / 2;
|
|
6348
|
+
const contentRight = maxX + NODE_W / 2;
|
|
6349
|
+
const contentTop = minY - NODE_H / 2;
|
|
6350
|
+
const contentBottom = maxY + NODE_H / 2;
|
|
6277
6351
|
const contentWidth = contentRight - contentLeft;
|
|
6278
6352
|
const contentHeight = contentBottom - contentTop;
|
|
6279
6353
|
const scaleX = (W - 80) / Math.max(1, contentWidth);
|
|
6280
6354
|
const scaleY = (H - 80) / Math.max(1, contentHeight);
|
|
6281
|
-
const
|
|
6282
|
-
const
|
|
6283
|
-
const
|
|
6284
|
-
|
|
6285
|
-
const ty = H / 2 - contentCenterY * scale;
|
|
6286
|
-
const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
|
|
6287
|
-
initialTransformRef.current = t;
|
|
6288
|
-
d3.select(svgRef.current).call(zoomRef.current.transform, t);
|
|
6355
|
+
const k = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scaleX, scaleY));
|
|
6356
|
+
const tx = W / 2 - (contentLeft + contentRight) / 2 * k;
|
|
6357
|
+
const ty = H / 2 - (contentTop + contentBottom) / 2 * k;
|
|
6358
|
+
applyTransform(d3.zoomIdentity.translate(tx, ty).scale(k));
|
|
6289
6359
|
};
|
|
6290
|
-
(
|
|
6291
|
-
|
|
6360
|
+
/** Fit, and go back to keeping the chart fitted (Fit button, new fitKey). */
|
|
6361
|
+
const resetToFit = () => {
|
|
6362
|
+
autoFitRef.current = true;
|
|
6363
|
+
fitToView();
|
|
6364
|
+
};
|
|
6365
|
+
const zoomBy = (factor) => {
|
|
6366
|
+
if (!svgRef.current || !zoomRef.current) return;
|
|
6367
|
+
autoFitRef.current = false;
|
|
6292
6368
|
const svg = d3.select(svgRef.current);
|
|
6293
|
-
svg.
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6369
|
+
if (prefersReducedMotion()) svg.interrupt().call(zoomRef.current.scaleBy, factor);
|
|
6370
|
+
else svg.transition().duration(250).call(zoomRef.current.scaleBy, factor);
|
|
6371
|
+
};
|
|
6372
|
+
(0, react.useEffect)(() => {
|
|
6373
|
+
onSelectRef.current = onSelect;
|
|
6374
|
+
}, [onSelect]);
|
|
6375
|
+
(0, react.useEffect)(() => {
|
|
6376
|
+
const svgEl = svgRef.current;
|
|
6377
|
+
if (!svgEl) return;
|
|
6378
|
+
const svg = d3.select(svgEl);
|
|
6379
|
+
const viewport = svg.append("g").attr("class", "oc-viewport");
|
|
6380
|
+
viewport.append("g").attr("class", "oc-links");
|
|
6381
|
+
viewport.append("g").attr("class", "oc-nodes");
|
|
6382
|
+
const zoom = d3.zoom().scaleExtent([MIN_SCALE, MAX_SCALE]).extent(() => {
|
|
6383
|
+
const { width, height } = getSize();
|
|
6384
|
+
return [[0, 0], [Math.max(1, width), Math.max(1, height)]];
|
|
6385
|
+
}).clickDistance(5).filter((event) => {
|
|
6386
|
+
if (event.type === "dblclick") return false;
|
|
6387
|
+
const e = event;
|
|
6388
|
+
return (!e.ctrlKey || event.type === "wheel") && !e.button;
|
|
6389
|
+
}).on("zoom", (event) => {
|
|
6390
|
+
viewport.attr("transform", event.transform.toString());
|
|
6391
|
+
if (!programmaticRef.current) autoFitRef.current = false;
|
|
6392
|
+
cancelAnimationFrame(scaleFrameRef.current);
|
|
6393
|
+
scaleFrameRef.current = requestAnimationFrame(() => setScale(event.transform.k));
|
|
6394
|
+
});
|
|
6395
|
+
zoomRef.current = zoom;
|
|
6396
|
+
svg.call(zoom);
|
|
6397
|
+
const ro = new ResizeObserver(() => {
|
|
6398
|
+
const size = getSize();
|
|
6399
|
+
if (size.width === 0 || size.height === 0) return;
|
|
6400
|
+
const last = lastSizeRef.current;
|
|
6401
|
+
lastSizeRef.current = size;
|
|
6402
|
+
if (autoFitRef.current || pendingFitRef.current || !last) {
|
|
6403
|
+
fitToView();
|
|
6404
|
+
return;
|
|
6405
|
+
}
|
|
6406
|
+
const dx = (size.width - last.width) / 2;
|
|
6407
|
+
const dy = (size.height - last.height) / 2;
|
|
6408
|
+
if (dx === 0 && dy === 0) return;
|
|
6409
|
+
const t = d3.zoomTransform(svgEl);
|
|
6410
|
+
applyTransform(d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k));
|
|
6411
|
+
});
|
|
6412
|
+
ro.observe(svgEl);
|
|
6413
|
+
return () => {
|
|
6414
|
+
ro.disconnect();
|
|
6415
|
+
cancelAnimationFrame(scaleFrameRef.current);
|
|
6416
|
+
svg.interrupt().on(".zoom", null);
|
|
6417
|
+
svg.selectAll("*").remove();
|
|
6418
|
+
zoomRef.current = null;
|
|
6419
|
+
};
|
|
6420
|
+
}, []);
|
|
6421
|
+
(0, react.useEffect)(() => {
|
|
6422
|
+
const svgEl = svgRef.current;
|
|
6423
|
+
if (!svgEl) return;
|
|
6424
|
+
const svg = d3.select(svgEl);
|
|
6425
|
+
const linkLayer = svg.select("g.oc-links");
|
|
6426
|
+
const nodeLayer = svg.select("g.oc-nodes");
|
|
6427
|
+
if (nodes.length === 0) {
|
|
6428
|
+
linkLayer.selectAll("*").remove();
|
|
6429
|
+
nodeLayer.selectAll("*").remove();
|
|
6430
|
+
treeBoundsRef.current = null;
|
|
6431
|
+
return;
|
|
6432
|
+
}
|
|
6433
|
+
const root = d3.hierarchy(buildD3Tree(nodes));
|
|
6434
|
+
const pointRoot = d3.tree().nodeSize([280, 185])(root);
|
|
6435
|
+
const descendants = pointRoot.descendants();
|
|
6298
6436
|
treeBoundsRef.current = {
|
|
6299
6437
|
minX: d3.min(descendants, (d) => d.x) ?? 0,
|
|
6300
6438
|
maxX: d3.max(descendants, (d) => d.x) ?? 0,
|
|
6301
6439
|
minY: d3.min(descendants, (d) => d.y) ?? 0,
|
|
6302
6440
|
maxY: d3.max(descendants, (d) => d.y) ?? 0
|
|
6303
6441
|
};
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
svg.call(zoom);
|
|
6308
|
-
g.selectAll("path").data(root.links().filter((l) => !l.source.data.isSyntheticRoot)).join("path").attr("fill", "none").attr("stroke", "#cbd5e1").attr("stroke-width", 1.5).attr("d", (link) => {
|
|
6309
|
-
const sx = link.source.x ?? 0;
|
|
6310
|
-
const sy = link.source.y ?? 0;
|
|
6311
|
-
const tx = link.target.x ?? 0;
|
|
6312
|
-
const ty = link.target.y ?? 0;
|
|
6313
|
-
return `M${sx},${sy}V${(sy + ty) / 2}H${tx}V${ty}`;
|
|
6442
|
+
linkLayer.selectAll("path").data(pointRoot.links().filter((l) => !l.source.data.isSyntheticRoot), (l) => `${l.source.data.originalId}>${l.target.data.originalId}`).join("path").attr("fill", "none").attr("stroke", "#cbd5e1").attr("stroke-width", 1.5).attr("d", (link) => {
|
|
6443
|
+
const mid = (link.source.y + link.target.y) / 2;
|
|
6444
|
+
return `M${link.source.x},${link.source.y}V${mid}H${link.target.x}V${link.target.y}`;
|
|
6314
6445
|
});
|
|
6315
|
-
const node =
|
|
6316
|
-
|
|
6317
|
-
|
|
6446
|
+
const node = nodeLayer.selectAll("g.oc-node").data(descendants.filter((d) => !d.data.isSyntheticRoot), (d) => d.data.originalId).join((enter) => {
|
|
6447
|
+
const g = enter.append("g").attr("class", "oc-node");
|
|
6448
|
+
g.append("title");
|
|
6449
|
+
g.append("rect").attr("class", "oc-card").attr("x", -125).attr("y", -42).attr("width", NODE_W).attr("height", NODE_H).attr("rx", 14).attr("filter", "drop-shadow(0 6px 12px rgba(15,23,42,0.08))");
|
|
6450
|
+
g.append("rect").attr("class", "oc-ring").attr("x", -128).attr("y", -45).attr("width", 256).attr("height", 90).attr("rx", 16).attr("fill", "none");
|
|
6451
|
+
g.append("text").attr("class", "oc-name").attr("text-anchor", "middle").attr("y", -8).attr("font-size", 13).attr("font-weight", 800);
|
|
6452
|
+
g.append("text").attr("class", "oc-desc").attr("text-anchor", "middle").attr("y", 16).attr("font-size", 11).attr("font-weight", 600);
|
|
6453
|
+
return g;
|
|
6454
|
+
}).attr("transform", (d) => `translate(${d.x},${d.y})`).classed("is-root", (d) => d.data.isRoot).classed("is-muted", (d) => d.data.isMuted).classed("is-selected", (d) => d.data.originalId === selectedIdRef.current).attr("aria-label", (d) => d.data.description ? `${d.data.name}, ${d.data.description}` : d.data.name).on("click", (_e, d) => onSelectRef.current?.(d.data.originalId)).on("keydown", (event, d) => {
|
|
6455
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
6456
|
+
event.preventDefault();
|
|
6457
|
+
onSelectRef.current?.(d.data.originalId);
|
|
6458
|
+
}).on("focus", (_e, d) => {
|
|
6459
|
+
if (!zoomRef.current) return;
|
|
6460
|
+
const { width: W, height: H } = getSize();
|
|
6461
|
+
const [sx, sy] = d3.zoomTransform(svgEl).apply([d.x, d.y]);
|
|
6462
|
+
const margin = 24;
|
|
6463
|
+
if (sx >= margin && sx <= W - margin && sy >= margin && sy <= H - margin) return;
|
|
6464
|
+
autoFitRef.current = false;
|
|
6465
|
+
svg.interrupt().call(zoomRef.current.translateTo, d.x, d.y);
|
|
6318
6466
|
});
|
|
6319
|
-
node.
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
})
|
|
6323
|
-
node.
|
|
6324
|
-
|
|
6325
|
-
if (selectedId) node.filter((d) => d.data.originalId === selectedId).append("rect").attr("x", -128).attr("y", -45).attr("width", 256).attr("height", 90).attr("rx", 16).attr("fill", "none").attr("stroke", "#6366f1").attr("stroke-width", 2.5).attr("opacity", .6);
|
|
6326
|
-
requestAnimationFrame(() => {
|
|
6327
|
-
fitToView();
|
|
6467
|
+
node.select("title").text((d) => d.data.description ? `${d.data.name}\n${d.data.description}` : d.data.name);
|
|
6468
|
+
node.select("text.oc-name").each(function(d) {
|
|
6469
|
+
fitText(this, d.data.name, TEXT_MAX_W, 13);
|
|
6470
|
+
});
|
|
6471
|
+
node.select("text.oc-desc").each(function(d) {
|
|
6472
|
+
fitText(this, d.data.description, TEXT_MAX_W, 11);
|
|
6328
6473
|
});
|
|
6474
|
+
if (autoFitRef.current || pendingFitRef.current) fitToView();
|
|
6475
|
+
}, [nodes]);
|
|
6476
|
+
(0, react.useEffect)(() => {
|
|
6477
|
+
if (!svgRef.current) return;
|
|
6478
|
+
d3.select(svgRef.current).selectAll("g.oc-node").attr("tabindex", interactive ? 0 : null).attr("role", interactive ? "button" : null);
|
|
6479
|
+
}, [interactive, nodes]);
|
|
6480
|
+
(0, react.useEffect)(() => {
|
|
6481
|
+
selectedIdRef.current = selectedId;
|
|
6482
|
+
if (!svgRef.current) return;
|
|
6483
|
+
d3.select(svgRef.current).selectAll("g.oc-node").classed("is-selected", (d) => d.data.originalId === selectedId).attr("aria-pressed", interactive ? (d) => String(d.data.originalId === selectedId) : null);
|
|
6329
6484
|
}, [
|
|
6330
|
-
nodes,
|
|
6331
6485
|
selectedId,
|
|
6332
|
-
|
|
6486
|
+
interactive,
|
|
6487
|
+
nodes
|
|
6333
6488
|
]);
|
|
6489
|
+
const fitKeyRef = (0, react.useRef)(fitKey);
|
|
6334
6490
|
(0, react.useEffect)(() => {
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
};
|
|
6347
|
-
const resetZoom = () => {
|
|
6348
|
-
fitToView();
|
|
6491
|
+
if (fitKeyRef.current === fitKey) return;
|
|
6492
|
+
fitKeyRef.current = fitKey;
|
|
6493
|
+
resetToFit();
|
|
6494
|
+
}, [fitKey]);
|
|
6495
|
+
const handleKeyDown = (event) => {
|
|
6496
|
+
if (event.ctrlKey || event.metaKey || event.altKey || isEmpty) return;
|
|
6497
|
+
if (event.key === "+" || event.key === "=") zoomBy(BUTTON_ZOOM_STEP);
|
|
6498
|
+
else if (event.key === "-" || event.key === "_") zoomBy(1 / BUTTON_ZOOM_STEP);
|
|
6499
|
+
else if (event.key === "0") resetToFit();
|
|
6500
|
+
else return;
|
|
6501
|
+
event.preventDefault();
|
|
6349
6502
|
};
|
|
6503
|
+
const atMax = scale >= 1.7990000000000002;
|
|
6504
|
+
const atMin = scale <= .051000000000000004;
|
|
6350
6505
|
const controlBtnClass = [
|
|
6351
6506
|
"appearance-none border-0 bg-none font-[inherit]",
|
|
6352
6507
|
"flex items-center justify-center rounded-lg p-2",
|
|
6353
|
-
"text-slate-
|
|
6354
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30"
|
|
6508
|
+
"text-slate-500 transition hover:bg-slate-100 hover:text-slate-700",
|
|
6509
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30",
|
|
6510
|
+
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
|
6355
6511
|
].join(" ");
|
|
6356
6512
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
6357
|
-
className: `relative w-full overflow-hidden bg-slate-50/50 ${className}`,
|
|
6513
|
+
className: `relative w-full overflow-hidden bg-slate-50/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-blue/30 ${className}`,
|
|
6358
6514
|
style: { height },
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6515
|
+
tabIndex: isEmpty ? void 0 : 0,
|
|
6516
|
+
"aria-keyshortcuts": isEmpty ? void 0 : "+ - 0",
|
|
6517
|
+
onKeyDown: handleKeyDown,
|
|
6518
|
+
children: [
|
|
6519
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `
|
|
6520
|
+
#${uid} .oc-card { fill: #fff; stroke: #e2e8f0; stroke-width: 1; transition: stroke 120ms ease; }
|
|
6521
|
+
#${uid} .oc-name { fill: #0f172a; }
|
|
6522
|
+
#${uid} .oc-desc { fill: #64748b; }
|
|
6523
|
+
#${uid} .oc-node.is-root .oc-card { fill: #0f172a; stroke: #0f172a; }
|
|
6524
|
+
#${uid} .oc-node.is-root .oc-name { fill: #fff; }
|
|
6525
|
+
#${uid} .oc-node.is-root .oc-desc { fill: #cbd5e1; }
|
|
6526
|
+
#${uid} .oc-node.is-muted .oc-card { fill: #f8fafc; stroke: #94a3b8; stroke-dasharray: 5 4; }
|
|
6527
|
+
#${uid} .oc-node.is-muted .oc-name { fill: #475569; }
|
|
6528
|
+
#${uid} .oc-node.is-muted .oc-desc { fill: #94a3b8; }
|
|
6529
|
+
#${uid} .oc-node.is-root.is-muted .oc-card { fill: #334155; stroke: #94a3b8; }
|
|
6530
|
+
#${uid} .oc-node.is-root.is-muted .oc-name { fill: #e2e8f0; }
|
|
6531
|
+
#${uid} .oc-node.is-root.is-muted .oc-desc { fill: #cbd5e1; }
|
|
6532
|
+
#${uid} .oc-ring { stroke: #6366f1; stroke-width: 2.5; opacity: 0; pointer-events: none; }
|
|
6533
|
+
#${uid}.is-interactive .oc-node { cursor: pointer; }
|
|
6534
|
+
#${uid}.is-interactive .oc-node:hover .oc-card { stroke: #a5b4fc; stroke-width: 1.5; }
|
|
6535
|
+
#${uid} .oc-node.is-selected .oc-card,
|
|
6536
|
+
#${uid}.is-interactive .oc-node.is-selected:hover .oc-card { stroke: #6366f1; stroke-width: 2.5; }
|
|
6537
|
+
#${uid} .oc-node.is-selected .oc-ring { opacity: 0.6; }
|
|
6538
|
+
#${uid} .oc-node:focus { outline: none; }
|
|
6539
|
+
#${uid} .oc-node:focus-visible .oc-ring { opacity: 1; stroke: #2563eb; }
|
|
6540
|
+
` }),
|
|
6541
|
+
!isEmpty && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
6542
|
+
className: "absolute right-4 top-4 z-10 flex flex-col items-center gap-1 rounded-lg border border-slate-100 bg-white p-1.5 shadow-md",
|
|
6543
|
+
children: [
|
|
6544
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
6545
|
+
type: "button",
|
|
6546
|
+
className: controlBtnClass,
|
|
6547
|
+
onClick: () => zoomBy(BUTTON_ZOOM_STEP),
|
|
6548
|
+
disabled: atMax,
|
|
6549
|
+
"aria-label": "Zoom in",
|
|
6550
|
+
title: "Zoom in (+)",
|
|
6551
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomIn, {
|
|
6552
|
+
size: 16,
|
|
6553
|
+
"aria-hidden": "true"
|
|
6554
|
+
})
|
|
6555
|
+
}),
|
|
6556
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
6557
|
+
className: "min-w-[3rem] select-none text-center text-[11px] font-semibold tabular-nums text-slate-500",
|
|
6558
|
+
children: [Math.round(scale * 100), "%"]
|
|
6559
|
+
}),
|
|
6560
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
6561
|
+
type: "button",
|
|
6562
|
+
className: controlBtnClass,
|
|
6563
|
+
onClick: () => zoomBy(1 / BUTTON_ZOOM_STEP),
|
|
6564
|
+
disabled: atMin,
|
|
6565
|
+
"aria-label": "Zoom out",
|
|
6566
|
+
title: "Zoom out (-)",
|
|
6567
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomOut, {
|
|
6568
|
+
size: 16,
|
|
6569
|
+
"aria-hidden": "true"
|
|
6570
|
+
})
|
|
6571
|
+
}),
|
|
6572
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
6573
|
+
className: "my-0.5 h-px w-6 bg-slate-100",
|
|
6389
6574
|
"aria-hidden": "true"
|
|
6575
|
+
}),
|
|
6576
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
6577
|
+
type: "button",
|
|
6578
|
+
className: controlBtnClass,
|
|
6579
|
+
onClick: resetToFit,
|
|
6580
|
+
"aria-label": "Fit to screen",
|
|
6581
|
+
title: "Fit to screen (0)",
|
|
6582
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Scan, {
|
|
6583
|
+
size: 16,
|
|
6584
|
+
"aria-hidden": "true"
|
|
6585
|
+
})
|
|
6390
6586
|
})
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
|
-
|
|
6587
|
+
]
|
|
6588
|
+
}),
|
|
6589
|
+
isEmpty && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
6590
|
+
className: "absolute inset-0 flex items-center justify-center p-6 text-center text-sm font-semibold text-slate-500",
|
|
6591
|
+
children: "No units to display"
|
|
6592
|
+
}),
|
|
6593
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
6594
|
+
ref: svgRef,
|
|
6595
|
+
id: uid,
|
|
6596
|
+
width: "100%",
|
|
6597
|
+
height: "100%",
|
|
6598
|
+
className: `block touch-none ${interactive ? "is-interactive" : ""}`,
|
|
6599
|
+
role: interactive ? "group" : "img",
|
|
6600
|
+
"aria-label": ariaLabel
|
|
6601
|
+
})
|
|
6602
|
+
]
|
|
6402
6603
|
});
|
|
6403
6604
|
}
|
|
6404
6605
|
//#endregion
|
package/lib/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import * as React$1 from "react";
|
|
|
4
4
|
import React, { cloneElement, createContext, createElement, isValidElement, use, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
5
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
|
-
import { AlertCircle, AlertTriangle, Archive, ArchiveRestore, ArrowDown, Ban, CalendarDays, Check, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clock, Command, File, Home, Info, Minus,
|
|
7
|
+
import { AlertCircle, AlertTriangle, Archive, ArchiveRestore, ArrowDown, Ban, CalendarDays, Check, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clock, Command, File, Home, Info, Minus, Scan, Search as Search$1, ShieldCheck, TrendingDown, TrendingUp, UploadCloud, X, ZoomIn, ZoomOut } from "lucide-react";
|
|
8
8
|
import Calendar$1 from "react-calendar";
|
|
9
9
|
import { format, isValid, parse } from "date-fns";
|
|
10
10
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
@@ -723,12 +723,18 @@ function CustomDropdown({ items, value, placeholder, status, disabled, renderOpt
|
|
|
723
723
|
useEffect(() => {
|
|
724
724
|
if (!open || !triggerRef.current) return;
|
|
725
725
|
const rect = triggerRef.current.getBoundingClientRect();
|
|
726
|
+
const MARGIN = 8;
|
|
727
|
+
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
|
728
|
+
const spaceAbove = rect.top - MARGIN;
|
|
729
|
+
const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
|
|
730
|
+
const maxHeight = Math.min(240, Math.max(120, openUp ? spaceAbove : spaceBelow));
|
|
726
731
|
setPanelStyle({
|
|
727
732
|
position: "fixed",
|
|
728
|
-
top: rect.bottom + 4,
|
|
733
|
+
...openUp ? { bottom: window.innerHeight - rect.top + 4 } : { top: rect.bottom + 4 },
|
|
729
734
|
left: rect.left,
|
|
730
735
|
zIndex: 9999,
|
|
731
|
-
width: rect.width
|
|
736
|
+
width: rect.width,
|
|
737
|
+
maxHeight
|
|
732
738
|
});
|
|
733
739
|
}, [open]);
|
|
734
740
|
const handleSelect = useCallback((opt) => {
|
|
@@ -831,7 +837,7 @@ function CustomDropdown({ items, value, placeholder, status, disabled, renderOpt
|
|
|
831
837
|
role: "listbox",
|
|
832
838
|
"aria-labelledby": effectiveTriggerId,
|
|
833
839
|
style: panelStyle,
|
|
834
|
-
className: "overflow-y-auto
|
|
840
|
+
className: "overflow-y-auto rounded-xl border border-slate-200 bg-white py-1 shadow-xl",
|
|
835
841
|
children: flatItems.map((fi, i) => {
|
|
836
842
|
if (fi.type === "group") return /* @__PURE__ */ jsx("li", {
|
|
837
843
|
role: "presentation",
|
|
@@ -6135,25 +6141,30 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
|
|
|
6135
6141
|
* - 250 × 84 px rounded rectangles (rx=14)
|
|
6136
6142
|
* - Root (depth 0): dark navy fill (#0f172a), white text
|
|
6137
6143
|
* - Children: white fill (#fff), slate border (#e2e8f0)
|
|
6144
|
+
* - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
|
|
6138
6145
|
* - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
|
|
6139
|
-
* - Name: 13px, font-weight 800, centered
|
|
6146
|
+
* - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
|
|
6140
6147
|
* - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
|
|
6141
|
-
* - Selected: indigo border overlay ring
|
|
6148
|
+
* - Hover: indigo-300 border; Selected: indigo border + overlay ring
|
|
6142
6149
|
* Connectors:
|
|
6143
6150
|
* - Stepped orthogonal path: M→V→H→V
|
|
6144
6151
|
* - Stroke: #cbd5e1, stroke-width 1.5
|
|
6145
6152
|
* Canvas:
|
|
6146
|
-
* -
|
|
6147
|
-
*
|
|
6148
|
-
*
|
|
6153
|
+
* - Fits the tree to the viewport until the user zooms or pans; after that
|
|
6154
|
+
* the user's view is kept across re-renders, data refreshes and resizes
|
|
6155
|
+
* (e.g. a modal hiding the page scrollbar). "Fit to screen" or a new
|
|
6156
|
+
* `fitKey` hands control back to auto-fit.
|
|
6157
|
+
* - Zoom: scaleExtent [0.05, 1.8]; wheel / pinch / buttons / keyboard
|
|
6158
|
+
* - Pan: mouse/touch drag (a drag never counts as a node click)
|
|
6149
6159
|
* - Layout: nodeSize [280, 185] — horizontal × vertical spacing
|
|
6150
6160
|
* Controls:
|
|
6151
|
-
* -
|
|
6161
|
+
* - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
|
|
6152
6162
|
*
|
|
6153
6163
|
* Accessibility:
|
|
6154
|
-
* - SVG has role="img"
|
|
6155
|
-
* -
|
|
6156
|
-
* - Keyboard:
|
|
6164
|
+
* - SVG has an aria-label; role="img" when read-only, role="group" when nodes are selectable
|
|
6165
|
+
* - Selectable nodes are focusable buttons (Tab, Enter / Space to select)
|
|
6166
|
+
* - Keyboard on the chart: + / = zoom in, - zoom out, 0 fit to screen
|
|
6167
|
+
* - Respects prefers-reduced-motion for button zoom transitions
|
|
6157
6168
|
* - Note: full ARIA tree semantics are not possible in an SVG diagram;
|
|
6158
6169
|
* use OrgUnitTree for a fully keyboard-accessible list-based alternative
|
|
6159
6170
|
*
|
|
@@ -6165,6 +6176,13 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
|
|
|
6165
6176
|
* aria-label="Company organization chart"
|
|
6166
6177
|
* />
|
|
6167
6178
|
*/
|
|
6179
|
+
var SYNTHETIC_ROOT_ID = "__synthetic_root__";
|
|
6180
|
+
var NODE_W = 250;
|
|
6181
|
+
var NODE_H = 84;
|
|
6182
|
+
var TEXT_MAX_W = 218;
|
|
6183
|
+
var MIN_SCALE = .05;
|
|
6184
|
+
var MAX_SCALE = 1.8;
|
|
6185
|
+
var BUTTON_ZOOM_STEP = 1.2;
|
|
6168
6186
|
function buildD3Tree(nodes) {
|
|
6169
6187
|
function convert(node, depth) {
|
|
6170
6188
|
return {
|
|
@@ -6173,161 +6191,344 @@ function buildD3Tree(nodes) {
|
|
|
6173
6191
|
description: node.description ?? "",
|
|
6174
6192
|
isRoot: depth === 0,
|
|
6175
6193
|
isSyntheticRoot: false,
|
|
6194
|
+
isMuted: node.tone === "muted",
|
|
6176
6195
|
originalId: node.id,
|
|
6177
6196
|
children: node.children?.map((c) => convert(c, depth + 1))
|
|
6178
6197
|
};
|
|
6179
6198
|
}
|
|
6180
6199
|
if (nodes.length === 1) return convert(nodes[0], 0);
|
|
6181
6200
|
return {
|
|
6182
|
-
id:
|
|
6201
|
+
id: SYNTHETIC_ROOT_ID,
|
|
6183
6202
|
name: "",
|
|
6184
6203
|
description: "",
|
|
6185
6204
|
isRoot: true,
|
|
6186
6205
|
isSyntheticRoot: true,
|
|
6187
|
-
|
|
6206
|
+
isMuted: false,
|
|
6207
|
+
originalId: SYNTHETIC_ROOT_ID,
|
|
6188
6208
|
children: nodes.map((n) => convert(n, 0))
|
|
6189
6209
|
};
|
|
6190
6210
|
}
|
|
6191
|
-
|
|
6211
|
+
/** Writes `text` into an SVG text element, truncating with an ellipsis to fit `maxWidth`. */
|
|
6212
|
+
function fitText(el, text, maxWidth, fontSize) {
|
|
6213
|
+
el.textContent = text;
|
|
6214
|
+
if (!text) return;
|
|
6215
|
+
const measured = typeof el.getComputedTextLength === "function" ? el.getComputedTextLength() : 0;
|
|
6216
|
+
if (!measured) {
|
|
6217
|
+
const maxChars = Math.max(1, Math.floor(maxWidth / (fontSize * .6)));
|
|
6218
|
+
if (text.length > maxChars) el.textContent = `${text.slice(0, maxChars - 1).trimEnd()}…`;
|
|
6219
|
+
return;
|
|
6220
|
+
}
|
|
6221
|
+
if (measured <= maxWidth) return;
|
|
6222
|
+
let lo = 0;
|
|
6223
|
+
let hi = text.length - 1;
|
|
6224
|
+
while (lo < hi) {
|
|
6225
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
6226
|
+
el.textContent = `${text.slice(0, mid).trimEnd()}…`;
|
|
6227
|
+
if (el.getComputedTextLength() <= maxWidth) lo = mid;
|
|
6228
|
+
else hi = mid - 1;
|
|
6229
|
+
}
|
|
6230
|
+
el.textContent = `${text.slice(0, lo).trimEnd()}…`;
|
|
6231
|
+
}
|
|
6232
|
+
var prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
6233
|
+
function OrgChart({ nodes, selectedId, onSelect, fitKey, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
|
|
6192
6234
|
const svgRef = useRef(null);
|
|
6193
6235
|
const zoomRef = useRef(null);
|
|
6194
|
-
const initialTransformRef = useRef(null);
|
|
6195
6236
|
const treeBoundsRef = useRef(null);
|
|
6196
|
-
const
|
|
6237
|
+
const autoFitRef = useRef(true);
|
|
6238
|
+
const programmaticRef = useRef(false);
|
|
6239
|
+
const pendingFitRef = useRef(false);
|
|
6240
|
+
const lastSizeRef = useRef(null);
|
|
6241
|
+
const onSelectRef = useRef(onSelect);
|
|
6242
|
+
const selectedIdRef = useRef(selectedId);
|
|
6243
|
+
const scaleFrameRef = useRef(0);
|
|
6244
|
+
const [scale, setScale] = useState(1);
|
|
6245
|
+
const uid = `oc-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
6246
|
+
const interactive = Boolean(onSelect);
|
|
6247
|
+
const isEmpty = nodes.length === 0;
|
|
6248
|
+
const getSize = () => {
|
|
6249
|
+
const rect = svgRef.current?.getBoundingClientRect();
|
|
6250
|
+
return {
|
|
6251
|
+
width: rect?.width ?? 0,
|
|
6252
|
+
height: rect?.height ?? 0
|
|
6253
|
+
};
|
|
6254
|
+
};
|
|
6255
|
+
/** Applies a transform without handing control away from auto-fit. */
|
|
6256
|
+
const applyTransform = (t) => {
|
|
6257
|
+
if (!svgRef.current || !zoomRef.current) return;
|
|
6258
|
+
const svg = d3.select(svgRef.current);
|
|
6259
|
+
svg.interrupt();
|
|
6260
|
+
programmaticRef.current = true;
|
|
6261
|
+
try {
|
|
6262
|
+
svg.call(zoomRef.current.transform, t);
|
|
6263
|
+
} finally {
|
|
6264
|
+
programmaticRef.current = false;
|
|
6265
|
+
}
|
|
6266
|
+
};
|
|
6197
6267
|
const fitToView = () => {
|
|
6198
6268
|
if (!svgRef.current || !zoomRef.current || !treeBoundsRef.current) return;
|
|
6199
|
-
const { width: W, height: H } =
|
|
6200
|
-
if (W === 0 || H === 0)
|
|
6269
|
+
const { width: W, height: H } = getSize();
|
|
6270
|
+
if (W === 0 || H === 0) {
|
|
6271
|
+
pendingFitRef.current = true;
|
|
6272
|
+
return;
|
|
6273
|
+
}
|
|
6274
|
+
pendingFitRef.current = false;
|
|
6201
6275
|
const { minX, maxX, minY, maxY } = treeBoundsRef.current;
|
|
6202
|
-
const contentLeft = minX -
|
|
6203
|
-
const contentRight = maxX +
|
|
6204
|
-
const contentTop = minY -
|
|
6205
|
-
const contentBottom = maxY +
|
|
6276
|
+
const contentLeft = minX - NODE_W / 2;
|
|
6277
|
+
const contentRight = maxX + NODE_W / 2;
|
|
6278
|
+
const contentTop = minY - NODE_H / 2;
|
|
6279
|
+
const contentBottom = maxY + NODE_H / 2;
|
|
6206
6280
|
const contentWidth = contentRight - contentLeft;
|
|
6207
6281
|
const contentHeight = contentBottom - contentTop;
|
|
6208
6282
|
const scaleX = (W - 80) / Math.max(1, contentWidth);
|
|
6209
6283
|
const scaleY = (H - 80) / Math.max(1, contentHeight);
|
|
6210
|
-
const
|
|
6211
|
-
const
|
|
6212
|
-
const
|
|
6213
|
-
|
|
6214
|
-
const ty = H / 2 - contentCenterY * scale;
|
|
6215
|
-
const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
|
|
6216
|
-
initialTransformRef.current = t;
|
|
6217
|
-
d3.select(svgRef.current).call(zoomRef.current.transform, t);
|
|
6284
|
+
const k = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scaleX, scaleY));
|
|
6285
|
+
const tx = W / 2 - (contentLeft + contentRight) / 2 * k;
|
|
6286
|
+
const ty = H / 2 - (contentTop + contentBottom) / 2 * k;
|
|
6287
|
+
applyTransform(d3.zoomIdentity.translate(tx, ty).scale(k));
|
|
6218
6288
|
};
|
|
6219
|
-
|
|
6220
|
-
|
|
6289
|
+
/** Fit, and go back to keeping the chart fitted (Fit button, new fitKey). */
|
|
6290
|
+
const resetToFit = () => {
|
|
6291
|
+
autoFitRef.current = true;
|
|
6292
|
+
fitToView();
|
|
6293
|
+
};
|
|
6294
|
+
const zoomBy = (factor) => {
|
|
6295
|
+
if (!svgRef.current || !zoomRef.current) return;
|
|
6296
|
+
autoFitRef.current = false;
|
|
6221
6297
|
const svg = d3.select(svgRef.current);
|
|
6222
|
-
svg.
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6298
|
+
if (prefersReducedMotion()) svg.interrupt().call(zoomRef.current.scaleBy, factor);
|
|
6299
|
+
else svg.transition().duration(250).call(zoomRef.current.scaleBy, factor);
|
|
6300
|
+
};
|
|
6301
|
+
useEffect(() => {
|
|
6302
|
+
onSelectRef.current = onSelect;
|
|
6303
|
+
}, [onSelect]);
|
|
6304
|
+
useEffect(() => {
|
|
6305
|
+
const svgEl = svgRef.current;
|
|
6306
|
+
if (!svgEl) return;
|
|
6307
|
+
const svg = d3.select(svgEl);
|
|
6308
|
+
const viewport = svg.append("g").attr("class", "oc-viewport");
|
|
6309
|
+
viewport.append("g").attr("class", "oc-links");
|
|
6310
|
+
viewport.append("g").attr("class", "oc-nodes");
|
|
6311
|
+
const zoom = d3.zoom().scaleExtent([MIN_SCALE, MAX_SCALE]).extent(() => {
|
|
6312
|
+
const { width, height } = getSize();
|
|
6313
|
+
return [[0, 0], [Math.max(1, width), Math.max(1, height)]];
|
|
6314
|
+
}).clickDistance(5).filter((event) => {
|
|
6315
|
+
if (event.type === "dblclick") return false;
|
|
6316
|
+
const e = event;
|
|
6317
|
+
return (!e.ctrlKey || event.type === "wheel") && !e.button;
|
|
6318
|
+
}).on("zoom", (event) => {
|
|
6319
|
+
viewport.attr("transform", event.transform.toString());
|
|
6320
|
+
if (!programmaticRef.current) autoFitRef.current = false;
|
|
6321
|
+
cancelAnimationFrame(scaleFrameRef.current);
|
|
6322
|
+
scaleFrameRef.current = requestAnimationFrame(() => setScale(event.transform.k));
|
|
6323
|
+
});
|
|
6324
|
+
zoomRef.current = zoom;
|
|
6325
|
+
svg.call(zoom);
|
|
6326
|
+
const ro = new ResizeObserver(() => {
|
|
6327
|
+
const size = getSize();
|
|
6328
|
+
if (size.width === 0 || size.height === 0) return;
|
|
6329
|
+
const last = lastSizeRef.current;
|
|
6330
|
+
lastSizeRef.current = size;
|
|
6331
|
+
if (autoFitRef.current || pendingFitRef.current || !last) {
|
|
6332
|
+
fitToView();
|
|
6333
|
+
return;
|
|
6334
|
+
}
|
|
6335
|
+
const dx = (size.width - last.width) / 2;
|
|
6336
|
+
const dy = (size.height - last.height) / 2;
|
|
6337
|
+
if (dx === 0 && dy === 0) return;
|
|
6338
|
+
const t = d3.zoomTransform(svgEl);
|
|
6339
|
+
applyTransform(d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k));
|
|
6340
|
+
});
|
|
6341
|
+
ro.observe(svgEl);
|
|
6342
|
+
return () => {
|
|
6343
|
+
ro.disconnect();
|
|
6344
|
+
cancelAnimationFrame(scaleFrameRef.current);
|
|
6345
|
+
svg.interrupt().on(".zoom", null);
|
|
6346
|
+
svg.selectAll("*").remove();
|
|
6347
|
+
zoomRef.current = null;
|
|
6348
|
+
};
|
|
6349
|
+
}, []);
|
|
6350
|
+
useEffect(() => {
|
|
6351
|
+
const svgEl = svgRef.current;
|
|
6352
|
+
if (!svgEl) return;
|
|
6353
|
+
const svg = d3.select(svgEl);
|
|
6354
|
+
const linkLayer = svg.select("g.oc-links");
|
|
6355
|
+
const nodeLayer = svg.select("g.oc-nodes");
|
|
6356
|
+
if (nodes.length === 0) {
|
|
6357
|
+
linkLayer.selectAll("*").remove();
|
|
6358
|
+
nodeLayer.selectAll("*").remove();
|
|
6359
|
+
treeBoundsRef.current = null;
|
|
6360
|
+
return;
|
|
6361
|
+
}
|
|
6362
|
+
const root = d3.hierarchy(buildD3Tree(nodes));
|
|
6363
|
+
const pointRoot = d3.tree().nodeSize([280, 185])(root);
|
|
6364
|
+
const descendants = pointRoot.descendants();
|
|
6227
6365
|
treeBoundsRef.current = {
|
|
6228
6366
|
minX: d3.min(descendants, (d) => d.x) ?? 0,
|
|
6229
6367
|
maxX: d3.max(descendants, (d) => d.x) ?? 0,
|
|
6230
6368
|
minY: d3.min(descendants, (d) => d.y) ?? 0,
|
|
6231
6369
|
maxY: d3.max(descendants, (d) => d.y) ?? 0
|
|
6232
6370
|
};
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
svg.call(zoom);
|
|
6237
|
-
g.selectAll("path").data(root.links().filter((l) => !l.source.data.isSyntheticRoot)).join("path").attr("fill", "none").attr("stroke", "#cbd5e1").attr("stroke-width", 1.5).attr("d", (link) => {
|
|
6238
|
-
const sx = link.source.x ?? 0;
|
|
6239
|
-
const sy = link.source.y ?? 0;
|
|
6240
|
-
const tx = link.target.x ?? 0;
|
|
6241
|
-
const ty = link.target.y ?? 0;
|
|
6242
|
-
return `M${sx},${sy}V${(sy + ty) / 2}H${tx}V${ty}`;
|
|
6371
|
+
linkLayer.selectAll("path").data(pointRoot.links().filter((l) => !l.source.data.isSyntheticRoot), (l) => `${l.source.data.originalId}>${l.target.data.originalId}`).join("path").attr("fill", "none").attr("stroke", "#cbd5e1").attr("stroke-width", 1.5).attr("d", (link) => {
|
|
6372
|
+
const mid = (link.source.y + link.target.y) / 2;
|
|
6373
|
+
return `M${link.source.x},${link.source.y}V${mid}H${link.target.x}V${link.target.y}`;
|
|
6243
6374
|
});
|
|
6244
|
-
const node =
|
|
6245
|
-
|
|
6246
|
-
|
|
6375
|
+
const node = nodeLayer.selectAll("g.oc-node").data(descendants.filter((d) => !d.data.isSyntheticRoot), (d) => d.data.originalId).join((enter) => {
|
|
6376
|
+
const g = enter.append("g").attr("class", "oc-node");
|
|
6377
|
+
g.append("title");
|
|
6378
|
+
g.append("rect").attr("class", "oc-card").attr("x", -125).attr("y", -42).attr("width", NODE_W).attr("height", NODE_H).attr("rx", 14).attr("filter", "drop-shadow(0 6px 12px rgba(15,23,42,0.08))");
|
|
6379
|
+
g.append("rect").attr("class", "oc-ring").attr("x", -128).attr("y", -45).attr("width", 256).attr("height", 90).attr("rx", 16).attr("fill", "none");
|
|
6380
|
+
g.append("text").attr("class", "oc-name").attr("text-anchor", "middle").attr("y", -8).attr("font-size", 13).attr("font-weight", 800);
|
|
6381
|
+
g.append("text").attr("class", "oc-desc").attr("text-anchor", "middle").attr("y", 16).attr("font-size", 11).attr("font-weight", 600);
|
|
6382
|
+
return g;
|
|
6383
|
+
}).attr("transform", (d) => `translate(${d.x},${d.y})`).classed("is-root", (d) => d.data.isRoot).classed("is-muted", (d) => d.data.isMuted).classed("is-selected", (d) => d.data.originalId === selectedIdRef.current).attr("aria-label", (d) => d.data.description ? `${d.data.name}, ${d.data.description}` : d.data.name).on("click", (_e, d) => onSelectRef.current?.(d.data.originalId)).on("keydown", (event, d) => {
|
|
6384
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
6385
|
+
event.preventDefault();
|
|
6386
|
+
onSelectRef.current?.(d.data.originalId);
|
|
6387
|
+
}).on("focus", (_e, d) => {
|
|
6388
|
+
if (!zoomRef.current) return;
|
|
6389
|
+
const { width: W, height: H } = getSize();
|
|
6390
|
+
const [sx, sy] = d3.zoomTransform(svgEl).apply([d.x, d.y]);
|
|
6391
|
+
const margin = 24;
|
|
6392
|
+
if (sx >= margin && sx <= W - margin && sy >= margin && sy <= H - margin) return;
|
|
6393
|
+
autoFitRef.current = false;
|
|
6394
|
+
svg.interrupt().call(zoomRef.current.translateTo, d.x, d.y);
|
|
6247
6395
|
});
|
|
6248
|
-
node.
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
})
|
|
6252
|
-
node.
|
|
6253
|
-
|
|
6254
|
-
if (selectedId) node.filter((d) => d.data.originalId === selectedId).append("rect").attr("x", -128).attr("y", -45).attr("width", 256).attr("height", 90).attr("rx", 16).attr("fill", "none").attr("stroke", "#6366f1").attr("stroke-width", 2.5).attr("opacity", .6);
|
|
6255
|
-
requestAnimationFrame(() => {
|
|
6256
|
-
fitToView();
|
|
6396
|
+
node.select("title").text((d) => d.data.description ? `${d.data.name}\n${d.data.description}` : d.data.name);
|
|
6397
|
+
node.select("text.oc-name").each(function(d) {
|
|
6398
|
+
fitText(this, d.data.name, TEXT_MAX_W, 13);
|
|
6399
|
+
});
|
|
6400
|
+
node.select("text.oc-desc").each(function(d) {
|
|
6401
|
+
fitText(this, d.data.description, TEXT_MAX_W, 11);
|
|
6257
6402
|
});
|
|
6403
|
+
if (autoFitRef.current || pendingFitRef.current) fitToView();
|
|
6404
|
+
}, [nodes]);
|
|
6405
|
+
useEffect(() => {
|
|
6406
|
+
if (!svgRef.current) return;
|
|
6407
|
+
d3.select(svgRef.current).selectAll("g.oc-node").attr("tabindex", interactive ? 0 : null).attr("role", interactive ? "button" : null);
|
|
6408
|
+
}, [interactive, nodes]);
|
|
6409
|
+
useEffect(() => {
|
|
6410
|
+
selectedIdRef.current = selectedId;
|
|
6411
|
+
if (!svgRef.current) return;
|
|
6412
|
+
d3.select(svgRef.current).selectAll("g.oc-node").classed("is-selected", (d) => d.data.originalId === selectedId).attr("aria-pressed", interactive ? (d) => String(d.data.originalId === selectedId) : null);
|
|
6258
6413
|
}, [
|
|
6259
|
-
nodes,
|
|
6260
6414
|
selectedId,
|
|
6261
|
-
|
|
6415
|
+
interactive,
|
|
6416
|
+
nodes
|
|
6262
6417
|
]);
|
|
6418
|
+
const fitKeyRef = useRef(fitKey);
|
|
6263
6419
|
useEffect(() => {
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
};
|
|
6276
|
-
const resetZoom = () => {
|
|
6277
|
-
fitToView();
|
|
6420
|
+
if (fitKeyRef.current === fitKey) return;
|
|
6421
|
+
fitKeyRef.current = fitKey;
|
|
6422
|
+
resetToFit();
|
|
6423
|
+
}, [fitKey]);
|
|
6424
|
+
const handleKeyDown = (event) => {
|
|
6425
|
+
if (event.ctrlKey || event.metaKey || event.altKey || isEmpty) return;
|
|
6426
|
+
if (event.key === "+" || event.key === "=") zoomBy(BUTTON_ZOOM_STEP);
|
|
6427
|
+
else if (event.key === "-" || event.key === "_") zoomBy(1 / BUTTON_ZOOM_STEP);
|
|
6428
|
+
else if (event.key === "0") resetToFit();
|
|
6429
|
+
else return;
|
|
6430
|
+
event.preventDefault();
|
|
6278
6431
|
};
|
|
6432
|
+
const atMax = scale >= 1.7990000000000002;
|
|
6433
|
+
const atMin = scale <= .051000000000000004;
|
|
6279
6434
|
const controlBtnClass = [
|
|
6280
6435
|
"appearance-none border-0 bg-none font-[inherit]",
|
|
6281
6436
|
"flex items-center justify-center rounded-lg p-2",
|
|
6282
|
-
"text-slate-
|
|
6283
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30"
|
|
6437
|
+
"text-slate-500 transition hover:bg-slate-100 hover:text-slate-700",
|
|
6438
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30",
|
|
6439
|
+
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
|
6284
6440
|
].join(" ");
|
|
6285
6441
|
return /* @__PURE__ */ jsxs("div", {
|
|
6286
|
-
className: `relative w-full overflow-hidden bg-slate-50/50 ${className}`,
|
|
6442
|
+
className: `relative w-full overflow-hidden bg-slate-50/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-blue/30 ${className}`,
|
|
6287
6443
|
style: { height },
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6444
|
+
tabIndex: isEmpty ? void 0 : 0,
|
|
6445
|
+
"aria-keyshortcuts": isEmpty ? void 0 : "+ - 0",
|
|
6446
|
+
onKeyDown: handleKeyDown,
|
|
6447
|
+
children: [
|
|
6448
|
+
/* @__PURE__ */ jsx("style", { children: `
|
|
6449
|
+
#${uid} .oc-card { fill: #fff; stroke: #e2e8f0; stroke-width: 1; transition: stroke 120ms ease; }
|
|
6450
|
+
#${uid} .oc-name { fill: #0f172a; }
|
|
6451
|
+
#${uid} .oc-desc { fill: #64748b; }
|
|
6452
|
+
#${uid} .oc-node.is-root .oc-card { fill: #0f172a; stroke: #0f172a; }
|
|
6453
|
+
#${uid} .oc-node.is-root .oc-name { fill: #fff; }
|
|
6454
|
+
#${uid} .oc-node.is-root .oc-desc { fill: #cbd5e1; }
|
|
6455
|
+
#${uid} .oc-node.is-muted .oc-card { fill: #f8fafc; stroke: #94a3b8; stroke-dasharray: 5 4; }
|
|
6456
|
+
#${uid} .oc-node.is-muted .oc-name { fill: #475569; }
|
|
6457
|
+
#${uid} .oc-node.is-muted .oc-desc { fill: #94a3b8; }
|
|
6458
|
+
#${uid} .oc-node.is-root.is-muted .oc-card { fill: #334155; stroke: #94a3b8; }
|
|
6459
|
+
#${uid} .oc-node.is-root.is-muted .oc-name { fill: #e2e8f0; }
|
|
6460
|
+
#${uid} .oc-node.is-root.is-muted .oc-desc { fill: #cbd5e1; }
|
|
6461
|
+
#${uid} .oc-ring { stroke: #6366f1; stroke-width: 2.5; opacity: 0; pointer-events: none; }
|
|
6462
|
+
#${uid}.is-interactive .oc-node { cursor: pointer; }
|
|
6463
|
+
#${uid}.is-interactive .oc-node:hover .oc-card { stroke: #a5b4fc; stroke-width: 1.5; }
|
|
6464
|
+
#${uid} .oc-node.is-selected .oc-card,
|
|
6465
|
+
#${uid}.is-interactive .oc-node.is-selected:hover .oc-card { stroke: #6366f1; stroke-width: 2.5; }
|
|
6466
|
+
#${uid} .oc-node.is-selected .oc-ring { opacity: 0.6; }
|
|
6467
|
+
#${uid} .oc-node:focus { outline: none; }
|
|
6468
|
+
#${uid} .oc-node:focus-visible .oc-ring { opacity: 1; stroke: #2563eb; }
|
|
6469
|
+
` }),
|
|
6470
|
+
!isEmpty && /* @__PURE__ */ jsxs("div", {
|
|
6471
|
+
className: "absolute right-4 top-4 z-10 flex flex-col items-center gap-1 rounded-lg border border-slate-100 bg-white p-1.5 shadow-md",
|
|
6472
|
+
children: [
|
|
6473
|
+
/* @__PURE__ */ jsx("button", {
|
|
6474
|
+
type: "button",
|
|
6475
|
+
className: controlBtnClass,
|
|
6476
|
+
onClick: () => zoomBy(BUTTON_ZOOM_STEP),
|
|
6477
|
+
disabled: atMax,
|
|
6478
|
+
"aria-label": "Zoom in",
|
|
6479
|
+
title: "Zoom in (+)",
|
|
6480
|
+
children: /* @__PURE__ */ jsx(ZoomIn, {
|
|
6481
|
+
size: 16,
|
|
6482
|
+
"aria-hidden": "true"
|
|
6483
|
+
})
|
|
6484
|
+
}),
|
|
6485
|
+
/* @__PURE__ */ jsxs("span", {
|
|
6486
|
+
className: "min-w-[3rem] select-none text-center text-[11px] font-semibold tabular-nums text-slate-500",
|
|
6487
|
+
children: [Math.round(scale * 100), "%"]
|
|
6488
|
+
}),
|
|
6489
|
+
/* @__PURE__ */ jsx("button", {
|
|
6490
|
+
type: "button",
|
|
6491
|
+
className: controlBtnClass,
|
|
6492
|
+
onClick: () => zoomBy(1 / BUTTON_ZOOM_STEP),
|
|
6493
|
+
disabled: atMin,
|
|
6494
|
+
"aria-label": "Zoom out",
|
|
6495
|
+
title: "Zoom out (-)",
|
|
6496
|
+
children: /* @__PURE__ */ jsx(ZoomOut, {
|
|
6497
|
+
size: 16,
|
|
6498
|
+
"aria-hidden": "true"
|
|
6499
|
+
})
|
|
6500
|
+
}),
|
|
6501
|
+
/* @__PURE__ */ jsx("div", {
|
|
6502
|
+
className: "my-0.5 h-px w-6 bg-slate-100",
|
|
6318
6503
|
"aria-hidden": "true"
|
|
6504
|
+
}),
|
|
6505
|
+
/* @__PURE__ */ jsx("button", {
|
|
6506
|
+
type: "button",
|
|
6507
|
+
className: controlBtnClass,
|
|
6508
|
+
onClick: resetToFit,
|
|
6509
|
+
"aria-label": "Fit to screen",
|
|
6510
|
+
title: "Fit to screen (0)",
|
|
6511
|
+
children: /* @__PURE__ */ jsx(Scan, {
|
|
6512
|
+
size: 16,
|
|
6513
|
+
"aria-hidden": "true"
|
|
6514
|
+
})
|
|
6319
6515
|
})
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
6330
|
-
|
|
6516
|
+
]
|
|
6517
|
+
}),
|
|
6518
|
+
isEmpty && /* @__PURE__ */ jsx("div", {
|
|
6519
|
+
className: "absolute inset-0 flex items-center justify-center p-6 text-center text-sm font-semibold text-slate-500",
|
|
6520
|
+
children: "No units to display"
|
|
6521
|
+
}),
|
|
6522
|
+
/* @__PURE__ */ jsx("svg", {
|
|
6523
|
+
ref: svgRef,
|
|
6524
|
+
id: uid,
|
|
6525
|
+
width: "100%",
|
|
6526
|
+
height: "100%",
|
|
6527
|
+
className: `block touch-none ${interactive ? "is-interactive" : ""}`,
|
|
6528
|
+
role: interactive ? "group" : "img",
|
|
6529
|
+
"aria-label": ariaLabel
|
|
6530
|
+
})
|
|
6531
|
+
]
|
|
6331
6532
|
});
|
|
6332
6533
|
}
|
|
6333
6534
|
//#endregion
|
|
@@ -13,25 +13,30 @@
|
|
|
13
13
|
* - 250 × 84 px rounded rectangles (rx=14)
|
|
14
14
|
* - Root (depth 0): dark navy fill (#0f172a), white text
|
|
15
15
|
* - Children: white fill (#fff), slate border (#e2e8f0)
|
|
16
|
+
* - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
|
|
16
17
|
* - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
|
|
17
|
-
* - Name: 13px, font-weight 800, centered
|
|
18
|
+
* - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
|
|
18
19
|
* - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
|
|
19
|
-
* - Selected: indigo border overlay ring
|
|
20
|
+
* - Hover: indigo-300 border; Selected: indigo border + overlay ring
|
|
20
21
|
* Connectors:
|
|
21
22
|
* - Stepped orthogonal path: M→V→H→V
|
|
22
23
|
* - Stroke: #cbd5e1, stroke-width 1.5
|
|
23
24
|
* Canvas:
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* - Fits the tree to the viewport until the user zooms or pans; after that
|
|
26
|
+
* the user's view is kept across re-renders, data refreshes and resizes
|
|
27
|
+
* (e.g. a modal hiding the page scrollbar). "Fit to screen" or a new
|
|
28
|
+
* `fitKey` hands control back to auto-fit.
|
|
29
|
+
* - Zoom: scaleExtent [0.05, 1.8]; wheel / pinch / buttons / keyboard
|
|
30
|
+
* - Pan: mouse/touch drag (a drag never counts as a node click)
|
|
27
31
|
* - Layout: nodeSize [280, 185] — horizontal × vertical spacing
|
|
28
32
|
* Controls:
|
|
29
|
-
* -
|
|
33
|
+
* - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
|
|
30
34
|
*
|
|
31
35
|
* Accessibility:
|
|
32
|
-
* - SVG has role="img"
|
|
33
|
-
* -
|
|
34
|
-
* - Keyboard:
|
|
36
|
+
* - SVG has an aria-label; role="img" when read-only, role="group" when nodes are selectable
|
|
37
|
+
* - Selectable nodes are focusable buttons (Tab, Enter / Space to select)
|
|
38
|
+
* - Keyboard on the chart: + / = zoom in, - zoom out, 0 fit to screen
|
|
39
|
+
* - Respects prefers-reduced-motion for button zoom transitions
|
|
35
40
|
* - Note: full ARIA tree semantics are not possible in an SVG diagram;
|
|
36
41
|
* use OrgUnitTree for a fully keyboard-accessible list-based alternative
|
|
37
42
|
*
|
|
@@ -50,6 +55,11 @@ export interface OrgChartNode {
|
|
|
50
55
|
label: string;
|
|
51
56
|
/** Secondary text line inside the node. */
|
|
52
57
|
description?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Visual tone. `'muted'` renders a dashed, greyed-out card — use it for
|
|
60
|
+
* inactive or archived units. Default: `'default'`.
|
|
61
|
+
*/
|
|
62
|
+
tone?: 'default' | 'muted';
|
|
53
63
|
/** Child nodes. */
|
|
54
64
|
children?: OrgChartNode[];
|
|
55
65
|
}
|
|
@@ -62,8 +72,14 @@ export interface OrgChartProps {
|
|
|
62
72
|
nodes: OrgChartNode[];
|
|
63
73
|
/** Currently selected node id — renders an indigo ring on the node. */
|
|
64
74
|
selectedId?: string;
|
|
65
|
-
/** Called when a node is clicked. */
|
|
75
|
+
/** Called when a node is clicked (or activated with Enter / Space). */
|
|
66
76
|
onSelect?: (id: string) => void;
|
|
77
|
+
/**
|
|
78
|
+
* Changing this value re-fits the tree to the viewport, discarding the
|
|
79
|
+
* user's zoom/pan. Use it when the chart shows a new context (e.g. a
|
|
80
|
+
* different company scope). Data refreshes alone keep the user's view.
|
|
81
|
+
*/
|
|
82
|
+
fitKey?: string | number;
|
|
67
83
|
/**
|
|
68
84
|
* Container height. Default: '75vh' — matching the production CompanyStructurePage.
|
|
69
85
|
* Pass a fixed pixel value for demos: '480px'.
|
|
@@ -74,4 +90,4 @@ export interface OrgChartProps {
|
|
|
74
90
|
/** Additional class on the root container div. */
|
|
75
91
|
className?: string;
|
|
76
92
|
}
|
|
77
|
-
export declare function OrgChart({ nodes, selectedId, onSelect, height, 'aria-label': ariaLabel, className, }: OrgChartProps): import("react").JSX.Element;
|
|
93
|
+
export declare function OrgChart({ nodes, selectedId, onSelect, fitKey, height, 'aria-label': ariaLabel, className, }: OrgChartProps): import("react").JSX.Element;
|