@mohasinac/appkit 3.5.3 → 3.5.4

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.
Files changed (44) hide show
  1. package/dist/features/seller/components/CategoryInlineSelect.d.ts +1 -1
  2. package/dist/features/seller/components/CategoryInlineSelect.js +1 -1
  3. package/dist/react/hooks/useBulkSelection.js +12 -3
  4. package/dist/react/hooks/useGesture.js +48 -4
  5. package/dist/react/hooks/useLongPress.d.ts +1 -0
  6. package/dist/react/hooks/useLongPress.js +5 -0
  7. package/dist/react/hooks/usePullToRefresh.js +12 -1
  8. package/dist/react/hooks/useRealtimeEvent.js +18 -1
  9. package/dist/react/hooks/useSwipe.js +14 -0
  10. package/dist/styles.css +1 -1
  11. package/dist/tailwind-utilities.css +1 -1
  12. package/dist/ui/components/BaseListingCard.d.ts +2 -1
  13. package/dist/ui/components/BaseListingCard.js +4 -5
  14. package/dist/ui/components/Button.js +52 -22
  15. package/dist/ui/components/DateInput.js +1 -0
  16. package/dist/ui/components/DynamicBgDiv.js +8 -6
  17. package/dist/ui/components/FormField.js +1 -1
  18. package/dist/ui/components/HorizontalScroller.js +17 -6
  19. package/dist/ui/components/Iframe.d.ts +8 -1
  20. package/dist/ui/components/Layout.d.ts +4 -4
  21. package/dist/ui/components/Layout.js +8 -8
  22. package/dist/ui/components/Motion.js +4 -1
  23. package/dist/ui/components/OtpInput.js +1 -1
  24. package/dist/ui/components/PaginatedSelect.js +29 -3
  25. package/dist/ui/components/Pagination.js +10 -5
  26. package/dist/ui/components/RichTextEditor.js +64 -3
  27. package/dist/ui/components/Select.js +1 -0
  28. package/dist/ui/components/Semantic.d.ts +10 -10
  29. package/dist/ui/components/Semantic.js +25 -25
  30. package/dist/ui/components/SideDrawer.js +13 -11
  31. package/dist/ui/components/SideModal.js +9 -7
  32. package/dist/ui/components/SlottedListingView.d.ts +13 -2
  33. package/dist/ui/components/StickyToolbar.js +9 -5
  34. package/dist/ui/components/TagInput.js +15 -2
  35. package/dist/ui/components/Textarea.js +1 -0
  36. package/dist/ui/components/Toggle.js +8 -1
  37. package/dist/ui/components/UnsavedChangesModal.js +12 -1
  38. package/dist/ui/forms/ColorPickerField.js +7 -0
  39. package/dist/ui/forms/FieldCheckbox.js +1 -1
  40. package/dist/utils/id-generators.d.ts +7 -4
  41. package/dist/utils/id-generators.js +53 -31
  42. package/dist/utils/number.formatter.js +19 -6
  43. package/dist/utils/string.formatter.js +4 -1
  44. package/package.json +1 -1
@@ -59,9 +59,9 @@ export function sectionBackgroundStyle(bg) {
59
59
  return {};
60
60
  }
61
61
  }
62
- export const Section = React.forwardRef(({ tone = "plain", background, color, layout, gap, align, justify, className = "", surface, padding, paddingX, paddingY, rounded, border, shadow, children, ...props }, ref) => (_jsxs("section", { className: [
62
+ export const Section = React.forwardRef(({ tone = "plain", background, color, layout, gap, align, justify, className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, children, ...props }, ref) => (_jsxs("section", { className: [
63
63
  SECTION_TONE_MAP[tone],
64
- buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, border, shadow }),
64
+ buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }),
65
65
  color ? SEMANTIC_COLOR_MAP[color] : "",
66
66
  layout ? SECTION_LAYOUT_MAP[layout] : "",
67
67
  gap ? SECTION_GAP_MAP[gap] : "",
@@ -73,13 +73,13 @@ export const Section = React.forwardRef(({ tone = "plain", background, color, la
73
73
  .filter(Boolean)
74
74
  .join(" "), ref: ref, ...props, children: [background?.value && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: "absolute inset-0 -z-10", style: sectionBackgroundStyle(background) }), background.overlay?.enabled && (_jsx("span", { "aria-hidden": true, className: "absolute inset-0 -z-10", style: { backgroundColor: background.overlay.color, opacity: background.overlay.opacity } }))] })), children] })));
75
75
  Section.displayName = "Section";
76
- export function Article({ className = "", surface, padding, rounded, border, shadow, color, children, ...props }) {
77
- return (_jsx("article", { className: [buildSurfaceClasses({ surface, padding, rounded, border, shadow }), color ? SEMANTIC_COLOR_MAP[color] : "", className].filter(Boolean).join(" "), ...props, children: children }));
76
+ export function Article({ className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, color, children, ...props }) {
77
+ return (_jsx("article", { className: [buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), color ? SEMANTIC_COLOR_MAP[color] : "", className].filter(Boolean).join(" "), ...props, children: children }));
78
78
  }
79
- export function Main({ className = "", surface, padding, paddingX, paddingY, rounded, border, shadow, color, children, ...props }) {
80
- return (_jsx("main", { className: [buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, border, shadow }), color ? SEMANTIC_COLOR_MAP[color] : "", className].filter(Boolean).join(" "), ...props, children: children }));
79
+ export function Main({ className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, color, children, ...props }) {
80
+ return (_jsx("main", { className: [buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), color ? SEMANTIC_COLOR_MAP[color] : "", className].filter(Boolean).join(" "), ...props, children: children }));
81
81
  }
82
- export const Aside = React.forwardRef(({ className = "", surface, padding, rounded, border, shadow, color, children, ...props }, ref) => (_jsx("aside", { className: [buildSurfaceClasses({ surface, padding, rounded, border, shadow }), color ? SEMANTIC_COLOR_MAP[color] : "", className].filter(Boolean).join(" "), ref: ref, ...props, children: children })));
82
+ export const Aside = React.forwardRef(({ className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, color, children, ...props }, ref) => (_jsx("aside", { className: [buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), color ? SEMANTIC_COLOR_MAP[color] : "", className].filter(Boolean).join(" "), ref: ref, ...props, children: children })));
83
83
  Aside.displayName = "Aside";
84
84
  const NAV_TEXT_SIZE_MAP = {
85
85
  xs: "text-xs",
@@ -97,8 +97,8 @@ const NAV_SPACING_MAP = {
97
97
  };
98
98
  const NAV_GAP_MAP = {
99
99
  none: "",
100
- "2xs": "gap-1.5",
101
- xs: "gap-1",
100
+ "2xs": "gap-1",
101
+ xs: "gap-1.5",
102
102
  sm: "gap-2",
103
103
  md: "gap-3",
104
104
  lg: "gap-4",
@@ -114,9 +114,9 @@ const NAV_COLOR_MAP = {
114
114
  muted: "appkit-color--muted",
115
115
  faint: "appkit-color--faint",
116
116
  };
117
- export function Nav({ surface, padding, rounded, border, shadow, spacing, gap, layout, color, textSize, className = "", children, ...props }) {
117
+ export function Nav({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, spacing, gap, layout, color, textSize, className = "", children, ...props }) {
118
118
  return (_jsx("nav", { className: [
119
- buildSurfaceClasses({ surface, padding, rounded, border, shadow }),
119
+ buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }),
120
120
  spacing ? NAV_SPACING_MAP[spacing] : "",
121
121
  gap ? NAV_GAP_MAP[gap] : "",
122
122
  layout ? NAV_LAYOUT_MAP[layout] : "",
@@ -125,11 +125,11 @@ export function Nav({ surface, padding, rounded, border, shadow, spacing, gap, l
125
125
  className,
126
126
  ].filter(Boolean).join(" "), ...props, children: children }));
127
127
  }
128
- export function BlockHeader({ className = "", surface, padding, rounded, border, shadow, children, ...props }) {
129
- return (_jsx("header", { className: [buildSurfaceClasses({ surface, padding, rounded, border, shadow }), className].filter(Boolean).join(" "), ...props, children: children }));
128
+ export function BlockHeader({ className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, children, ...props }) {
129
+ return (_jsx("header", { className: [buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), className].filter(Boolean).join(" "), ...props, children: children }));
130
130
  }
131
- export function BlockFooter({ className = "", surface, padding, rounded, border, shadow, children, ...props }) {
132
- return (_jsx("footer", { className: [buildSurfaceClasses({ surface, padding, rounded, border, shadow }), className].filter(Boolean).join(" "), ...props, children: children }));
131
+ export function BlockFooter({ className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, children, ...props }) {
132
+ return (_jsx("footer", { className: [buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), className].filter(Boolean).join(" "), ...props, children: children }));
133
133
  }
134
134
  const LIST_MARKER_MAP = {
135
135
  disc: "appkit-list--marker-disc",
@@ -263,8 +263,8 @@ const TABLE_SIZE_MAP = {
263
263
  md: "appkit-table--md",
264
264
  lg: "appkit-table--lg",
265
265
  };
266
- export function Table({ variant = "default", size = "md", stickyHeader = false, className = "", surface, padding, rounded, border, shadow, children, ...props }) {
267
- return (_jsx("table", { className: ["appkit-table", TABLE_VARIANT_MAP[variant], TABLE_SIZE_MAP[size], stickyHeader ? "appkit-table--sticky-header" : "", buildSurfaceClasses({ surface, padding, rounded, border, shadow }), className].filter(Boolean).join(" "), ...props, children: children }));
266
+ export function Table({ variant = "default", size = "md", stickyHeader = false, className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, children, ...props }) {
267
+ return (_jsx("table", { className: ["appkit-table", TABLE_VARIANT_MAP[variant], TABLE_SIZE_MAP[size], stickyHeader ? "appkit-table--sticky-header" : "", buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), className].filter(Boolean).join(" "), ...props, children: children }));
268
268
  }
269
269
  const THEAD_SURFACE_MAP = {
270
270
  none: "",
@@ -444,8 +444,8 @@ export function Code({ color = "default", weight, size, padding, rounded, surfac
444
444
  className,
445
445
  ].filter(Boolean).join(" "), ...props, children: children }));
446
446
  }
447
- export function Pre({ className = "", surface, padding, rounded, border, shadow, children, ...props }) {
448
- return (_jsx("pre", { className: ["appkit-pre", buildSurfaceClasses({ surface, padding, rounded, border, shadow }), className].filter(Boolean).join(" "), ...props, children: children }));
447
+ export function Pre({ className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, children, ...props }) {
448
+ return (_jsx("pre", { className: ["appkit-pre", buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), className].filter(Boolean).join(" "), ...props, children: children }));
449
449
  }
450
450
  const BLOCKQUOTE_COLOR_MAP = {
451
451
  default: "appkit-blockquote--default",
@@ -453,22 +453,22 @@ const BLOCKQUOTE_COLOR_MAP = {
453
453
  info: "appkit-blockquote--info",
454
454
  warning: "appkit-blockquote--warning",
455
455
  };
456
- export function Blockquote({ color = "default", className = "", surface, padding, paddingX, paddingY, rounded, border, shadow, children, ...props }) {
457
- return (_jsx("blockquote", { className: ["appkit-blockquote", BLOCKQUOTE_COLOR_MAP[color], buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, border, shadow }), className].filter(Boolean).join(" "), ...props, children: children }));
456
+ export function Blockquote({ color = "default", className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, children, ...props }) {
457
+ return (_jsx("blockquote", { className: ["appkit-blockquote", BLOCKQUOTE_COLOR_MAP[color], buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), className].filter(Boolean).join(" "), ...props, children: children }));
458
458
  }
459
- export function Figure({ className = "", surface, padding, rounded, border, shadow, children, ...props }) {
460
- return (_jsx("figure", { className: ["appkit-figure", buildSurfaceClasses({ surface, padding, rounded, border, shadow }), className].filter(Boolean).join(" "), ...props, children: children }));
459
+ export function Figure({ className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, children, ...props }) {
460
+ return (_jsx("figure", { className: ["appkit-figure", buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), className].filter(Boolean).join(" "), ...props, children: children }));
461
461
  }
462
462
  export function Figcaption({ className = "", children, ...props }) {
463
463
  return (_jsx("figcaption", { className: ["appkit-figcaption", className].filter(Boolean).join(" "), ...props, children: children }));
464
464
  }
465
- export function Dl({ variant = "stacked", divide, className = "", surface, padding, rounded, border, shadow, children, ...props }) {
465
+ export function Dl({ variant = "stacked", divide, className = "", surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, children, ...props }) {
466
466
  const divideCls = divide
467
467
  ? divide === "subtle"
468
468
  ? "appkit-stack--divide-subtle"
469
469
  : "appkit-stack--divide"
470
470
  : "";
471
- return (_jsx("dl", { className: ["appkit-dl", variant === "inline" ? "appkit-dl--inline" : "appkit-dl--stacked", divideCls, buildSurfaceClasses({ surface, padding, rounded, border, shadow }), className].filter(Boolean).join(" "), ...props, children: children }));
471
+ return (_jsx("dl", { className: ["appkit-dl", variant === "inline" ? "appkit-dl--inline" : "appkit-dl--stacked", divideCls, buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }), className].filter(Boolean).join(" "), ...props, children: children }));
472
472
  }
473
473
  const DT_DD_COLOR_MAP = {
474
474
  default: "",
@@ -124,18 +124,20 @@ export function SideDrawer({ isOpen, onClose, title, children, footer, mode = "v
124
124
  }, [isOpen]);
125
125
  // Prevent body scroll when drawer is open
126
126
  useEffect(() => {
127
- if (isOpen) {
128
- const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
129
- document.body.style.overflow = "hidden";
130
- document.body.style.paddingRight = `${scrollbarWidth}px`;
131
- }
132
- else {
133
- document.body.style.overflow = "";
134
- document.body.style.paddingRight = "";
135
- }
127
+ if (!isOpen)
128
+ return;
129
+ // Capture and restore the PRIOR values rather than hard-coding ""
130
+ // otherwise closing this drawer while another overlay (Modal,
131
+ // SideModal, a second SideDrawer) is still open underneath wipes out
132
+ // that overlay's own scroll lock too.
133
+ const previousOverflow = document.body.style.overflow;
134
+ const previousPaddingRight = document.body.style.paddingRight;
135
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
136
+ document.body.style.overflow = "hidden";
137
+ document.body.style.paddingRight = `${scrollbarWidth}px`;
136
138
  return () => {
137
- document.body.style.overflow = "";
138
- document.body.style.paddingRight = "";
139
+ document.body.style.overflow = previousOverflow;
140
+ document.body.style.paddingRight = previousPaddingRight;
139
141
  };
140
142
  }, [isOpen]);
141
143
  useSwipe(drawerRef, {
@@ -40,14 +40,16 @@ export function SideModal({ isOpen, onClose, title, side = "right", children, cl
40
40
  }, [isOpen]);
41
41
  // Prevent body scroll while open
42
42
  useEffect(() => {
43
- if (isOpen) {
44
- document.body.style.overflow = "hidden";
45
- }
46
- else {
47
- document.body.style.overflow = "";
48
- }
43
+ if (!isOpen)
44
+ return;
45
+ // Capture and restore the PRIOR value rather than hard-coding "" —
46
+ // otherwise closing this modal while another overlay is still open
47
+ // underneath (Modal, SideDrawer, a second SideModal) wipes out that
48
+ // overlay's own scroll lock too.
49
+ const previousOverflow = document.body.style.overflow;
50
+ document.body.style.overflow = "hidden";
49
51
  return () => {
50
- document.body.style.overflow = "";
52
+ document.body.style.overflow = previousOverflow;
51
53
  };
52
54
  }, [isOpen]);
53
55
  const reduced = useReducedMotion();
@@ -46,11 +46,22 @@ export interface SlottedListingViewProps {
46
46
  * - `public`: no managed state by default
47
47
  */
48
48
  portal?: ViewPortal;
49
- /** Enable managed search state (default driven by portal, otherwise false). */
49
+ /**
50
+ * Currently unused — `renderSearch` is always invoked when provided,
51
+ * regardless of this flag. Kept as a no-op accepted prop because real
52
+ * consumers (CategoryProductsView, StoreProductsView, FAQPageContent, …)
53
+ * already pass their own self-contained `renderSearch` closures that
54
+ * manage search state externally and would lose their search UI if this
55
+ * ever started gating the render call. Do not wire this up to gate
56
+ * `renderSearch` without auditing every consumer's `renderSearch` first.
57
+ */
50
58
  manageSearch?: boolean;
51
59
  /** Enable managed selection state (default driven by portal, otherwise false). */
52
60
  manageSelection?: boolean;
53
- /** Enable managed sort state (default empty string). */
61
+ /**
62
+ * Currently unused — see `manageSearch` above; `renderSort` is always
63
+ * invoked when provided, regardless of this flag, for the same reason.
64
+ */
54
65
  manageSort?: boolean;
55
66
  /** Wrap search + sort slots in a flex row. */
56
67
  inlineToolbar?: boolean;
@@ -12,21 +12,25 @@ const PADDING_CLS = {
12
12
  lg: "px-4 py-3",
13
13
  toolbar: "px-3 py-1.5",
14
14
  };
15
- function resolveOffset(offset) {
15
+ function resolveOffsetClass(offset) {
16
16
  if (offset === "header") {
17
17
  return "top-[var(--header-height,0px)]";
18
18
  }
19
19
  if (offset === "header+nav") {
20
20
  return "top-[calc(var(--header-height,0px)+var(--appkit-navbar-height,2.5rem))]";
21
21
  }
22
- // Numeric pixel offset.
23
- return `top-[${offset}px]`;
22
+ // Numeric offsets are applied via inline style instead (see below) —
23
+ // Tailwind's static scanner can never see a dynamically-interpolated
24
+ // `top-[${offset}px]` arbitrary value, so no CSS rule would ever be
25
+ // generated for it in the compiled stylesheet.
26
+ return "";
24
27
  }
25
28
  export function StickyToolbar({ children, offset = "header", tone = "translucent", border = true, padding = "toolbar", z = "above-content", role, }) {
26
- const offsetCls = resolveOffset(offset);
29
+ const offsetCls = resolveOffsetClass(offset);
30
+ const offsetStyle = typeof offset === "number" ? { top: `${offset}px` } : undefined;
27
31
  const borderCls = border ? "border-b border-[var(--appkit-color-border)]" : "";
28
32
  const zCls = z === "above-content" ? "z-10" : "z-[5]";
29
- return (_jsx("div", { role: role,
33
+ return (_jsx("div", { role: role, style: offsetStyle,
30
34
  // for the translucent sticky-toolbar pattern. The header offset is
31
35
  // sourced from --header-height (set by AppLayoutShell at runtime).
32
36
  className: `sticky ${offsetCls} ${zCls} ${TONE_CLS[tone]} ${borderCls} ${PADDING_CLS[padding]}`, children: children }));
@@ -5,8 +5,9 @@ import { Label, Span } from "./Typography";
5
5
  export function TagInput({ value, onChange, disabled = false, label, placeholder = "Add a tag...", className = "", helperText = "Press Enter or comma to add a tag", }) {
6
6
  const [draft, setDraft] = useState("");
7
7
  const inputRef = useRef(null);
8
+ const normalizeTag = (raw) => raw.trim().replace(/,+$/, "").trim();
8
9
  const addTag = (raw) => {
9
- const tag = raw.trim().replace(/,+$/, "").trim();
10
+ const tag = normalizeTag(raw);
10
11
  if (!tag || value.includes(tag)) {
11
12
  setDraft("");
12
13
  return;
@@ -32,7 +33,19 @@ export function TagInput({ value, onChange, disabled = false, label, placeholder
32
33
  if (inputValue.includes(",")) {
33
34
  const parts = inputValue.split(",");
34
35
  const last = parts.pop() ?? "";
35
- parts.forEach((part) => addTag(part));
36
+ // Accumulate locally instead of calling addTag() in a loop — addTag
37
+ // always computes `[...value, tag]` off the current render's `value`
38
+ // prop, so multiple synchronous calls in the same paste each started
39
+ // from the SAME stale array and every onChange but the last one got
40
+ // silently overwritten (only the final comma-segment ever survived).
41
+ let next = value;
42
+ for (const part of parts) {
43
+ const tag = normalizeTag(part);
44
+ if (tag && !next.includes(tag))
45
+ next = [...next, tag];
46
+ }
47
+ if (next !== value)
48
+ onChange(next);
36
49
  setDraft(last);
37
50
  return;
38
51
  }
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import React from "react";
3
4
  import { Label, Text } from "./Typography";
@@ -43,5 +43,12 @@ export function Toggle({ checked: checkedProp, defaultChecked = false, onChange,
43
43
  .join(" "), children: _jsx(Span, { "aria-hidden": "true", className: [
44
44
  UI_TOGGLE.thumb,
45
45
  checked ? UI_TOGGLE.thumbOn : UI_TOGGLE.thumbOff,
46
- ].join(" ") }) }), label && (_jsx(Label, { id: `${toggleId}-label`, htmlFor: toggleId, className: disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer", onClick: disabled ? undefined : handleToggle, children: label }))] }));
46
+ ].join(" ") }) }), label && (
47
+ // No onClick here — `htmlFor` on a `<label>` already makes the
48
+ // browser forward a click on the label to the labelable `<button>`
49
+ // above natively. Also attaching handleToggle here fired it TWICE
50
+ // per click (once from this handler, once from the native
51
+ // label→button forwarding), both reading the same stale `checked`
52
+ // closure, so onChange(true) could fire twice for one click.
53
+ _jsx(Label, { id: `${toggleId}-label`, htmlFor: toggleId, className: disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer", children: label }))] }));
47
54
  }
@@ -18,7 +18,18 @@ export function UnsavedChangesModal({ labels = {} }) {
18
18
  useEffect(() => {
19
19
  const subscription = eventBus.on(UNSAVED_CHANGES_EVENT, (...args) => {
20
20
  const resolveFn = args[0];
21
- setResolve(() => resolveFn);
21
+ setResolve((prevResolve) => {
22
+ // A second UNSAVED_CHANGES_EVENT arrived before the user answered
23
+ // the first — resolve that abandoned promise with `false` instead
24
+ // of silently overwriting it and leaving it pending forever.
25
+ prevResolve?.(false);
26
+ // Returning `resolveFn` directly (not `() => resolveFn`) is
27
+ // correct here — the double-wrap trick is only needed when
28
+ // passing setState a bare function *value* (to stop React from
29
+ // treating it as an updater); inside an updater callback the
30
+ // return value already becomes the next state as-is.
31
+ return resolveFn;
32
+ });
22
33
  setIsOpen(true);
23
34
  });
24
35
  return () => subscription.unsubscribe();
@@ -31,6 +31,13 @@ export function ColorPickerField({ name, label, hint, required, swatchWidth = "f
31
31
  }
32
32
  function handleChange(e) {
33
33
  onChange?.(e.target.value);
34
+ // FieldInput/FieldTextarea/FieldSelect/FieldCheckbox all clear the
35
+ // FormShell error for this field on change — this was the only Field*
36
+ // wrapper that omitted it, so a picked (valid) color never cleared a
37
+ // previously-set error until something else called clearErrors/
38
+ // setFieldError.
39
+ if (showError)
40
+ ctx?.clearFieldError(name);
34
41
  }
35
42
  return (_jsxs(Span, { layout: "flex", gap: "sm", className: "flex-col", children: [label != null && (_jsx(Label, { htmlFor: inputId, required: required, children: label })), _jsx("input", { id: inputId, type: "color", name: name, className: `h-10 ${SWATCH_WIDTH_MAP[swatchWidth]} rounded border border-[var(--appkit-color-border)] cursor-pointer ${className ?? ""}`, "aria-invalid": showError ? true : undefined, "aria-describedby": showError ? errorId : undefined, onBlur: handleBlur, onChange: handleChange, ...inputProps }), hint != null && !showError && (_jsx(Text, { size: "xs", color: "muted", children: hint })), showError && (_jsx(Text, { id: errorId, role: "alert", size: "xs", color: "error", children: error }))] }));
36
43
  }
@@ -21,5 +21,5 @@ export function FieldCheckbox({ name, label, hint, checked, onChange, onBlur, di
21
21
  ctx?.setFieldTouched(name);
22
22
  onBlur?.();
23
23
  }
24
- return (_jsxs("div", { className: ["appkit-form-field", className].filter(Boolean).join(" "), children: [_jsx(Checkbox, { id: inputId, name: name, checked: checked, onChange: handleChange, onBlur: handleBlur, label: label, disabled: disabled, error: showError ? error : undefined, "aria-describedby": showError ? errorId : undefined }), !showError && hint && (_jsx(Text, { size: "sm", variant: "secondary", className: "appkit-form-field__hint", children: hint })), showError && (_jsx(Text, { id: errorId, size: "sm", variant: "error", className: "appkit-form-field__error", role: "alert", children: error }))] }));
24
+ return (_jsxs("div", { className: ["appkit-form-field", className].filter(Boolean).join(" "), children: [_jsx(Checkbox, { id: inputId, name: name, checked: checked, onChange: handleChange, onBlur: handleBlur, label: label, disabled: disabled, "aria-describedby": showError ? errorId : undefined }), !showError && hint && (_jsx(Text, { size: "sm", variant: "secondary", className: "appkit-form-field__hint", children: hint })), showError && (_jsx(Text, { id: errorId, size: "sm", variant: "error", className: "appkit-form-field__error", role: "alert", children: error }))] }));
25
25
  }
@@ -270,10 +270,13 @@ export type MediaFilenameContext = ({
270
270
  export declare function generateMediaFilename(ctx: MediaFilenameContext): string;
271
271
  export declare function validateMediaFilename(filename: string): boolean;
272
272
  /**
273
- * Derives the `MediaFilenameContext["type"]` a filename was generated under
274
- * (e.g. "catalogue-image") by matching against the same prefix list
275
- * {@link validateMediaFilename} uses the longest matching prefix wins so
276
- * "blog-content-image" isn't mis-derived as "blog-cover"'s sibling "blog-".
273
+ * Derives the `MediaFilenameContext["type"]` family a filename was
274
+ * generated under (e.g. "catalogue-image") by matching against the same
275
+ * shape patterns {@link validateMediaFilename} uses. Sibling context types
276
+ * that share one generator (e.g. blog-cover/blog-content-image/
277
+ * blog-additional-image all produce the same shape as blog-image) resolve
278
+ * to that shared family name — the dispatcher itself can't distinguish them
279
+ * from the filename alone, since it never encoded the sub-type into the name.
277
280
  * Used at /api/media/finalize time to stamp `MediaAssetDocument.contextType`
278
281
  * without changing the finalize request contract.
279
282
  */
@@ -74,7 +74,8 @@ export function generateOrderId(input) {
74
74
  export function generateFAQId(input) {
75
75
  if (input.customId?.trim())
76
76
  return input.customId.trim();
77
- return `faq-${slugify(input.category)}-${slugify(input.question).substring(0, 50)}`;
77
+ const questionSlug = slugify(input.question).substring(0, 50).replace(/-+$/, "");
78
+ return `faq-${slugify(input.category)}-${questionSlug}`;
78
79
  }
79
80
  // ─── Coupon ───────────────────────────────────────────────────────────────────
80
81
  export function generateCouponId(code, customId) {
@@ -427,53 +428,74 @@ export function generateMediaFilename(ctx) {
427
428
  }
428
429
  }
429
430
  /**
430
- * W1-51 — validate that a filename matches the canonical pattern produced
431
- * by {@link generateMediaFilename}. Called from /api/media/sign before a
431
+ * W1-51 (regex fixed 2026-08-15 see bug notes below) validate that a
432
+ * filename matches the canonical pattern produced by
433
+ * {@link generateMediaFilename}. Called from /api/media/sign before a
432
434
  * signed-URL is issued so malformed names can never enter storage.
433
435
  *
434
- * Pattern: `<prefix>-<slug parts>-<YYYYMMDD>(-<N>)?(-<rand>)?\.<ext>` where
435
- * the prefix is one of the known media type tokens.
436
+ * The generator functions above put the type word (image/video/logo/
437
+ * banner/avatar) at the END of the slug portion, e.g.
438
+ * `product-{name}-{category}-{store}-image-{n}.ext`, NOT as a literal
439
+ * compound prefix like `product-image-{...}`. The previous version of this
440
+ * validator anchored on the compound-prefix shape and rejected real output
441
+ * from every generator except invoice/payout-doc/shipping-proof/
442
+ * refund-proof/catalogue-image — causing /api/media/sign to 500 for
443
+ * product/review/auction/preorder/store/brand/blog/event/category/user/
444
+ * carousel/rich-text uploads. Each pattern below mirrors one generator
445
+ * function's actual output shape. blog-cover/blog-content-image/
446
+ * blog-additional-image all dispatch to the same generator as blog-image
447
+ * (and the four event-* variants likewise share one shape), so one pattern
448
+ * validates all sub-types of that family.
436
449
  *
437
450
  * Returns true when the filename is acceptable.
438
451
  */
439
- const MEDIA_FILENAME_PREFIXES = [
440
- "product-image", "product-video",
441
- "review-image", "review-video",
442
- "auction-image", "preorder-image",
443
- "store-logo", "store-banner",
444
- "brand-logo", "brand-banner",
445
- "blog-image", "blog-cover", "blog-content-image", "blog-additional-image",
446
- "event-image", "event-cover", "event-winner-image", "event-additional-image",
447
- "rich-text-image",
448
- "category-image",
449
- "user-avatar",
450
- "carousel-image",
451
- "catalogue-image",
452
- "invoice", "payout-doc",
453
- "shipping-proof", "refund-proof",
452
+ const SLUG_PART = "[a-z0-9]+(?:-[a-z0-9]+)*";
453
+ const EXT_PART = "[a-z0-9]+";
454
+ const DATE_PART = "\\d{8}";
455
+ const MEDIA_FILENAME_PATTERNS = [
456
+ { context: "product-image", pattern: new RegExp(`^product-${SLUG_PART}-image-\\d+\\.${EXT_PART}$`) },
457
+ { context: "product-video", pattern: new RegExp(`^product-${SLUG_PART}-video-\\d+\\.${EXT_PART}$`) },
458
+ { context: "review-image", pattern: new RegExp(`^review-${SLUG_PART}-image-\\d+\\.${EXT_PART}$`) },
459
+ { context: "review-video", pattern: new RegExp(`^review-${SLUG_PART}-video-1\\.${EXT_PART}$`) },
460
+ { context: "auction-image", pattern: new RegExp(`^auction-${SLUG_PART}-image-\\d+\\.${EXT_PART}$`) },
461
+ { context: "preorder-image", pattern: new RegExp(`^preorder-${SLUG_PART}-image-\\d+\\.${EXT_PART}$`) },
462
+ { context: "store-logo", pattern: new RegExp(`^store-${SLUG_PART}-logo\\.${EXT_PART}$`) },
463
+ { context: "store-banner", pattern: new RegExp(`^store-${SLUG_PART}-banner\\.${EXT_PART}$`) },
464
+ { context: "brand-logo", pattern: new RegExp(`^brand-${SLUG_PART}-logo\\.${EXT_PART}$`) },
465
+ { context: "brand-banner", pattern: new RegExp(`^brand-${SLUG_PART}-banner\\.${EXT_PART}$`) },
466
+ { context: "blog-image", pattern: new RegExp(`^blog-${SLUG_PART}-image-\\d+\\.${EXT_PART}$`) },
467
+ { context: "event-image", pattern: new RegExp(`^event-${SLUG_PART}-image-\\d+\\.${EXT_PART}$`) },
468
+ { context: "rich-text-image", pattern: new RegExp(`^rich-text-${SLUG_PART}-image-\\d+\\.${EXT_PART}$`) },
469
+ { context: "category-image", pattern: new RegExp(`^category-${SLUG_PART}-image\\.${EXT_PART}$`) },
470
+ { context: "user-avatar", pattern: new RegExp(`^user-${SLUG_PART}-avatar\\.${EXT_PART}$`) },
471
+ { context: "carousel-image", pattern: new RegExp(`^carousel-${SLUG_PART}-image\\.${EXT_PART}$`) },
472
+ { context: "catalogue-image", pattern: new RegExp(`^catalogue-image-${SLUG_PART}-\\d+\\.${EXT_PART}$`) },
473
+ { context: "invoice", pattern: new RegExp(`^invoice-${SLUG_PART}-${DATE_PART}\\.pdf$`) },
474
+ { context: "payout-doc", pattern: new RegExp(`^payout-doc-${SLUG_PART}-${DATE_PART}\\.pdf$`) },
475
+ { context: "shipping-proof", pattern: new RegExp(`^shipping-proof-${SLUG_PART}-${DATE_PART}\\.${EXT_PART}$`) },
476
+ { context: "refund-proof", pattern: new RegExp(`^refund-proof-${SLUG_PART}-${SLUG_PART}-${DATE_PART}\\.${EXT_PART}$`) },
477
+ { context: "payment-proof", pattern: new RegExp(`^payment-proof-${SLUG_PART}-${SLUG_PART}-${DATE_PART}\\.${EXT_PART}$`) },
454
478
  ];
455
- const MEDIA_FILENAME_REGEX = new RegExp(`^(?:${MEDIA_FILENAME_PREFIXES.join("|")})-[a-z0-9]([a-z0-9-]*[a-z0-9])?(?:-\\d+)?\\.[a-z0-9]+$`);
456
479
  export function validateMediaFilename(filename) {
457
480
  if (!filename || filename.length > 256)
458
481
  return false;
459
482
  if (filename.includes("..") || filename.includes("/") || filename.includes("\\"))
460
483
  return false;
461
- return MEDIA_FILENAME_REGEX.test(filename);
484
+ return MEDIA_FILENAME_PATTERNS.some(({ pattern }) => pattern.test(filename));
462
485
  }
463
486
  /**
464
- * Derives the `MediaFilenameContext["type"]` a filename was generated under
465
- * (e.g. "catalogue-image") by matching against the same prefix list
466
- * {@link validateMediaFilename} uses the longest matching prefix wins so
467
- * "blog-content-image" isn't mis-derived as "blog-cover"'s sibling "blog-".
487
+ * Derives the `MediaFilenameContext["type"]` family a filename was
488
+ * generated under (e.g. "catalogue-image") by matching against the same
489
+ * shape patterns {@link validateMediaFilename} uses. Sibling context types
490
+ * that share one generator (e.g. blog-cover/blog-content-image/
491
+ * blog-additional-image all produce the same shape as blog-image) resolve
492
+ * to that shared family name — the dispatcher itself can't distinguish them
493
+ * from the filename alone, since it never encoded the sub-type into the name.
468
494
  * Used at /api/media/finalize time to stamp `MediaAssetDocument.contextType`
469
495
  * without changing the finalize request contract.
470
496
  */
471
497
  export function deriveContextTypeFromFilename(filename) {
472
- const stem = filename.replace(/\.[^.]+$/, "");
473
- const matches = MEDIA_FILENAME_PREFIXES.filter((prefix) => stem.startsWith(`${prefix}-`) || stem === prefix);
474
- if (matches.length === 0)
475
- return undefined;
476
- return matches.reduce((longest, candidate) => (candidate.length > longest.length ? candidate : longest));
498
+ return MEDIA_FILENAME_PATTERNS.find(({ pattern }) => pattern.test(filename))?.context;
477
499
  }
478
500
  /**
479
501
  * Generates a scannable barcode identifier for a product listing.
@@ -40,10 +40,20 @@ export function formatFileSize(bytes) {
40
40
  export function formatCompactNumber(num) {
41
41
  if (num < 1000)
42
42
  return num.toString();
43
- if (num < 1000000)
44
- return `${(num / 1000).toFixed(1)}K`;
45
- if (num < 1000000000)
46
- return `${(num / 1000000).toFixed(1)}M`;
43
+ if (num < 1000000) {
44
+ const kValue = (num / 1000).toFixed(1);
45
+ // Rounding can push a value right up to the next unit's boundary
46
+ // (e.g. 999950 -> "1000.0K") — escalate instead of displaying "1000.0K".
47
+ if (kValue === "1000.0")
48
+ return `${(num / 1000000).toFixed(1)}M`;
49
+ return `${kValue}K`;
50
+ }
51
+ if (num < 1000000000) {
52
+ const mValue = (num / 1000000).toFixed(1);
53
+ if (mValue === "1000.0")
54
+ return `${(num / 1000000000).toFixed(1)}B`;
55
+ return `${mValue}M`;
56
+ }
47
57
  return `${(num / 1000000000).toFixed(1)}B`;
48
58
  }
49
59
  export function formatDecimal(num, decimals = 2) {
@@ -73,7 +83,10 @@ export function parseFormattedNumber(str) {
73
83
  }
74
84
  else if (lastDotIndex > -1 && lastCommaIndex === -1) {
75
85
  const afterDot = cleaned.substring(lastDotIndex + 1);
76
- const isThousands = dotCount > 1 || afterDot.length > 3;
86
+ // A single separator followed by exactly 3 digits is the standard
87
+ // thousands-grouping width (e.g. "3.500" -> 3500), not a decimal — only
88
+ // 1-2 trailing digits are treated as a genuine decimal fraction.
89
+ const isThousands = dotCount > 1 || afterDot.length >= 3;
77
90
  if (isThousands)
78
91
  cleaned = cleaned.replace(/\./g, "");
79
92
  decimalSeparator = ".";
@@ -81,7 +94,7 @@ export function parseFormattedNumber(str) {
81
94
  }
82
95
  else if (lastCommaIndex > -1 && lastDotIndex === -1) {
83
96
  const afterComma = cleaned.substring(lastCommaIndex + 1);
84
- const commaIsThousands = commaCount > 1 || afterComma.length > 3;
97
+ const commaIsThousands = commaCount > 1 || afterComma.length >= 3;
85
98
  if (commaIsThousands) {
86
99
  cleaned = cleaned.replace(/,/g, "");
87
100
  decimalSeparator = ".";
@@ -76,7 +76,10 @@ function applyMark(text, mark) {
76
76
  }
77
77
  if (mark.type === "link") {
78
78
  const rawHref = String(mark.attrs?.href ?? "#").trim();
79
- const safe = /^(https?:\/\/|mailto:|\/|#)/i.test(rawHref) ? rawHref : "#";
79
+ // `\/(?!\/)` allows a single leading slash (same-site relative path) but
80
+ // rejects `//host/...` protocol-relative URLs, which browsers resolve to
81
+ // an arbitrary external origin despite "looking" like a local path.
82
+ const safe = /^(https?:\/\/|mailto:|\/(?!\/)|#)/i.test(rawHref) ? rawHref : "#";
80
83
  const href = safe.replace(/[&"<>]/g, (c) => ({ "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" })[c] ?? c);
81
84
  return `<a href="${href}" rel="noopener noreferrer">${text}</a>`;
82
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "3.5.3",
3
+ "version": "3.5.4",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"