@diwauhris/ui 1.7.42 → 1.7.43

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 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
@@ -6206,25 +6206,30 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
6206
6206
  * - 250 × 84 px rounded rectangles (rx=14)
6207
6207
  * - Root (depth 0): dark navy fill (#0f172a), white text
6208
6208
  * - Children: white fill (#fff), slate border (#e2e8f0)
6209
+ * - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
6209
6210
  * - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
6210
- * - Name: 13px, font-weight 800, centered
6211
+ * - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
6211
6212
  * - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
6212
- * - Selected: indigo border overlay ring
6213
+ * - Hover: indigo-300 border; Selected: indigo border + overlay ring
6213
6214
  * Connectors:
6214
6215
  * - Stepped orthogonal path: M→V→H→V
6215
6216
  * - Stroke: #cbd5e1, stroke-width 1.5
6216
6217
  * Canvas:
6217
- * - Viewport sized to container; fit-to-view on render
6218
- * - Zoom: scaleExtent [0.05, 1.8]
6219
- * - Pan: mouse/touch drag
6218
+ * - Fits the tree to the viewport until the user zooms or pans; after that
6219
+ * the user's view is kept across re-renders, data refreshes and resizes
6220
+ * (e.g. a modal hiding the page scrollbar). "Fit to screen" or a new
6221
+ * `fitKey` hands control back to auto-fit.
6222
+ * - Zoom: scaleExtent [0.05, 1.8]; wheel / pinch / buttons / keyboard
6223
+ * - Pan: mouse/touch drag (a drag never counts as a node click)
6220
6224
  * - Layout: nodeSize [280, 185] — horizontal × vertical spacing
6221
6225
  * Controls:
6222
- * - ZoomIn, Reset, ZoomOut buttons (top-right overlay panel)
6226
+ * - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
6223
6227
  *
6224
6228
  * Accessibility:
6225
- * - SVG has role="img" + aria-label
6226
- * - Zoom controls have aria-label
6227
- * - Keyboard: Tab to control buttons; diagram itself is navigated visually
6229
+ * - SVG has an aria-label; role="img" when read-only, role="group" when nodes are selectable
6230
+ * - Selectable nodes are focusable buttons (Tab, Enter / Space to select)
6231
+ * - Keyboard on the chart: + / = zoom in, - zoom out, 0 fit to screen
6232
+ * - Respects prefers-reduced-motion for button zoom transitions
6228
6233
  * - Note: full ARIA tree semantics are not possible in an SVG diagram;
6229
6234
  * use OrgUnitTree for a fully keyboard-accessible list-based alternative
6230
6235
  *
@@ -6236,6 +6241,13 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
6236
6241
  * aria-label="Company organization chart"
6237
6242
  * />
6238
6243
  */
6244
+ var SYNTHETIC_ROOT_ID = "__synthetic_root__";
6245
+ var NODE_W = 250;
6246
+ var NODE_H = 84;
6247
+ var TEXT_MAX_W = 218;
6248
+ var MIN_SCALE = .05;
6249
+ var MAX_SCALE = 1.8;
6250
+ var BUTTON_ZOOM_STEP = 1.2;
6239
6251
  function buildD3Tree(nodes) {
6240
6252
  function convert(node, depth) {
6241
6253
  return {
@@ -6244,161 +6256,344 @@ function buildD3Tree(nodes) {
6244
6256
  description: node.description ?? "",
6245
6257
  isRoot: depth === 0,
6246
6258
  isSyntheticRoot: false,
6259
+ isMuted: node.tone === "muted",
6247
6260
  originalId: node.id,
6248
6261
  children: node.children?.map((c) => convert(c, depth + 1))
6249
6262
  };
6250
6263
  }
6251
6264
  if (nodes.length === 1) return convert(nodes[0], 0);
6252
6265
  return {
6253
- id: "__synthetic_root__",
6266
+ id: SYNTHETIC_ROOT_ID,
6254
6267
  name: "",
6255
6268
  description: "",
6256
6269
  isRoot: true,
6257
6270
  isSyntheticRoot: true,
6258
- originalId: "__synthetic_root__",
6271
+ isMuted: false,
6272
+ originalId: SYNTHETIC_ROOT_ID,
6259
6273
  children: nodes.map((n) => convert(n, 0))
6260
6274
  };
6261
6275
  }
6262
- function OrgChart({ nodes, selectedId, onSelect, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
6276
+ /** Writes `text` into an SVG text element, truncating with an ellipsis to fit `maxWidth`. */
6277
+ function fitText(el, text, maxWidth, fontSize) {
6278
+ el.textContent = text;
6279
+ if (!text) return;
6280
+ const measured = typeof el.getComputedTextLength === "function" ? el.getComputedTextLength() : 0;
6281
+ if (!measured) {
6282
+ const maxChars = Math.max(1, Math.floor(maxWidth / (fontSize * .6)));
6283
+ if (text.length > maxChars) el.textContent = `${text.slice(0, maxChars - 1).trimEnd()}…`;
6284
+ return;
6285
+ }
6286
+ if (measured <= maxWidth) return;
6287
+ let lo = 0;
6288
+ let hi = text.length - 1;
6289
+ while (lo < hi) {
6290
+ const mid = Math.ceil((lo + hi) / 2);
6291
+ el.textContent = `${text.slice(0, mid).trimEnd()}…`;
6292
+ if (el.getComputedTextLength() <= maxWidth) lo = mid;
6293
+ else hi = mid - 1;
6294
+ }
6295
+ el.textContent = `${text.slice(0, lo).trimEnd()}…`;
6296
+ }
6297
+ var prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
6298
+ function OrgChart({ nodes, selectedId, onSelect, fitKey, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
6263
6299
  const svgRef = (0, react.useRef)(null);
6264
6300
  const zoomRef = (0, react.useRef)(null);
6265
- const initialTransformRef = (0, react.useRef)(null);
6266
6301
  const treeBoundsRef = (0, react.useRef)(null);
6267
- const uid = (0, react.useId)().replace(/:/g, "");
6302
+ const autoFitRef = (0, react.useRef)(true);
6303
+ const programmaticRef = (0, react.useRef)(false);
6304
+ const pendingFitRef = (0, react.useRef)(false);
6305
+ const lastSizeRef = (0, react.useRef)(null);
6306
+ const onSelectRef = (0, react.useRef)(onSelect);
6307
+ const selectedIdRef = (0, react.useRef)(selectedId);
6308
+ const scaleFrameRef = (0, react.useRef)(0);
6309
+ const [scale, setScale] = (0, react.useState)(1);
6310
+ const uid = `oc-${(0, react.useId)().replace(/[^a-zA-Z0-9_-]/g, "")}`;
6311
+ const interactive = Boolean(onSelect);
6312
+ const isEmpty = nodes.length === 0;
6313
+ const getSize = () => {
6314
+ const rect = svgRef.current?.getBoundingClientRect();
6315
+ return {
6316
+ width: rect?.width ?? 0,
6317
+ height: rect?.height ?? 0
6318
+ };
6319
+ };
6320
+ /** Applies a transform without handing control away from auto-fit. */
6321
+ const applyTransform = (t) => {
6322
+ if (!svgRef.current || !zoomRef.current) return;
6323
+ const svg = d3.select(svgRef.current);
6324
+ svg.interrupt();
6325
+ programmaticRef.current = true;
6326
+ try {
6327
+ svg.call(zoomRef.current.transform, t);
6328
+ } finally {
6329
+ programmaticRef.current = false;
6330
+ }
6331
+ };
6268
6332
  const fitToView = () => {
6269
6333
  if (!svgRef.current || !zoomRef.current || !treeBoundsRef.current) return;
6270
- const { width: W, height: H } = svgRef.current.getBoundingClientRect();
6271
- if (W === 0 || H === 0) return;
6334
+ const { width: W, height: H } = getSize();
6335
+ if (W === 0 || H === 0) {
6336
+ pendingFitRef.current = true;
6337
+ return;
6338
+ }
6339
+ pendingFitRef.current = false;
6272
6340
  const { minX, maxX, minY, maxY } = treeBoundsRef.current;
6273
- const contentLeft = minX - 125;
6274
- const contentRight = maxX + 125;
6275
- const contentTop = minY - 42;
6276
- const contentBottom = maxY + 42;
6341
+ const contentLeft = minX - NODE_W / 2;
6342
+ const contentRight = maxX + NODE_W / 2;
6343
+ const contentTop = minY - NODE_H / 2;
6344
+ const contentBottom = maxY + NODE_H / 2;
6277
6345
  const contentWidth = contentRight - contentLeft;
6278
6346
  const contentHeight = contentBottom - contentTop;
6279
6347
  const scaleX = (W - 80) / Math.max(1, contentWidth);
6280
6348
  const scaleY = (H - 80) / Math.max(1, contentHeight);
6281
- const scale = Math.max(.05, Math.min(1.8, scaleX, scaleY));
6282
- const contentCenterX = (contentLeft + contentRight) / 2;
6283
- const contentCenterY = (contentTop + contentBottom) / 2;
6284
- const tx = W / 2 - contentCenterX * scale;
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);
6349
+ const k = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scaleX, scaleY));
6350
+ const tx = W / 2 - (contentLeft + contentRight) / 2 * k;
6351
+ const ty = H / 2 - (contentTop + contentBottom) / 2 * k;
6352
+ applyTransform(d3.zoomIdentity.translate(tx, ty).scale(k));
6289
6353
  };
6290
- (0, react.useEffect)(() => {
6291
- if (!svgRef.current || nodes.length === 0) return;
6354
+ /** Fit, and go back to keeping the chart fitted (Fit button, new fitKey). */
6355
+ const resetToFit = () => {
6356
+ autoFitRef.current = true;
6357
+ fitToView();
6358
+ };
6359
+ const zoomBy = (factor) => {
6360
+ if (!svgRef.current || !zoomRef.current) return;
6361
+ autoFitRef.current = false;
6292
6362
  const svg = d3.select(svgRef.current);
6293
- svg.selectAll("*").remove();
6294
- const treeData = buildD3Tree(nodes);
6295
- const root = d3.hierarchy(treeData);
6296
- d3.tree().nodeSize([280, 185])(root);
6297
- const descendants = root.descendants();
6363
+ if (prefersReducedMotion()) svg.interrupt().call(zoomRef.current.scaleBy, factor);
6364
+ else svg.transition().duration(250).call(zoomRef.current.scaleBy, factor);
6365
+ };
6366
+ (0, react.useEffect)(() => {
6367
+ onSelectRef.current = onSelect;
6368
+ }, [onSelect]);
6369
+ (0, react.useEffect)(() => {
6370
+ const svgEl = svgRef.current;
6371
+ if (!svgEl) return;
6372
+ const svg = d3.select(svgEl);
6373
+ const viewport = svg.append("g").attr("class", "oc-viewport");
6374
+ viewport.append("g").attr("class", "oc-links");
6375
+ viewport.append("g").attr("class", "oc-nodes");
6376
+ const zoom = d3.zoom().scaleExtent([MIN_SCALE, MAX_SCALE]).extent(() => {
6377
+ const { width, height } = getSize();
6378
+ return [[0, 0], [Math.max(1, width), Math.max(1, height)]];
6379
+ }).clickDistance(5).filter((event) => {
6380
+ if (event.type === "dblclick") return false;
6381
+ const e = event;
6382
+ return (!e.ctrlKey || event.type === "wheel") && !e.button;
6383
+ }).on("zoom", (event) => {
6384
+ viewport.attr("transform", event.transform.toString());
6385
+ if (!programmaticRef.current) autoFitRef.current = false;
6386
+ cancelAnimationFrame(scaleFrameRef.current);
6387
+ scaleFrameRef.current = requestAnimationFrame(() => setScale(event.transform.k));
6388
+ });
6389
+ zoomRef.current = zoom;
6390
+ svg.call(zoom);
6391
+ const ro = new ResizeObserver(() => {
6392
+ const size = getSize();
6393
+ if (size.width === 0 || size.height === 0) return;
6394
+ const last = lastSizeRef.current;
6395
+ lastSizeRef.current = size;
6396
+ if (autoFitRef.current || pendingFitRef.current || !last) {
6397
+ fitToView();
6398
+ return;
6399
+ }
6400
+ const dx = (size.width - last.width) / 2;
6401
+ const dy = (size.height - last.height) / 2;
6402
+ if (dx === 0 && dy === 0) return;
6403
+ const t = d3.zoomTransform(svgEl);
6404
+ applyTransform(d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k));
6405
+ });
6406
+ ro.observe(svgEl);
6407
+ return () => {
6408
+ ro.disconnect();
6409
+ cancelAnimationFrame(scaleFrameRef.current);
6410
+ svg.interrupt().on(".zoom", null);
6411
+ svg.selectAll("*").remove();
6412
+ zoomRef.current = null;
6413
+ };
6414
+ }, []);
6415
+ (0, react.useEffect)(() => {
6416
+ const svgEl = svgRef.current;
6417
+ if (!svgEl) return;
6418
+ const svg = d3.select(svgEl);
6419
+ const linkLayer = svg.select("g.oc-links");
6420
+ const nodeLayer = svg.select("g.oc-nodes");
6421
+ if (nodes.length === 0) {
6422
+ linkLayer.selectAll("*").remove();
6423
+ nodeLayer.selectAll("*").remove();
6424
+ treeBoundsRef.current = null;
6425
+ return;
6426
+ }
6427
+ const root = d3.hierarchy(buildD3Tree(nodes));
6428
+ const pointRoot = d3.tree().nodeSize([280, 185])(root);
6429
+ const descendants = pointRoot.descendants();
6298
6430
  treeBoundsRef.current = {
6299
6431
  minX: d3.min(descendants, (d) => d.x) ?? 0,
6300
6432
  maxX: d3.max(descendants, (d) => d.x) ?? 0,
6301
6433
  minY: d3.min(descendants, (d) => d.y) ?? 0,
6302
6434
  maxY: d3.max(descendants, (d) => d.y) ?? 0
6303
6435
  };
6304
- const g = svg.append("g");
6305
- const zoom = d3.zoom().scaleExtent([.05, 1.8]).on("zoom", (e) => g.attr("transform", e.transform.toString()));
6306
- zoomRef.current = zoom;
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}`;
6436
+ 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) => {
6437
+ const mid = (link.source.y + link.target.y) / 2;
6438
+ return `M${link.source.x},${link.source.y}V${mid}H${link.target.x}V${link.target.y}`;
6314
6439
  });
6315
- const node = g.selectAll("g.node").data(root.descendants().filter((d) => !d.data.isSyntheticRoot)).join("g").attr("class", "node").attr("transform", (d) => `translate(${d.x},${d.y})`).style("cursor", (d) => d.data.originalId !== "__synthetic_root__" ? "pointer" : "default").on("click", (_e, d) => {
6316
- if (d.data.isSyntheticRoot) return;
6317
- onSelect?.(d.data.originalId);
6440
+ const node = nodeLayer.selectAll("g.oc-node").data(descendants.filter((d) => !d.data.isSyntheticRoot), (d) => d.data.originalId).join((enter) => {
6441
+ const g = enter.append("g").attr("class", "oc-node");
6442
+ g.append("title");
6443
+ 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))");
6444
+ 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");
6445
+ g.append("text").attr("class", "oc-name").attr("text-anchor", "middle").attr("y", -8).attr("font-size", 13).attr("font-weight", 800);
6446
+ g.append("text").attr("class", "oc-desc").attr("text-anchor", "middle").attr("y", 16).attr("font-size", 11).attr("font-weight", 600);
6447
+ return g;
6448
+ }).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) => {
6449
+ if (event.key !== "Enter" && event.key !== " ") return;
6450
+ event.preventDefault();
6451
+ onSelectRef.current?.(d.data.originalId);
6452
+ }).on("focus", (_e, d) => {
6453
+ if (!zoomRef.current) return;
6454
+ const { width: W, height: H } = getSize();
6455
+ const [sx, sy] = d3.zoomTransform(svgEl).apply([d.x, d.y]);
6456
+ const margin = 24;
6457
+ if (sx >= margin && sx <= W - margin && sy >= margin && sy <= H - margin) return;
6458
+ autoFitRef.current = false;
6459
+ svg.interrupt().call(zoomRef.current.translateTo, d.x, d.y);
6318
6460
  });
6319
- node.append("rect").attr("x", -125).attr("y", -42).attr("width", 250).attr("height", 84).attr("rx", 14).attr("fill", (d) => d.data.isRoot ? "#0f172a" : "#fff").attr("stroke", (d) => {
6320
- if (selectedId && d.data.originalId === selectedId) return "#6366f1";
6321
- return d.data.isRoot ? "#0f172a" : "#e2e8f0";
6322
- }).attr("stroke-width", (d) => selectedId && d.data.originalId === selectedId ? 2.5 : 1).attr("filter", "drop-shadow(0 6px 12px rgba(15,23,42,0.08))");
6323
- node.append("text").attr("text-anchor", "middle").attr("y", -8).attr("font-size", 13).attr("font-weight", 800).attr("fill", (d) => d.data.isRoot ? "#fff" : "#0f172a").text((d) => d.data.name);
6324
- node.append("text").attr("text-anchor", "middle").attr("y", 16).attr("font-size", 11).attr("font-weight", 600).attr("fill", (d) => d.data.isRoot ? "#cbd5e1" : "#64748b").text((d) => d.data.description);
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();
6461
+ node.select("title").text((d) => d.data.description ? `${d.data.name}\n${d.data.description}` : d.data.name);
6462
+ node.select("text.oc-name").each(function(d) {
6463
+ fitText(this, d.data.name, TEXT_MAX_W, 13);
6464
+ });
6465
+ node.select("text.oc-desc").each(function(d) {
6466
+ fitText(this, d.data.description, TEXT_MAX_W, 11);
6328
6467
  });
6468
+ if (autoFitRef.current || pendingFitRef.current) fitToView();
6469
+ }, [nodes]);
6470
+ (0, react.useEffect)(() => {
6471
+ if (!svgRef.current) return;
6472
+ d3.select(svgRef.current).selectAll("g.oc-node").attr("tabindex", interactive ? 0 : null).attr("role", interactive ? "button" : null);
6473
+ }, [interactive, nodes]);
6474
+ (0, react.useEffect)(() => {
6475
+ selectedIdRef.current = selectedId;
6476
+ if (!svgRef.current) return;
6477
+ 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
6478
  }, [
6330
- nodes,
6331
6479
  selectedId,
6332
- onSelect
6480
+ interactive,
6481
+ nodes
6333
6482
  ]);
6483
+ const fitKeyRef = (0, react.useRef)(fitKey);
6334
6484
  (0, react.useEffect)(() => {
6335
- const el = svgRef.current;
6336
- if (!el) return;
6337
- const ro = new ResizeObserver(() => {
6338
- fitToView();
6339
- });
6340
- ro.observe(el);
6341
- return () => ro.disconnect();
6342
- }, []);
6343
- const zoomBy = (factor) => {
6344
- if (!svgRef.current || !zoomRef.current) return;
6345
- d3.select(svgRef.current).transition().duration(250).call(zoomRef.current.scaleBy, factor);
6346
- };
6347
- const resetZoom = () => {
6348
- fitToView();
6485
+ if (fitKeyRef.current === fitKey) return;
6486
+ fitKeyRef.current = fitKey;
6487
+ resetToFit();
6488
+ }, [fitKey]);
6489
+ const handleKeyDown = (event) => {
6490
+ if (event.ctrlKey || event.metaKey || event.altKey || isEmpty) return;
6491
+ if (event.key === "+" || event.key === "=") zoomBy(BUTTON_ZOOM_STEP);
6492
+ else if (event.key === "-" || event.key === "_") zoomBy(1 / BUTTON_ZOOM_STEP);
6493
+ else if (event.key === "0") resetToFit();
6494
+ else return;
6495
+ event.preventDefault();
6349
6496
  };
6497
+ const atMax = scale >= 1.7990000000000002;
6498
+ const atMin = scale <= .051000000000000004;
6350
6499
  const controlBtnClass = [
6351
6500
  "appearance-none border-0 bg-none font-[inherit]",
6352
6501
  "flex items-center justify-center rounded-lg p-2",
6353
- "text-slate-400 transition hover:bg-slate-100 hover:text-slate-700",
6354
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30"
6502
+ "text-slate-500 transition hover:bg-slate-100 hover:text-slate-700",
6503
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30",
6504
+ "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
6355
6505
  ].join(" ");
6356
6506
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6357
- className: `relative w-full overflow-hidden bg-slate-50/50 ${className}`,
6507
+ 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
6508
  style: { height },
6359
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6360
- className: "absolute right-4 top-4 z-10 flex flex-col gap-2 rounded-lg border border-slate-100 bg-white p-2 shadow-md",
6361
- children: [
6362
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6363
- type: "button",
6364
- className: controlBtnClass,
6365
- onClick: () => zoomBy(1.2),
6366
- "aria-label": "Zoom in",
6367
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomIn, {
6368
- size: 16,
6369
- "aria-hidden": "true"
6370
- })
6371
- }),
6372
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6373
- type: "button",
6374
- className: controlBtnClass,
6375
- onClick: resetZoom,
6376
- "aria-label": "Reset zoom",
6377
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.RotateCcw, {
6378
- size: 16,
6379
- "aria-hidden": "true"
6380
- })
6381
- }),
6382
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6383
- type: "button",
6384
- className: controlBtnClass,
6385
- onClick: () => zoomBy(.8),
6386
- "aria-label": "Zoom out",
6387
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomOut, {
6388
- size: 16,
6509
+ tabIndex: isEmpty ? void 0 : 0,
6510
+ "aria-keyshortcuts": isEmpty ? void 0 : "+ - 0",
6511
+ onKeyDown: handleKeyDown,
6512
+ children: [
6513
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `
6514
+ #${uid} .oc-card { fill: #fff; stroke: #e2e8f0; stroke-width: 1; transition: stroke 120ms ease; }
6515
+ #${uid} .oc-name { fill: #0f172a; }
6516
+ #${uid} .oc-desc { fill: #64748b; }
6517
+ #${uid} .oc-node.is-root .oc-card { fill: #0f172a; stroke: #0f172a; }
6518
+ #${uid} .oc-node.is-root .oc-name { fill: #fff; }
6519
+ #${uid} .oc-node.is-root .oc-desc { fill: #cbd5e1; }
6520
+ #${uid} .oc-node.is-muted .oc-card { fill: #f8fafc; stroke: #94a3b8; stroke-dasharray: 5 4; }
6521
+ #${uid} .oc-node.is-muted .oc-name { fill: #475569; }
6522
+ #${uid} .oc-node.is-muted .oc-desc { fill: #94a3b8; }
6523
+ #${uid} .oc-node.is-root.is-muted .oc-card { fill: #334155; stroke: #94a3b8; }
6524
+ #${uid} .oc-node.is-root.is-muted .oc-name { fill: #e2e8f0; }
6525
+ #${uid} .oc-node.is-root.is-muted .oc-desc { fill: #cbd5e1; }
6526
+ #${uid} .oc-ring { stroke: #6366f1; stroke-width: 2.5; opacity: 0; pointer-events: none; }
6527
+ #${uid}.is-interactive .oc-node { cursor: pointer; }
6528
+ #${uid}.is-interactive .oc-node:hover .oc-card { stroke: #a5b4fc; stroke-width: 1.5; }
6529
+ #${uid} .oc-node.is-selected .oc-card,
6530
+ #${uid}.is-interactive .oc-node.is-selected:hover .oc-card { stroke: #6366f1; stroke-width: 2.5; }
6531
+ #${uid} .oc-node.is-selected .oc-ring { opacity: 0.6; }
6532
+ #${uid} .oc-node:focus { outline: none; }
6533
+ #${uid} .oc-node:focus-visible .oc-ring { opacity: 1; stroke: #2563eb; }
6534
+ ` }),
6535
+ !isEmpty && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6536
+ 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",
6537
+ children: [
6538
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6539
+ type: "button",
6540
+ className: controlBtnClass,
6541
+ onClick: () => zoomBy(BUTTON_ZOOM_STEP),
6542
+ disabled: atMax,
6543
+ "aria-label": "Zoom in",
6544
+ title: "Zoom in (+)",
6545
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomIn, {
6546
+ size: 16,
6547
+ "aria-hidden": "true"
6548
+ })
6549
+ }),
6550
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6551
+ className: "min-w-[3rem] select-none text-center text-[11px] font-semibold tabular-nums text-slate-500",
6552
+ children: [Math.round(scale * 100), "%"]
6553
+ }),
6554
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6555
+ type: "button",
6556
+ className: controlBtnClass,
6557
+ onClick: () => zoomBy(1 / BUTTON_ZOOM_STEP),
6558
+ disabled: atMin,
6559
+ "aria-label": "Zoom out",
6560
+ title: "Zoom out (-)",
6561
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomOut, {
6562
+ size: 16,
6563
+ "aria-hidden": "true"
6564
+ })
6565
+ }),
6566
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6567
+ className: "my-0.5 h-px w-6 bg-slate-100",
6389
6568
  "aria-hidden": "true"
6569
+ }),
6570
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6571
+ type: "button",
6572
+ className: controlBtnClass,
6573
+ onClick: resetToFit,
6574
+ "aria-label": "Fit to screen",
6575
+ title: "Fit to screen (0)",
6576
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Scan, {
6577
+ size: 16,
6578
+ "aria-hidden": "true"
6579
+ })
6390
6580
  })
6391
- })
6392
- ]
6393
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
6394
- ref: svgRef,
6395
- id: uid,
6396
- width: "100%",
6397
- height: "100%",
6398
- className: "block touch-none",
6399
- role: "img",
6400
- "aria-label": ariaLabel
6401
- })]
6581
+ ]
6582
+ }),
6583
+ isEmpty && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6584
+ className: "absolute inset-0 flex items-center justify-center p-6 text-center text-sm font-semibold text-slate-500",
6585
+ children: "No units to display"
6586
+ }),
6587
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
6588
+ ref: svgRef,
6589
+ id: uid,
6590
+ width: "100%",
6591
+ height: "100%",
6592
+ className: `block touch-none ${interactive ? "is-interactive" : ""}`,
6593
+ role: interactive ? "group" : "img",
6594
+ "aria-label": ariaLabel
6595
+ })
6596
+ ]
6402
6597
  });
6403
6598
  }
6404
6599
  //#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, RotateCcw, Search as Search$1, ShieldCheck, TrendingDown, TrendingUp, UploadCloud, X, ZoomIn, ZoomOut } from "lucide-react";
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";
@@ -6135,25 +6135,30 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
6135
6135
  * - 250 × 84 px rounded rectangles (rx=14)
6136
6136
  * - Root (depth 0): dark navy fill (#0f172a), white text
6137
6137
  * - Children: white fill (#fff), slate border (#e2e8f0)
6138
+ * - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
6138
6139
  * - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
6139
- * - Name: 13px, font-weight 800, centered
6140
+ * - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
6140
6141
  * - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
6141
- * - Selected: indigo border overlay ring
6142
+ * - Hover: indigo-300 border; Selected: indigo border + overlay ring
6142
6143
  * Connectors:
6143
6144
  * - Stepped orthogonal path: M→V→H→V
6144
6145
  * - Stroke: #cbd5e1, stroke-width 1.5
6145
6146
  * Canvas:
6146
- * - Viewport sized to container; fit-to-view on render
6147
- * - Zoom: scaleExtent [0.05, 1.8]
6148
- * - Pan: mouse/touch drag
6147
+ * - Fits the tree to the viewport until the user zooms or pans; after that
6148
+ * the user's view is kept across re-renders, data refreshes and resizes
6149
+ * (e.g. a modal hiding the page scrollbar). "Fit to screen" or a new
6150
+ * `fitKey` hands control back to auto-fit.
6151
+ * - Zoom: scaleExtent [0.05, 1.8]; wheel / pinch / buttons / keyboard
6152
+ * - Pan: mouse/touch drag (a drag never counts as a node click)
6149
6153
  * - Layout: nodeSize [280, 185] — horizontal × vertical spacing
6150
6154
  * Controls:
6151
- * - ZoomIn, Reset, ZoomOut buttons (top-right overlay panel)
6155
+ * - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
6152
6156
  *
6153
6157
  * Accessibility:
6154
- * - SVG has role="img" + aria-label
6155
- * - Zoom controls have aria-label
6156
- * - Keyboard: Tab to control buttons; diagram itself is navigated visually
6158
+ * - SVG has an aria-label; role="img" when read-only, role="group" when nodes are selectable
6159
+ * - Selectable nodes are focusable buttons (Tab, Enter / Space to select)
6160
+ * - Keyboard on the chart: + / = zoom in, - zoom out, 0 fit to screen
6161
+ * - Respects prefers-reduced-motion for button zoom transitions
6157
6162
  * - Note: full ARIA tree semantics are not possible in an SVG diagram;
6158
6163
  * use OrgUnitTree for a fully keyboard-accessible list-based alternative
6159
6164
  *
@@ -6165,6 +6170,13 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
6165
6170
  * aria-label="Company organization chart"
6166
6171
  * />
6167
6172
  */
6173
+ var SYNTHETIC_ROOT_ID = "__synthetic_root__";
6174
+ var NODE_W = 250;
6175
+ var NODE_H = 84;
6176
+ var TEXT_MAX_W = 218;
6177
+ var MIN_SCALE = .05;
6178
+ var MAX_SCALE = 1.8;
6179
+ var BUTTON_ZOOM_STEP = 1.2;
6168
6180
  function buildD3Tree(nodes) {
6169
6181
  function convert(node, depth) {
6170
6182
  return {
@@ -6173,161 +6185,344 @@ function buildD3Tree(nodes) {
6173
6185
  description: node.description ?? "",
6174
6186
  isRoot: depth === 0,
6175
6187
  isSyntheticRoot: false,
6188
+ isMuted: node.tone === "muted",
6176
6189
  originalId: node.id,
6177
6190
  children: node.children?.map((c) => convert(c, depth + 1))
6178
6191
  };
6179
6192
  }
6180
6193
  if (nodes.length === 1) return convert(nodes[0], 0);
6181
6194
  return {
6182
- id: "__synthetic_root__",
6195
+ id: SYNTHETIC_ROOT_ID,
6183
6196
  name: "",
6184
6197
  description: "",
6185
6198
  isRoot: true,
6186
6199
  isSyntheticRoot: true,
6187
- originalId: "__synthetic_root__",
6200
+ isMuted: false,
6201
+ originalId: SYNTHETIC_ROOT_ID,
6188
6202
  children: nodes.map((n) => convert(n, 0))
6189
6203
  };
6190
6204
  }
6191
- function OrgChart({ nodes, selectedId, onSelect, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
6205
+ /** Writes `text` into an SVG text element, truncating with an ellipsis to fit `maxWidth`. */
6206
+ function fitText(el, text, maxWidth, fontSize) {
6207
+ el.textContent = text;
6208
+ if (!text) return;
6209
+ const measured = typeof el.getComputedTextLength === "function" ? el.getComputedTextLength() : 0;
6210
+ if (!measured) {
6211
+ const maxChars = Math.max(1, Math.floor(maxWidth / (fontSize * .6)));
6212
+ if (text.length > maxChars) el.textContent = `${text.slice(0, maxChars - 1).trimEnd()}…`;
6213
+ return;
6214
+ }
6215
+ if (measured <= maxWidth) return;
6216
+ let lo = 0;
6217
+ let hi = text.length - 1;
6218
+ while (lo < hi) {
6219
+ const mid = Math.ceil((lo + hi) / 2);
6220
+ el.textContent = `${text.slice(0, mid).trimEnd()}…`;
6221
+ if (el.getComputedTextLength() <= maxWidth) lo = mid;
6222
+ else hi = mid - 1;
6223
+ }
6224
+ el.textContent = `${text.slice(0, lo).trimEnd()}…`;
6225
+ }
6226
+ var prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
6227
+ function OrgChart({ nodes, selectedId, onSelect, fitKey, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
6192
6228
  const svgRef = useRef(null);
6193
6229
  const zoomRef = useRef(null);
6194
- const initialTransformRef = useRef(null);
6195
6230
  const treeBoundsRef = useRef(null);
6196
- const uid = useId().replace(/:/g, "");
6231
+ const autoFitRef = useRef(true);
6232
+ const programmaticRef = useRef(false);
6233
+ const pendingFitRef = useRef(false);
6234
+ const lastSizeRef = useRef(null);
6235
+ const onSelectRef = useRef(onSelect);
6236
+ const selectedIdRef = useRef(selectedId);
6237
+ const scaleFrameRef = useRef(0);
6238
+ const [scale, setScale] = useState(1);
6239
+ const uid = `oc-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
6240
+ const interactive = Boolean(onSelect);
6241
+ const isEmpty = nodes.length === 0;
6242
+ const getSize = () => {
6243
+ const rect = svgRef.current?.getBoundingClientRect();
6244
+ return {
6245
+ width: rect?.width ?? 0,
6246
+ height: rect?.height ?? 0
6247
+ };
6248
+ };
6249
+ /** Applies a transform without handing control away from auto-fit. */
6250
+ const applyTransform = (t) => {
6251
+ if (!svgRef.current || !zoomRef.current) return;
6252
+ const svg = d3.select(svgRef.current);
6253
+ svg.interrupt();
6254
+ programmaticRef.current = true;
6255
+ try {
6256
+ svg.call(zoomRef.current.transform, t);
6257
+ } finally {
6258
+ programmaticRef.current = false;
6259
+ }
6260
+ };
6197
6261
  const fitToView = () => {
6198
6262
  if (!svgRef.current || !zoomRef.current || !treeBoundsRef.current) return;
6199
- const { width: W, height: H } = svgRef.current.getBoundingClientRect();
6200
- if (W === 0 || H === 0) return;
6263
+ const { width: W, height: H } = getSize();
6264
+ if (W === 0 || H === 0) {
6265
+ pendingFitRef.current = true;
6266
+ return;
6267
+ }
6268
+ pendingFitRef.current = false;
6201
6269
  const { minX, maxX, minY, maxY } = treeBoundsRef.current;
6202
- const contentLeft = minX - 125;
6203
- const contentRight = maxX + 125;
6204
- const contentTop = minY - 42;
6205
- const contentBottom = maxY + 42;
6270
+ const contentLeft = minX - NODE_W / 2;
6271
+ const contentRight = maxX + NODE_W / 2;
6272
+ const contentTop = minY - NODE_H / 2;
6273
+ const contentBottom = maxY + NODE_H / 2;
6206
6274
  const contentWidth = contentRight - contentLeft;
6207
6275
  const contentHeight = contentBottom - contentTop;
6208
6276
  const scaleX = (W - 80) / Math.max(1, contentWidth);
6209
6277
  const scaleY = (H - 80) / Math.max(1, contentHeight);
6210
- const scale = Math.max(.05, Math.min(1.8, scaleX, scaleY));
6211
- const contentCenterX = (contentLeft + contentRight) / 2;
6212
- const contentCenterY = (contentTop + contentBottom) / 2;
6213
- const tx = W / 2 - contentCenterX * scale;
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);
6278
+ const k = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scaleX, scaleY));
6279
+ const tx = W / 2 - (contentLeft + contentRight) / 2 * k;
6280
+ const ty = H / 2 - (contentTop + contentBottom) / 2 * k;
6281
+ applyTransform(d3.zoomIdentity.translate(tx, ty).scale(k));
6218
6282
  };
6219
- useEffect(() => {
6220
- if (!svgRef.current || nodes.length === 0) return;
6283
+ /** Fit, and go back to keeping the chart fitted (Fit button, new fitKey). */
6284
+ const resetToFit = () => {
6285
+ autoFitRef.current = true;
6286
+ fitToView();
6287
+ };
6288
+ const zoomBy = (factor) => {
6289
+ if (!svgRef.current || !zoomRef.current) return;
6290
+ autoFitRef.current = false;
6221
6291
  const svg = d3.select(svgRef.current);
6222
- svg.selectAll("*").remove();
6223
- const treeData = buildD3Tree(nodes);
6224
- const root = d3.hierarchy(treeData);
6225
- d3.tree().nodeSize([280, 185])(root);
6226
- const descendants = root.descendants();
6292
+ if (prefersReducedMotion()) svg.interrupt().call(zoomRef.current.scaleBy, factor);
6293
+ else svg.transition().duration(250).call(zoomRef.current.scaleBy, factor);
6294
+ };
6295
+ useEffect(() => {
6296
+ onSelectRef.current = onSelect;
6297
+ }, [onSelect]);
6298
+ useEffect(() => {
6299
+ const svgEl = svgRef.current;
6300
+ if (!svgEl) return;
6301
+ const svg = d3.select(svgEl);
6302
+ const viewport = svg.append("g").attr("class", "oc-viewport");
6303
+ viewport.append("g").attr("class", "oc-links");
6304
+ viewport.append("g").attr("class", "oc-nodes");
6305
+ const zoom = d3.zoom().scaleExtent([MIN_SCALE, MAX_SCALE]).extent(() => {
6306
+ const { width, height } = getSize();
6307
+ return [[0, 0], [Math.max(1, width), Math.max(1, height)]];
6308
+ }).clickDistance(5).filter((event) => {
6309
+ if (event.type === "dblclick") return false;
6310
+ const e = event;
6311
+ return (!e.ctrlKey || event.type === "wheel") && !e.button;
6312
+ }).on("zoom", (event) => {
6313
+ viewport.attr("transform", event.transform.toString());
6314
+ if (!programmaticRef.current) autoFitRef.current = false;
6315
+ cancelAnimationFrame(scaleFrameRef.current);
6316
+ scaleFrameRef.current = requestAnimationFrame(() => setScale(event.transform.k));
6317
+ });
6318
+ zoomRef.current = zoom;
6319
+ svg.call(zoom);
6320
+ const ro = new ResizeObserver(() => {
6321
+ const size = getSize();
6322
+ if (size.width === 0 || size.height === 0) return;
6323
+ const last = lastSizeRef.current;
6324
+ lastSizeRef.current = size;
6325
+ if (autoFitRef.current || pendingFitRef.current || !last) {
6326
+ fitToView();
6327
+ return;
6328
+ }
6329
+ const dx = (size.width - last.width) / 2;
6330
+ const dy = (size.height - last.height) / 2;
6331
+ if (dx === 0 && dy === 0) return;
6332
+ const t = d3.zoomTransform(svgEl);
6333
+ applyTransform(d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k));
6334
+ });
6335
+ ro.observe(svgEl);
6336
+ return () => {
6337
+ ro.disconnect();
6338
+ cancelAnimationFrame(scaleFrameRef.current);
6339
+ svg.interrupt().on(".zoom", null);
6340
+ svg.selectAll("*").remove();
6341
+ zoomRef.current = null;
6342
+ };
6343
+ }, []);
6344
+ useEffect(() => {
6345
+ const svgEl = svgRef.current;
6346
+ if (!svgEl) return;
6347
+ const svg = d3.select(svgEl);
6348
+ const linkLayer = svg.select("g.oc-links");
6349
+ const nodeLayer = svg.select("g.oc-nodes");
6350
+ if (nodes.length === 0) {
6351
+ linkLayer.selectAll("*").remove();
6352
+ nodeLayer.selectAll("*").remove();
6353
+ treeBoundsRef.current = null;
6354
+ return;
6355
+ }
6356
+ const root = d3.hierarchy(buildD3Tree(nodes));
6357
+ const pointRoot = d3.tree().nodeSize([280, 185])(root);
6358
+ const descendants = pointRoot.descendants();
6227
6359
  treeBoundsRef.current = {
6228
6360
  minX: d3.min(descendants, (d) => d.x) ?? 0,
6229
6361
  maxX: d3.max(descendants, (d) => d.x) ?? 0,
6230
6362
  minY: d3.min(descendants, (d) => d.y) ?? 0,
6231
6363
  maxY: d3.max(descendants, (d) => d.y) ?? 0
6232
6364
  };
6233
- const g = svg.append("g");
6234
- const zoom = d3.zoom().scaleExtent([.05, 1.8]).on("zoom", (e) => g.attr("transform", e.transform.toString()));
6235
- zoomRef.current = zoom;
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}`;
6365
+ 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) => {
6366
+ const mid = (link.source.y + link.target.y) / 2;
6367
+ return `M${link.source.x},${link.source.y}V${mid}H${link.target.x}V${link.target.y}`;
6243
6368
  });
6244
- const node = g.selectAll("g.node").data(root.descendants().filter((d) => !d.data.isSyntheticRoot)).join("g").attr("class", "node").attr("transform", (d) => `translate(${d.x},${d.y})`).style("cursor", (d) => d.data.originalId !== "__synthetic_root__" ? "pointer" : "default").on("click", (_e, d) => {
6245
- if (d.data.isSyntheticRoot) return;
6246
- onSelect?.(d.data.originalId);
6369
+ const node = nodeLayer.selectAll("g.oc-node").data(descendants.filter((d) => !d.data.isSyntheticRoot), (d) => d.data.originalId).join((enter) => {
6370
+ const g = enter.append("g").attr("class", "oc-node");
6371
+ g.append("title");
6372
+ 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))");
6373
+ 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");
6374
+ g.append("text").attr("class", "oc-name").attr("text-anchor", "middle").attr("y", -8).attr("font-size", 13).attr("font-weight", 800);
6375
+ g.append("text").attr("class", "oc-desc").attr("text-anchor", "middle").attr("y", 16).attr("font-size", 11).attr("font-weight", 600);
6376
+ return g;
6377
+ }).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) => {
6378
+ if (event.key !== "Enter" && event.key !== " ") return;
6379
+ event.preventDefault();
6380
+ onSelectRef.current?.(d.data.originalId);
6381
+ }).on("focus", (_e, d) => {
6382
+ if (!zoomRef.current) return;
6383
+ const { width: W, height: H } = getSize();
6384
+ const [sx, sy] = d3.zoomTransform(svgEl).apply([d.x, d.y]);
6385
+ const margin = 24;
6386
+ if (sx >= margin && sx <= W - margin && sy >= margin && sy <= H - margin) return;
6387
+ autoFitRef.current = false;
6388
+ svg.interrupt().call(zoomRef.current.translateTo, d.x, d.y);
6247
6389
  });
6248
- node.append("rect").attr("x", -125).attr("y", -42).attr("width", 250).attr("height", 84).attr("rx", 14).attr("fill", (d) => d.data.isRoot ? "#0f172a" : "#fff").attr("stroke", (d) => {
6249
- if (selectedId && d.data.originalId === selectedId) return "#6366f1";
6250
- return d.data.isRoot ? "#0f172a" : "#e2e8f0";
6251
- }).attr("stroke-width", (d) => selectedId && d.data.originalId === selectedId ? 2.5 : 1).attr("filter", "drop-shadow(0 6px 12px rgba(15,23,42,0.08))");
6252
- node.append("text").attr("text-anchor", "middle").attr("y", -8).attr("font-size", 13).attr("font-weight", 800).attr("fill", (d) => d.data.isRoot ? "#fff" : "#0f172a").text((d) => d.data.name);
6253
- node.append("text").attr("text-anchor", "middle").attr("y", 16).attr("font-size", 11).attr("font-weight", 600).attr("fill", (d) => d.data.isRoot ? "#cbd5e1" : "#64748b").text((d) => d.data.description);
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();
6390
+ node.select("title").text((d) => d.data.description ? `${d.data.name}\n${d.data.description}` : d.data.name);
6391
+ node.select("text.oc-name").each(function(d) {
6392
+ fitText(this, d.data.name, TEXT_MAX_W, 13);
6393
+ });
6394
+ node.select("text.oc-desc").each(function(d) {
6395
+ fitText(this, d.data.description, TEXT_MAX_W, 11);
6257
6396
  });
6397
+ if (autoFitRef.current || pendingFitRef.current) fitToView();
6398
+ }, [nodes]);
6399
+ useEffect(() => {
6400
+ if (!svgRef.current) return;
6401
+ d3.select(svgRef.current).selectAll("g.oc-node").attr("tabindex", interactive ? 0 : null).attr("role", interactive ? "button" : null);
6402
+ }, [interactive, nodes]);
6403
+ useEffect(() => {
6404
+ selectedIdRef.current = selectedId;
6405
+ if (!svgRef.current) return;
6406
+ 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
6407
  }, [
6259
- nodes,
6260
6408
  selectedId,
6261
- onSelect
6409
+ interactive,
6410
+ nodes
6262
6411
  ]);
6412
+ const fitKeyRef = useRef(fitKey);
6263
6413
  useEffect(() => {
6264
- const el = svgRef.current;
6265
- if (!el) return;
6266
- const ro = new ResizeObserver(() => {
6267
- fitToView();
6268
- });
6269
- ro.observe(el);
6270
- return () => ro.disconnect();
6271
- }, []);
6272
- const zoomBy = (factor) => {
6273
- if (!svgRef.current || !zoomRef.current) return;
6274
- d3.select(svgRef.current).transition().duration(250).call(zoomRef.current.scaleBy, factor);
6275
- };
6276
- const resetZoom = () => {
6277
- fitToView();
6414
+ if (fitKeyRef.current === fitKey) return;
6415
+ fitKeyRef.current = fitKey;
6416
+ resetToFit();
6417
+ }, [fitKey]);
6418
+ const handleKeyDown = (event) => {
6419
+ if (event.ctrlKey || event.metaKey || event.altKey || isEmpty) return;
6420
+ if (event.key === "+" || event.key === "=") zoomBy(BUTTON_ZOOM_STEP);
6421
+ else if (event.key === "-" || event.key === "_") zoomBy(1 / BUTTON_ZOOM_STEP);
6422
+ else if (event.key === "0") resetToFit();
6423
+ else return;
6424
+ event.preventDefault();
6278
6425
  };
6426
+ const atMax = scale >= 1.7990000000000002;
6427
+ const atMin = scale <= .051000000000000004;
6279
6428
  const controlBtnClass = [
6280
6429
  "appearance-none border-0 bg-none font-[inherit]",
6281
6430
  "flex items-center justify-center rounded-lg p-2",
6282
- "text-slate-400 transition hover:bg-slate-100 hover:text-slate-700",
6283
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30"
6431
+ "text-slate-500 transition hover:bg-slate-100 hover:text-slate-700",
6432
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30",
6433
+ "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
6284
6434
  ].join(" ");
6285
6435
  return /* @__PURE__ */ jsxs("div", {
6286
- className: `relative w-full overflow-hidden bg-slate-50/50 ${className}`,
6436
+ 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
6437
  style: { height },
6288
- children: [/* @__PURE__ */ jsxs("div", {
6289
- className: "absolute right-4 top-4 z-10 flex flex-col gap-2 rounded-lg border border-slate-100 bg-white p-2 shadow-md",
6290
- children: [
6291
- /* @__PURE__ */ jsx("button", {
6292
- type: "button",
6293
- className: controlBtnClass,
6294
- onClick: () => zoomBy(1.2),
6295
- "aria-label": "Zoom in",
6296
- children: /* @__PURE__ */ jsx(ZoomIn, {
6297
- size: 16,
6298
- "aria-hidden": "true"
6299
- })
6300
- }),
6301
- /* @__PURE__ */ jsx("button", {
6302
- type: "button",
6303
- className: controlBtnClass,
6304
- onClick: resetZoom,
6305
- "aria-label": "Reset zoom",
6306
- children: /* @__PURE__ */ jsx(RotateCcw, {
6307
- size: 16,
6308
- "aria-hidden": "true"
6309
- })
6310
- }),
6311
- /* @__PURE__ */ jsx("button", {
6312
- type: "button",
6313
- className: controlBtnClass,
6314
- onClick: () => zoomBy(.8),
6315
- "aria-label": "Zoom out",
6316
- children: /* @__PURE__ */ jsx(ZoomOut, {
6317
- size: 16,
6438
+ tabIndex: isEmpty ? void 0 : 0,
6439
+ "aria-keyshortcuts": isEmpty ? void 0 : "+ - 0",
6440
+ onKeyDown: handleKeyDown,
6441
+ children: [
6442
+ /* @__PURE__ */ jsx("style", { children: `
6443
+ #${uid} .oc-card { fill: #fff; stroke: #e2e8f0; stroke-width: 1; transition: stroke 120ms ease; }
6444
+ #${uid} .oc-name { fill: #0f172a; }
6445
+ #${uid} .oc-desc { fill: #64748b; }
6446
+ #${uid} .oc-node.is-root .oc-card { fill: #0f172a; stroke: #0f172a; }
6447
+ #${uid} .oc-node.is-root .oc-name { fill: #fff; }
6448
+ #${uid} .oc-node.is-root .oc-desc { fill: #cbd5e1; }
6449
+ #${uid} .oc-node.is-muted .oc-card { fill: #f8fafc; stroke: #94a3b8; stroke-dasharray: 5 4; }
6450
+ #${uid} .oc-node.is-muted .oc-name { fill: #475569; }
6451
+ #${uid} .oc-node.is-muted .oc-desc { fill: #94a3b8; }
6452
+ #${uid} .oc-node.is-root.is-muted .oc-card { fill: #334155; stroke: #94a3b8; }
6453
+ #${uid} .oc-node.is-root.is-muted .oc-name { fill: #e2e8f0; }
6454
+ #${uid} .oc-node.is-root.is-muted .oc-desc { fill: #cbd5e1; }
6455
+ #${uid} .oc-ring { stroke: #6366f1; stroke-width: 2.5; opacity: 0; pointer-events: none; }
6456
+ #${uid}.is-interactive .oc-node { cursor: pointer; }
6457
+ #${uid}.is-interactive .oc-node:hover .oc-card { stroke: #a5b4fc; stroke-width: 1.5; }
6458
+ #${uid} .oc-node.is-selected .oc-card,
6459
+ #${uid}.is-interactive .oc-node.is-selected:hover .oc-card { stroke: #6366f1; stroke-width: 2.5; }
6460
+ #${uid} .oc-node.is-selected .oc-ring { opacity: 0.6; }
6461
+ #${uid} .oc-node:focus { outline: none; }
6462
+ #${uid} .oc-node:focus-visible .oc-ring { opacity: 1; stroke: #2563eb; }
6463
+ ` }),
6464
+ !isEmpty && /* @__PURE__ */ jsxs("div", {
6465
+ 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",
6466
+ children: [
6467
+ /* @__PURE__ */ jsx("button", {
6468
+ type: "button",
6469
+ className: controlBtnClass,
6470
+ onClick: () => zoomBy(BUTTON_ZOOM_STEP),
6471
+ disabled: atMax,
6472
+ "aria-label": "Zoom in",
6473
+ title: "Zoom in (+)",
6474
+ children: /* @__PURE__ */ jsx(ZoomIn, {
6475
+ size: 16,
6476
+ "aria-hidden": "true"
6477
+ })
6478
+ }),
6479
+ /* @__PURE__ */ jsxs("span", {
6480
+ className: "min-w-[3rem] select-none text-center text-[11px] font-semibold tabular-nums text-slate-500",
6481
+ children: [Math.round(scale * 100), "%"]
6482
+ }),
6483
+ /* @__PURE__ */ jsx("button", {
6484
+ type: "button",
6485
+ className: controlBtnClass,
6486
+ onClick: () => zoomBy(1 / BUTTON_ZOOM_STEP),
6487
+ disabled: atMin,
6488
+ "aria-label": "Zoom out",
6489
+ title: "Zoom out (-)",
6490
+ children: /* @__PURE__ */ jsx(ZoomOut, {
6491
+ size: 16,
6492
+ "aria-hidden": "true"
6493
+ })
6494
+ }),
6495
+ /* @__PURE__ */ jsx("div", {
6496
+ className: "my-0.5 h-px w-6 bg-slate-100",
6318
6497
  "aria-hidden": "true"
6498
+ }),
6499
+ /* @__PURE__ */ jsx("button", {
6500
+ type: "button",
6501
+ className: controlBtnClass,
6502
+ onClick: resetToFit,
6503
+ "aria-label": "Fit to screen",
6504
+ title: "Fit to screen (0)",
6505
+ children: /* @__PURE__ */ jsx(Scan, {
6506
+ size: 16,
6507
+ "aria-hidden": "true"
6508
+ })
6319
6509
  })
6320
- })
6321
- ]
6322
- }), /* @__PURE__ */ jsx("svg", {
6323
- ref: svgRef,
6324
- id: uid,
6325
- width: "100%",
6326
- height: "100%",
6327
- className: "block touch-none",
6328
- role: "img",
6329
- "aria-label": ariaLabel
6330
- })]
6510
+ ]
6511
+ }),
6512
+ isEmpty && /* @__PURE__ */ jsx("div", {
6513
+ className: "absolute inset-0 flex items-center justify-center p-6 text-center text-sm font-semibold text-slate-500",
6514
+ children: "No units to display"
6515
+ }),
6516
+ /* @__PURE__ */ jsx("svg", {
6517
+ ref: svgRef,
6518
+ id: uid,
6519
+ width: "100%",
6520
+ height: "100%",
6521
+ className: `block touch-none ${interactive ? "is-interactive" : ""}`,
6522
+ role: interactive ? "group" : "img",
6523
+ "aria-label": ariaLabel
6524
+ })
6525
+ ]
6331
6526
  });
6332
6527
  }
6333
6528
  //#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
- * - Viewport sized to container; fit-to-view on render
25
- * - Zoom: scaleExtent [0.05, 1.8]
26
- * - Pan: mouse/touch drag
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
- * - ZoomIn, Reset, ZoomOut buttons (top-right overlay panel)
33
+ * - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
30
34
  *
31
35
  * Accessibility:
32
- * - SVG has role="img" + aria-label
33
- * - Zoom controls have aria-label
34
- * - Keyboard: Tab to control buttons; diagram itself is navigated visually
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;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@diwauhris/ui",
3
3
  "private": false,
4
- "version": "1.7.42",
4
+ "version": "1.7.43",
5
5
  "type": "module",
6
6
  "description": "DIWA UHRIS UI component library — React + Tailwind CSS",
7
7
  "keywords": [