@nexub/react 7.4.0 → 7.5.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.4.0",
2
+ "version": "7.5.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,4 +1,9 @@
1
1
  import * as React from 'react';
2
+ import { Command } from 'cmdk';
3
+ export interface ComboboxListProps extends React.ComponentProps<typeof Command.List> {
4
+ /** Options are loading: shows a loading row, sets aria-busy and hides Empty. */
5
+ loading?: boolean;
6
+ }
2
7
  export declare const Combobox: {
3
8
  Root: React.ForwardRefExoticComponent<Omit<{
4
9
  children?: React.ReactNode;
@@ -25,15 +30,7 @@ export declare const Combobox: {
25
30
  value?: string;
26
31
  onValueChange?: (search: string) => void;
27
32
  } & React.RefAttributes<HTMLInputElement>, "ref"> & React.RefAttributes<HTMLInputElement>>;
28
- List: React.ForwardRefExoticComponent<Omit<{
29
- children?: React.ReactNode;
30
- } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
31
- ref?: React.Ref<HTMLDivElement>;
32
- } & {
33
- asChild?: boolean;
34
- }, "asChild" | "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
35
- label?: string;
36
- } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
33
+ List: React.ForwardRefExoticComponent<Omit<ComboboxListProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
37
34
  Empty: React.ForwardRefExoticComponent<Omit<{
38
35
  children?: React.ReactNode;
39
36
  } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
@@ -1,8 +1,10 @@
1
1
  "use client";
2
- import { jsx } from "react/jsx-runtime";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { Command } from "cmdk";
5
5
  import { cn } from "../../utils/cn.js";
6
+ import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
7
+ import { Spinner } from "../Spinner/Spinner.js";
6
8
  function scrollWithinList(el) {
7
9
  if (!el) return;
8
10
  el.scrollIntoView = () => {
@@ -47,14 +49,33 @@ const Input = React.forwardRef(
47
49
  );
48
50
  Input.displayName = "Combobox.Input";
49
51
  const List = React.forwardRef(
50
- ({ className, ...props }, ref) => /* @__PURE__ */ jsx(
51
- Command.List,
52
- {
53
- ref,
54
- className: cn("max-h-64 overflow-y-auto overflow-x-hidden p-1", className),
55
- ...props
56
- }
57
- )
52
+ ({ className, loading, children, ...props }, ref) => {
53
+ const messages = useNexubMessages();
54
+ return /* @__PURE__ */ jsxs(
55
+ Command.List,
56
+ {
57
+ ref,
58
+ "aria-busy": loading || void 0,
59
+ className: cn(
60
+ // cmdk measures its content into --cmdk-list-height; box-content keeps the padding outside it.
61
+ "box-content h-[var(--cmdk-list-height)] max-h-64 overflow-y-auto overflow-x-hidden p-1",
62
+ "transition-[height] duration-spatial-default ease-spatial-default motion-reduce:transition-none",
63
+ loading && "[&_[cmdk-empty]]:hidden",
64
+ className
65
+ ),
66
+ ...props,
67
+ children: [
68
+ loading && // Not Command.Loading: its role="progressbar" isn't allowed inside a listbox
69
+ // (axe aria-required-children). The listbox's aria-busy carries the state.
70
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-2.5 py-2 text-body-sm text-[var(--color-on-surface-muted)]", children: [
71
+ /* @__PURE__ */ jsx(Spinner, { size: "sm", "aria-hidden": "true" }),
72
+ messages.loading
73
+ ] }),
74
+ children
75
+ ]
76
+ }
77
+ );
78
+ }
58
79
  );
59
80
  List.displayName = "Combobox.List";
60
81
  const Empty = React.forwardRef(
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { type ChevronProps, type Labels } from 'react-day-picker';
2
+ import { type ChevronProps, type Labels, type Matcher } from 'react-day-picker';
3
3
  import { type Locale } from 'date-fns';
4
4
  /**
5
5
  * A date-fns Locale wins (date-fns 'PP'); else with a NexubProvider locale,
@@ -34,6 +34,21 @@ export interface DatePickerProps {
34
34
  'aria-label'?: string;
35
35
  /** date-fns Locale for the calendar and trigger text; wins over NexubProvider's `dateLocale`. */
36
36
  locale?: Locale;
37
+ /** Days that can't be picked (react-day-picker Matcher), e.g. `{ dayOfWeek: [0, 6] }`. */
38
+ disabledDates?: Matcher | Matcher[];
39
+ /** Quick picks under the calendar (Today, In a week…); choosing one sets the value and closes. */
40
+ presets?: {
41
+ label: string;
42
+ date: Date;
43
+ }[];
44
+ /**
45
+ * The trigger is a text input that also accepts a typed date in the locale's
46
+ * short format (date-fns `P`, e.g. 01/15/2024). Enter or blur commits; an
47
+ * invalid or unavailable date stays as typed, marked aria-invalid, and the
48
+ * value is unchanged. `id` and `aria-label` go to the input, `ref` to the
49
+ * calendar button.
50
+ */
51
+ typeable?: boolean;
37
52
  }
38
53
  export declare const DatePicker: React.ForwardRefExoticComponent<DatePickerProps & React.RefAttributes<HTMLButtonElement>>;
39
54
  export {};
@@ -2,13 +2,14 @@
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import * as RadixPopover from "@radix-ui/react-popover";
5
- import { DayPicker } from "react-day-picker";
6
- import { format } from "date-fns";
5
+ import { DayPicker, dateMatchModifiers } from "react-day-picker";
6
+ import { format, isValid, parse } from "date-fns";
7
7
  import { CalendarDays, ChevronLeft, ChevronRight } from "@nexub/icons";
8
8
  import { cn } from "../../utils/cn.js";
9
9
  import { useIntlLocale, useLocale, useNexubMessages } from "../NexubProvider/NexubProvider.js";
10
- import { field, fieldFocusVisible } from "../../utils/control.js";
10
+ import { field, fieldDisabled, fieldFocusVisible, fieldFocusWithin, fieldInvalid } from "../../utils/control.js";
11
11
  import { floatingSurface } from "../../utils/surface.js";
12
+ import { Button } from "../Button/Button.js";
12
13
  function formatDate(date, locale, dateLocale) {
13
14
  if (dateLocale) return format(date, "PP", { locale: dateLocale });
14
15
  return locale ? new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date) : format(date, "PP");
@@ -71,7 +72,18 @@ const calendarClassNames = {
71
72
  today: "[&>button]:border-[var(--color-primary)]",
72
73
  outside: "[&:not([data-selected]):not([data-disabled])>button]:text-[var(--color-on-surface-muted)]",
73
74
  disabled: "",
74
- hidden: "invisible"
75
+ hidden: "invisible",
76
+ // Month change (animate): the months crossfade. react-day-picker adds each of
77
+ // these with classList.add, so one class apiece. Keyframes from @nexub/themes
78
+ // core.css; reduced motion collapses them and animationend still cleans up.
79
+ caption_after_enter: "animate-[nx-fade-in_var(--motion-spring-effects-default-duration)_var(--motion-spring-effects-default-easing)]",
80
+ caption_before_enter: "animate-[nx-fade-in_var(--motion-spring-effects-default-duration)_var(--motion-spring-effects-default-easing)]",
81
+ weeks_after_enter: "animate-[nx-fade-in_var(--motion-spring-effects-default-duration)_var(--motion-spring-effects-default-easing)]",
82
+ weeks_before_enter: "animate-[nx-fade-in_var(--motion-spring-effects-default-duration)_var(--motion-spring-effects-default-easing)]",
83
+ caption_after_exit: "animate-[nx-fade-out_var(--motion-spring-effects-fast-duration)_var(--motion-spring-effects-fast-easing)_forwards]",
84
+ caption_before_exit: "animate-[nx-fade-out_var(--motion-spring-effects-fast-duration)_var(--motion-spring-effects-fast-easing)_forwards]",
85
+ weeks_after_exit: "animate-[nx-fade-out_var(--motion-spring-effects-fast-duration)_var(--motion-spring-effects-fast-easing)_forwards]",
86
+ weeks_before_exit: "animate-[nx-fade-out_var(--motion-spring-effects-fast-duration)_var(--motion-spring-effects-fast-easing)_forwards]"
75
87
  };
76
88
  const DatePicker = React.forwardRef(
77
89
  function DatePicker2({
@@ -84,15 +96,75 @@ const DatePicker = React.forwardRef(
84
96
  className,
85
97
  id,
86
98
  "aria-label": ariaLabel,
87
- locale: localeProp
99
+ locale: localeProp,
100
+ disabledDates,
101
+ presets,
102
+ typeable = false
88
103
  }, ref) {
89
104
  const [open, setOpen] = React.useState(false);
90
105
  const messages = useNexubMessages();
91
106
  const placeholder = placeholderProp ?? messages.pickDate;
92
107
  const calendar = useCalendarLocale(localeProp);
93
108
  const display = value ? calendar.format(value) : placeholder;
109
+ const [text, setText] = React.useState(null);
110
+ const dateLocale = calendar.dayPickerProps.locale;
111
+ const typed = text?.trim() ? parse(text.trim(), "P", /* @__PURE__ */ new Date(), { locale: dateLocale }) : void 0;
112
+ const invalid = typed !== void 0 && (!isValid(typed) || minDate !== void 0 && typed < minDate || maxDate !== void 0 && typed > maxDate || disabledDates !== void 0 && dateMatchModifiers(typed, disabledDates));
113
+ const commit = () => {
114
+ if (text === null || invalid) return;
115
+ onChange?.(typed);
116
+ setText(null);
117
+ };
118
+ const pick = (date) => {
119
+ onChange?.(date);
120
+ setText(null);
121
+ setOpen(false);
122
+ };
94
123
  return /* @__PURE__ */ jsxs(RadixPopover.Root, { open, onOpenChange: setOpen, children: [
95
- /* @__PURE__ */ jsx(RadixPopover.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs(
124
+ typeable ? /* @__PURE__ */ jsx(RadixPopover.Anchor, { asChild: true, children: /* @__PURE__ */ jsxs(
125
+ "div",
126
+ {
127
+ className: cn(
128
+ "flex h-control-md w-full items-center gap-1 ps-3.5 pe-1",
129
+ field,
130
+ fieldFocusWithin,
131
+ invalid && fieldInvalid,
132
+ disabled && fieldDisabled,
133
+ className
134
+ ),
135
+ children: [
136
+ /* @__PURE__ */ jsx(
137
+ "input",
138
+ {
139
+ id,
140
+ type: "text",
141
+ disabled,
142
+ "aria-label": ariaLabel,
143
+ "aria-invalid": invalid || void 0,
144
+ placeholder,
145
+ value: text ?? (value ? format(value, "P", { locale: dateLocale }) : ""),
146
+ onChange: (e) => setText(e.target.value),
147
+ onBlur: commit,
148
+ onKeyDown: (e) => {
149
+ if (e.key === "Enter") commit();
150
+ },
151
+ className: "min-w-0 flex-1 bg-transparent text-body-md outline-none placeholder:text-[var(--color-on-surface-muted)] disabled:cursor-not-allowed"
152
+ }
153
+ ),
154
+ /* @__PURE__ */ jsx(RadixPopover.Trigger, { asChild: true, children: /* @__PURE__ */ jsx(
155
+ "button",
156
+ {
157
+ ref,
158
+ type: "button",
159
+ disabled,
160
+ "aria-label": messages.pickDate,
161
+ className: "nx-state-layer inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full disabled:pointer-events-none",
162
+ children: /* @__PURE__ */ jsx(CalendarDays, { size: "sm", "aria-hidden": "true" })
163
+ }
164
+ ) })
165
+ ]
166
+ }
167
+ ) }) : /* @__PURE__ */ jsx(RadixPopover.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs(
96
168
  "button",
97
169
  {
98
170
  ref,
@@ -115,7 +187,7 @@ const DatePicker = React.forwardRef(
115
187
  ]
116
188
  }
117
189
  ) }),
118
- /* @__PURE__ */ jsx(RadixPopover.Portal, { children: /* @__PURE__ */ jsx(
190
+ /* @__PURE__ */ jsx(RadixPopover.Portal, { children: /* @__PURE__ */ jsxs(
119
191
  RadixPopover.Content,
120
192
  {
121
193
  align: "start",
@@ -124,25 +196,27 @@ const DatePicker = React.forwardRef(
124
196
  floatingSurface,
125
197
  "nx-motion nx-pop origin-[var(--radix-popover-content-transform-origin)]"
126
198
  ),
127
- children: /* @__PURE__ */ jsx(
128
- DayPicker,
129
- {
130
- mode: "single",
131
- selected: value,
132
- defaultMonth: value,
133
- onSelect: (date) => {
134
- onChange?.(date);
135
- setOpen(false);
136
- },
137
- startMonth: minDate,
138
- endMonth: maxDate,
139
- autoFocus: true,
140
- showOutsideDays: true,
141
- className: "relative p-3",
142
- classNames: calendarClassNames,
143
- ...calendar.dayPickerProps
144
- }
145
- )
199
+ children: [
200
+ /* @__PURE__ */ jsx(
201
+ DayPicker,
202
+ {
203
+ mode: "single",
204
+ selected: value,
205
+ defaultMonth: value,
206
+ onSelect: pick,
207
+ disabled: disabledDates,
208
+ startMonth: minDate,
209
+ endMonth: maxDate,
210
+ autoFocus: true,
211
+ animate: true,
212
+ showOutsideDays: true,
213
+ className: "relative p-3",
214
+ classNames: calendarClassNames,
215
+ ...calendar.dayPickerProps
216
+ }
217
+ ),
218
+ presets && presets.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1 border-t border-[var(--color-outline-variant)] p-2", children: presets.map((p) => /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "xs", onClick: () => pick(p.date), children: p.label }, p.label)) })
219
+ ]
146
220
  }
147
221
  ) })
148
222
  ] });
@@ -5,6 +5,7 @@ import { Upload, X } from "@nexub/icons";
5
5
  import { cn } from "../../utils/cn.js";
6
6
  import { useLocale, useNexubMessages } from "../NexubProvider/NexubProvider.js";
7
7
  import { Progress } from "../Progress/Progress.js";
8
+ import { SPRING_MS } from "../Motion/useMotion.js";
8
9
  function formatFileSize(bytes, locale) {
9
10
  const [value, unit] = bytes < 1024 ? [bytes, "byte"] : bytes < 1024 * 1024 ? [bytes / 1024, "kilobyte"] : [bytes / (1024 * 1024), "megabyte"];
10
11
  return new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: "short", maximumFractionDigits: 1 }).format(value);
@@ -27,7 +28,7 @@ function useFileUpload() {
27
28
  return ctx;
28
29
  }
29
30
  const Root = React.forwardRef(
30
- ({ className, accept, maxFiles, maxSize, onFilesChange, onReject, validate, children, ...props }, ref) => {
31
+ ({ className, accept, maxFiles, maxSize, onFilesChange, onReject, validate, onPaste, children, ...props }, ref) => {
31
32
  const [files, setFiles] = React.useState([]);
32
33
  const [rejections, setRejections] = React.useState([]);
33
34
  const [isDragging, setIsDragging] = React.useState(false);
@@ -65,7 +66,22 @@ const Root = React.forwardRef(
65
66
  () => ({ accept, maxFiles, maxSize, onFilesChange, files, rejections, addFiles, removeFile, isDragging, setIsDragging }),
66
67
  [accept, maxFiles, maxSize, onFilesChange, files, rejections, addFiles, removeFile, isDragging, setIsDragging]
67
68
  );
68
- return /* @__PURE__ */ jsx(FileUploadContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx("div", { ref, className: cn("flex flex-col gap-4", className), ...props, children }) });
69
+ return /* @__PURE__ */ jsx(FileUploadContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx(
70
+ "div",
71
+ {
72
+ ref,
73
+ className: cn("flex flex-col gap-4", className),
74
+ onPaste: (e) => {
75
+ onPaste?.(e);
76
+ const pasted = Array.from(e.clipboardData?.files ?? []);
77
+ if (e.defaultPrevented || !pasted.length) return;
78
+ e.preventDefault();
79
+ addFiles(pasted);
80
+ },
81
+ ...props,
82
+ children
83
+ }
84
+ ) });
69
85
  }
70
86
  );
71
87
  Root.displayName = "FileUpload.Root";
@@ -140,7 +156,7 @@ const List = React.forwardRef(
140
156
  ({ className, ...props }, ref) => {
141
157
  const { files } = useFileUpload();
142
158
  if (files.length === 0) return null;
143
- return /* @__PURE__ */ jsx("ul", { ref, className: cn("flex flex-col gap-2", className), ...props });
159
+ return /* @__PURE__ */ jsx("ul", { ref, className: cn("flex flex-col", className), ...props });
144
160
  }
145
161
  );
146
162
  List.displayName = "FileUpload.List";
@@ -149,40 +165,78 @@ const Item = React.forwardRef(({ className, index, progress, onCancel, error, ..
149
165
  const messages = useNexubMessages();
150
166
  const { locale } = useLocale();
151
167
  const file = files[index];
168
+ const [exiting, setExiting] = React.useState(null);
169
+ const [entered, setEntered] = React.useState(false);
170
+ const [thumb, setThumb] = React.useState();
171
+ React.useEffect(() => {
172
+ if (!file?.type.startsWith("image/") || typeof URL.createObjectURL !== "function") return;
173
+ const url = URL.createObjectURL(file);
174
+ setThumb(url);
175
+ return () => {
176
+ URL.revokeObjectURL(url);
177
+ setThumb(void 0);
178
+ };
179
+ }, [file]);
180
+ const isExiting = !!file && exiting === file;
181
+ React.useEffect(() => {
182
+ if (!isExiting) return;
183
+ const t = setTimeout(() => removeFile(index), SPRING_MS.effects.default + 50);
184
+ return () => clearTimeout(t);
185
+ }, [isExiting, index, removeFile]);
152
186
  if (!file) return null;
153
187
  const uploading = progress !== void 0 && progress < 100;
154
188
  const percent = Math.round(Math.max(0, Math.min(100, progress ?? 0)));
155
- return /* @__PURE__ */ jsxs(
156
- "li",
157
- {
158
- ref,
159
- className: cn(
160
- "flex items-center justify-between gap-3 rounded-shape-container border border-[var(--color-outline-variant)] bg-[var(--color-surface)] p-3",
161
- className
162
- ),
163
- ...props,
164
- children: [
165
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
166
- /* @__PURE__ */ jsx("p", { className: "truncate text-sm font-medium text-[var(--color-on-surface)]", children: file.name }),
167
- /* @__PURE__ */ jsxs("p", { className: "text-xs text-[var(--color-on-surface-muted)]", children: [
168
- formatFileSize(file.size, locale),
169
- uploading && ` \xB7 ${percent}%`
170
- ] }),
171
- uploading && /* @__PURE__ */ jsx(Progress, { value: percent, size: "sm", className: "mt-1.5", "aria-label": messages.uploadProgress(file.name, percent) }),
172
- error && /* @__PURE__ */ jsx("p", { role: "alert", className: "mt-1 text-xs text-[var(--color-error)]", children: error })
173
- ] }),
174
- /* @__PURE__ */ jsx(
175
- "button",
189
+ return (
190
+ // nx-list-item: grows in on add, collapses out on remove (grid rows, spring tokens).
191
+ /* @__PURE__ */ jsx(
192
+ "li",
193
+ {
194
+ ref,
195
+ className: "nx-list-item",
196
+ "data-state": isExiting ? "exit" : entered ? void 0 : "enter",
197
+ onAnimationEnd: (e) => {
198
+ if (e.target !== e.currentTarget) return;
199
+ if (isExiting) removeFile(index);
200
+ else setEntered(true);
201
+ },
202
+ ...props,
203
+ children: /* @__PURE__ */ jsx("div", { className: "nx-list-item-inner", children: /* @__PURE__ */ jsx("div", { className: "[li:not(:last-child)>.nx-list-item-inner>&]:pb-2", children: /* @__PURE__ */ jsxs(
204
+ "div",
176
205
  {
177
- type: "button",
178
- onClick: () => uploading && onCancel ? onCancel() : removeFile(index),
179
- "aria-label": uploading && onCancel ? messages.cancelUpload(file.name) : messages.removeFile(file.name),
180
- className: "nx-state-layer shrink-0 rounded-full p-1 text-[var(--color-on-surface-muted)] hover:text-[var(--color-on-surface)] transition-colors",
181
- children: /* @__PURE__ */ jsx(X, { size: "sm", "aria-hidden": "true" })
206
+ className: cn(
207
+ "flex items-center justify-between gap-3 rounded-shape-container border border-[var(--color-outline-variant)] bg-[var(--color-surface)] p-3",
208
+ className
209
+ ),
210
+ children: [
211
+ thumb && /* @__PURE__ */ jsx("img", { src: thumb, alt: "", className: "h-10 w-10 shrink-0 rounded-shape-min object-cover" }),
212
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
213
+ /* @__PURE__ */ jsx("p", { className: "truncate text-sm font-medium text-[var(--color-on-surface)]", children: file.name }),
214
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-[var(--color-on-surface-muted)]", children: [
215
+ formatFileSize(file.size, locale),
216
+ uploading && ` \xB7 ${percent}%`
217
+ ] }),
218
+ uploading && /* @__PURE__ */ jsx(Progress, { value: percent, size: "sm", className: "mt-1.5", "aria-label": messages.uploadProgress(file.name, percent) }),
219
+ error && /* @__PURE__ */ jsx("p", { role: "alert", className: "mt-1 text-xs text-[var(--color-error)]", children: error })
220
+ ] }),
221
+ /* @__PURE__ */ jsx(
222
+ "button",
223
+ {
224
+ type: "button",
225
+ onClick: () => {
226
+ if (uploading && onCancel) return onCancel();
227
+ setEntered(true);
228
+ setExiting(file);
229
+ },
230
+ "aria-label": uploading && onCancel ? messages.cancelUpload(file.name) : messages.removeFile(file.name),
231
+ className: "nx-state-layer shrink-0 rounded-full p-1 text-[var(--color-on-surface-muted)] hover:text-[var(--color-on-surface)] transition-colors",
232
+ children: /* @__PURE__ */ jsx(X, { size: "sm", "aria-hidden": "true" })
233
+ }
234
+ )
235
+ ]
182
236
  }
183
- )
184
- ]
185
- }
237
+ ) }) })
238
+ }
239
+ )
186
240
  );
187
241
  });
188
242
  Item.displayName = "FileUpload.Item";
@@ -17,5 +17,11 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
17
17
  leadingIcon?: React.ReactNode;
18
18
  trailingIcon?: React.ReactNode;
19
19
  wrapperClassName?: string;
20
+ /** Shows a clear button while there is a value. Clearing fires `onChange` with "". */
21
+ clearable?: boolean;
22
+ /** Shows a "12/80" counter; needs `maxLength`. Announced politely near the limit. */
23
+ showCount?: boolean;
24
+ /** Spinner in the trailing slot and `aria-busy` on the input. */
25
+ loading?: boolean;
20
26
  }
21
27
  export declare const Input: React.ForwardRefExoticComponent<InputProps & React.RefAttributes<HTMLInputElement>>;
@@ -1,8 +1,14 @@
1
- import { jsx, jsxs } from "react/jsx-runtime";
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
3
  import * as React from "react";
4
+ import { X } from "@nexub/icons";
3
5
  import { cva } from "class-variance-authority";
4
6
  import { cn } from "../../utils/cn.js";
5
7
  import { field, fieldDisabledWithin, fieldFocusWithin, fieldInvalid } from "../../utils/control.js";
8
+ import { useMergedRefs } from "../../hooks/useMergedRefs.js";
9
+ import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
10
+ import { IconButton } from "../IconButton/IconButton.js";
11
+ import { Spinner } from "../Spinner/Spinner.js";
6
12
  const inputVariants = cva(
7
13
  [
8
14
  // The wrapper owns ALL focus styling (border + ring). Strip every possible
@@ -67,6 +73,7 @@ const addonVariants = cva(
67
73
  }
68
74
  }
69
75
  );
76
+ const nearLimit = (length, max) => length >= max - Math.max(1, Math.ceil(max * 0.1));
70
77
  const Input = React.forwardRef(
71
78
  ({
72
79
  className,
@@ -78,26 +85,61 @@ const Input = React.forwardRef(
78
85
  trailingIcon,
79
86
  wrapperClassName,
80
87
  disabled,
88
+ clearable,
89
+ showCount,
90
+ loading,
81
91
  ...props
82
92
  }, ref) => {
83
93
  const hasAddon = Boolean(leadingAddon ?? trailingAddon);
94
+ const messages = useNexubMessages();
95
+ const innerRef = React.useRef(null);
96
+ const mergedRef = useMergedRefs(ref, innerRef);
97
+ const countId = React.useId();
98
+ const [uncontrolledLength, setLength] = React.useState(String(props.defaultValue ?? "").length);
99
+ const length = props.value !== void 0 && props.value !== null ? String(props.value).length : uncontrolledLength;
100
+ const max = props.maxLength;
101
+ const counting = Boolean(showCount && max !== void 0 && max >= 0);
102
+ const handleChange = (e) => {
103
+ setLength(e.target.value.length);
104
+ props.onChange?.(e);
105
+ };
106
+ const clear = () => {
107
+ const el = innerRef.current;
108
+ if (!el) return;
109
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(el, "");
110
+ el.dispatchEvent(new Event("input", { bubbles: true }));
111
+ el.focus();
112
+ };
84
113
  const inputEl = /* @__PURE__ */ jsx(
85
114
  "input",
86
115
  {
87
- ref,
88
116
  className: cn(inputVariants({ size }), className),
89
117
  "aria-invalid": invalid ?? void 0,
118
+ "aria-busy": loading || void 0,
90
119
  disabled,
91
- ...props
120
+ ...props,
121
+ "aria-describedby": [props["aria-describedby"], counting && countId].filter(Boolean).join(" ") || void 0,
122
+ onChange: handleChange,
123
+ ref: mergedRef
92
124
  }
93
125
  );
126
+ const showClear = clearable && length > 0 && !disabled && !props.readOnly;
127
+ const trailing = /* @__PURE__ */ jsxs(Fragment, { children: [
128
+ counting && /* @__PURE__ */ jsxs("span", { className: "shrink-0 text-label-sm tabular-nums text-[var(--color-on-surface-muted)]", children: [
129
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: `${length}/${max}` }),
130
+ /* @__PURE__ */ jsx("span", { id: countId, className: "sr-only", "aria-live": nearLimit(length, max) ? "polite" : "off", children: messages.characterCount(length, max) })
131
+ ] }),
132
+ loading && /* @__PURE__ */ jsx(Spinner, { size: "sm", className: "shrink-0" }),
133
+ showClear && /* @__PURE__ */ jsx(IconButton, { size: "xs", label: messages.clear, onClick: clear, className: "-me-1.5", children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4", "aria-hidden": "true" }) }),
134
+ trailingIcon && /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[var(--color-on-surface-variant)]", children: trailingIcon })
135
+ ] });
94
136
  if (hasAddon) {
95
137
  return /* @__PURE__ */ jsxs("div", { className: cn("flex w-full overflow-hidden", field, fieldFocusWithin, invalid && fieldInvalid), children: [
96
138
  leadingAddon && /* @__PURE__ */ jsx("span", { className: cn(addonVariants({ side: "leading" })), children: leadingAddon }),
97
139
  /* @__PURE__ */ jsxs("div", { className: cn(inputWrapperVariants({ size, invalid }), "rounded-none border-0 shadow-none focus-within:shadow-none", wrapperClassName), children: [
98
140
  leadingIcon && /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[var(--color-on-surface-variant)]", children: leadingIcon }),
99
141
  inputEl,
100
- trailingIcon && /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[var(--color-on-surface-variant)]", children: trailingIcon })
142
+ trailing
101
143
  ] }),
102
144
  trailingAddon && /* @__PURE__ */ jsx("span", { className: cn(addonVariants({ side: "trailing" })), children: trailingAddon })
103
145
  ] });
@@ -105,7 +147,7 @@ const Input = React.forwardRef(
105
147
  return /* @__PURE__ */ jsxs("div", { className: cn(inputWrapperVariants({ size, invalid }), wrapperClassName), children: [
106
148
  leadingIcon && /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[var(--color-on-surface-variant)]", children: leadingIcon }),
107
149
  inputEl,
108
- trailingIcon && /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[var(--color-on-surface-variant)]", children: trailingIcon })
150
+ trailing
109
151
  ] });
110
152
  }
111
153
  );
@@ -1,7 +1,22 @@
1
1
  import * as React from 'react';
2
2
  import * as RadixSelect from '@radix-ui/react-select';
3
+ export interface SelectRootProps extends RadixSelect.SelectProps {
4
+ /** Shows a clear button on the Trigger while there is a value; clearing calls `onValueChange('')` and shows the placeholder. */
5
+ clearable?: boolean;
6
+ }
7
+ export interface SelectContentProps extends RadixSelect.SelectContentProps {
8
+ /** A filter field at the top; items whose text (or `textValue`) doesn't match are hidden. Typing on an item moves to the field. */
9
+ searchable?: boolean;
10
+ }
11
+ export interface SelectItemProps extends RadixSelect.SelectItemProps {
12
+ /** A secondary line under the label: the option's accessible description, not part of its name. */
13
+ description?: React.ReactNode;
14
+ }
3
15
  export declare const Select: {
4
- Root: React.FC<RadixSelect.SelectProps>;
16
+ Root: {
17
+ ({ clearable, value, defaultValue, onValueChange, onOpenChange, ...props }: SelectRootProps): React.JSX.Element;
18
+ displayName: string;
19
+ };
5
20
  Group: React.ForwardRefExoticComponent<RadixSelect.SelectGroupProps & React.RefAttributes<HTMLDivElement>>;
6
21
  Value: React.ForwardRefExoticComponent<RadixSelect.SelectValueProps & React.RefAttributes<HTMLSpanElement>>;
7
22
  Trigger: React.ForwardRefExoticComponent<RadixSelect.SelectTriggerProps & {
@@ -9,8 +24,8 @@ export declare const Select: {
9
24
  } & React.RefAttributes<HTMLButtonElement>>;
10
25
  ScrollUpButton: React.ForwardRefExoticComponent<RadixSelect.SelectScrollUpButtonProps & React.RefAttributes<HTMLDivElement>>;
11
26
  ScrollDownButton: React.ForwardRefExoticComponent<RadixSelect.SelectScrollDownButtonProps & React.RefAttributes<HTMLDivElement>>;
12
- Content: React.ForwardRefExoticComponent<RadixSelect.SelectContentProps & React.RefAttributes<HTMLDivElement>>;
27
+ Content: React.ForwardRefExoticComponent<SelectContentProps & React.RefAttributes<HTMLDivElement>>;
13
28
  Label: React.ForwardRefExoticComponent<RadixSelect.SelectLabelProps & React.RefAttributes<HTMLDivElement>>;
14
- Item: React.ForwardRefExoticComponent<RadixSelect.SelectItemProps & React.RefAttributes<HTMLDivElement>>;
29
+ Item: React.ForwardRefExoticComponent<SelectItemProps & React.RefAttributes<HTMLDivElement>>;
15
30
  Separator: React.ForwardRefExoticComponent<RadixSelect.SelectSeparatorProps & React.RefAttributes<HTMLDivElement>>;
16
31
  };