@diwauhris/ui 1.7.20 → 1.7.21

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/index.cjs CHANGED
@@ -3924,9 +3924,15 @@ function Dropdown({ trigger, isOpen, onToggle, onClose, children, triggerClassNa
3924
3924
  * Menu — Design System Component
3925
3925
  *
3926
3926
  * A dropdown menu anchored to a trigger. Opens on trigger click,
3927
- * closes on outside-click or Escape. Full keyboard navigation:
3928
- * ArrowDown/ArrowUp to move between items, Home/End for first/last,
3929
- * Escape to close.
3927
+ * closes on outside-click, Escape, or scroll. Full keyboard navigation:
3928
+ * ArrowDown/ArrowUp move between items (skipping disabled), Home/End jump to
3929
+ * the first/last enabled item, Escape closes. When focus is on a closed
3930
+ * trigger, Enter/Space/ArrowDown open it and focus the first item; ArrowUp
3931
+ * opens it and focuses the last item.
3932
+ *
3933
+ * The panel renders in a body portal with fixed positioning: it flips above
3934
+ * the trigger when there isn't room below, and clamps horizontally so it never
3935
+ * overflows the viewport.
3930
3936
  *
3931
3937
  * Follows WAI-ARIA Menu Button pattern:
3932
3938
  * - trigger: role="button", aria-haspopup="menu", aria-expanded
@@ -3942,7 +3948,7 @@ function Dropdown({ trigger, isOpen, onToggle, onClose, children, triggerClassNa
3942
3948
  * ]}
3943
3949
  * />
3944
3950
  */
3945
- function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: controlledOpen, onOpenChange, panelClassName = "" }) {
3951
+ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: controlledOpen, onOpenChange, closeOnSelect = true, panelClassName = "" }) {
3946
3952
  const [internalOpen, setInternalOpen] = (0, react.useState)(false);
3947
3953
  const isControlled = controlledOpen !== void 0;
3948
3954
  const open = isControlled ? controlledOpen : internalOpen;
@@ -3955,6 +3961,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
3955
3961
  const panelRef = (0, react.useRef)(null);
3956
3962
  const itemRefs = (0, react.useRef)([]);
3957
3963
  const [panelStyle, setPanelStyle] = (0, react.useState)({});
3964
+ const pendingFocusRef = (0, react.useRef)("first");
3958
3965
  (0, react.useEffect)(() => {
3959
3966
  if (!open || !triggerRef.current) return;
3960
3967
  const GAP = 4;
@@ -3969,21 +3976,23 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
3969
3976
  const panelHeight = panelRef.current?.offsetHeight ?? items.length * ROW_HEIGHT + HEADER_HEIGHT;
3970
3977
  const spaceBelow = window.innerHeight - rect.bottom;
3971
3978
  const flipUp = spaceBelow < panelHeight + GAP + MARGIN && rect.top > spaceBelow;
3979
+ const panelWidth = Math.max(panelRef.current?.offsetWidth ?? 0, rect.width, PANEL_MIN_WIDTH);
3980
+ const desiredLeft = align === "left" ? rect.left : rect.right - panelWidth;
3981
+ const maxLeft = window.innerWidth - panelWidth - MARGIN;
3982
+ const left = Math.min(Math.max(MARGIN, desiredLeft), Math.max(MARGIN, maxLeft));
3972
3983
  setPanelStyle({
3973
3984
  position: "fixed",
3974
3985
  ...flipUp ? { bottom: Math.max(MARGIN, window.innerHeight - rect.top + GAP) } : { top: rect.bottom + GAP },
3975
- left: align === "left" ? rect.left : Math.max(MARGIN, rect.right - PANEL_MIN_WIDTH),
3986
+ left,
3976
3987
  zIndex: 9999,
3977
3988
  minWidth: Math.max(rect.width, PANEL_MIN_WIDTH)
3978
3989
  });
3979
3990
  };
3980
3991
  reposition();
3981
3992
  const raf = window.requestAnimationFrame(reposition);
3982
- window.addEventListener("scroll", reposition, true);
3983
3993
  window.addEventListener("resize", reposition);
3984
3994
  return () => {
3985
3995
  window.cancelAnimationFrame(raf);
3986
- window.removeEventListener("scroll", reposition, true);
3987
3996
  window.removeEventListener("resize", reposition);
3988
3997
  };
3989
3998
  }, [
@@ -4007,33 +4016,64 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4007
4016
  (triggerRef.current?.querySelector("button,a,[tabindex]"))?.focus();
4008
4017
  }
4009
4018
  };
4019
+ const handleScroll = (e) => {
4020
+ if (panelRef.current && e.target instanceof Node && panelRef.current.contains(e.target)) return;
4021
+ setOpen(false);
4022
+ };
4010
4023
  document.addEventListener("mousedown", handlePointerDown);
4011
4024
  document.addEventListener("keydown", handleKeyDown);
4025
+ window.addEventListener("scroll", handleScroll, true);
4012
4026
  window.setTimeout(() => {
4013
- itemRefs.current.find((r) => r && !r.disabled)?.focus();
4027
+ const enabled = itemRefs.current.map((node, i) => ({
4028
+ node,
4029
+ i
4030
+ })).filter((x) => x.node && !x.node.disabled);
4031
+ (pendingFocusRef.current === "last" ? enabled[enabled.length - 1] : enabled[0])?.node?.focus();
4032
+ pendingFocusRef.current = "first";
4014
4033
  }, 0);
4015
4034
  return () => {
4016
4035
  document.removeEventListener("mousedown", handlePointerDown);
4017
4036
  document.removeEventListener("keydown", handleKeyDown);
4037
+ window.removeEventListener("scroll", handleScroll, true);
4018
4038
  };
4019
4039
  }, [open]);
4020
- const focusItem = (index) => {
4021
- const next = (index % items.length + items.length) % items.length;
4022
- itemRefs.current[next]?.focus();
4040
+ const focusFrom = (index, dir) => {
4041
+ const n = items.length;
4042
+ if (n === 0) return;
4043
+ for (let step = 0; step < n; step++) {
4044
+ const next = ((index + dir * step) % n + n) % n;
4045
+ const node = itemRefs.current[next];
4046
+ if (node && !node.disabled) {
4047
+ node.focus();
4048
+ return;
4049
+ }
4050
+ }
4023
4051
  };
4024
4052
  const handleItemKeyDown = (e, index) => {
4025
4053
  if (e.key === "ArrowDown") {
4026
4054
  e.preventDefault();
4027
- focusItem(index + 1);
4055
+ focusFrom(index + 1, 1);
4028
4056
  } else if (e.key === "ArrowUp") {
4029
4057
  e.preventDefault();
4030
- focusItem(index - 1);
4058
+ focusFrom(index - 1, -1);
4031
4059
  } else if (e.key === "Home") {
4032
4060
  e.preventDefault();
4033
- focusItem(0);
4061
+ focusFrom(0, 1);
4034
4062
  } else if (e.key === "End") {
4035
4063
  e.preventDefault();
4036
- focusItem(items.length - 1);
4064
+ focusFrom(items.length - 1, -1);
4065
+ }
4066
+ };
4067
+ const handleTriggerKeyDown = (e) => {
4068
+ if (open) return;
4069
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
4070
+ e.preventDefault();
4071
+ pendingFocusRef.current = "first";
4072
+ setOpen(true);
4073
+ } else if (e.key === "ArrowUp") {
4074
+ e.preventDefault();
4075
+ pendingFocusRef.current = "last";
4076
+ setOpen(true);
4037
4077
  }
4038
4078
  };
4039
4079
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -4042,6 +4082,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4042
4082
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4043
4083
  ref: triggerRef,
4044
4084
  onClick: () => setOpen(!open),
4085
+ onKeyDown: handleTriggerKeyDown,
4045
4086
  className: "cursor-pointer",
4046
4087
  children: (0, react.isValidElement)(trigger) ? (0, react.cloneElement)(trigger, {
4047
4088
  "aria-expanded": open,
@@ -4061,7 +4102,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4061
4102
  role: "menuitem",
4062
4103
  disabled: item.disabled,
4063
4104
  onClick: () => {
4064
- setOpen(false);
4105
+ if (closeOnSelect) setOpen(false);
4065
4106
  item.onClick();
4066
4107
  },
4067
4108
  onKeyDown: (e) => handleItemKeyDown(e, index),
@@ -4077,7 +4118,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4077
4118
  "aria-hidden": "true",
4078
4119
  children: item.icon
4079
4120
  }), item.label]
4080
- }, item.label))
4121
+ }, item.id ?? item.label ?? index))
4081
4122
  }), document.body)]
4082
4123
  });
4083
4124
  }
package/lib/index.mjs CHANGED
@@ -3900,9 +3900,15 @@ function Dropdown({ trigger, isOpen, onToggle, onClose, children, triggerClassNa
3900
3900
  * Menu — Design System Component
3901
3901
  *
3902
3902
  * A dropdown menu anchored to a trigger. Opens on trigger click,
3903
- * closes on outside-click or Escape. Full keyboard navigation:
3904
- * ArrowDown/ArrowUp to move between items, Home/End for first/last,
3905
- * Escape to close.
3903
+ * closes on outside-click, Escape, or scroll. Full keyboard navigation:
3904
+ * ArrowDown/ArrowUp move between items (skipping disabled), Home/End jump to
3905
+ * the first/last enabled item, Escape closes. When focus is on a closed
3906
+ * trigger, Enter/Space/ArrowDown open it and focus the first item; ArrowUp
3907
+ * opens it and focuses the last item.
3908
+ *
3909
+ * The panel renders in a body portal with fixed positioning: it flips above
3910
+ * the trigger when there isn't room below, and clamps horizontally so it never
3911
+ * overflows the viewport.
3906
3912
  *
3907
3913
  * Follows WAI-ARIA Menu Button pattern:
3908
3914
  * - trigger: role="button", aria-haspopup="menu", aria-expanded
@@ -3918,7 +3924,7 @@ function Dropdown({ trigger, isOpen, onToggle, onClose, children, triggerClassNa
3918
3924
  * ]}
3919
3925
  * />
3920
3926
  */
3921
- function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: controlledOpen, onOpenChange, panelClassName = "" }) {
3927
+ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: controlledOpen, onOpenChange, closeOnSelect = true, panelClassName = "" }) {
3922
3928
  const [internalOpen, setInternalOpen] = useState(false);
3923
3929
  const isControlled = controlledOpen !== void 0;
3924
3930
  const open = isControlled ? controlledOpen : internalOpen;
@@ -3931,6 +3937,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
3931
3937
  const panelRef = useRef(null);
3932
3938
  const itemRefs = useRef([]);
3933
3939
  const [panelStyle, setPanelStyle] = useState({});
3940
+ const pendingFocusRef = useRef("first");
3934
3941
  useEffect(() => {
3935
3942
  if (!open || !triggerRef.current) return;
3936
3943
  const GAP = 4;
@@ -3945,21 +3952,23 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
3945
3952
  const panelHeight = panelRef.current?.offsetHeight ?? items.length * ROW_HEIGHT + HEADER_HEIGHT;
3946
3953
  const spaceBelow = window.innerHeight - rect.bottom;
3947
3954
  const flipUp = spaceBelow < panelHeight + GAP + MARGIN && rect.top > spaceBelow;
3955
+ const panelWidth = Math.max(panelRef.current?.offsetWidth ?? 0, rect.width, PANEL_MIN_WIDTH);
3956
+ const desiredLeft = align === "left" ? rect.left : rect.right - panelWidth;
3957
+ const maxLeft = window.innerWidth - panelWidth - MARGIN;
3958
+ const left = Math.min(Math.max(MARGIN, desiredLeft), Math.max(MARGIN, maxLeft));
3948
3959
  setPanelStyle({
3949
3960
  position: "fixed",
3950
3961
  ...flipUp ? { bottom: Math.max(MARGIN, window.innerHeight - rect.top + GAP) } : { top: rect.bottom + GAP },
3951
- left: align === "left" ? rect.left : Math.max(MARGIN, rect.right - PANEL_MIN_WIDTH),
3962
+ left,
3952
3963
  zIndex: 9999,
3953
3964
  minWidth: Math.max(rect.width, PANEL_MIN_WIDTH)
3954
3965
  });
3955
3966
  };
3956
3967
  reposition();
3957
3968
  const raf = window.requestAnimationFrame(reposition);
3958
- window.addEventListener("scroll", reposition, true);
3959
3969
  window.addEventListener("resize", reposition);
3960
3970
  return () => {
3961
3971
  window.cancelAnimationFrame(raf);
3962
- window.removeEventListener("scroll", reposition, true);
3963
3972
  window.removeEventListener("resize", reposition);
3964
3973
  };
3965
3974
  }, [
@@ -3983,33 +3992,64 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
3983
3992
  (triggerRef.current?.querySelector("button,a,[tabindex]"))?.focus();
3984
3993
  }
3985
3994
  };
3995
+ const handleScroll = (e) => {
3996
+ if (panelRef.current && e.target instanceof Node && panelRef.current.contains(e.target)) return;
3997
+ setOpen(false);
3998
+ };
3986
3999
  document.addEventListener("mousedown", handlePointerDown);
3987
4000
  document.addEventListener("keydown", handleKeyDown);
4001
+ window.addEventListener("scroll", handleScroll, true);
3988
4002
  window.setTimeout(() => {
3989
- itemRefs.current.find((r) => r && !r.disabled)?.focus();
4003
+ const enabled = itemRefs.current.map((node, i) => ({
4004
+ node,
4005
+ i
4006
+ })).filter((x) => x.node && !x.node.disabled);
4007
+ (pendingFocusRef.current === "last" ? enabled[enabled.length - 1] : enabled[0])?.node?.focus();
4008
+ pendingFocusRef.current = "first";
3990
4009
  }, 0);
3991
4010
  return () => {
3992
4011
  document.removeEventListener("mousedown", handlePointerDown);
3993
4012
  document.removeEventListener("keydown", handleKeyDown);
4013
+ window.removeEventListener("scroll", handleScroll, true);
3994
4014
  };
3995
4015
  }, [open]);
3996
- const focusItem = (index) => {
3997
- const next = (index % items.length + items.length) % items.length;
3998
- itemRefs.current[next]?.focus();
4016
+ const focusFrom = (index, dir) => {
4017
+ const n = items.length;
4018
+ if (n === 0) return;
4019
+ for (let step = 0; step < n; step++) {
4020
+ const next = ((index + dir * step) % n + n) % n;
4021
+ const node = itemRefs.current[next];
4022
+ if (node && !node.disabled) {
4023
+ node.focus();
4024
+ return;
4025
+ }
4026
+ }
3999
4027
  };
4000
4028
  const handleItemKeyDown = (e, index) => {
4001
4029
  if (e.key === "ArrowDown") {
4002
4030
  e.preventDefault();
4003
- focusItem(index + 1);
4031
+ focusFrom(index + 1, 1);
4004
4032
  } else if (e.key === "ArrowUp") {
4005
4033
  e.preventDefault();
4006
- focusItem(index - 1);
4034
+ focusFrom(index - 1, -1);
4007
4035
  } else if (e.key === "Home") {
4008
4036
  e.preventDefault();
4009
- focusItem(0);
4037
+ focusFrom(0, 1);
4010
4038
  } else if (e.key === "End") {
4011
4039
  e.preventDefault();
4012
- focusItem(items.length - 1);
4040
+ focusFrom(items.length - 1, -1);
4041
+ }
4042
+ };
4043
+ const handleTriggerKeyDown = (e) => {
4044
+ if (open) return;
4045
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
4046
+ e.preventDefault();
4047
+ pendingFocusRef.current = "first";
4048
+ setOpen(true);
4049
+ } else if (e.key === "ArrowUp") {
4050
+ e.preventDefault();
4051
+ pendingFocusRef.current = "last";
4052
+ setOpen(true);
4013
4053
  }
4014
4054
  };
4015
4055
  return /* @__PURE__ */ jsxs("div", {
@@ -4018,6 +4058,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4018
4058
  children: [/* @__PURE__ */ jsx("div", {
4019
4059
  ref: triggerRef,
4020
4060
  onClick: () => setOpen(!open),
4061
+ onKeyDown: handleTriggerKeyDown,
4021
4062
  className: "cursor-pointer",
4022
4063
  children: isValidElement(trigger) ? cloneElement(trigger, {
4023
4064
  "aria-expanded": open,
@@ -4037,7 +4078,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4037
4078
  role: "menuitem",
4038
4079
  disabled: item.disabled,
4039
4080
  onClick: () => {
4040
- setOpen(false);
4081
+ if (closeOnSelect) setOpen(false);
4041
4082
  item.onClick();
4042
4083
  },
4043
4084
  onKeyDown: (e) => handleItemKeyDown(e, index),
@@ -4053,7 +4094,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4053
4094
  "aria-hidden": "true",
4054
4095
  children: item.icon
4055
4096
  }), item.label]
4056
- }, item.label))
4097
+ }, item.id ?? item.label ?? index))
4057
4098
  }), document.body)]
4058
4099
  });
4059
4100
  }
@@ -2,9 +2,15 @@
2
2
  * Menu — Design System Component
3
3
  *
4
4
  * A dropdown menu anchored to a trigger. Opens on trigger click,
5
- * closes on outside-click or Escape. Full keyboard navigation:
6
- * ArrowDown/ArrowUp to move between items, Home/End for first/last,
7
- * Escape to close.
5
+ * closes on outside-click, Escape, or scroll. Full keyboard navigation:
6
+ * ArrowDown/ArrowUp move between items (skipping disabled), Home/End jump to
7
+ * the first/last enabled item, Escape closes. When focus is on a closed
8
+ * trigger, Enter/Space/ArrowDown open it and focus the first item; ArrowUp
9
+ * opens it and focuses the last item.
10
+ *
11
+ * The panel renders in a body portal with fixed positioning: it flips above
12
+ * the trigger when there isn't room below, and clamps horizontally so it never
13
+ * overflows the viewport.
8
14
  *
9
15
  * Follows WAI-ARIA Menu Button pattern:
10
16
  * - trigger: role="button", aria-haspopup="menu", aria-expanded
@@ -23,6 +29,12 @@
23
29
  import { type ReactNode } from 'react';
24
30
  export type MenuItemTone = 'default' | 'danger';
25
31
  export interface MenuItem {
32
+ /**
33
+ * Stable identity for the item. Used for the React key and focus tracking.
34
+ * Recommended when labels can repeat or the item list is dynamic; falls back
35
+ * to `label` then index when omitted.
36
+ */
37
+ id?: string;
26
38
  label: string;
27
39
  icon?: ReactNode;
28
40
  onClick: () => void;
@@ -46,7 +58,12 @@ export interface MenuProps {
46
58
  open?: boolean;
47
59
  /** Called when the menu should open or close. */
48
60
  onOpenChange?: (open: boolean) => void;
61
+ /**
62
+ * Close the menu after an item is selected. Default `true`.
63
+ * Set `false` for menus that stay open across clicks (e.g. multi-toggle filters).
64
+ */
65
+ closeOnSelect?: boolean;
49
66
  /** Additional class on the floating panel. */
50
67
  panelClassName?: string;
51
68
  }
52
- export declare function Menu({ trigger, items, ariaLabel, align, open: controlledOpen, onOpenChange, panelClassName, }: MenuProps): import("react").JSX.Element;
69
+ export declare function Menu({ trigger, items, ariaLabel, align, open: controlledOpen, onOpenChange, closeOnSelect, panelClassName, }: MenuProps): 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.20",
4
+ "version": "1.7.21",
5
5
  "type": "module",
6
6
  "description": "DIWA UHRIS UI component library — React + Tailwind CSS",
7
7
  "keywords": [