@nexub/react 5.0.1 → 5.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": "5.0.1",
2
+ "version": "5.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 on every pull request; a new violation fails the build.",
@@ -693,6 +693,23 @@
693
693
  "keyboard": [],
694
694
  "screenReader": []
695
695
  },
696
+ {
697
+ "slug": "multi-select",
698
+ "name": "Multi Select",
699
+ "package": "@nexub/react",
700
+ "automated": "pass",
701
+ "exceptions": [],
702
+ "keyboard": [
703
+ "Type: Filters the options and opens the list.",
704
+ "ArrowDown / ArrowUp: Moves through the options.",
705
+ "Enter: Toggles the highlighted option.",
706
+ "Backspace: In an empty field, removes the last chip.",
707
+ "Escape: Closes the list."
708
+ ],
709
+ "screenReader": [
710
+ "Options carry `aria-checked`; each chip has a \"Remove <label>\" button."
711
+ ]
712
+ },
696
713
  {
697
714
  "slug": "navigation-rail",
698
715
  "name": "Navigation Rail",
@@ -10,9 +10,25 @@ export interface AutocompleteProps {
10
10
  placeholder?: string;
11
11
  emptyText?: string;
12
12
  loading?: boolean;
13
+ /** Controlled input text. */
14
+ inputValue?: string;
15
+ /**
16
+ * Called as the user types. When set, the options are taken as already
17
+ * filtered (fetched for this text), so the built-in filtering is off.
18
+ */
19
+ onInputChange?: (text: string) => void;
20
+ /**
21
+ * `'inline'` (default): the list always shows under the input.
22
+ * `'popover'`: a field whose list opens below it while typing.
23
+ */
24
+ mode?: 'inline' | 'popover';
25
+ /** Adds a "Create “text”" option when the text matches no option exactly. */
26
+ onCreate?: (text: string) => void;
27
+ disabled?: boolean;
13
28
  className?: string;
29
+ 'aria-label'?: string;
14
30
  }
15
- export declare function Autocomplete({ options, value, onValueChange, placeholder, emptyText, loading, className, }: AutocompleteProps): React.JSX.Element;
31
+ export declare function Autocomplete({ options, value, onValueChange, placeholder, emptyText, loading, inputValue, onInputChange, mode, onCreate, disabled, className, 'aria-label': ariaLabel, }: AutocompleteProps): React.JSX.Element;
16
32
  export declare namespace Autocomplete {
17
33
  var displayName: string;
18
34
  }
@@ -1,8 +1,11 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
+ import { Plus } from "@nexub/icons";
5
+ import { cn } from "../../utils/cn.js";
4
6
  import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
5
7
  import { Combobox } from "../Combobox/Combobox.js";
8
+ import { ComboboxLoading, ComboboxPopover } from "../Combobox/ComboboxPopover.js";
6
9
  function Autocomplete({
7
10
  options,
8
11
  value,
@@ -10,34 +13,86 @@ function Autocomplete({
10
13
  placeholder,
11
14
  emptyText,
12
15
  loading = false,
13
- className
16
+ inputValue,
17
+ onInputChange,
18
+ mode = "inline",
19
+ onCreate,
20
+ disabled,
21
+ className,
22
+ "aria-label": ariaLabel
14
23
  }) {
15
24
  const messages = useNexubMessages();
16
25
  const selectedLabel = options.find((o) => o.value === value)?.label ?? "";
17
- const [search, setSearch] = React.useState(selectedLabel);
26
+ const [innerSearch, setInnerSearch] = React.useState(selectedLabel);
18
27
  const [prevValue, setPrevValue] = React.useState(value);
28
+ const [open, setOpen] = React.useState(false);
29
+ const search = inputValue ?? innerSearch;
30
+ const setSearch = (text2) => {
31
+ setInnerSearch(text2);
32
+ onInputChange?.(text2);
33
+ };
19
34
  if (value !== prevValue) {
20
35
  setPrevValue(value);
21
- setSearch(selectedLabel);
36
+ setInnerSearch(selectedLabel);
22
37
  }
23
- return /* @__PURE__ */ jsxs(Combobox.Root, { className, children: [
24
- /* @__PURE__ */ jsx(Combobox.Input, { placeholder: placeholder ?? messages.searchPlaceholder, value: search, onValueChange: setSearch }),
25
- /* @__PURE__ */ jsx(Combobox.List, { children: loading ? /* @__PURE__ */ jsx("div", { className: "py-6 text-center text-sm text-[var(--color-on-surface-muted)]", children: messages.loading }) : /* @__PURE__ */ jsxs(Fragment, { children: [
26
- /* @__PURE__ */ jsx(Combobox.Empty, { children: emptyText ?? messages.noResults }),
27
- options.map((opt) => /* @__PURE__ */ jsx(
28
- Combobox.Item,
29
- {
30
- value: opt.label,
31
- keywords: [opt.value],
32
- onSelect: () => {
33
- onValueChange?.(opt.value);
34
- setSearch(opt.label);
35
- },
36
- children: opt.label
38
+ const text = search.trim();
39
+ const canCreate = !!onCreate && !loading && text !== "" && !options.some((o) => o.label.toLowerCase() === text.toLowerCase());
40
+ const list = /* @__PURE__ */ jsx(Combobox.List, { children: loading ? /* @__PURE__ */ jsx(ComboboxLoading, {}) : /* @__PURE__ */ jsxs(Fragment, { children: [
41
+ !canCreate && /* @__PURE__ */ jsx(Combobox.Empty, { children: emptyText ?? messages.noResults }),
42
+ options.map((opt) => /* @__PURE__ */ jsx(
43
+ Combobox.Item,
44
+ {
45
+ value: opt.label,
46
+ keywords: [opt.value],
47
+ onSelect: () => {
48
+ onValueChange?.(opt.value);
49
+ setSearch(opt.label);
50
+ setOpen(false);
37
51
  },
38
- opt.value
39
- ))
40
- ] }) })
52
+ children: opt.label
53
+ },
54
+ opt.value
55
+ )),
56
+ canCreate && /* @__PURE__ */ jsxs(
57
+ Combobox.Item,
58
+ {
59
+ forceMount: true,
60
+ value: "\0",
61
+ onSelect: () => {
62
+ onCreate(text);
63
+ setOpen(false);
64
+ },
65
+ className: "gap-2",
66
+ children: [
67
+ /* @__PURE__ */ jsx(Plus, { size: "sm", "aria-hidden": "true" }),
68
+ messages.createOption(text)
69
+ ]
70
+ }
71
+ )
72
+ ] }) });
73
+ const input = {
74
+ placeholder: placeholder ?? messages.searchPlaceholder,
75
+ value: search,
76
+ "aria-label": ariaLabel,
77
+ onValueChange: (t) => {
78
+ setSearch(t);
79
+ setOpen(true);
80
+ }
81
+ };
82
+ if (mode === "popover") {
83
+ return /* @__PURE__ */ jsx(
84
+ Combobox.Root,
85
+ {
86
+ label: ariaLabel,
87
+ className: cn("overflow-visible rounded-none bg-transparent", className),
88
+ shouldFilter: !onInputChange,
89
+ children: /* @__PURE__ */ jsx(ComboboxPopover, { open: open && !disabled, onOpenChange: setOpen, input, disabled, children: list })
90
+ }
91
+ );
92
+ }
93
+ return /* @__PURE__ */ jsxs(Combobox.Root, { label: ariaLabel, className, shouldFilter: !onInputChange, children: [
94
+ /* @__PURE__ */ jsx(Combobox.Input, { ...input, disabled }),
95
+ list
41
96
  ] });
42
97
  }
43
98
  Autocomplete.displayName = "Autocomplete";
@@ -1,4 +1,17 @@
1
1
  import * as React from 'react';
2
+ export interface BreadcrumbTrailItem {
3
+ label: React.ReactNode;
4
+ href?: string;
5
+ }
6
+ export interface BreadcrumbTrailProps extends React.HTMLAttributes<HTMLElement> {
7
+ /** The path, root first. The last item is the current page. */
8
+ items: BreadcrumbTrailItem[];
9
+ /**
10
+ * Most items shown. Longer trails keep the first item and the last
11
+ * `maxItems - 1`, and put the rest in a "More" menu. Default 4.
12
+ */
13
+ maxItems?: number;
14
+ }
2
15
  export declare const Breadcrumb: {
3
16
  Root: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement>>;
4
17
  List: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLOListElement> & React.RefAttributes<HTMLOListElement>>;
@@ -18,4 +31,5 @@ export declare const Breadcrumb: {
18
31
  ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>): React.JSX.Element;
19
32
  displayName: string;
20
33
  };
34
+ Trail: React.ForwardRefExoticComponent<BreadcrumbTrailProps & React.RefAttributes<HTMLElement>>;
21
35
  };
@@ -1,9 +1,10 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
2
+ 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
6
  import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
7
+ import { DropdownMenu } from "../DropdownMenu/DropdownMenu.js";
7
8
  const Root = React.forwardRef(
8
9
  ({ className, ...props }, ref) => /* @__PURE__ */ jsx("nav", { ref, "aria-label": useNexubMessages().breadcrumb, className: cn("", className), ...props })
9
10
  );
@@ -77,7 +78,34 @@ const Ellipsis = ({ className, ...props }) => /* @__PURE__ */ jsxs(
77
78
  }
78
79
  );
79
80
  Ellipsis.displayName = "Breadcrumb.Ellipsis";
80
- const Breadcrumb = { Root, List, Item, Link, Page, Separator, Ellipsis };
81
+ const Trail = React.forwardRef(({ items, maxItems = 4, ...props }, ref) => {
82
+ const messages = useNexubMessages();
83
+ const keep = Math.max(2, maxItems);
84
+ const collapsed = items.length > keep;
85
+ const hidden = collapsed ? items.slice(1, items.length - (keep - 1)) : [];
86
+ 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
+ return /* @__PURE__ */ jsx(Root, { ref, ...props, children: /* @__PURE__ */ jsx(List, { children: shown.map((item, i) => /* @__PURE__ */ jsxs(React.Fragment, { children: [
89
+ i > 0 && /* @__PURE__ */ jsx(Separator, {}),
90
+ /* @__PURE__ */ jsx(Item, { children: crumb(item, i === shown.length - 1) }),
91
+ i === 0 && collapsed && /* @__PURE__ */ jsxs(Fragment, { children: [
92
+ /* @__PURE__ */ jsx(Separator, {}),
93
+ /* @__PURE__ */ jsx(Item, { children: /* @__PURE__ */ jsxs(DropdownMenu.Root, { children: [
94
+ /* @__PURE__ */ jsx(
95
+ DropdownMenu.Trigger,
96
+ {
97
+ "aria-label": messages.breadcrumbMore,
98
+ className: "nx-state-layer flex h-9 w-9 items-center justify-center rounded-shape-control",
99
+ children: /* @__PURE__ */ jsx(MoreHorizontal, { size: "sm", "aria-hidden": "true" })
100
+ }
101
+ ),
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
+ ] }) })
104
+ ] })
105
+ ] }, i)) }) });
106
+ });
107
+ Trail.displayName = "Breadcrumb.Trail";
108
+ const Breadcrumb = { Root, List, Item, Link, Page, Separator, Ellipsis, Trail };
81
109
  export {
82
110
  Breadcrumb
83
111
  };
@@ -1 +1,2 @@
1
1
  export { Breadcrumb } from './Breadcrumb.js';
2
+ export type { BreadcrumbTrailItem, BreadcrumbTrailProps } from './Breadcrumb.js';
@@ -8,5 +8,7 @@ export declare const chipVariants: (props?: ({
8
8
  export interface ChipProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof chipVariants> {
9
9
  tone?: ToneValue;
10
10
  onRemove?: () => void;
11
+ /** Accessible name of the remove button. Defaults to the localised "Remove". */
12
+ removeLabel?: string;
11
13
  }
12
14
  export declare const Chip: React.ForwardRefExoticComponent<ChipProps & React.RefAttributes<HTMLSpanElement>>;
@@ -32,7 +32,7 @@ const chipVariants = cva(
32
32
  }
33
33
  );
34
34
  const Chip = React.forwardRef(
35
- ({ className, size, tone = "neutral", onRemove, children, ...props }, ref) => {
35
+ ({ className, size, tone = "neutral", onRemove, removeLabel, children, ...props }, ref) => {
36
36
  const messages = useNexubMessages();
37
37
  return /* @__PURE__ */ jsxs(
38
38
  "span",
@@ -46,7 +46,7 @@ const Chip = React.forwardRef(
46
46
  "button",
47
47
  {
48
48
  type: "button",
49
- "aria-label": messages.remove,
49
+ "aria-label": removeLabel ?? messages.remove,
50
50
  onClick: (e) => {
51
51
  e.stopPropagation();
52
52
  onRemove();
@@ -0,0 +1,19 @@
1
+ import * as React from 'react';
2
+ import { Command } from 'cmdk';
3
+ export interface ComboboxPopoverProps {
4
+ open: boolean;
5
+ onOpenChange: (open: boolean) => void;
6
+ /** Rendered inside the field, before the input (MultiSelect chips). */
7
+ before?: React.ReactNode;
8
+ input: React.ComponentProps<typeof Command.Input>;
9
+ disabled?: boolean;
10
+ /** The list (a Combobox.List). */
11
+ children: React.ReactNode;
12
+ }
13
+ /**
14
+ * A field that keeps focus in its input while a list opens below it.
15
+ * The list is not portalled: cmdk finds its items inside the Command root.
16
+ */
17
+ export declare function ComboboxPopover({ open, onOpenChange, before, input, disabled, children }: ComboboxPopoverProps): React.JSX.Element;
18
+ /** Three placeholder rows while options load; the word "Loading" is read out. */
19
+ export declare function ComboboxLoading(): React.JSX.Element;
@@ -0,0 +1,91 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import * as RadixPopover from "@radix-ui/react-popover";
5
+ import { Command } from "cmdk";
6
+ import { cn } from "../../utils/cn.js";
7
+ import { floatingSurface } from "../../utils/surface.js";
8
+ import { field, fieldDisabled, fieldFocusWithin } from "../../utils/control.js";
9
+ import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect.js";
10
+ import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
11
+ import { Skeleton } from "../Skeleton/Skeleton.js";
12
+ function ComboboxPopover({ open, onOpenChange, before, input, disabled, children }) {
13
+ const anchorRef = React.useRef(null);
14
+ const inputRef = React.useRef(null);
15
+ const labelledBy = input["aria-labelledby"];
16
+ useIsomorphicLayoutEffect(() => {
17
+ inputRef.current?.setAttribute("aria-expanded", String(open));
18
+ if (labelledBy) inputRef.current?.setAttribute("aria-labelledby", labelledBy);
19
+ });
20
+ return /* @__PURE__ */ jsxs(RadixPopover.Root, { open, onOpenChange, children: [
21
+ /* @__PURE__ */ jsx(RadixPopover.Anchor, { asChild: true, children: /* @__PURE__ */ jsxs(
22
+ "div",
23
+ {
24
+ ref: anchorRef,
25
+ className: cn(
26
+ field,
27
+ fieldFocusWithin,
28
+ "flex min-h-control-md w-full flex-wrap items-center gap-1.5 px-3 py-1",
29
+ disabled && fieldDisabled
30
+ ),
31
+ onPointerDown: (e) => {
32
+ if (disabled) return;
33
+ if (e.target.closest("button")) return;
34
+ if (e.target !== inputRef.current) e.preventDefault();
35
+ inputRef.current?.focus();
36
+ onOpenChange(true);
37
+ },
38
+ children: [
39
+ before,
40
+ /* @__PURE__ */ jsx(
41
+ Command.Input,
42
+ {
43
+ ref: inputRef,
44
+ disabled,
45
+ ...input,
46
+ onKeyDown: (e) => {
47
+ if (e.key === "ArrowDown" && !open) onOpenChange(true);
48
+ input.onKeyDown?.(e);
49
+ },
50
+ className: cn(
51
+ "min-w-8 flex-1 bg-transparent text-body-md outline-none placeholder:text-[var(--color-on-surface-muted)]",
52
+ input.className
53
+ )
54
+ }
55
+ )
56
+ ]
57
+ }
58
+ ) }),
59
+ /* @__PURE__ */ jsx(
60
+ RadixPopover.Content,
61
+ {
62
+ align: "start",
63
+ sideOffset: 4,
64
+ onOpenAutoFocus: (e) => e.preventDefault(),
65
+ onCloseAutoFocus: (e) => e.preventDefault(),
66
+ onInteractOutside: (e) => {
67
+ if (anchorRef.current?.contains(e.target)) e.preventDefault();
68
+ },
69
+ className: cn(
70
+ "z-[var(--z-popover)] w-[var(--radix-popover-trigger-width)] overflow-hidden p-0 outline-none",
71
+ floatingSurface,
72
+ "nx-motion nx-pop origin-[var(--radix-popover-content-transform-origin)]"
73
+ ),
74
+ children
75
+ }
76
+ )
77
+ ] });
78
+ }
79
+ function ComboboxLoading() {
80
+ const messages = useNexubMessages();
81
+ return /* @__PURE__ */ jsxs("div", { role: "status", "aria-busy": "true", className: "flex flex-col gap-2 px-2.5 py-2", children: [
82
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: messages.loading }),
83
+ /* @__PURE__ */ jsx(Skeleton, { className: "w-3/4", "aria-hidden": "true" }),
84
+ /* @__PURE__ */ jsx(Skeleton, { className: "w-1/2", "aria-hidden": "true" }),
85
+ /* @__PURE__ */ jsx(Skeleton, { className: "w-2/3", "aria-hidden": "true" })
86
+ ] });
87
+ }
88
+ export {
89
+ ComboboxLoading,
90
+ ComboboxPopover
91
+ };
@@ -12,6 +12,11 @@ export interface CommandPaletteRootProps extends RadixDialog.DialogProps {
12
12
  scrollLock?: 'style' | 'class';
13
13
  /** `false` when the items are already filtered (e.g. async search results). @default true */
14
14
  shouldFilter?: boolean;
15
+ /**
16
+ * A letter that toggles the palette with Cmd (macOS) or Ctrl, anywhere on
17
+ * the page, e.g. `'k'` for Cmd+K / Ctrl+K. Off by default.
18
+ */
19
+ shortcut?: string;
15
20
  }
16
21
  export declare const CommandPalette: {
17
22
  Root: React.ForwardRefExoticComponent<CommandPaletteRootProps & React.RefAttributes<HTMLDivElement>>;
@@ -8,17 +8,30 @@ import { cn } from "../../utils/cn.js";
8
8
  import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
9
9
  import { overlaySurface, scrim } from "../../utils/surface.js";
10
10
  import { useClassScrollLock } from "../../hooks/useClassScrollLock.js";
11
+ import { useControllableState } from "../../hooks/useControllableState.js";
11
12
  const overlayClasses = cn("fixed inset-0 z-[var(--z-modal)] nx-motion", scrim);
12
13
  function ClassLockOverlay() {
13
14
  useClassScrollLock();
14
15
  return /* @__PURE__ */ jsx("div", { "data-state": "open", className: overlayClasses });
15
16
  }
16
17
  const Root = React.forwardRef(
17
- ({ children, label: labelProp, scrollLock = "style", shouldFilter, ...props }, ref) => {
18
+ ({ children, label: labelProp, scrollLock = "style", shouldFilter, shortcut, open: openProp, defaultOpen, onOpenChange, ...props }, ref) => {
18
19
  const messages = useNexubMessages();
20
+ const [open = false, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });
21
+ React.useEffect(() => {
22
+ if (!shortcut) return;
23
+ const onKeyDown = (e) => {
24
+ if (e.defaultPrevented || e.altKey || e.shiftKey || !(e.metaKey || e.ctrlKey)) return;
25
+ if (e.key.toLowerCase() !== shortcut.toLowerCase()) return;
26
+ e.preventDefault();
27
+ setOpen(!open);
28
+ };
29
+ document.addEventListener("keydown", onKeyDown);
30
+ return () => document.removeEventListener("keydown", onKeyDown);
31
+ }, [shortcut, open, setOpen]);
19
32
  const label = labelProp ?? messages.commandPalette;
20
33
  const returnFocusRef = React.useRef(null);
21
- return /* @__PURE__ */ jsx(RadixDialog.Root, { ...props, children: /* @__PURE__ */ jsxs(RadixDialog.Portal, { children: [
34
+ return /* @__PURE__ */ jsx(RadixDialog.Root, { open, onOpenChange: setOpen, ...props, children: /* @__PURE__ */ jsxs(RadixDialog.Portal, { children: [
22
35
  scrollLock === "class" ? /* @__PURE__ */ jsx(ClassLockOverlay, {}) : /* @__PURE__ */ jsx(RadixDialog.Overlay, { className: overlayClasses }),
23
36
  /* @__PURE__ */ jsxs(
24
37
  RadixDialog.Content,
@@ -105,11 +105,16 @@ const Description = React.forwardRef(
105
105
  );
106
106
  Description.displayName = "Drawer.Description";
107
107
  const Header = React.forwardRef(
108
- ({ className, ...props }, ref) => /* @__PURE__ */ jsx("div", { ref, className: cn("flex flex-col gap-1.5 p-6", className), ...props })
108
+ ({ className, ...props }, ref) => (
109
+ // Sticky on solid panels: long content scrolls under the header and footer,
110
+ // which inherit the panel background. Glass panels keep one frosted layer
111
+ // (a second translucent layer reads as an opaque band), so they don't stick.
112
+ /* @__PURE__ */ jsx("div", { ref, className: cn("sticky top-0 z-10 flex flex-col gap-1.5 bg-inherit p-6 [[data-material=glass]_&]:static [[data-material=glass]_&]:bg-transparent", className), ...props })
113
+ )
109
114
  );
110
115
  Header.displayName = "Drawer.Header";
111
116
  const Footer = React.forwardRef(
112
- ({ className, ...props }, ref) => /* @__PURE__ */ jsx("div", { ref, className: cn("mt-auto flex flex-col gap-2 p-6", className), ...props })
117
+ ({ className, ...props }, ref) => /* @__PURE__ */ jsx("div", { ref, className: cn("sticky bottom-0 z-10 mt-auto flex flex-col gap-2 bg-inherit p-6 [[data-material=glass]_&]:static [[data-material=glass]_&]:bg-transparent", className), ...props })
113
118
  );
114
119
  Footer.displayName = "Drawer.Footer";
115
120
  const Drawer = {
@@ -1,10 +1,24 @@
1
1
  import * as React from 'react';
2
2
  /** Localised file size ("12.3 kB", "1,5 Mo"), 1024-based as before. */
3
3
  export declare function formatFileSize(bytes: number, locale: string): string;
4
+ /**
5
+ * Whether `file` matches an `accept` list: MIME types (`image/png`), wildcards
6
+ * (`image/*`) and extensions (`.pdf`), like the native input. A UX filter only:
7
+ * the server must still check what it receives.
8
+ */
9
+ export declare function fileAccepted(file: File, accept?: string[]): boolean;
10
+ export interface FileRejection {
11
+ file: File;
12
+ reason: 'type' | 'size' | 'count';
13
+ }
4
14
  export interface FileUploadRootProps extends React.HTMLAttributes<HTMLDivElement> {
5
15
  accept?: string[];
6
16
  maxFiles?: number;
17
+ /** Largest file in bytes. Bigger files are rejected with a message. */
18
+ maxSize?: number;
7
19
  onFilesChange?: (files: File[]) => void;
20
+ /** Files turned away by `accept`, `maxSize` or `maxFiles`, from the picker or a drop. */
21
+ onReject?: (rejections: FileRejection[]) => void;
8
22
  }
9
23
  export declare const FileUpload: {
10
24
  Root: React.ForwardRefExoticComponent<FileUploadRootProps & React.RefAttributes<HTMLDivElement>>;
@@ -8,6 +8,17 @@ function formatFileSize(bytes, locale) {
8
8
  const [value, unit] = bytes < 1024 ? [bytes, "byte"] : bytes < 1024 * 1024 ? [bytes / 1024, "kilobyte"] : [bytes / (1024 * 1024), "megabyte"];
9
9
  return new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: "short", maximumFractionDigits: 1 }).format(value);
10
10
  }
11
+ function fileAccepted(file, accept) {
12
+ if (!accept?.length) return true;
13
+ const name = file.name.toLowerCase();
14
+ const type = file.type.toLowerCase();
15
+ return accept.some((raw) => {
16
+ const a = raw.trim().toLowerCase();
17
+ if (a.startsWith(".")) return name.endsWith(a);
18
+ if (a.endsWith("/*")) return type.startsWith(a.slice(0, -1));
19
+ return type === a;
20
+ });
21
+ }
11
22
  const FileUploadContext = React.createContext(null);
12
23
  function useFileUpload() {
13
24
  const ctx = React.useContext(FileUploadContext);
@@ -15,32 +26,41 @@ function useFileUpload() {
15
26
  return ctx;
16
27
  }
17
28
  const Root = React.forwardRef(
18
- ({ className, accept, maxFiles, onFilesChange, children, ...props }, ref) => {
29
+ ({ className, accept, maxFiles, maxSize, onFilesChange, onReject, children, ...props }, ref) => {
19
30
  const [files, setFiles] = React.useState([]);
31
+ const [rejections, setRejections] = React.useState([]);
20
32
  const [isDragging, setIsDragging] = React.useState(false);
21
33
  const addFiles = React.useCallback(
22
34
  (newFiles) => {
23
- setFiles((prev) => {
24
- const next = [...prev, ...newFiles].slice(0, maxFiles);
35
+ const rejected = [];
36
+ const next = [...files];
37
+ for (const file of newFiles) {
38
+ if (!fileAccepted(file, accept)) rejected.push({ file, reason: "type" });
39
+ else if (maxSize !== void 0 && file.size > maxSize) rejected.push({ file, reason: "size" });
40
+ else if (maxFiles !== void 0 && next.length >= maxFiles) rejected.push({ file, reason: "count" });
41
+ else next.push(file);
42
+ }
43
+ setRejections(rejected);
44
+ if (next.length !== files.length) {
45
+ setFiles(next);
25
46
  onFilesChange?.(next);
26
- return next;
27
- });
47
+ }
48
+ if (rejected.length) onReject?.(rejected);
28
49
  },
29
- [maxFiles, onFilesChange]
50
+ [files, accept, maxFiles, maxSize, onFilesChange, onReject]
30
51
  );
31
52
  const removeFile = React.useCallback(
32
53
  (index) => {
33
- setFiles((prev) => {
34
- const next = prev.filter((_, i) => i !== index);
35
- onFilesChange?.(next);
36
- return next;
37
- });
54
+ const next = files.filter((_, i) => i !== index);
55
+ setFiles(next);
56
+ setRejections([]);
57
+ onFilesChange?.(next);
38
58
  },
39
- [onFilesChange]
59
+ [files, onFilesChange]
40
60
  );
41
61
  const contextValue = React.useMemo(
42
- () => ({ accept, maxFiles, onFilesChange, files, addFiles, removeFile, isDragging, setIsDragging }),
43
- [accept, maxFiles, onFilesChange, files, addFiles, removeFile, isDragging, setIsDragging]
62
+ () => ({ accept, maxFiles, maxSize, onFilesChange, files, rejections, addFiles, removeFile, isDragging, setIsDragging }),
63
+ [accept, maxFiles, maxSize, onFilesChange, files, rejections, addFiles, removeFile, isDragging, setIsDragging]
44
64
  );
45
65
  return /* @__PURE__ */ jsx(FileUploadContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx("div", { ref, className: cn("flex flex-col gap-4", className), ...props, children }) });
46
66
  }
@@ -48,8 +68,10 @@ const Root = React.forwardRef(
48
68
  Root.displayName = "FileUpload.Root";
49
69
  const Dropzone = React.forwardRef(
50
70
  ({ className, children, ...props }, ref) => {
51
- const { accept, addFiles, isDragging, setIsDragging } = useFileUpload();
71
+ const { accept, maxFiles, maxSize, rejections, addFiles, isDragging, setIsDragging } = useFileUpload();
52
72
  const messages = useNexubMessages();
73
+ const { locale } = useLocale();
74
+ const errorId = React.useId();
53
75
  const inputRef = React.useRef(null);
54
76
  function handleDrop(e) {
55
77
  e.preventDefault();
@@ -61,7 +83,7 @@ const Dropzone = React.forwardRef(
61
83
  // A <label> around the native file input: click, Enter and Space open the
62
84
  // picker natively, the visible text is the accessible name, and the
63
85
  // focus ring follows the input's keyboard focus onto the drop zone.
64
- /* @__PURE__ */ jsx(
86
+ /* @__PURE__ */ jsxs(
65
87
  "div",
66
88
  {
67
89
  ref,
@@ -79,28 +101,32 @@ const Dropzone = React.forwardRef(
79
101
  className
80
102
  ),
81
103
  ...props,
82
- children: /* @__PURE__ */ jsxs("label", { className: "flex cursor-pointer flex-col items-center justify-center gap-2 p-8", children: [
83
- /* @__PURE__ */ jsx(Upload, { size: "lg", className: "text-[var(--color-on-surface-muted)]", "aria-hidden": "true" }),
84
- children ?? /* @__PURE__ */ jsxs("span", { className: "text-sm text-[var(--color-on-surface-muted)]", children: [
85
- /* @__PURE__ */ jsx("span", { className: "font-semibold text-[var(--color-primary)]", children: messages.clickToUpload }),
86
- " ",
87
- messages.orDragAndDrop
88
- ] }),
89
- /* @__PURE__ */ jsx(
90
- "input",
91
- {
92
- ref: inputRef,
93
- type: "file",
94
- className: "sr-only",
95
- multiple: true,
96
- accept: accept?.join(","),
97
- onChange: (e) => {
98
- if (e.target.files) addFiles(Array.from(e.target.files));
99
- e.target.value = "";
104
+ children: [
105
+ /* @__PURE__ */ jsxs("label", { className: "flex cursor-pointer flex-col items-center justify-center gap-2 p-8", children: [
106
+ /* @__PURE__ */ jsx(Upload, { size: "lg", className: "text-[var(--color-on-surface-muted)]", "aria-hidden": "true" }),
107
+ children ?? /* @__PURE__ */ jsxs("span", { className: "text-sm text-[var(--color-on-surface-muted)]", children: [
108
+ /* @__PURE__ */ jsx("span", { className: "font-semibold text-[var(--color-primary)]", children: messages.clickToUpload }),
109
+ " ",
110
+ messages.orDragAndDrop
111
+ ] }),
112
+ /* @__PURE__ */ jsx(
113
+ "input",
114
+ {
115
+ ref: inputRef,
116
+ type: "file",
117
+ className: "sr-only",
118
+ multiple: true,
119
+ accept: accept?.join(","),
120
+ onChange: (e) => {
121
+ if (e.target.files) addFiles(Array.from(e.target.files));
122
+ e.target.value = "";
123
+ },
124
+ "aria-describedby": rejections.length ? errorId : void 0
100
125
  }
101
- }
102
- )
103
- ] })
126
+ )
127
+ ] }),
128
+ /* @__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}`)) }) })
129
+ ]
104
130
  }
105
131
  )
106
132
  );
@@ -152,5 +178,6 @@ Item.displayName = "FileUpload.Item";
152
178
  const FileUpload = { Root, Dropzone, List, Item };
153
179
  export {
154
180
  FileUpload,
181
+ fileAccepted,
155
182
  formatFileSize
156
183
  };
@@ -1,2 +1,2 @@
1
- export { FileUpload } from './FileUpload.js';
2
- export type { FileUploadRootProps } from './FileUpload.js';
1
+ export { FileUpload, fileAccepted } from './FileUpload.js';
2
+ export type { FileUploadRootProps, FileRejection } from './FileUpload.js';