najm-kit 2.11.6 → 2.11.8

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/CHANGELOG.md CHANGED
@@ -1,14 +1,39 @@
1
- # Changelog
2
-
3
- ## 2.11.6 - 2026-08-15
4
-
5
- - Fixed `FormInput` dropping React Hook Form's native field binding for text,
6
- number, password, textarea, and time controls. These controls now forward
7
- their field name, composed blur handler, and focus ref to the actual native
8
- element, preserving browser form semantics, touched-state tracking, and
9
- programmatic focus without changing composite-control contracts.
10
-
11
- ## 2.11.5 - 2026-08-14
1
+ # Changelog
2
+
3
+ ## 2.11.8 - 2026-08-28
4
+
5
+ - Fixed `NContextMenu` leaving keyboard focus on its opener after the menu
6
+ appeared. The first enabled action now receives focus, disabled actions are
7
+ skipped, Arrow Up/Down wrap, Home/End jump to the bounds, Tab dismisses the
8
+ menu, and Escape restores focus to the opener. Focused actions also receive
9
+ the same visual treatment as hovered actions.
10
+
11
+ ## 2.11.7 - 2026-08-17
12
+
13
+ - Fixed date, combobox, and multiselect `FormInput` controls reaching the
14
+ accessibility tree with no accessible name. These types render their trigger
15
+ as a styled `div`, so the `<label for>` that `FormControl` wires up pointed at
16
+ a non-labelable element and named nothing: screen readers announced an unnamed
17
+ control, and `getByRole(..., { name })` could not resolve one. The form label
18
+ now becomes the trigger's `aria-label`, and `DateInput` and `ComboboxInput`
19
+ accept an explicit `ariaLabel` to override it. `select` keeps naming itself
20
+ from `ariaLabel || placeholder`; renaming it would break consumers that select
21
+ it by placeholder today.
22
+ - Fixed `DateInput` rendering its popover trigger as a `div`. Radix only merges
23
+ trigger props onto the child, so the control had no role, no tab stop, and no
24
+ keyboard activation — it was unreachable without a mouse. It is now a real
25
+ `<button type="button">`, which also keeps it from submitting the surrounding
26
+ form when the calendar opens.
27
+
28
+ ## 2.11.6 - 2026-08-15
29
+
30
+ - Fixed `FormInput` dropping React Hook Form's native field binding for text,
31
+ number, password, textarea, and time controls. These controls now forward
32
+ their field name, composed blur handler, and focus ref to the actual native
33
+ element, preserving browser form semantics, touched-state tracking, and
34
+ programmatic focus without changing composite-control contracts.
35
+
36
+ ## 2.11.5 - 2026-08-14
12
37
 
13
38
  - Fixed numbered lists staying on an invalid final page after a deletion or
14
39
  another mutation reduced the server total. Paged lists now clamp before
package/dist/index.d.ts CHANGED
@@ -2238,6 +2238,7 @@ interface ComboboxInputProps extends BaseProps {
2238
2238
  showIcon?: boolean;
2239
2239
  disabled?: boolean;
2240
2240
  allowFreeText?: boolean;
2241
+ ariaLabel?: string;
2241
2242
  }
2242
2243
  interface MultiSelectInputProps extends BaseProps {
2243
2244
  value: string[];
@@ -2294,6 +2295,7 @@ interface DateInputProps extends BaseProps {
2294
2295
  placeholder?: string;
2295
2296
  icon?: InputIcon;
2296
2297
  showIcon?: boolean;
2298
+ ariaLabel?: string;
2297
2299
  }
2298
2300
  interface StarRatingInputProps extends BaseProps {
2299
2301
  value: number;
package/dist/index.mjs CHANGED
@@ -4551,7 +4551,7 @@ var TextAreaInput = React66__default.forwardRef(({ value, onChange, placeholder
4551
4551
  );
4552
4552
  });
4553
4553
  TextAreaInput.displayName = "TextAreaInput";
4554
- var ComboboxInput = ({ placeholder = "Select...", searchPlaceholder = "Search...", emptyMessage = "No results found.", loading = false, loadingMessage = "Loading...", onSearchChange, shouldFilter = true, value, onChange, icon, showIcon = true, iconColor, items = [], className = "", variant = "default", status = "default", bordered, borderColor, disabled = false, allowFreeText = false }) => {
4554
+ var ComboboxInput = ({ placeholder = "Select...", searchPlaceholder = "Search...", emptyMessage = "No results found.", loading = false, loadingMessage = "Loading...", onSearchChange, shouldFilter = true, value, onChange, icon, showIcon = true, iconColor, items = [], className = "", variant = "default", status = "default", bordered, borderColor, disabled = false, allowFreeText = false, ariaLabel }) => {
4555
4555
  const [open, setOpen] = useState(false);
4556
4556
  const triggerRef = React66__default.useRef(null);
4557
4557
  const pointerDismissedRef = React66__default.useRef(false);
@@ -4590,6 +4590,7 @@ var ComboboxInput = ({ placeholder = "Select...", searchPlaceholder = "Search...
4590
4590
  bordered,
4591
4591
  borderColor,
4592
4592
  role: "combobox",
4593
+ "aria-label": ariaLabel,
4593
4594
  tabIndex: disabled ? -1 : 0,
4594
4595
  "aria-expanded": open,
4595
4596
  className: cn(
@@ -4782,13 +4783,13 @@ function Calendar(props) {
4782
4783
  }
4783
4784
  );
4784
4785
  }
4785
- var DateInput = ({ value, onChange, placeholder = "Pick a date", className = "", icon, showIcon = true, iconColor, variant = "default", status = "default", bordered, borderColor }) => {
4786
+ var DateInput = ({ value, onChange, placeholder = "Pick a date", className = "", icon, showIcon = true, iconColor, variant = "default", status = "default", bordered, borderColor, ariaLabel }) => {
4786
4787
  const iconProps = getIconColorProps(iconColor, "h-4 w-4");
4787
4788
  const toDateString = (date) => date ? format(date, "yyyy-MM-dd") : void 0;
4788
4789
  const toDate = (val) => typeof val === "string" ? parseISO(val) : val;
4789
4790
  return /* @__PURE__ */ jsxs(BaseInput, { variant, status, bordered, borderColor, className, children: [
4790
4791
  /* @__PURE__ */ jsxs(Popover, { children: [
4791
- /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx("div", { className: "w-full flex items-center cursor-pointer gap-2 justify-start text-left font-normal", children: /* @__PURE__ */ jsx(Label2, { className: cn("cursor-pointer", value ? "text-foreground" : "text-muted-foreground"), children: value ? format(toDate(value), "PPP") : placeholder }) }) }),
4792
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx("button", { type: "button", "aria-label": ariaLabel, className: "w-full flex items-center cursor-pointer gap-2 justify-start text-left font-normal bg-transparent border-0 p-0 outline-none", children: /* @__PURE__ */ jsx("span", { className: cn("cursor-pointer", value ? "text-foreground" : "text-muted-foreground"), children: value ? format(toDate(value), "PPP") : placeholder }) }) }),
4792
4793
  /* @__PURE__ */ jsx(PopoverContent, { className: "w-auto p-0", align: "start", children: /* @__PURE__ */ jsx(Calendar, { mode: "single", selected: toDate(value), onSelect: (date) => onChange(toDateString(date)), captionLayout: "dropdown" }) })
4793
4794
  ] }),
4794
4795
  !icon && showIcon && /* @__PURE__ */ jsx(Calendar$1, { className: iconProps.className, style: iconProps.style })
@@ -9878,6 +9879,7 @@ function NViewBody({ children, className, variant = "table" }) {
9878
9879
  function NContextMenu({ x, y, items, onAction, onClose, className }) {
9879
9880
  const [openSub, setOpenSub] = useState(null);
9880
9881
  const menuRef = useRef(null);
9882
+ const returnFocusRef = useRef(null);
9881
9883
  const [pos, setPos] = useState({ left: x, top: y });
9882
9884
  useLayoutEffect(() => {
9883
9885
  if (!menuRef.current) return;
@@ -9894,13 +9896,54 @@ function NContextMenu({ x, y, items, onAction, onClose, className }) {
9894
9896
  (prev) => prev.left === next.left && prev.top === next.top ? prev : next
9895
9897
  );
9896
9898
  }, [x, y]);
9899
+ useLayoutEffect(() => {
9900
+ const activeElement = document.activeElement;
9901
+ if (activeElement instanceof HTMLElement && !menuRef.current?.contains(activeElement)) {
9902
+ returnFocusRef.current = activeElement;
9903
+ }
9904
+ menuRef.current?.querySelector("[data-context-menu-item]:not(:disabled)")?.focus();
9905
+ }, []);
9897
9906
  useEffect(() => {
9898
9907
  const onKey = (e) => {
9899
- if (e.key === "Escape") onClose();
9908
+ if (e.key !== "Escape") return;
9909
+ e.preventDefault();
9910
+ onClose();
9911
+ queueMicrotask(() => returnFocusRef.current?.focus());
9900
9912
  };
9901
9913
  window.addEventListener("keydown", onKey);
9902
9914
  return () => window.removeEventListener("keydown", onKey);
9903
9915
  }, [onClose]);
9916
+ const onMenuKeyDown = (e) => {
9917
+ const enabledItems = Array.from(
9918
+ menuRef.current?.querySelectorAll(
9919
+ "[data-context-menu-item]:not(:disabled)"
9920
+ ) ?? []
9921
+ );
9922
+ if (!enabledItems.length) return;
9923
+ const currentIndex = enabledItems.indexOf(document.activeElement);
9924
+ let nextIndex = null;
9925
+ switch (e.key) {
9926
+ case "ArrowDown":
9927
+ nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % enabledItems.length;
9928
+ break;
9929
+ case "ArrowUp":
9930
+ nextIndex = currentIndex < 0 ? enabledItems.length - 1 : (currentIndex - 1 + enabledItems.length) % enabledItems.length;
9931
+ break;
9932
+ case "Home":
9933
+ nextIndex = 0;
9934
+ break;
9935
+ case "End":
9936
+ nextIndex = enabledItems.length - 1;
9937
+ break;
9938
+ case "Tab":
9939
+ onClose();
9940
+ return;
9941
+ default:
9942
+ return;
9943
+ }
9944
+ e.preventDefault();
9945
+ enabledItems[nextIndex].focus();
9946
+ };
9904
9947
  useEffect(() => {
9905
9948
  const isInsideMenu = (target) => target instanceof Node && !!menuRef.current?.contains(target);
9906
9949
  const onMouseDown = (e) => {
@@ -9929,6 +9972,7 @@ function NContextMenu({ x, y, items, onAction, onClose, className }) {
9929
9972
  ),
9930
9973
  style: { left: pos.left, top: pos.top },
9931
9974
  onContextMenu: (e) => e.preventDefault(),
9975
+ onKeyDown: onMenuKeyDown,
9932
9976
  children: items.map((item) => {
9933
9977
  const Icon2 = item.icon;
9934
9978
  const hasSub = !!item.submenu?.length;
@@ -9946,6 +9990,8 @@ function NContextMenu({ x, y, items, onAction, onClose, className }) {
9946
9990
  {
9947
9991
  type: "button",
9948
9992
  role: "menuitem",
9993
+ tabIndex: -1,
9994
+ "data-context-menu-item": true,
9949
9995
  disabled: item.disabled,
9950
9996
  onClick: () => {
9951
9997
  if (hasSub) return;
@@ -9953,8 +9999,8 @@ function NContextMenu({ x, y, items, onAction, onClose, className }) {
9953
9999
  onClose();
9954
10000
  },
9955
10001
  className: cn(
9956
- "flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-40",
9957
- item.danger && "text-destructive hover:text-destructive"
10002
+ "flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-40",
10003
+ item.danger && "text-destructive hover:text-destructive focus:text-destructive"
9958
10004
  ),
9959
10005
  children: [
9960
10006
  /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
@@ -9973,14 +10019,15 @@ function NContextMenu({ x, y, items, onAction, onClose, className }) {
9973
10019
  {
9974
10020
  type: "button",
9975
10021
  role: "menuitem",
10022
+ tabIndex: -1,
9976
10023
  disabled: sub.disabled,
9977
10024
  onClick: () => {
9978
10025
  onAction(sub.id);
9979
10026
  onClose();
9980
10027
  },
9981
10028
  className: cn(
9982
- "flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-40",
9983
- sub.danger && "text-destructive hover:text-destructive"
10029
+ "flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-40",
10030
+ sub.danger && "text-destructive hover:text-destructive focus:text-destructive"
9984
10031
  ),
9985
10032
  children: [
9986
10033
  SubIcon && /* @__PURE__ */ jsx(SubIcon, { size: 14 }),
@@ -10879,6 +10926,7 @@ var Inputs = {
10879
10926
  slider: SliderInput
10880
10927
  };
10881
10928
  var nativeFieldTypes = /* @__PURE__ */ new Set(["text", "number", "password", "textarea", "time"]);
10929
+ var labelNamedCompositeTypes = /* @__PURE__ */ new Set(["date", "combobox", "multiselect"]);
10882
10930
  var FormInput = ({ name, type, formLabel, formDescription, required = false, disabled = false, readOnly = false, hidden = false, icon, iconColor, classNames, background, ...rest }) => {
10883
10931
  const InputComponent = Inputs[type];
10884
10932
  const { control } = useFormContext();
@@ -10897,6 +10945,7 @@ var FormInput = ({ name, type, formLabel, formDescription, required = false, dis
10897
10945
  };
10898
10946
  const presetInput = type === "textarea" ? preset.input?.replace(/\bh-\d+\b/g, "").trim() : preset.input;
10899
10947
  const isFillAvatar = type === "avatar" && inputRest.fill === true;
10948
+ const compositeAriaLabel = labelNamedCompositeTypes.has(type) && typeof formLabel === "string" ? { ariaLabel: inputRest.ariaLabel ?? formLabel } : {};
10900
10949
  const slot = {
10901
10950
  item: cn(
10902
10951
  preset.item,
@@ -10929,7 +10978,7 @@ var FormInput = ({ name, type, formLabel, formDescription, required = false, dis
10929
10978
  formLabel,
10930
10979
  required && !disabled && !readOnly && /* @__PURE__ */ jsx("span", { className: "text-destructive ml-1", children: "*" })
10931
10980
  ] }),
10932
- /* @__PURE__ */ jsx(FormControl, { children: /* @__PURE__ */ jsx(InputComponent, { value: field.value ?? getDefaultValue(), onChange: handleChange, status: hasError && !isHidden ? "error" : "default", bordered: contextBordered, icon: formLabel ? void 0 : icon, iconColor: formLabel ? void 0 : iconColor, disabled, readOnly, ...inputRest, ...nativeFieldBinding, className: slot.input }) }),
10981
+ /* @__PURE__ */ jsx(FormControl, { children: /* @__PURE__ */ jsx(InputComponent, { value: field.value ?? getDefaultValue(), onChange: handleChange, status: hasError && !isHidden ? "error" : "default", bordered: contextBordered, icon: formLabel ? void 0 : icon, iconColor: formLabel ? void 0 : iconColor, disabled, readOnly, ...inputRest, ...compositeAriaLabel, ...nativeFieldBinding, className: slot.input }) }),
10933
10982
  !hasError && !disabled && !readOnly && formDescription && /* @__PURE__ */ jsx(FormDescription, { className: slot.description, children: formDescription }),
10934
10983
  !isHidden && /* @__PURE__ */ jsx(FormMessage, { className: slot.error })
10935
10984
  ] });
package/dist/theme.css CHANGED
@@ -307,10 +307,10 @@ input:autofill {
307
307
  overflow-x: auto;
308
308
  scrollbar-width: none;
309
309
  }
310
- .najm-overlay-scroll-x::-webkit-scrollbar {
311
- display: none;
312
- }
313
-
310
+ .najm-overlay-scroll-x::-webkit-scrollbar {
311
+ display: none;
312
+ }
313
+
314
314
  /* Responsive table actions stay discoverable on touch and tablet layouts.
315
315
  Fine-pointer desktops may keep the quieter hover/focus reveal treatment. */
316
316
  .ntable-card-action {
@@ -367,7 +367,7 @@ input:autofill {
367
367
  .nimage-input-compact-overlay:focus-visible {
368
368
  opacity: 1;
369
369
  }
370
- }
370
+ }
371
371
 
372
372
  /* OverlayScrollbars theme used by the <NajmScroll> component — a thin,
373
373
  translucent slate bar that floats over content with no reserved space.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-kit",
3
- "version": "2.11.6",
3
+ "version": "2.11.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Reusable React UI component package for Najm framework",