@eifi1/ui-kit 0.16.0 → 0.16.1

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.
@@ -5,6 +5,7 @@ import { cn } from "../lib/cn.js";
5
5
  import { useAnnounce } from "../hooks/use-announce.js";
6
6
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels.js";
7
7
  import { Button, Spinner } from "./ui.js";
8
+ import { Tooltip } from "./tooltip.js";
8
9
  const DEFAULT_FILE_PICKER_LABELS = {
9
10
  rejectedType: (name) => `\u201C${name}\u201D is not a supported file type`,
10
11
  rejectedTypeOnly: (accept) => `Only ${accept} files`,
@@ -272,7 +273,10 @@ const FileButton = forwardRef(function FileButton2({
272
273
  }
273
274
  return (
274
275
  // One inline row, so the name sits beside the button wherever the button sits; the
275
- // name truncates (full list in its title) rather than pushing the row wider.
276
+ // name truncates rather than pushing the row wider, and the full list is in the
277
+ // kit's own Tooltip — not a native `title` (0.16.0 shipped one: the one-tooltip rule
278
+ // since keksdose dev#523, and a title never opens on touch). Lazy, so the closed
279
+ // bubble adds nothing to the read-out's text.
276
280
  /* @__PURE__ */ jsxs("span", { className: "inline-flex max-w-full min-w-0 items-center gap-2", children: [
277
281
  button,
278
282
  /* @__PURE__ */ jsx(
@@ -281,9 +285,8 @@ const FileButton = forwardRef(function FileButton2({
281
285
  id: pickedId,
282
286
  "aria-live": "polite",
283
287
  "data-slot": "file-button-name",
284
- title: picked.map((f) => f.name).join(", ") || void 0,
285
- className: "min-w-0 truncate text-sm text-[var(--text-secondary)]",
286
- children: pickedText
288
+ className: "flex min-w-0 text-sm text-[var(--text-secondary)]",
289
+ children: /* @__PURE__ */ jsx(Tooltip, { label: picked.map((f) => f.name).join(", "), lazy: true, className: "min-w-0", children: /* @__PURE__ */ jsx("span", { className: "block truncate", children: pickedText }) })
287
290
  }
288
291
  ),
289
292
  picker.element
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/file-button.tsx"],"sourcesContent":["import { forwardRef, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, DragEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner } from \"./ui\";\n\n/**\n * A button that opens the file picker — the shape all three apps kept writing by hand\n * as a `<Button>` plus a hidden `<input type=\"file\">` plus a ref between them (seven\n * copies in keksdose, two each in kastlan and lenkbank).\n *\n * Every copy had to remember the same four things, and each one forgot at least one:\n *\n * 1. **Reset the input after every pick.** A file input fires `change` only when its\n * value CHANGES, so picking the same file twice in a row — the retry after a failed\n * upload, the second photo of the same receipt — did nothing at all, which reads as\n * a broken button. `value = \"\"` after each pick; always, not per call site.\n * 2. **`type=\"button\"`.** `<Button>` does not set it, and inside a form a bare button\n * submits the form before the picker opens.\n * 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's\n * \"All files\" switch, a drop, and a mobile share sheet all hand over whatever the\n * user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and\n * an optional `isValid`.\n * 4. **Say no without a toast.** A rejection is reported through `onReject`, with a\n * translated message per file, and spoken through a live region — the kit does not\n * decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).\n *\n * The part that is not a button — the hidden input, the check, the announcement — is\n * {@link useFilePicker}, for the case where the thing that opens the picker is someone\n * else's control (a card's \"Add\" action, a menu item, a second button for the camera).\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.\n * Messages are functions of the file name so a translation can put it anywhere. */\nexport interface FilePickerLabels {\n /** The file's type is not in `accept`, when no `accept` is known to name (see\n * `rejectedTypeOnly`). */\n rejectedType: (name: string) => string;\n /** The file's type is not in `accept`, naming what IS accepted: `accept`'s tokens,\n * lower-cased and joined with \", \" (`\".csv\"` => \"Only .csv files\"). Used whenever\n * `accept` is set — kastlan kept a custom `isValid` + `invalidMessage` per form only\n * to say this, and `accept` already knows it. */\n rejectedTypeOnly: (accept: string, name: string) => string;\n /** The file is larger than `maxSize`; `maxSize` arrives formatted (\"5 MB\"). */\n rejectedSize: (name: string, maxSize: string) => string;\n /** The file was one too many for `maxFiles`. */\n rejectedCount: (name: string, maxFiles: number) => string;\n /** `isValid` said no and the caller gave no `invalidMessage`. */\n rejectedInvalid: (name: string) => string;\n /** Spoken instead of the per-file message when more than one file was refused. */\n rejectedMany: (count: number) => string;\n /** Spoken (and shown, on a dropzone) when `onPick` refused the WHOLE pick.\n * Receives the number of files in it. */\n rejectedPick: (count: number) => string;\n /** Spoken after a pick the component itself echoes (the dropzone). */\n selected: (count: number, firstName: string) => string;\n /** The dropzone's remove button for one file. */\n remove: (name: string) => string;\n /** The dropzone's remove-everything button in `multiple` mode. */\n clearAll: string;\n /** Spoken after a remove / clear, since the button that was pressed is gone. */\n removed: (name: string) => string;\n cleared: string;\n /** The dropzone's accessible name, when the host passes no `dropLabel`. */\n dropzone: string;\n /** The dropzone's Browse button, when the host passes no `browseLabel`. */\n browse: string;\n /** What an empty single-file dropzone says (no `emptyLabel` given). */\n empty: string;\n /** …and an empty `multiple` one. */\n emptyMultiple: string;\n /** The dropzone's second line (no `hint` given). Receives `accept` as a person\n * reads it (`\".pdf, image/png\"`), or `\"\"` when anything goes. */\n hint: (accept: string) => string;\n /** What a `busy` dropzone says in place of its body (\"Uploading…\"). */\n busy: string;\n}\n\nexport const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {\n rejectedType: (name) => `“${name}” is not a supported file type`,\n rejectedTypeOnly: (accept) => `Only ${accept} files`,\n rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,\n rejectedCount: (name, maxFiles) =>\n `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`,\n rejectedInvalid: (name) => `“${name}” cannot be used here`,\n rejectedMany: (count) => `${count} files were not added`,\n rejectedPick: (count) =>\n count === 1 ? \"The file was not added\" : `None of the ${count} files were added`,\n selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),\n remove: (name) => `Remove “${name}”`,\n clearAll: \"Remove all files\",\n removed: (name) => `“${name}” removed`,\n cleared: \"All files removed\",\n dropzone: \"File upload\",\n browse: \"Browse\",\n empty: \"Drop a file here\",\n emptyMultiple: \"Drop files here\",\n hint: (accept) => (accept ? `Accepted: ${accept}` : \"Any file type\"),\n busy: \"Uploading…\",\n};\n\n/* ── Screening ───────────────────────────────────────────────────────────── */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\" | \"invalid\";\n\n/** One file the picker refused, and why. `message` is already translated (see\n * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just\n * wants to show it can render `rejections[0].message` as is. */\nexport interface FileRejection {\n file: File;\n reason: FileRejectionReason;\n message: string;\n}\n\n/**\n * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?\n * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`\n * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.\n *\n * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME\n * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND\n * `model/step`: that is how `accept` has to be written for the dialog anyway.\n */\nexport function matchesAccept(file: File, accept: string | undefined): boolean {\n const tokens = (accept ?? \"\")\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean);\n if (tokens.length === 0) return true;\n const name = file.name.toLowerCase();\n // An EMPTY type is the browser saying \"I don't know\", not \"it's something else\": HEIC\n // photos on Windows without the codec arrive with `type === \"\"`. Refusing them\n // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a\n // missing type is inferred from the extension where that is unambiguous, and a file\n // whose type cannot be known at all is given the benefit of the doubt — nothing here\n // proves it does not match, and the server validates what it receives anyway.\n const type = (file.type || typeFromExtension(name) || \"\").toLowerCase();\n if (!type && !tokens.every((t) => t.startsWith(\".\"))) return true;\n return tokens.some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (!type) return false;\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/** MIME types for the extensions a browser most often leaves untyped. */\nconst EXTENSION_TYPES: Record<string, string> = {\n heic: \"image/heic\",\n heif: \"image/heif\",\n avif: \"image/avif\",\n webp: \"image/webp\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n csv: \"text/csv\",\n txt: \"text/plain\",\n};\n\nfunction typeFromExtension(name: string): string | undefined {\n const dot = name.lastIndexOf(\".\");\n return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];\n}\n\n/** @internal `\" .PDF, image/png ,\"` => `\".pdf, image/png\"` — `accept` as a person reads\n * it. Also the dropzone's default hint. */\nexport function formatAccept(accept: string): string {\n return accept\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean)\n .join(\", \");\n}\n\n/** What the pickers screen a pick with. All optional; nothing set accepts everything. */\nexport interface FileScreenOptions {\n accept?: string;\n /** Bytes. Larger files are refused with reason `\"size\"`. */\n maxSize?: number;\n /** How many files one pick may deliver. The rest are refused with reason `\"count\"`\n * — first come, first kept. For a running cap (\"at most 5 attachments\") pass what\n * is LEFT: `maxFiles={5 - attachments.length}`. */\n maxFiles?: number;\n /** The caller's own check, after `accept` and `maxSize`. */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */\n invalidMessage?: string;\n}\n\n/** @internal Split a pick into the files that pass and the ones that do not. */\nexport function screenFiles(\n files: readonly File[],\n opts: FileScreenOptions,\n labels: FilePickerLabels,\n formatSize: (bytes: number) => string,\n): { accepted: File[]; rejected: FileRejection[] } {\n const accepted: File[] = [];\n const rejected: FileRejection[] = [];\n // Name what `accept` takes (\"Only .csv files\") — unless the host translated\n // `rejectedType` but not yet `rejectedTypeOnly`: its own sentence beats an English one.\n const acceptText = opts.accept ? formatAccept(opts.accept) : \"\";\n const useOnly =\n acceptText !== \"\" &&\n (labels.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||\n labels.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);\n const typeMessage = (name: string) =>\n useOnly ? labels.rejectedTypeOnly(acceptText, name) : labels.rejectedType(name);\n for (const file of files) {\n let reason: FileRejectionReason | null = null;\n if (!matchesAccept(file, opts.accept)) reason = \"type\";\n else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = \"size\";\n else if (opts.isValid && !opts.isValid(file)) reason = \"invalid\";\n // Count last, and only against files that passed everything else: a refused\n // file should not use up one of the slots a good one could have had.\n else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = \"count\";\n if (reason === null) {\n accepted.push(file);\n continue;\n }\n const message =\n reason === \"type\"\n ? typeMessage(file.name)\n : reason === \"size\"\n ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))\n : reason === \"count\"\n ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))\n : (opts.invalidMessage ?? labels.rejectedInvalid(file.name));\n rejected.push({ file, reason, message });\n }\n return { accepted, rejected };\n}\n\n/**\n * Judges a pick as a whole — keksdose's \"refuse the pick if any file is bad\", which\n * with `onFiles` + `onReject` alone had to be rebuilt from two calls in a microtask.\n *\n * Called ONCE per pick, after screening and before `onFiles` / `onReject`, with both\n * halves (either may be empty). Return `false` to refuse the whole pick: the accepted\n * files are not delivered, `onReject` still receives what screening refused, and one\n * sentence (`labels.rejectedPick`) is spoken for the pick instead of a per-file one.\n * Return nothing to let the pick through as usual.\n *\n * ```tsx\n * onPick={(ok, bad) => bad.length === 0 || false} // all or nothing\n * ```\n */\nexport type FilePickHandler = (accepted: File[], rejected: FileRejection[]) => boolean | void;\n\n/** @internal The shared tail of both pickers: run `onPick`, and say whether the pick\n * stands. A pick with nothing accepted has nothing to refuse, so `false` there is\n * treated as no verdict and the usual per-file messages are kept. */\nexport function judgePick(\n onPick: FilePickHandler | undefined,\n accepted: File[],\n rejected: FileRejection[],\n): boolean {\n if (!onPick) return true;\n return onPick(accepted, rejected) !== false || accepted.length === 0;\n}\n\n/** One sentence for a whole batch of refusals: the file's own message for one, a\n * count for several — reading out five sentences in a row helps nobody. */\nexport function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {\n if (rejected.length === 0) return \"\";\n if (rejected.length === 1) return rejected[0].message;\n return labels.rejectedMany(rejected.length);\n}\n\n/* ── useFilePicker ───────────────────────────────────────────────────────── */\n\nexport interface UseFilePickerOptions extends FileScreenOptions {\n /** Let one pick deliver several files. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /**\n * Ask a phone for its camera instead of the file chooser: `\"environment\"` is the\n * back camera, `\"user\"` the front. A HINT — desktop browsers ignore it, and Chrome\n * drops it when `multiple` is also set (a camera cannot deliver a list), which is why\n * keksdose's camera input has no `multiple`. Pass one or the other.\n */\n capture?: boolean | \"user\" | \"environment\";\n /** The files that passed, in pick order. Never called with an empty array.\n * Optional since 0.7 for a caller that takes the pick through `onPick` alone. */\n onFiles?: (files: File[]) => void;\n /** See {@link FilePickHandler}: the whole pick at once, with the power to refuse it. */\n onPick?: FilePickHandler;\n /** The files that did not, with a translated message each. The refusals are also\n * spoken through a live region, so this is for SHOWING them, not for a11y. */\n onReject?: (rejections: FileRejection[]) => void;\n /** `open()` and `take()` do nothing while set. */\n disabled?: boolean;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n}\n\nexport interface UseFilePickerReturn {\n /** Open the system picker. Call it from a click handler — browsers only open a file\n * dialog in response to a user gesture. */\n open: () => void;\n /** Screen and deliver files that arrived some other way — a drop, a paste. Honours\n * `multiple` (only the first file without it) and every check. */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The hidden input and the live region. Render it once, anywhere — it takes no\n * space and needs no positioned ancestor. */\n element: ReactElement;\n}\n\n/**\n * The headless half of {@link FileButton}: a hidden file input you can open from any\n * control, with the reset, the screening and the announcement built in.\n *\n * ```tsx\n * const picker = useFilePicker({ accept: \".pdf\", onFiles: ([f]) => upload(f) });\n * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;\n * ```\n */\nexport function useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n disabled,\n labels: labelsProp,\n}: UseFilePickerOptions): UseFilePickerReturn {\n const inputRef = useRef<HTMLInputElement>(null);\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n const fileText = useKitFileLabels();\n // Assertive: a refusal is a failure the user has to hear before they move on, and\n // it is the only thing this region ever says.\n const { announce, regionProps } = useAnnounce({ politeness: \"assertive\" });\n\n const take = (list: ArrayLike<File> | null | undefined) => {\n if (disabled || !list || list.length === 0) return;\n const all = Array.from(list);\n const files = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n files,\n // A single-file picker holds one file by definition; `maxFiles` is a multi-mode\n // cap and would only ever say \"0\" there if a caller passed it by mistake.\n { accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },\n labels,\n fileText.size,\n );\n if (!judgePick(onPick, accepted, rejected)) {\n if (rejected.length > 0) onReject?.(rejected);\n announce(labels.rejectedPick(files.length));\n return;\n }\n if (accepted.length > 0) onFiles?.(accepted);\n if (rejected.length > 0) {\n onReject?.(rejected);\n announce(summariseRejections(rejected, labels));\n }\n };\n\n const element = (\n <>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n capture={capture}\n disabled={disabled}\n // `hidden` (display: none), not `sr-only`: the control a user reaches is the\n // button, and a second, nameless tab stop for the same action is noise. A\n // display:none file input still opens on `.click()` in every current browser —\n // and, unlike `sr-only`, it cannot escape to the initial containing block and\n // stretch the page (see the note in `file-dropzone.tsx`).\n className=\"hidden\"\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n // Copy BEFORE the reset: `files` is a live view of the input's value, and\n // clearing the value empties it.\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n take(picked);\n }}\n />\n <span {...regionProps} />\n </>\n );\n\n return {\n open: () => {\n if (!disabled) inputRef.current?.click();\n },\n take,\n element,\n };\n}\n\n/* ── FileButton ──────────────────────────────────────────────────────────── */\n\ntype ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;\n\nexport interface FileButtonProps\n extends Omit<UseFilePickerOptions, \"disabled\">,\n // `onDrop` & co. stay the caller's own when `droppable` is off; `type` is fixed to\n // \"button\" (see 2. above) — a file trigger never submits a form.\n Omit<ButtonOwnProps, \"type\" | \"children\" | \"accept\" | \"capture\" | \"multiple\"> {\n /** The button's content — usually an icon and a word. It is the accessible name. */\n children: ReactNode;\n /** Busy: disabled, `aria-busy`, and a spinner before the content — for \"uploading\". */\n pending?: boolean;\n /** Also accept files dropped ON the button (lenkbank's \"drop onto the button\").\n * Off by default: a button that silently takes drops is a surprise on a page that\n * has a real drop target elsewhere. */\n droppable?: boolean;\n /**\n * Show what was picked beside the button: the file's name, or \"3 files selected\"\n * (`filePicker.selected`) for several — a native `<input type=\"file\">` says this,\n * and a bare button that takes a file and says nothing leaves the user to trust it.\n * The read-out is a polite live region and describes the button, so it is heard\n * when it changes and again on focus. It holds the last ACCEPTED pick: a refused\n * file leaves it as it was. Off by default, so the button's layout is unchanged.\n */\n showFileName?: boolean;\n}\n\n/**\n * {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop\n * pass through, and the ref is the `<button>` (so `ref.current.click()` opens the\n * picker from elsewhere too).\n *\n * ```tsx\n * <FileButton accept=\"image/*,application/pdf\" capture=\"environment\" variant=\"secondary\"\n * maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>\n * <Camera aria-hidden /> Photograph receipt\n * </FileButton>\n * ```\n */\nexport const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(function FileButton(\n {\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n labels,\n pending,\n droppable,\n showFileName = false,\n disabled,\n children,\n className,\n onClick,\n onDragEnter,\n onDragOver,\n onDragLeave,\n onDrop,\n ...rest\n },\n ref,\n) {\n const inert = Boolean(disabled || pending);\n const [picked, setPicked] = useState<readonly File[]>([]);\n const pickedId = useId();\n const pickerLabels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labels);\n const picker = useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles: showFileName\n ? (files) => {\n setPicked(files);\n onFiles?.(files);\n }\n : onFiles,\n onPick,\n onReject,\n labels,\n disabled: inert,\n });\n const pickedText =\n picked.length === 0 ? \"\" : picked.length === 1 ? picked[0].name : pickerLabels.selected(picked.length, picked[0].name);\n const [dragOver, setDragOver] = useState(false);\n\n const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n // Only when `droppable`: otherwise the caller's own handlers are passed untouched.\n const dropHandlers = droppable\n ? {\n onDragEnter: (e: DragEvent<HTMLButtonElement>) => {\n onDragEnter?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n if (!inert) setDragOver(true);\n },\n onDragOver: (e: DragEvent<HTMLButtonElement>) => {\n onDragOver?.(e);\n if (!hasFiles(e)) return;\n // Always cancel for a file drag, busy or not: an uncancelled drop makes the\n // browser NAVIGATE to the file, which loses the page.\n e.preventDefault();\n e.dataTransfer.dropEffect = inert ? \"none\" : \"copy\";\n },\n onDragLeave: (e: DragEvent<HTMLButtonElement>) => {\n onDragLeave?.(e);\n if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n setDragOver(false);\n },\n onDrop: (e: DragEvent<HTMLButtonElement>) => {\n onDrop?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n setDragOver(false);\n picker.take(e.dataTransfer.files);\n },\n }\n : { onDragEnter, onDragOver, onDragLeave, onDrop };\n\n // `Button` is a plain function component; under React 19 `ref` is an ordinary prop\n // and reaches the `<button>` through its `...rest`. Its props type just does not\n // declare it, hence the widening here rather than a second button implementation.\n const refProp = { ref } as { ref?: Ref<HTMLButtonElement> };\n\n const button = (\n <Button\n {...rest}\n {...refProp}\n {...dropHandlers}\n type=\"button\"\n disabled={inert}\n aria-busy={pending || undefined}\n aria-describedby={\n showFileName && pickedText\n ? [rest[\"aria-describedby\"], pickedId].filter(Boolean).join(\" \")\n : rest[\"aria-describedby\"]\n }\n data-drag-over={dragOver || undefined}\n className={cn(\n // A live drag gets the focus ring's colour as a ring: \"let go here\", in the\n // same vocabulary the button already uses for \"you are here\".\n dragOver && \"ring-2 ring-[var(--brand)]\",\n // Beside a long file name it is the NAME that gives way, never the button.\n showFileName && \"shrink-0\",\n className,\n )}\n onClick={(e) => {\n onClick?.(e);\n if (!e.defaultPrevented) picker.open();\n }}\n >\n {/* `label={null}`: the spinner is decoration here — `aria-busy` says the same\n thing on the button, and a spoken \"Loading\" would run into its name. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {children}\n </Button>\n );\n\n if (!showFileName) {\n return (\n <>\n {button}\n {picker.element}\n </>\n );\n }\n return (\n // One inline row, so the name sits beside the button wherever the button sits; the\n // name truncates (full list in its title) rather than pushing the row wider.\n <span className=\"inline-flex max-w-full min-w-0 items-center gap-2\">\n {button}\n <span\n id={pickedId}\n aria-live=\"polite\"\n data-slot=\"file-button-name\"\n title={picked.map((f) => f.name).join(\", \") || undefined}\n className=\"min-w-0 truncate text-sm text-[var(--text-secondary)]\"\n >\n {pickedText}\n </span>\n {picker.element}\n </span>\n );\n});\nFileButton.displayName = \"FileButton\";\n"],"mappings":"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","names":["FileButton"]}
1
+ {"version":3,"sources":["../../src/components/file-button.tsx"],"sourcesContent":["import { forwardRef, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, DragEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner } from \"./ui\";\nimport { Tooltip } from \"./tooltip\";\n\n/**\n * A button that opens the file picker — the shape all three apps kept writing by hand\n * as a `<Button>` plus a hidden `<input type=\"file\">` plus a ref between them (seven\n * copies in keksdose, two each in kastlan and lenkbank).\n *\n * Every copy had to remember the same four things, and each one forgot at least one:\n *\n * 1. **Reset the input after every pick.** A file input fires `change` only when its\n * value CHANGES, so picking the same file twice in a row — the retry after a failed\n * upload, the second photo of the same receipt — did nothing at all, which reads as\n * a broken button. `value = \"\"` after each pick; always, not per call site.\n * 2. **`type=\"button\"`.** `<Button>` does not set it, and inside a form a bare button\n * submits the form before the picker opens.\n * 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's\n * \"All files\" switch, a drop, and a mobile share sheet all hand over whatever the\n * user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and\n * an optional `isValid`.\n * 4. **Say no without a toast.** A rejection is reported through `onReject`, with a\n * translated message per file, and spoken through a live region — the kit does not\n * decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).\n *\n * The part that is not a button — the hidden input, the check, the announcement — is\n * {@link useFilePicker}, for the case where the thing that opens the picker is someone\n * else's control (a card's \"Add\" action, a menu item, a second button for the camera).\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.\n * Messages are functions of the file name so a translation can put it anywhere. */\nexport interface FilePickerLabels {\n /** The file's type is not in `accept`, when no `accept` is known to name (see\n * `rejectedTypeOnly`). */\n rejectedType: (name: string) => string;\n /** The file's type is not in `accept`, naming what IS accepted: `accept`'s tokens,\n * lower-cased and joined with \", \" (`\".csv\"` => \"Only .csv files\"). Used whenever\n * `accept` is set — kastlan kept a custom `isValid` + `invalidMessage` per form only\n * to say this, and `accept` already knows it. */\n rejectedTypeOnly: (accept: string, name: string) => string;\n /** The file is larger than `maxSize`; `maxSize` arrives formatted (\"5 MB\"). */\n rejectedSize: (name: string, maxSize: string) => string;\n /** The file was one too many for `maxFiles`. */\n rejectedCount: (name: string, maxFiles: number) => string;\n /** `isValid` said no and the caller gave no `invalidMessage`. */\n rejectedInvalid: (name: string) => string;\n /** Spoken instead of the per-file message when more than one file was refused. */\n rejectedMany: (count: number) => string;\n /** Spoken (and shown, on a dropzone) when `onPick` refused the WHOLE pick.\n * Receives the number of files in it. */\n rejectedPick: (count: number) => string;\n /** Spoken after a pick the component itself echoes (the dropzone). */\n selected: (count: number, firstName: string) => string;\n /** The dropzone's remove button for one file. */\n remove: (name: string) => string;\n /** The dropzone's remove-everything button in `multiple` mode. */\n clearAll: string;\n /** Spoken after a remove / clear, since the button that was pressed is gone. */\n removed: (name: string) => string;\n cleared: string;\n /** The dropzone's accessible name, when the host passes no `dropLabel`. */\n dropzone: string;\n /** The dropzone's Browse button, when the host passes no `browseLabel`. */\n browse: string;\n /** What an empty single-file dropzone says (no `emptyLabel` given). */\n empty: string;\n /** …and an empty `multiple` one. */\n emptyMultiple: string;\n /** The dropzone's second line (no `hint` given). Receives `accept` as a person\n * reads it (`\".pdf, image/png\"`), or `\"\"` when anything goes. */\n hint: (accept: string) => string;\n /** What a `busy` dropzone says in place of its body (\"Uploading…\"). */\n busy: string;\n}\n\nexport const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {\n rejectedType: (name) => `“${name}” is not a supported file type`,\n rejectedTypeOnly: (accept) => `Only ${accept} files`,\n rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,\n rejectedCount: (name, maxFiles) =>\n `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`,\n rejectedInvalid: (name) => `“${name}” cannot be used here`,\n rejectedMany: (count) => `${count} files were not added`,\n rejectedPick: (count) =>\n count === 1 ? \"The file was not added\" : `None of the ${count} files were added`,\n selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),\n remove: (name) => `Remove “${name}”`,\n clearAll: \"Remove all files\",\n removed: (name) => `“${name}” removed`,\n cleared: \"All files removed\",\n dropzone: \"File upload\",\n browse: \"Browse\",\n empty: \"Drop a file here\",\n emptyMultiple: \"Drop files here\",\n hint: (accept) => (accept ? `Accepted: ${accept}` : \"Any file type\"),\n busy: \"Uploading…\",\n};\n\n/* ── Screening ───────────────────────────────────────────────────────────── */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\" | \"invalid\";\n\n/** One file the picker refused, and why. `message` is already translated (see\n * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just\n * wants to show it can render `rejections[0].message` as is. */\nexport interface FileRejection {\n file: File;\n reason: FileRejectionReason;\n message: string;\n}\n\n/**\n * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?\n * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`\n * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.\n *\n * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME\n * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND\n * `model/step`: that is how `accept` has to be written for the dialog anyway.\n */\nexport function matchesAccept(file: File, accept: string | undefined): boolean {\n const tokens = (accept ?? \"\")\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean);\n if (tokens.length === 0) return true;\n const name = file.name.toLowerCase();\n // An EMPTY type is the browser saying \"I don't know\", not \"it's something else\": HEIC\n // photos on Windows without the codec arrive with `type === \"\"`. Refusing them\n // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a\n // missing type is inferred from the extension where that is unambiguous, and a file\n // whose type cannot be known at all is given the benefit of the doubt — nothing here\n // proves it does not match, and the server validates what it receives anyway.\n const type = (file.type || typeFromExtension(name) || \"\").toLowerCase();\n if (!type && !tokens.every((t) => t.startsWith(\".\"))) return true;\n return tokens.some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (!type) return false;\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/** MIME types for the extensions a browser most often leaves untyped. */\nconst EXTENSION_TYPES: Record<string, string> = {\n heic: \"image/heic\",\n heif: \"image/heif\",\n avif: \"image/avif\",\n webp: \"image/webp\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n csv: \"text/csv\",\n txt: \"text/plain\",\n};\n\nfunction typeFromExtension(name: string): string | undefined {\n const dot = name.lastIndexOf(\".\");\n return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];\n}\n\n/** @internal `\" .PDF, image/png ,\"` => `\".pdf, image/png\"` — `accept` as a person reads\n * it. Also the dropzone's default hint. */\nexport function formatAccept(accept: string): string {\n return accept\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean)\n .join(\", \");\n}\n\n/** What the pickers screen a pick with. All optional; nothing set accepts everything. */\nexport interface FileScreenOptions {\n accept?: string;\n /** Bytes. Larger files are refused with reason `\"size\"`. */\n maxSize?: number;\n /** How many files one pick may deliver. The rest are refused with reason `\"count\"`\n * — first come, first kept. For a running cap (\"at most 5 attachments\") pass what\n * is LEFT: `maxFiles={5 - attachments.length}`. */\n maxFiles?: number;\n /** The caller's own check, after `accept` and `maxSize`. */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */\n invalidMessage?: string;\n}\n\n/** @internal Split a pick into the files that pass and the ones that do not. */\nexport function screenFiles(\n files: readonly File[],\n opts: FileScreenOptions,\n labels: FilePickerLabels,\n formatSize: (bytes: number) => string,\n): { accepted: File[]; rejected: FileRejection[] } {\n const accepted: File[] = [];\n const rejected: FileRejection[] = [];\n // Name what `accept` takes (\"Only .csv files\") — unless the host translated\n // `rejectedType` but not yet `rejectedTypeOnly`: its own sentence beats an English one.\n const acceptText = opts.accept ? formatAccept(opts.accept) : \"\";\n const useOnly =\n acceptText !== \"\" &&\n (labels.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||\n labels.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);\n const typeMessage = (name: string) =>\n useOnly ? labels.rejectedTypeOnly(acceptText, name) : labels.rejectedType(name);\n for (const file of files) {\n let reason: FileRejectionReason | null = null;\n if (!matchesAccept(file, opts.accept)) reason = \"type\";\n else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = \"size\";\n else if (opts.isValid && !opts.isValid(file)) reason = \"invalid\";\n // Count last, and only against files that passed everything else: a refused\n // file should not use up one of the slots a good one could have had.\n else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = \"count\";\n if (reason === null) {\n accepted.push(file);\n continue;\n }\n const message =\n reason === \"type\"\n ? typeMessage(file.name)\n : reason === \"size\"\n ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))\n : reason === \"count\"\n ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))\n : (opts.invalidMessage ?? labels.rejectedInvalid(file.name));\n rejected.push({ file, reason, message });\n }\n return { accepted, rejected };\n}\n\n/**\n * Judges a pick as a whole — keksdose's \"refuse the pick if any file is bad\", which\n * with `onFiles` + `onReject` alone had to be rebuilt from two calls in a microtask.\n *\n * Called ONCE per pick, after screening and before `onFiles` / `onReject`, with both\n * halves (either may be empty). Return `false` to refuse the whole pick: the accepted\n * files are not delivered, `onReject` still receives what screening refused, and one\n * sentence (`labels.rejectedPick`) is spoken for the pick instead of a per-file one.\n * Return nothing to let the pick through as usual.\n *\n * ```tsx\n * onPick={(ok, bad) => bad.length === 0 || false} // all or nothing\n * ```\n */\nexport type FilePickHandler = (accepted: File[], rejected: FileRejection[]) => boolean | void;\n\n/** @internal The shared tail of both pickers: run `onPick`, and say whether the pick\n * stands. A pick with nothing accepted has nothing to refuse, so `false` there is\n * treated as no verdict and the usual per-file messages are kept. */\nexport function judgePick(\n onPick: FilePickHandler | undefined,\n accepted: File[],\n rejected: FileRejection[],\n): boolean {\n if (!onPick) return true;\n return onPick(accepted, rejected) !== false || accepted.length === 0;\n}\n\n/** One sentence for a whole batch of refusals: the file's own message for one, a\n * count for several — reading out five sentences in a row helps nobody. */\nexport function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {\n if (rejected.length === 0) return \"\";\n if (rejected.length === 1) return rejected[0].message;\n return labels.rejectedMany(rejected.length);\n}\n\n/* ── useFilePicker ───────────────────────────────────────────────────────── */\n\nexport interface UseFilePickerOptions extends FileScreenOptions {\n /** Let one pick deliver several files. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /**\n * Ask a phone for its camera instead of the file chooser: `\"environment\"` is the\n * back camera, `\"user\"` the front. A HINT — desktop browsers ignore it, and Chrome\n * drops it when `multiple` is also set (a camera cannot deliver a list), which is why\n * keksdose's camera input has no `multiple`. Pass one or the other.\n */\n capture?: boolean | \"user\" | \"environment\";\n /** The files that passed, in pick order. Never called with an empty array.\n * Optional since 0.7 for a caller that takes the pick through `onPick` alone. */\n onFiles?: (files: File[]) => void;\n /** See {@link FilePickHandler}: the whole pick at once, with the power to refuse it. */\n onPick?: FilePickHandler;\n /** The files that did not, with a translated message each. The refusals are also\n * spoken through a live region, so this is for SHOWING them, not for a11y. */\n onReject?: (rejections: FileRejection[]) => void;\n /** `open()` and `take()` do nothing while set. */\n disabled?: boolean;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n}\n\nexport interface UseFilePickerReturn {\n /** Open the system picker. Call it from a click handler — browsers only open a file\n * dialog in response to a user gesture. */\n open: () => void;\n /** Screen and deliver files that arrived some other way — a drop, a paste. Honours\n * `multiple` (only the first file without it) and every check. */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The hidden input and the live region. Render it once, anywhere — it takes no\n * space and needs no positioned ancestor. */\n element: ReactElement;\n}\n\n/**\n * The headless half of {@link FileButton}: a hidden file input you can open from any\n * control, with the reset, the screening and the announcement built in.\n *\n * ```tsx\n * const picker = useFilePicker({ accept: \".pdf\", onFiles: ([f]) => upload(f) });\n * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;\n * ```\n */\nexport function useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n disabled,\n labels: labelsProp,\n}: UseFilePickerOptions): UseFilePickerReturn {\n const inputRef = useRef<HTMLInputElement>(null);\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n const fileText = useKitFileLabels();\n // Assertive: a refusal is a failure the user has to hear before they move on, and\n // it is the only thing this region ever says.\n const { announce, regionProps } = useAnnounce({ politeness: \"assertive\" });\n\n const take = (list: ArrayLike<File> | null | undefined) => {\n if (disabled || !list || list.length === 0) return;\n const all = Array.from(list);\n const files = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n files,\n // A single-file picker holds one file by definition; `maxFiles` is a multi-mode\n // cap and would only ever say \"0\" there if a caller passed it by mistake.\n { accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },\n labels,\n fileText.size,\n );\n if (!judgePick(onPick, accepted, rejected)) {\n if (rejected.length > 0) onReject?.(rejected);\n announce(labels.rejectedPick(files.length));\n return;\n }\n if (accepted.length > 0) onFiles?.(accepted);\n if (rejected.length > 0) {\n onReject?.(rejected);\n announce(summariseRejections(rejected, labels));\n }\n };\n\n const element = (\n <>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n capture={capture}\n disabled={disabled}\n // `hidden` (display: none), not `sr-only`: the control a user reaches is the\n // button, and a second, nameless tab stop for the same action is noise. A\n // display:none file input still opens on `.click()` in every current browser —\n // and, unlike `sr-only`, it cannot escape to the initial containing block and\n // stretch the page (see the note in `file-dropzone.tsx`).\n className=\"hidden\"\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n // Copy BEFORE the reset: `files` is a live view of the input's value, and\n // clearing the value empties it.\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n take(picked);\n }}\n />\n <span {...regionProps} />\n </>\n );\n\n return {\n open: () => {\n if (!disabled) inputRef.current?.click();\n },\n take,\n element,\n };\n}\n\n/* ── FileButton ──────────────────────────────────────────────────────────── */\n\ntype ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;\n\nexport interface FileButtonProps\n extends Omit<UseFilePickerOptions, \"disabled\">,\n // `onDrop` & co. stay the caller's own when `droppable` is off; `type` is fixed to\n // \"button\" (see 2. above) — a file trigger never submits a form.\n Omit<ButtonOwnProps, \"type\" | \"children\" | \"accept\" | \"capture\" | \"multiple\"> {\n /** The button's content — usually an icon and a word. It is the accessible name. */\n children: ReactNode;\n /** Busy: disabled, `aria-busy`, and a spinner before the content — for \"uploading\". */\n pending?: boolean;\n /** Also accept files dropped ON the button (lenkbank's \"drop onto the button\").\n * Off by default: a button that silently takes drops is a surprise on a page that\n * has a real drop target elsewhere. */\n droppable?: boolean;\n /**\n * Show what was picked beside the button: the file's name, or \"3 files selected\"\n * (`filePicker.selected`) for several — a native `<input type=\"file\">` says this,\n * and a bare button that takes a file and says nothing leaves the user to trust it.\n * The read-out is a polite live region and describes the button, so it is heard\n * when it changes and again on focus. It holds the last ACCEPTED pick: a refused\n * file leaves it as it was. Off by default, so the button's layout is unchanged.\n */\n showFileName?: boolean;\n}\n\n/**\n * {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop\n * pass through, and the ref is the `<button>` (so `ref.current.click()` opens the\n * picker from elsewhere too).\n *\n * ```tsx\n * <FileButton accept=\"image/*,application/pdf\" capture=\"environment\" variant=\"secondary\"\n * maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>\n * <Camera aria-hidden /> Photograph receipt\n * </FileButton>\n * ```\n */\nexport const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(function FileButton(\n {\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n labels,\n pending,\n droppable,\n showFileName = false,\n disabled,\n children,\n className,\n onClick,\n onDragEnter,\n onDragOver,\n onDragLeave,\n onDrop,\n ...rest\n },\n ref,\n) {\n const inert = Boolean(disabled || pending);\n const [picked, setPicked] = useState<readonly File[]>([]);\n const pickedId = useId();\n const pickerLabels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labels);\n const picker = useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles: showFileName\n ? (files) => {\n setPicked(files);\n onFiles?.(files);\n }\n : onFiles,\n onPick,\n onReject,\n labels,\n disabled: inert,\n });\n const pickedText =\n picked.length === 0 ? \"\" : picked.length === 1 ? picked[0].name : pickerLabels.selected(picked.length, picked[0].name);\n const [dragOver, setDragOver] = useState(false);\n\n const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n // Only when `droppable`: otherwise the caller's own handlers are passed untouched.\n const dropHandlers = droppable\n ? {\n onDragEnter: (e: DragEvent<HTMLButtonElement>) => {\n onDragEnter?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n if (!inert) setDragOver(true);\n },\n onDragOver: (e: DragEvent<HTMLButtonElement>) => {\n onDragOver?.(e);\n if (!hasFiles(e)) return;\n // Always cancel for a file drag, busy or not: an uncancelled drop makes the\n // browser NAVIGATE to the file, which loses the page.\n e.preventDefault();\n e.dataTransfer.dropEffect = inert ? \"none\" : \"copy\";\n },\n onDragLeave: (e: DragEvent<HTMLButtonElement>) => {\n onDragLeave?.(e);\n if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n setDragOver(false);\n },\n onDrop: (e: DragEvent<HTMLButtonElement>) => {\n onDrop?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n setDragOver(false);\n picker.take(e.dataTransfer.files);\n },\n }\n : { onDragEnter, onDragOver, onDragLeave, onDrop };\n\n // `Button` is a plain function component; under React 19 `ref` is an ordinary prop\n // and reaches the `<button>` through its `...rest`. Its props type just does not\n // declare it, hence the widening here rather than a second button implementation.\n const refProp = { ref } as { ref?: Ref<HTMLButtonElement> };\n\n const button = (\n <Button\n {...rest}\n {...refProp}\n {...dropHandlers}\n type=\"button\"\n disabled={inert}\n aria-busy={pending || undefined}\n aria-describedby={\n showFileName && pickedText\n ? [rest[\"aria-describedby\"], pickedId].filter(Boolean).join(\" \")\n : rest[\"aria-describedby\"]\n }\n data-drag-over={dragOver || undefined}\n className={cn(\n // A live drag gets the focus ring's colour as a ring: \"let go here\", in the\n // same vocabulary the button already uses for \"you are here\".\n dragOver && \"ring-2 ring-[var(--brand)]\",\n // Beside a long file name it is the NAME that gives way, never the button.\n showFileName && \"shrink-0\",\n className,\n )}\n onClick={(e) => {\n onClick?.(e);\n if (!e.defaultPrevented) picker.open();\n }}\n >\n {/* `label={null}`: the spinner is decoration here — `aria-busy` says the same\n thing on the button, and a spoken \"Loading\" would run into its name. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {children}\n </Button>\n );\n\n if (!showFileName) {\n return (\n <>\n {button}\n {picker.element}\n </>\n );\n }\n return (\n // One inline row, so the name sits beside the button wherever the button sits; the\n // name truncates rather than pushing the row wider, and the full list is in the\n // kit's own Tooltip — not a native `title` (0.16.0 shipped one: the one-tooltip rule\n // since keksdose dev#523, and a title never opens on touch). Lazy, so the closed\n // bubble adds nothing to the read-out's text.\n <span className=\"inline-flex max-w-full min-w-0 items-center gap-2\">\n {button}\n {/* The live region is the OUTER span and never remounts: a Tooltip with an empty\n label renders no wrapper, so wrapping the region itself would swap its element\n the moment the first file arrives — and a fresh live region may not announce. */}\n <span\n id={pickedId}\n aria-live=\"polite\"\n data-slot=\"file-button-name\"\n className=\"flex min-w-0 text-sm text-[var(--text-secondary)]\"\n >\n <Tooltip label={picked.map((f) => f.name).join(\", \")} lazy className=\"min-w-0\">\n <span className=\"block truncate\">{pickedText}</span>\n </Tooltip>\n </span>\n {picker.element}\n </span>\n );\n});\nFileButton.displayName = \"FileButton\";\n"],"mappings":";AA+WI,mBACE,KADF;AA/WJ,SAAS,YAAY,OAAO,QAAQ,gBAAgB;AAEpD,SAAS,UAAU;AACnB,SAAS,mBAAmB;AAC5B,SAAS,kBAAkB,oBAAoB;AAC/C,SAAS,QAAQ,eAAe;AAChC,SAAS,eAAe;AA4EjB,MAAM,6BAA+C;AAAA,EAC1D,cAAc,CAAC,SAAS,SAAI,IAAI;AAAA,EAChC,kBAAkB,CAAC,WAAW,QAAQ,MAAM;AAAA,EAC5C,cAAc,CAAC,MAAM,YAAY,SAAI,IAAI,yBAAoB,OAAO;AAAA,EACpE,eAAe,CAAC,MAAM,aACpB,SAAI,IAAI,iCAA4B,QAAQ,IAAI,aAAa,IAAI,SAAS,OAAO;AAAA,EACnF,iBAAiB,CAAC,SAAS,SAAI,IAAI;AAAA,EACnC,cAAc,CAAC,UAAU,GAAG,KAAK;AAAA,EACjC,cAAc,CAAC,UACb,UAAU,IAAI,2BAA2B,eAAe,KAAK;AAAA,EAC/D,UAAU,CAAC,OAAO,cAAe,UAAU,IAAI,SAAI,SAAS,oBAAe,GAAG,KAAK;AAAA,EACnF,QAAQ,CAAC,SAAS,gBAAW,IAAI;AAAA,EACjC,UAAU;AAAA,EACV,SAAS,CAAC,SAAS,SAAI,IAAI;AAAA,EAC3B,SAAS;AAAA,EACT,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,eAAe;AAAA,EACf,MAAM,CAAC,WAAY,SAAS,aAAa,MAAM,KAAK;AAAA,EACpD,MAAM;AACR;AAwBO,SAAS,cAAc,MAAY,QAAqC;AAC7E,QAAM,UAAU,UAAU,IACvB,MAAM,GAAG,EACT,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,YAAY,CAAC,EACjC,OAAO,OAAO;AACjB,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,QAAM,OAAO,KAAK,KAAK,YAAY;AAOnC,QAAM,QAAQ,KAAK,QAAQ,kBAAkB,IAAI,KAAK,IAAI,YAAY;AACtE,MAAI,CAAC,QAAQ,CAAC,OAAO,MAAM,CAAC,MAAM,EAAE,WAAW,GAAG,CAAC,EAAG,QAAO;AAC7D,SAAO,OAAO,KAAK,CAAC,UAAU;AAC5B,QAAI,MAAM,WAAW,GAAG,EAAG,QAAO,KAAK,SAAS,KAAK;AACrD,QAAI,CAAC,KAAM,QAAO;AAClB,QAAI,MAAM,SAAS,IAAI,EAAG,QAAO,KAAK,WAAW,MAAM,MAAM,GAAG,EAAE,CAAC;AACnE,WAAO,SAAS;AAAA,EAClB,CAAC;AACH;AAGA,MAAM,kBAA0C;AAAA,EAC9C,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AACP;AAEA,SAAS,kBAAkB,MAAkC;AAC3D,QAAM,MAAM,KAAK,YAAY,GAAG;AAChC,SAAO,QAAQ,KAAK,SAAY,gBAAgB,KAAK,MAAM,MAAM,CAAC,CAAC;AACrE;AAIO,SAAS,aAAa,QAAwB;AACnD,SAAO,OACJ,MAAM,GAAG,EACT,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,YAAY,CAAC,EACjC,OAAO,OAAO,EACd,KAAK,IAAI;AACd;AAkBO,SAAS,YACd,OACA,MACA,QACA,YACiD;AACjD,QAAM,WAAmB,CAAC;AAC1B,QAAM,WAA4B,CAAC;AAGnC,QAAM,aAAa,KAAK,SAAS,aAAa,KAAK,MAAM,IAAI;AAC7D,QAAM,UACJ,eAAe,OACd,OAAO,qBAAqB,2BAA2B,oBACtD,OAAO,iBAAiB,2BAA2B;AACvD,QAAM,cAAc,CAAC,SACnB,UAAU,OAAO,iBAAiB,YAAY,IAAI,IAAI,OAAO,aAAa,IAAI;AAChF,aAAW,QAAQ,OAAO;AACxB,QAAI,SAAqC;AACzC,QAAI,CAAC,cAAc,MAAM,KAAK,MAAM,EAAG,UAAS;AAAA,aACvC,KAAK,YAAY,UAAa,KAAK,OAAO,KAAK,QAAS,UAAS;AAAA,aACjE,KAAK,WAAW,CAAC,KAAK,QAAQ,IAAI,EAAG,UAAS;AAAA,aAG9C,KAAK,aAAa,UAAa,SAAS,UAAU,KAAK,SAAU,UAAS;AACnF,QAAI,WAAW,MAAM;AACnB,eAAS,KAAK,IAAI;AAClB;AAAA,IACF;AACA,UAAM,UACJ,WAAW,SACP,YAAY,KAAK,IAAI,IACrB,WAAW,SACT,OAAO,aAAa,KAAK,MAAM,WAAW,KAAK,WAAW,CAAC,CAAC,IAC5D,WAAW,UACT,OAAO,cAAc,KAAK,MAAM,KAAK,IAAI,GAAG,KAAK,YAAY,CAAC,CAAC,IAC9D,KAAK,kBAAkB,OAAO,gBAAgB,KAAK,IAAI;AAClE,aAAS,KAAK,EAAE,MAAM,QAAQ,QAAQ,CAAC;AAAA,EACzC;AACA,SAAO,EAAE,UAAU,SAAS;AAC9B;AAqBO,SAAS,UACd,QACA,UACA,UACS;AACT,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,OAAO,UAAU,QAAQ,MAAM,SAAS,SAAS,WAAW;AACrE;AAIO,SAAS,oBAAoB,UAAoC,QAAkC;AACxG,MAAI,SAAS,WAAW,EAAG,QAAO;AAClC,MAAI,SAAS,WAAW,EAAG,QAAO,SAAS,CAAC,EAAE;AAC9C,SAAO,OAAO,aAAa,SAAS,MAAM;AAC5C;AAiDO,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AACV,GAA8C;AAC5C,QAAM,WAAW,OAAyB,IAAI;AAC9C,QAAM,SAAS,aAAa,cAAc,4BAA4B,UAAU;AAChF,QAAM,WAAW,iBAAiB;AAGlC,QAAM,EAAE,UAAU,YAAY,IAAI,YAAY,EAAE,YAAY,YAAY,CAAC;AAEzE,QAAM,OAAO,CAAC,SAA6C;AACzD,QAAI,YAAY,CAAC,QAAQ,KAAK,WAAW,EAAG;AAC5C,UAAM,MAAM,MAAM,KAAK,IAAI;AAC3B,UAAM,QAAQ,WAAW,MAAM,IAAI,MAAM,GAAG,CAAC;AAC7C,UAAM,EAAE,UAAU,SAAS,IAAI;AAAA,MAC7B;AAAA;AAAA;AAAA,MAGA,EAAE,QAAQ,SAAS,UAAU,WAAW,WAAW,QAAW,SAAS,eAAe;AAAA,MACtF;AAAA,MACA,SAAS;AAAA,IACX;AACA,QAAI,CAAC,UAAU,QAAQ,UAAU,QAAQ,GAAG;AAC1C,UAAI,SAAS,SAAS,EAAG,YAAW,QAAQ;AAC5C,eAAS,OAAO,aAAa,MAAM,MAAM,CAAC;AAC1C;AAAA,IACF;AACA,QAAI,SAAS,SAAS,EAAG,WAAU,QAAQ;AAC3C,QAAI,SAAS,SAAS,GAAG;AACvB,iBAAW,QAAQ;AACnB,eAAS,oBAAoB,UAAU,MAAM,CAAC;AAAA,IAChD;AAAA,EACF;AAEA,QAAM,UACJ,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QAMA,WAAU;AAAA,QACV,UAAU;AAAA,QACV,eAAW;AAAA,QACX,UAAU,CAAC,MAAM;AAGf,gBAAM,SAAS,MAAM,KAAK,EAAE,cAAc,SAAS,CAAC,CAAC;AACrD,YAAE,cAAc,QAAQ;AACxB,eAAK,MAAM;AAAA,QACb;AAAA;AAAA,IACF;AAAA,IACA,oBAAC,UAAM,GAAG,aAAa;AAAA,KACzB;AAGF,SAAO;AAAA,IACL,MAAM,MAAM;AACV,UAAI,CAAC,SAAU,UAAS,SAAS,MAAM;AAAA,IACzC;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AA0CO,MAAM,aAAa,WAA+C,SAASA,YAChF;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GACA,KACA;AACA,QAAM,QAAQ,QAAQ,YAAY,OAAO;AACzC,QAAM,CAAC,QAAQ,SAAS,IAAI,SAA0B,CAAC,CAAC;AACxD,QAAM,WAAW,MAAM;AACvB,QAAM,eAAe,aAAa,cAAc,4BAA4B,MAAM;AAClF,QAAM,SAAS,cAAc;AAAA,IAC3B;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,SAAS,eACL,CAAC,UAAU;AACT,gBAAU,KAAK;AACf,gBAAU,KAAK;AAAA,IACjB,IACA;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA,UAAU;AAAA,EACZ,CAAC;AACD,QAAM,aACJ,OAAO,WAAW,IAAI,KAAK,OAAO,WAAW,IAAI,OAAO,CAAC,EAAE,OAAO,aAAa,SAAS,OAAO,QAAQ,OAAO,CAAC,EAAE,IAAI;AACvH,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,KAAK;AAE9C,QAAM,WAAW,CAAC,MAAiB,MAAM,KAAK,EAAE,cAAc,SAAS,CAAC,CAAC,EAAE,SAAS,OAAO;AAE3F,QAAM,eAAe,YACjB;AAAA,IACE,aAAa,CAAC,MAAoC;AAChD,oBAAc,CAAC;AACf,UAAI,CAAC,SAAS,CAAC,EAAG;AAClB,QAAE,eAAe;AACjB,UAAI,CAAC,MAAO,aAAY,IAAI;AAAA,IAC9B;AAAA,IACA,YAAY,CAAC,MAAoC;AAC/C,mBAAa,CAAC;AACd,UAAI,CAAC,SAAS,CAAC,EAAG;AAGlB,QAAE,eAAe;AACjB,QAAE,aAAa,aAAa,QAAQ,SAAS;AAAA,IAC/C;AAAA,IACA,aAAa,CAAC,MAAoC;AAChD,oBAAc,CAAC;AACf,UAAI,EAAE,cAAc,SAAS,EAAE,aAA4B,EAAG;AAC9D,kBAAY,KAAK;AAAA,IACnB;AAAA,IACA,QAAQ,CAAC,MAAoC;AAC3C,eAAS,CAAC;AACV,UAAI,CAAC,SAAS,CAAC,EAAG;AAClB,QAAE,eAAe;AACjB,kBAAY,KAAK;AACjB,aAAO,KAAK,EAAE,aAAa,KAAK;AAAA,IAClC;AAAA,EACF,IACA,EAAE,aAAa,YAAY,aAAa,OAAO;AAKnD,QAAM,UAAU,EAAE,IAAI;AAEtB,QAAM,SACJ;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACH,GAAG;AAAA,MACH,GAAG;AAAA,MACJ,MAAK;AAAA,MACL,UAAU;AAAA,MACV,aAAW,WAAW;AAAA,MACtB,oBACE,gBAAgB,aACZ,CAAC,KAAK,kBAAkB,GAAG,QAAQ,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG,IAC7D,KAAK,kBAAkB;AAAA,MAE7B,kBAAgB,YAAY;AAAA,MAC5B,WAAW;AAAA;AAAA;AAAA,QAGT,YAAY;AAAA;AAAA,QAEZ,gBAAgB;AAAA,QAChB;AAAA,MACF;AAAA,MACA,SAAS,CAAC,MAAM;AACd,kBAAU,CAAC;AACX,YAAI,CAAC,EAAE,iBAAkB,QAAO,KAAK;AAAA,MACvC;AAAA,MAIC;AAAA,mBAAW,oBAAC,WAAQ,OAAO,MAAM,WAAU,UAAS;AAAA,QACpD;AAAA;AAAA;AAAA,EACH;AAGF,MAAI,CAAC,cAAc;AACjB,WACE,iCACG;AAAA;AAAA,MACA,OAAO;AAAA,OACV;AAAA,EAEJ;AACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAME,qBAAC,UAAK,WAAU,qDACb;AAAA;AAAA,MAID;AAAA,QAAC;AAAA;AAAA,UACC,IAAI;AAAA,UACJ,aAAU;AAAA,UACV,aAAU;AAAA,UACV,WAAU;AAAA,UAEV,8BAAC,WAAQ,OAAO,OAAO,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,IAAI,GAAG,MAAI,MAAC,WAAU,WACnE,8BAAC,UAAK,WAAU,kBAAkB,sBAAW,GAC/C;AAAA;AAAA,MACF;AAAA,MACC,OAAO;AAAA,OACV;AAAA;AAEJ,CAAC;AACD,WAAW,cAAc;","names":["FileButton"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -4,6 +4,7 @@ import { cn } from "../lib/cn";
4
4
  import { useAnnounce } from "../hooks/use-announce";
5
5
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
6
6
  import { Button, Spinner } from "./ui";
7
+ import { Tooltip } from "./tooltip";
7
8
 
8
9
  /**
9
10
  * A button that opens the file picker — the shape all three apps kept writing by hand
@@ -578,17 +579,24 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
578
579
  }
579
580
  return (
580
581
  // One inline row, so the name sits beside the button wherever the button sits; the
581
- // name truncates (full list in its title) rather than pushing the row wider.
582
+ // name truncates rather than pushing the row wider, and the full list is in the
583
+ // kit's own Tooltip — not a native `title` (0.16.0 shipped one: the one-tooltip rule
584
+ // since keksdose dev#523, and a title never opens on touch). Lazy, so the closed
585
+ // bubble adds nothing to the read-out's text.
582
586
  <span className="inline-flex max-w-full min-w-0 items-center gap-2">
583
587
  {button}
588
+ {/* The live region is the OUTER span and never remounts: a Tooltip with an empty
589
+ label renders no wrapper, so wrapping the region itself would swap its element
590
+ the moment the first file arrives — and a fresh live region may not announce. */}
584
591
  <span
585
592
  id={pickedId}
586
593
  aria-live="polite"
587
594
  data-slot="file-button-name"
588
- title={picked.map((f) => f.name).join(", ") || undefined}
589
- className="min-w-0 truncate text-sm text-[var(--text-secondary)]"
595
+ className="flex min-w-0 text-sm text-[var(--text-secondary)]"
590
596
  >
591
- {pickedText}
597
+ <Tooltip label={picked.map((f) => f.name).join(", ")} lazy className="min-w-0">
598
+ <span className="block truncate">{pickedText}</span>
599
+ </Tooltip>
592
600
  </span>
593
601
  {picker.element}
594
602
  </span>