@nexub/react 5.1.0 → 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.1.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",
@@ -17,9 +17,18 @@ export interface AutocompleteProps {
17
17
  * filtered (fetched for this text), so the built-in filtering is off.
18
18
  */
19
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;
20
28
  className?: string;
29
+ 'aria-label'?: string;
21
30
  }
22
- export declare function Autocomplete({ options, value, onValueChange, placeholder, emptyText, loading, inputValue, onInputChange, 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;
23
32
  export declare namespace Autocomplete {
24
33
  var displayName: string;
25
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,
@@ -12,39 +15,84 @@ function Autocomplete({
12
15
  loading = false,
13
16
  inputValue,
14
17
  onInputChange,
15
- className
18
+ mode = "inline",
19
+ onCreate,
20
+ disabled,
21
+ className,
22
+ "aria-label": ariaLabel
16
23
  }) {
17
24
  const messages = useNexubMessages();
18
25
  const selectedLabel = options.find((o) => o.value === value)?.label ?? "";
19
26
  const [innerSearch, setInnerSearch] = React.useState(selectedLabel);
20
27
  const [prevValue, setPrevValue] = React.useState(value);
28
+ const [open, setOpen] = React.useState(false);
21
29
  const search = inputValue ?? innerSearch;
22
- const setSearch = (text) => {
23
- setInnerSearch(text);
24
- onInputChange?.(text);
30
+ const setSearch = (text2) => {
31
+ setInnerSearch(text2);
32
+ onInputChange?.(text2);
25
33
  };
26
34
  if (value !== prevValue) {
27
35
  setPrevValue(value);
28
36
  setInnerSearch(selectedLabel);
29
37
  }
30
- return /* @__PURE__ */ jsxs(Combobox.Root, { className, shouldFilter: !onInputChange, children: [
31
- /* @__PURE__ */ jsx(Combobox.Input, { placeholder: placeholder ?? messages.searchPlaceholder, value: search, onValueChange: setSearch }),
32
- /* @__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: [
33
- /* @__PURE__ */ jsx(Combobox.Empty, { children: emptyText ?? messages.noResults }),
34
- options.map((opt) => /* @__PURE__ */ jsx(
35
- Combobox.Item,
36
- {
37
- value: opt.label,
38
- keywords: [opt.value],
39
- onSelect: () => {
40
- onValueChange?.(opt.value);
41
- setSearch(opt.label);
42
- },
43
- 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);
44
51
  },
45
- opt.value
46
- ))
47
- ] }) })
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
48
96
  ] });
49
97
  }
50
98
  Autocomplete.displayName = "Autocomplete";
@@ -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
+ };
@@ -0,0 +1,35 @@
1
+ import * as React from 'react';
2
+ export interface MultiSelectOption {
3
+ value: string;
4
+ label: string;
5
+ }
6
+ export interface MultiSelectProps {
7
+ options: MultiSelectOption[];
8
+ value?: string[];
9
+ defaultValue?: string[];
10
+ onValueChange?: (value: string[]) => void;
11
+ placeholder?: string;
12
+ emptyText?: string;
13
+ loading?: boolean;
14
+ /** Controlled search text. */
15
+ inputValue?: string;
16
+ /** Called as the user types; options are then shown as given (no built-in filtering). */
17
+ onInputChange?: (text: string) => void;
18
+ /** Adds a "Create “text”" option when the text matches no option exactly. */
19
+ onCreate?: (text: string) => void;
20
+ /** Chips shown before the rest collapse into "+N more". Default 3. */
21
+ maxVisibleChips?: number;
22
+ disabled?: boolean;
23
+ className?: string;
24
+ id?: string;
25
+ 'aria-label'?: string;
26
+ 'aria-labelledby'?: string;
27
+ }
28
+ /**
29
+ * Pick several options: chips in the field, a filterable list below.
30
+ * Backspace in an empty field removes the last chip.
31
+ */
32
+ export declare function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, placeholder, emptyText, loading, inputValue, onInputChange, onCreate, maxVisibleChips, disabled, className, id, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, }: MultiSelectProps): React.JSX.Element;
33
+ export declare namespace MultiSelect {
34
+ var displayName: string;
35
+ }
@@ -0,0 +1,142 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Check, Plus } from "@nexub/icons";
5
+ import { cn } from "../../utils/cn.js";
6
+ import { useControllableState } from "../../hooks/useControllableState.js";
7
+ import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
8
+ import { Chip } from "../Chip/Chip.js";
9
+ import { Combobox } from "../Combobox/Combobox.js";
10
+ import { ComboboxLoading, ComboboxPopover } from "../Combobox/ComboboxPopover.js";
11
+ function MultiSelect({
12
+ options,
13
+ value: valueProp,
14
+ defaultValue,
15
+ onValueChange,
16
+ placeholder,
17
+ emptyText,
18
+ loading = false,
19
+ inputValue,
20
+ onInputChange,
21
+ onCreate,
22
+ maxVisibleChips = 3,
23
+ disabled,
24
+ className,
25
+ id,
26
+ "aria-label": ariaLabel,
27
+ "aria-labelledby": ariaLabelledBy
28
+ }) {
29
+ const messages = useNexubMessages();
30
+ const [value = [], setValue] = useControllableState({
31
+ value: valueProp,
32
+ defaultValue: defaultValue ?? [],
33
+ onChange: onValueChange
34
+ });
35
+ const [innerSearch, setInnerSearch] = React.useState("");
36
+ const [open, setOpen] = React.useState(false);
37
+ const search = inputValue ?? innerSearch;
38
+ const setSearch = (text2) => {
39
+ setInnerSearch(text2);
40
+ onInputChange?.(text2);
41
+ };
42
+ const [labels, setLabels] = React.useState(() => /* @__PURE__ */ new Map());
43
+ const missing = options.some((o) => labels.get(o.value) !== o.label);
44
+ if (missing) setLabels(new Map([...labels, ...options.map((o) => [o.value, o.label])]));
45
+ const labelOf = (v) => labels.get(v) ?? options.find((o) => o.value === v)?.label ?? v;
46
+ const toggle = (v) => setValue(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
47
+ const remove = (v) => setValue(value.filter((x) => x !== v));
48
+ const text = search.trim();
49
+ const canCreate = !!onCreate && !loading && text !== "" && !options.some((o) => o.label.toLowerCase() === text.toLowerCase());
50
+ const hiddenCount = Math.max(0, value.length - maxVisibleChips);
51
+ const chips = /* @__PURE__ */ jsxs(Fragment, { children: [
52
+ value.slice(0, maxVisibleChips).map((v) => /* @__PURE__ */ jsx(
53
+ Chip,
54
+ {
55
+ size: "sm",
56
+ onRemove: disabled ? void 0 : () => remove(v),
57
+ removeLabel: messages.removeOption(labelOf(v)),
58
+ children: labelOf(v)
59
+ },
60
+ v
61
+ )),
62
+ hiddenCount > 0 && /* @__PURE__ */ jsxs(Chip, { size: "sm", children: [
63
+ "+",
64
+ messages.moreCount(hiddenCount)
65
+ ] })
66
+ ] });
67
+ return /* @__PURE__ */ jsx(
68
+ Combobox.Root,
69
+ {
70
+ label: ariaLabel,
71
+ className: cn("overflow-visible rounded-none bg-transparent", className),
72
+ shouldFilter: !onInputChange,
73
+ children: /* @__PURE__ */ jsx(
74
+ ComboboxPopover,
75
+ {
76
+ open: open && !disabled,
77
+ onOpenChange: setOpen,
78
+ before: chips,
79
+ disabled,
80
+ input: {
81
+ id,
82
+ "aria-label": ariaLabel,
83
+ "aria-labelledby": ariaLabelledBy,
84
+ placeholder: value.length ? void 0 : placeholder ?? messages.searchPlaceholder,
85
+ value: search,
86
+ onValueChange: (t) => {
87
+ setSearch(t);
88
+ setOpen(true);
89
+ },
90
+ onKeyDown: (e) => {
91
+ if (e.key === "Backspace" && search === "" && value.length) remove(value[value.length - 1]);
92
+ }
93
+ },
94
+ children: /* @__PURE__ */ jsx(Combobox.List, { "aria-multiselectable": "true", children: loading ? /* @__PURE__ */ jsx(ComboboxLoading, {}) : /* @__PURE__ */ jsxs(Fragment, { children: [
95
+ !canCreate && /* @__PURE__ */ jsx(Combobox.Empty, { children: emptyText ?? messages.noResults }),
96
+ options.map((opt) => {
97
+ const checked = value.includes(opt.value);
98
+ return /* @__PURE__ */ jsxs(
99
+ Combobox.Item,
100
+ {
101
+ value: opt.label,
102
+ keywords: [opt.value],
103
+ "aria-checked": checked,
104
+ onSelect: () => {
105
+ toggle(opt.value);
106
+ if (search) setSearch("");
107
+ },
108
+ className: "gap-2",
109
+ children: [
110
+ /* @__PURE__ */ jsx(Check, { size: "sm", "aria-hidden": "true", className: cn(!checked && "invisible") }),
111
+ opt.label
112
+ ]
113
+ },
114
+ opt.value
115
+ );
116
+ }),
117
+ canCreate && /* @__PURE__ */ jsxs(
118
+ Combobox.Item,
119
+ {
120
+ forceMount: true,
121
+ value: "\0",
122
+ onSelect: () => {
123
+ onCreate(text);
124
+ setSearch("");
125
+ },
126
+ className: "gap-2",
127
+ children: [
128
+ /* @__PURE__ */ jsx(Plus, { size: "sm", "aria-hidden": "true" }),
129
+ messages.createOption(text)
130
+ ]
131
+ }
132
+ )
133
+ ] }) })
134
+ }
135
+ )
136
+ }
137
+ );
138
+ }
139
+ MultiSelect.displayName = "MultiSelect";
140
+ export {
141
+ MultiSelect
142
+ };
@@ -0,0 +1,2 @@
1
+ export { MultiSelect } from './MultiSelect.js';
2
+ export type { MultiSelectProps, MultiSelectOption } from './MultiSelect.js';
@@ -0,0 +1,4 @@
1
+ import { MultiSelect } from "./MultiSelect.js";
2
+ export {
3
+ MultiSelect
4
+ };
package/dist/index.d.ts CHANGED
@@ -160,6 +160,8 @@ export { Select } from './components/Select/index.js';
160
160
  export { Combobox } from './components/Combobox/index.js';
161
161
  export { Autocomplete } from './components/Autocomplete/index.js';
162
162
  export type { AutocompleteOption, AutocompleteProps } from './components/Autocomplete/index.js';
163
+ export { MultiSelect } from './components/MultiSelect/index.js';
164
+ export type { MultiSelectOption, MultiSelectProps } from './components/MultiSelect/index.js';
163
165
  export { Switch } from './components/Switch/index.js';
164
166
  export type { SwitchProps } from './components/Switch/index.js';
165
167
  export { Checkbox } from './components/Checkbox/index.js';
package/dist/index.js CHANGED
@@ -89,6 +89,7 @@ import { Drawer, Sheet } from "./components/Drawer/index.js";
89
89
  import { Select } from "./components/Select/index.js";
90
90
  import { Combobox } from "./components/Combobox/index.js";
91
91
  import { Autocomplete } from "./components/Autocomplete/index.js";
92
+ import { MultiSelect } from "./components/MultiSelect/index.js";
92
93
  import { Switch } from "./components/Switch/index.js";
93
94
  import { Checkbox } from "./components/Checkbox/index.js";
94
95
  import { RadioGroup } from "./components/RadioGroup/index.js";
@@ -201,6 +202,7 @@ export {
201
202
  MessageActions,
202
203
  MessageBubble,
203
204
  MoneyInput,
205
+ MultiSelect,
204
206
  NavigationMenu,
205
207
  NavigationRail,
206
208
  NexubProvider,