@diwauhris/ui 1.7.41 → 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
@@ -54,6 +54,7 @@ let sonner = require("sonner");
54
54
  function cn(...inputs) {
55
55
  return (0, tailwind_merge.twMerge)((0, clsx.clsx)(inputs));
56
56
  }
57
+ var FLOATING_LAYER_SELECTOR = `.pis-datepicker-popover, [data-pis-floating]`;
57
58
  //#endregion
58
59
  //#region node_modules/@radix-ui/react-compose-refs/dist/index.mjs
59
60
  var __defProp$1 = Object.defineProperty;
@@ -2471,7 +2472,7 @@ function Modal({ isOpen, onClose, title, children, icon, subtitle, headerMeta, f
2471
2472
  onCloseAutoFocus,
2472
2473
  onEscapeKeyDown: preventClose ? (e) => e.preventDefault() : onEscapeKeyDown,
2473
2474
  onInteractOutside: (e) => {
2474
- if (e.target?.closest(".pis-datepicker-popover")) {
2475
+ if (e.target?.closest(FLOATING_LAYER_SELECTOR)) {
2475
2476
  e.preventDefault();
2476
2477
  return;
2477
2478
  }
@@ -2596,7 +2597,7 @@ function Dialog({ open, onClose, title, description, preventClose = false, size
2596
2597
  className: cn("fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2", "flex w-full flex-col overflow-hidden", "rounded-lg bg-white shadow-xl", "max-h-[90vh] outline-none", SIZE_CLASS[size]),
2597
2598
  onEscapeKeyDown: preventClose ? (e) => e.preventDefault() : onEscapeKeyDown,
2598
2599
  onInteractOutside: (e) => {
2599
- if (e.target?.closest(".pis-datepicker-popover")) {
2600
+ if (e.target?.closest(FLOATING_LAYER_SELECTOR)) {
2600
2601
  e.preventDefault();
2601
2602
  return;
2602
2603
  }
@@ -4265,7 +4266,11 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4265
4266
  ref: panelRef,
4266
4267
  role: "menu",
4267
4268
  "aria-label": ariaLabel,
4268
- style: panelStyle,
4269
+ ["data-pis-floating"]: "",
4270
+ style: {
4271
+ ...panelStyle,
4272
+ pointerEvents: "auto"
4273
+ },
4269
4274
  className: cn("overflow-y-auto overscroll-contain rounded-xl border border-slate-200 bg-white py-1 shadow-xl", panelClassName),
4270
4275
  children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4271
4276
  ref: (node) => {
@@ -6201,25 +6206,30 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
6201
6206
  * - 250 × 84 px rounded rectangles (rx=14)
6202
6207
  * - Root (depth 0): dark navy fill (#0f172a), white text
6203
6208
  * - Children: white fill (#fff), slate border (#e2e8f0)
6209
+ * - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
6204
6210
  * - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
6205
- * - Name: 13px, font-weight 800, centered
6211
+ * - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
6206
6212
  * - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
6207
- * - Selected: indigo border overlay ring
6213
+ * - Hover: indigo-300 border; Selected: indigo border + overlay ring
6208
6214
  * Connectors:
6209
6215
  * - Stepped orthogonal path: M→V→H→V
6210
6216
  * - Stroke: #cbd5e1, stroke-width 1.5
6211
6217
  * Canvas:
6212
- * - Viewport sized to container; fit-to-view on render
6213
- * - Zoom: scaleExtent [0.05, 1.8]
6214
- * - 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)
6215
6224
  * - Layout: nodeSize [280, 185] — horizontal × vertical spacing
6216
6225
  * Controls:
6217
- * - ZoomIn, Reset, ZoomOut buttons (top-right overlay panel)
6226
+ * - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
6218
6227
  *
6219
6228
  * Accessibility:
6220
- * - SVG has role="img" + aria-label
6221
- * - Zoom controls have aria-label
6222
- * - 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
6223
6233
  * - Note: full ARIA tree semantics are not possible in an SVG diagram;
6224
6234
  * use OrgUnitTree for a fully keyboard-accessible list-based alternative
6225
6235
  *
@@ -6231,6 +6241,13 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
6231
6241
  * aria-label="Company organization chart"
6232
6242
  * />
6233
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;
6234
6251
  function buildD3Tree(nodes) {
6235
6252
  function convert(node, depth) {
6236
6253
  return {
@@ -6239,161 +6256,344 @@ function buildD3Tree(nodes) {
6239
6256
  description: node.description ?? "",
6240
6257
  isRoot: depth === 0,
6241
6258
  isSyntheticRoot: false,
6259
+ isMuted: node.tone === "muted",
6242
6260
  originalId: node.id,
6243
6261
  children: node.children?.map((c) => convert(c, depth + 1))
6244
6262
  };
6245
6263
  }
6246
6264
  if (nodes.length === 1) return convert(nodes[0], 0);
6247
6265
  return {
6248
- id: "__synthetic_root__",
6266
+ id: SYNTHETIC_ROOT_ID,
6249
6267
  name: "",
6250
6268
  description: "",
6251
6269
  isRoot: true,
6252
6270
  isSyntheticRoot: true,
6253
- originalId: "__synthetic_root__",
6271
+ isMuted: false,
6272
+ originalId: SYNTHETIC_ROOT_ID,
6254
6273
  children: nodes.map((n) => convert(n, 0))
6255
6274
  };
6256
6275
  }
6257
- 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 = "" }) {
6258
6299
  const svgRef = (0, react.useRef)(null);
6259
6300
  const zoomRef = (0, react.useRef)(null);
6260
- const initialTransformRef = (0, react.useRef)(null);
6261
6301
  const treeBoundsRef = (0, react.useRef)(null);
6262
- 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
+ };
6263
6332
  const fitToView = () => {
6264
6333
  if (!svgRef.current || !zoomRef.current || !treeBoundsRef.current) return;
6265
- const { width: W, height: H } = svgRef.current.getBoundingClientRect();
6266
- 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;
6267
6340
  const { minX, maxX, minY, maxY } = treeBoundsRef.current;
6268
- const contentLeft = minX - 125;
6269
- const contentRight = maxX + 125;
6270
- const contentTop = minY - 42;
6271
- 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;
6272
6345
  const contentWidth = contentRight - contentLeft;
6273
6346
  const contentHeight = contentBottom - contentTop;
6274
6347
  const scaleX = (W - 80) / Math.max(1, contentWidth);
6275
6348
  const scaleY = (H - 80) / Math.max(1, contentHeight);
6276
- const scale = Math.max(.05, Math.min(1.8, scaleX, scaleY));
6277
- const contentCenterX = (contentLeft + contentRight) / 2;
6278
- const contentCenterY = (contentTop + contentBottom) / 2;
6279
- const tx = W / 2 - contentCenterX * scale;
6280
- const ty = H / 2 - contentCenterY * scale;
6281
- const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
6282
- initialTransformRef.current = t;
6283
- 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));
6284
6353
  };
6285
- (0, react.useEffect)(() => {
6286
- 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;
6287
6362
  const svg = d3.select(svgRef.current);
6288
- svg.selectAll("*").remove();
6289
- const treeData = buildD3Tree(nodes);
6290
- const root = d3.hierarchy(treeData);
6291
- d3.tree().nodeSize([280, 185])(root);
6292
- 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();
6293
6430
  treeBoundsRef.current = {
6294
6431
  minX: d3.min(descendants, (d) => d.x) ?? 0,
6295
6432
  maxX: d3.max(descendants, (d) => d.x) ?? 0,
6296
6433
  minY: d3.min(descendants, (d) => d.y) ?? 0,
6297
6434
  maxY: d3.max(descendants, (d) => d.y) ?? 0
6298
6435
  };
6299
- const g = svg.append("g");
6300
- const zoom = d3.zoom().scaleExtent([.05, 1.8]).on("zoom", (e) => g.attr("transform", e.transform.toString()));
6301
- zoomRef.current = zoom;
6302
- svg.call(zoom);
6303
- 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) => {
6304
- const sx = link.source.x ?? 0;
6305
- const sy = link.source.y ?? 0;
6306
- const tx = link.target.x ?? 0;
6307
- const ty = link.target.y ?? 0;
6308
- 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}`;
6309
6439
  });
6310
- 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) => {
6311
- if (d.data.isSyntheticRoot) return;
6312
- 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);
6313
6460
  });
6314
- 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) => {
6315
- if (selectedId && d.data.originalId === selectedId) return "#6366f1";
6316
- return d.data.isRoot ? "#0f172a" : "#e2e8f0";
6317
- }).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))");
6318
- 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);
6319
- 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);
6320
- 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);
6321
- requestAnimationFrame(() => {
6322
- 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);
6323
6464
  });
6465
+ node.select("text.oc-desc").each(function(d) {
6466
+ fitText(this, d.data.description, TEXT_MAX_W, 11);
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);
6324
6478
  }, [
6325
- nodes,
6326
6479
  selectedId,
6327
- onSelect
6480
+ interactive,
6481
+ nodes
6328
6482
  ]);
6483
+ const fitKeyRef = (0, react.useRef)(fitKey);
6329
6484
  (0, react.useEffect)(() => {
6330
- const el = svgRef.current;
6331
- if (!el) return;
6332
- const ro = new ResizeObserver(() => {
6333
- fitToView();
6334
- });
6335
- ro.observe(el);
6336
- return () => ro.disconnect();
6337
- }, []);
6338
- const zoomBy = (factor) => {
6339
- if (!svgRef.current || !zoomRef.current) return;
6340
- d3.select(svgRef.current).transition().duration(250).call(zoomRef.current.scaleBy, factor);
6341
- };
6342
- const resetZoom = () => {
6343
- 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();
6344
6496
  };
6497
+ const atMax = scale >= 1.7990000000000002;
6498
+ const atMin = scale <= .051000000000000004;
6345
6499
  const controlBtnClass = [
6346
6500
  "appearance-none border-0 bg-none font-[inherit]",
6347
6501
  "flex items-center justify-center rounded-lg p-2",
6348
- "text-slate-400 transition hover:bg-slate-100 hover:text-slate-700",
6349
- "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"
6350
6505
  ].join(" ");
6351
6506
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6352
- 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}`,
6353
6508
  style: { height },
6354
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6355
- 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",
6356
- children: [
6357
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6358
- type: "button",
6359
- className: controlBtnClass,
6360
- onClick: () => zoomBy(1.2),
6361
- "aria-label": "Zoom in",
6362
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomIn, {
6363
- size: 16,
6364
- "aria-hidden": "true"
6365
- })
6366
- }),
6367
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6368
- type: "button",
6369
- className: controlBtnClass,
6370
- onClick: resetZoom,
6371
- "aria-label": "Reset zoom",
6372
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.RotateCcw, {
6373
- size: 16,
6374
- "aria-hidden": "true"
6375
- })
6376
- }),
6377
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6378
- type: "button",
6379
- className: controlBtnClass,
6380
- onClick: () => zoomBy(.8),
6381
- "aria-label": "Zoom out",
6382
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomOut, {
6383
- 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",
6384
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
+ })
6385
6580
  })
6386
- })
6387
- ]
6388
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
6389
- ref: svgRef,
6390
- id: uid,
6391
- width: "100%",
6392
- height: "100%",
6393
- className: "block touch-none",
6394
- role: "img",
6395
- "aria-label": ariaLabel
6396
- })]
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
+ ]
6397
6597
  });
6398
6598
  }
6399
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";
@@ -30,6 +30,7 @@ var __exportAll = (all, no_symbols) => {
30
30
  function cn(...inputs) {
31
31
  return twMerge(clsx(inputs));
32
32
  }
33
+ var FLOATING_LAYER_SELECTOR = `.pis-datepicker-popover, [data-pis-floating]`;
33
34
  //#endregion
34
35
  //#region node_modules/@radix-ui/react-compose-refs/dist/index.mjs
35
36
  var __defProp$1 = Object.defineProperty;
@@ -2447,7 +2448,7 @@ function Modal({ isOpen, onClose, title, children, icon, subtitle, headerMeta, f
2447
2448
  onCloseAutoFocus,
2448
2449
  onEscapeKeyDown: preventClose ? (e) => e.preventDefault() : onEscapeKeyDown,
2449
2450
  onInteractOutside: (e) => {
2450
- if (e.target?.closest(".pis-datepicker-popover")) {
2451
+ if (e.target?.closest(FLOATING_LAYER_SELECTOR)) {
2451
2452
  e.preventDefault();
2452
2453
  return;
2453
2454
  }
@@ -2572,7 +2573,7 @@ function Dialog({ open, onClose, title, description, preventClose = false, size
2572
2573
  className: cn("fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2", "flex w-full flex-col overflow-hidden", "rounded-lg bg-white shadow-xl", "max-h-[90vh] outline-none", SIZE_CLASS[size]),
2573
2574
  onEscapeKeyDown: preventClose ? (e) => e.preventDefault() : onEscapeKeyDown,
2574
2575
  onInteractOutside: (e) => {
2575
- if (e.target?.closest(".pis-datepicker-popover")) {
2576
+ if (e.target?.closest(FLOATING_LAYER_SELECTOR)) {
2576
2577
  e.preventDefault();
2577
2578
  return;
2578
2579
  }
@@ -4241,7 +4242,11 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4241
4242
  ref: panelRef,
4242
4243
  role: "menu",
4243
4244
  "aria-label": ariaLabel,
4244
- style: panelStyle,
4245
+ ["data-pis-floating"]: "",
4246
+ style: {
4247
+ ...panelStyle,
4248
+ pointerEvents: "auto"
4249
+ },
4245
4250
  className: cn("overflow-y-auto overscroll-contain rounded-xl border border-slate-200 bg-white py-1 shadow-xl", panelClassName),
4246
4251
  children: items.map((item, index) => /* @__PURE__ */ jsxs("button", {
4247
4252
  ref: (node) => {
@@ -6130,25 +6135,30 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
6130
6135
  * - 250 × 84 px rounded rectangles (rx=14)
6131
6136
  * - Root (depth 0): dark navy fill (#0f172a), white text
6132
6137
  * - Children: white fill (#fff), slate border (#e2e8f0)
6138
+ * - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
6133
6139
  * - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
6134
- * - Name: 13px, font-weight 800, centered
6140
+ * - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
6135
6141
  * - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
6136
- * - Selected: indigo border overlay ring
6142
+ * - Hover: indigo-300 border; Selected: indigo border + overlay ring
6137
6143
  * Connectors:
6138
6144
  * - Stepped orthogonal path: M→V→H→V
6139
6145
  * - Stroke: #cbd5e1, stroke-width 1.5
6140
6146
  * Canvas:
6141
- * - Viewport sized to container; fit-to-view on render
6142
- * - Zoom: scaleExtent [0.05, 1.8]
6143
- * - 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)
6144
6153
  * - Layout: nodeSize [280, 185] — horizontal × vertical spacing
6145
6154
  * Controls:
6146
- * - ZoomIn, Reset, ZoomOut buttons (top-right overlay panel)
6155
+ * - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
6147
6156
  *
6148
6157
  * Accessibility:
6149
- * - SVG has role="img" + aria-label
6150
- * - Zoom controls have aria-label
6151
- * - 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
6152
6162
  * - Note: full ARIA tree semantics are not possible in an SVG diagram;
6153
6163
  * use OrgUnitTree for a fully keyboard-accessible list-based alternative
6154
6164
  *
@@ -6160,6 +6170,13 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
6160
6170
  * aria-label="Company organization chart"
6161
6171
  * />
6162
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;
6163
6180
  function buildD3Tree(nodes) {
6164
6181
  function convert(node, depth) {
6165
6182
  return {
@@ -6168,161 +6185,344 @@ function buildD3Tree(nodes) {
6168
6185
  description: node.description ?? "",
6169
6186
  isRoot: depth === 0,
6170
6187
  isSyntheticRoot: false,
6188
+ isMuted: node.tone === "muted",
6171
6189
  originalId: node.id,
6172
6190
  children: node.children?.map((c) => convert(c, depth + 1))
6173
6191
  };
6174
6192
  }
6175
6193
  if (nodes.length === 1) return convert(nodes[0], 0);
6176
6194
  return {
6177
- id: "__synthetic_root__",
6195
+ id: SYNTHETIC_ROOT_ID,
6178
6196
  name: "",
6179
6197
  description: "",
6180
6198
  isRoot: true,
6181
6199
  isSyntheticRoot: true,
6182
- originalId: "__synthetic_root__",
6200
+ isMuted: false,
6201
+ originalId: SYNTHETIC_ROOT_ID,
6183
6202
  children: nodes.map((n) => convert(n, 0))
6184
6203
  };
6185
6204
  }
6186
- 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 = "" }) {
6187
6228
  const svgRef = useRef(null);
6188
6229
  const zoomRef = useRef(null);
6189
- const initialTransformRef = useRef(null);
6190
6230
  const treeBoundsRef = useRef(null);
6191
- 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
+ };
6192
6261
  const fitToView = () => {
6193
6262
  if (!svgRef.current || !zoomRef.current || !treeBoundsRef.current) return;
6194
- const { width: W, height: H } = svgRef.current.getBoundingClientRect();
6195
- 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;
6196
6269
  const { minX, maxX, minY, maxY } = treeBoundsRef.current;
6197
- const contentLeft = minX - 125;
6198
- const contentRight = maxX + 125;
6199
- const contentTop = minY - 42;
6200
- 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;
6201
6274
  const contentWidth = contentRight - contentLeft;
6202
6275
  const contentHeight = contentBottom - contentTop;
6203
6276
  const scaleX = (W - 80) / Math.max(1, contentWidth);
6204
6277
  const scaleY = (H - 80) / Math.max(1, contentHeight);
6205
- const scale = Math.max(.05, Math.min(1.8, scaleX, scaleY));
6206
- const contentCenterX = (contentLeft + contentRight) / 2;
6207
- const contentCenterY = (contentTop + contentBottom) / 2;
6208
- const tx = W / 2 - contentCenterX * scale;
6209
- const ty = H / 2 - contentCenterY * scale;
6210
- const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
6211
- initialTransformRef.current = t;
6212
- 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));
6213
6282
  };
6214
- useEffect(() => {
6215
- 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;
6216
6291
  const svg = d3.select(svgRef.current);
6217
- svg.selectAll("*").remove();
6218
- const treeData = buildD3Tree(nodes);
6219
- const root = d3.hierarchy(treeData);
6220
- d3.tree().nodeSize([280, 185])(root);
6221
- 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();
6222
6359
  treeBoundsRef.current = {
6223
6360
  minX: d3.min(descendants, (d) => d.x) ?? 0,
6224
6361
  maxX: d3.max(descendants, (d) => d.x) ?? 0,
6225
6362
  minY: d3.min(descendants, (d) => d.y) ?? 0,
6226
6363
  maxY: d3.max(descendants, (d) => d.y) ?? 0
6227
6364
  };
6228
- const g = svg.append("g");
6229
- const zoom = d3.zoom().scaleExtent([.05, 1.8]).on("zoom", (e) => g.attr("transform", e.transform.toString()));
6230
- zoomRef.current = zoom;
6231
- svg.call(zoom);
6232
- 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) => {
6233
- const sx = link.source.x ?? 0;
6234
- const sy = link.source.y ?? 0;
6235
- const tx = link.target.x ?? 0;
6236
- const ty = link.target.y ?? 0;
6237
- 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}`;
6238
6368
  });
6239
- 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) => {
6240
- if (d.data.isSyntheticRoot) return;
6241
- 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);
6242
6389
  });
6243
- 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) => {
6244
- if (selectedId && d.data.originalId === selectedId) return "#6366f1";
6245
- return d.data.isRoot ? "#0f172a" : "#e2e8f0";
6246
- }).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))");
6247
- 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);
6248
- 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);
6249
- 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);
6250
- requestAnimationFrame(() => {
6251
- 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);
6252
6393
  });
6394
+ node.select("text.oc-desc").each(function(d) {
6395
+ fitText(this, d.data.description, TEXT_MAX_W, 11);
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);
6253
6407
  }, [
6254
- nodes,
6255
6408
  selectedId,
6256
- onSelect
6409
+ interactive,
6410
+ nodes
6257
6411
  ]);
6412
+ const fitKeyRef = useRef(fitKey);
6258
6413
  useEffect(() => {
6259
- const el = svgRef.current;
6260
- if (!el) return;
6261
- const ro = new ResizeObserver(() => {
6262
- fitToView();
6263
- });
6264
- ro.observe(el);
6265
- return () => ro.disconnect();
6266
- }, []);
6267
- const zoomBy = (factor) => {
6268
- if (!svgRef.current || !zoomRef.current) return;
6269
- d3.select(svgRef.current).transition().duration(250).call(zoomRef.current.scaleBy, factor);
6270
- };
6271
- const resetZoom = () => {
6272
- 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();
6273
6425
  };
6426
+ const atMax = scale >= 1.7990000000000002;
6427
+ const atMin = scale <= .051000000000000004;
6274
6428
  const controlBtnClass = [
6275
6429
  "appearance-none border-0 bg-none font-[inherit]",
6276
6430
  "flex items-center justify-center rounded-lg p-2",
6277
- "text-slate-400 transition hover:bg-slate-100 hover:text-slate-700",
6278
- "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"
6279
6434
  ].join(" ");
6280
6435
  return /* @__PURE__ */ jsxs("div", {
6281
- 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}`,
6282
6437
  style: { height },
6283
- children: [/* @__PURE__ */ jsxs("div", {
6284
- 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",
6285
- children: [
6286
- /* @__PURE__ */ jsx("button", {
6287
- type: "button",
6288
- className: controlBtnClass,
6289
- onClick: () => zoomBy(1.2),
6290
- "aria-label": "Zoom in",
6291
- children: /* @__PURE__ */ jsx(ZoomIn, {
6292
- size: 16,
6293
- "aria-hidden": "true"
6294
- })
6295
- }),
6296
- /* @__PURE__ */ jsx("button", {
6297
- type: "button",
6298
- className: controlBtnClass,
6299
- onClick: resetZoom,
6300
- "aria-label": "Reset zoom",
6301
- children: /* @__PURE__ */ jsx(RotateCcw, {
6302
- size: 16,
6303
- "aria-hidden": "true"
6304
- })
6305
- }),
6306
- /* @__PURE__ */ jsx("button", {
6307
- type: "button",
6308
- className: controlBtnClass,
6309
- onClick: () => zoomBy(.8),
6310
- "aria-label": "Zoom out",
6311
- children: /* @__PURE__ */ jsx(ZoomOut, {
6312
- 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",
6313
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
+ })
6314
6509
  })
6315
- })
6316
- ]
6317
- }), /* @__PURE__ */ jsx("svg", {
6318
- ref: svgRef,
6319
- id: uid,
6320
- width: "100%",
6321
- height: "100%",
6322
- className: "block touch-none",
6323
- role: "img",
6324
- "aria-label": ariaLabel
6325
- })]
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
+ ]
6326
6526
  });
6327
6527
  }
6328
6528
  //#endregion
@@ -1,2 +1,11 @@
1
1
  import { type ClassValue } from 'clsx';
2
2
  export declare function cn(...inputs: ClassValue[]): string;
3
+ /**
4
+ * Floating layers (Menu panels, DatePicker calendars) portal to document.body.
5
+ * Inside an open Modal/Dialog, Radix blocks pointer events outside its content
6
+ * and treats any interaction there as "outside" (closing the dialog). Floating
7
+ * layers opt out by setting `pointer-events: auto` and carrying this marker;
8
+ * Modal and Dialog then ignore interactions that land inside them.
9
+ */
10
+ export declare const FLOATING_LAYER_ATTR = "data-pis-floating";
11
+ export declare const FLOATING_LAYER_SELECTOR = ".pis-datepicker-popover, [data-pis-floating]";
@@ -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.41",
4
+ "version": "1.7.43",
5
5
  "type": "module",
6
6
  "description": "DIWA UHRIS UI component library — React + Tailwind CSS",
7
7
  "keywords": [