@diwauhris/ui 1.7.20 → 1.7.22

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,28 @@ 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 available = window.innerWidth - 16;
3980
+ const horizontal = align === "left" ? {
3981
+ left: Math.max(MARGIN, Math.min(rect.left, window.innerWidth - MARGIN)),
3982
+ right: "auto"
3983
+ } : {
3984
+ right: Math.max(MARGIN, window.innerWidth - rect.right),
3985
+ left: "auto"
3986
+ };
3972
3987
  setPanelStyle({
3973
3988
  position: "fixed",
3974
3989
  ...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),
3990
+ ...horizontal,
3976
3991
  zIndex: 9999,
3977
- minWidth: Math.max(rect.width, PANEL_MIN_WIDTH)
3992
+ minWidth: PANEL_MIN_WIDTH,
3993
+ maxWidth: available
3978
3994
  });
3979
3995
  };
3980
3996
  reposition();
3981
3997
  const raf = window.requestAnimationFrame(reposition);
3982
- window.addEventListener("scroll", reposition, true);
3983
3998
  window.addEventListener("resize", reposition);
3984
3999
  return () => {
3985
4000
  window.cancelAnimationFrame(raf);
3986
- window.removeEventListener("scroll", reposition, true);
3987
4001
  window.removeEventListener("resize", reposition);
3988
4002
  };
3989
4003
  }, [
@@ -4007,33 +4021,64 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4007
4021
  (triggerRef.current?.querySelector("button,a,[tabindex]"))?.focus();
4008
4022
  }
4009
4023
  };
4024
+ const handleScroll = (e) => {
4025
+ if (panelRef.current && e.target instanceof Node && panelRef.current.contains(e.target)) return;
4026
+ setOpen(false);
4027
+ };
4010
4028
  document.addEventListener("mousedown", handlePointerDown);
4011
4029
  document.addEventListener("keydown", handleKeyDown);
4030
+ window.addEventListener("scroll", handleScroll, true);
4012
4031
  window.setTimeout(() => {
4013
- itemRefs.current.find((r) => r && !r.disabled)?.focus();
4032
+ const enabled = itemRefs.current.map((node, i) => ({
4033
+ node,
4034
+ i
4035
+ })).filter((x) => x.node && !x.node.disabled);
4036
+ (pendingFocusRef.current === "last" ? enabled[enabled.length - 1] : enabled[0])?.node?.focus();
4037
+ pendingFocusRef.current = "first";
4014
4038
  }, 0);
4015
4039
  return () => {
4016
4040
  document.removeEventListener("mousedown", handlePointerDown);
4017
4041
  document.removeEventListener("keydown", handleKeyDown);
4042
+ window.removeEventListener("scroll", handleScroll, true);
4018
4043
  };
4019
4044
  }, [open]);
4020
- const focusItem = (index) => {
4021
- const next = (index % items.length + items.length) % items.length;
4022
- itemRefs.current[next]?.focus();
4045
+ const focusFrom = (index, dir) => {
4046
+ const n = items.length;
4047
+ if (n === 0) return;
4048
+ for (let step = 0; step < n; step++) {
4049
+ const next = ((index + dir * step) % n + n) % n;
4050
+ const node = itemRefs.current[next];
4051
+ if (node && !node.disabled) {
4052
+ node.focus();
4053
+ return;
4054
+ }
4055
+ }
4023
4056
  };
4024
4057
  const handleItemKeyDown = (e, index) => {
4025
4058
  if (e.key === "ArrowDown") {
4026
4059
  e.preventDefault();
4027
- focusItem(index + 1);
4060
+ focusFrom(index + 1, 1);
4028
4061
  } else if (e.key === "ArrowUp") {
4029
4062
  e.preventDefault();
4030
- focusItem(index - 1);
4063
+ focusFrom(index - 1, -1);
4031
4064
  } else if (e.key === "Home") {
4032
4065
  e.preventDefault();
4033
- focusItem(0);
4066
+ focusFrom(0, 1);
4034
4067
  } else if (e.key === "End") {
4035
4068
  e.preventDefault();
4036
- focusItem(items.length - 1);
4069
+ focusFrom(items.length - 1, -1);
4070
+ }
4071
+ };
4072
+ const handleTriggerKeyDown = (e) => {
4073
+ if (open) return;
4074
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
4075
+ e.preventDefault();
4076
+ pendingFocusRef.current = "first";
4077
+ setOpen(true);
4078
+ } else if (e.key === "ArrowUp") {
4079
+ e.preventDefault();
4080
+ pendingFocusRef.current = "last";
4081
+ setOpen(true);
4037
4082
  }
4038
4083
  };
4039
4084
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -4042,6 +4087,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4042
4087
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4043
4088
  ref: triggerRef,
4044
4089
  onClick: () => setOpen(!open),
4090
+ onKeyDown: handleTriggerKeyDown,
4045
4091
  className: "cursor-pointer",
4046
4092
  children: (0, react.isValidElement)(trigger) ? (0, react.cloneElement)(trigger, {
4047
4093
  "aria-expanded": open,
@@ -4061,7 +4107,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4061
4107
  role: "menuitem",
4062
4108
  disabled: item.disabled,
4063
4109
  onClick: () => {
4064
- setOpen(false);
4110
+ if (closeOnSelect) setOpen(false);
4065
4111
  item.onClick();
4066
4112
  },
4067
4113
  onKeyDown: (e) => handleItemKeyDown(e, index),
@@ -4077,7 +4123,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4077
4123
  "aria-hidden": "true",
4078
4124
  children: item.icon
4079
4125
  }), item.label]
4080
- }, item.label))
4126
+ }, item.id ?? item.label ?? index))
4081
4127
  }), document.body)]
4082
4128
  });
4083
4129
  }
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,28 @@ 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 available = window.innerWidth - 16;
3956
+ const horizontal = align === "left" ? {
3957
+ left: Math.max(MARGIN, Math.min(rect.left, window.innerWidth - MARGIN)),
3958
+ right: "auto"
3959
+ } : {
3960
+ right: Math.max(MARGIN, window.innerWidth - rect.right),
3961
+ left: "auto"
3962
+ };
3948
3963
  setPanelStyle({
3949
3964
  position: "fixed",
3950
3965
  ...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),
3966
+ ...horizontal,
3952
3967
  zIndex: 9999,
3953
- minWidth: Math.max(rect.width, PANEL_MIN_WIDTH)
3968
+ minWidth: PANEL_MIN_WIDTH,
3969
+ maxWidth: available
3954
3970
  });
3955
3971
  };
3956
3972
  reposition();
3957
3973
  const raf = window.requestAnimationFrame(reposition);
3958
- window.addEventListener("scroll", reposition, true);
3959
3974
  window.addEventListener("resize", reposition);
3960
3975
  return () => {
3961
3976
  window.cancelAnimationFrame(raf);
3962
- window.removeEventListener("scroll", reposition, true);
3963
3977
  window.removeEventListener("resize", reposition);
3964
3978
  };
3965
3979
  }, [
@@ -3983,33 +3997,64 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
3983
3997
  (triggerRef.current?.querySelector("button,a,[tabindex]"))?.focus();
3984
3998
  }
3985
3999
  };
4000
+ const handleScroll = (e) => {
4001
+ if (panelRef.current && e.target instanceof Node && panelRef.current.contains(e.target)) return;
4002
+ setOpen(false);
4003
+ };
3986
4004
  document.addEventListener("mousedown", handlePointerDown);
3987
4005
  document.addEventListener("keydown", handleKeyDown);
4006
+ window.addEventListener("scroll", handleScroll, true);
3988
4007
  window.setTimeout(() => {
3989
- itemRefs.current.find((r) => r && !r.disabled)?.focus();
4008
+ const enabled = itemRefs.current.map((node, i) => ({
4009
+ node,
4010
+ i
4011
+ })).filter((x) => x.node && !x.node.disabled);
4012
+ (pendingFocusRef.current === "last" ? enabled[enabled.length - 1] : enabled[0])?.node?.focus();
4013
+ pendingFocusRef.current = "first";
3990
4014
  }, 0);
3991
4015
  return () => {
3992
4016
  document.removeEventListener("mousedown", handlePointerDown);
3993
4017
  document.removeEventListener("keydown", handleKeyDown);
4018
+ window.removeEventListener("scroll", handleScroll, true);
3994
4019
  };
3995
4020
  }, [open]);
3996
- const focusItem = (index) => {
3997
- const next = (index % items.length + items.length) % items.length;
3998
- itemRefs.current[next]?.focus();
4021
+ const focusFrom = (index, dir) => {
4022
+ const n = items.length;
4023
+ if (n === 0) return;
4024
+ for (let step = 0; step < n; step++) {
4025
+ const next = ((index + dir * step) % n + n) % n;
4026
+ const node = itemRefs.current[next];
4027
+ if (node && !node.disabled) {
4028
+ node.focus();
4029
+ return;
4030
+ }
4031
+ }
3999
4032
  };
4000
4033
  const handleItemKeyDown = (e, index) => {
4001
4034
  if (e.key === "ArrowDown") {
4002
4035
  e.preventDefault();
4003
- focusItem(index + 1);
4036
+ focusFrom(index + 1, 1);
4004
4037
  } else if (e.key === "ArrowUp") {
4005
4038
  e.preventDefault();
4006
- focusItem(index - 1);
4039
+ focusFrom(index - 1, -1);
4007
4040
  } else if (e.key === "Home") {
4008
4041
  e.preventDefault();
4009
- focusItem(0);
4042
+ focusFrom(0, 1);
4010
4043
  } else if (e.key === "End") {
4011
4044
  e.preventDefault();
4012
- focusItem(items.length - 1);
4045
+ focusFrom(items.length - 1, -1);
4046
+ }
4047
+ };
4048
+ const handleTriggerKeyDown = (e) => {
4049
+ if (open) return;
4050
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
4051
+ e.preventDefault();
4052
+ pendingFocusRef.current = "first";
4053
+ setOpen(true);
4054
+ } else if (e.key === "ArrowUp") {
4055
+ e.preventDefault();
4056
+ pendingFocusRef.current = "last";
4057
+ setOpen(true);
4013
4058
  }
4014
4059
  };
4015
4060
  return /* @__PURE__ */ jsxs("div", {
@@ -4018,6 +4063,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4018
4063
  children: [/* @__PURE__ */ jsx("div", {
4019
4064
  ref: triggerRef,
4020
4065
  onClick: () => setOpen(!open),
4066
+ onKeyDown: handleTriggerKeyDown,
4021
4067
  className: "cursor-pointer",
4022
4068
  children: isValidElement(trigger) ? cloneElement(trigger, {
4023
4069
  "aria-expanded": open,
@@ -4037,7 +4083,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4037
4083
  role: "menuitem",
4038
4084
  disabled: item.disabled,
4039
4085
  onClick: () => {
4040
- setOpen(false);
4086
+ if (closeOnSelect) setOpen(false);
4041
4087
  item.onClick();
4042
4088
  },
4043
4089
  onKeyDown: (e) => handleItemKeyDown(e, index),
@@ -4053,7 +4099,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4053
4099
  "aria-hidden": "true",
4054
4100
  children: item.icon
4055
4101
  }), item.label]
4056
- }, item.label))
4102
+ }, item.id ?? item.label ?? index))
4057
4103
  }), document.body)]
4058
4104
  });
4059
4105
  }
@@ -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.22",
5
5
  "type": "module",
6
6
  "description": "DIWA UHRIS UI component library — React + Tailwind CSS",
7
7
  "keywords": [