@nexub/react 7.1.0 → 7.2.0

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "7.1.0",
2
+ "version": "7.2.0",
3
3
  "standard": "WCAG 2.2 Level AA",
4
4
  "method": {
5
5
  "automated": "axe-core rules tagged wcag2a, wcag2aa, wcag21a, wcag21aa and wcag22aa, run on every Storybook story in light and dark, and on open popups (menus, listboxes, dialogs), on every pull request; a new violation fails the build.",
@@ -1,5 +1,11 @@
1
1
  import * as React from 'react';
2
2
  import * as RadixAlertDialog from '@radix-ui/react-alert-dialog';
3
+ import { type ScrollLock } from '../../hooks/useClassScrollLock.js';
4
+ export interface AlertDialogRootProps extends RadixAlertDialog.AlertDialogProps {
5
+ /** See Dialog.Root `scrollLock`. @default 'style' (becomes 'class' in 8.0) */
6
+ scrollLock?: ScrollLock;
7
+ }
8
+ declare function Root({ scrollLock, ...props }: AlertDialogRootProps): React.JSX.Element;
3
9
  export interface AlertDialogConfirmProps {
4
10
  /** What the user must type, e.g. the resource name. Matching ignores outer spaces. */
5
11
  text: string;
@@ -16,7 +22,7 @@ declare namespace Confirm {
16
22
  var displayName: string;
17
23
  }
18
24
  export declare const AlertDialog: {
19
- Root: React.FC<RadixAlertDialog.AlertDialogProps>;
25
+ Root: typeof Root;
20
26
  Trigger: React.ForwardRefExoticComponent<RadixAlertDialog.AlertDialogTriggerProps & React.RefAttributes<HTMLButtonElement>>;
21
27
  Portal: React.FC<RadixAlertDialog.AlertDialogPortalProps>;
22
28
  Overlay: React.ForwardRefExoticComponent<RadixAlertDialog.AlertDialogOverlayProps & React.RefAttributes<HTMLDivElement>>;
@@ -7,12 +7,19 @@ import { overlaySurface, scrim } from "../../utils/surface.js";
7
7
  import { buttonVariants } from "../Button/Button.js";
8
8
  import { Input } from "../Input/Input.js";
9
9
  import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
10
+ import { ClassLockOverlay, ScrollLockContext } from "../../hooks/useClassScrollLock.js";
10
11
  const ConfirmContext = React.createContext(null);
11
- const Root = RadixAlertDialog.Root;
12
+ const ALERT_LOCK = { style: { scrollLock: "style", modal: true }, class: { scrollLock: "class", modal: true } };
13
+ function Root({ scrollLock = "style", ...props }) {
14
+ return /* @__PURE__ */ jsx(ScrollLockContext.Provider, { value: ALERT_LOCK[scrollLock], children: /* @__PURE__ */ jsx(RadixAlertDialog.Root, { ...props }) });
15
+ }
12
16
  const Trigger = RadixAlertDialog.Trigger;
13
17
  const Portal = RadixAlertDialog.Portal;
14
18
  const Overlay = React.forwardRef(
15
19
  function AlertDialogOverlay({ className, ...props }, ref) {
20
+ const { scrollLock } = React.useContext(ScrollLockContext);
21
+ if (scrollLock === "class")
22
+ return /* @__PURE__ */ jsx(ClassLockOverlay, { ref, className: cn("fixed inset-0 z-[var(--z-modal)] nx-motion", scrim, className) });
16
23
  return /* @__PURE__ */ jsx(
17
24
  RadixAlertDialog.Overlay,
18
25
  {
@@ -3,6 +3,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { cva } from "class-variance-authority";
5
5
  import { cn } from "../../utils/cn.js";
6
+ import { safeSrc } from "../../utils/safeHref.js";
6
7
  const avatarVariants = cva(
7
8
  "relative inline-flex items-center justify-center shrink-0 rounded-full overflow-hidden bg-[var(--color-surface-container-high)] text-[var(--color-on-surface-muted)] font-medium select-none",
8
9
  {
@@ -38,13 +39,14 @@ const Avatar = React.forwardRef(
38
39
  ({
39
40
  className,
40
41
  size = "md",
41
- src,
42
+ src: srcProp,
42
43
  name,
43
44
  fallback,
44
45
  badge,
45
46
  badgeColor,
46
47
  ...props
47
48
  }, ref) => {
49
+ const src = safeSrc(srcProp);
48
50
  const [failedSrc, setFailedSrc] = React.useState();
49
51
  const showImage = Boolean(src) && failedSrc !== src;
50
52
  const initials = name ? getInitials(name) : null;
@@ -3,6 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { ChevronRight, MoreHorizontal } from "@nexub/icons";
5
5
  import { cn } from "../../utils/cn.js";
6
+ import { safeHref } from "../../utils/safeHref.js";
6
7
  import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
7
8
  import { DropdownMenu } from "../DropdownMenu/DropdownMenu.js";
8
9
  const Root = React.forwardRef(
@@ -84,7 +85,7 @@ const Trail = React.forwardRef(({ items, maxItems = 4, ...props }, ref) => {
84
85
  const collapsed = items.length > keep;
85
86
  const hidden = collapsed ? items.slice(1, items.length - (keep - 1)) : [];
86
87
  const shown = collapsed ? [items[0], ...items.slice(items.length - (keep - 1))] : items;
87
- const crumb = (item, last) => last ? /* @__PURE__ */ jsx(Page, { children: item.label }) : /* @__PURE__ */ jsx(Link, { href: item.href, children: item.label });
88
+ const crumb = (item, last) => last ? /* @__PURE__ */ jsx(Page, { children: item.label }) : /* @__PURE__ */ jsx(Link, { href: safeHref(item.href), children: item.label });
88
89
  return /* @__PURE__ */ jsx(Root, { ref, ...props, children: /* @__PURE__ */ jsx(List, { children: shown.map((item, i) => /* @__PURE__ */ jsxs(React.Fragment, { children: [
89
90
  i > 0 && /* @__PURE__ */ jsx(Separator, {}),
90
91
  /* @__PURE__ */ jsx(Item, { children: crumb(item, i === shown.length - 1) }),
@@ -99,7 +100,7 @@ const Trail = React.forwardRef(({ items, maxItems = 4, ...props }, ref) => {
99
100
  children: /* @__PURE__ */ jsx(MoreHorizontal, { size: "sm", "aria-hidden": "true" })
100
101
  }
101
102
  ),
102
- /* @__PURE__ */ jsx(DropdownMenu.Content, { align: "start", children: hidden.map((h, j) => /* @__PURE__ */ jsx(DropdownMenu.Item, { asChild: true, children: /* @__PURE__ */ jsx("a", { href: h.href, children: h.label }) }, j)) })
103
+ /* @__PURE__ */ jsx(DropdownMenu.Content, { align: "start", children: hidden.map((h, j) => /* @__PURE__ */ jsx(DropdownMenu.Item, { asChild: true, children: /* @__PURE__ */ jsx("a", { href: safeHref(h.href), children: h.label }) }, j)) })
103
104
  ] }) })
104
105
  ] })
105
106
  ] }, i)) }) });
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import { Slot } from "@radix-ui/react-slot";
5
5
  import { cva } from "class-variance-authority";
6
6
  import { cn } from "../../utils/cn.js";
7
+ import { safeHref } from "../../utils/safeHref.js";
7
8
  import { glassSurfaceClass } from "../../utils/glass.js";
8
9
  import { usePointerSheen } from "../../hooks/usePointerSheen.js";
9
10
  const cardVariants = cva(
@@ -72,7 +73,7 @@ const Card = React.forwardRef(
72
73
  );
73
74
  Card.displayName = "Card";
74
75
  const CardLink = React.forwardRef(
75
- ({ className, asChild = false, ...props }, ref) => {
76
+ ({ className, asChild = false, href, ...props }, ref) => {
76
77
  const Comp = asChild ? Slot : "a";
77
78
  return /* @__PURE__ */ jsx(
78
79
  Comp,
@@ -83,6 +84,7 @@ const CardLink = React.forwardRef(
83
84
  "text-inherit no-underline outline-none after:absolute after:inset-0 after:rounded-[inherit] after:content-['']",
84
85
  className
85
86
  ),
87
+ href: safeHref(href),
86
88
  ...props
87
89
  }
88
90
  );
@@ -2,12 +2,22 @@ import * as React from 'react';
2
2
  import * as RadixDialog from '@radix-ui/react-dialog';
3
3
  import type { Size } from '../../utils/control.js';
4
4
  import { type SurfaceValue } from '../../utils/glass.js';
5
+ import { type ScrollLock } from '../../hooks/useClassScrollLock.js';
6
+ export interface DialogRootProps extends RadixDialog.DialogProps {
7
+ /**
8
+ * `'class'` locks page scroll with a class on <html> instead of the <style>
9
+ * Radix injects, so the dialog works under a strict `style-src` with no nonce.
10
+ * @default 'style' (becomes 'class' in 8.0)
11
+ */
12
+ scrollLock?: ScrollLock;
13
+ }
14
+ declare function Root({ scrollLock, modal, ...props }: DialogRootProps): React.JSX.Element;
5
15
  interface ContentProps extends RadixDialog.DialogContentProps {
6
16
  size?: Size | 'xl' | 'full';
7
17
  surface?: SurfaceValue;
8
18
  }
9
19
  export declare const Dialog: {
10
- Root: React.FC<RadixDialog.DialogProps>;
20
+ Root: typeof Root;
11
21
  Trigger: React.ForwardRefExoticComponent<RadixDialog.DialogTriggerProps & React.RefAttributes<HTMLButtonElement>>;
12
22
  Portal: React.FC<RadixDialog.DialogPortalProps>;
13
23
  Overlay: React.ForwardRefExoticComponent<RadixDialog.DialogOverlayProps & React.RefAttributes<HTMLDivElement>>;
@@ -5,12 +5,19 @@ import * as RadixDialog from "@radix-ui/react-dialog";
5
5
  import { cn } from "../../utils/cn.js";
6
6
  import { overlaySurface, scrim } from "../../utils/surface.js";
7
7
  import { glassSurfaceClass } from "../../utils/glass.js";
8
- const Root = RadixDialog.Root;
8
+ import { ClassLockOverlay, ScrollLockContext } from "../../hooks/useClassScrollLock.js";
9
+ function Root({ scrollLock = "style", modal = true, ...props }) {
10
+ const lock = React.useMemo(() => ({ scrollLock, modal }), [scrollLock, modal]);
11
+ return /* @__PURE__ */ jsx(ScrollLockContext.Provider, { value: lock, children: /* @__PURE__ */ jsx(RadixDialog.Root, { modal, ...props }) });
12
+ }
9
13
  const Trigger = RadixDialog.Trigger;
10
14
  const Portal = RadixDialog.Portal;
11
15
  const Close = RadixDialog.Close;
12
16
  const Overlay = React.forwardRef(
13
17
  function DialogOverlay({ className, ...props }, ref) {
18
+ const { scrollLock, modal } = React.useContext(ScrollLockContext);
19
+ const cls = cn("fixed inset-0 z-[var(--z-modal)] nx-motion", scrim, className);
20
+ if (scrollLock === "class") return modal ? /* @__PURE__ */ jsx(ClassLockOverlay, { ref, className: cls }) : null;
14
21
  return /* @__PURE__ */ jsx(
15
22
  RadixDialog.Overlay,
16
23
  {
@@ -9,7 +9,9 @@ export declare function formatFileSize(bytes: number, locale: string): string;
9
9
  export declare function fileAccepted(file: File, accept?: string[]): boolean;
10
10
  export interface FileRejection {
11
11
  file: File;
12
- reason: 'type' | 'size' | 'count';
12
+ reason: 'type' | 'size' | 'count' | 'custom';
13
+ /** The message returned by `validate` (reason `'custom'`). */
14
+ message?: string;
13
15
  }
14
16
  export interface FileUploadRootProps extends React.HTMLAttributes<HTMLDivElement> {
15
17
  accept?: string[];
@@ -19,6 +21,11 @@ export interface FileUploadRootProps extends React.HTMLAttributes<HTMLDivElement
19
21
  onFilesChange?: (files: File[]) => void;
20
22
  /** Files turned away by `accept`, `maxSize` or `maxFiles`, from the picker or a drop. */
21
23
  onReject?: (rejections: FileRejection[]) => void;
24
+ /**
25
+ * Your own check, run after `accept` and `maxSize`: return a message to reject
26
+ * the file. Client-side checks are a convenience; validate again on the server.
27
+ */
28
+ validate?: (file: File) => string | null | undefined;
22
29
  }
23
30
  export interface FileUploadItemProps extends React.HTMLAttributes<HTMLLIElement> {
24
31
  index: number;
@@ -27,17 +27,19 @@ function useFileUpload() {
27
27
  return ctx;
28
28
  }
29
29
  const Root = React.forwardRef(
30
- ({ className, accept, maxFiles, maxSize, onFilesChange, onReject, children, ...props }, ref) => {
30
+ ({ className, accept, maxFiles, maxSize, onFilesChange, onReject, validate, children, ...props }, ref) => {
31
31
  const [files, setFiles] = React.useState([]);
32
32
  const [rejections, setRejections] = React.useState([]);
33
33
  const [isDragging, setIsDragging] = React.useState(false);
34
34
  const addFiles = React.useCallback(
35
35
  (newFiles) => {
36
36
  const rejected = [];
37
+ let custom;
37
38
  const next = [...files];
38
39
  for (const file of newFiles) {
39
40
  if (!fileAccepted(file, accept)) rejected.push({ file, reason: "type" });
40
41
  else if (maxSize !== void 0 && file.size > maxSize) rejected.push({ file, reason: "size" });
42
+ else if (custom = validate?.(file) || null) rejected.push({ file, reason: "custom", message: custom });
41
43
  else if (maxFiles !== void 0 && next.length >= maxFiles) rejected.push({ file, reason: "count" });
42
44
  else next.push(file);
43
45
  }
@@ -48,7 +50,7 @@ const Root = React.forwardRef(
48
50
  }
49
51
  if (rejected.length) onReject?.(rejected);
50
52
  },
51
- [files, accept, maxFiles, maxSize, onFilesChange, onReject]
53
+ [files, accept, maxFiles, maxSize, onFilesChange, onReject, validate]
52
54
  );
53
55
  const removeFile = React.useCallback(
54
56
  (index) => {
@@ -126,7 +128,7 @@ const Dropzone = React.forwardRef(
126
128
  }
127
129
  )
128
130
  ] }),
129
- /* @__PURE__ */ jsx("div", { id: errorId, role: "alert", className: "empty:hidden px-4 pb-3 text-start text-sm text-[var(--color-error)]", children: rejections.length > 0 && /* @__PURE__ */ jsx("ul", { children: rejections.map(({ file, reason }, i) => /* @__PURE__ */ jsx("li", { children: reason === "type" ? messages.fileTypeRejected(file.name) : reason === "size" ? messages.fileTooLarge(file.name, formatFileSize(maxSize ?? 0, locale)) : messages.tooManyFiles(maxFiles ?? 0) }, `${file.name}-${i}`)) }) })
131
+ /* @__PURE__ */ jsx("div", { id: errorId, role: "alert", className: "empty:hidden px-4 pb-3 text-start text-sm text-[var(--color-error)]", children: rejections.length > 0 && /* @__PURE__ */ jsx("ul", { children: rejections.map(({ file, reason, message }, i) => /* @__PURE__ */ jsx("li", { children: reason === "custom" ? message : reason === "type" ? messages.fileTypeRejected(file.name) : reason === "size" ? messages.fileTooLarge(file.name, formatFileSize(maxSize ?? 0, locale)) : messages.tooManyFiles(maxFiles ?? 0) }, `${file.name}-${i}`)) }) })
130
132
  ]
131
133
  }
132
134
  )
@@ -1,6 +1,7 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cn } from "../../utils/cn.js";
4
+ import { safeHref, safeSrc } from "../../utils/safeHref.js";
4
5
  import { glassSurfaceClass } from "../../utils/glass.js";
5
6
  const ratioClass = {
6
7
  "16/9": "aspect-video",
@@ -9,7 +10,9 @@ const ratioClass = {
9
10
  "3/4": "aspect-[3/4]"
10
11
  };
11
12
  const MediaCard = React.forwardRef(
12
- ({ src, alt, ratio = "16/9", title, description, eyebrow, href, className, children, ...props }, ref) => {
13
+ ({ src: srcProp, alt, ratio = "16/9", title, description, eyebrow, href: hrefProp, className, children, ...props }, ref) => {
14
+ const src = safeSrc(srcProp);
15
+ const href = safeHref(hrefProp);
13
16
  const hasCaption = title != null || description != null || eyebrow != null || children != null;
14
17
  return /* @__PURE__ */ jsxs(
15
18
  "div",
@@ -14,6 +14,12 @@ export interface NexubProviderProps {
14
14
  dateLocale?: Locale;
15
15
  /** Overrides for the default English strings. */
16
16
  messages?: Partial<NexubMessages>;
17
+ /**
18
+ * CSP nonce for the `<style>` tags Radix overlays inject at runtime (scroll
19
+ * lock, scrollbar gap). Pass the same per-request nonce as your
20
+ * `style-src-elem` so they work without `'unsafe-inline'`.
21
+ */
22
+ nonce?: string;
17
23
  children?: React.ReactNode;
18
24
  }
19
25
  /**
@@ -21,7 +27,7 @@ export interface NexubProviderProps {
21
27
  * It does not set `dir`/`lang` on <html> — the app does that (as it renders
22
28
  * <html>), so the page and CSS `rtl:` variants agree with this provider.
23
29
  */
24
- export declare function NexubProvider({ locale, dir, dateLocale, messages, children }: NexubProviderProps): React.JSX.Element;
30
+ export declare function NexubProvider({ locale, dir, dateLocale, messages, nonce, children }: NexubProviderProps): React.JSX.Element;
25
31
  /** The active strings: defaults merged with the nearest NexubProvider's overrides. */
26
32
  export declare function useNexubMessages(): NexubMessages;
27
33
  /** The active locale (default 'en'), direction and optional date-fns Locale. */
@@ -2,6 +2,7 @@
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { DirectionProvider } from "@radix-ui/react-direction";
5
+ import { setNonce } from "get-nonce";
5
6
  import { defaultMessages, directionOf } from "@nexub/core";
6
7
  const NexubContext = React.createContext({
7
8
  locale: void 0,
@@ -9,7 +10,8 @@ const NexubContext = React.createContext({
9
10
  dateLocale: void 0,
10
11
  messages: defaultMessages
11
12
  });
12
- function NexubProvider({ locale = "en", dir, dateLocale, messages, children }) {
13
+ function NexubProvider({ locale = "en", dir, dateLocale, messages, nonce, children }) {
14
+ if (nonce) setNonce(nonce);
13
15
  const resolvedDir = dir ?? directionOf(locale);
14
16
  const value = React.useMemo(
15
17
  () => ({ locale, dir: resolvedDir, dateLocale, messages: { ...defaultMessages, ...messages } }),
@@ -14,12 +14,16 @@ const PasswordInput = React.forwardRef(
14
14
  onChange: onVisibleChange
15
15
  });
16
16
  const messages = useNexubMessages();
17
+ const autoId = React.useId();
18
+ const inputId = props.id ?? autoId;
17
19
  const toggleButton = /* @__PURE__ */ jsx(
18
20
  "button",
19
21
  {
20
22
  type: "button",
21
23
  onClick: () => setShowPassword(!showPassword),
22
- "aria-label": showPassword ? messages.hidePassword : messages.showPassword,
24
+ "aria-label": messages.showPassword,
25
+ "aria-pressed": showPassword,
26
+ "aria-controls": inputId,
23
27
  className: cn(
24
28
  "shrink-0 rounded-full text-[var(--color-on-surface-muted)] hover:text-[var(--color-on-surface)] transition-colors"
25
29
  ),
@@ -30,6 +34,7 @@ const PasswordInput = React.forwardRef(
30
34
  Input,
31
35
  {
32
36
  ref,
37
+ id: inputId,
33
38
  autoComplete: "current-password",
34
39
  ...props,
35
40
  type: showPassword ? "text" : "password",
@@ -2,6 +2,7 @@
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect.js";
5
+ const PRODUCTS = ["leap", "bookub", "payub", "founderub"];
5
6
  const GLASS_MODES = ["clear", "balanced", "tinted", "off"];
6
7
  const ThemeContext = React.createContext(void 0);
7
8
  ThemeContext.displayName = "ThemeContext";
@@ -165,7 +166,7 @@ function useTheme() {
165
166
  const jsString = (s) => JSON.stringify(s).replace(/</g, "\\u003c");
166
167
  function themeScriptSource(storageKey, productStorageKey, glassStorageKey = "nexub-glass", contrastStorageKey) {
167
168
  const contrast = contrastStorageKey ? `var c=localStorage.getItem(${jsString(contrastStorageKey)});if(['standard','medium','high'].indexOf(c)>-1)d.setAttribute('data-contrast',c);` : "";
168
- const product = productStorageKey ? `var b=localStorage.getItem(${jsString(productStorageKey)});if(b&&b!=='base')d.setAttribute('data-product',b);` : "";
169
+ const product = productStorageKey ? `var b=localStorage.getItem(${jsString(productStorageKey)});if(${JSON.stringify(PRODUCTS)}.indexOf(b)>-1)d.setAttribute('data-product',b);` : "";
169
170
  const glass = `var g=localStorage.getItem(${jsString(glassStorageKey)});if(${JSON.stringify(GLASS_MODES)}.indexOf(g)>-1)d.setAttribute('data-glass',g);`;
170
171
  return `try{var d=document.documentElement,t=localStorage.getItem(${jsString(storageKey)});if(t==='light'||t==='dark')d.setAttribute('data-theme',t);${product}${glass}${contrast}}catch(e){}`;
171
172
  }
@@ -1,6 +1,19 @@
1
+ import * as React from 'react';
1
2
  /**
2
3
  * CSP-safe page scroll lock: toggles `nexub-scroll-lock` (from @nexub/themes/base)
3
4
  * on <html> instead of injecting a <style> tag. On while mounted; counted, so
4
5
  * nested overlays (a Sheet and a CommandPalette) share one lock.
5
6
  */
6
7
  export declare function useClassScrollLock(): void;
8
+ export type ScrollLock = 'style' | 'class';
9
+ /**
10
+ * Which scroll lock a modal Root asked for. `'style'` (7.x default) is Radix's
11
+ * react-remove-scroll, which injects a <style> (needs a CSP nonce, see
12
+ * NexubProvider `nonce`); `'class'` is the class lock above, no <style> at all.
13
+ */
14
+ export declare const ScrollLockContext: React.Context<{
15
+ scrollLock: ScrollLock;
16
+ modal: boolean;
17
+ }>;
18
+ /** Plain overlay for scrollLock="class": Radix's RemoveScroll lives in its Overlay. */
19
+ export declare const ClassLockOverlay: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>>;
@@ -13,6 +13,18 @@ function useClassScrollLock() {
13
13
  };
14
14
  }, []);
15
15
  }
16
+ const ScrollLockContext = React.createContext({
17
+ scrollLock: "style",
18
+ modal: true
19
+ });
20
+ const ClassLockOverlay = React.forwardRef(
21
+ function ClassLockOverlay2(props, ref) {
22
+ useClassScrollLock();
23
+ return React.createElement("div", { ref, "data-state": "open", ...props });
24
+ }
25
+ );
16
26
  export {
27
+ ClassLockOverlay,
28
+ ScrollLockContext,
17
29
  useClassScrollLock
18
30
  };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { cn } from './utils/cn.js';
2
2
  export type { Size } from './utils/control.js';
3
3
  export { startViewTransition } from './utils/viewTransition.js';
4
+ export { safeHref, safeSrc } from './utils/safeHref.js';
5
+ export type { SafeHrefOptions } from './utils/safeHref.js';
4
6
  export { useScopedSeed } from './hooks/useScopedSeed.js';
5
7
  export { useControllableState } from './hooks/useControllableState.js';
6
8
  export { useDisclosure } from './hooks/useDisclosure.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cn } from "./utils/cn.js";
2
2
  import { startViewTransition } from "./utils/viewTransition.js";
3
+ import { safeHref, safeSrc } from "./utils/safeHref.js";
3
4
  import { useScopedSeed } from "./hooks/useScopedSeed.js";
4
5
  import { useControllableState } from "./hooks/useControllableState.js";
5
6
  import { useDisclosure } from "./hooks/useDisclosure.js";
@@ -326,6 +327,8 @@ export {
326
327
  progressFillVariants,
327
328
  progressTrackVariants,
328
329
  ratingVariants,
330
+ safeHref,
331
+ safeSrc,
329
332
  safeUrl,
330
333
  sectionVariants,
331
334
  separatorVariants,
@@ -0,0 +1,12 @@
1
+ export interface SafeHrefOptions {
2
+ /**
3
+ * `false` (7.x default): block script-capable schemes (`javascript:`,
4
+ * `vbscript:`, `data:`, `file:`), allow everything else.
5
+ * `true` (8.0 default): allow only relative URLs, `#…`, http(s), mailto, tel and sms.
6
+ */
7
+ strict?: boolean;
8
+ }
9
+ /** A link target that can't run script. Returns `undefined` when blocked. */
10
+ export declare function safeHref(url: string | null | undefined, { strict }?: SafeHrefOptions): string | undefined;
11
+ /** An image source: relative, http(s), blob: or a data:image/* URL. */
12
+ export declare function safeSrc(url: string | null | undefined): string | undefined;
@@ -0,0 +1,24 @@
1
+ const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
2
+ const DANGEROUS = /* @__PURE__ */ new Set(["javascript", "vbscript", "data", "file"]);
3
+ const STRICT_HREF = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel", "sms"]);
4
+ function scheme(url) {
5
+ const probe = url.replace(/[\u0000- \u007f]+/g, "");
6
+ return SCHEME.exec(probe)?.[1]?.toLowerCase() ?? null;
7
+ }
8
+ function safeHref(url, { strict = false } = {}) {
9
+ if (!url) return void 0;
10
+ const s = scheme(url);
11
+ if (s === null) return url;
12
+ if (strict) return STRICT_HREF.has(s) ? url : void 0;
13
+ return DANGEROUS.has(s) ? void 0 : url;
14
+ }
15
+ function safeSrc(url) {
16
+ if (!url) return void 0;
17
+ const s = scheme(url);
18
+ if (s === null || s === "http" || s === "https" || s === "blob") return url;
19
+ return s === "data" && /^data:image\//i.test(url.trim()) ? url : void 0;
20
+ }
21
+ export {
22
+ safeHref,
23
+ safeSrc
24
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexub/react",
3
- "version": "7.1.0",
3
+ "version": "7.2.0",
4
4
  "description": "React component library for Nexub Design System — 90+ accessible, themeable UI components built on Radix UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,6 +39,10 @@
39
39
  "types": "./dist/utils/cn.d.ts",
40
40
  "default": "./dist/utils/cn.js"
41
41
  },
42
+ "./safeHref": {
43
+ "types": "./dist/utils/safeHref.d.ts",
44
+ "default": "./dist/utils/safeHref.js"
45
+ },
42
46
  "./*": {
43
47
  "types": "./dist/components/*/index.d.ts",
44
48
  "default": "./dist/components/*/index.js"
@@ -79,16 +83,17 @@
79
83
  "clsx": "^2.1.1",
80
84
  "cmdk": "^1.1.1",
81
85
  "date-fns": "^4.4.0",
86
+ "get-nonce": "^1.0.1",
82
87
  "react-day-picker": "^10.0.2",
83
88
  "tailwind-merge": "^3.7.0",
84
- "@nexub/core": "7.1.0",
85
- "@nexub/icons": "7.1.0"
89
+ "@nexub/core": "7.2.0",
90
+ "@nexub/icons": "7.2.0"
86
91
  },
87
92
  "peerDependencies": {
88
93
  "react": ">=19 <20",
89
94
  "react-dom": ">=19 <20",
90
95
  "@tanstack/react-virtual": "^3.14.22",
91
- "@nexub/tokens": "^7.1.0"
96
+ "@nexub/tokens": "^7.2.0"
92
97
  },
93
98
  "peerDependenciesMeta": {
94
99
  "@tanstack/react-virtual": {
@@ -120,8 +125,8 @@
120
125
  "vaul": "1.1.2",
121
126
  "vite": "^8.3.5",
122
127
  "vitest": "^5.0.5",
123
- "@nexub/themes": "7.1.0",
124
- "@nexub/tokens": "7.1.0"
128
+ "@nexub/themes": "7.2.0",
129
+ "@nexub/tokens": "7.2.0"
125
130
  },
126
131
  "scripts": {
127
132
  "build": "rm -rf dist && tsup && tsc -p tsconfig.build.json && rm -f dist/vendor/*.d.ts && node scripts/check-dts.mjs && tailwindcss -i src/styles.css -o dist/styles.css --minify && cp -R ../themes/src/fonts dist/fonts",