@openzeppelin/ui-components 3.1.0 → 3.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/dist/index.cjs CHANGED
@@ -38,7 +38,7 @@ let sonner = require("sonner");
38
38
  let next_themes = require("next-themes");
39
39
 
40
40
  //#region src/version.ts
41
- const VERSION = "3.1.0";
41
+ const VERSION = "3.2.0";
42
42
 
43
43
  //#endregion
44
44
  //#region src/components/ui/accordion.tsx
@@ -3057,6 +3057,10 @@ TextAreaField.displayName = "TextAreaField";
3057
3057
  //#region src/components/fields/AddressListField/labels.ts
3058
3058
  /** Default English labels for {@link AddressListField}. */
3059
3059
  const DEFAULT_ADDRESS_LIST_FIELD_LABELS = {
3060
+ addSingleAddress: "Add",
3061
+ invalidSingleAddress: "Enter a valid address",
3062
+ enableBulkEntry: "Bulk paste",
3063
+ enableSingleEntry: "Single entry",
3060
3064
  addAddresses: "Add addresses",
3061
3065
  addAddressCount: (count) => `Add ${count} address${count === 1 ? "" : "es"}`,
3062
3066
  invalidPrefix: "Invalid:",
@@ -3109,41 +3113,9 @@ function formatAddressBulkSummary(classification, addedCount, labels) {
3109
3113
  }
3110
3114
 
3111
3115
  //#endregion
3112
- //#region src/components/fields/AddressListField/AddressListField.tsx
3113
- /**
3114
- * Multi-address list field for bulk paste workflows.
3115
- *
3116
- * Architecture flow:
3117
- * 1. App or wizard steps hold the committed address list in local or form state
3118
- * 2. AddressListField renders a draft textarea plus the committed list rows
3119
- * 3. Parsing and classification run via `@openzeppelin/ui-utils` helpers
3120
- * 4. Optional {@link AddressingCapability} validates candidates before add
3121
- * 5. {@link AddressDisplay} renders each committed row with copy and explorer links
3122
- *
3123
- * The component includes:
3124
- * - Delimiter-aware bulk paste (newlines, commas, semicolons)
3125
- * - Live preview of accepted, invalid, duplicate, and already-listed candidates
3126
- * - Optional `maxItems` cap with disabled input at the limit
3127
- * - Removable list rows with accessible remove buttons
3128
- * - Caller-controlled copy for placeholders and format hints
3129
- * - Optional `labels` overrides for built-in button and status strings
3130
- *
3131
- * @example
3132
- * ```tsx
3133
- * const [addresses, setAddresses] = useState<string[]>([]);
3134
- *
3135
- * <AddressListField
3136
- * value={addresses}
3137
- * onChange={setAddresses}
3138
- * placeholder="Paste addresses (one per line or comma-separated)"
3139
- * formatHint="Each entry is validated before it is added."
3140
- * addressing={capabilities}
3141
- * />
3142
- * ```
3143
- */
3144
- function AddressListField({ value, onChange, placeholder, formatHint, addressing, getExplorerUrl, label, helperText, maxItems, disabled = false, maxInvalidPreview = 3, className, labels: labelOverrides }) {
3145
- const fieldId = (0, react.useId)();
3146
- const labels = (0, react.useMemo)(() => resolveAddressListFieldLabels(labelOverrides), [labelOverrides]);
3116
+ //#region src/components/fields/AddressListField/AddressListBulkEntry.tsx
3117
+ /** Bulk paste entry — info callout, textarea, and add action in a compact stack. */
3118
+ function AddressListBulkEntry({ fieldId, value, onAdd, placeholder, formatHint, addressing, maxItems, disabled = false, maxInvalidPreview = 3, labels, modeToggle }) {
3147
3119
  const atLimit = maxItems != null && value.length >= maxItems;
3148
3120
  const inputDisabled = disabled || atLimit;
3149
3121
  const [feedback, setFeedback] = (0, react.useState)(null);
@@ -3162,97 +3134,270 @@ function AddressListField({ value, onChange, placeholder, formatHint, addressing
3162
3134
  maxItems
3163
3135
  ]);
3164
3136
  const previewSummary = (0, react.useMemo)(() => buildAddressListPreviewSummary(classification, labels), [classification, labels]);
3165
- const inlineHelperText = rawInput.trim() ? previewSummary ?? void 0 : feedback ?? void 0;
3137
+ const statusMessage = rawInput.trim() ? previewSummary ?? void 0 : feedback ?? void 0;
3166
3138
  const handleAdd = (0, react.useCallback)(() => {
3167
3139
  if (!classification || classification.accepted.length === 0 || atLimit) return;
3168
- onChange([...value, ...classification.accepted]);
3140
+ onAdd(classification.accepted);
3169
3141
  reset({ input: "" });
3170
3142
  setFeedback(formatAddressBulkSummary(classification, classification.accepted.length, labels));
3171
3143
  }, [
3172
3144
  classification,
3173
3145
  atLimit,
3174
- onChange,
3175
- value,
3146
+ onAdd,
3176
3147
  reset,
3177
3148
  labels
3178
3149
  ]);
3179
- const handleRemove = (0, react.useCallback)((index) => {
3180
- onChange(value.filter((_, currentIndex) => currentIndex !== index));
3181
- setFeedback(null);
3182
- }, [onChange, value]);
3183
3150
  const acceptedCount = classification?.accepted.length ?? 0;
3184
3151
  const addButtonLabel = acceptedCount > 0 ? labels.addAddressCount(acceptedCount) : labels.addAddresses;
3185
3152
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3186
- className: (0, _openzeppelin_ui_utils.cn)("space-y-3", className),
3153
+ className: "space-y-3",
3187
3154
  children: [
3188
3155
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3189
- className: "space-y-1",
3190
- children: [helperText ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3191
- className: "text-xs text-muted-foreground",
3192
- children: helperText
3193
- }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3194
- className: "text-xs text-muted-foreground",
3156
+ className: "flex items-start gap-2 rounded-lg border border-border/70 bg-muted/30 px-3 py-2.5",
3157
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Info, { className: "mt-0.5 size-4 shrink-0 text-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3158
+ className: "text-xs leading-relaxed text-muted-foreground",
3195
3159
  children: formatHint
3196
3160
  })]
3197
3161
  }),
3198
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextAreaField, {
3199
- id: `address-list-field-${fieldId}`,
3200
- name: "input",
3201
- label: label ?? "",
3202
- placeholder,
3203
- helperText: inlineHelperText,
3204
- control,
3205
- rows: 4,
3206
- validation: { required: false },
3207
- readOnly: inputDisabled
3208
- }),
3209
- classification && classification.invalid.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
3210
- className: "text-xs text-destructive",
3211
- children: [
3212
- labels.invalidPrefix,
3213
- " ",
3214
- classification.invalid.slice(0, maxInvalidPreview).join(", "),
3215
- classification.invalid.length > maxInvalidPreview ? ` ${labels.invalidMore(classification.invalid.length - maxInvalidPreview)}` : ""
3216
- ]
3217
- }),
3218
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3162
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3163
+ className: "relative",
3164
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextAreaField, {
3165
+ id: `address-list-bulk-${fieldId}`,
3166
+ name: "input",
3167
+ label: "",
3168
+ placeholder,
3169
+ helperText: statusMessage,
3170
+ control,
3171
+ rows: 4,
3172
+ validation: { required: false },
3173
+ readOnly: inputDisabled
3174
+ })
3175
+ }), modeToggle ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3176
+ className: "flex justify-end pr-2",
3177
+ children: modeToggle
3178
+ }) : null] }),
3179
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3180
+ className: "flex flex-wrap items-center justify-between gap-2",
3181
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3182
+ className: "min-w-0 flex-1",
3183
+ children: [classification && classification.invalid.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
3184
+ className: "text-xs text-destructive",
3185
+ children: [
3186
+ labels.invalidPrefix,
3187
+ " ",
3188
+ classification.invalid.slice(0, maxInvalidPreview).join(", "),
3189
+ classification.invalid.length > maxInvalidPreview ? ` ${labels.invalidMore(classification.invalid.length - maxInvalidPreview)}` : ""
3190
+ ]
3191
+ }) : null, atLimit && maxItems != null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3192
+ className: "text-xs text-muted-foreground",
3193
+ children: labels.maxItemsReached(maxItems)
3194
+ }) : null]
3195
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
3196
+ type: "button",
3197
+ size: "sm",
3198
+ className: "shrink-0",
3199
+ disabled: inputDisabled || acceptedCount === 0,
3200
+ onClick: () => handleAdd(),
3201
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Plus, { className: "mr-1 size-4" }), addButtonLabel]
3202
+ })]
3203
+ })
3204
+ ]
3205
+ });
3206
+ }
3207
+
3208
+ //#endregion
3209
+ //#region src/components/fields/AddressListField/AddressListEntries.tsx
3210
+ /** Renders committed addresses with remove actions. Shared by single and bulk entry modes. */
3211
+ function AddressListEntries({ value, getExplorerUrl, disabled = false, labels, onRemove }) {
3212
+ if (value.length === 0) return null;
3213
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3214
+ className: "space-y-1",
3215
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3216
+ className: "text-xs text-muted-foreground",
3217
+ children: labels.addressesAdded(value.length)
3218
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3219
+ className: "max-h-44 space-y-1 overflow-y-auto rounded-md border border-border/70 p-1",
3220
+ children: value.map((address, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3221
+ className: "flex items-center justify-between rounded bg-muted p-2",
3222
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressDisplay, {
3223
+ address,
3224
+ variant: "inline",
3225
+ truncateWhenLabeled: true,
3226
+ showCopyButton: true,
3227
+ explorerUrl: getExplorerUrl?.(address) ?? void 0
3228
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3229
+ type: "button",
3230
+ variant: "ghost",
3231
+ size: "sm",
3232
+ className: "size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive",
3233
+ onClick: () => onRemove(index),
3234
+ disabled,
3235
+ "aria-label": labels.removeAddress(index),
3236
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.X, { className: "size-3.5" })
3237
+ })]
3238
+ }, `${address}-${index}`))
3239
+ })]
3240
+ });
3241
+ }
3242
+
3243
+ //#endregion
3244
+ //#region src/components/fields/AddressListField/AddressListSingleEntry.tsx
3245
+ /** Single-address entry — inline {@link AddressField} with an Add button (address-book aware). */
3246
+ function AddressListSingleEntry({ fieldId, value, onAdd, placeholder, addressing, maxItems, disabled = false, labels, modeToggle }) {
3247
+ const atLimit = maxItems != null && value.length >= maxItems;
3248
+ const inputDisabled = disabled || atLimit;
3249
+ const { control, reset, getValues, handleSubmit } = (0, react_hook_form.useForm)({
3250
+ defaultValues: { draft: "" },
3251
+ mode: "onChange"
3252
+ });
3253
+ const trimmedDraft = ((0, react_hook_form.useWatch)({
3254
+ control,
3255
+ name: "draft"
3256
+ }) ?? "").trim();
3257
+ const validationState = (0, react.useMemo)(() => {
3258
+ if (!trimmedDraft) return {
3259
+ canAdd: false,
3260
+ showInvalid: false
3261
+ };
3262
+ if (value.includes(trimmedDraft)) return {
3263
+ canAdd: false,
3264
+ showInvalid: false
3265
+ };
3266
+ if (atLimit) return {
3267
+ canAdd: false,
3268
+ showInvalid: false
3269
+ };
3270
+ if (addressing && !addressing.isValidAddress(trimmedDraft)) return {
3271
+ canAdd: false,
3272
+ showInvalid: true
3273
+ };
3274
+ return {
3275
+ canAdd: true,
3276
+ showInvalid: false
3277
+ };
3278
+ }, [
3279
+ trimmedDraft,
3280
+ value,
3281
+ atLimit,
3282
+ addressing
3283
+ ]);
3284
+ const submitAdd = (0, react.useCallback)(() => {
3285
+ if (!validationState.canAdd) return;
3286
+ onAdd(getValues("draft").trim());
3287
+ reset({ draft: "" });
3288
+ }, [
3289
+ validationState.canAdd,
3290
+ onAdd,
3291
+ getValues,
3292
+ reset
3293
+ ]);
3294
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3295
+ className: "space-y-1",
3296
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3297
+ className: "flex items-start gap-2",
3298
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3299
+ className: "min-w-0 flex-1",
3300
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressField, {
3301
+ id: `address-list-single-${fieldId}`,
3302
+ name: "draft",
3303
+ label: "",
3304
+ placeholder,
3305
+ helperText: validationState.showInvalid ? labels.invalidSingleAddress : void 0,
3306
+ control,
3307
+ addressing,
3308
+ validation: { required: false },
3309
+ readOnly: inputDisabled
3310
+ }), modeToggle ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3311
+ className: "-mt-px flex justify-end pr-2",
3312
+ children: modeToggle
3313
+ }) : null]
3314
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
3219
3315
  type: "button",
3220
- size: "sm",
3221
- disabled: inputDisabled || acceptedCount === 0,
3222
- onClick: () => handleAdd(),
3223
- children: addButtonLabel
3224
- }),
3225
- atLimit && maxItems != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3226
- className: "text-xs text-muted-foreground",
3227
- children: labels.maxItemsReached(maxItems)
3316
+ className: "h-10 shrink-0",
3317
+ disabled: inputDisabled || !validationState.canAdd,
3318
+ onClick: handleSubmit(submitAdd),
3319
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Plus, { className: "mr-1 size-4" }), labels.addSingleAddress]
3320
+ })]
3321
+ }), atLimit && maxItems != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3322
+ className: "text-xs text-muted-foreground",
3323
+ children: labels.maxItemsReached(maxItems)
3324
+ })]
3325
+ });
3326
+ }
3327
+
3328
+ //#endregion
3329
+ //#region src/components/fields/AddressListField/AddressListField.tsx
3330
+ /**
3331
+ * Multi-address list field with single-entry default and optional bulk paste.
3332
+ */
3333
+ function AddressListField({ value, onChange, placeholder, bulkPlaceholder, formatHint, addressing, getExplorerUrl, label, helperText, maxItems, disabled = false, maxInvalidPreview = 3, className, defaultEntryMode = "single", allowModeToggle = true, labels: labelOverrides }) {
3334
+ const fieldId = (0, react.useId)();
3335
+ const labels = (0, react.useMemo)(() => resolveAddressListFieldLabels(labelOverrides), [labelOverrides]);
3336
+ const [entryMode, setEntryMode] = (0, react.useState)(defaultEntryMode);
3337
+ const resolvedBulkPlaceholder = bulkPlaceholder ?? placeholder;
3338
+ const handleRemove = (0, react.useCallback)((index) => {
3339
+ onChange(value.filter((_, currentIndex) => currentIndex !== index));
3340
+ }, [onChange, value]);
3341
+ const handleAddSingle = (0, react.useCallback)((address) => {
3342
+ onChange([...value, address]);
3343
+ }, [onChange, value]);
3344
+ const handleAddBulk = (0, react.useCallback)((addresses) => {
3345
+ onChange([...value, ...addresses]);
3346
+ }, [onChange, value]);
3347
+ const switchToBulk = (0, react.useCallback)(() => setEntryMode("bulk"), []);
3348
+ const switchToSingle = (0, react.useCallback)(() => setEntryMode("single"), []);
3349
+ const toggleClassName = "inline-flex items-center gap-1 rounded-b-md rounded-t-none border border-t-0 border-input bg-muted/40 px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
3350
+ const modeToggle = !allowModeToggle ? void 0 : entryMode === "single" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3351
+ type: "button",
3352
+ onClick: switchToBulk,
3353
+ className: toggleClassName,
3354
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ClipboardPaste, { className: "size-3" }), labels.enableBulkEntry]
3355
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3356
+ type: "button",
3357
+ onClick: switchToSingle,
3358
+ className: toggleClassName,
3359
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.PencilLine, { className: "size-3" }), labels.enableSingleEntry]
3360
+ });
3361
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3362
+ className: (0, _openzeppelin_ui_utils.cn)("space-y-3", className),
3363
+ children: [
3364
+ label ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Label, {
3365
+ className: "text-sm font-medium leading-none",
3366
+ children: label
3367
+ }) : null,
3368
+ helperText && entryMode === "single" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3369
+ className: "text-sm leading-relaxed text-muted-foreground",
3370
+ children: helperText
3371
+ }) : null,
3372
+ entryMode === "single" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressListSingleEntry, {
3373
+ fieldId,
3374
+ value,
3375
+ onAdd: handleAddSingle,
3376
+ placeholder,
3377
+ addressing,
3378
+ maxItems,
3379
+ disabled,
3380
+ labels,
3381
+ modeToggle
3382
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressListBulkEntry, {
3383
+ fieldId,
3384
+ value,
3385
+ onAdd: handleAddBulk,
3386
+ placeholder: resolvedBulkPlaceholder,
3387
+ formatHint,
3388
+ addressing,
3389
+ maxItems,
3390
+ disabled,
3391
+ maxInvalidPreview,
3392
+ labels,
3393
+ modeToggle
3228
3394
  }),
3229
- value.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3230
- className: "space-y-1",
3231
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3232
- className: "text-xs text-muted-foreground",
3233
- children: labels.addressesAdded(value.length)
3234
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3235
- className: "max-h-44 space-y-1 overflow-y-auto rounded-md border border-border/70 p-1",
3236
- children: value.map((address, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3237
- className: "flex items-center justify-between rounded bg-muted p-2",
3238
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressDisplay, {
3239
- address,
3240
- variant: "inline",
3241
- truncateWhenLabeled: true,
3242
- showCopyButton: true,
3243
- explorerUrl: getExplorerUrl?.(address) ?? void 0
3244
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3245
- type: "button",
3246
- variant: "ghost",
3247
- size: "sm",
3248
- className: "size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive",
3249
- onClick: () => handleRemove(index),
3250
- disabled,
3251
- "aria-label": labels.removeAddress(index),
3252
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.X, { className: "size-3.5" })
3253
- })]
3254
- }, `${address}-${index}`))
3255
- })]
3395
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressListEntries, {
3396
+ value,
3397
+ getExplorerUrl,
3398
+ disabled,
3399
+ labels,
3400
+ onRemove: handleRemove
3256
3401
  })
3257
3402
  ]
3258
3403
  });