@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 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 max-h-60 rounded-xl border border-slate-200 bg-white py-1 shadow-xl",
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
- * - Viewport sized to container; fit-to-view on render
6218
- * - Zoom: scaleExtent [0.05, 1.8]
6219
- * - Pan: mouse/touch drag
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
- * - ZoomIn, Reset, ZoomOut buttons (top-right overlay panel)
6232
+ * - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
6223
6233
  *
6224
6234
  * 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
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: "__synthetic_root__",
6272
+ id: SYNTHETIC_ROOT_ID,
6254
6273
  name: "",
6255
6274
  description: "",
6256
6275
  isRoot: true,
6257
6276
  isSyntheticRoot: true,
6258
- originalId: "__synthetic_root__",
6277
+ isMuted: false,
6278
+ originalId: SYNTHETIC_ROOT_ID,
6259
6279
  children: nodes.map((n) => convert(n, 0))
6260
6280
  };
6261
6281
  }
6262
- function OrgChart({ nodes, selectedId, onSelect, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
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 uid = (0, react.useId)().replace(/:/g, "");
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 } = svgRef.current.getBoundingClientRect();
6271
- if (W === 0 || H === 0) return;
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 - 125;
6274
- const contentRight = maxX + 125;
6275
- const contentTop = minY - 42;
6276
- const contentBottom = maxY + 42;
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 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);
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
- (0, react.useEffect)(() => {
6291
- if (!svgRef.current || nodes.length === 0) return;
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.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();
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
- 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}`;
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 = 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);
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.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();
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
- onSelect
6486
+ interactive,
6487
+ nodes
6333
6488
  ]);
6489
+ const fitKeyRef = (0, react.useRef)(fitKey);
6334
6490
  (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();
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-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"
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
- 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,
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
- }), /* @__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
- })]
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, 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";
@@ -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 max-h-60 rounded-xl border border-slate-200 bg-white py-1 shadow-xl",
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
- * - Viewport sized to container; fit-to-view on render
6147
- * - Zoom: scaleExtent [0.05, 1.8]
6148
- * - Pan: mouse/touch drag
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
- * - ZoomIn, Reset, ZoomOut buttons (top-right overlay panel)
6161
+ * - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
6152
6162
  *
6153
6163
  * 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
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: "__synthetic_root__",
6201
+ id: SYNTHETIC_ROOT_ID,
6183
6202
  name: "",
6184
6203
  description: "",
6185
6204
  isRoot: true,
6186
6205
  isSyntheticRoot: true,
6187
- originalId: "__synthetic_root__",
6206
+ isMuted: false,
6207
+ originalId: SYNTHETIC_ROOT_ID,
6188
6208
  children: nodes.map((n) => convert(n, 0))
6189
6209
  };
6190
6210
  }
6191
- function OrgChart({ nodes, selectedId, onSelect, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
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 uid = useId().replace(/:/g, "");
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 } = svgRef.current.getBoundingClientRect();
6200
- if (W === 0 || H === 0) return;
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 - 125;
6203
- const contentRight = maxX + 125;
6204
- const contentTop = minY - 42;
6205
- const contentBottom = maxY + 42;
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 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);
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
- useEffect(() => {
6220
- if (!svgRef.current || nodes.length === 0) return;
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.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();
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
- 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}`;
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 = 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);
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.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();
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
- onSelect
6415
+ interactive,
6416
+ nodes
6262
6417
  ]);
6418
+ const fitKeyRef = useRef(fitKey);
6263
6419
  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();
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-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"
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
- 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,
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
- }), /* @__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
- })]
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
- * - 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.44",
5
5
  "type": "module",
6
6
  "description": "DIWA UHRIS UI component library — React + Tailwind CSS",
7
7
  "keywords": [