@diwauhris/ui 1.7.19 → 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,18 +3961,45 @@ 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
- const rect = triggerRef.current.getBoundingClientRect();
3967
+ const GAP = 4;
3968
+ const MARGIN = 8;
3961
3969
  const PANEL_MIN_WIDTH = 176;
3962
- setPanelStyle({
3963
- position: "fixed",
3964
- top: rect.bottom + 4,
3965
- left: align === "left" ? rect.left : Math.max(0, rect.right - PANEL_MIN_WIDTH),
3966
- zIndex: 9999,
3967
- minWidth: Math.max(rect.width, PANEL_MIN_WIDTH)
3968
- });
3969
- }, [open, align]);
3970
+ const ROW_HEIGHT = 36;
3971
+ const HEADER_HEIGHT = 8;
3972
+ const reposition = () => {
3973
+ const trigger = triggerRef.current;
3974
+ if (!trigger) return;
3975
+ const rect = trigger.getBoundingClientRect();
3976
+ const panelHeight = panelRef.current?.offsetHeight ?? items.length * ROW_HEIGHT + HEADER_HEIGHT;
3977
+ const spaceBelow = window.innerHeight - rect.bottom;
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));
3983
+ setPanelStyle({
3984
+ position: "fixed",
3985
+ ...flipUp ? { bottom: Math.max(MARGIN, window.innerHeight - rect.top + GAP) } : { top: rect.bottom + GAP },
3986
+ left,
3987
+ zIndex: 9999,
3988
+ minWidth: Math.max(rect.width, PANEL_MIN_WIDTH)
3989
+ });
3990
+ };
3991
+ reposition();
3992
+ const raf = window.requestAnimationFrame(reposition);
3993
+ window.addEventListener("resize", reposition);
3994
+ return () => {
3995
+ window.cancelAnimationFrame(raf);
3996
+ window.removeEventListener("resize", reposition);
3997
+ };
3998
+ }, [
3999
+ open,
4000
+ align,
4001
+ items.length
4002
+ ]);
3970
4003
  (0, react.useEffect)(() => {
3971
4004
  if (!open) return;
3972
4005
  const handlePointerDown = (e) => {
@@ -3983,33 +4016,64 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
3983
4016
  (triggerRef.current?.querySelector("button,a,[tabindex]"))?.focus();
3984
4017
  }
3985
4018
  };
4019
+ const handleScroll = (e) => {
4020
+ if (panelRef.current && e.target instanceof Node && panelRef.current.contains(e.target)) return;
4021
+ setOpen(false);
4022
+ };
3986
4023
  document.addEventListener("mousedown", handlePointerDown);
3987
4024
  document.addEventListener("keydown", handleKeyDown);
4025
+ window.addEventListener("scroll", handleScroll, true);
3988
4026
  window.setTimeout(() => {
3989
- 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";
3990
4033
  }, 0);
3991
4034
  return () => {
3992
4035
  document.removeEventListener("mousedown", handlePointerDown);
3993
4036
  document.removeEventListener("keydown", handleKeyDown);
4037
+ window.removeEventListener("scroll", handleScroll, true);
3994
4038
  };
3995
4039
  }, [open]);
3996
- const focusItem = (index) => {
3997
- const next = (index % items.length + items.length) % items.length;
3998
- 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
+ }
3999
4051
  };
4000
4052
  const handleItemKeyDown = (e, index) => {
4001
4053
  if (e.key === "ArrowDown") {
4002
4054
  e.preventDefault();
4003
- focusItem(index + 1);
4055
+ focusFrom(index + 1, 1);
4004
4056
  } else if (e.key === "ArrowUp") {
4005
4057
  e.preventDefault();
4006
- focusItem(index - 1);
4058
+ focusFrom(index - 1, -1);
4007
4059
  } else if (e.key === "Home") {
4008
4060
  e.preventDefault();
4009
- focusItem(0);
4061
+ focusFrom(0, 1);
4010
4062
  } else if (e.key === "End") {
4011
4063
  e.preventDefault();
4012
- 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);
4013
4077
  }
4014
4078
  };
4015
4079
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -4018,6 +4082,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4018
4082
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4019
4083
  ref: triggerRef,
4020
4084
  onClick: () => setOpen(!open),
4085
+ onKeyDown: handleTriggerKeyDown,
4021
4086
  className: "cursor-pointer",
4022
4087
  children: (0, react.isValidElement)(trigger) ? (0, react.cloneElement)(trigger, {
4023
4088
  "aria-expanded": open,
@@ -4037,7 +4102,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4037
4102
  role: "menuitem",
4038
4103
  disabled: item.disabled,
4039
4104
  onClick: () => {
4040
- setOpen(false);
4105
+ if (closeOnSelect) setOpen(false);
4041
4106
  item.onClick();
4042
4107
  },
4043
4108
  onKeyDown: (e) => handleItemKeyDown(e, index),
@@ -4053,7 +4118,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4053
4118
  "aria-hidden": "true",
4054
4119
  children: item.icon
4055
4120
  }), item.label]
4056
- }, item.label))
4121
+ }, item.id ?? item.label ?? index))
4057
4122
  }), document.body)]
4058
4123
  });
4059
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,18 +3937,45 @@ 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
- const rect = triggerRef.current.getBoundingClientRect();
3943
+ const GAP = 4;
3944
+ const MARGIN = 8;
3937
3945
  const PANEL_MIN_WIDTH = 176;
3938
- setPanelStyle({
3939
- position: "fixed",
3940
- top: rect.bottom + 4,
3941
- left: align === "left" ? rect.left : Math.max(0, rect.right - PANEL_MIN_WIDTH),
3942
- zIndex: 9999,
3943
- minWidth: Math.max(rect.width, PANEL_MIN_WIDTH)
3944
- });
3945
- }, [open, align]);
3946
+ const ROW_HEIGHT = 36;
3947
+ const HEADER_HEIGHT = 8;
3948
+ const reposition = () => {
3949
+ const trigger = triggerRef.current;
3950
+ if (!trigger) return;
3951
+ const rect = trigger.getBoundingClientRect();
3952
+ const panelHeight = panelRef.current?.offsetHeight ?? items.length * ROW_HEIGHT + HEADER_HEIGHT;
3953
+ const spaceBelow = window.innerHeight - rect.bottom;
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));
3959
+ setPanelStyle({
3960
+ position: "fixed",
3961
+ ...flipUp ? { bottom: Math.max(MARGIN, window.innerHeight - rect.top + GAP) } : { top: rect.bottom + GAP },
3962
+ left,
3963
+ zIndex: 9999,
3964
+ minWidth: Math.max(rect.width, PANEL_MIN_WIDTH)
3965
+ });
3966
+ };
3967
+ reposition();
3968
+ const raf = window.requestAnimationFrame(reposition);
3969
+ window.addEventListener("resize", reposition);
3970
+ return () => {
3971
+ window.cancelAnimationFrame(raf);
3972
+ window.removeEventListener("resize", reposition);
3973
+ };
3974
+ }, [
3975
+ open,
3976
+ align,
3977
+ items.length
3978
+ ]);
3946
3979
  useEffect(() => {
3947
3980
  if (!open) return;
3948
3981
  const handlePointerDown = (e) => {
@@ -3959,33 +3992,64 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
3959
3992
  (triggerRef.current?.querySelector("button,a,[tabindex]"))?.focus();
3960
3993
  }
3961
3994
  };
3995
+ const handleScroll = (e) => {
3996
+ if (panelRef.current && e.target instanceof Node && panelRef.current.contains(e.target)) return;
3997
+ setOpen(false);
3998
+ };
3962
3999
  document.addEventListener("mousedown", handlePointerDown);
3963
4000
  document.addEventListener("keydown", handleKeyDown);
4001
+ window.addEventListener("scroll", handleScroll, true);
3964
4002
  window.setTimeout(() => {
3965
- 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";
3966
4009
  }, 0);
3967
4010
  return () => {
3968
4011
  document.removeEventListener("mousedown", handlePointerDown);
3969
4012
  document.removeEventListener("keydown", handleKeyDown);
4013
+ window.removeEventListener("scroll", handleScroll, true);
3970
4014
  };
3971
4015
  }, [open]);
3972
- const focusItem = (index) => {
3973
- const next = (index % items.length + items.length) % items.length;
3974
- 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
+ }
3975
4027
  };
3976
4028
  const handleItemKeyDown = (e, index) => {
3977
4029
  if (e.key === "ArrowDown") {
3978
4030
  e.preventDefault();
3979
- focusItem(index + 1);
4031
+ focusFrom(index + 1, 1);
3980
4032
  } else if (e.key === "ArrowUp") {
3981
4033
  e.preventDefault();
3982
- focusItem(index - 1);
4034
+ focusFrom(index - 1, -1);
3983
4035
  } else if (e.key === "Home") {
3984
4036
  e.preventDefault();
3985
- focusItem(0);
4037
+ focusFrom(0, 1);
3986
4038
  } else if (e.key === "End") {
3987
4039
  e.preventDefault();
3988
- 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);
3989
4053
  }
3990
4054
  };
3991
4055
  return /* @__PURE__ */ jsxs("div", {
@@ -3994,6 +4058,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
3994
4058
  children: [/* @__PURE__ */ jsx("div", {
3995
4059
  ref: triggerRef,
3996
4060
  onClick: () => setOpen(!open),
4061
+ onKeyDown: handleTriggerKeyDown,
3997
4062
  className: "cursor-pointer",
3998
4063
  children: isValidElement(trigger) ? cloneElement(trigger, {
3999
4064
  "aria-expanded": open,
@@ -4013,7 +4078,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4013
4078
  role: "menuitem",
4014
4079
  disabled: item.disabled,
4015
4080
  onClick: () => {
4016
- setOpen(false);
4081
+ if (closeOnSelect) setOpen(false);
4017
4082
  item.onClick();
4018
4083
  },
4019
4084
  onKeyDown: (e) => handleItemKeyDown(e, index),
@@ -4029,7 +4094,7 @@ function Menu({ trigger, items, ariaLabel = "Menu", align = "right", open: contr
4029
4094
  "aria-hidden": "true",
4030
4095
  children: item.icon
4031
4096
  }), item.label]
4032
- }, item.label))
4097
+ }, item.id ?? item.label ?? index))
4033
4098
  }), document.body)]
4034
4099
  });
4035
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.19",
4
+ "version": "1.7.21",
5
5
  "type": "module",
6
6
  "description": "DIWA UHRIS UI component library — React + Tailwind CSS",
7
7
  "keywords": [