@eifi1/ui-kit 0.5.1 → 0.6.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.
Files changed (136) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +103 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/checkbox.d.ts +9 -0
  8. package/dist/components/checkbox.js +7 -2
  9. package/dist/components/checkbox.js.map +1 -1
  10. package/dist/components/chip.d.ts +3 -2
  11. package/dist/components/chip.js +14 -1
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/choice-card.d.ts +100 -0
  14. package/dist/components/choice-card.js +170 -0
  15. package/dist/components/choice-card.js.map +1 -0
  16. package/dist/components/combobox-core.d.ts +76 -6
  17. package/dist/components/combobox-core.js +119 -49
  18. package/dist/components/combobox-core.js.map +1 -1
  19. package/dist/components/combobox.d.ts +12 -2
  20. package/dist/components/combobox.js +42 -17
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/danger-confirm.d.ts +91 -0
  23. package/dist/components/danger-confirm.js +181 -0
  24. package/dist/components/danger-confirm.js.map +1 -0
  25. package/dist/components/dialog-frame.d.ts +84 -0
  26. package/dist/components/dialog-frame.js +86 -0
  27. package/dist/components/dialog-frame.js.map +1 -0
  28. package/dist/components/disclosure.d.ts +108 -0
  29. package/dist/components/disclosure.js +127 -0
  30. package/dist/components/disclosure.js.map +1 -0
  31. package/dist/components/entity-combobox.d.ts +17 -3
  32. package/dist/components/entity-combobox.js +25 -5
  33. package/dist/components/entity-combobox.js.map +1 -1
  34. package/dist/components/file-button.d.ts +161 -0
  35. package/dist/components/file-button.js +243 -0
  36. package/dist/components/file-button.js.map +1 -0
  37. package/dist/components/file-dropzone.d.ts +72 -23
  38. package/dist/components/file-dropzone.js +219 -94
  39. package/dist/components/file-dropzone.js.map +1 -1
  40. package/dist/components/icon-picker.d.ts +72 -0
  41. package/dist/components/icon-picker.js +104 -0
  42. package/dist/components/icon-picker.js.map +1 -0
  43. package/dist/components/mini-calendar.d.ts +3 -0
  44. package/dist/components/mini-calendar.js +4 -3
  45. package/dist/components/mini-calendar.js.map +1 -1
  46. package/dist/components/modal.d.ts +8 -1
  47. package/dist/components/modal.js +4 -2
  48. package/dist/components/modal.js.map +1 -1
  49. package/dist/components/multi-entity-combobox.d.ts +16 -3
  50. package/dist/components/multi-entity-combobox.js +25 -5
  51. package/dist/components/multi-entity-combobox.js.map +1 -1
  52. package/dist/components/number-field.d.ts +41 -1
  53. package/dist/components/number-field.js +42 -10
  54. package/dist/components/number-field.js.map +1 -1
  55. package/dist/components/number-input.d.ts +35 -2
  56. package/dist/components/number-input.js +35 -4
  57. package/dist/components/number-input.js.map +1 -1
  58. package/dist/components/numpad-sheet.d.ts +7 -0
  59. package/dist/components/search-field.d.ts +16 -0
  60. package/dist/components/search-field.js +29 -7
  61. package/dist/components/search-field.js.map +1 -1
  62. package/dist/components/signature-pad.d.ts +43 -1
  63. package/dist/components/signature-pad.js +74 -2
  64. package/dist/components/signature-pad.js.map +1 -1
  65. package/dist/components/swatch-picker.d.ts +69 -0
  66. package/dist/components/swatch-picker.js +75 -0
  67. package/dist/components/swatch-picker.js.map +1 -0
  68. package/dist/components/switch.d.ts +9 -0
  69. package/dist/components/switch.js +7 -2
  70. package/dist/components/switch.js.map +1 -1
  71. package/dist/components/tile-radio.d.ts +50 -0
  72. package/dist/components/tile-radio.js +140 -0
  73. package/dist/components/tile-radio.js.map +1 -0
  74. package/dist/components/toggle-group.d.ts +27 -5
  75. package/dist/components/toggle-group.js +22 -15
  76. package/dist/components/toggle-group.js.map +1 -1
  77. package/dist/components/ui.d.ts +150 -12
  78. package/dist/components/ui.js +196 -22
  79. package/dist/components/ui.js.map +1 -1
  80. package/dist/i18n/defaults.d.ts +7 -0
  81. package/dist/i18n/defaults.js +13 -2
  82. package/dist/i18n/defaults.js.map +1 -1
  83. package/dist/i18n/kit-labels.d.ts +38 -5
  84. package/dist/i18n/kit-labels.js +12 -4
  85. package/dist/i18n/kit-labels.js.map +1 -1
  86. package/dist/index.d.ts +16 -7
  87. package/dist/index.js +12 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/lib/table-text.d.ts +127 -0
  90. package/dist/lib/table-text.js +82 -0
  91. package/dist/lib/table-text.js.map +1 -0
  92. package/dist/rhf/form.d.ts +79 -0
  93. package/dist/rhf/form.js +143 -0
  94. package/dist/rhf/form.js.map +1 -0
  95. package/dist/rhf.d.ts +4 -0
  96. package/dist/rhf.js +3 -0
  97. package/dist/rhf.js.map +1 -0
  98. package/dist/shell/app-shell.js +3 -1
  99. package/dist/shell/app-shell.js.map +1 -1
  100. package/dist/table-text.d.ts +1 -0
  101. package/dist/table-text.js +3 -0
  102. package/dist/table-text.js.map +1 -0
  103. package/package.json +14 -1
  104. package/src/components/autocomplete.tsx +429 -0
  105. package/src/components/checkbox.tsx +16 -0
  106. package/src/components/chip.tsx +24 -2
  107. package/src/components/choice-card.tsx +305 -0
  108. package/src/components/combobox-core.tsx +228 -58
  109. package/src/components/combobox.tsx +58 -21
  110. package/src/components/danger-confirm.tsx +286 -0
  111. package/src/components/dialog-frame.tsx +179 -0
  112. package/src/components/disclosure.tsx +259 -0
  113. package/src/components/entity-combobox.tsx +41 -6
  114. package/src/components/file-button.tsx +458 -0
  115. package/src/components/file-dropzone.tsx +323 -117
  116. package/src/components/icon-picker.tsx +181 -0
  117. package/src/components/mini-calendar.tsx +7 -3
  118. package/src/components/modal.tsx +10 -2
  119. package/src/components/multi-entity-combobox.tsx +40 -6
  120. package/src/components/number-field.tsx +86 -10
  121. package/src/components/number-input.tsx +79 -2
  122. package/src/components/search-field.tsx +49 -6
  123. package/src/components/signature-pad.tsx +112 -0
  124. package/src/components/swatch-picker.tsx +141 -0
  125. package/src/components/switch.tsx +16 -0
  126. package/src/components/tile-radio.tsx +228 -0
  127. package/src/components/toggle-group.tsx +54 -18
  128. package/src/components/ui.tsx +400 -24
  129. package/src/i18n/defaults.ts +12 -1
  130. package/src/i18n/kit-labels.tsx +45 -5
  131. package/src/index.ts +19 -0
  132. package/src/lib/table-text.ts +265 -0
  133. package/src/rhf/form.tsx +300 -0
  134. package/src/rhf.ts +9 -0
  135. package/src/shell/app-shell.tsx +3 -1
  136. package/src/table-text.ts +8 -0
@@ -19,7 +19,10 @@ const DEFAULT_SIGNATURE_PAD_LABELS = {
19
19
  useDrawn: "Draw instead",
20
20
  typedName: "Full name",
21
21
  cleared: "Signature cleared",
22
- undone: "Last stroke removed"
22
+ undone: "Last stroke removed",
23
+ viewEmpty: "Not signed",
24
+ viewDrawn: "Handwritten signature",
25
+ viewTyped: (name) => `Signed with the typed name ${name}`
23
26
  };
24
27
  const DEFAULT_EXPORT_INK = "#111111";
25
28
  const SCRIPT_FONT = `"Segoe Script", "Bradley Hand", "Brush Script MT", cursive`;
@@ -398,8 +401,77 @@ const SignaturePad = forwardRef(function SignaturePad2({
398
401
  ] });
399
402
  });
400
403
  SignaturePad.displayName = "SignaturePad";
404
+ function SignatureView({
405
+ value,
406
+ typedName,
407
+ label,
408
+ adaptInk = true,
409
+ frameClassName,
410
+ labels: labelsProp,
411
+ className,
412
+ ...rest
413
+ }) {
414
+ const labels = useKitLabels("signaturePad", DEFAULT_SIGNATURE_PAD_LABELS, labelsProp);
415
+ const name = typedName?.trim() ?? "";
416
+ const kind = value ? "drawn" : name ? "typed" : "empty";
417
+ const caption = label === void 0 ? labels.label : label;
418
+ const captionId = useId();
419
+ const captioned = caption !== null && caption !== false;
420
+ return /* @__PURE__ */ jsxs(
421
+ "figure",
422
+ {
423
+ "aria-labelledby": captioned ? captionId : void 0,
424
+ ...rest,
425
+ className: cn("m-0 space-y-1.5", className),
426
+ "data-empty": kind === "empty" || void 0,
427
+ children: [
428
+ captioned && /* @__PURE__ */ jsx("figcaption", { id: captionId, className: "block text-sm font-medium text-[var(--text-primary)]", children: caption }),
429
+ /* @__PURE__ */ jsxs(
430
+ "div",
431
+ {
432
+ className: cn(
433
+ "relative flex h-40 items-center justify-center overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)]",
434
+ frameClassName
435
+ ),
436
+ children: [
437
+ kind === "drawn" && /* @__PURE__ */ jsx(
438
+ "img",
439
+ {
440
+ src: value,
441
+ alt: labels.viewDrawn,
442
+ className: cn("relative max-h-full max-w-full object-contain", adaptInk && "dark:invert")
443
+ }
444
+ ),
445
+ kind === "typed" && // One name for the whole thing: the visible text alone would be read as a bare
446
+ // name, with nothing saying it IS the signature.
447
+ /* @__PURE__ */ jsx(
448
+ "span",
449
+ {
450
+ role: "img",
451
+ "aria-label": labels.viewTyped(name),
452
+ className: "relative max-w-[85%] truncate px-2 text-4xl italic text-[var(--text-primary)]",
453
+ style: { fontFamily: SCRIPT_FONT },
454
+ children: name
455
+ }
456
+ ),
457
+ kind === "empty" && /* @__PURE__ */ jsx("span", { className: "relative text-xs text-[var(--text-muted)]", children: labels.viewEmpty }),
458
+ /* @__PURE__ */ jsx(
459
+ "div",
460
+ {
461
+ "aria-hidden": "true",
462
+ className: "pointer-events-none absolute inset-x-6 bottom-8 border-b border-dashed border-[var(--border-strong)]"
463
+ }
464
+ )
465
+ ]
466
+ }
467
+ )
468
+ ]
469
+ }
470
+ );
471
+ }
401
472
  export {
402
473
  DEFAULT_SIGNATURE_PAD_LABELS,
403
- SignaturePad
474
+ SignaturePad,
475
+ SignatureView
404
476
  };
405
477
  //# sourceMappingURL=signature-pad.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/signature-pad.tsx"],"sourcesContent":["import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { Eraser, Keyboard, PenLine, Undo2 } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, FIELD_INVALID, Input } from \"./ui\";\n\n/**\n * A drawn signature, captured on a canvas and handed back as a PNG data URL.\n *\n * Kastlan wrote the first one (`features/handover/components/signature-pad.tsx`) for\n * the handover protocol, where tenant and inspector each sign on the same phone. This\n * keeps its contract — draw, get a PNG — and fixes what a copy per app would keep\n * getting wrong:\n *\n * 1. **Strokes are data, not pixels.** Each stroke is kept as a list of points, and\n * the canvas is a VIEW of them. That is what lets a resize (a phone rotating\n * mid-signature) keep the drawing instead of wiping it — setting `canvas.width`\n * clears the bitmap, so a pixels-only pad loses everything — and what makes undo\n * one `pop()`. The pixel buffer is sized to `devicePixelRatio`, so a stroke is\n * sharp on a 3× screen rather than upscaled from a 1× bitmap.\n * 2. **Smooth, and pressure-aware.** Segments are quadratic curves through the\n * midpoints between samples, so a fast stroke is a curve, not a polyline; a pen\n * that reports pressure varies the width, a mouse or finger (whose `pressure` is a\n * constant 0.5, or junk) draws at the base width.\n * 3. **Two inks.** On screen the ink is the computed `color` of the canvas, which is\n * `--text-primary` — so it reads in the dark theme, where a hard-coded near-black\n * stroke vanished into the surface. The exported PNG is re-rendered from the same\n * strokes in `exportInk` (near-black by default) on a transparent background. A\n * signature is a DOCUMENT artefact: it is printed on the protocol, shown on a white\n * card, embedded in a PDF — none of which follow the signer's theme. Exporting\n * the on-screen ink would have produced a pale-grey-on-transparent PNG for every\n * dark-mode signer, invisible on white paper.\n *\n * **Accessibility.** A canvas has no content a screen reader or a keyboard can reach,\n * and no amount of ARIA changes that — drawing is a pointer act. What the pad does:\n * the canvas is `role=\"img\"`, named by the visible label and described by the\n * instructions and by its state (\"Nothing drawn yet\" / \"Signature drawn\"), so a\n * screen-reader user knows what the box is and whether it holds anything; undo and\n * clear are real buttons, and their effect is announced. What it cannot do is let\n * someone sign without a pointer — hence `allowTypedName`, which offers a typed name\n * instead. It is OPT-IN, because whether a typed name counts as a signature is a\n * product and legal decision, not a UI one (Kastlan's handover accepts one; a flow\n * that needs a drawn mark would not). Where a drawn mark is not strictly required,\n * turn it on.\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the pad renders or speaks. */\nexport interface SignaturePadLabels {\n /** Visible label above the pad, and the canvas's accessible name. The `label`\n * prop overrides it per instance. */\n label: string;\n /** Under the label, and part of the canvas's description. */\n instructions: string;\n /** Appended to the instructions when `allowTypedName` is on. */\n typedFallbackHint: string;\n /** The canvas's state for a screen reader. */\n empty: string;\n signed: string;\n undo: string;\n clear: string;\n /** Only rendered when `onSave` is given. */\n save: string;\n /** The mode switch (`allowTypedName`). */\n useTyped: string;\n useDrawn: string;\n /** Label of the typed-name field. */\n typedName: string;\n /** Live-region messages after the matching button. */\n cleared: string;\n undone: string;\n}\n\nexport const DEFAULT_SIGNATURE_PAD_LABELS: SignaturePadLabels = {\n label: \"Signature\",\n instructions: \"Sign in the box with a mouse, your finger or a pen.\",\n typedFallbackHint: \"If you cannot draw, type your name instead.\",\n empty: \"Nothing drawn yet\",\n signed: \"Signature drawn\",\n undo: \"Undo last stroke\",\n clear: \"Clear\",\n save: \"Save signature\",\n useTyped: \"Type name instead\",\n useDrawn: \"Draw instead\",\n typedName: \"Full name\",\n cleared: \"Signature cleared\",\n undone: \"Last stroke removed\",\n};\n\n/* ── Types ───────────────────────────────────────────────────────────────── */\n\n/** How the value handed back was made. */\nexport interface SignatureDetail {\n method: \"drawn\" | \"typed\";\n /** The name as typed, when `method` is `\"typed\"` — the PNG shows it, but a host\n * that stores a typed name as TEXT (Kastlan does) wants the string. */\n typedName?: string;\n}\n\n/** The imperative handle: `ref.current.toDataURL()` at submit time, for a form that\n * does not want to mirror the value into state on every stroke. */\nexport interface SignaturePadHandle {\n /** Remove every stroke and the typed name. Fires `onChange(null)`. */\n clear: () => void;\n /** The PNG, rendered in `exportInk` — or `null` while nothing is signed. */\n toDataURL: () => string | null;\n isEmpty: () => boolean;\n}\n\nexport interface SignaturePadProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\"> {\n /** Visible label; defaults to `labels.label`. */\n label?: ReactNode;\n /** Replaces `labels.instructions` for this instance (e.g. a legal note). */\n description?: ReactNode;\n /**\n * After every finished stroke, undo, clear and typed-name edit: the PNG, or `null`\n * once the pad is empty. Not called mid-stroke — encoding a PNG per pointer move\n * would stall the drawing on a phone.\n */\n onChange?: (dataUrl: string | null, detail: SignatureDetail) => void;\n /** Renders a Save button, enabled once something is signed. */\n onSave?: (dataUrl: string, detail: SignatureDetail) => void;\n disabled?: boolean;\n /** Paints the pad as wrong and sets `aria-invalid`. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** Message under the pad; implies `invalid` and is attached with\n * `aria-describedby`, like the kit's other fields. */\n error?: ReactNode;\n /** Offer a typed name as an alternative to drawing. See the accessibility note. */\n allowTypedName?: boolean;\n /** Ink of the exported PNG. Default near-black, legible on white. */\n exportInk?: string;\n /** Fill behind the exported PNG. Default transparent. */\n exportBackground?: string;\n /** Base stroke width in CSS pixels. Default 2.5. */\n lineWidth?: number;\n /** Classes for the canvas — its height, chiefly (default `h-40`). */\n canvasClassName?: string;\n /** User-facing strings; see {@link SignaturePadLabels}. */\n labels?: Partial<SignaturePadLabels>;\n}\n\n/* ── Drawing ─────────────────────────────────────────────────────────────── */\n\ninterface Point {\n x: number;\n y: number;\n /** 0–1. A constant 0.5 for anything that is not a pen. */\n p: number;\n}\n\ntype Stroke = Point[];\n\nconst DEFAULT_EXPORT_INK = \"#111111\";\n\n// A handwriting face where the platform has one, `cursive` everywhere else. Only\n// ever used to render a typed name onto the canvas.\nconst SCRIPT_FONT = `\"Segoe Script\", \"Bradley Hand\", \"Brush Script MT\", cursive`;\n\n/** Width multiplier from pressure: 0.5 (the no-pen constant) is exactly 1. */\nconst pressureWidth = (p: number) => 0.4 + 1.2 * p;\n\nconst mid = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, p: (a.p + b.p) / 2 });\n\n/**\n * Segment `i` (1 ≤ i < n) of a stroke: a quadratic from the midpoint before sample\n * `i - 1` to the midpoint after it, with the sample as the control point. The live\n * drawing and the full repaint both go through here, so a stroke looks the same\n * before and after a resize.\n */\nfunction drawSegment(ctx: CanvasRenderingContext2D, pts: Stroke, i: number, scale: number, width: number) {\n const a = i === 1 ? pts[0] : mid(pts[i - 2], pts[i - 1]);\n const c = pts[i - 1];\n const b = mid(pts[i - 1], pts[i]);\n ctx.lineWidth = width * scale * pressureWidth((a.p + b.p) / 2);\n ctx.beginPath();\n ctx.moveTo(a.x * scale, a.y * scale);\n ctx.quadraticCurveTo(c.x * scale, c.y * scale, b.x * scale, b.y * scale);\n ctx.stroke();\n}\n\n/** The last half-segment, from the final midpoint to the final sample. Drawn once the\n * stroke is finished — while drawing, the pen is still there and it would be redrawn\n * on every move. A single tap is a dot. */\nfunction drawTail(ctx: CanvasRenderingContext2D, pts: Stroke, scale: number, width: number) {\n const last = pts[pts.length - 1];\n if (pts.length === 1) {\n ctx.beginPath();\n ctx.arc(last.x * scale, last.y * scale, (width * scale * pressureWidth(last.p)) / 2, 0, Math.PI * 2);\n ctx.fill();\n return;\n }\n const from = pts.length === 2 ? pts[0] : mid(pts[pts.length - 2], last);\n ctx.lineWidth = width * scale * pressureWidth(last.p);\n ctx.beginPath();\n ctx.moveTo(from.x * scale, from.y * scale);\n ctx.lineTo(last.x * scale, last.y * scale);\n ctx.stroke();\n}\n\nfunction prepare(ctx: CanvasRenderingContext2D, ink: string, dpr: number) {\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.lineCap = \"round\";\n ctx.lineJoin = \"round\";\n ctx.strokeStyle = ink;\n ctx.fillStyle = ink;\n}\n\n/** A typed name, as large as fits, on the pad's baseline. */\nfunction drawName(ctx: CanvasRenderingContext2D, name: string, w: number, h: number) {\n let size = Math.min(48, h * 0.45);\n ctx.font = `italic ${size}px ${SCRIPT_FONT}`;\n const room = w * 0.85;\n const measured = ctx.measureText(name).width;\n if (measured > room) {\n size = Math.max(12, (size * room) / measured);\n ctx.font = `italic ${size}px ${SCRIPT_FONT}`;\n }\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"alphabetic\";\n ctx.fillText(name, w / 2, h * 0.68);\n}\n\n/* ── The component ───────────────────────────────────────────────────────── */\n\nexport const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(function SignaturePad(\n {\n label,\n description,\n onChange,\n onSave,\n disabled,\n invalid,\n error,\n allowTypedName,\n exportInk = DEFAULT_EXPORT_INK,\n exportBackground,\n lineWidth = 2.5,\n canvasClassName,\n labels: labelsProp,\n className,\n ...rest\n },\n ref,\n) {\n // Prop > <UiKitProvider> > English, like every labelled component in the kit.\n const labels = useKitLabels(\"signaturePad\", DEFAULT_SIGNATURE_PAD_LABELS, labelsProp);\n const { announce, regionProps } = useAnnounce();\n const labelId = useId();\n const descId = useId();\n const statusId = useId();\n const errorId = useId();\n\n const canvasRef = useRef<HTMLCanvasElement>(null);\n /**\n * What is on the pad lives in a ref, not in state: a pointer move must not\n * re-render, and `onChange` has to see the pad AFTER a change, from inside the\n * handler that made it — before React has rendered anything. The state below\n * mirrors the parts the markup needs (empty or not, which mode, the field's text).\n */\n const scene = useRef<{ strokes: Stroke[]; mode: \"drawn\" | \"typed\"; name: string }>({\n strokes: [],\n mode: \"drawn\",\n name: \"\",\n });\n const live = useRef<{ id: number; stroke: Stroke } | null>(null);\n /**\n * The canvas width, in CSS px, the stored points are relative to. Fixed by the\n * first stroke; a later resize scales the drawing UNIFORMLY by `width / refWidth`,\n * so a rotated phone shows the same signature larger or smaller, never stretched.\n */\n const refWidth = useRef(0);\n const [strokeCount, setStrokeCount] = useState(0);\n const [mode, setMode] = useState<\"drawn\" | \"typed\">(\"drawn\");\n const [typedName, setTypedName] = useState(\"\");\n\n const typed = mode === \"typed\";\n const isEmpty = typed ? typedName.trim() === \"\" : strokeCount === 0;\n\n // Everything the paint and export paths read that can change between renders, so\n // the observers below subscribe once instead of on every keystroke.\n const opts = useRef({ lineWidth, exportInk, exportBackground });\n useEffect(() => {\n opts.current = { lineWidth, exportInk, exportBackground };\n }, [lineWidth, exportInk, exportBackground]);\n\n const sceneEmpty = () => {\n const s = scene.current;\n return s.mode === \"typed\" ? s.name.trim() === \"\" : s.strokes.length === 0;\n };\n\n /** The whole scene, painted into `ctx` at `w`×`h` CSS px. */\n const paintInto = useCallback((ctx: CanvasRenderingContext2D, ink: string, w: number, h: number, dpr: number) => {\n const { lineWidth: width } = opts.current;\n const s = scene.current;\n prepare(ctx, ink, dpr);\n if (s.mode === \"typed\") {\n if (s.name.trim()) drawName(ctx, s.name.trim(), w, h);\n return;\n }\n const scale = refWidth.current > 0 ? w / refWidth.current : 1;\n for (const stroke of s.strokes) {\n for (let i = 1; i < stroke.length; i += 1) drawSegment(ctx, stroke, i, scale, width);\n drawTail(ctx, stroke, scale, width);\n }\n const pending = live.current?.stroke;\n if (pending) for (let i = 1; i < pending.length; i += 1) drawSegment(ctx, pending, i, scale, width);\n }, []);\n\n /** The ink on screen: the canvas's own computed `color`, i.e. `--text-primary`. */\n const screenInk = (canvas: HTMLCanvasElement) =>\n // Read as `color` rather than as the custom property: `--text-primary` is whatever\n // the palette wrote (a `color-mix()` in some presets), which a 2D context may not\n // parse; `color` has already been resolved by the browser.\n getComputedStyle(canvas).color || opts.current.exportInk;\n\n /** Repaint the visible canvas from the scene, in the theme's ink. */\n const repaint = useCallback(() => {\n const canvas = canvasRef.current;\n const ctx = canvas?.getContext(\"2d\");\n if (!canvas || !ctx) return;\n const dpr = window.devicePixelRatio || 1;\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n paintInto(ctx, screenInk(canvas), canvas.width / dpr, canvas.height / dpr, dpr);\n }, [paintInto]);\n\n /** Size the bitmap to the element × devicePixelRatio, then repaint. */\n const resize = useCallback(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const rect = canvas.getBoundingClientRect();\n const dpr = window.devicePixelRatio || 1;\n const w = Math.round(rect.width * dpr);\n const h = Math.round(rect.height * dpr);\n // Assigning `width` clears the bitmap even when the value is unchanged, so only\n // when it moved — and the repaint puts the strokes back either way.\n if (canvas.width !== w) canvas.width = w;\n if (canvas.height !== h) canvas.height = h;\n repaint();\n }, [repaint]);\n\n useLayoutEffect(() => {\n resize();\n }, [resize]);\n\n // A changed base width redraws what is already there.\n useEffect(() => {\n repaint();\n }, [lineWidth, repaint]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const cleanups: Array<() => void> = [];\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => resize());\n ro.observe(canvas);\n cleanups.push(() => ro.disconnect());\n }\n // The ink follows the theme, and the theme is a class (`.dark`) or inline\n // variables (the palette store) on <html> — neither of which tells a canvas.\n if (typeof MutationObserver !== \"undefined\") {\n const mo = new MutationObserver(() => repaint());\n mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] });\n cleanups.push(() => mo.disconnect());\n }\n const mq = typeof window.matchMedia === \"function\" ? window.matchMedia(\"(prefers-color-scheme: dark)\") : null;\n if (mq?.addEventListener) {\n mq.addEventListener(\"change\", repaint);\n cleanups.push(() => mq.removeEventListener(\"change\", repaint));\n }\n return () => cleanups.forEach((fn) => fn());\n }, [resize, repaint]);\n\n /** The PNG, re-rendered off-screen in the export ink — `null` while empty. */\n const exportPng = useCallback((): string | null => {\n const canvas = canvasRef.current;\n if (!canvas || sceneEmpty()) return null;\n const { exportInk: ink, exportBackground: bg } = opts.current;\n const dpr = window.devicePixelRatio || 1;\n const out = document.createElement(\"canvas\");\n out.width = canvas.width;\n out.height = canvas.height;\n const ctx = out.getContext(\"2d\");\n if (!ctx) return null;\n if (bg) {\n ctx.fillStyle = bg;\n ctx.fillRect(0, 0, out.width, out.height);\n }\n paintInto(ctx, ink, out.width / dpr, out.height / dpr, dpr);\n return out.toDataURL(\"image/png\");\n }, [paintInto]);\n\n const detail = (): SignatureDetail => {\n const s = scene.current;\n return s.mode === \"typed\" ? { method: \"typed\", typedName: s.name.trim() } : { method: \"drawn\" };\n };\n\n /** After every change to the scene: sync the markup's mirror, repaint, tell the host. */\n const onChangeRef = useRef(onChange);\n useEffect(() => {\n onChangeRef.current = onChange;\n }, [onChange]);\n const commit = () => {\n const s = scene.current;\n setStrokeCount(s.strokes.length);\n setMode(s.mode);\n setTypedName(s.name);\n repaint();\n onChangeRef.current?.(exportPng(), detail());\n };\n\n const clearScene = () => {\n scene.current = { ...scene.current, strokes: [], name: \"\" };\n live.current = null;\n refWidth.current = 0;\n commit();\n };\n\n useImperativeHandle(ref, () => ({ clear: clearScene, toDataURL: exportPng, isEmpty: sceneEmpty }));\n\n /* ── Pointer ── */\n\n const toPoint = (e: { clientX: number; clientY: number; pressure: number; pointerType: string }, rect: DOMRect): Point => {\n const scale = refWidth.current > 0 && rect.width > 0 ? rect.width / refWidth.current : 1;\n return {\n x: (e.clientX - rect.left) / scale,\n y: (e.clientY - rect.top) / scale,\n // Only a pen's pressure means anything: a mouse reports 0.5 while pressed and\n // touch hardware reports 0, 1 or noise.\n p: e.pointerType === \"pen\" && e.pressure > 0 ? e.pressure : 0.5,\n };\n };\n\n const onPointerDown = (e: ReactPointerEvent<HTMLCanvasElement>) => {\n // One stroke at a time: a palm resting beside the pen is a second pointer.\n if (disabled || typed || live.current) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n e.preventDefault();\n const canvas = e.currentTarget;\n canvas.setPointerCapture?.(e.pointerId);\n const rect = canvas.getBoundingClientRect();\n if (refWidth.current === 0) refWidth.current = rect.width || 1;\n live.current = { id: e.pointerId, stroke: [toPoint(e, rect)] };\n };\n\n const onPointerMove = (e: ReactPointerEvent<HTMLCanvasElement>) => {\n const pending = live.current;\n if (!pending || pending.id !== e.pointerId) return;\n e.preventDefault();\n const canvas = e.currentTarget;\n const rect = canvas.getBoundingClientRect();\n // Coalesced events: the samples the browser merged into this one move. A fast\n // flick on a 120 Hz pen is otherwise a handful of long straight chords.\n const samples = e.nativeEvent.getCoalescedEvents?.() ?? [];\n const events = samples.length > 0 ? samples : [e];\n const ctx = canvas.getContext(\"2d\");\n if (ctx) prepare(ctx, screenInk(canvas), window.devicePixelRatio || 1);\n const scale = refWidth.current > 0 && rect.width > 0 ? rect.width / refWidth.current : 1;\n for (const ev of events) {\n pending.stroke.push(toPoint(ev, rect));\n // Only the newest segment — a full repaint per move is O(ink) on every event.\n if (ctx) drawSegment(ctx, pending.stroke, pending.stroke.length - 1, scale, opts.current.lineWidth);\n }\n };\n\n const endStroke = (e: ReactPointerEvent<HTMLCanvasElement>) => {\n const pending = live.current;\n if (!pending || pending.id !== e.pointerId) return;\n e.currentTarget.releasePointerCapture?.(e.pointerId);\n live.current = null;\n scene.current = { ...scene.current, strokes: [...scene.current.strokes, pending.stroke] };\n commit();\n };\n\n /* ── Buttons ── */\n\n const undo = () => {\n if (disabled || scene.current.strokes.length === 0) return;\n const strokes = scene.current.strokes.slice(0, -1);\n if (strokes.length === 0) refWidth.current = 0;\n scene.current = { ...scene.current, strokes };\n commit();\n announce(labels.undone);\n };\n\n const clear = () => {\n if (disabled || sceneEmpty()) return;\n clearScene();\n announce(labels.cleared);\n };\n\n const switchMode = () => {\n if (disabled) return;\n scene.current = { ...scene.current, mode: scene.current.mode === \"drawn\" ? \"typed\" : \"drawn\" };\n commit();\n };\n\n const onTypedChange = (name: string) => {\n scene.current = { ...scene.current, name };\n commit();\n };\n\n const save = () => {\n if (disabled) return;\n const png = exportPng();\n if (png) onSave?.(png, detail());\n };\n\n const hasError = error !== undefined && error !== null && error !== false && error !== \"\";\n const isInvalid = Boolean(invalid) || hasError;\n const describedBy = [descId, statusId, hasError ? errorId : null].filter(Boolean).join(\" \");\n\n // `aria-disabled` rather than `disabled` on undo/clear: they disable THEMSELVES\n // (clear empties the pad, the last undo does too), and a natively disabled button\n // drops keyboard focus to <body> the moment it is activated.\n const softDisabled = \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50\";\n\n return (\n <div {...rest} className={cn(\"relative space-y-1.5\", className)} data-empty={isEmpty || undefined}>\n <div>\n <span id={labelId} className=\"block text-sm font-medium text-[var(--text-primary)]\">\n {label ?? labels.label}\n </span>\n <p id={descId} className=\"text-xs text-[var(--text-muted)]\">\n {description ?? labels.instructions}\n {allowTypedName && !typed ? ` ${labels.typedFallbackHint}` : null}\n </p>\n </div>\n\n <div className=\"relative\">\n <canvas\n ref={canvasRef}\n role=\"img\"\n aria-labelledby={labelId}\n aria-describedby={describedBy}\n // No `aria-invalid` / `aria-disabled`: `img` supports neither. The error\n // reaches a screen reader through `aria-describedby`; the attributes below\n // are for styling and tests.\n data-invalid={isInvalid || undefined}\n data-disabled={disabled || undefined}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endStroke}\n onPointerCancel={endStroke}\n className={cn(\n // `touch-none` stops the page scrolling under a finger that is signing.\n \"block h-40 w-full touch-none select-none rounded-md border border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-primary)]\",\n typed || disabled ? \"cursor-default\" : \"cursor-crosshair\",\n disabled && \"opacity-60\",\n isInvalid && FIELD_INVALID,\n canvasClassName,\n )}\n />\n {/* The signing line — where people expect to sign. Decoration only. */}\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-x-6 bottom-8 border-b border-dashed border-[var(--border-strong)]\"\n />\n <span id={statusId} className=\"sr-only\">\n {isEmpty ? labels.empty : labels.signed}\n </span>\n </div>\n\n {typed && (\n <Input\n label={labels.typedName}\n value={typedName}\n autoComplete=\"name\"\n disabled={disabled}\n onChange={(e) => onTypedChange(e.target.value)}\n />\n )}\n\n <div className=\"flex flex-wrap items-center gap-2\">\n {!typed && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={undo}\n aria-disabled={disabled || strokeCount === 0 || undefined}\n className={softDisabled}\n >\n <Undo2 aria-hidden=\"true\" className=\"size-4\" />\n {labels.undo}\n </Button>\n )}\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={clear}\n aria-disabled={disabled || isEmpty || undefined}\n className={softDisabled}\n >\n <Eraser aria-hidden=\"true\" className=\"size-4\" />\n {labels.clear}\n </Button>\n {allowTypedName && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n onClick={switchMode}\n disabled={disabled}\n >\n {typed ? <PenLine aria-hidden=\"true\" className=\"size-4\" /> : <Keyboard aria-hidden=\"true\" className=\"size-4\" />}\n {typed ? labels.useDrawn : labels.useTyped}\n </Button>\n )}\n {onSave && (\n <Button type=\"button\" variant=\"brand\" onClick={save} disabled={disabled || isEmpty} className=\"ms-auto\">\n {labels.save}\n </Button>\n )}\n </div>\n\n {hasError && (\n <p id={errorId} className=\"mt-1 text-[11px] leading-tight text-[var(--danger)]\">\n {error}\n </p>\n )}\n {/* Rendered from the start and never unmounted — see useAnnounce. */}\n <div {...regionProps} />\n </div>\n );\n});\nSignaturePad.displayName = \"SignaturePad\";\n"],"mappings":"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","names":["SignaturePad"]}
1
+ {"version":3,"sources":["../../src/components/signature-pad.tsx"],"sourcesContent":["import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { Eraser, Keyboard, PenLine, Undo2 } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, FIELD_INVALID, Input } from \"./ui\";\n\n/**\n * A drawn signature, captured on a canvas and handed back as a PNG data URL.\n *\n * Kastlan wrote the first one (`features/handover/components/signature-pad.tsx`) for\n * the handover protocol, where tenant and inspector each sign on the same phone. This\n * keeps its contract — draw, get a PNG — and fixes what a copy per app would keep\n * getting wrong:\n *\n * 1. **Strokes are data, not pixels.** Each stroke is kept as a list of points, and\n * the canvas is a VIEW of them. That is what lets a resize (a phone rotating\n * mid-signature) keep the drawing instead of wiping it — setting `canvas.width`\n * clears the bitmap, so a pixels-only pad loses everything — and what makes undo\n * one `pop()`. The pixel buffer is sized to `devicePixelRatio`, so a stroke is\n * sharp on a 3× screen rather than upscaled from a 1× bitmap.\n * 2. **Smooth, and pressure-aware.** Segments are quadratic curves through the\n * midpoints between samples, so a fast stroke is a curve, not a polyline; a pen\n * that reports pressure varies the width, a mouse or finger (whose `pressure` is a\n * constant 0.5, or junk) draws at the base width.\n * 3. **Two inks.** On screen the ink is the computed `color` of the canvas, which is\n * `--text-primary` — so it reads in the dark theme, where a hard-coded near-black\n * stroke vanished into the surface. The exported PNG is re-rendered from the same\n * strokes in `exportInk` (near-black by default) on a transparent background. A\n * signature is a DOCUMENT artefact: it is printed on the protocol, shown on a white\n * card, embedded in a PDF — none of which follow the signer's theme. Exporting\n * the on-screen ink would have produced a pale-grey-on-transparent PNG for every\n * dark-mode signer, invisible on white paper.\n *\n * **Accessibility.** A canvas has no content a screen reader or a keyboard can reach,\n * and no amount of ARIA changes that — drawing is a pointer act. What the pad does:\n * the canvas is `role=\"img\"`, named by the visible label and described by the\n * instructions and by its state (\"Nothing drawn yet\" / \"Signature drawn\"), so a\n * screen-reader user knows what the box is and whether it holds anything; undo and\n * clear are real buttons, and their effect is announced. What it cannot do is let\n * someone sign without a pointer — hence `allowTypedName`, which offers a typed name\n * instead. It is OPT-IN, because whether a typed name counts as a signature is a\n * product and legal decision, not a UI one (Kastlan's handover accepts one; a flow\n * that needs a drawn mark would not). Where a drawn mark is not strictly required,\n * turn it on.\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the pad renders or speaks. */\nexport interface SignaturePadLabels {\n /** Visible label above the pad, and the canvas's accessible name. The `label`\n * prop overrides it per instance. */\n label: string;\n /** Under the label, and part of the canvas's description. */\n instructions: string;\n /** Appended to the instructions when `allowTypedName` is on. */\n typedFallbackHint: string;\n /** The canvas's state for a screen reader. */\n empty: string;\n signed: string;\n undo: string;\n clear: string;\n /** Only rendered when `onSave` is given. */\n save: string;\n /** The mode switch (`allowTypedName`). */\n useTyped: string;\n useDrawn: string;\n /** Label of the typed-name field. */\n typedName: string;\n /** Live-region messages after the matching button. */\n cleared: string;\n undone: string;\n /** {@link SignatureView}: what it says when there is no signature to show. */\n viewEmpty: string;\n /** {@link SignatureView}: the saved PNG's alternative text. */\n viewDrawn: string;\n /** {@link SignatureView}: the accessible name of a typed-name signature. */\n viewTyped: (name: string) => string;\n}\n\nexport const DEFAULT_SIGNATURE_PAD_LABELS: SignaturePadLabels = {\n label: \"Signature\",\n instructions: \"Sign in the box with a mouse, your finger or a pen.\",\n typedFallbackHint: \"If you cannot draw, type your name instead.\",\n empty: \"Nothing drawn yet\",\n signed: \"Signature drawn\",\n undo: \"Undo last stroke\",\n clear: \"Clear\",\n save: \"Save signature\",\n useTyped: \"Type name instead\",\n useDrawn: \"Draw instead\",\n typedName: \"Full name\",\n cleared: \"Signature cleared\",\n undone: \"Last stroke removed\",\n viewEmpty: \"Not signed\",\n viewDrawn: \"Handwritten signature\",\n viewTyped: (name) => `Signed with the typed name ${name}`,\n};\n\n/* ── Types ───────────────────────────────────────────────────────────────── */\n\n/** How the value handed back was made. */\nexport interface SignatureDetail {\n method: \"drawn\" | \"typed\";\n /** The name as typed, when `method` is `\"typed\"` — the PNG shows it, but a host\n * that stores a typed name as TEXT (Kastlan does) wants the string. */\n typedName?: string;\n}\n\n/** The imperative handle: `ref.current.toDataURL()` at submit time, for a form that\n * does not want to mirror the value into state on every stroke. */\nexport interface SignaturePadHandle {\n /** Remove every stroke and the typed name. Fires `onChange(null)`. */\n clear: () => void;\n /** The PNG, rendered in `exportInk` — or `null` while nothing is signed. */\n toDataURL: () => string | null;\n isEmpty: () => boolean;\n}\n\nexport interface SignaturePadProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\"> {\n /** Visible label; defaults to `labels.label`. */\n label?: ReactNode;\n /** Replaces `labels.instructions` for this instance (e.g. a legal note). */\n description?: ReactNode;\n /**\n * After every finished stroke, undo, clear and typed-name edit: the PNG, or `null`\n * once the pad is empty. Not called mid-stroke — encoding a PNG per pointer move\n * would stall the drawing on a phone.\n */\n onChange?: (dataUrl: string | null, detail: SignatureDetail) => void;\n /** Renders a Save button, enabled once something is signed. */\n onSave?: (dataUrl: string, detail: SignatureDetail) => void;\n disabled?: boolean;\n /** Paints the pad as wrong and sets `aria-invalid`. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** Message under the pad; implies `invalid` and is attached with\n * `aria-describedby`, like the kit's other fields. */\n error?: ReactNode;\n /** Offer a typed name as an alternative to drawing. See the accessibility note. */\n allowTypedName?: boolean;\n /** Ink of the exported PNG. Default near-black, legible on white. */\n exportInk?: string;\n /** Fill behind the exported PNG. Default transparent. */\n exportBackground?: string;\n /** Base stroke width in CSS pixels. Default 2.5. */\n lineWidth?: number;\n /** Classes for the canvas — its height, chiefly (default `h-40`). */\n canvasClassName?: string;\n /** User-facing strings; see {@link SignaturePadLabels}. */\n labels?: Partial<SignaturePadLabels>;\n}\n\n/* ── Drawing ─────────────────────────────────────────────────────────────── */\n\ninterface Point {\n x: number;\n y: number;\n /** 0–1. A constant 0.5 for anything that is not a pen. */\n p: number;\n}\n\ntype Stroke = Point[];\n\nconst DEFAULT_EXPORT_INK = \"#111111\";\n\n// A handwriting face where the platform has one, `cursive` everywhere else. Only\n// ever used to render a typed name onto the canvas.\nconst SCRIPT_FONT = `\"Segoe Script\", \"Bradley Hand\", \"Brush Script MT\", cursive`;\n\n/** Width multiplier from pressure: 0.5 (the no-pen constant) is exactly 1. */\nconst pressureWidth = (p: number) => 0.4 + 1.2 * p;\n\nconst mid = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, p: (a.p + b.p) / 2 });\n\n/**\n * Segment `i` (1 ≤ i < n) of a stroke: a quadratic from the midpoint before sample\n * `i - 1` to the midpoint after it, with the sample as the control point. The live\n * drawing and the full repaint both go through here, so a stroke looks the same\n * before and after a resize.\n */\nfunction drawSegment(ctx: CanvasRenderingContext2D, pts: Stroke, i: number, scale: number, width: number) {\n const a = i === 1 ? pts[0] : mid(pts[i - 2], pts[i - 1]);\n const c = pts[i - 1];\n const b = mid(pts[i - 1], pts[i]);\n ctx.lineWidth = width * scale * pressureWidth((a.p + b.p) / 2);\n ctx.beginPath();\n ctx.moveTo(a.x * scale, a.y * scale);\n ctx.quadraticCurveTo(c.x * scale, c.y * scale, b.x * scale, b.y * scale);\n ctx.stroke();\n}\n\n/** The last half-segment, from the final midpoint to the final sample. Drawn once the\n * stroke is finished — while drawing, the pen is still there and it would be redrawn\n * on every move. A single tap is a dot. */\nfunction drawTail(ctx: CanvasRenderingContext2D, pts: Stroke, scale: number, width: number) {\n const last = pts[pts.length - 1];\n if (pts.length === 1) {\n ctx.beginPath();\n ctx.arc(last.x * scale, last.y * scale, (width * scale * pressureWidth(last.p)) / 2, 0, Math.PI * 2);\n ctx.fill();\n return;\n }\n const from = pts.length === 2 ? pts[0] : mid(pts[pts.length - 2], last);\n ctx.lineWidth = width * scale * pressureWidth(last.p);\n ctx.beginPath();\n ctx.moveTo(from.x * scale, from.y * scale);\n ctx.lineTo(last.x * scale, last.y * scale);\n ctx.stroke();\n}\n\nfunction prepare(ctx: CanvasRenderingContext2D, ink: string, dpr: number) {\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.lineCap = \"round\";\n ctx.lineJoin = \"round\";\n ctx.strokeStyle = ink;\n ctx.fillStyle = ink;\n}\n\n/** A typed name, as large as fits, on the pad's baseline. */\nfunction drawName(ctx: CanvasRenderingContext2D, name: string, w: number, h: number) {\n let size = Math.min(48, h * 0.45);\n ctx.font = `italic ${size}px ${SCRIPT_FONT}`;\n const room = w * 0.85;\n const measured = ctx.measureText(name).width;\n if (measured > room) {\n size = Math.max(12, (size * room) / measured);\n ctx.font = `italic ${size}px ${SCRIPT_FONT}`;\n }\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"alphabetic\";\n ctx.fillText(name, w / 2, h * 0.68);\n}\n\n/* ── The component ───────────────────────────────────────────────────────── */\n\nexport const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(function SignaturePad(\n {\n label,\n description,\n onChange,\n onSave,\n disabled,\n invalid,\n error,\n allowTypedName,\n exportInk = DEFAULT_EXPORT_INK,\n exportBackground,\n lineWidth = 2.5,\n canvasClassName,\n labels: labelsProp,\n className,\n ...rest\n },\n ref,\n) {\n // Prop > <UiKitProvider> > English, like every labelled component in the kit.\n const labels = useKitLabels(\"signaturePad\", DEFAULT_SIGNATURE_PAD_LABELS, labelsProp);\n const { announce, regionProps } = useAnnounce();\n const labelId = useId();\n const descId = useId();\n const statusId = useId();\n const errorId = useId();\n\n const canvasRef = useRef<HTMLCanvasElement>(null);\n /**\n * What is on the pad lives in a ref, not in state: a pointer move must not\n * re-render, and `onChange` has to see the pad AFTER a change, from inside the\n * handler that made it — before React has rendered anything. The state below\n * mirrors the parts the markup needs (empty or not, which mode, the field's text).\n */\n const scene = useRef<{ strokes: Stroke[]; mode: \"drawn\" | \"typed\"; name: string }>({\n strokes: [],\n mode: \"drawn\",\n name: \"\",\n });\n const live = useRef<{ id: number; stroke: Stroke } | null>(null);\n /**\n * The canvas width, in CSS px, the stored points are relative to. Fixed by the\n * first stroke; a later resize scales the drawing UNIFORMLY by `width / refWidth`,\n * so a rotated phone shows the same signature larger or smaller, never stretched.\n */\n const refWidth = useRef(0);\n const [strokeCount, setStrokeCount] = useState(0);\n const [mode, setMode] = useState<\"drawn\" | \"typed\">(\"drawn\");\n const [typedName, setTypedName] = useState(\"\");\n\n const typed = mode === \"typed\";\n const isEmpty = typed ? typedName.trim() === \"\" : strokeCount === 0;\n\n // Everything the paint and export paths read that can change between renders, so\n // the observers below subscribe once instead of on every keystroke.\n const opts = useRef({ lineWidth, exportInk, exportBackground });\n useEffect(() => {\n opts.current = { lineWidth, exportInk, exportBackground };\n }, [lineWidth, exportInk, exportBackground]);\n\n const sceneEmpty = () => {\n const s = scene.current;\n return s.mode === \"typed\" ? s.name.trim() === \"\" : s.strokes.length === 0;\n };\n\n /** The whole scene, painted into `ctx` at `w`×`h` CSS px. */\n const paintInto = useCallback((ctx: CanvasRenderingContext2D, ink: string, w: number, h: number, dpr: number) => {\n const { lineWidth: width } = opts.current;\n const s = scene.current;\n prepare(ctx, ink, dpr);\n if (s.mode === \"typed\") {\n if (s.name.trim()) drawName(ctx, s.name.trim(), w, h);\n return;\n }\n const scale = refWidth.current > 0 ? w / refWidth.current : 1;\n for (const stroke of s.strokes) {\n for (let i = 1; i < stroke.length; i += 1) drawSegment(ctx, stroke, i, scale, width);\n drawTail(ctx, stroke, scale, width);\n }\n const pending = live.current?.stroke;\n if (pending) for (let i = 1; i < pending.length; i += 1) drawSegment(ctx, pending, i, scale, width);\n }, []);\n\n /** The ink on screen: the canvas's own computed `color`, i.e. `--text-primary`. */\n const screenInk = (canvas: HTMLCanvasElement) =>\n // Read as `color` rather than as the custom property: `--text-primary` is whatever\n // the palette wrote (a `color-mix()` in some presets), which a 2D context may not\n // parse; `color` has already been resolved by the browser.\n getComputedStyle(canvas).color || opts.current.exportInk;\n\n /** Repaint the visible canvas from the scene, in the theme's ink. */\n const repaint = useCallback(() => {\n const canvas = canvasRef.current;\n const ctx = canvas?.getContext(\"2d\");\n if (!canvas || !ctx) return;\n const dpr = window.devicePixelRatio || 1;\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n paintInto(ctx, screenInk(canvas), canvas.width / dpr, canvas.height / dpr, dpr);\n }, [paintInto]);\n\n /** Size the bitmap to the element × devicePixelRatio, then repaint. */\n const resize = useCallback(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const rect = canvas.getBoundingClientRect();\n const dpr = window.devicePixelRatio || 1;\n const w = Math.round(rect.width * dpr);\n const h = Math.round(rect.height * dpr);\n // Assigning `width` clears the bitmap even when the value is unchanged, so only\n // when it moved — and the repaint puts the strokes back either way.\n if (canvas.width !== w) canvas.width = w;\n if (canvas.height !== h) canvas.height = h;\n repaint();\n }, [repaint]);\n\n useLayoutEffect(() => {\n resize();\n }, [resize]);\n\n // A changed base width redraws what is already there.\n useEffect(() => {\n repaint();\n }, [lineWidth, repaint]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const cleanups: Array<() => void> = [];\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => resize());\n ro.observe(canvas);\n cleanups.push(() => ro.disconnect());\n }\n // The ink follows the theme, and the theme is a class (`.dark`) or inline\n // variables (the palette store) on <html> — neither of which tells a canvas.\n if (typeof MutationObserver !== \"undefined\") {\n const mo = new MutationObserver(() => repaint());\n mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] });\n cleanups.push(() => mo.disconnect());\n }\n const mq = typeof window.matchMedia === \"function\" ? window.matchMedia(\"(prefers-color-scheme: dark)\") : null;\n if (mq?.addEventListener) {\n mq.addEventListener(\"change\", repaint);\n cleanups.push(() => mq.removeEventListener(\"change\", repaint));\n }\n return () => cleanups.forEach((fn) => fn());\n }, [resize, repaint]);\n\n /** The PNG, re-rendered off-screen in the export ink — `null` while empty. */\n const exportPng = useCallback((): string | null => {\n const canvas = canvasRef.current;\n if (!canvas || sceneEmpty()) return null;\n const { exportInk: ink, exportBackground: bg } = opts.current;\n const dpr = window.devicePixelRatio || 1;\n const out = document.createElement(\"canvas\");\n out.width = canvas.width;\n out.height = canvas.height;\n const ctx = out.getContext(\"2d\");\n if (!ctx) return null;\n if (bg) {\n ctx.fillStyle = bg;\n ctx.fillRect(0, 0, out.width, out.height);\n }\n paintInto(ctx, ink, out.width / dpr, out.height / dpr, dpr);\n return out.toDataURL(\"image/png\");\n }, [paintInto]);\n\n const detail = (): SignatureDetail => {\n const s = scene.current;\n return s.mode === \"typed\" ? { method: \"typed\", typedName: s.name.trim() } : { method: \"drawn\" };\n };\n\n /** After every change to the scene: sync the markup's mirror, repaint, tell the host. */\n const onChangeRef = useRef(onChange);\n useEffect(() => {\n onChangeRef.current = onChange;\n }, [onChange]);\n const commit = () => {\n const s = scene.current;\n setStrokeCount(s.strokes.length);\n setMode(s.mode);\n setTypedName(s.name);\n repaint();\n onChangeRef.current?.(exportPng(), detail());\n };\n\n const clearScene = () => {\n scene.current = { ...scene.current, strokes: [], name: \"\" };\n live.current = null;\n refWidth.current = 0;\n commit();\n };\n\n useImperativeHandle(ref, () => ({ clear: clearScene, toDataURL: exportPng, isEmpty: sceneEmpty }));\n\n /* ── Pointer ── */\n\n const toPoint = (e: { clientX: number; clientY: number; pressure: number; pointerType: string }, rect: DOMRect): Point => {\n const scale = refWidth.current > 0 && rect.width > 0 ? rect.width / refWidth.current : 1;\n return {\n x: (e.clientX - rect.left) / scale,\n y: (e.clientY - rect.top) / scale,\n // Only a pen's pressure means anything: a mouse reports 0.5 while pressed and\n // touch hardware reports 0, 1 or noise.\n p: e.pointerType === \"pen\" && e.pressure > 0 ? e.pressure : 0.5,\n };\n };\n\n const onPointerDown = (e: ReactPointerEvent<HTMLCanvasElement>) => {\n // One stroke at a time: a palm resting beside the pen is a second pointer.\n if (disabled || typed || live.current) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n e.preventDefault();\n const canvas = e.currentTarget;\n canvas.setPointerCapture?.(e.pointerId);\n const rect = canvas.getBoundingClientRect();\n if (refWidth.current === 0) refWidth.current = rect.width || 1;\n live.current = { id: e.pointerId, stroke: [toPoint(e, rect)] };\n };\n\n const onPointerMove = (e: ReactPointerEvent<HTMLCanvasElement>) => {\n const pending = live.current;\n if (!pending || pending.id !== e.pointerId) return;\n e.preventDefault();\n const canvas = e.currentTarget;\n const rect = canvas.getBoundingClientRect();\n // Coalesced events: the samples the browser merged into this one move. A fast\n // flick on a 120 Hz pen is otherwise a handful of long straight chords.\n const samples = e.nativeEvent.getCoalescedEvents?.() ?? [];\n const events = samples.length > 0 ? samples : [e];\n const ctx = canvas.getContext(\"2d\");\n if (ctx) prepare(ctx, screenInk(canvas), window.devicePixelRatio || 1);\n const scale = refWidth.current > 0 && rect.width > 0 ? rect.width / refWidth.current : 1;\n for (const ev of events) {\n pending.stroke.push(toPoint(ev, rect));\n // Only the newest segment — a full repaint per move is O(ink) on every event.\n if (ctx) drawSegment(ctx, pending.stroke, pending.stroke.length - 1, scale, opts.current.lineWidth);\n }\n };\n\n const endStroke = (e: ReactPointerEvent<HTMLCanvasElement>) => {\n const pending = live.current;\n if (!pending || pending.id !== e.pointerId) return;\n e.currentTarget.releasePointerCapture?.(e.pointerId);\n live.current = null;\n scene.current = { ...scene.current, strokes: [...scene.current.strokes, pending.stroke] };\n commit();\n };\n\n /* ── Buttons ── */\n\n const undo = () => {\n if (disabled || scene.current.strokes.length === 0) return;\n const strokes = scene.current.strokes.slice(0, -1);\n if (strokes.length === 0) refWidth.current = 0;\n scene.current = { ...scene.current, strokes };\n commit();\n announce(labels.undone);\n };\n\n const clear = () => {\n if (disabled || sceneEmpty()) return;\n clearScene();\n announce(labels.cleared);\n };\n\n const switchMode = () => {\n if (disabled) return;\n scene.current = { ...scene.current, mode: scene.current.mode === \"drawn\" ? \"typed\" : \"drawn\" };\n commit();\n };\n\n const onTypedChange = (name: string) => {\n scene.current = { ...scene.current, name };\n commit();\n };\n\n const save = () => {\n if (disabled) return;\n const png = exportPng();\n if (png) onSave?.(png, detail());\n };\n\n const hasError = error !== undefined && error !== null && error !== false && error !== \"\";\n const isInvalid = Boolean(invalid) || hasError;\n const describedBy = [descId, statusId, hasError ? errorId : null].filter(Boolean).join(\" \");\n\n // `aria-disabled` rather than `disabled` on undo/clear: they disable THEMSELVES\n // (clear empties the pad, the last undo does too), and a natively disabled button\n // drops keyboard focus to <body> the moment it is activated.\n const softDisabled = \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50\";\n\n return (\n <div {...rest} className={cn(\"relative space-y-1.5\", className)} data-empty={isEmpty || undefined}>\n <div>\n <span id={labelId} className=\"block text-sm font-medium text-[var(--text-primary)]\">\n {label ?? labels.label}\n </span>\n <p id={descId} className=\"text-xs text-[var(--text-muted)]\">\n {description ?? labels.instructions}\n {allowTypedName && !typed ? ` ${labels.typedFallbackHint}` : null}\n </p>\n </div>\n\n <div className=\"relative\">\n <canvas\n ref={canvasRef}\n role=\"img\"\n aria-labelledby={labelId}\n aria-describedby={describedBy}\n // No `aria-invalid` / `aria-disabled`: `img` supports neither. The error\n // reaches a screen reader through `aria-describedby`; the attributes below\n // are for styling and tests.\n data-invalid={isInvalid || undefined}\n data-disabled={disabled || undefined}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endStroke}\n onPointerCancel={endStroke}\n className={cn(\n // `touch-none` stops the page scrolling under a finger that is signing.\n \"block h-40 w-full touch-none select-none rounded-md border border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-primary)]\",\n typed || disabled ? \"cursor-default\" : \"cursor-crosshair\",\n disabled && \"opacity-60\",\n isInvalid && FIELD_INVALID,\n canvasClassName,\n )}\n />\n {/* The signing line — where people expect to sign. Decoration only. */}\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-x-6 bottom-8 border-b border-dashed border-[var(--border-strong)]\"\n />\n <span id={statusId} className=\"sr-only\">\n {isEmpty ? labels.empty : labels.signed}\n </span>\n </div>\n\n {typed && (\n <Input\n label={labels.typedName}\n value={typedName}\n autoComplete=\"name\"\n disabled={disabled}\n onChange={(e) => onTypedChange(e.target.value)}\n />\n )}\n\n <div className=\"flex flex-wrap items-center gap-2\">\n {!typed && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={undo}\n aria-disabled={disabled || strokeCount === 0 || undefined}\n className={softDisabled}\n >\n <Undo2 aria-hidden=\"true\" className=\"size-4\" />\n {labels.undo}\n </Button>\n )}\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={clear}\n aria-disabled={disabled || isEmpty || undefined}\n className={softDisabled}\n >\n <Eraser aria-hidden=\"true\" className=\"size-4\" />\n {labels.clear}\n </Button>\n {allowTypedName && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n onClick={switchMode}\n disabled={disabled}\n >\n {typed ? <PenLine aria-hidden=\"true\" className=\"size-4\" /> : <Keyboard aria-hidden=\"true\" className=\"size-4\" />}\n {typed ? labels.useDrawn : labels.useTyped}\n </Button>\n )}\n {onSave && (\n <Button type=\"button\" variant=\"brand\" onClick={save} disabled={disabled || isEmpty} className=\"ms-auto\">\n {labels.save}\n </Button>\n )}\n </div>\n\n {hasError && (\n <p id={errorId} className=\"mt-1 text-[11px] leading-tight text-[var(--danger)]\">\n {error}\n </p>\n )}\n {/* Rendered from the start and never unmounted — see useAnnounce. */}\n <div {...regionProps} />\n </div>\n );\n});\nSignaturePad.displayName = \"SignaturePad\";\n\n/* ── Read-only ───────────────────────────────────────────────────────────── */\n\nexport interface SignatureViewProps extends ComponentPropsWithoutRef<\"figure\"> {\n /** The saved PNG — what `SignaturePad` handed to `onChange`/`onSave`, as stored. */\n value?: string | null;\n /**\n * A signature given as a typed name (`detail.typedName`), for a host that stores the\n * text rather than its PNG. Rendered in the same script face the pad draws it in.\n * Ignored while `value` is set.\n */\n typedName?: string | null;\n /** Visible caption above the signature; defaults to `labels.label`. `null` hides it\n * (the image keeps its alternative text) — for a signature block whose own heading\n * already says whose signature it is. */\n label?: ReactNode;\n /**\n * The exported PNG is dark ink on a transparent ground (`exportInk`), which is\n * invisible on the dark theme's surface. By default the image is colour-inverted\n * under `.dark`, so the ink reads light; `false` keeps the pixels as stored — for a\n * PNG exported with its own `exportBackground`, which inverting would turn black.\n */\n adaptInk?: boolean;\n /** Classes for the frame — its height, chiefly (default `h-40`, the pad's). */\n frameClassName?: string;\n /** User-facing strings; see {@link SignaturePadLabels} (`view*`). */\n labels?: Partial<SignaturePadLabels>;\n}\n\n/**\n * A saved signature, shown — the read side of {@link SignaturePad}: the same frame and\n * signing line, none of the drawing chrome (no canvas, buttons, instructions or live\n * region). A separate component rather than a `readOnly` pad because there is nothing\n * of the pad to reuse: the pad's scene is STROKES, and a stored signature is a PNG\n * (or a name) that cannot be turned back into them.\n *\n * Kastlan's handover protocol, reopened after signing, is the case: two signatures\n * that must be visible, and must not look editable.\n */\nexport function SignatureView({\n value,\n typedName,\n label,\n adaptInk = true,\n frameClassName,\n labels: labelsProp,\n className,\n ...rest\n}: SignatureViewProps) {\n const labels = useKitLabels(\"signaturePad\", DEFAULT_SIGNATURE_PAD_LABELS, labelsProp);\n const name = typedName?.trim() ?? \"\";\n const kind = value ? \"drawn\" : name ? \"typed\" : \"empty\";\n const caption = label === undefined ? labels.label : label;\n const captionId = useId();\n const captioned = caption !== null && caption !== false;\n return (\n <figure\n // Named explicitly: the figcaption-to-figure name is not computed everywhere.\n aria-labelledby={captioned ? captionId : undefined}\n {...rest}\n className={cn(\"m-0 space-y-1.5\", className)}\n data-empty={kind === \"empty\" || undefined}\n >\n {captioned && (\n <figcaption id={captionId} className=\"block text-sm font-medium text-[var(--text-primary)]\">{caption}</figcaption>\n )}\n <div\n className={cn(\n \"relative flex h-40 items-center justify-center overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)]\",\n frameClassName,\n )}\n >\n {kind === \"drawn\" && (\n <img\n src={value as string}\n alt={labels.viewDrawn}\n className={cn(\"relative max-h-full max-w-full object-contain\", adaptInk && \"dark:invert\")}\n />\n )}\n {kind === \"typed\" && (\n // One name for the whole thing: the visible text alone would be read as a bare\n // name, with nothing saying it IS the signature.\n <span\n role=\"img\"\n aria-label={labels.viewTyped(name)}\n className=\"relative max-w-[85%] truncate px-2 text-4xl italic text-[var(--text-primary)]\"\n style={{ fontFamily: SCRIPT_FONT }}\n >\n {name}\n </span>\n )}\n {kind === \"empty\" && (\n <span className=\"relative text-xs text-[var(--text-muted)]\">{labels.viewEmpty}</span>\n )}\n {/* The signing line, as on the pad. Decoration only. */}\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-x-6 bottom-8 border-b border-dashed border-[var(--border-strong)]\"\n />\n </div>\n </figure>\n );\n}\n"],"mappings":"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","names":["SignaturePad"]}
@@ -0,0 +1,69 @@
1
+ import * as react from 'react';
2
+ import { ComponentPropsWithoutRef } from 'react';
3
+ import { TileSize } from './tile-radio.js';
4
+
5
+ interface SwatchPickerLabels {
6
+ /** The "no colour" tile's name, when `allowNone` is set. */
7
+ none: string;
8
+ /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */
9
+ mixed: string;
10
+ }
11
+ declare const DEFAULT_SWATCH_PICKER_LABELS: SwatchPickerLabels;
12
+ interface SwatchOption<T extends string> {
13
+ value: T;
14
+ /** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an
15
+ * inline background, so it does not have to be a class the kit's CSS knows.
16
+ * Leave it out when `swatchClassName` paints the dot instead. */
17
+ color?: string;
18
+ /** The colour's name, in the user's language. Shown in the bubble and read out;
19
+ * required, because a swatch with no name is a colour only some people can read. */
20
+ label: string;
21
+ /** Classes for the swatch dot, for a palette that lives in classes rather than
22
+ * values (a light/dark pair). An inline `color` would win over a class, so pass
23
+ * one or the other. */
24
+ swatchClassName?: string;
25
+ /** A remark read after the name and shown under it in the bubble ("used by
26
+ * Groceries"). The tile wears a dot while it has one. */
27
+ note?: string;
28
+ disabled?: boolean;
29
+ }
30
+ /**
31
+ * `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's
32
+ * is omitted rather than shadowed, as on {@link ToggleGroup}.
33
+ */
34
+ interface SwatchPickerProps<T extends string> extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children" | "defaultValue"> {
35
+ options: SwatchOption<T>[];
36
+ /** The chosen colour, or `null` for none. */
37
+ value: T | null;
38
+ onChange: (value: T | null) => void;
39
+ /** Lead with a "no colour" tile whose value is `null`. Clearing a colour has to be
40
+ * as reachable as setting one, and a radio cannot be unchecked by clicking it. */
41
+ allowNone?: boolean;
42
+ /**
43
+ * "Some of each" — a bulk edit over rows whose colours differ. Nothing is checked
44
+ * (not even "none", whatever `value` says), and the group is described as mixed.
45
+ * Distinct from `value={null}`, which is an answer: "no colour".
46
+ */
47
+ mixed?: boolean;
48
+ /** See {@link IconPickerProps.activation}. Default "automatic". */
49
+ activation?: "automatic" | "manual";
50
+ /** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
51
+ size?: TileSize;
52
+ disabled?: boolean;
53
+ labels?: Partial<SwatchPickerLabels>;
54
+ }
55
+ /**
56
+ * A row of colour swatches that is one radio group.
57
+ *
58
+ * Name the group with `aria-label` or `aria-labelledby` — usually the visible heading
59
+ * above it. Each swatch is named by its `label` and shows it in a bubble on hover and
60
+ * focus, so the name is not the screen reader's alone.
61
+ *
62
+ * Keyboard (the APG radio group): Tab reaches the checked swatch, or the first one
63
+ * while nothing is checked; the arrow keys move between swatches and, by default,
64
+ * choose as they go; Home and End jump to the ends. Left and right follow the page's
65
+ * direction.
66
+ */
67
+ declare function SwatchPicker<T extends string>({ options, value, onChange, allowNone, mixed, activation, size, disabled, labels, className, ...rest }: SwatchPickerProps<T>): react.JSX.Element;
68
+
69
+ export { DEFAULT_SWATCH_PICKER_LABELS, type SwatchOption, SwatchPicker, type SwatchPickerLabels, type SwatchPickerProps };
@@ -0,0 +1,75 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useId, useMemo } from "react";
4
+ import { cn } from "../lib/cn.js";
5
+ import { useKitLabels } from "../i18n/kit-labels.js";
6
+ import { TILE_SIZE, TileRadioGroup } from "./tile-radio.js";
7
+ const DEFAULT_SWATCH_PICKER_LABELS = {
8
+ none: "No colour",
9
+ mixed: "Mixed: the selected items have different colours"
10
+ };
11
+ function SwatchPicker({
12
+ options,
13
+ value,
14
+ onChange,
15
+ allowNone = false,
16
+ mixed = false,
17
+ activation = "automatic",
18
+ size = "md",
19
+ disabled = false,
20
+ labels,
21
+ className,
22
+ ...rest
23
+ }) {
24
+ const text = useKitLabels("swatchPicker", DEFAULT_SWATCH_PICKER_LABELS, labels);
25
+ const mixedId = useId();
26
+ const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
27
+ const items = [
28
+ ...allowNone ? [{ key: null, label: text.none }] : [],
29
+ ...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled }))
30
+ ];
31
+ return /* @__PURE__ */ jsxs(
32
+ "div",
33
+ {
34
+ ...rest,
35
+ role: "radiogroup",
36
+ "aria-disabled": disabled || void 0,
37
+ "aria-describedby": [rest["aria-describedby"], mixed && mixedId].filter(Boolean).join(" ") || void 0,
38
+ className: cn("flex flex-wrap items-center gap-1.5", className),
39
+ children: [
40
+ /* @__PURE__ */ jsx(
41
+ TileRadioGroup,
42
+ {
43
+ items,
44
+ checked: mixed ? void 0 : value,
45
+ onSelect: onChange,
46
+ activation,
47
+ disabled,
48
+ size,
49
+ renderTile: (item) => {
50
+ const opt = item.key === null ? void 0 : byValue.get(item.key);
51
+ return /* @__PURE__ */ jsx(
52
+ "span",
53
+ {
54
+ "aria-hidden": true,
55
+ className: cn(
56
+ "rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15",
57
+ TILE_SIZE[size].swatch,
58
+ opt?.swatchClassName
59
+ ),
60
+ style: { background: opt?.color }
61
+ }
62
+ );
63
+ }
64
+ }
65
+ ),
66
+ mixed && /* @__PURE__ */ jsx("span", { id: mixedId, hidden: true, children: text.mixed })
67
+ ]
68
+ }
69
+ );
70
+ }
71
+ export {
72
+ DEFAULT_SWATCH_PICKER_LABELS,
73
+ SwatchPicker
74
+ };
75
+ //# sourceMappingURL=swatch-picker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/swatch-picker.tsx"],"sourcesContent":["import { useId, useMemo } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\n\nexport interface SwatchPickerLabels {\n /** The \"no colour\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n}\n\nexport const DEFAULT_SWATCH_PICKER_LABELS: SwatchPickerLabels = {\n none: \"No colour\",\n mixed: \"Mixed: the selected items have different colours\",\n};\n\nexport interface SwatchOption<T extends string> {\n value: T;\n /** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an\n * inline background, so it does not have to be a class the kit's CSS knows.\n * Leave it out when `swatchClassName` paints the dot instead. */\n color?: string;\n /** The colour's name, in the user's language. Shown in the bubble and read out;\n * required, because a swatch with no name is a colour only some people can read. */\n label: string;\n /** Classes for the swatch dot, for a palette that lives in classes rather than\n * values (a light/dark pair). An inline `color` would win over a class, so pass\n * one or the other. */\n swatchClassName?: string;\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Groceries\"). The tile wears a dot while it has one. */\n note?: string;\n disabled?: boolean;\n}\n\n/**\n * `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's\n * is omitted rather than shadowed, as on {@link ToggleGroup}.\n */\nexport interface SwatchPickerProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: SwatchOption<T>[];\n /** The chosen colour, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no colour\" tile whose value is `null`. Clearing a colour has to be\n * as reachable as setting one, and a radio cannot be unchecked by clicking it. */\n allowNone?: boolean;\n /**\n * \"Some of each\" — a bulk edit over rows whose colours differ. Nothing is checked\n * (not even \"none\", whatever `value` says), and the group is described as mixed.\n * Distinct from `value={null}`, which is an answer: \"no colour\".\n */\n mixed?: boolean;\n /** See {@link IconPickerProps.activation}. Default \"automatic\". */\n activation?: \"automatic\" | \"manual\";\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n disabled?: boolean;\n labels?: Partial<SwatchPickerLabels>;\n}\n\n/**\n * A row of colour swatches that is one radio group.\n *\n * Name the group with `aria-label` or `aria-labelledby` — usually the visible heading\n * above it. Each swatch is named by its `label` and shows it in a bubble on hover and\n * focus, so the name is not the screen reader's alone.\n *\n * Keyboard (the APG radio group): Tab reaches the checked swatch, or the first one\n * while nothing is checked; the arrow keys move between swatches and, by default,\n * choose as they go; Home and End jump to the ends. Left and right follow the page's\n * direction.\n */\nexport function SwatchPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n size = \"md\",\n disabled = false,\n labels,\n className,\n ...rest\n}: SwatchPickerProps<T>) {\n const text = useKitLabels(\"swatchPicker\", DEFAULT_SWATCH_PICKER_LABELS, labels);\n const mixedId = useId();\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),\n ];\n return (\n <div\n {...rest}\n role=\"radiogroup\"\n aria-disabled={disabled || undefined}\n aria-describedby={\n [rest[\"aria-describedby\"], mixed && mixedId].filter(Boolean).join(\" \") || undefined\n }\n className={cn(\"flex flex-wrap items-center gap-1.5\", className)}\n >\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n onSelect={onChange}\n activation={activation}\n disabled={disabled}\n size={size}\n renderTile={(item) => {\n const opt = item.key === null ? undefined : byValue.get(item.key);\n return (\n <span\n aria-hidden\n // A hairline round the dot, drawn over whatever colour it is: a pale\n // yellow swatch on a white surface is otherwise a hole in the row.\n className={cn(\n \"rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15\",\n TILE_SIZE[size].swatch,\n opt?.swatchClassName,\n )}\n style={{ background: opt?.color }}\n />\n );\n }}\n />\n {/* `hidden`, not `sr-only`: a description is read from hidden text, and this\n way it takes no room in the row. */}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n </div>\n );\n}\n"],"mappings":";AAkGI,SAmBQ,KAnBR;AAlGJ,SAAS,OAAO,eAAe;AAE/B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,WAAW,sBAAsB;AAUnC,MAAM,+BAAmD;AAAA,EAC9D,MAAM;AAAA,EACN,OAAO;AACT;AA4DO,SAAS,aAA+B;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,OAAO;AAAA,EACP,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAyB;AACvB,QAAM,OAAO,aAAa,gBAAgB,8BAA8B,MAAM;AAC9E,QAAM,UAAU,MAAM;AACtB,QAAM,UAAU,QAAQ,MAAM,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC;AAClF,QAAM,QAAuB;AAAA,IAC3B,GAAI,YAAY,CAAC,EAAE,KAAK,MAAM,OAAO,KAAK,KAAK,CAAC,IAAI,CAAC;AAAA,IACrD,GAAG,QAAQ,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,UAAU,EAAE,SAAS,EAAE;AAAA,EAC9F;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,MAAK;AAAA,MACL,iBAAe,YAAY;AAAA,MAC3B,oBACE,CAAC,KAAK,kBAAkB,GAAG,SAAS,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG,KAAK;AAAA,MAE5E,WAAW,GAAG,uCAAuC,SAAS;AAAA,MAE9D;AAAA;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA,SAAS,QAAQ,SAAY;AAAA,YAC7B,UAAU;AAAA,YACV;AAAA,YACA;AAAA,YACA;AAAA,YACA,YAAY,CAAC,SAAS;AACpB,oBAAM,MAAM,KAAK,QAAQ,OAAO,SAAY,QAAQ,IAAI,KAAK,GAAG;AAChE,qBACE;AAAA,gBAAC;AAAA;AAAA,kBACC,eAAW;AAAA,kBAGX,WAAW;AAAA,oBACT;AAAA,oBACA,UAAU,IAAI,EAAE;AAAA,oBAChB,KAAK;AAAA,kBACP;AAAA,kBACA,OAAO,EAAE,YAAY,KAAK,MAAM;AAAA;AAAA,cAClC;AAAA,YAEJ;AAAA;AAAA,QACF;AAAA,QAGC,SACC,oBAAC,UAAK,IAAI,SAAS,QAAM,MACtB,eAAK,OACR;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
@@ -47,6 +47,15 @@ interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"
47
47
  /** Classes for the `<input>` (the track). `className` styles the outermost element:
48
48
  * the row when there is a label, the switch's own wrapper when there is not. */
49
49
  inputClassName?: string;
50
+ /**
51
+ * Must be ticked to submit — a consent, an acceptance before paying. Reaches the
52
+ * `<input>` as the native `required` (so a `<form>` refuses to submit without it and a
53
+ * screen reader announces "required"), and draws the kit's required mark after the
54
+ * label, the same `aria-hidden` star as {@link Label}'s: the word is announced from
55
+ * the control, so a star inside the name would only be read out as noise. Write the
56
+ * label WITHOUT a literal "*". No mark without a label.
57
+ */
58
+ required?: boolean;
50
59
  }
51
60
  /**
52
61
  * A native checkbox drawn as a track and thumb, announced as a switch.
@@ -22,6 +22,7 @@ const Switch = forwardRef(function Switch2({
22
22
  inputClassName,
23
23
  id,
24
24
  disabled,
25
+ required,
25
26
  ...rest
26
27
  }, ref) {
27
28
  const generated = useId();
@@ -37,6 +38,7 @@ const Switch = forwardRef(function Switch2({
37
38
  ref,
38
39
  id: bare ? id : inputId,
39
40
  disabled,
41
+ required,
40
42
  ...rest,
41
43
  type: "checkbox",
42
44
  role: "switch",
@@ -53,7 +55,7 @@ const Switch = forwardRef(function Switch2({
53
55
  if (bare) return control;
54
56
  const cell = /* @__PURE__ */ jsx("span", { className: "flex h-5 shrink-0 items-center", children: control });
55
57
  const words = /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
56
- label !== void 0 && /* @__PURE__ */ jsx(
58
+ label !== void 0 && /* @__PURE__ */ jsxs(
57
59
  "label",
58
60
  {
59
61
  htmlFor: inputId,
@@ -61,7 +63,10 @@ const Switch = forwardRef(function Switch2({
61
63
  "block text-sm leading-5 text-[var(--text-primary)]",
62
64
  disabled ? "cursor-not-allowed" : "cursor-pointer select-none"
63
65
  ),
64
- children: label
66
+ children: [
67
+ label,
68
+ required && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "ms-0.5 text-[var(--danger)]", children: "*" })
69
+ ]
65
70
  }
66
71
  ),
67
72
  showDescription && /* @__PURE__ */ jsx("span", { id: descriptionId, className: "mt-0.5 block text-xs text-[var(--text-muted)]", children: description })
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/switch.tsx"],"sourcesContent":["import { forwardRef, useId } from \"react\";\nimport type { ChangeEvent, InputHTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { hasMessage, mergeDescribedBy } from \"./choice-parts\";\n\n/**\n * An on/off switch that is still `<input type=\"checkbox\">`, with `role=\"switch\"`.\n *\n * Kastlan's was a Radix `<button>`; this keeps the native element for the reasons\n * `Checkbox` gives (form submission, `required`, reset, `<label for>`), and adds the\n * one thing a checkbox is not: `role=\"switch\"`, which screen readers announce as\n * \"on/off\" rather than \"checked/not checked\". Use it for a setting that takes effect\n * the moment it is flipped; a choice that waits for a Save button is a checkbox.\n */\n\n/** Geometry per size. The thumb sits `2px` in from each end of the track, so its\n * checked offset is `track width − thumb − 2px`. `start-*`, never `left-*` or a\n * `translate-x`: an inline-start offset follows the writing direction, so in a\n * right-to-left form the thumb travels right-to-left with no `rtl:` override to\n * keep in step. */\nconst SIZES = {\n // 28×16 track, 12px thumb: the dense setting rows in Kastlan's tables.\n sm: { track: \"h-4 w-7\", thumb: \"size-3 peer-checked:start-[14px]\" },\n // 36×20 track, 16px thumb.\n md: { track: \"h-5 w-9\", thumb: \"size-4 peer-checked:start-[18px]\" },\n} as const;\n\nexport type SwitchSize = keyof typeof SIZES;\n\n// The track IS the input — `appearance-none` and a pill — so its box is the pointer\n// target and the focus ring lands around the whole control, not the thumb alone.\n//\n// Unchecked it takes `--border-strong`, not `--border`: a hairline-coloured track on\n// a surface-coloured card is a switch you have to look for. Checked it takes the\n// brand fill, which is the only difference in COLOUR; the thumb's position is the\n// difference in SHAPE, so the state never rests on colour alone.\nconst TRACK =\n \"peer block shrink-0 cursor-pointer appearance-none rounded-full bg-[var(--border-strong)] transition-colors \" +\n \"checked:bg-[var(--brand)] \" +\n \"focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] \" +\n \"disabled:cursor-not-allowed\";\n\n// The thumb, laid over the input and ignoring the pointer so every click still reaches\n// the input. It swaps to `--brand-contrast` on the brand fill: the surface colour would\n// match the fill in some presets, and the contrast token is the one guaranteed to read\n// against --brand.\nconst THUMB =\n \"pointer-events-none absolute start-0.5 top-1/2 -translate-y-1/2 rounded-full bg-[var(--bg-surface)] shadow-sm \" +\n \"transition-[inset-inline-start,background-color] motion-reduce:transition-none \" +\n \"peer-checked:bg-[var(--brand-contrast)]\";\n\nexport interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, \"type\" | \"size\" | \"role\"> {\n /** `\"md\"` (36×20, default) or `\"sm\"` (28×16) — Kastlan's `default` and `sm`. */\n size?: SwitchSize;\n /** The words beside the switch, as a `<label for>` — see {@link Checkbox}'s\n * `label` for why it does not wrap the input. Without it, give the switch an\n * `aria-label`. */\n label?: ReactNode;\n /** Secondary text under the label, attached with `aria-describedby` (merged with\n * any the caller passed). */\n description?: ReactNode;\n /** Which end of the row the switch sits at when there is a label. `\"end\"` (the\n * default) is the settings-list shape Kastlan's profile page uses — the words at\n * the start, the control at the far end; `\"start\"` puts it before the words, like\n * a checkbox. Logical, so it follows the writing direction. */\n switchPosition?: \"start\" | \"end\";\n /** The new state as a boolean — Radix's `onCheckedChange`, so Kastlan's call sites\n * move without unwrapping the event. Fires alongside `onChange`. */\n onCheckedChange?: (checked: boolean) => void;\n /** Classes for the `<input>` (the track). `className` styles the outermost element:\n * the row when there is a label, the switch's own wrapper when there is not. */\n inputClassName?: string;\n}\n\n/**\n * A native checkbox drawn as a track and thumb, announced as a switch.\n *\n * Every other prop reaches the `<input>`, so it works controlled (`checked` +\n * `onChange`/`onCheckedChange`), uncontrolled (`defaultChecked`) and in a plain\n * `<form>` (`name`, `value`).\n */\nexport const Switch = forwardRef<HTMLInputElement, SwitchProps>(function Switch(\n {\n size = \"md\",\n label,\n description,\n switchPosition = \"end\",\n onCheckedChange,\n onChange,\n className,\n inputClassName,\n id,\n disabled,\n ...rest\n },\n ref,\n) {\n const generated = useId();\n const inputId = id ?? generated;\n const descriptionId = `${inputId}-description`;\n const showDescription = hasMessage(description);\n const bare = label === undefined && !showDescription;\n const geometry = SIZES[size];\n\n const control = (\n <span className={cn(\"relative inline-flex shrink-0\", bare && className)}>\n <input\n ref={ref}\n id={bare ? id : inputId}\n disabled={disabled}\n {...rest}\n // After the spread: a caller's props object must not be able to turn this\n // back into a plain checkbox, or into a text field.\n type=\"checkbox\"\n role=\"switch\"\n aria-describedby={mergeDescribedBy(rest[\"aria-describedby\"], showDescription && descriptionId)}\n onChange={(e: ChangeEvent<HTMLInputElement>) => {\n onChange?.(e);\n onCheckedChange?.(e.target.checked);\n }}\n className={cn(TRACK, geometry.track, inputClassName)}\n />\n <span aria-hidden className={cn(THUMB, geometry.thumb)} />\n </span>\n );\n\n if (bare) return control;\n\n // The switch's cell is one label line tall (text-sm, 20px) so it centres on the\n // FIRST line of a label that wraps, and a description underneath does not drag it\n // down to the middle of the paragraph.\n const cell = <span className=\"flex h-5 shrink-0 items-center\">{control}</span>;\n const words = (\n <span className=\"min-w-0 flex-1\">\n {label !== undefined && (\n <label\n htmlFor={inputId}\n className={cn(\n \"block text-sm leading-5 text-[var(--text-primary)]\",\n disabled ? \"cursor-not-allowed\" : \"cursor-pointer select-none\",\n )}\n >\n {label}\n </label>\n )}\n {showDescription && (\n <span id={descriptionId} className=\"mt-0.5 block text-xs text-[var(--text-muted)]\">\n {description}\n </span>\n )}\n </span>\n );\n\n return (\n <div className={cn(\"flex items-start gap-3\", disabled && \"opacity-60\", className)}>\n {switchPosition === \"end\" ? (\n <>\n {words}\n {cell}\n </>\n ) : (\n <>\n {cell}\n {words}\n </>\n )}\n </div>\n );\n});\nSwitch.displayName = \"Switch\";\n"],"mappings":";AAyGI,SAmDI,UAlDF,KADF;AAzGJ,SAAS,YAAY,aAAa;AAElC,SAAS,UAAU;AACnB,SAAS,YAAY,wBAAwB;AAiB7C,MAAM,QAAQ;AAAA;AAAA,EAEZ,IAAI,EAAE,OAAO,WAAW,OAAO,mCAAmC;AAAA;AAAA,EAElE,IAAI,EAAE,OAAO,WAAW,OAAO,mCAAmC;AACpE;AAWA,MAAM,QACJ;AASF,MAAM,QACJ;AAkCK,MAAM,SAAS,WAA0C,SAASA,QACvE;AAAA,EACE,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA,iBAAiB;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GACA,KACA;AACA,QAAM,YAAY,MAAM;AACxB,QAAM,UAAU,MAAM;AACtB,QAAM,gBAAgB,GAAG,OAAO;AAChC,QAAM,kBAAkB,WAAW,WAAW;AAC9C,QAAM,OAAO,UAAU,UAAa,CAAC;AACrC,QAAM,WAAW,MAAM,IAAI;AAE3B,QAAM,UACJ,qBAAC,UAAK,WAAW,GAAG,iCAAiC,QAAQ,SAAS,GACpE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,IAAI,OAAO,KAAK;AAAA,QAChB;AAAA,QACC,GAAG;AAAA,QAGJ,MAAK;AAAA,QACL,MAAK;AAAA,QACL,oBAAkB,iBAAiB,KAAK,kBAAkB,GAAG,mBAAmB,aAAa;AAAA,QAC7F,UAAU,CAAC,MAAqC;AAC9C,qBAAW,CAAC;AACZ,4BAAkB,EAAE,OAAO,OAAO;AAAA,QACpC;AAAA,QACA,WAAW,GAAG,OAAO,SAAS,OAAO,cAAc;AAAA;AAAA,IACrD;AAAA,IACA,oBAAC,UAAK,eAAW,MAAC,WAAW,GAAG,OAAO,SAAS,KAAK,GAAG;AAAA,KAC1D;AAGF,MAAI,KAAM,QAAO;AAKjB,QAAM,OAAO,oBAAC,UAAK,WAAU,kCAAkC,mBAAQ;AACvE,QAAM,QACJ,qBAAC,UAAK,WAAU,kBACb;AAAA,cAAU,UACT;AAAA,MAAC;AAAA;AAAA,QACC,SAAS;AAAA,QACT,WAAW;AAAA,UACT;AAAA,UACA,WAAW,uBAAuB;AAAA,QACpC;AAAA,QAEC;AAAA;AAAA,IACH;AAAA,IAED,mBACC,oBAAC,UAAK,IAAI,eAAe,WAAU,iDAChC,uBACH;AAAA,KAEJ;AAGF,SACE,oBAAC,SAAI,WAAW,GAAG,0BAA0B,YAAY,cAAc,SAAS,GAC7E,6BAAmB,QAClB,iCACG;AAAA;AAAA,IACA;AAAA,KACH,IAEA,iCACG;AAAA;AAAA,IACA;AAAA,KACH,GAEJ;AAEJ,CAAC;AACD,OAAO,cAAc;","names":["Switch"]}
1
+ {"version":3,"sources":["../../src/components/switch.tsx"],"sourcesContent":["import { forwardRef, useId } from \"react\";\nimport type { ChangeEvent, InputHTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { hasMessage, mergeDescribedBy } from \"./choice-parts\";\n\n/**\n * An on/off switch that is still `<input type=\"checkbox\">`, with `role=\"switch\"`.\n *\n * Kastlan's was a Radix `<button>`; this keeps the native element for the reasons\n * `Checkbox` gives (form submission, `required`, reset, `<label for>`), and adds the\n * one thing a checkbox is not: `role=\"switch\"`, which screen readers announce as\n * \"on/off\" rather than \"checked/not checked\". Use it for a setting that takes effect\n * the moment it is flipped; a choice that waits for a Save button is a checkbox.\n */\n\n/** Geometry per size. The thumb sits `2px` in from each end of the track, so its\n * checked offset is `track width − thumb − 2px`. `start-*`, never `left-*` or a\n * `translate-x`: an inline-start offset follows the writing direction, so in a\n * right-to-left form the thumb travels right-to-left with no `rtl:` override to\n * keep in step. */\nconst SIZES = {\n // 28×16 track, 12px thumb: the dense setting rows in Kastlan's tables.\n sm: { track: \"h-4 w-7\", thumb: \"size-3 peer-checked:start-[14px]\" },\n // 36×20 track, 16px thumb.\n md: { track: \"h-5 w-9\", thumb: \"size-4 peer-checked:start-[18px]\" },\n} as const;\n\nexport type SwitchSize = keyof typeof SIZES;\n\n// The track IS the input — `appearance-none` and a pill — so its box is the pointer\n// target and the focus ring lands around the whole control, not the thumb alone.\n//\n// Unchecked it takes `--border-strong`, not `--border`: a hairline-coloured track on\n// a surface-coloured card is a switch you have to look for. Checked it takes the\n// brand fill, which is the only difference in COLOUR; the thumb's position is the\n// difference in SHAPE, so the state never rests on colour alone.\nconst TRACK =\n \"peer block shrink-0 cursor-pointer appearance-none rounded-full bg-[var(--border-strong)] transition-colors \" +\n \"checked:bg-[var(--brand)] \" +\n \"focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] \" +\n \"disabled:cursor-not-allowed\";\n\n// The thumb, laid over the input and ignoring the pointer so every click still reaches\n// the input. It swaps to `--brand-contrast` on the brand fill: the surface colour would\n// match the fill in some presets, and the contrast token is the one guaranteed to read\n// against --brand.\nconst THUMB =\n \"pointer-events-none absolute start-0.5 top-1/2 -translate-y-1/2 rounded-full bg-[var(--bg-surface)] shadow-sm \" +\n \"transition-[inset-inline-start,background-color] motion-reduce:transition-none \" +\n \"peer-checked:bg-[var(--brand-contrast)]\";\n\nexport interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, \"type\" | \"size\" | \"role\"> {\n /** `\"md\"` (36×20, default) or `\"sm\"` (28×16) — Kastlan's `default` and `sm`. */\n size?: SwitchSize;\n /** The words beside the switch, as a `<label for>` — see {@link Checkbox}'s\n * `label` for why it does not wrap the input. Without it, give the switch an\n * `aria-label`. */\n label?: ReactNode;\n /** Secondary text under the label, attached with `aria-describedby` (merged with\n * any the caller passed). */\n description?: ReactNode;\n /** Which end of the row the switch sits at when there is a label. `\"end\"` (the\n * default) is the settings-list shape Kastlan's profile page uses — the words at\n * the start, the control at the far end; `\"start\"` puts it before the words, like\n * a checkbox. Logical, so it follows the writing direction. */\n switchPosition?: \"start\" | \"end\";\n /** The new state as a boolean — Radix's `onCheckedChange`, so Kastlan's call sites\n * move without unwrapping the event. Fires alongside `onChange`. */\n onCheckedChange?: (checked: boolean) => void;\n /** Classes for the `<input>` (the track). `className` styles the outermost element:\n * the row when there is a label, the switch's own wrapper when there is not. */\n inputClassName?: string;\n /**\n * Must be ticked to submit — a consent, an acceptance before paying. Reaches the\n * `<input>` as the native `required` (so a `<form>` refuses to submit without it and a\n * screen reader announces \"required\"), and draws the kit's required mark after the\n * label, the same `aria-hidden` star as {@link Label}'s: the word is announced from\n * the control, so a star inside the name would only be read out as noise. Write the\n * label WITHOUT a literal \"*\". No mark without a label.\n */\n required?: boolean;\n}\n\n/**\n * A native checkbox drawn as a track and thumb, announced as a switch.\n *\n * Every other prop reaches the `<input>`, so it works controlled (`checked` +\n * `onChange`/`onCheckedChange`), uncontrolled (`defaultChecked`) and in a plain\n * `<form>` (`name`, `value`).\n */\nexport const Switch = forwardRef<HTMLInputElement, SwitchProps>(function Switch(\n {\n size = \"md\",\n label,\n description,\n switchPosition = \"end\",\n onCheckedChange,\n onChange,\n className,\n inputClassName,\n id,\n disabled,\n required,\n ...rest\n },\n ref,\n) {\n const generated = useId();\n const inputId = id ?? generated;\n const descriptionId = `${inputId}-description`;\n const showDescription = hasMessage(description);\n const bare = label === undefined && !showDescription;\n const geometry = SIZES[size];\n\n const control = (\n <span className={cn(\"relative inline-flex shrink-0\", bare && className)}>\n <input\n ref={ref}\n id={bare ? id : inputId}\n disabled={disabled}\n required={required}\n {...rest}\n // After the spread: a caller's props object must not be able to turn this\n // back into a plain checkbox, or into a text field.\n type=\"checkbox\"\n role=\"switch\"\n aria-describedby={mergeDescribedBy(rest[\"aria-describedby\"], showDescription && descriptionId)}\n onChange={(e: ChangeEvent<HTMLInputElement>) => {\n onChange?.(e);\n onCheckedChange?.(e.target.checked);\n }}\n className={cn(TRACK, geometry.track, inputClassName)}\n />\n <span aria-hidden className={cn(THUMB, geometry.thumb)} />\n </span>\n );\n\n if (bare) return control;\n\n // The switch's cell is one label line tall (text-sm, 20px) so it centres on the\n // FIRST line of a label that wraps, and a description underneath does not drag it\n // down to the middle of the paragraph.\n const cell = <span className=\"flex h-5 shrink-0 items-center\">{control}</span>;\n const words = (\n <span className=\"min-w-0 flex-1\">\n {label !== undefined && (\n <label\n htmlFor={inputId}\n className={cn(\n \"block text-sm leading-5 text-[var(--text-primary)]\",\n disabled ? \"cursor-not-allowed\" : \"cursor-pointer select-none\",\n )}\n >\n {label}\n {required && (\n <span aria-hidden className=\"ms-0.5 text-[var(--danger)]\">\n *\n </span>\n )}\n </label>\n )}\n {showDescription && (\n <span id={descriptionId} className=\"mt-0.5 block text-xs text-[var(--text-muted)]\">\n {description}\n </span>\n )}\n </span>\n );\n\n return (\n <div className={cn(\"flex items-start gap-3\", disabled && \"opacity-60\", className)}>\n {switchPosition === \"end\" ? (\n <>\n {words}\n {cell}\n </>\n ) : (\n <>\n {cell}\n {words}\n </>\n )}\n </div>\n );\n});\nSwitch.displayName = \"Switch\";\n"],"mappings":";AAmHI,SAyDI,UAxDF,KADF;AAnHJ,SAAS,YAAY,aAAa;AAElC,SAAS,UAAU;AACnB,SAAS,YAAY,wBAAwB;AAiB7C,MAAM,QAAQ;AAAA;AAAA,EAEZ,IAAI,EAAE,OAAO,WAAW,OAAO,mCAAmC;AAAA;AAAA,EAElE,IAAI,EAAE,OAAO,WAAW,OAAO,mCAAmC;AACpE;AAWA,MAAM,QACJ;AASF,MAAM,QACJ;AA2CK,MAAM,SAAS,WAA0C,SAASA,QACvE;AAAA,EACE,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA,iBAAiB;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GACA,KACA;AACA,QAAM,YAAY,MAAM;AACxB,QAAM,UAAU,MAAM;AACtB,QAAM,gBAAgB,GAAG,OAAO;AAChC,QAAM,kBAAkB,WAAW,WAAW;AAC9C,QAAM,OAAO,UAAU,UAAa,CAAC;AACrC,QAAM,WAAW,MAAM,IAAI;AAE3B,QAAM,UACJ,qBAAC,UAAK,WAAW,GAAG,iCAAiC,QAAQ,SAAS,GACpE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,IAAI,OAAO,KAAK;AAAA,QAChB;AAAA,QACA;AAAA,QACC,GAAG;AAAA,QAGJ,MAAK;AAAA,QACL,MAAK;AAAA,QACL,oBAAkB,iBAAiB,KAAK,kBAAkB,GAAG,mBAAmB,aAAa;AAAA,QAC7F,UAAU,CAAC,MAAqC;AAC9C,qBAAW,CAAC;AACZ,4BAAkB,EAAE,OAAO,OAAO;AAAA,QACpC;AAAA,QACA,WAAW,GAAG,OAAO,SAAS,OAAO,cAAc;AAAA;AAAA,IACrD;AAAA,IACA,oBAAC,UAAK,eAAW,MAAC,WAAW,GAAG,OAAO,SAAS,KAAK,GAAG;AAAA,KAC1D;AAGF,MAAI,KAAM,QAAO;AAKjB,QAAM,OAAO,oBAAC,UAAK,WAAU,kCAAkC,mBAAQ;AACvE,QAAM,QACJ,qBAAC,UAAK,WAAU,kBACb;AAAA,cAAU,UACT;AAAA,MAAC;AAAA;AAAA,QACC,SAAS;AAAA,QACT,WAAW;AAAA,UACT;AAAA,UACA,WAAW,uBAAuB;AAAA,QACpC;AAAA,QAEC;AAAA;AAAA,UACA,YACC,oBAAC,UAAK,eAAW,MAAC,WAAU,+BAA8B,eAE1D;AAAA;AAAA;AAAA,IAEJ;AAAA,IAED,mBACC,oBAAC,UAAK,IAAI,eAAe,WAAU,iDAChC,uBACH;AAAA,KAEJ;AAGF,SACE,oBAAC,SAAI,WAAW,GAAG,0BAA0B,YAAY,cAAc,SAAS,GAC7E,6BAAmB,QAClB,iCACG;AAAA;AAAA,IACA;AAAA,KACH,IAEA,iCACG;AAAA;AAAA,IACA;AAAA,KACH,GAEJ;AAEJ,CAAC;AACD,OAAO,cAAc;","names":["Switch"]}
@@ -0,0 +1,50 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * The machinery {@link SwatchPicker} and {@link IconPicker} share: a row of square
6
+ * tiles that behaves as ONE radio group. Private to the package — not re-exported
7
+ * from the barrel.
8
+ *
9
+ * Keksdose had three of these (the category colours, the category symbols, the
10
+ * transaction flags) and all three were rows of `aria-pressed` buttons: a separate
11
+ * tab stop per tile, and "pressed" on a choice that is really one-of-n. The APG radio
12
+ * group is the shape a screen reader expects for that ("Red, radio, 2 of 7, checked")
13
+ * and the one a keyboard expects too — a single tab stop, arrows to move.
14
+ */
15
+ type TileSize = "sm" | "md" | "lg";
16
+ /** 28 / 32 / 44px. `lg` is the touch target a phone surface wants (`min-h-11`). */
17
+ declare const TILE_SIZE: Record<TileSize, {
18
+ tile: string;
19
+ glyph: string;
20
+ swatch: string;
21
+ }>;
22
+ /** One tile, whatever it shows. `key` is `null` for the "none" entry. */
23
+ interface TileItem<T extends string> {
24
+ key: T | null;
25
+ label: string;
26
+ /** Said after the name, and shown in the bubble under it. Marks the tile with a
27
+ * dot, so the note is visible without hovering every tile to find it. */
28
+ note?: string;
29
+ disabled?: boolean;
30
+ }
31
+ interface TileRadioGroupProps<T extends string> {
32
+ items: TileItem<T>[];
33
+ /** The checked key, or `undefined` for "nothing is checked" (the mixed state, or
34
+ * a value that is not among the options). */
35
+ checked: T | null | undefined;
36
+ onSelect: (key: T | null) => void;
37
+ /** "automatic": an arrow key moves AND checks, the APG default. "manual": arrows
38
+ * move focus only, Space or Enter checks — for a picker whose change is expensive
39
+ * (a bulk edit that saves on every change). */
40
+ activation: "automatic" | "manual";
41
+ disabled: boolean;
42
+ size: TileSize;
43
+ /** What goes inside a tile. The frame, the tick and the note's dot are drawn here. */
44
+ renderTile: (item: TileItem<T>, selected: boolean) => ReactNode;
45
+ /** Extra classes for a tile, per item. */
46
+ tileClassName?: (item: TileItem<T>, selected: boolean) => string | undefined;
47
+ }
48
+ declare function TileRadioGroup<T extends string>({ items, checked, onSelect, activation, disabled, size, renderTile, tileClassName, }: TileRadioGroupProps<T>): react.JSX.Element;
49
+
50
+ export { TILE_SIZE, type TileItem, TileRadioGroup, type TileRadioGroupProps, type TileSize };