@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.
- package/accessibility.json +18 -1
- package/dist/components/Autocomplete/Autocomplete.d.ts +17 -1
- package/dist/components/Autocomplete/Autocomplete.js +75 -20
- package/dist/components/Breadcrumb/Breadcrumb.d.ts +14 -0
- package/dist/components/Breadcrumb/Breadcrumb.js +30 -2
- package/dist/components/Breadcrumb/index.d.ts +1 -0
- package/dist/components/Chip/Chip.d.ts +2 -0
- package/dist/components/Chip/Chip.js +2 -2
- package/dist/components/Combobox/ComboboxPopover.d.ts +19 -0
- package/dist/components/Combobox/ComboboxPopover.js +91 -0
- package/dist/components/CommandPalette/CommandPalette.d.ts +5 -0
- package/dist/components/CommandPalette/CommandPalette.js +15 -2
- package/dist/components/Drawer/Drawer.js +7 -2
- package/dist/components/FileUpload/FileUpload.d.ts +14 -0
- package/dist/components/FileUpload/FileUpload.js +64 -37
- package/dist/components/FileUpload/index.d.ts +2 -2
- package/dist/components/FileUpload/index.js +3 -2
- package/dist/components/MultiSelect/MultiSelect.d.ts +35 -0
- package/dist/components/MultiSelect/MultiSelect.js +142 -0
- package/dist/components/MultiSelect/index.d.ts +2 -0
- package/dist/components/MultiSelect/index.js +4 -0
- package/dist/components/Toast/Toaster.d.ts +4 -1
- package/dist/components/Toast/Toaster.js +29 -12
- package/dist/components/Toast/index.d.ts +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/dist/styles.css +1 -1
- package/package.json +6 -6
package/accessibility.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "5.0
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
36
|
+
setInnerSearch(selectedLabel);
|
|
22
37
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
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
|
|
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
|
};
|
|
@@ -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) =>
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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__ */
|
|
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:
|
|
83
|
-
/* @__PURE__ */
|
|
84
|
-
|
|
85
|
-
/* @__PURE__ */
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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';
|