@nexub/react 5.9.0 → 6.1.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.9.0",
2
+ "version": "6.1.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.",
@@ -410,6 +410,15 @@
410
410
  "keyboard": [],
411
411
  "screenReader": []
412
412
  },
413
+ {
414
+ "slug": "fieldset",
415
+ "name": "Fieldset",
416
+ "package": "@nexub/react",
417
+ "automated": "pass",
418
+ "exceptions": [],
419
+ "keyboard": [],
420
+ "screenReader": []
421
+ },
413
422
  {
414
423
  "slug": "filter-builder",
415
424
  "name": "Filter Builder",
@@ -459,6 +468,17 @@
459
468
  "keyboard": [],
460
469
  "screenReader": []
461
470
  },
471
+ {
472
+ "slug": "form",
473
+ "name": "Form",
474
+ "package": "@nexub/react",
475
+ "automated": "pass",
476
+ "exceptions": [],
477
+ "keyboard": [
478
+ "Enter: Submits from any text field; the first invalid field takes focus."
479
+ ],
480
+ "screenReader": []
481
+ },
462
482
  {
463
483
  "slug": "funnel",
464
484
  "name": "Funnel",
@@ -546,6 +566,19 @@
546
566
  "keyboard": [],
547
567
  "screenReader": []
548
568
  },
569
+ {
570
+ "slug": "inline-edit",
571
+ "name": "Inline Edit",
572
+ "package": "@nexub/react",
573
+ "automated": "pass",
574
+ "exceptions": [],
575
+ "keyboard": [
576
+ "Enter / Space: On the text: starts editing.",
577
+ "Enter: In the input: saves.",
578
+ "Escape: Reverts and returns focus to the text."
579
+ ],
580
+ "screenReader": []
581
+ },
549
582
  {
550
583
  "slug": "input",
551
584
  "name": "Input",
@@ -18,9 +18,8 @@ export interface AutocompleteProps {
18
18
  */
19
19
  onInputChange?: (text: string) => void;
20
20
  /**
21
- * `'inline'` (default in 5.x): the list always shows under the input.
22
- * `'popover'`: a field whose list opens below it while typing. The default from 6.0;
23
- * set `mode` explicitly to keep either behaviour across the upgrade.
21
+ * `'popover'` (default): a field whose list opens below it while typing.
22
+ * `'inline'`: the list always shows under the input (the 5.x default).
24
23
  */
25
24
  mode?: 'inline' | 'popover';
26
25
  /** Adds a "Create “text”" option when the text matches no option exactly. */
@@ -15,7 +15,7 @@ function Autocomplete({
15
15
  loading = false,
16
16
  inputValue,
17
17
  onInputChange,
18
- mode = "inline",
18
+ mode = "popover",
19
19
  onCreate,
20
20
  disabled,
21
21
  className,
@@ -32,7 +32,8 @@ export interface DataTableProps<TData> extends React.HTMLAttributes<HTMLDivEleme
32
32
  enableRowSelection?: boolean;
33
33
  /**
34
34
  * With `enableRowSelection`: a checkbox column with select-page in its
35
- * header, and "Select all N" across pages. On by default from 6.0.
35
+ * header, and "Select all N" across pages. Default true; false keeps selection
36
+ * without the column (you render your own checkboxes).
36
37
  */
37
38
  selectionColumn?: boolean;
38
39
  /** Controlled selection, keyed by row id (see `getRowId`). */
@@ -110,7 +110,7 @@ function DataTable({
110
110
  data,
111
111
  enableSorting = false,
112
112
  enableRowSelection = false,
113
- selectionColumn = false,
113
+ selectionColumn = true,
114
114
  rowSelection: rowSelectionProp,
115
115
  onRowSelectionChange,
116
116
  getRowId,
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+ export interface FieldsetProps extends React.FieldsetHTMLAttributes<HTMLFieldSetElement> {
3
+ /** The group's name, read before each field inside it. */
4
+ legend: React.ReactNode;
5
+ description?: React.ReactNode;
6
+ }
7
+ /**
8
+ * Groups related fields under a heading (`<fieldset>` + `<legend>`). `disabled`
9
+ * disables every control inside it, natively.
10
+ */
11
+ export declare const Fieldset: React.ForwardRefExoticComponent<FieldsetProps & React.RefAttributes<HTMLFieldSetElement>>;
@@ -0,0 +1,23 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../../utils/cn.js";
4
+ const Fieldset = React.forwardRef(function Fieldset2({ legend, description, className, children, ...props }, ref) {
5
+ const descriptionId = React.useId();
6
+ return /* @__PURE__ */ jsxs(
7
+ "fieldset",
8
+ {
9
+ ref,
10
+ "aria-describedby": description ? descriptionId : void 0,
11
+ className: cn("m-0 flex min-w-0 flex-col gap-4 border-0 p-0", className),
12
+ ...props,
13
+ children: [
14
+ /* @__PURE__ */ jsx("legend", { className: "mb-1 p-0 text-title-sm font-semibold text-[var(--color-on-surface)]", children: legend }),
15
+ description && /* @__PURE__ */ jsx("p", { id: descriptionId, className: "text-body-sm text-[var(--color-on-surface-muted)]", children: description }),
16
+ children
17
+ ]
18
+ }
19
+ );
20
+ });
21
+ export {
22
+ Fieldset
23
+ };
@@ -0,0 +1,2 @@
1
+ export { Fieldset } from './Fieldset.js';
2
+ export type { FieldsetProps } from './Fieldset.js';
@@ -0,0 +1,4 @@
1
+ import { Fieldset } from "./Fieldset.js";
2
+ export {
3
+ Fieldset
4
+ };
@@ -0,0 +1,33 @@
1
+ import * as React from 'react';
2
+ export interface FormErrorItem {
3
+ /** The field's `name`. */
4
+ name: string;
5
+ /** The field's DOM id, or its name when it has none (ErrorSummary link target). */
6
+ id: string;
7
+ message: string;
8
+ }
9
+ export type FormErrors = Record<string, string>;
10
+ export interface FormContextValue {
11
+ errors: FormErrors;
12
+ submitting: boolean;
13
+ }
14
+ /** Errors and submitting state of the closest `Form`, or null outside one. */
15
+ export declare function useForm(): FormContextValue | null;
16
+ export interface FormProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'onInvalid'> {
17
+ /**
18
+ * Extra checks after the browser's own (`required`, `type`, `pattern`, `min`…).
19
+ * Return `{ fieldName: message }`; empty values mean valid. Plug zod or any
20
+ * schema library in here.
21
+ */
22
+ validate?: (data: FormData) => Record<string, string | null | undefined | false> | void;
23
+ /** Called with the form data only when every field is valid. A promise sets `submitting`. */
24
+ onSubmit?: (data: FormData, event: React.FormEvent<HTMLFormElement>) => void | Promise<unknown>;
25
+ /** Called after a failed submit, in field order; pass the list to ErrorSummary. */
26
+ onInvalidSubmit?: (errors: FormErrorItem[]) => void;
27
+ }
28
+ /**
29
+ * A `<form>` that validates on submit with the browser's constraint validation
30
+ * plus your `validate`, shows the messages through FormField, and focuses the
31
+ * first invalid field. A field's error clears as soon as it is edited.
32
+ */
33
+ export declare const Form: React.ForwardRefExoticComponent<FormProps & React.RefAttributes<HTMLFormElement>>;
@@ -0,0 +1,79 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { cn } from "../../utils/cn.js";
5
+ const FormContext = React.createContext(null);
6
+ function useForm() {
7
+ return React.useContext(FormContext);
8
+ }
9
+ const isControl = (el) => "validity" in el && "name" in el && Boolean(el.name);
10
+ const Form = React.forwardRef(function Form2({ validate, onSubmit, onInvalidSubmit, onInput, className, children, ...props }, ref) {
11
+ const [errors, setErrors] = React.useState({});
12
+ const [submitting, setSubmitting] = React.useState(false);
13
+ const handleSubmit = async (event) => {
14
+ event.preventDefault();
15
+ if (submitting) return;
16
+ const form = event.currentTarget;
17
+ const data = new FormData(form);
18
+ const controls = Array.from(form.elements).filter(isControl).filter((el) => !el.disabled);
19
+ const next = {};
20
+ for (const el of controls) {
21
+ if (!el.validity.valid && !next[el.name]) next[el.name] = el.validationMessage;
22
+ }
23
+ for (const [name, message] of Object.entries(validate?.(data) ?? {})) {
24
+ if (message && !next[name]) next[name] = message;
25
+ }
26
+ setErrors(next);
27
+ if (Object.keys(next).length) {
28
+ const seen = /* @__PURE__ */ new Set();
29
+ const items = [];
30
+ for (const el of controls) {
31
+ if (next[el.name] && !seen.has(el.name)) {
32
+ seen.add(el.name);
33
+ items.push({ name: el.name, id: el.id || el.name, message: next[el.name] });
34
+ }
35
+ }
36
+ for (const name of Object.keys(next)) {
37
+ if (!seen.has(name)) items.push({ name, id: name, message: next[name] });
38
+ }
39
+ onInvalidSubmit?.(items);
40
+ const first = controls.find((el) => next[el.name] && el.type !== "hidden");
41
+ first?.focus();
42
+ return;
43
+ }
44
+ const result = onSubmit?.(data, event);
45
+ if (result instanceof Promise) {
46
+ setSubmitting(true);
47
+ try {
48
+ await result;
49
+ } finally {
50
+ setSubmitting(false);
51
+ }
52
+ }
53
+ };
54
+ const handleInput = (event) => {
55
+ onInput?.(event);
56
+ const name = event.target.name;
57
+ if (name && errors[name]) {
58
+ setErrors(({ [name]: _cleared, ...rest }) => rest);
59
+ }
60
+ };
61
+ const value = React.useMemo(() => ({ errors, submitting }), [errors, submitting]);
62
+ return /* @__PURE__ */ jsx(FormContext.Provider, { value, children: /* @__PURE__ */ jsx(
63
+ "form",
64
+ {
65
+ ref,
66
+ noValidate: true,
67
+ "aria-busy": submitting || void 0,
68
+ className: cn("flex flex-col gap-5", className),
69
+ onSubmit: handleSubmit,
70
+ onInput: handleInput,
71
+ ...props,
72
+ children
73
+ }
74
+ ) });
75
+ });
76
+ export {
77
+ Form,
78
+ useForm
79
+ };
@@ -0,0 +1,2 @@
1
+ export { Form, useForm } from './Form.js';
2
+ export type { FormProps, FormErrorItem, FormErrors, FormContextValue } from './Form.js';
@@ -0,0 +1,5 @@
1
+ import { Form, useForm } from "./Form.js";
2
+ export {
3
+ Form,
4
+ useForm
5
+ };
@@ -10,6 +10,8 @@ export interface FormFieldContextValue {
10
10
  disabled?: boolean;
11
11
  descriptionId: string;
12
12
  errorId: string;
13
+ /** The message from the enclosing Form for this field's `name`. */
14
+ formError?: string;
13
15
  }
14
16
  export interface FormFieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
15
17
  id?: string;
@@ -5,6 +5,7 @@ import { cn } from "../../utils/cn.js";
5
5
  import { Label } from "../Label/Label.js";
6
6
  import { HelperText } from "../HelperText/HelperText.js";
7
7
  import { FormError } from "../FormError/FormError.js";
8
+ import { useForm } from "../Form/Form.js";
8
9
  const FormFieldContext = React.createContext(null);
9
10
  function useFormField() {
10
11
  const ctx = React.useContext(FormFieldContext);
@@ -13,9 +14,12 @@ function useFormField() {
13
14
  }
14
15
  return ctx;
15
16
  }
16
- const Root = React.forwardRef(function Root2({ id: idProp, name, invalid, required, disabled, className, children, ...props }, ref) {
17
+ const Root = React.forwardRef(function Root2({ id: idProp, name, invalid: invalidProp, required, disabled, className, children, ...props }, ref) {
17
18
  const generatedId = React.useId();
18
19
  const id = idProp ?? generatedId;
20
+ const form = useForm();
21
+ const formError = name ? form?.errors[name] : void 0;
22
+ const invalid = invalidProp ?? (formError ? true : void 0);
19
23
  const value = React.useMemo(
20
24
  () => ({
21
25
  id,
@@ -24,9 +28,10 @@ const Root = React.forwardRef(function Root2({ id: idProp, name, invalid, requir
24
28
  required,
25
29
  disabled,
26
30
  descriptionId: `${id}-description`,
27
- errorId: `${id}-error`
31
+ errorId: `${id}-error`,
32
+ formError
28
33
  }),
29
- [id, name, invalid, required, disabled]
34
+ [id, name, invalid, required, disabled, formError]
30
35
  );
31
36
  return /* @__PURE__ */ jsx(FormFieldContext.Provider, { value, children: /* @__PURE__ */ jsx("div", { ref, className: cn("flex flex-col gap-1.5", className), ...props, children }) });
32
37
  });
@@ -79,9 +84,9 @@ function FieldError({
79
84
  children,
80
85
  ...props
81
86
  }) {
82
- const { errorId, invalid } = useFormField();
87
+ const { errorId, invalid, formError } = useFormField();
83
88
  if (!invalid) return null;
84
- return /* @__PURE__ */ jsx(FormError, { id: errorId, className, ...props, children });
89
+ return /* @__PURE__ */ jsx(FormError, { id: errorId, className, ...props, children: children ?? formError });
85
90
  }
86
91
  FieldError.displayName = "FormField.Error";
87
92
  const FormField = { Root, Label: FieldLabel, Control, Description, Error: FieldError };
@@ -0,0 +1,21 @@
1
+ import * as React from 'react';
2
+ export interface InlineEditProps {
3
+ value: string;
4
+ /**
5
+ * Called with the new text on Enter or blur (only when it changed). Return a
6
+ * promise to keep the field open and busy until it settles; a rejection keeps
7
+ * it open and marks it invalid so the person can retry or press Escape.
8
+ */
9
+ onSave: (value: string) => void | Promise<unknown>;
10
+ /** Names the field: the input's label and "Edit ‹label›" on the button. */
11
+ label: string;
12
+ /** Shown when `value` is empty. */
13
+ placeholder?: string;
14
+ disabled?: boolean;
15
+ className?: string;
16
+ }
17
+ /**
18
+ * Text that turns into an input on click. Enter or blur saves, Escape reverts,
19
+ * and focus returns to the text afterwards.
20
+ */
21
+ export declare function InlineEdit({ value, onSave, label, placeholder, disabled, className, }: InlineEditProps): React.JSX.Element;
@@ -0,0 +1,103 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { cn } from "../../utils/cn.js";
5
+ import { Input } from "../Input/Input.js";
6
+ import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
7
+ function InlineEdit({
8
+ value,
9
+ onSave,
10
+ label,
11
+ placeholder,
12
+ disabled,
13
+ className
14
+ }) {
15
+ const t = useNexubMessages();
16
+ const [editing, setEditing] = React.useState(false);
17
+ const [draft, setDraft] = React.useState(value);
18
+ const [saving, setSaving] = React.useState(false);
19
+ const [failed, setFailed] = React.useState(false);
20
+ const buttonRef = React.useRef(null);
21
+ const returnFocus = React.useRef(false);
22
+ React.useEffect(() => {
23
+ if (!editing && returnFocus.current) {
24
+ returnFocus.current = false;
25
+ buttonRef.current?.focus();
26
+ }
27
+ }, [editing]);
28
+ const close = (refocus) => {
29
+ returnFocus.current = refocus;
30
+ setFailed(false);
31
+ setEditing(false);
32
+ };
33
+ const save = async (refocus) => {
34
+ if (saving) return;
35
+ if (draft === value) return close(refocus);
36
+ const result = onSave(draft);
37
+ if (!(result instanceof Promise)) return close(refocus);
38
+ setSaving(true);
39
+ try {
40
+ await result;
41
+ close(refocus);
42
+ } catch {
43
+ setFailed(true);
44
+ } finally {
45
+ setSaving(false);
46
+ }
47
+ };
48
+ if (editing) {
49
+ return /* @__PURE__ */ jsx(
50
+ Input,
51
+ {
52
+ autoFocus: true,
53
+ "aria-label": label,
54
+ value: draft,
55
+ invalid: failed,
56
+ "aria-busy": saving || void 0,
57
+ readOnly: saving,
58
+ wrapperClassName: className,
59
+ onChange: (e) => {
60
+ setDraft(e.target.value);
61
+ setFailed(false);
62
+ },
63
+ onKeyDown: (e) => {
64
+ if (e.key === "Enter") {
65
+ e.preventDefault();
66
+ void save(true);
67
+ } else if (e.key === "Escape") {
68
+ e.preventDefault();
69
+ e.stopPropagation();
70
+ close(true);
71
+ }
72
+ },
73
+ onBlur: () => {
74
+ if (!failed) void save(false);
75
+ }
76
+ }
77
+ );
78
+ }
79
+ return /* @__PURE__ */ jsx(
80
+ "button",
81
+ {
82
+ ref: buttonRef,
83
+ type: "button",
84
+ disabled,
85
+ "aria-label": `${t.editField(label)}, ${value || placeholder || ""}`,
86
+ onClick: () => {
87
+ setDraft(value);
88
+ setEditing(true);
89
+ },
90
+ className: cn(
91
+ "-mx-2 inline-flex min-h-control-sm max-w-full items-center rounded-shape-control px-2 text-start",
92
+ "text-body-md text-[var(--color-on-surface)] hover:bg-[var(--color-surface-container-high)]",
93
+ "disabled:cursor-not-allowed disabled:hover:bg-transparent",
94
+ !value && "text-[var(--color-on-surface-muted)]",
95
+ className
96
+ ),
97
+ children: /* @__PURE__ */ jsx("span", { className: "truncate", children: value || placeholder })
98
+ }
99
+ );
100
+ }
101
+ export {
102
+ InlineEdit
103
+ };
@@ -0,0 +1,2 @@
1
+ export { InlineEdit } from './InlineEdit.js';
2
+ export type { InlineEditProps } from './InlineEdit.js';
@@ -0,0 +1,4 @@
1
+ import { InlineEdit } from "./InlineEdit.js";
2
+ export {
3
+ InlineEdit
4
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Asks the browser to confirm before the page unloads (reload, close, external
3
+ * link) while `dirty` is true. In-app navigation belongs to your router: block
4
+ * it there with the same flag.
5
+ */
6
+ export declare function useUnsavedChanges(dirty: boolean): void;
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { useEffect } from "react";
3
+ function useUnsavedChanges(dirty) {
4
+ useEffect(() => {
5
+ if (!dirty) return;
6
+ const onBeforeUnload = (event) => {
7
+ event.preventDefault();
8
+ };
9
+ window.addEventListener("beforeunload", onBeforeUnload);
10
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
11
+ }, [dirty]);
12
+ }
13
+ export {
14
+ useUnsavedChanges
15
+ };
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { useDisclosure } from './hooks/useDisclosure.js';
7
7
  export { useIsomorphicLayoutEffect } from './hooks/useIsomorphicLayoutEffect.js';
8
8
  export { useMediaQuery } from './hooks/useMediaQuery.js';
9
9
  export { useMergedRefs } from './hooks/useMergedRefs.js';
10
+ export { useUnsavedChanges } from './hooks/useUnsavedChanges.js';
10
11
  export { usePointerSheen } from './hooks/usePointerSheen.js';
11
12
  export { PromptInput } from './components/PromptInput/index.js';
12
13
  export type { PromptInputProps, PromptInputMessages, PromptInputStatus } from './components/PromptInput/index.js';
@@ -213,6 +214,12 @@ export type { ErrorStateProps } from './components/ErrorState/index.js';
213
214
  export { FileUpload, fileAccepted } from './components/FileUpload/index.js';
214
215
  export type { FileUploadRootProps, FileUploadItemProps, FileRejection } from './components/FileUpload/index.js';
215
216
  export { OTPInput } from './components/OTPInput/index.js';
217
+ export { Form, useForm } from './components/Form/index.js';
218
+ export type { FormProps, FormErrorItem, FormErrors, FormContextValue } from './components/Form/index.js';
219
+ export { Fieldset } from './components/Fieldset/index.js';
220
+ export type { FieldsetProps } from './components/Fieldset/index.js';
221
+ export { InlineEdit } from './components/InlineEdit/index.js';
222
+ export type { InlineEditProps } from './components/InlineEdit/index.js';
216
223
  export type { OTPInputProps } from './components/OTPInput/index.js';
217
224
  export { DatePicker } from './components/DatePicker/index.js';
218
225
  export type { DatePickerProps } from './components/DatePicker/index.js';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { useDisclosure } from "./hooks/useDisclosure.js";
6
6
  import { useIsomorphicLayoutEffect } from "./hooks/useIsomorphicLayoutEffect.js";
7
7
  import { useMediaQuery } from "./hooks/useMediaQuery.js";
8
8
  import { useMergedRefs } from "./hooks/useMergedRefs.js";
9
+ import { useUnsavedChanges } from "./hooks/useUnsavedChanges.js";
9
10
  import { usePointerSheen } from "./hooks/usePointerSheen.js";
10
11
  import { PromptInput } from "./components/PromptInput/index.js";
11
12
  import { ThinkingIndicator } from "./components/ThinkingIndicator/index.js";
@@ -120,6 +121,9 @@ import { EmptyState, EmptyStatePreset } from "./components/EmptyState/index.js";
120
121
  import { ErrorState } from "./components/ErrorState/index.js";
121
122
  import { FileUpload, fileAccepted } from "./components/FileUpload/index.js";
122
123
  import { OTPInput } from "./components/OTPInput/index.js";
124
+ import { Form, useForm } from "./components/Form/index.js";
125
+ import { Fieldset } from "./components/Fieldset/index.js";
126
+ import { InlineEdit } from "./components/InlineEdit/index.js";
123
127
  import { DatePicker } from "./components/DatePicker/index.js";
124
128
  import { DateRangePicker } from "./components/DateRangePicker/index.js";
125
129
  import { todayIn } from "./components/DateRangePicker/index.js";
@@ -175,6 +179,7 @@ export {
175
179
  ErrorState,
176
180
  EyebrowChip,
177
181
  Fade,
182
+ Fieldset,
178
183
  FileUpload,
179
184
  FilterBuilder,
180
185
  FilterChip,
@@ -182,6 +187,7 @@ export {
182
187
  Flex,
183
188
  Float,
184
189
  FloatingInput,
190
+ Form,
185
191
  FormError,
186
192
  FormField,
187
193
  Funnel,
@@ -193,6 +199,7 @@ export {
193
199
  HelperText,
194
200
  HoverCard,
195
201
  IconButton,
202
+ InlineEdit,
196
203
  Input,
197
204
  Kbd,
198
205
  KeyboardShortcut,
@@ -312,6 +319,7 @@ export {
312
319
  transitionName,
313
320
  useControllableState,
314
321
  useDisclosure,
322
+ useForm,
315
323
  useIsomorphicLayoutEffect,
316
324
  useLocale,
317
325
  useMediaQuery,
@@ -321,5 +329,6 @@ export {
321
329
  usePrefersReducedMotion,
322
330
  useScopedSeed,
323
331
  useTheme,
324
- useToastStore
332
+ useToastStore,
333
+ useUnsavedChanges
325
334
  };