@lerianstudio/sindarian-ui 2.0.0-beta.2 → 2.0.0-beta.20
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/LICENSE +93 -0
- package/README.md +52 -1
- package/dist/components/breadcrumb/index.d.ts.map +1 -1
- package/dist/components/breadcrumb/index.js +25 -1
- package/dist/components/card/account-card/index.js +5 -5
- package/dist/components/card/basic-card/index.d.ts.map +1 -1
- package/dist/components/card/basic-card/index.js +2 -2
- package/dist/components/card/entity-card/index.js +1 -1
- package/dist/components/dialog/confirmation-dialog/index.d.ts.map +1 -1
- package/dist/components/dialog/confirmation-dialog/index.js +4 -1
- package/dist/components/entity-box/index.d.ts +18 -2
- package/dist/components/entity-box/index.d.ts.map +1 -1
- package/dist/components/entity-box/index.js +32 -4
- package/dist/components/form/date-picker-field/index.js +1 -1
- package/dist/components/form/date-range-field/index.js +1 -1
- package/dist/components/form/date-time-picker-field/index.js +1 -1
- package/dist/components/form/password-field/index.d.ts +5 -1
- package/dist/components/form/password-field/index.d.ts.map +1 -1
- package/dist/components/form/password-field/index.js +2 -2
- package/dist/components/form/select-field/index.d.ts +15 -1
- package/dist/components/form/select-field/index.d.ts.map +1 -1
- package/dist/components/form/select-field/index.js +28 -3
- package/dist/components/form/switch-field/index.d.ts.map +1 -1
- package/dist/components/form/switch-field/index.js +6 -1
- package/dist/components/page/index.d.ts +35 -1
- package/dist/components/page/index.d.ts.map +1 -1
- package/dist/components/page/index.js +6 -2
- package/dist/components/page-header/index.js +1 -1
- package/dist/components/table/id-table-cell.d.ts +13 -1
- package/dist/components/table/id-table-cell.d.ts.map +1 -1
- package/dist/components/table/id-table-cell.js +99 -9
- package/dist/components/table/index.d.ts +1 -0
- package/dist/components/table/index.d.ts.map +1 -1
- package/dist/components/table/index.js +1 -0
- package/dist/components/table/locked-table-actions.d.ts +24 -0
- package/dist/components/table/locked-table-actions.d.ts.map +1 -1
- package/dist/components/table/locked-table-actions.js +31 -1
- package/dist/components/table/name-table-cell.d.ts +57 -4
- package/dist/components/table/name-table-cell.d.ts.map +1 -1
- package/dist/components/table/name-table-cell.js +50 -2
- package/dist/components/ui/autocomplete/index.d.ts +24 -0
- package/dist/components/ui/autocomplete/index.d.ts.map +1 -1
- package/dist/components/ui/autocomplete/index.js +24 -0
- package/dist/components/ui/badge/index.d.ts +11 -7
- package/dist/components/ui/badge/index.d.ts.map +1 -1
- package/dist/components/ui/badge/index.js +20 -8
- package/dist/components/ui/button/index.d.ts +3 -3
- package/dist/components/ui/copy-field/index.d.ts +25 -1
- package/dist/components/ui/copy-field/index.d.ts.map +1 -1
- package/dist/components/ui/copy-field/index.js +16 -8
- package/dist/components/ui/dialog/index.d.ts +1 -1
- package/dist/components/ui/dialog/index.d.ts.map +1 -1
- package/dist/components/ui/dialog/index.js +2 -2
- package/dist/components/ui/file-upload/index.d.ts +159 -0
- package/dist/components/ui/file-upload/index.d.ts.map +1 -1
- package/dist/components/ui/file-upload/index.js +308 -6
- package/dist/components/ui/icon-button/index.d.ts +1 -1
- package/dist/components/ui/icon-button/styles.css +42 -0
- package/dist/components/ui/input/styles.css +79 -4
- package/dist/components/ui/otp-input/index.js +1 -1
- package/dist/components/ui/radio-group/index.js +1 -1
- package/dist/components/ui/select/index.d.ts.map +1 -1
- package/dist/components/ui/select/index.js +8 -4
- package/dist/components/ui/select/styles.css +1 -1
- package/dist/components/ui/sheet/index.d.ts +42 -0
- package/dist/components/ui/sheet/index.d.ts.map +1 -1
- package/dist/components/ui/sheet/index.js +47 -14
- package/dist/components/ui/sidebar/index.d.ts +1 -0
- package/dist/components/ui/sidebar/index.d.ts.map +1 -1
- package/dist/components/ui/sidebar/index.js +1 -0
- package/dist/components/ui/sidebar/sidebar-expand-button.d.ts +1 -1
- package/dist/components/ui/sidebar/sidebar-expand-button.d.ts.map +1 -1
- package/dist/components/ui/sidebar/sidebar-expand-button.js +15 -1
- package/dist/components/ui/sidebar/sidebar-item.d.ts +1 -1
- package/dist/components/ui/sidebar/sidebar-item.d.ts.map +1 -1
- package/dist/components/ui/sidebar/sidebar-item.js +28 -4
- package/dist/components/ui/sidebar/sidebar-provider.d.ts +58 -0
- package/dist/components/ui/sidebar/sidebar-provider.d.ts.map +1 -1
- package/dist/components/ui/sidebar/sidebar-provider.js +146 -4
- package/dist/components/ui/sidebar/sidebar-root.d.ts +38 -1
- package/dist/components/ui/sidebar/sidebar-root.d.ts.map +1 -1
- package/dist/components/ui/sidebar/sidebar-root.js +111 -5
- package/dist/components/ui/sidebar/sidebar-trigger.d.ts +45 -0
- package/dist/components/ui/sidebar/sidebar-trigger.d.ts.map +1 -0
- package/dist/components/ui/sidebar/sidebar-trigger.js +57 -0
- package/dist/components/ui/textarea/index.js +1 -1
- package/dist/components/ui/toast/toaster.d.ts +5 -1
- package/dist/components/ui/toast/toaster.d.ts.map +1 -1
- package/dist/components/ui/toast/toaster.js +3 -2
- package/dist/enterprise/data-table/index.d.ts +77 -7
- package/dist/enterprise/data-table/index.d.ts.map +1 -1
- package/dist/enterprise/data-table/index.js +56 -11
- package/dist/enterprise/date-range-picker/index.js +4 -4
- package/dist/enterprise/number-input/index.d.ts.map +1 -1
- package/dist/enterprise/number-input/index.js +1 -1
- package/dist/esm/components/breadcrumb/index.d.ts.map +1 -1
- package/dist/esm/components/breadcrumb/index.js +25 -1
- package/dist/esm/components/card/account-card/index.js +5 -5
- package/dist/esm/components/card/basic-card/index.d.ts.map +1 -1
- package/dist/esm/components/card/basic-card/index.js +2 -2
- package/dist/esm/components/card/entity-card/index.js +1 -1
- package/dist/esm/components/dialog/confirmation-dialog/index.d.ts.map +1 -1
- package/dist/esm/components/dialog/confirmation-dialog/index.js +4 -1
- package/dist/esm/components/entity-box/index.d.ts +18 -2
- package/dist/esm/components/entity-box/index.d.ts.map +1 -1
- package/dist/esm/components/entity-box/index.js +29 -4
- package/dist/esm/components/form/date-picker-field/index.js +1 -1
- package/dist/esm/components/form/date-range-field/index.js +1 -1
- package/dist/esm/components/form/date-time-picker-field/index.js +1 -1
- package/dist/esm/components/form/password-field/index.d.ts +5 -1
- package/dist/esm/components/form/password-field/index.d.ts.map +1 -1
- package/dist/esm/components/form/password-field/index.js +2 -2
- package/dist/esm/components/form/select-field/index.d.ts +15 -1
- package/dist/esm/components/form/select-field/index.d.ts.map +1 -1
- package/dist/esm/components/form/select-field/index.js +29 -4
- package/dist/esm/components/form/switch-field/index.d.ts.map +1 -1
- package/dist/esm/components/form/switch-field/index.js +7 -2
- package/dist/esm/components/page/index.d.ts +35 -1
- package/dist/esm/components/page/index.d.ts.map +1 -1
- package/dist/esm/components/page/index.js +6 -2
- package/dist/esm/components/page-header/index.js +1 -1
- package/dist/esm/components/table/id-table-cell.d.ts +13 -1
- package/dist/esm/components/table/id-table-cell.d.ts.map +1 -1
- package/dist/esm/components/table/id-table-cell.js +100 -7
- package/dist/esm/components/table/index.d.ts +1 -0
- package/dist/esm/components/table/index.d.ts.map +1 -1
- package/dist/esm/components/table/index.js +1 -0
- package/dist/esm/components/table/locked-table-actions.d.ts +24 -0
- package/dist/esm/components/table/locked-table-actions.d.ts.map +1 -1
- package/dist/esm/components/table/locked-table-actions.js +31 -1
- package/dist/esm/components/table/name-table-cell.d.ts +57 -4
- package/dist/esm/components/table/name-table-cell.d.ts.map +1 -1
- package/dist/esm/components/table/name-table-cell.js +50 -2
- package/dist/esm/components/ui/autocomplete/index.d.ts +24 -0
- package/dist/esm/components/ui/autocomplete/index.d.ts.map +1 -1
- package/dist/esm/components/ui/autocomplete/index.js +24 -0
- package/dist/esm/components/ui/badge/index.d.ts +11 -7
- package/dist/esm/components/ui/badge/index.d.ts.map +1 -1
- package/dist/esm/components/ui/badge/index.js +20 -8
- package/dist/esm/components/ui/button/index.d.ts +3 -3
- package/dist/esm/components/ui/copy-field/index.d.ts +25 -1
- package/dist/esm/components/ui/copy-field/index.d.ts.map +1 -1
- package/dist/esm/components/ui/copy-field/index.js +16 -8
- package/dist/esm/components/ui/dialog/index.d.ts +1 -1
- package/dist/esm/components/ui/dialog/index.d.ts.map +1 -1
- package/dist/esm/components/ui/dialog/index.js +3 -3
- package/dist/esm/components/ui/file-upload/index.d.ts +159 -0
- package/dist/esm/components/ui/file-upload/index.d.ts.map +1 -1
- package/dist/esm/components/ui/file-upload/index.js +307 -5
- package/dist/esm/components/ui/icon-button/index.d.ts +1 -1
- package/dist/esm/components/ui/otp-input/index.js +1 -1
- package/dist/esm/components/ui/radio-group/index.js +1 -1
- package/dist/esm/components/ui/select/index.d.ts.map +1 -1
- package/dist/esm/components/ui/select/index.js +8 -4
- package/dist/esm/components/ui/sheet/index.d.ts +42 -0
- package/dist/esm/components/ui/sheet/index.d.ts.map +1 -1
- package/dist/esm/components/ui/sheet/index.js +48 -15
- package/dist/esm/components/ui/sidebar/index.d.ts +1 -0
- package/dist/esm/components/ui/sidebar/index.d.ts.map +1 -1
- package/dist/esm/components/ui/sidebar/index.js +1 -0
- package/dist/esm/components/ui/sidebar/sidebar-expand-button.d.ts +1 -1
- package/dist/esm/components/ui/sidebar/sidebar-expand-button.d.ts.map +1 -1
- package/dist/esm/components/ui/sidebar/sidebar-expand-button.js +15 -1
- package/dist/esm/components/ui/sidebar/sidebar-item.d.ts +1 -1
- package/dist/esm/components/ui/sidebar/sidebar-item.d.ts.map +1 -1
- package/dist/esm/components/ui/sidebar/sidebar-item.js +28 -4
- package/dist/esm/components/ui/sidebar/sidebar-provider.d.ts +58 -0
- package/dist/esm/components/ui/sidebar/sidebar-provider.d.ts.map +1 -1
- package/dist/esm/components/ui/sidebar/sidebar-provider.js +143 -1
- package/dist/esm/components/ui/sidebar/sidebar-root.d.ts +38 -1
- package/dist/esm/components/ui/sidebar/sidebar-root.d.ts.map +1 -1
- package/dist/esm/components/ui/sidebar/sidebar-root.js +113 -7
- package/dist/esm/components/ui/sidebar/sidebar-trigger.d.ts +45 -0
- package/dist/esm/components/ui/sidebar/sidebar-trigger.d.ts.map +1 -0
- package/dist/esm/components/ui/sidebar/sidebar-trigger.js +53 -0
- package/dist/esm/components/ui/textarea/index.js +1 -1
- package/dist/esm/components/ui/toast/toaster.d.ts +5 -1
- package/dist/esm/components/ui/toast/toaster.d.ts.map +1 -1
- package/dist/esm/components/ui/toast/toaster.js +3 -2
- package/dist/esm/enterprise/data-table/index.d.ts +77 -7
- package/dist/esm/enterprise/data-table/index.d.ts.map +1 -1
- package/dist/esm/enterprise/data-table/index.js +57 -12
- package/dist/esm/enterprise/date-range-picker/index.js +4 -4
- package/dist/esm/enterprise/number-input/index.d.ts.map +1 -1
- package/dist/esm/enterprise/number-input/index.js +1 -1
- package/dist/esm/hooks/use-toast.d.ts +18 -2
- package/dist/esm/hooks/use-toast.d.ts.map +1 -1
- package/dist/esm/hooks/use-toast.js +60 -16
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/lib/utils.d.ts +2 -0
- package/dist/esm/lib/utils.d.ts.map +1 -1
- package/dist/esm/lib/utils.js +10 -0
- package/dist/esm/toast/helpers.d.ts +13 -0
- package/dist/esm/toast/helpers.d.ts.map +1 -1
- package/dist/esm/toast/helpers.js +8 -1
- package/dist/globals.css +81 -31
- package/dist/hooks/use-toast.d.ts +18 -2
- package/dist/hooks/use-toast.d.ts.map +1 -1
- package/dist/hooks/use-toast.js +61 -16
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +11 -0
- package/dist/toast/helpers.d.ts +13 -0
- package/dist/toast/helpers.d.ts.map +1 -1
- package/dist/toast/helpers.js +8 -1
- package/package.json +7 -4
|
@@ -34,7 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
};
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
-
exports.FileUpload = void 0;
|
|
37
|
+
exports.MultipleFileUpload = exports.FileUpload = void 0;
|
|
38
38
|
exports.validateFile = validateFile;
|
|
39
39
|
exports.humanizeSize = humanizeSize;
|
|
40
40
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
@@ -226,10 +226,18 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
|
|
|
226
226
|
event.target.value = '';
|
|
227
227
|
handleFile(file);
|
|
228
228
|
};
|
|
229
|
+
// `preventDefault` FIRST, and unconditionally. Returning before it does
|
|
230
|
+
// not merely refuse the drop: nothing cancels the browser's OWN action for
|
|
231
|
+
// a dropped file, so the window navigates to the file and whatever the
|
|
232
|
+
// page held unsaved is gone. A zone the host turned off has to swallow the
|
|
233
|
+
// drop, never hand it back to the browser. `dropEffect` says so to the
|
|
234
|
+
// cursor while the file is still in the air.
|
|
229
235
|
const onDragOver = (event) => {
|
|
230
|
-
if (disabled)
|
|
231
|
-
return;
|
|
232
236
|
event.preventDefault();
|
|
237
|
+
if (disabled) {
|
|
238
|
+
event.dataTransfer.dropEffect = 'none';
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
233
241
|
setDragActive(true);
|
|
234
242
|
};
|
|
235
243
|
const onDragLeave = (event) => {
|
|
@@ -237,10 +245,13 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
|
|
|
237
245
|
setDragActive(false);
|
|
238
246
|
};
|
|
239
247
|
const onDrop = (event) => {
|
|
240
|
-
if (disabled)
|
|
241
|
-
return;
|
|
242
248
|
event.preventDefault();
|
|
249
|
+
// Cleared before the gate, not after, so a host that disables the zone
|
|
250
|
+
// mid-drag does not leave it highlighted for a drag that can no longer
|
|
251
|
+
// land.
|
|
243
252
|
setDragActive(false);
|
|
253
|
+
if (disabled)
|
|
254
|
+
return;
|
|
244
255
|
handleFile(event.dataTransfer.files?.[0]);
|
|
245
256
|
};
|
|
246
257
|
// Mouse convenience only: clicking the styled zone opens the picker. A
|
|
@@ -260,7 +271,7 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
|
|
|
260
271
|
if (internalRef.current)
|
|
261
272
|
internalRef.current.value = '';
|
|
262
273
|
};
|
|
263
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)('space-y-2', className), children: [(0, jsx_runtime_1.jsxs)("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: (0, utils_1.cn)('border-input bg-card focus-within:ring-ring focus-within:ring-offset-background aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-within:ring-destructive flex w-full items-center gap-3 rounded-md border px-3 py-4 text-sm shadow-xs transition-colors focus-within:ring-2 focus-within:ring-offset-1 focus-within:outline-none', dragActive &&
|
|
274
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)('space-y-2', className), children: [(0, jsx_runtime_1.jsxs)("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: (0, utils_1.cn)('border-input-border bg-card focus-within:ring-ring focus-within:ring-offset-background aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-within:ring-destructive flex w-full items-center gap-3 rounded-md border px-3 py-4 text-sm shadow-xs transition-colors focus-within:ring-2 focus-within:ring-offset-1 focus-within:outline-none', dragActive &&
|
|
264
275
|
'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', disabled
|
|
265
276
|
? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
|
|
266
277
|
: 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [(0, jsx_runtime_1.jsx)("input", { ref: internalRef, id: inputId, type: "file", accept: accept, disabled: disabled, className: "sr-only", "aria-invalid": invalid || undefined, "aria-required": ariaRequired || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, onChange: onInputChange, ...rest }), (0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), value ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 flex-1 truncate", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: value.file.name }), ' ', (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(value.file.size) })] }), (0, jsx_runtime_1.jsx)(icon_button_1.IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove?.trim() ? labels.remove : 'Remove file', onClick: (event) => {
|
|
@@ -270,3 +281,294 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
|
|
|
270
281
|
}, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4", "aria-hidden": "true" }) })] })) : ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose a file' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), errorText ? ((0, jsx_runtime_1.jsx)("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorText })) : null] }));
|
|
271
282
|
});
|
|
272
283
|
exports.FileUpload.displayName = 'FileUpload';
|
|
284
|
+
/** The default English announcement for each rejection kind. */
|
|
285
|
+
function defaultMultipleErrorMessage(error) {
|
|
286
|
+
if (error.kind === 'too-many') {
|
|
287
|
+
return `Too many files (max ${error.maxFiles}). ${error.file.name} was not added.`;
|
|
288
|
+
}
|
|
289
|
+
return defaultErrorMessage(error);
|
|
290
|
+
}
|
|
291
|
+
/** The default zone copy once the cap is reached, in grammatical English. */
|
|
292
|
+
function defaultFullMessage(maxFiles) {
|
|
293
|
+
return maxFiles === 1
|
|
294
|
+
? 'Maximum of 1 file reached.'
|
|
295
|
+
: `Maximum of ${maxFiles} files reached.`;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* MultipleFileUpload: the plural sibling of FileUpload. Pick SEVERAL files,
|
|
299
|
+
* accumulate them across repeated picks, validate each one, cap the total, and
|
|
300
|
+
* hand back `FileUploadResult[]`.
|
|
301
|
+
*
|
|
302
|
+
* A SIBLING COMPONENT, not a `multiple` flag. This library already answers
|
|
303
|
+
* "this one takes many" that way (`Select` / `MultipleSelect`), and FileUpload
|
|
304
|
+
* strips `'multiple'` from its props on purpose: single-file is its contract,
|
|
305
|
+
* not a default it happens to have. A boolean would have forced every prop
|
|
306
|
+
* here into a union that means one thing when the flag is set and another when
|
|
307
|
+
* it is not: `value` as `Result | Result[] | null`, a `maxFiles` that is
|
|
308
|
+
* meaningless in half the configurations, and a remove control whose
|
|
309
|
+
* accessible name is fixed copy in one mode and per-file in the other. The
|
|
310
|
+
* plural props follow the house shape for plural components: `value?: T[]`
|
|
311
|
+
* with `onValueChange?: (values: T[]) => void`.
|
|
312
|
+
*
|
|
313
|
+
* WHERE THE LINE SITS: this component owns SELECTION and nothing after it.
|
|
314
|
+
* Choosing, validating, capping, listing and removing are its job; uploading
|
|
315
|
+
* is not. That split is not squeamishness about scope, it is where the
|
|
316
|
+
* knowledge actually lives. An upload needs an endpoint, an auth scheme, a
|
|
317
|
+
* concurrency policy, a retry policy and, very often, a parent id that does
|
|
318
|
+
* not exist yet when the files are chosen: the motivating host stages evidence
|
|
319
|
+
* files while a form is being filled and can only upload them against the id
|
|
320
|
+
* that its create call returns afterwards. None of that is knowable from
|
|
321
|
+
* inside a library primitive, and a component that guessed would have to be
|
|
322
|
+
* fought rather than used. So the host keeps its own per-file record with
|
|
323
|
+
* status and retry, and this component keeps the part a form can hold and
|
|
324
|
+
* validate: the chosen files. `FileUploadResult[]` is a value; an upload state
|
|
325
|
+
* machine is not.
|
|
326
|
+
*
|
|
327
|
+
* ACCUMULATION is the defining behaviour. A second pick ADDS to the selection
|
|
328
|
+
* rather than replacing it, because a user assembling five documents does it
|
|
329
|
+
* in two or three trips to the file dialog, not one. Everything else follows
|
|
330
|
+
* from that: room is measured against what is already selected, and the batch
|
|
331
|
+
* that overflows the cap still contributes the files that fit.
|
|
332
|
+
*
|
|
333
|
+
* A BATCH SURVIVES ITS OWN CASUALTIES. One file rejected for type, size or a
|
|
334
|
+
* failed read does not discard the rest of the batch, and it does not consume
|
|
335
|
+
* a slot either: validation runs over the WHOLE batch before the cap is
|
|
336
|
+
* applied, so a file that was never eligible cannot cost an eligible one its
|
|
337
|
+
* place, and `'too-many'` always names a file a slot would genuinely have
|
|
338
|
+
* taken. The alternative punishes
|
|
339
|
+
* a user for a mistake in one file by throwing away four good ones, and hands
|
|
340
|
+
* back no way to tell which was which. Every rejection is reported through
|
|
341
|
+
* `onError` and announced together in one `role="alert"`.
|
|
342
|
+
*
|
|
343
|
+
* Accessibility follows the sibling BELOW THE CAP: the real `<input
|
|
344
|
+
* type="file">` is `sr-only` but focusable and labelable, and never
|
|
345
|
+
* `aria-hidden`, so FormControl-injected ARIA and react-hook-form's focus on
|
|
346
|
+
* error both work while the picker is enabled. At the cap that changes, and
|
|
347
|
+
* the paragraph below says how.
|
|
348
|
+
* The file list sits OUTSIDE the click zone, so activating a remove control
|
|
349
|
+
* cannot also reopen the picker, and each remove control is named after its
|
|
350
|
+
* own file: a column of identical "Remove file" buttons tells a screen-reader
|
|
351
|
+
* user nothing about which row they are on.
|
|
352
|
+
*
|
|
353
|
+
* AT THE CAP the picker takes the native `disabled` attribute, and that DOES
|
|
354
|
+
* take it out of the tab order. This is the one state in which the input is
|
|
355
|
+
* not a focus target, and the one state in which focus-on-error cannot land on
|
|
356
|
+
* it, so it is a real cost rather than a free win. The cap has no counterpart
|
|
357
|
+
* in the single-file sibling, so the precedent followed here is
|
|
358
|
+
* `DateRangePicker`'s trigger: a control whose only job is to open a dialog has
|
|
359
|
+
* no state worth keeping focusable, and native `disabled` is what both removes
|
|
360
|
+
* it from the tab order and keeps the dialog shut. The alternative, an enabled
|
|
361
|
+
* picker that opens the file dialog and then refuses every file with
|
|
362
|
+
* `too-many`, is a control that lies about being available. What keeps the cap
|
|
363
|
+
* from being a dead end is the escape hatch: the remove controls answer to
|
|
364
|
+
* `disabled` alone and NEVER to the cap, so they stay focusable and removing
|
|
365
|
+
* one file reopens the picker.
|
|
366
|
+
*/
|
|
367
|
+
exports.MultipleFileUpload = React.forwardRef(function MultipleFileUpload({ accept, maxSizeBytes, maxFiles, value = [], readAs = 'text', labels, onValueChange, onError, disabled = false, id, className, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, ...rest }, ref) {
|
|
368
|
+
const internalRef = React.useRef(null);
|
|
369
|
+
React.useImperativeHandle(ref, () => internalRef.current);
|
|
370
|
+
const reactId = React.useId();
|
|
371
|
+
const inputId = id ?? reactId;
|
|
372
|
+
const errorId = `${inputId}-file-upload-error`;
|
|
373
|
+
const [dragActive, setDragActive] = React.useState(false);
|
|
374
|
+
const [errors, setErrors] = React.useState([]);
|
|
375
|
+
// The authoritative base for the next append. Props win on every render, so
|
|
376
|
+
// the host stays in control; the commit below also writes through, so a
|
|
377
|
+
// second batch that settles before the host has re-rendered still appends to
|
|
378
|
+
// the first batch's result instead of overwriting it.
|
|
379
|
+
const valueRef = React.useRef(value);
|
|
380
|
+
React.useEffect(() => {
|
|
381
|
+
valueRef.current = value;
|
|
382
|
+
});
|
|
383
|
+
// Superseding is NOT the contract here the way it is in the single-file
|
|
384
|
+
// sibling — batches accumulate, so an in-flight read is never stale. The
|
|
385
|
+
// readers are tracked for the one case that does have to stop them: an
|
|
386
|
+
// unmount. A read that lands afterwards would settle its batch and commit,
|
|
387
|
+
// calling the host's `onValueChange` for a component that no longer exists.
|
|
388
|
+
const readersRef = React.useRef(new Set());
|
|
389
|
+
React.useEffect(() => () => {
|
|
390
|
+
for (const reader of readersRef.current)
|
|
391
|
+
reader.abort();
|
|
392
|
+
readersRef.current.clear();
|
|
393
|
+
}, []);
|
|
394
|
+
const invalid = ariaInvalid || errors.length > 0;
|
|
395
|
+
// Resolve the announcements BEFORE deciding whether the alert exists: a
|
|
396
|
+
// consumer that returns nothing for every rejection is opting out of this
|
|
397
|
+
// surface, and an association pointing at an unrendered node is worse than
|
|
398
|
+
// none.
|
|
399
|
+
const messages = errors
|
|
400
|
+
.map((failure) => labels?.error
|
|
401
|
+
? labels.error(failure)
|
|
402
|
+
: defaultMultipleErrorMessage(failure))
|
|
403
|
+
.filter((message) => Boolean(message));
|
|
404
|
+
// A plain join, never `cn`: tailwind-merge treats these as class names and
|
|
405
|
+
// would drop an id that happens to look like a conflicting utility.
|
|
406
|
+
const describedBy = [ariaDescribedby, messages.length > 0 ? errorId : undefined]
|
|
407
|
+
.filter(Boolean)
|
|
408
|
+
.join(' ') || undefined;
|
|
409
|
+
const full = maxFiles !== undefined && value.length >= maxFiles;
|
|
410
|
+
// Removing must stay possible at the cap, so only the PICKER closes.
|
|
411
|
+
const pickerDisabled = disabled || full;
|
|
412
|
+
const roomFor = (selected) => maxFiles === undefined
|
|
413
|
+
? Number.POSITIVE_INFINITY
|
|
414
|
+
: Math.max(maxFiles - selected, 0);
|
|
415
|
+
// The cap is enforced HERE, at the only place that appends, because only the
|
|
416
|
+
// commit knows the base it lands on. `ingest` measures room too, but for an
|
|
417
|
+
// asynchronous batch it measures it BEFORE any read settles: two overlapping
|
|
418
|
+
// batches both see the pre-commit selection and would each believe they fit.
|
|
419
|
+
const commit = (accepted, rejections) => {
|
|
420
|
+
const room = roomFor(valueRef.current.length);
|
|
421
|
+
const fitting = accepted.slice(0, room);
|
|
422
|
+
const overflow = accepted[room];
|
|
423
|
+
const failures = overflow !== undefined && maxFiles !== undefined
|
|
424
|
+
? [
|
|
425
|
+
...rejections,
|
|
426
|
+
{ kind: 'too-many', file: overflow.file, maxFiles }
|
|
427
|
+
]
|
|
428
|
+
: rejections;
|
|
429
|
+
setErrors(failures);
|
|
430
|
+
for (const rejection of failures)
|
|
431
|
+
onError?.(rejection);
|
|
432
|
+
if (fitting.length === 0)
|
|
433
|
+
return;
|
|
434
|
+
const next = [...valueRef.current, ...fitting];
|
|
435
|
+
valueRef.current = next;
|
|
436
|
+
onValueChange(next);
|
|
437
|
+
};
|
|
438
|
+
const ingest = (incoming) => {
|
|
439
|
+
if (incoming.length === 0)
|
|
440
|
+
return;
|
|
441
|
+
const room = roomFor(valueRef.current.length);
|
|
442
|
+
const rejections = [];
|
|
443
|
+
// Validate EVERY file BEFORE the cap is applied. Slicing to the remaining
|
|
444
|
+
// room first would let a file that was never eligible consume a slot a
|
|
445
|
+
// good file could have used — one bad pick costing a good one, which is
|
|
446
|
+
// the opposite of a batch surviving its own casualties — and it would
|
|
447
|
+
// leave every file past the slice window neither validated nor reported.
|
|
448
|
+
// Validation is pure metadata (size and accept), so running it over files
|
|
449
|
+
// that may not fit costs nothing.
|
|
450
|
+
const eligible = [];
|
|
451
|
+
for (const file of incoming) {
|
|
452
|
+
const rejection = validateFile(file, { accept, maxSizeBytes });
|
|
453
|
+
// Continue rather than abort: one bad file must not cost the good ones.
|
|
454
|
+
if (rejection) {
|
|
455
|
+
rejections.push(rejection);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
eligible.push(file);
|
|
459
|
+
}
|
|
460
|
+
// The cap then applies to the SURVIVORS. One rejection for the batch,
|
|
461
|
+
// naming the FIRST eligible file that did not fit: naming one already
|
|
462
|
+
// refused for its size or type would blame the cap for the wrong thing,
|
|
463
|
+
// and repeating it per overflowing file buries the actionable part.
|
|
464
|
+
if (eligible.length > room && maxFiles !== undefined) {
|
|
465
|
+
rejections.push({ kind: 'too-many', file: eligible[room], maxFiles });
|
|
466
|
+
}
|
|
467
|
+
const candidates = eligible.slice(0, room);
|
|
468
|
+
// Binary path: no decode, no reader, no retained garbage string.
|
|
469
|
+
if (readAs === 'none') {
|
|
470
|
+
commit(candidates.map((file) => ({ file, text: '' })), rejections);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (candidates.length === 0) {
|
|
474
|
+
commit([], rejections);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Slot-per-candidate so the emitted batch keeps PICK order regardless of
|
|
478
|
+
// the order the reads settle in. A null slot is a read that failed; the
|
|
479
|
+
// batch commits once every read has settled, one way or the other.
|
|
480
|
+
const slots = new Array(candidates.length).fill(null);
|
|
481
|
+
let remaining = candidates.length;
|
|
482
|
+
const settle = () => {
|
|
483
|
+
remaining -= 1;
|
|
484
|
+
if (remaining > 0)
|
|
485
|
+
return;
|
|
486
|
+
const accepted = [];
|
|
487
|
+
const readFailures = [];
|
|
488
|
+
slots.forEach((slot, index) => {
|
|
489
|
+
if (slot)
|
|
490
|
+
accepted.push(slot);
|
|
491
|
+
else
|
|
492
|
+
readFailures.push({ kind: 'read-failed', file: candidates[index] });
|
|
493
|
+
});
|
|
494
|
+
commit(accepted, [...rejections, ...readFailures]);
|
|
495
|
+
};
|
|
496
|
+
candidates.forEach((file, index) => {
|
|
497
|
+
const reader = new FileReader();
|
|
498
|
+
readersRef.current.add(reader);
|
|
499
|
+
// Deliberately no `onabort` handler: an aborted read must NOT settle,
|
|
500
|
+
// or the batch would commit at exactly the moment we are stopping it.
|
|
501
|
+
reader.onload = () => {
|
|
502
|
+
readersRef.current.delete(reader);
|
|
503
|
+
slots[index] = { file, text: String(reader.result ?? '') };
|
|
504
|
+
settle();
|
|
505
|
+
};
|
|
506
|
+
reader.onerror = () => {
|
|
507
|
+
readersRef.current.delete(reader);
|
|
508
|
+
settle();
|
|
509
|
+
};
|
|
510
|
+
reader.readAsText(file);
|
|
511
|
+
});
|
|
512
|
+
};
|
|
513
|
+
const onInputChange = (event) => {
|
|
514
|
+
const files = Array.from(event.target.files ?? []);
|
|
515
|
+
// Release the FileList the moment it has been read, or re-picking the same
|
|
516
|
+
// file is silently a no-op: the browser fires `change` only when the
|
|
517
|
+
// selection DIFFERS from what the input already holds.
|
|
518
|
+
event.target.value = '';
|
|
519
|
+
ingest(files);
|
|
520
|
+
};
|
|
521
|
+
// `preventDefault` FIRST, and unconditionally. Returning before it does
|
|
522
|
+
// not merely refuse the drop: nothing cancels the browser's OWN action for
|
|
523
|
+
// a dropped file, so the window navigates to the file and whatever the
|
|
524
|
+
// page held unsaved is gone. A zone the host turned off has to swallow the
|
|
525
|
+
// drop, never hand it back to the browser. `dropEffect` says so to the
|
|
526
|
+
// cursor while the file is still in the air.
|
|
527
|
+
const onDragOver = (event) => {
|
|
528
|
+
event.preventDefault();
|
|
529
|
+
if (disabled) {
|
|
530
|
+
event.dataTransfer.dropEffect = 'none';
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
setDragActive(true);
|
|
534
|
+
};
|
|
535
|
+
const onDragLeave = (event) => {
|
|
536
|
+
event.preventDefault();
|
|
537
|
+
setDragActive(false);
|
|
538
|
+
};
|
|
539
|
+
const onDrop = (event) => {
|
|
540
|
+
event.preventDefault();
|
|
541
|
+
// Cleared before the gate, not after, so a host that disables the zone
|
|
542
|
+
// mid-drag does not leave it highlighted for a drag that can no longer
|
|
543
|
+
// land.
|
|
544
|
+
setDragActive(false);
|
|
545
|
+
if (disabled)
|
|
546
|
+
return;
|
|
547
|
+
// Deliberately NOT gated on `full`: a drop onto a full zone is answered
|
|
548
|
+
// with the too-many rejection, which says why, instead of nothing at all.
|
|
549
|
+
ingest(Array.from(event.dataTransfer.files ?? []));
|
|
550
|
+
};
|
|
551
|
+
// Mouse convenience only. A click that ORIGINATED on the input already opens
|
|
552
|
+
// the picker natively and bubbles up here, so ignore it or it opens twice.
|
|
553
|
+
const openPicker = (event) => {
|
|
554
|
+
if (pickerDisabled || event.target === internalRef.current)
|
|
555
|
+
return;
|
|
556
|
+
internalRef.current?.click();
|
|
557
|
+
};
|
|
558
|
+
const removeAt = (index) => {
|
|
559
|
+
// By index, not by name: two files can share a filename and identity is
|
|
560
|
+
// what the row actually stands for.
|
|
561
|
+
const next = valueRef.current.filter((_, position) => position !== index);
|
|
562
|
+
valueRef.current = next;
|
|
563
|
+
onValueChange(next);
|
|
564
|
+
};
|
|
565
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)('space-y-2', className), children: [(0, jsx_runtime_1.jsxs)("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: (0, utils_1.cn)('border-input-border bg-card focus-within:ring-ring focus-within:ring-offset-background aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-within:ring-destructive flex w-full items-center gap-3 rounded-md border px-3 py-4 text-sm shadow-xs transition-colors focus-within:ring-2 focus-within:ring-offset-1 focus-within:outline-none', dragActive &&
|
|
566
|
+
'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', pickerDisabled
|
|
567
|
+
? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
|
|
568
|
+
: 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [(0, jsx_runtime_1.jsx)("input", { ref: internalRef, id: inputId, type: "file", multiple: true, accept: accept, disabled: pickerDisabled, className: "sr-only", "aria-invalid": invalid || undefined, "aria-required": ariaRequired || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, onChange: onInputChange, ...rest }), (0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), full && maxFiles !== undefined ? ((0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: labels?.full
|
|
569
|
+
? labels.full(maxFiles)
|
|
570
|
+
: defaultFullMessage(maxFiles) })) : ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose files' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), value.length > 0 ? ((0, jsx_runtime_1.jsx)("ul", { className: "space-y-1", children: value.map((entry, index) => ((0, jsx_runtime_1.jsxs)("li", { className: "flex items-center gap-3 text-sm", children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 flex-1 truncate", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: entry.file.name }), ' ', (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(entry.file.size) })] }), (0, jsx_runtime_1.jsx)(icon_button_1.IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove
|
|
571
|
+
? labels.remove(entry.file)
|
|
572
|
+
: `Remove ${entry.file.name}`, onClick: () => removeAt(index), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4", "aria-hidden": "true" }) })] }, `${entry.file.name}-${index}`))) })) : null, messages.length > 0 ? ((0, jsx_runtime_1.jsx)("div", { id: errorId, role: "alert", className: "space-y-1", children: messages.map((message, index) => ((0, jsx_runtime_1.jsx)("p", { className: "text-system-error-h1a text-xs font-medium", children: message }, index))) })) : null] }));
|
|
573
|
+
});
|
|
574
|
+
exports.MultipleFileUpload.displayName = 'MultipleFileUpload';
|
|
@@ -2,7 +2,7 @@ import { type VariantProps } from 'class-variance-authority';
|
|
|
2
2
|
import { buttonVariants } from '../button';
|
|
3
3
|
export declare const iconButtonVariants: (props?: ({
|
|
4
4
|
rounded?: boolean | null | undefined;
|
|
5
|
-
size?: "
|
|
5
|
+
size?: "small" | "default" | null | undefined;
|
|
6
6
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
7
7
|
export type IconButtonProps = React.ComponentProps<'button'> & VariantProps<typeof buttonVariants> & {
|
|
8
8
|
rounded?: boolean;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
@theme inline {
|
|
2
2
|
--spacing-icon-button-p: calc(var(--spacing) * 2);
|
|
3
|
+
/* The tap target every icon control in this package answers to, which is
|
|
4
|
+
also `.icon-button-base`'s own box. Named once so the small variant's
|
|
5
|
+
target and the default variant's size cannot drift apart. */
|
|
6
|
+
--spacing-icon-button-target: calc(var(--spacing) * 10);
|
|
3
7
|
}
|
|
4
8
|
|
|
5
9
|
@layer components {
|
|
@@ -19,6 +23,44 @@
|
|
|
19
23
|
@apply size-8 [&>*]:size-4;
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
/* ⛔ THE GLYPH IS 2rem, THE TARGET IS 2.5rem, AND THEY ARE NOT THE SAME BOX.
|
|
27
|
+
A table's row-actions kebab is the one control in its row, and at
|
|
28
|
+
`size-8` it was a 32x32 tap target where every icon control in the shell
|
|
29
|
+
is 40x40 — measured at 32x32 on the ledgers, transactions and
|
|
30
|
+
bank-transfer listings at a 390px viewport.
|
|
31
|
+
|
|
32
|
+
Growing the button instead would have grown the row: `TableCell` is
|
|
33
|
+
`py-4`, so a `size-10` trigger takes the row from 64.5px to 72.5px and a
|
|
34
|
+
phone shows fewer rows for the trouble. An absolutely positioned
|
|
35
|
+
pseudo-element takes part in hit testing and in nothing else, so the row,
|
|
36
|
+
the cell, the alignment and every other layout this class appears in are
|
|
37
|
+
byte-identical; `.button-base` is already `relative`, which is what this
|
|
38
|
+
anchors to.
|
|
39
|
+
|
|
40
|
+
⚠️ CENTRED AND SIZED, NOT INSET BY A CONSTANT. A negative `inset` is
|
|
41
|
+
measured from the PADDING box, so `-inset-1` on this button came out
|
|
42
|
+
38x38 rather than 40x40 — `.button-outline` carries a 1px transparent
|
|
43
|
+
border, and three of the eight perimeter points fell outside it. Half of
|
|
44
|
+
50% resolves against whatever the box actually is, so this states the
|
|
45
|
+
target as a size and lets the border be whatever it is.
|
|
46
|
+
|
|
47
|
+
The target is `--spacing-icon-button-target`, which is `.icon-button-base`
|
|
48
|
+
itself, so it is 2.5rem at any browser font rather than 40px at one of
|
|
49
|
+
them. That leaves a quarter of a rem on each side, which is also the
|
|
50
|
+
`gap-1` between the two buttons in `CopyField` — the only place in this
|
|
51
|
+
package where two of these sit side by side — so each target stops at its
|
|
52
|
+
neighbour's visible edge and neither can be tapped by aiming at the other.
|
|
53
|
+
They do share the empty gap, which the later one paints over and takes.
|
|
54
|
+
|
|
55
|
+
`scripts/measure-phone-shapes.mjs` walks eight points around the 2.5rem
|
|
56
|
+
perimeter and asks the document what is there; run it when touching this
|
|
57
|
+
rule. */
|
|
58
|
+
.icon-button-small::after {
|
|
59
|
+
position: absolute;
|
|
60
|
+
inset: calc(50% - var(--spacing-icon-button-target) / 2);
|
|
61
|
+
content: '';
|
|
62
|
+
}
|
|
63
|
+
|
|
22
64
|
.icon-button-rounded {
|
|
23
65
|
@apply rounded-full;
|
|
24
66
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
--spacing-input-px: calc(var(--spacing) * 4);
|
|
5
5
|
--color-input-foreground: var(--color-foreground);
|
|
6
6
|
--color-input-placeholder: hsl(var(--input-placeholder));
|
|
7
|
-
--color-input-border: var(--
|
|
7
|
+
--color-input-border: hsl(var(--input-border));
|
|
8
8
|
|
|
9
9
|
--color-input-adornment: var(--color-container-text);
|
|
10
10
|
--spacing-input-adornment-size: calc(var(--spacing) * 10);
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
@layer components {
|
|
15
15
|
.input-wrapper {
|
|
16
|
-
@apply h-input-wrapper-h border-input-border p-input-wrapper-p relative flex w-full cursor-text items-center rounded-md border transition-[color,box-shadow];
|
|
16
|
+
@apply h-input-wrapper-h border-input-border p-input-wrapper-p relative flex w-full min-w-0 cursor-text items-center rounded-md border transition-[color,box-shadow];
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
.input-wrapper-focus {
|
|
@@ -27,9 +27,84 @@
|
|
|
27
27
|
/* Don't give the placeholder its own size or weight. At a smaller size it
|
|
28
28
|
still shares the input's baseline, so it rode ~0.75px below the value and
|
|
29
29
|
the text jumped the moment you typed; font-medium gave the hint the same
|
|
30
|
-
presence as the label naming the field.
|
|
30
|
+
presence as the label naming the field.
|
|
31
|
+
|
|
32
|
+
THE CONTRACT, and all three halves of it are measured. A field keeps its
|
|
33
|
+
natural width: nothing here declares one, so the control contributes its
|
|
34
|
+
own intrinsic size and a box sized BY ITS CONTENT — a search box in a
|
|
35
|
+
`flex gap-4` filter bar — comes out as a field. A field fills its box
|
|
36
|
+
instead of overrunning it: `min-w-0` on both boxes drops the automatic
|
|
37
|
+
minimum that used to hold the control at 239px inside a narrower wrapper,
|
|
38
|
+
down to the one width it cannot go under. That floor is arithmetic rather
|
|
39
|
+
than policy — `px-input-px` is 16px a side and the box sizing is
|
|
40
|
+
border-box, so the control is 32px wide carrying nothing at all — and a
|
|
41
|
+
box narrower than that gets the difference painted outside it. Measured at
|
|
42
|
+
23px on the console's template-builder header, whose `w-48` wrapper is
|
|
43
|
+
squeezed to 18px and which the console repairs with `shrink-0` on that
|
|
44
|
+
wrapper. This package's own `SheetContent side="right"` used to run the
|
|
45
|
+
same arithmetic one step earlier: `w-2/5 p-12` at a 390px viewport left a
|
|
46
|
+
59px panel, and the field came out 41px wide with 9px of text — unusable,
|
|
47
|
+
but inside its box, where 2.0.0-beta.9 painted 189px of it across the
|
|
48
|
+
panel edge. That panel grew a phone step in 2.0.0-beta.12 and now fills
|
|
49
|
+
the screen, so the field reads 307px there; the arithmetic still stands
|
|
50
|
+
wherever a box is genuinely narrower than the control's own padding. A row
|
|
51
|
+
that must compress says so on its own flex item, with `min-w-0`, and that
|
|
52
|
+
one token now works.
|
|
53
|
+
|
|
54
|
+
Why no declared width, measured rather than assumed. A declared width
|
|
55
|
+
replaces the control's max-content contribution as well as its min-content
|
|
56
|
+
one, because an `<input>` does not wrap and has only the one intrinsic
|
|
57
|
+
size. `w-0` shipped in 2.0.0-beta.10 and bought a narrow row's compression
|
|
58
|
+
by giving that up: five product-console screens rendered an empty 32px
|
|
59
|
+
chip at every viewport, and a sixth — the template builder's name field —
|
|
60
|
+
did at 390px while keeping 142px of text at 1440px, all of them wrappers
|
|
61
|
+
that had been handed 0px to be as wide as.
|
|
62
|
+
`w-full` buys the same compression without the chip — a percentage
|
|
63
|
+
contributes max-content but zero min-content — and it was the runner-up
|
|
64
|
+
here. It loses on the same filter bar at 390px, where the control is not
|
|
65
|
+
merely sized by its content but squeezed, and it disappears rather than
|
|
66
|
+
letting the bar scroll. Seventeen rule sets were measured against
|
|
67
|
+
seventeen fixtures; none closes all of them.
|
|
68
|
+
|
|
69
|
+
What this one leaves open is `two-up-200`: two fields in a caller's
|
|
70
|
+
`flex-1` columns inside a 200px row, which now overflows by 138px instead
|
|
71
|
+
of compressing. The caller's one token closes it — `min-w-0` on its own
|
|
72
|
+
column, measured at 78px and fitting — and the fixture beside it,
|
|
73
|
+
`two-up-200-caller-min-w-0`, is the guard that keeps that hatch working.
|
|
74
|
+
On 2.0.0-beta.9 the hatch did NOT work: the same row overflowed by 48px
|
|
75
|
+
and the control painted 152px across its neighbour, because the control
|
|
76
|
+
floored at its intrinsic size inside the column that had just agreed to
|
|
77
|
+
compress. That is the difference this rule set makes.
|
|
78
|
+
|
|
79
|
+
`min-width` reaches exactly one box: the one it is declared on. It does
|
|
80
|
+
not lower an ancestor's automatic minimum and it does not lower a
|
|
81
|
+
descendant's — the sentence this comment carried before the fix said so
|
|
82
|
+
and was right. Nothing above contradicts it: the rule works because BOTH
|
|
83
|
+
boxes declare their own, the control's on `.input-base` and the wrapper's
|
|
84
|
+
on `.input-wrapper`, and neither half works alone. Measured: the wrapper's
|
|
85
|
+
alone fails eleven fixtures (`--candidate=wrapper-only-auto`), the
|
|
86
|
+
control's alone fails four (`--candidate=auto-min-w-0`). It is also why
|
|
87
|
+
`two-up-200` stays open above. The caller's `flex-1` column is a third
|
|
88
|
+
box, no declaration in this package reaches it, and only the caller's own
|
|
89
|
+
`min-w-0` lowers it — which is why that row still overflows by 138px.
|
|
90
|
+
|
|
91
|
+
There is deliberately NO positive min-width floor, and that was measured
|
|
92
|
+
too. A floor here cannot reach the sites that want one: a consumer
|
|
93
|
+
writing `.input-base` by hand tends to add its own `min-w-0`, which is a
|
|
94
|
+
`utilities`-layer class and outranks anything this `components`-layer rule
|
|
95
|
+
set declares. With `min-w-16` added here, the same fixture read 64px wide
|
|
96
|
+
without that hand-written `min-w-0` and 32px with it. The floor also cost
|
|
97
|
+
ground elsewhere: two inputs sharing a 120px row went from fitting to
|
|
98
|
+
overflowing by 52px, and a floor small enough to survive that row still
|
|
99
|
+
left a squeezed filter bar at 32px of content. A caller whose field must
|
|
100
|
+
hold its width says so on its own wrapper, with `shrink-0`.
|
|
101
|
+
|
|
102
|
+
`scripts/measure-input-shrink.mjs` carries the fixtures and the numbers;
|
|
103
|
+
run it when touching this rule set, and `--candidate=ID` re-measures any
|
|
104
|
+
of the seventeen, or `shipped` for this one, before re-opening the
|
|
105
|
+
trade. */
|
|
31
106
|
.input-base {
|
|
32
|
-
@apply px-input-px text-input-foreground placeholder:text-input-placeholder h-full min-h-0 flex-1 border-none bg-transparent outline-none focus:ring-0 focus:ring-offset-0;
|
|
107
|
+
@apply px-input-px text-input-foreground placeholder:text-input-placeholder h-full min-h-0 min-w-0 flex-1 border-none bg-transparent outline-none focus:ring-0 focus:ring-offset-0;
|
|
33
108
|
}
|
|
34
109
|
|
|
35
110
|
.input-disabled {
|
|
@@ -52,7 +52,7 @@ function InputOTPGroup({ className, ...props }) {
|
|
|
52
52
|
function InputOTPSlot({ index, className, ...props }) {
|
|
53
53
|
const inputOTPContext = React.useContext(input_otp_1.OTPInputContext);
|
|
54
54
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
|
55
|
-
return ((0, jsx_runtime_1.jsxs)("div", { "data-slot": "input-otp-slot", "data-active": isActive, className: (0, utils_1.cn)('border-input-border bg-background text-foreground', 'relative flex h-14 w-12 items-center justify-center border-y border-r text-2xl font-semibold shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md', 'data-[active=true]:
|
|
55
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-slot": "input-otp-slot", "data-active": isActive, className: (0, utils_1.cn)('border-input-border bg-background text-foreground', 'relative flex h-14 w-12 items-center justify-center border-y border-r text-2xl font-semibold shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md', 'data-[active=true]:ring-ring data-[active=true]:z-10 data-[active=true]:ring-2', className), ...props, children: [char, hasFakeCaret && ((0, jsx_runtime_1.jsx)("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: (0, jsx_runtime_1.jsx)("div", { className: "motion-safe:animate-caret-blink bg-foreground h-6 w-px duration-1000" }) }))] }));
|
|
56
56
|
}
|
|
57
57
|
function InputOTPSeparator({ ...props }) {
|
|
58
58
|
return ((0, jsx_runtime_1.jsx)("div", { "data-slot": "input-otp-separator", role: "separator", ...props, children: (0, jsx_runtime_1.jsx)(lucide_react_1.MinusIcon, {}) }));
|
|
@@ -44,5 +44,5 @@ function RadioGroup({ className, ...props }) {
|
|
|
44
44
|
return ((0, jsx_runtime_1.jsx)(RadioGroupPrimitive.Root, { "data-slot": "radio-group", className: (0, utils_1.cn)('grid gap-3', className), ...props }));
|
|
45
45
|
}
|
|
46
46
|
function RadioGroupItem({ className, ...props }) {
|
|
47
|
-
return ((0, jsx_runtime_1.jsx)(RadioGroupPrimitive.Item, { "data-slot": "radio-group-item", className: (0, utils_1.cn)('border-input text-primary focus-visible:
|
|
47
|
+
return ((0, jsx_runtime_1.jsx)(RadioGroupPrimitive.Item, { "data-slot": "radio-group-item", className: (0, utils_1.cn)('border-input-border text-primary focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', className), ...props, children: (0, jsx_runtime_1.jsx)(RadioGroupPrimitive.Indicator, { "data-slot": "radio-group-indicator", className: "relative flex items-center justify-center", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Circle, { className: "fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" }) }) }));
|
|
48
48
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/select/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,eAAe,MAAM,wBAAwB,CAAA;AAKzD,iBAAS,MAAM,CAAC,EACd,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,IAAI,CAAC,qBAEnD;AAED,iBAAS,WAAW,CAAC,EACnB,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,KAAK,CAAC,qBAEpD;AAED;;;;GAIG;AACH,iBAAS,WAAW,CAAC,EACnB,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,KAAK,CAAC,qBAEpD;AAED,KAAK,kBAAkB,GAAG,KAAK,CAAC,cAAc,CAC5C,OAAO,eAAe,CAAC,OAAO,CAC/B,GAAG;IACF,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/select/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,eAAe,MAAM,wBAAwB,CAAA;AAKzD,iBAAS,MAAM,CAAC,EACd,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,IAAI,CAAC,qBAEnD;AAED,iBAAS,WAAW,CAAC,EACnB,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,KAAK,CAAC,qBAEpD;AAED;;;;GAIG;AACH,iBAAS,WAAW,CAAC,EACnB,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,KAAK,CAAC,qBAEpD;AAED,KAAK,kBAAkB,GAAG,KAAK,CAAC,cAAc,CAC5C,OAAO,eAAe,CAAC,OAAO,CAC/B,GAAG;IACF,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AA0BD,iBAAS,aAAa,CAAC,EACrB,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,GAAG,KAAK,EACT,EAAE,kBAAkB,qBAsBpB;AAED,iBAAS,oBAAoB,CAAC,EAC5B,SAAS,EACT,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,cAAc,CAAC,qBAU7D;AACD,iBAAS,sBAAsB,CAAC,EAC9B,SAAS,EACT,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,gBAAgB,CAAC,qBAU/D;AAED,iBAAS,aAAa,CAAC,EACrB,SAAS,EACT,QAAQ,EACR,QAAmB,EACnB,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,OAAO,CAAC,qBA0CtD;AAED,iBAAS,WAAW,CAAC,EACnB,SAAS,EACT,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,wBAAwB,CAAC,KAAK,CAAC,qBAQvC;AAED,iBAAS,WAAW,CAAC,EACnB,SAAS,EACT,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,KAAK,CAAC,qBAQpD;AAED,iBAAS,UAAU,CAAC,EAClB,SAAS,EACT,QAAQ,EACR,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,IAAI,CAAC,qBAgBnD;AAED,iBAAS,eAAe,CAAC,EACvB,SAAS,EACT,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,SAAS,CAAC,qBAQxD;AAED,OAAO,EACL,MAAM,EACN,WAAW,EACX,WAAW,EACX,aAAa,EACb,aAAa,EACb,WAAW,EACX,WAAW,EACX,UAAU,EACV,eAAe,EACf,oBAAoB,EACpB,sBAAsB,EACvB,CAAA"}
|
|
@@ -73,15 +73,19 @@ function SelectValue({ ...props }) {
|
|
|
73
73
|
* slot shrink, `truncate` ellipsises what no longer fits — the chevron keeps its
|
|
74
74
|
* place because the value absorbs all the shrinkage.
|
|
75
75
|
*
|
|
76
|
-
* Written as a
|
|
77
|
-
* `className` off `Select.Value` itself
|
|
78
|
-
*
|
|
76
|
+
* Written as a descendant-scoped variant on the TRIGGER because Radix strips
|
|
77
|
+
* `className` off `Select.Value` itself. DESCENDANT, not `>`: a trigger that
|
|
78
|
+
* carries a leading glyph groups the glyph and the value into one flex child
|
|
79
|
+
* (see `SelectField`'s `leadingIcon`), so the value span is no longer the
|
|
80
|
+
* trigger's direct child and a `>` combinator stopped truncating exactly where
|
|
81
|
+
* the trigger has the least room. The wrapper carries `min-w-0` itself, which
|
|
82
|
+
* is what lets the shrinkage reach the value.
|
|
79
83
|
*
|
|
80
84
|
* Truncation hides text, so a trigger showing a long value should also carry
|
|
81
85
|
* `title` — on the trigger, never on the value slot, which Radix renders
|
|
82
86
|
* `pointer-events: none`.
|
|
83
87
|
*/
|
|
84
|
-
const VALUE_OVERFLOW_CLASS = '[
|
|
88
|
+
const VALUE_OVERFLOW_CLASS = '[&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate';
|
|
85
89
|
function SelectTrigger({ className, readOnly, children, ...props }) {
|
|
86
90
|
return ((0, jsx_runtime_1.jsxs)(SelectPrimitive.Trigger, { "data-slot": "select-trigger", "data-size": "default", className: (0, utils_1.cn)('select-trigger select-read-only select-disabled', VALUE_OVERFLOW_CLASS, className), "aria-readonly": readOnly, "data-read-only": readOnly ? '' : undefined, ...props, children: [children, !readOnly && ((0, jsx_runtime_1.jsx)(SelectPrimitive.Icon, { asChild: true, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "select-chevron" }) }))] }));
|
|
87
91
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
--color-select-foreground: var(--color-foreground);
|
|
7
7
|
--color-select-placeholder: var(--color-muted-foreground);
|
|
8
|
-
--color-select-border: var(--
|
|
8
|
+
--color-select-border: hsl(var(--input-border));
|
|
9
9
|
--color-select-chevron: var(--color-container-text);
|
|
10
10
|
}
|
|
11
11
|
|
|
@@ -6,6 +6,48 @@ declare function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPri
|
|
|
6
6
|
declare function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>): React.JSX.Element;
|
|
7
7
|
declare function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>): React.JSX.Element;
|
|
8
8
|
declare function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>): React.JSX.Element;
|
|
9
|
+
/**
|
|
10
|
+
* ⛔ A PANEL BUILT FOR A DESKTOP HAS TO STOP BEING TWO FIFTHS ON A PHONE.
|
|
11
|
+
*
|
|
12
|
+
* `w-2/5` and `p-12` were unconditional. At a 390px viewport that is a 156px
|
|
13
|
+
* panel with 59px of usable width, and a form field inside it measures 9px of
|
|
14
|
+
* text — a sheet that cannot be read, let alone filled in. At 320px the panel
|
|
15
|
+
* is 128px and the field has NOTHING: its own padding is wider than its box.
|
|
16
|
+
* Thirteen product-console routes opened a sheet that looked like that, which
|
|
17
|
+
* is why that console has carried a rule against `[data-slot='sheet-content']`
|
|
18
|
+
* since its M10 lane; this is that rule brought home, and the console can drop
|
|
19
|
+
* its copy.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ THE PHONE VALUES CARRY THE MODIFIER, THE DESKTOP ONES DO NOT, AND THAT
|
|
22
|
+
* ASYMMETRY IS THE WHOLE MECHANISM. `cn` resolves these through tailwind-merge
|
|
23
|
+
* before the cascade sees them, and tailwind-merge only drops a conflicting
|
|
24
|
+
* class carrying the SAME modifiers. So a caller's `w-[594px]` replaces
|
|
25
|
+
* `w-2/5` and leaves `max-sm:w-full` standing: the fifteen product-console
|
|
26
|
+
* sheets that declare a pixel width of their own — and the two that declare
|
|
27
|
+
* `w-full` — all still fill a phone, with no edit at the call site. Written
|
|
28
|
+
* the other way round — `w-full sm:w-2/5` — the caller's bare `w-[594px]`
|
|
29
|
+
* would have been overruled by `sm:w-2/5` at every desktop width instead,
|
|
30
|
+
* which is the same bug pointing the other way.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ IT CUTS BOTH WAYS, AND THE PADDING IS WHERE THAT IS FELT. A caller's bare
|
|
33
|
+
* `p-0` drops `p-12` and cannot reach `max-sm:px-4` either, so a panel that
|
|
34
|
+
* asked for no padding gets 16px a side below 40rem. That is deliberate — it
|
|
35
|
+
* is what makes the phone step reach the call sites that need it most — and it
|
|
36
|
+
* is what product-console's own unlayered rule already did to those five
|
|
37
|
+
* sheets, so nothing there moves. For any other consumer it is a behaviour
|
|
38
|
+
* change from 2.0.0-beta.11, and the way out is one token.
|
|
39
|
+
*
|
|
40
|
+
* A call site that genuinely wants its own phone width or padding says so with
|
|
41
|
+
* the modifier (`max-sm:w-[320px]`, `max-sm:p-0`), which is one token and
|
|
42
|
+
* strictly more than the console's unlayered rule allowed anyone.
|
|
43
|
+
*
|
|
44
|
+
* 40rem rather than 768px, unlike `SidebarTrigger` next door: nothing here is
|
|
45
|
+
* paired with a pixel media query in JavaScript. This is a reading-width
|
|
46
|
+
* decision and it is the complement of `sm:`, so it moves with the reader's
|
|
47
|
+
* font exactly as the rest of the package's responsive steps do. The one panel
|
|
48
|
+
* that IS coupled to the pixel breakpoint is the navigation drawer, and
|
|
49
|
+
* `SidebarRoot` opts it out by restating both tokens under `max-sm:`.
|
|
50
|
+
*/
|
|
9
51
|
declare const sheetVariants: (props?: ({
|
|
10
52
|
side?: "bottom" | "left" | "right" | "top" | null | undefined;
|
|
11
53
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|