@eifi1/ui-kit 0.50.1 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/dist/admin/user-roster.js +31 -12
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/verify-email.d.ts +4 -0
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/components/alert-banner.d.ts +9 -0
- package/dist/components/alert-banner.js +9 -5
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/button-look.d.ts +4 -19
- package/dist/components/button-look.js +3 -30
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-virtual.js +1 -0
- package/dist/components/data-table-virtual.js.map +1 -1
- package/dist/components/field-base.d.ts +31 -1
- package/dist/components/field-base.js +28 -0
- package/dist/components/field-base.js.map +1 -1
- package/dist/components/floating-panel.d.ts +6 -13
- package/dist/components/floating-panel.js +2 -9
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/icon-button.js +3 -5
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/select.js +3 -2
- package/dist/components/select.js.map +1 -1
- package/dist/components/stat-tile.d.ts +8 -1
- package/dist/components/stat-tile.js +7 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/german.js +2 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +2 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +2 -0
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +2 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +2 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +2 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +2 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +2 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/page-seo.d.ts +19 -11
- package/dist/landing/page-seo.js +11 -7
- package/dist/landing/page-seo.js.map +1 -1
- package/dist/landing/public-page.d.ts +57 -0
- package/dist/landing/public-page.js +45 -0
- package/dist/landing/public-page.js.map +1 -0
- package/dist/lib/server-wake.d.ts +3 -2
- package/dist/lib/server-wake.js +19 -13
- package/dist/lib/server-wake.js.map +1 -1
- package/dist/shell/app-shell.d.ts +2 -15
- package/dist/shell/app-shell.js +7 -26
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/dev-menu.d.ts +98 -0
- package/dist/shell/dev-menu.js +133 -0
- package/dist/shell/dev-menu.js.map +1 -0
- package/dist/shell/dev-switchers.d.ts +6 -53
- package/dist/shell/dev-switchers.js +5 -100
- package/dist/shell/dev-switchers.js.map +1 -1
- package/dist/shell/top-bar-actions.d.ts +4 -1
- package/dist/shell/top-bar-actions.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.d.ts +18 -9
- package/dist/shell/top-bar-parts-labels.js +2 -0
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +17 -2
- package/dist/shell/topbar-action-menu.js +3 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +3 -1
- package/dist/shell.js +3 -1
- package/dist/shell.js.map +1 -1
- package/dist/theme/palette-presets.d.ts +0 -3
- package/dist/theme/palette-presets.js +0 -2
- package/dist/theme/palette-presets.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/user-roster.tsx +30 -10
- package/src/auth/verify-email.tsx +4 -0
- package/src/components/alert-banner.tsx +37 -10
- package/src/components/button-look.ts +7 -57
- package/src/components/data-table-fit.ts +4 -1
- package/src/components/data-table-virtual.ts +14 -1
- package/src/components/field-base.tsx +53 -1
- package/src/components/floating-panel.tsx +6 -28
- package/src/components/icon-button.tsx +6 -8
- package/src/components/select.tsx +3 -6
- package/src/components/stat-tile.tsx +27 -9
- package/src/i18n/german.ts +2 -0
- package/src/i18n/kit-labels.tsx +2 -2
- package/src/i18n/locales/es.ts +2 -0
- package/src/i18n/locales/fr.ts +2 -0
- package/src/i18n/locales/hu.ts +2 -0
- package/src/i18n/locales/it.ts +2 -0
- package/src/i18n/locales/zh.ts +2 -0
- package/src/index.ts +4 -1
- package/src/landing/page-seo.ts +28 -16
- package/src/landing/public-page.tsx +106 -0
- package/src/lib/server-wake.ts +41 -17
- package/src/shell/app-shell.tsx +11 -48
- package/src/shell/dev-menu.tsx +259 -0
- package/src/shell/dev-switchers.tsx +13 -174
- package/src/shell/top-bar-actions.tsx +4 -1
- package/src/shell/top-bar-parts-labels.ts +20 -9
- package/src/shell/topbar-action-menu.tsx +28 -2
- package/src/shell.ts +5 -1
- package/src/theme/palette-presets.ts +0 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/floating-panel.tsx"],"sourcesContent":["import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n HTMLAttributes,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { warnDeprecatedVariant } from \"./button-look\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { registerFloating } from \"../lib/floating-stack\";\nimport { useFloatClearance } from \"../lib/float-clearance\";\nimport { isComposing } from \"../lib/ime\";\nimport { takeEscape, useOverlayOpen } from \"../hooks/use-dismiss\";\nimport { useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport { assignRef } from \"./field-parts\";\nimport { pickLinkRenderer } from \"./text-link\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_FLOATING_PANEL_LABELS } from \"./floating-panel-labels\";\n\nexport interface FloatingPanelLabels {\n /** Accessible name of the panel header's X. */\n close: string;\n /**\n * What a {@link FloatingAction}'s count badge adds to its accessible name (\"3 new\"),\n * read after the label: \"Invoices, 3 new\". The dot itself is hidden from a reader —\n * a bare \"3\" read out of context says nothing about what is counted.\n */\n badge: (count: number) => string;\n}\n\n// Defined in ./floating-panel-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FLOATING_PANEL_LABELS };\n\n/** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */\nexport type FloatingCorner = \"bottom-end\" | \"bottom-start\";\n\n/**\n * How far off the bottom edge anything floating here sits: clear of `AppShell`'s phone\n * nav (`--app-nav-h`, `0px` above `md` where the nav is not rendered) and of the home\n * indicator, whichever reaches higher — the nav does not pad itself for the inset, so\n * with no nav on screen the inset is all there is to clear. `max`, not a sum: where the\n * nav does cover the inset, adding both would float the button a nav-height too high.\n */\nconst BOTTOM_CLEARANCE = \"max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))\";\n/** The side gutter, widened for a landscape notch. Both insets, because a logical\n * side has no one physical inset to name. */\nconst SIDE_GUTTER = \"max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))\";\n\nfunction cornerStyle(corner: FloatingCorner, lift: string): CSSProperties {\n return {\n bottom: `calc(${BOTTOM_CLEARANCE} + ${lift})`,\n // Logical properties, so the corner follows the reading direction with no JS. The\n // portal carries the `dir` across (see `usePortalDir`).\n [corner === \"bottom-start\" ? \"insetInlineStart\" : \"insetInlineEnd\"]: SIDE_GUTTER,\n };\n}\n\n/**\n * The reading direction where the component was RENDERED, for its portalled root: a\n * portal to `<body>` leaves the subtree whose `dir` it inherited, and a logical\n * `inset-inline-end` resolves against the portal's own direction. Read off a hidden\n * inline marker in a layout effect, so the first painted frame already has it.\n */\nfunction usePortalDir(): [Ref<HTMLSpanElement>, Direction | undefined] {\n const marker = useRef<HTMLSpanElement>(null);\n const [dir, setDir] = useState<Direction | undefined>(undefined);\n useLayoutEffect(() => {\n setDir(dirOf(marker.current));\n }, []);\n return [marker, dir];\n}\n\nexport interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\"> {\n /** The accessible name — the button shows only its icon. */\n label: string;\n icon: ReactNode;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /**\n * Distance above the nav / home indicator, as a CSS length. Default `1rem`. Raise it\n * to stack above another floating control in the same corner — keksdose's assistant\n * clears the transactions page's own action group this way (`calc(1rem + 4rem)`).\n */\n offset?: string;\n /**\n * Whether the button carries the browser's own tooltip (`title`, the `label` unless a\n * `title` is passed). Default `true`, so an icon-only button keeps a hover hint for a\n * mouse user with no other way to learn what it is. `false` renders no `title` at all:\n * keksdose has ONE tooltip, the kit's `Tooltip` (dev#523), and its source scan fails a\n * native `title=` anywhere — so a flag, not `title=\"\"`, which the scan would still\n * see. An empty `title` renders no attribute either, for the caller who has no scan.\n *\n * Not flipped to off by default: a FAB wrapped in nothing would lose the only hover\n * hint it has, silently, in every app already on it. And the kit cannot wrap itself\n * in its `Tooltip` — the bubble's anchor is a `relative inline-flex` span, and the\n * button is `fixed` and portalled — so the one-tooltip caller wraps it.\n */\n nativeTitle?: boolean;\n /**\n * Make it a toggle. `true`/`false` set `aria-pressed` and swap the solid brand disc for\n * a surface disc — the glyph in brand on the quiet brand fill when on, the secondary\n * text colour when off — the \"on\" look of `IconButton`'s `pressed` and of a selected\n * Chip. Left out, it is the ordinary action FAB. For keksdose's corner filter toggles\n * (feedback-page's \"awaiting only\", the /transactions pending and upcoming toggles),\n * which paint `text-[var(--brand)]` on a surface button by hand. Keep `label` the same\n * in both states; `aria-pressed` already says which one it is in.\n */\n pressed?: boolean;\n /**\n * Show `label` beside the icon: the EXTENDED FAB, a pill rather than a disc. For a\n * corner control whose words are the point — kastlan's offline indicator\n * (shared/offline/offline-indicator.tsx:39) is a pill saying \"Offline — 2 photo(s)\n * queued\" / \"Syncing…\", hand-placed at `fixed bottom-4 left-4`, i.e. over the\n * bottom nav and on the wrong side in RTL. Same height as the disc (3rem), so a\n * {@link FloatingPanel} card above it still clears it.\n */\n extended?: boolean;\n /**\n * Announce `label` whenever it changes, through a polite `role=\"status\"` region — for\n * an extended FAB that REPORTS something (kastlan's offline/sync state), where a\n * sighted user sees the words change and a screen reader user otherwise hears\n * nothing until they happen to land on the button.\n *\n * ⚠️ A live region announces changes, not its own arrival: a FAB that mounts already\n * saying \"Offline\" says it to nobody. Keep it mounted and pass `hidden` while there\n * is nothing to report (kastlan returns `null` when online and idle); the region\n * stays in the page, and the first real status is heard.\n */\n live?: boolean;\n /**\n * `accent` (default): the solid brand disc of a page's main action, as Button's\n * `accent`. `surface`: the page's surface with a hairline border — for a FAB that is a\n * status rather than a call to action, as kastlan's offline pill is (a brand pill would\n * shout \"press me\" at a user who is merely offline). Ignored while `pressed` is set; a\n * toggle has its own two looks. `primary` is a deprecated alias of `accent` (0.49; a\n * development build warns once), removed in 0.51.\n */\n variant?:\n | \"accent\"\n | \"surface\"\n /** @deprecated 0.49 — use `accent` (same look); removed in 0.51. */\n | \"primary\";\n /**\n * Hover and focus label in the kit {@link Tooltip}, instead of the native `title` —\n * keksdose has ONE tooltip (dev#523) and wrapped its assistant launcher by hand,\n * which cannot work: the tooltip's anchor is a `relative inline-flex` span, and this\n * button is `fixed` and portalled. With `tooltip` the FAB positions a fixed wrapper\n * instead and the button sits in the tooltip inside it. `true` shows the `label`;\n * any other content shows that. Implies no native `title` (whatever `nativeTitle`\n * says): one hover hint, not two. `style` stays on the button; the corner and\n * `offset` move to the wrapper.\n */\n tooltip?: ReactNode;\n /** Where the {@link tooltip} opens. Default `top`; the bubble is portalled and kept on\n * screen, so a corner button's label is not pushed off the edge. */\n tooltipSide?: TooltipSide;\n /**\n * 0.49 KIT-11: step aside (as `hidden` does: off screen, the room kept) while a dialog,\n * a sheet, a popover, a hover menu or a list is open, instead of floating over it or\n * beside it. Default `true`; `false` keeps it on screen. A tooltip does not count.\n */\n hideWhenOverlay?: boolean;\n ref?: Ref<HTMLButtonElement>;\n}\n\n/** FloatingActionButton's and FloatingAction's `primary` was always the solid brand\n * fill: it reads as `accent` (warning once in development), never as Button's\n * `default`. */\nfunction deprecatedPrimary(component: string): \"accent\" {\n warnDeprecatedVariant(component, \"primary\", \"accent\");\n return \"accent\";\n}\n\n/**\n * A round, icon-only button pinned to a corner of the viewport — the trigger of a\n * {@link FloatingPanel}, and usable alone for a page's one primary action.\n *\n * keksdose hand-placed each of its floating controls: the assistant launcher at\n * `fixed bottom-36 right-4 md:bottom-6`, the feedback page's and the transactions\n * page's action groups at `right-4 bottom-20`. Every one of those numbers is a guess\n * at the phone nav's height plus whatever else floats there (Keksdose live #314 found\n * out what such a guess is worth for a footer), `right` rather than `end` puts it over\n * the page's leading edge in RTL, and none allows for the home indicator. Here the\n * base is `--app-nav-h` and the safe-area inset, the corner is logical, and\n * {@link FloatingActionButtonProps.offset} is the one number left to the caller — the\n * height of whatever else it has to clear, not the nav's.\n *\n * Portalled, because `position: fixed` inside any transformed ancestor (an animated\n * page transition) is fixed to that ancestor, not to the screen.\n *\n * While mounted it reports its box (`lib/floating-stack.ts`), so `ServerWakeNotice`\n * stacks above it rather than over it.\n *\n * 0.48: while mounted, `hidden` or not, it also publishes the room it takes above the\n * nav (`offset` + 3rem + a 0.5rem gap) as `--app-float-clearance` on `<html>` (the\n * largest of several wins), and `AppShell` pads its `<main>` past it on a phone, so the\n * page's last row no longer ends under the button (keksdose dev#612).\n */\nexport function FloatingActionButton({\n label,\n icon,\n corner = \"bottom-end\",\n offset = \"1rem\",\n nativeTitle = true,\n pressed,\n extended = false,\n live = false,\n variant: givenVariant = \"accent\",\n tooltip,\n tooltipSide = \"top\",\n hideWhenOverlay = true,\n className,\n style,\n type = \"button\",\n title,\n hidden: hiddenProp,\n ref,\n ...rest\n}: FloatingActionButtonProps) {\n const overlay = useOverlayOpen(hideWhenOverlay);\n const hidden = hiddenProp || overlay || undefined;\n const [marker, dir] = usePortalDir();\n const variant = givenVariant === \"primary\" ? deprecatedPrimary(\"FloatingActionButton variant\") : givenVariant;\n const toggle = pressed !== undefined;\n const tip = tooltip === true ? label : tooltip === false || tooltip === \"\" ? undefined : tooltip;\n const wrapped = tip !== undefined && tip !== null;\n // The fixed element — the button, or the wrapper when there is a tooltip.\n const [fixed, setFixed] = useState<HTMLElement | null>(null);\n useEffect(() => (fixed ? registerFloating(fixed) : undefined), [fixed]);\n // The room it takes above the nav, for AppShell's <main> to pad past (keksdose\n // dev#612). Hidden too: the room is the launcher's while it is mounted, so the page\n // does not change length under the reader as it hides and shows.\n useFloatClearance(offset);\n const buttonRef = useCallback(\n (node: HTMLButtonElement | null) => {\n assignRef(ref, node);\n if (!wrapped) setFixed(node);\n },\n [ref, wrapped],\n );\n // An empty title is \"no tooltip\", not an attribute with nothing in it. The kit\n // tooltip replaces it outright.\n const nativeTip = !wrapped && nativeTitle && title !== \"\" ? (title ?? label) : undefined;\n const button = (\n <button\n {...rest}\n hidden={wrapped ? undefined : hidden}\n ref={buttonRef}\n type={type}\n dir={dir}\n aria-label={label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n title={nativeTip}\n style={wrapped ? style : { ...cornerStyle(corner, offset), ...style }}\n className={cn(\n // Wrapped, the wrapper is the fixed thing and the button sits in it.\n !wrapped && \"fixed z-40\",\n \"inline-flex items-center justify-center rounded-full\",\n extended ? \"h-12 gap-2 px-4 text-sm font-medium [&_svg]:size-5\" : \"size-12 [&_svg]:size-6\",\n \"shadow-lg transition-colors disabled:cursor-default\",\n !toggle &&\n variant === \"accent\" &&\n \"bg-brand text-brand-contrast hover:bg-brand-hover\",\n !toggle &&\n variant === \"surface\" &&\n \"border border-subtle bg-surface text-primary hover:bg-hover disabled:hover:bg-surface\",\n // A toggle sits on the page's surface — a solid brand disc would read as\n // \"on\" in both states — and says \"on\" in the brand family.\n toggle && \"border border-subtle\",\n pressed === false &&\n \"bg-surface text-secondary hover:bg-hover hover:text-primary\",\n pressed === true &&\n \"border-brand bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted\",\n \"outline-none focus-visible:ring-offset-2\",\n FOCUS_RING,\n className,\n )}\n >\n {icon}\n {/* Visible words, not the name: `aria-label` already is the name, and the\n span would only repeat it. */}\n {extended && (\n <span aria-hidden className=\"whitespace-nowrap\">\n {label}\n </span>\n )}\n </button>\n );\n return (\n <>\n <span ref={marker} hidden />\n {typeof document !== \"undefined\" &&\n createPortal(\n <>\n {wrapped ? (\n <div\n ref={setFixed}\n dir={dir}\n hidden={hidden}\n style={cornerStyle(corner, offset)}\n className=\"fixed z-40 inline-flex\"\n >\n <Tooltip label={tip} side={tooltipSide} portal>\n {/* A fragment when the bubble says the name again, so the tooltip\n does not describe the button with its own label — a reader would\n hear it twice. Other text is a real description. */}\n {tip === label ? <>{button}</> : button}\n </Tooltip>\n </div>\n ) : (\n button\n )}\n {live && (\n <span role=\"status\" className=\"sr-only\">\n {label}\n </span>\n )}\n </>,\n document.body,\n )}\n </>\n );\n}\n\nexport interface FloatingPanelProps {\n /** The panel's heading, and its accessible name. */\n title: ReactNode;\n /** The trigger's accessible name (\"Assistant\", \"Send feedback\"). */\n fabLabel: string;\n fabIcon: ReactNode;\n /** Controlled open state. Leave it out to let the panel own it. */\n open?: boolean;\n /** Uncontrolled initial state. Default closed. */\n defaultOpen?: boolean;\n /** Every request to open or close — the FAB, the X, Escape. */\n onOpenChange?: (open: boolean) => void;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.\n * From `md` up the card sits above the FAB, so it moves with it. */\n offset?: string;\n /** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.\n * `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */\n fabNativeTitle?: boolean;\n /** The FAB's kit tooltip — see {@link FloatingActionButtonProps.tooltip}. `true` for\n * keksdose's assistant launcher, which wanted the kit bubble and had no way to\n * put one on a fixed, portalled button (dev#523). */\n fabTooltip?: ReactNode;\n /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else \"Close\". */\n closeLabel?: string;\n /**\n * Where focus goes on open. Default the panel itself, so a phone's keyboard does not\n * rise over the sheet before the user has asked to type; pass the chat field for a\n * panel whose one job is typing.\n */\n initialFocus?: () => HTMLElement | null;\n /** Extra classes for the panel. */\n className?: string;\n /** Extra classes for the scrolling body. */\n bodyClassName?: string;\n /**\n * 0.48: take the FAB and the panel off screen while keeping both mounted — the open\n * state and the bottom room (`--app-float-clearance`) stay, and an open panel is back\n * when this turns `false`. keksdose's assistant launcher steps aside while an editor\n * or a modal covers its corner (dev#612); unmounting it instead would give the room\n * back and shorten the page under the reader's thumb.\n */\n hidden?: boolean;\n /**\n * 0.49 KIT-11: the FAB steps aside while a dialog, a sheet, a popover, a hover menu or\n * a list is open elsewhere (see {@link FloatingActionButtonProps.hideWhenOverlay}).\n * Default `true`. An OPEN panel stays: what opens from inside it (a select, a date\n * picker) is its own content.\n */\n hideWhenOverlay?: boolean;\n children: ReactNode;\n}\n\n/**\n * A docked, NON-MODAL panel in a corner of the screen, opened by a\n * {@link FloatingActionButton} — keksdose's assistant launcher and its feedback page,\n * which each built one.\n *\n * ## Not a Modal\n *\n * The page stays usable while it is open: that is the point of an assistant beside the\n * data it is asked about. So there is no backdrop, no scroll lock, no `aria-modal` and\n * no focus trap — Tab walks out of the panel into the page and back, and a click on the\n * page does not close it. It is `role=\"dialog\"` without `aria-modal`, which is what\n * ARIA calls a non-modal dialog, named by its heading.\n *\n * What it keeps from a dialog is the focus HANDOFF: opening moves focus into it (a\n * keyboard user who pressed the FAB should not have to hunt for what appeared), and\n * closing gives it back to the FAB — but only when focus was in the panel, so a panel\n * closed from the page (a controlled owner) never pulls focus out of the field the\n * user is typing in. Escape closes it while focus is inside, and only then: on the\n * panel element, not the document, so Escape in the page keeps meaning whatever the\n * page means by it. A layer of the kit's Escape stack open over it — a tooltip, a list —\n * is handed the key first.\n *\n * ## Layout\n *\n * From `md` up, a card above the FAB in its corner. Below `md`, a full-width sheet\n * docked to the bottom, sitting ON the `AppShell` bottom nav (`--app-nav-h`) and\n * clear of the home indicator. Pure CSS — no width is measured in JavaScript.\n *\n * The FAB carries `aria-expanded` and, while the panel exists, `aria-controls`. The\n * panel unmounts when closed; an app that must keep a draft across a close keeps it in\n * its own state, where it also survives a reload.\n */\nexport function FloatingPanel({\n title,\n fabLabel,\n fabIcon,\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n corner = \"bottom-end\",\n offset = \"1rem\",\n fabNativeTitle,\n fabTooltip,\n closeLabel,\n initialFocus,\n className,\n bodyClassName,\n hidden: hiddenProp = false,\n hideWhenOverlay = true,\n children,\n}: FloatingPanelProps) {\n const labels = useKitLabels(\n \"floatingPanel\",\n DEFAULT_FLOATING_PANEL_LABELS,\n closeLabel === undefined ? undefined : { close: closeLabel },\n );\n const [ownOpen, setOwnOpen] = useState(defaultOpen);\n const open = openProp ?? ownOpen;\n const overlay = useOverlayOpen(hideWhenOverlay && !open);\n const hidden = hiddenProp || overlay;\n const setOpen = (next: boolean) => {\n if (openProp === undefined) setOwnOpen(next);\n onOpenChange?.(next);\n };\n\n const uid = useId();\n const panelId = `${uid}-panel`;\n const titleId = `${uid}-title`;\n const fabRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [marker, dir] = usePortalDir();\n\n const initialFocusRef = useRef(initialFocus);\n useEffect(() => {\n initialFocusRef.current = initialFocus;\n });\n\n // Focus follows the open/closed TRANSITION, never the mount: a panel that renders\n // open (`defaultOpen`, a restored route) does not take the page's focus by existing.\n const wasOpen = useRef(open);\n // Whether focus was inside the panel when it last changed — read at close time, when\n // the panel (and whatever had focus in it) is already gone.\n const focusInside = useRef(false);\n useEffect(() => {\n const changed = wasOpen.current !== open;\n wasOpen.current = open;\n if (!changed) return;\n if (open) {\n (initialFocusRef.current?.() ?? panelRef.current)?.focus();\n return;\n }\n const active = document.activeElement;\n const lost = active === null || active === document.body;\n if (focusInside.current || lost) fabRef.current?.focus();\n focusInside.current = false;\n }, [open]);\n\n const onPanelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // The kit's one Escape stack first (round 0.34 Wave 2, DUP-1): a layer open above the\n // panel without focus — a tooltip, the numpad, a hover menu — is the topmost thing on\n // screen and is handed the key. This took it for itself and stopped it, so the\n // tooltip never heard it and one press closed the whole panel instead.\n if (takeEscape(e)) return;\n // Not a layer itself: it answers only while focus is inside it, and a layer would\n // also be handed an Escape pressed in the page. Kept by a child's claim (ChipInput\n // clearing its draft, `claimEscape`), and by an IME composition, as a layer is.\n if (e.defaultPrevented || isComposing(e)) return;\n // Stopped here, so an overlay underneath with a document-level Escape listener does\n // not close too — the innermost thing that has focus is the one that closes.\n e.stopPropagation();\n focusInside.current = true;\n setOpen(false);\n };\n\n return (\n <>\n <span ref={marker} hidden />\n <FloatingActionButton\n ref={fabRef}\n label={fabLabel}\n icon={fabIcon}\n corner={corner}\n offset={offset}\n nativeTitle={fabNativeTitle}\n tooltip={fabTooltip}\n hidden={hidden || undefined}\n hideWhenOverlay={false}\n aria-expanded={open}\n aria-controls={open && !hidden ? panelId : undefined}\n onClick={() => {\n // A press ON the FAB closing the panel: the FAB keeps focus by being clicked,\n // nothing to hand back.\n focusInside.current = false;\n setOpen(!open);\n }}\n />\n {open &&\n !hidden &&\n typeof document !== \"undefined\" &&\n createPortal(\n // A container for Escape bubbling up from anything inside it, as a dialog is.\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n ref={panelRef}\n id={panelId}\n role=\"dialog\"\n aria-labelledby={titleId}\n tabIndex={-1}\n dir={dir}\n data-corner={corner}\n onKeyDown={onPanelKeyDown}\n onFocus={() => {\n focusInside.current = true;\n }}\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) focusInside.current = false;\n }}\n // The phone's sheet sits on the nav; from `md` up the card floats above the\n // FAB (its offset + the 3rem button + a 0.75rem gap).\n style={\n {\n \"--fp-bottom\": `calc(${BOTTOM_CLEARANCE})`,\n \"--fp-bottom-md\": `calc(${BOTTOM_CLEARANCE} + ${offset} + 3.75rem)`,\n \"--fp-side\": SIDE_GUTTER,\n } as CSSProperties\n }\n className={cn(\n \"animate-sheet fixed z-40 flex flex-col overflow-hidden outline-none\",\n \"border border-subtle bg-surface shadow-2xl\",\n // Phone: full width, docked, rounded only where it meets the page.\n \"inset-x-0 bottom-[var(--fp-bottom)] max-h-[min(85dvh,calc(100dvh_-_var(--fp-bottom)_-_1rem))] rounded-t-xl border-x-0 border-b-0\",\n \"pb-[env(safe-area-inset-bottom,0px)] md:pb-0\",\n // md+: a card in the corner, above the FAB.\n \"md:inset-x-auto md:bottom-[var(--fp-bottom-md)] md:w-[24rem] md:max-h-[min(36rem,calc(100dvh_-_var(--fp-bottom-md)_-_1rem))] md:rounded-xl md:border\",\n corner === \"bottom-start\" ? \"md:start-[var(--fp-side)]\" : \"md:end-[var(--fp-side)]\",\n className,\n )}\n >\n <div className=\"flex shrink-0 items-center justify-between gap-2 border-b border-subtle px-4 py-3\">\n <h2 id={titleId} className=\"min-w-0 truncate text-base font-semibold text-primary\">\n {title}\n </h2>\n <button\n type=\"button\"\n onClick={() => {\n focusInside.current = true;\n setOpen(false);\n }}\n aria-label={labels.close}\n className={cn(\"-me-1.5 shrink-0 rounded p-1.5 text-muted outline-none hover:bg-hover hover:text-secondary\", FOCUS_RING)}\n >\n <X aria-hidden className=\"size-5\" />\n </button>\n </div>\n <div className={cn(\"min-h-0 flex-1 overflow-y-auto overscroll-contain p-4\", bodyClassName)}>\n {children}\n </div>\n </div>,\n document.body,\n )}\n </>\n );\n}\n\nexport interface FloatingActionGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, \"role\"> {\n /** Name the group (\"Register actions\"); a reader then introduces the members as one\n * set. A group with no name is one more unlabelled wrapper. */\n \"aria-label\"?: string;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /** Distance above the nav / home indicator — see {@link FloatingActionButtonProps.offset}. */\n offset?: string;\n /** {@link FloatingAction}s, in reading order. */\n children: ReactNode;\n /** 0.49 KIT-11: step aside while a dialog, a sheet, a popover, a hover menu or a list\n * is open — see {@link FloatingActionButtonProps.hideWhenOverlay}. Default `true`. */\n hideWhenOverlay?: boolean;\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * A floating pill of {@link FloatingAction}s — one to four `size-12` toggles, actions\n * or links with hairline dividers — pinned to a corner exactly as the\n * {@link FloatingActionButton} is: `--app-nav-h` and the home indicator cleared, a\n * logical corner, portalled.\n *\n * keksdose built it twice by hand: the /transactions phone toolbar\n * (transactions-page.tsx:644 — upcoming and pending toggles, an invoices link with a\n * pending count, and the brand \"+\") and the feedback page's \"awaiting only\" toggle\n * (feedback-page.tsx:854, one button in the group anyway, so the two pages look\n * alike). Both at `right-4 bottom-20` — `right`, not `end`, so over the page's leading\n * edge in RTL — with `divide-x`, slate hovers, and each member's `Tooltip` spelled out.\n *\n * `role=\"group\"`, NOT a toolbar: every member keeps its own tab stop, as the\n * hand-built ones did, and there is no arrow-key roving to learn. Hide it where it\n * does not belong with a class (`md:hidden`, as both keksdose pages do).\n */\nexport function FloatingActionGroup({\n corner = \"bottom-end\",\n offset = \"1rem\",\n hideWhenOverlay = true,\n hidden,\n className,\n style,\n children,\n ...rest\n}: FloatingActionGroupProps) {\n const [marker, dir] = usePortalDir();\n const overlay = useOverlayOpen(hideWhenOverlay);\n // As the FAB's (keksdose dev#612): its 3rem members plus the frame's 1px borders.\n useFloatClearance(offset, \"2px\");\n return (\n <>\n <span ref={marker} hidden />\n {typeof document !== \"undefined\" &&\n createPortal(\n <div\n {...rest}\n hidden={hidden || overlay || undefined}\n role=\"group\"\n dir={dir}\n data-corner={corner}\n style={{ ...cornerStyle(corner, offset), ...style }}\n className={cn(\n \"fixed z-40 inline-flex flex-row overflow-hidden rounded-2xl\",\n \"border border-subtle bg-surface shadow-lg\",\n // Dividers on the logical start edge of every member but the first, so\n // they sit between the same two members in RTL. The member is the\n // tooltip's wrapper span, which is the direct child.\n \"[&>*:not(:first-child)]:border-s [&>*]:border-subtle\",\n className,\n )}\n >\n {children}\n </div>,\n document.body,\n )}\n </>\n );\n}\n\n/** What {@link FloatingActionProps.renderLink} is handed. Spread it onto your router's\n * link, mapping `href` — `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */\nexport interface FloatingActionLinkProps {\n href: string;\n /** The member's whole look — keep it. */\n className: string;\n children: ReactNode;\n \"aria-label\": string;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nexport interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\" | \"onClick\"> {\n /** The accessible name, and the tooltip's text. Keep it the same in both states of a\n * toggle; `aria-pressed` says which one it is in. */\n label: string;\n icon: ReactNode;\n /** Make it a toggle: `aria-pressed` and the brand \"on\" look (the brand glyph on the\n * quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link.\n * For the state without the tint, see {@link pressedStyle}. */\n pressed?: boolean;\n /**\n * How `pressed` looks. `tint` (default): the brand \"on\" look. `plain`: the member\n * keeps its quiet look in both states and only `aria-pressed` changes — for a toggle\n * whose icon already says which state it is in (keksdose's /transactions \"Upcoming\"\n * swaps two icons; a tint on top said it twice). The same as passing `aria-pressed`\n * without `pressed`, which also rides through untinted.\n */\n pressedStyle?: \"tint\" | \"plain\";\n /**\n * `accent`: the brand-filled member, for the group's main action (the /transactions\n * \"+\"), as Button's `accent`. `default` (default): the quiet one. `primary` is a\n * deprecated alias of `accent` (0.49; a development build warns once), removed no\n * earlier than 0.51.\n */\n variant?:\n | \"default\"\n | \"accent\"\n /** @deprecated 0.49 — use `accent` (same look); removed in 0.51. */\n | \"primary\";\n /**\n * A count in a dot at the member's top end corner — the /transactions invoices link's\n * pending count. Nothing is drawn for `0` or less; above 99 it reads `99+`. The count\n * joins the accessible name through `floatingPanel.badge` (\"Invoices, 3 new\"), or\n * `badgeLabel` when passed.\n */\n badge?: number;\n /** The badge's words for a reader, replacing `floatingPanel.badge` — \"3 to review\". */\n badgeLabel?: string;\n /** The dot's colour. Default `warning`, the pending count's. */\n badgeTone?: \"warning\" | \"danger\" | \"brand\";\n /** Show `label` in the kit {@link Tooltip} on hover and focus. Default `true`: the\n * members are icon-only and this is their only visible label. */\n tooltip?: boolean;\n /** Where the tooltip opens. Default `top` — the group sits at the bottom of the screen. */\n tooltipSide?: TooltipSide;\n /** Make it a link — the /transactions invoices member. Drawn by `renderLink`, else\n * the `<UiKitProvider linkComponent>` (an in-app `href`), else a plain `<a>`. */\n href?: string;\n /** Render the link through your router's `Link` — see {@link FloatingActionLinkProps}. */\n renderLink?: (props: FloatingActionLinkProps) => ReactElement;\n onClick?: (event: MouseEvent<HTMLElement>) => void;\n ref?: Ref<HTMLButtonElement>;\n}\n\n// Each fill under its own foreground (0.33): warning borrowed `--text-inverse`, the\n// surface colour, which was 4.24:1 on the light warning fill.\nconst BADGE_TONE: Record<NonNullable<FloatingActionProps[\"badgeTone\"]>, string> = {\n warning: \"bg-warning text-warning-contrast\",\n danger: \"bg-danger text-danger-contrast\",\n brand: \"bg-brand text-brand-contrast\",\n};\n\n/**\n * One member of a {@link FloatingActionGroup}: a `size-12` icon button, toggle or link\n * with its label in a styled tooltip — what each keksdose member wrapped by hand in\n * `<Tooltip portal>`. The tooltip is portalled because the group clips (`overflow-hidden`\n * keeps the brand member's fill inside the pill's corners), and the focus ring is inset\n * for the same reason.\n */\nexport function FloatingAction({\n label,\n icon,\n pressed,\n pressedStyle = \"tint\",\n variant: givenVariant = \"default\",\n badge,\n badgeLabel,\n badgeTone = \"warning\",\n tooltip = true,\n tooltipSide = \"top\",\n href,\n renderLink,\n className,\n type = \"button\",\n onClick,\n ref,\n ...rest\n}: FloatingActionProps) {\n const labels = useKitLabels(\"floatingPanel\", DEFAULT_FLOATING_PANEL_LABELS);\n const kitLink = useKitLink();\n const counted = badge !== undefined && badge > 0;\n const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;\n const isLink = href !== undefined;\n const variant = givenVariant === \"primary\" ? deprecatedPrimary(\"FloatingAction variant\") : givenVariant;\n const look = cn(\n \"relative flex size-12 items-center justify-center transition-colors\",\n \"outline-none focus-visible:ring-inset\",\n FOCUS_RING,\n \"disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-5\",\n variant === \"accent\"\n ? \"bg-brand text-brand-contrast hover:bg-brand-hover\"\n : pressed && !isLink && pressedStyle === \"tint\"\n ? \"bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted\"\n : \"text-secondary hover:bg-hover hover:text-primary\",\n className,\n );\n const body = (\n <>\n {icon}\n {counted && (\n <span\n aria-hidden\n data-badge=\"\"\n className={cn(\n \"pointer-events-none absolute end-1 top-1 h-4 min-w-4 rounded-full px-1 text-center text-micro leading-4 font-semibold\",\n BADGE_TONE[badgeTone],\n )}\n >\n {badge > 99 ? \"99+\" : badge}\n </span>\n )}\n </>\n );\n\n let member: ReactElement;\n if (isLink) {\n // The button-only attributes stay behind; `aria-*`, `data-*` and the like ride along.\n const { disabled: _disabled, form: _form, value: _value, name: _name, ...anchorRest } = rest;\n const linkProps: FloatingActionLinkProps = {\n ...(anchorRest as AnchorHTMLAttributes<HTMLAnchorElement>),\n href,\n \"aria-label\": name,\n onClick,\n className: look,\n children: body,\n };\n const { children: content, ...anchor } = linkProps;\n const render = pickLinkRenderer(renderLink, kitLink, href);\n member = render ? (\n <RenderedFloatingLink render={render} {...linkProps} />\n ) : (\n <a {...anchor}>{content}</a>\n );\n } else {\n member = (\n <button\n {...rest}\n ref={ref}\n type={type}\n aria-label={name}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n onClick={onClick}\n className={look}\n >\n {body}\n </button>\n );\n }\n if (!tooltip) return member;\n return (\n // A fragment: the bubble says the member's name, and a description that repeats\n // the name is read twice.\n <Tooltip label={label} side={tooltipSide} portal>\n <>{member}</>\n </Tooltip>\n );\n}\n\n/** Calls `renderLink` as a component, so its hooks belong to the link, not to us. */\nfunction RenderedFloatingLink({\n render,\n ...props\n}: FloatingActionLinkProps & { render: (props: FloatingActionLinkProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":";AA8PU,SA8DyB,UAxBrB,KAtCJ;AA9PV,SAAS,aAAa,WAAW,OAAO,iBAAiB,QAAQ,gBAAgB;AACjF,SAAS,oBAAoB;AAY7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,kBAAkB;AAC3B,SAAS,6BAA6B;AACtC,SAAS,aAA6B;AACtC,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,mBAAmB;AAC5B,SAAS,YAAY,sBAAsB;AAC3C,SAAS,cAAc,kBAAkB;AACzC,SAAS,iBAAiB;AAC1B,SAAS,wBAAwB;AACjC,SAAS,eAAiC;AAC1C,SAAS,qCAAqC;AA0B9C,MAAM,mBAAmB;AAGzB,MAAM,cAAc;AAEpB,SAAS,YAAY,QAAwB,MAA6B;AACxE,SAAO;AAAA,IACL,QAAQ,QAAQ,gBAAgB,MAAM,IAAI;AAAA;AAAA;AAAA,IAG1C,CAAC,WAAW,iBAAiB,qBAAqB,gBAAgB,GAAG;AAAA,EACvE;AACF;AAQA,SAAS,eAA8D;AACrE,QAAM,SAAS,OAAwB,IAAI;AAC3C,QAAM,CAAC,KAAK,MAAM,IAAI,SAAgC,MAAS;AAC/D,kBAAgB,MAAM;AACpB,WAAO,MAAM,OAAO,OAAO,CAAC;AAAA,EAC9B,GAAG,CAAC,CAAC;AACL,SAAO,CAAC,QAAQ,GAAG;AACrB;AAkGA,SAAS,kBAAkB,WAA6B;AACtD,wBAAsB,WAAW,WAAW,QAAQ;AACpD,SAAO;AACT;AA2BO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT,SAAS;AAAA,EACT,cAAc;AAAA,EACd;AAAA,EACA,WAAW;AAAA,EACX,OAAO;AAAA,EACP,SAAS,eAAe;AAAA,EACxB;AAAA,EACA,cAAc;AAAA,EACd,kBAAkB;AAAA,EAClB;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAA8B;AAC5B,QAAM,UAAU,eAAe,eAAe;AAC9C,QAAM,SAAS,cAAc,WAAW;AACxC,QAAM,CAAC,QAAQ,GAAG,IAAI,aAAa;AACnC,QAAM,UAAU,iBAAiB,YAAY,kBAAkB,8BAA8B,IAAI;AACjG,QAAM,SAAS,YAAY;AAC3B,QAAM,MAAM,YAAY,OAAO,QAAQ,YAAY,SAAS,YAAY,KAAK,SAAY;AACzF,QAAM,UAAU,QAAQ,UAAa,QAAQ;AAE7C,QAAM,CAAC,OAAO,QAAQ,IAAI,SAA6B,IAAI;AAC3D,YAAU,MAAO,QAAQ,iBAAiB,KAAK,IAAI,QAAY,CAAC,KAAK,CAAC;AAItE,oBAAkB,MAAM;AACxB,QAAM,YAAY;AAAA,IAChB,CAAC,SAAmC;AAClC,gBAAU,KAAK,IAAI;AACnB,UAAI,CAAC,QAAS,UAAS,IAAI;AAAA,IAC7B;AAAA,IACA,CAAC,KAAK,OAAO;AAAA,EACf;AAGA,QAAM,YAAY,CAAC,WAAW,eAAe,UAAU,KAAM,SAAS,QAAS;AAC/E,QAAM,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,QAAQ,UAAU,SAAY;AAAA,MAC9B,KAAK;AAAA,MACL;AAAA,MACA;AAAA,MACA,cAAY;AAAA,MACZ,gBAAc,WAAW,KAAK,cAAc;AAAA,MAC5C,OAAO;AAAA,MACP,OAAO,UAAU,QAAQ,EAAE,GAAG,YAAY,QAAQ,MAAM,GAAG,GAAG,MAAM;AAAA,MACpE,WAAW;AAAA;AAAA,QAET,CAAC,WAAW;AAAA,QACZ;AAAA,QACA,WAAW,uDAAuD;AAAA,QAClE;AAAA,QACA,CAAC,UACC,YAAY,YACZ;AAAA,QACF,CAAC,UACC,YAAY,aACZ;AAAA;AAAA;AAAA,QAGF,UAAU;AAAA,QACV,YAAY,SACV;AAAA,QACF,YAAY,QACV;AAAA,QACF;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,MAEC;AAAA;AAAA,QAGA,YACC,oBAAC,UAAK,eAAW,MAAC,WAAU,qBACzB,iBACH;AAAA;AAAA;AAAA,EAEJ;AAER,SACE,iCACE;AAAA,wBAAC,UAAK,KAAK,QAAQ,QAAM,MAAC;AAAA,IACzB,OAAO,aAAa,eACnB;AAAA,MACE,iCACG;AAAA,kBACC;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL;AAAA,YACA;AAAA,YACA,OAAO,YAAY,QAAQ,MAAM;AAAA,YACjC,WAAU;AAAA,YAEV,8BAAC,WAAQ,OAAO,KAAK,MAAM,aAAa,QAAM,MAI3C,kBAAQ,QAAQ,gCAAG,kBAAO,IAAM,QACnC;AAAA;AAAA,QACF,IAEA;AAAA,QAED,QACC,oBAAC,UAAK,MAAK,UAAS,WAAU,WAC3B,iBACH;AAAA,SAEJ;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;AAwFO,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,cAAc;AAAA,EACd;AAAA,EACA,SAAS;AAAA,EACT,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ,aAAa;AAAA,EACrB,kBAAkB;AAAA,EAClB;AACF,GAAuB;AACrB,QAAM,SAAS;AAAA,IACb;AAAA,IACA;AAAA,IACA,eAAe,SAAY,SAAY,EAAE,OAAO,WAAW;AAAA,EAC7D;AACA,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,WAAW;AAClD,QAAM,OAAO,YAAY;AACzB,QAAM,UAAU,eAAe,mBAAmB,CAAC,IAAI;AACvD,QAAM,SAAS,cAAc;AAC7B,QAAM,UAAU,CAAC,SAAkB;AACjC,QAAI,aAAa,OAAW,YAAW,IAAI;AAC3C,mBAAe,IAAI;AAAA,EACrB;AAEA,QAAM,MAAM,MAAM;AAClB,QAAM,UAAU,GAAG,GAAG;AACtB,QAAM,UAAU,GAAG,GAAG;AACtB,QAAM,SAAS,OAA0B,IAAI;AAC7C,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,CAAC,QAAQ,GAAG,IAAI,aAAa;AAEnC,QAAM,kBAAkB,OAAO,YAAY;AAC3C,YAAU,MAAM;AACd,oBAAgB,UAAU;AAAA,EAC5B,CAAC;AAID,QAAM,UAAU,OAAO,IAAI;AAG3B,QAAM,cAAc,OAAO,KAAK;AAChC,YAAU,MAAM;AACd,UAAM,UAAU,QAAQ,YAAY;AACpC,YAAQ,UAAU;AAClB,QAAI,CAAC,QAAS;AACd,QAAI,MAAM;AACR,OAAC,gBAAgB,UAAU,KAAK,SAAS,UAAU,MAAM;AACzD;AAAA,IACF;AACA,UAAM,SAAS,SAAS;AACxB,UAAM,OAAO,WAAW,QAAQ,WAAW,SAAS;AACpD,QAAI,YAAY,WAAW,KAAM,QAAO,SAAS,MAAM;AACvD,gBAAY,UAAU;AAAA,EACxB,GAAG,CAAC,IAAI,CAAC;AAET,QAAM,iBAAiB,CAAC,MAAqC;AAC3D,QAAI,EAAE,QAAQ,SAAU;AAKxB,QAAI,WAAW,CAAC,EAAG;AAInB,QAAI,EAAE,oBAAoB,YAAY,CAAC,EAAG;AAG1C,MAAE,gBAAgB;AAClB,gBAAY,UAAU;AACtB,YAAQ,KAAK;AAAA,EACf;AAEA,SACE,iCACE;AAAA,wBAAC,UAAK,KAAK,QAAQ,QAAM,MAAC;AAAA,IAC1B;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN;AAAA,QACA;AAAA,QACA,aAAa;AAAA,QACb,SAAS;AAAA,QACT,QAAQ,UAAU;AAAA,QAClB,iBAAiB;AAAA,QACjB,iBAAe;AAAA,QACf,iBAAe,QAAQ,CAAC,SAAS,UAAU;AAAA,QAC3C,SAAS,MAAM;AAGb,sBAAY,UAAU;AACtB,kBAAQ,CAAC,IAAI;AAAA,QACf;AAAA;AAAA,IACF;AAAA,IACC,QACC,CAAC,UACD,OAAO,aAAa,eACpB;AAAA;AAAA;AAAA,MAGE;AAAA,QAAC;AAAA;AAAA,UACC,KAAK;AAAA,UACL,IAAI;AAAA,UACJ,MAAK;AAAA,UACL,mBAAiB;AAAA,UACjB,UAAU;AAAA,UACV;AAAA,UACA,eAAa;AAAA,UACb,WAAW;AAAA,UACX,SAAS,MAAM;AACb,wBAAY,UAAU;AAAA,UACxB;AAAA,UACA,QAAQ,CAAC,MAAM;AACb,gBAAI,CAAC,EAAE,cAAc,SAAS,EAAE,aAA4B,EAAG,aAAY,UAAU;AAAA,UACvF;AAAA,UAGA,OACE;AAAA,YACE,eAAe,QAAQ,gBAAgB;AAAA,YACvC,kBAAkB,QAAQ,gBAAgB,MAAM,MAAM;AAAA,YACtD,aAAa;AAAA,UACf;AAAA,UAEF,WAAW;AAAA,YACT;AAAA,YACA;AAAA;AAAA,YAEA;AAAA,YACA;AAAA;AAAA,YAEA;AAAA,YACA,WAAW,iBAAiB,8BAA8B;AAAA,YAC1D;AAAA,UACF;AAAA,UAEA;AAAA,iCAAC,SAAI,WAAU,qFACb;AAAA,kCAAC,QAAG,IAAI,SAAS,WAAU,yDACxB,iBACH;AAAA,cACA;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAS,MAAM;AACb,gCAAY,UAAU;AACtB,4BAAQ,KAAK;AAAA,kBACf;AAAA,kBACA,cAAY,OAAO;AAAA,kBACnB,WAAW,GAAG,8FAA8F,UAAU;AAAA,kBAEtH,8BAAC,KAAE,eAAW,MAAC,WAAU,UAAS;AAAA;AAAA,cACpC;AAAA,eACF;AAAA,YACA,oBAAC,SAAI,WAAW,GAAG,yDAAyD,aAAa,GACtF,UACH;AAAA;AAAA;AAAA,MACF;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;AAmCO,SAAS,oBAAoB;AAAA,EAClC,SAAS;AAAA,EACT,SAAS;AAAA,EACT,kBAAkB;AAAA,EAClB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAA6B;AAC3B,QAAM,CAAC,QAAQ,GAAG,IAAI,aAAa;AACnC,QAAM,UAAU,eAAe,eAAe;AAE9C,oBAAkB,QAAQ,KAAK;AAC/B,SACE,iCACE;AAAA,wBAAC,UAAK,KAAK,QAAQ,QAAM,MAAC;AAAA,IACzB,OAAO,aAAa,eACnB;AAAA,MACE;AAAA,QAAC;AAAA;AAAA,UACE,GAAG;AAAA,UACJ,QAAQ,UAAU,WAAW;AAAA,UAC7B,MAAK;AAAA,UACL;AAAA,UACA,eAAa;AAAA,UACb,OAAO,EAAE,GAAG,YAAY,QAAQ,MAAM,GAAG,GAAG,MAAM;AAAA,UAClD,WAAW;AAAA,YACT;AAAA,YACA;AAAA;AAAA;AAAA;AAAA,YAIA;AAAA,YACA;AAAA,UACF;AAAA,UAEC;AAAA;AAAA,MACH;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;AAsEA,MAAM,aAA4E;AAAA,EAChF,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,OAAO;AACT;AASO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf,SAAS,eAAe;AAAA,EACxB;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,UAAU;AAAA,EACV,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAwB;AACtB,QAAM,SAAS,aAAa,iBAAiB,6BAA6B;AAC1E,QAAM,UAAU,WAAW;AAC3B,QAAM,UAAU,UAAU,UAAa,QAAQ;AAC/C,QAAM,OAAO,UAAU,GAAG,KAAK,KAAK,cAAc,OAAO,MAAM,KAAK,CAAC,KAAK;AAC1E,QAAM,SAAS,SAAS;AACxB,QAAM,UAAU,iBAAiB,YAAY,kBAAkB,wBAAwB,IAAI;AAC3F,QAAM,OAAO;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,YAAY,WACR,sDACA,WAAW,CAAC,UAAU,iBAAiB,SACrC,8EACA;AAAA,IACN;AAAA,EACF;AACA,QAAM,OACJ,iCACG;AAAA;AAAA,IACA,WACC;AAAA,MAAC;AAAA;AAAA,QACC,eAAW;AAAA,QACX,cAAW;AAAA,QACX,WAAW;AAAA,UACT;AAAA,UACA,WAAW,SAAS;AAAA,QACtB;AAAA,QAEC,kBAAQ,KAAK,QAAQ;AAAA;AAAA,IACxB;AAAA,KAEJ;AAGF,MAAI;AACJ,MAAI,QAAQ;AAEV,UAAM,EAAE,UAAU,WAAW,MAAM,OAAO,OAAO,QAAQ,MAAM,OAAO,GAAG,WAAW,IAAI;AACxF,UAAM,YAAqC;AAAA,MACzC,GAAI;AAAA,MACJ;AAAA,MACA,cAAc;AAAA,MACd;AAAA,MACA,WAAW;AAAA,MACX,UAAU;AAAA,IACZ;AACA,UAAM,EAAE,UAAU,SAAS,GAAG,OAAO,IAAI;AACzC,UAAM,SAAS,iBAAiB,YAAY,SAAS,IAAI;AACzD,aAAS,SACP,oBAAC,wBAAqB,QAAiB,GAAG,WAAW,IAErD,oBAAC,OAAG,GAAG,QAAS,mBAAQ;AAAA,EAE5B,OAAO;AACL,aACE;AAAA,MAAC;AAAA;AAAA,QACE,GAAG;AAAA,QACJ;AAAA,QACA;AAAA,QACA,cAAY;AAAA,QACZ,gBAAc,WAAW,KAAK,cAAc;AAAA,QAC5C;AAAA,QACA,WAAW;AAAA,QAEV;AAAA;AAAA,IACH;AAAA,EAEJ;AACA,MAAI,CAAC,QAAS,QAAO;AACrB;AAAA;AAAA;AAAA,IAGE,oBAAC,WAAQ,OAAc,MAAM,aAAa,QAAM,MAC9C,0CAAG,kBAAO,GACZ;AAAA;AAEJ;AAGA,SAAS,qBAAqB;AAAA,EAC5B;AAAA,EACA,GAAG;AACL,GAA2F;AACzF,SAAO,OAAO,KAAK;AACrB;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/floating-panel.tsx"],"sourcesContent":["import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n HTMLAttributes,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { registerFloating } from \"../lib/floating-stack\";\nimport { useFloatClearance } from \"../lib/float-clearance\";\nimport { isComposing } from \"../lib/ime\";\nimport { takeEscape, useOverlayOpen } from \"../hooks/use-dismiss\";\nimport { useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport { assignRef } from \"./field-parts\";\nimport { pickLinkRenderer } from \"./text-link\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_FLOATING_PANEL_LABELS } from \"./floating-panel-labels\";\n\nexport interface FloatingPanelLabels {\n /** Accessible name of the panel header's X. */\n close: string;\n /**\n * What a {@link FloatingAction}'s count badge adds to its accessible name (\"3 new\"),\n * read after the label: \"Invoices, 3 new\". The dot itself is hidden from a reader —\n * a bare \"3\" read out of context says nothing about what is counted.\n */\n badge: (count: number) => string;\n}\n\n// Defined in ./floating-panel-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FLOATING_PANEL_LABELS };\n\n/** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */\nexport type FloatingCorner = \"bottom-end\" | \"bottom-start\";\n\n/**\n * How far off the bottom edge anything floating here sits: clear of `AppShell`'s phone\n * nav (`--app-nav-h`, `0px` above `md` where the nav is not rendered) and of the home\n * indicator, whichever reaches higher — the nav does not pad itself for the inset, so\n * with no nav on screen the inset is all there is to clear. `max`, not a sum: where the\n * nav does cover the inset, adding both would float the button a nav-height too high.\n */\nconst BOTTOM_CLEARANCE = \"max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))\";\n/** The side gutter, widened for a landscape notch. Both insets, because a logical\n * side has no one physical inset to name. */\nconst SIDE_GUTTER = \"max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))\";\n\nfunction cornerStyle(corner: FloatingCorner, lift: string): CSSProperties {\n return {\n bottom: `calc(${BOTTOM_CLEARANCE} + ${lift})`,\n // Logical properties, so the corner follows the reading direction with no JS. The\n // portal carries the `dir` across (see `usePortalDir`).\n [corner === \"bottom-start\" ? \"insetInlineStart\" : \"insetInlineEnd\"]: SIDE_GUTTER,\n };\n}\n\n/**\n * The reading direction where the component was RENDERED, for its portalled root: a\n * portal to `<body>` leaves the subtree whose `dir` it inherited, and a logical\n * `inset-inline-end` resolves against the portal's own direction. Read off a hidden\n * inline marker in a layout effect, so the first painted frame already has it.\n */\nfunction usePortalDir(): [Ref<HTMLSpanElement>, Direction | undefined] {\n const marker = useRef<HTMLSpanElement>(null);\n const [dir, setDir] = useState<Direction | undefined>(undefined);\n useLayoutEffect(() => {\n setDir(dirOf(marker.current));\n }, []);\n return [marker, dir];\n}\n\nexport interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\"> {\n /** The accessible name — the button shows only its icon. */\n label: string;\n icon: ReactNode;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /**\n * Distance above the nav / home indicator, as a CSS length. Default `1rem`. Raise it\n * to stack above another floating control in the same corner — keksdose's assistant\n * clears the transactions page's own action group this way (`calc(1rem + 4rem)`).\n */\n offset?: string;\n /**\n * Whether the button carries the browser's own tooltip (`title`, the `label` unless a\n * `title` is passed). Default `true`, so an icon-only button keeps a hover hint for a\n * mouse user with no other way to learn what it is. `false` renders no `title` at all:\n * keksdose has ONE tooltip, the kit's `Tooltip` (dev#523), and its source scan fails a\n * native `title=` anywhere — so a flag, not `title=\"\"`, which the scan would still\n * see. An empty `title` renders no attribute either, for the caller who has no scan.\n *\n * Not flipped to off by default: a FAB wrapped in nothing would lose the only hover\n * hint it has, silently, in every app already on it. And the kit cannot wrap itself\n * in its `Tooltip` — the bubble's anchor is a `relative inline-flex` span, and the\n * button is `fixed` and portalled — so the one-tooltip caller wraps it.\n */\n nativeTitle?: boolean;\n /**\n * Make it a toggle. `true`/`false` set `aria-pressed` and swap the solid brand disc for\n * a surface disc — the glyph in brand on the quiet brand fill when on, the secondary\n * text colour when off — the \"on\" look of `IconButton`'s `pressed` and of a selected\n * Chip. Left out, it is the ordinary action FAB. For keksdose's corner filter toggles\n * (feedback-page's \"awaiting only\", the /transactions pending and upcoming toggles),\n * which paint `text-[var(--brand)]` on a surface button by hand. Keep `label` the same\n * in both states; `aria-pressed` already says which one it is in.\n */\n pressed?: boolean;\n /**\n * Show `label` beside the icon: the EXTENDED FAB, a pill rather than a disc. For a\n * corner control whose words are the point — kastlan's offline indicator\n * (shared/offline/offline-indicator.tsx:39) is a pill saying \"Offline — 2 photo(s)\n * queued\" / \"Syncing…\", hand-placed at `fixed bottom-4 left-4`, i.e. over the\n * bottom nav and on the wrong side in RTL. Same height as the disc (3rem), so a\n * {@link FloatingPanel} card above it still clears it.\n */\n extended?: boolean;\n /**\n * Announce `label` whenever it changes, through a polite `role=\"status\"` region — for\n * an extended FAB that REPORTS something (kastlan's offline/sync state), where a\n * sighted user sees the words change and a screen reader user otherwise hears\n * nothing until they happen to land on the button.\n *\n * ⚠️ A live region announces changes, not its own arrival: a FAB that mounts already\n * saying \"Offline\" says it to nobody. Keep it mounted and pass `hidden` while there\n * is nothing to report (kastlan returns `null` when online and idle); the region\n * stays in the page, and the first real status is heard.\n */\n live?: boolean;\n /**\n * `accent` (default): the solid brand disc of a page's main action, as Button's\n * `accent`. `surface`: the page's surface with a hairline border — for a FAB that is a\n * status rather than a call to action, as kastlan's offline pill is (a brand pill would\n * shout \"press me\" at a user who is merely offline). Ignored while `pressed` is set; a\n * toggle has its own two looks.\n */\n variant?: \"accent\" | \"surface\";\n /**\n * Hover and focus label in the kit {@link Tooltip}, instead of the native `title` —\n * keksdose has ONE tooltip (dev#523) and wrapped its assistant launcher by hand,\n * which cannot work: the tooltip's anchor is a `relative inline-flex` span, and this\n * button is `fixed` and portalled. With `tooltip` the FAB positions a fixed wrapper\n * instead and the button sits in the tooltip inside it. `true` shows the `label`;\n * any other content shows that. Implies no native `title` (whatever `nativeTitle`\n * says): one hover hint, not two. `style` stays on the button; the corner and\n * `offset` move to the wrapper.\n */\n tooltip?: ReactNode;\n /** Where the {@link tooltip} opens. Default `top`; the bubble is portalled and kept on\n * screen, so a corner button's label is not pushed off the edge. */\n tooltipSide?: TooltipSide;\n /**\n * 0.49 KIT-11: step aside (as `hidden` does: off screen, the room kept) while a dialog,\n * a sheet, a popover, a hover menu or a list is open, instead of floating over it or\n * beside it. Default `true`; `false` keeps it on screen. A tooltip does not count.\n */\n hideWhenOverlay?: boolean;\n ref?: Ref<HTMLButtonElement>;\n}\n\n/**\n * A round, icon-only button pinned to a corner of the viewport — the trigger of a\n * {@link FloatingPanel}, and usable alone for a page's one primary action.\n *\n * keksdose hand-placed each of its floating controls: the assistant launcher at\n * `fixed bottom-36 right-4 md:bottom-6`, the feedback page's and the transactions\n * page's action groups at `right-4 bottom-20`. Every one of those numbers is a guess\n * at the phone nav's height plus whatever else floats there (Keksdose live #314 found\n * out what such a guess is worth for a footer), `right` rather than `end` puts it over\n * the page's leading edge in RTL, and none allows for the home indicator. Here the\n * base is `--app-nav-h` and the safe-area inset, the corner is logical, and\n * {@link FloatingActionButtonProps.offset} is the one number left to the caller — the\n * height of whatever else it has to clear, not the nav's.\n *\n * Portalled, because `position: fixed` inside any transformed ancestor (an animated\n * page transition) is fixed to that ancestor, not to the screen.\n *\n * While mounted it reports its box (`lib/floating-stack.ts`), so `ServerWakeNotice`\n * stacks above it rather than over it.\n *\n * 0.48: while mounted, `hidden` or not, it also publishes the room it takes above the\n * nav (`offset` + 3rem + a 0.5rem gap) as `--app-float-clearance` on `<html>` (the\n * largest of several wins), and `AppShell` pads its `<main>` past it on a phone, so the\n * page's last row no longer ends under the button (keksdose dev#612).\n */\nexport function FloatingActionButton({\n label,\n icon,\n corner = \"bottom-end\",\n offset = \"1rem\",\n nativeTitle = true,\n pressed,\n extended = false,\n live = false,\n variant = \"accent\",\n tooltip,\n tooltipSide = \"top\",\n hideWhenOverlay = true,\n className,\n style,\n type = \"button\",\n title,\n hidden: hiddenProp,\n ref,\n ...rest\n}: FloatingActionButtonProps) {\n const overlay = useOverlayOpen(hideWhenOverlay);\n const hidden = hiddenProp || overlay || undefined;\n const [marker, dir] = usePortalDir();\n const toggle = pressed !== undefined;\n const tip = tooltip === true ? label : tooltip === false || tooltip === \"\" ? undefined : tooltip;\n const wrapped = tip !== undefined && tip !== null;\n // The fixed element — the button, or the wrapper when there is a tooltip.\n const [fixed, setFixed] = useState<HTMLElement | null>(null);\n useEffect(() => (fixed ? registerFloating(fixed) : undefined), [fixed]);\n // The room it takes above the nav, for AppShell's <main> to pad past (keksdose\n // dev#612). Hidden too: the room is the launcher's while it is mounted, so the page\n // does not change length under the reader as it hides and shows.\n useFloatClearance(offset);\n const buttonRef = useCallback(\n (node: HTMLButtonElement | null) => {\n assignRef(ref, node);\n if (!wrapped) setFixed(node);\n },\n [ref, wrapped],\n );\n // An empty title is \"no tooltip\", not an attribute with nothing in it. The kit\n // tooltip replaces it outright.\n const nativeTip = !wrapped && nativeTitle && title !== \"\" ? (title ?? label) : undefined;\n const button = (\n <button\n {...rest}\n hidden={wrapped ? undefined : hidden}\n ref={buttonRef}\n type={type}\n dir={dir}\n aria-label={label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n title={nativeTip}\n style={wrapped ? style : { ...cornerStyle(corner, offset), ...style }}\n className={cn(\n // Wrapped, the wrapper is the fixed thing and the button sits in it.\n !wrapped && \"fixed z-40\",\n \"inline-flex items-center justify-center rounded-full\",\n extended ? \"h-12 gap-2 px-4 text-sm font-medium [&_svg]:size-5\" : \"size-12 [&_svg]:size-6\",\n \"shadow-lg transition-colors disabled:cursor-default\",\n !toggle &&\n variant === \"accent\" &&\n \"bg-brand text-brand-contrast hover:bg-brand-hover\",\n !toggle &&\n variant === \"surface\" &&\n \"border border-subtle bg-surface text-primary hover:bg-hover disabled:hover:bg-surface\",\n // A toggle sits on the page's surface — a solid brand disc would read as\n // \"on\" in both states — and says \"on\" in the brand family.\n toggle && \"border border-subtle\",\n pressed === false &&\n \"bg-surface text-secondary hover:bg-hover hover:text-primary\",\n pressed === true &&\n \"border-brand bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted\",\n \"outline-none focus-visible:ring-offset-2\",\n FOCUS_RING,\n className,\n )}\n >\n {icon}\n {/* Visible words, not the name: `aria-label` already is the name, and the\n span would only repeat it. */}\n {extended && (\n <span aria-hidden className=\"whitespace-nowrap\">\n {label}\n </span>\n )}\n </button>\n );\n return (\n <>\n <span ref={marker} hidden />\n {typeof document !== \"undefined\" &&\n createPortal(\n <>\n {wrapped ? (\n <div\n ref={setFixed}\n dir={dir}\n hidden={hidden}\n style={cornerStyle(corner, offset)}\n className=\"fixed z-40 inline-flex\"\n >\n <Tooltip label={tip} side={tooltipSide} portal>\n {/* A fragment when the bubble says the name again, so the tooltip\n does not describe the button with its own label — a reader would\n hear it twice. Other text is a real description. */}\n {tip === label ? <>{button}</> : button}\n </Tooltip>\n </div>\n ) : (\n button\n )}\n {live && (\n <span role=\"status\" className=\"sr-only\">\n {label}\n </span>\n )}\n </>,\n document.body,\n )}\n </>\n );\n}\n\nexport interface FloatingPanelProps {\n /** The panel's heading, and its accessible name. */\n title: ReactNode;\n /** The trigger's accessible name (\"Assistant\", \"Send feedback\"). */\n fabLabel: string;\n fabIcon: ReactNode;\n /** Controlled open state. Leave it out to let the panel own it. */\n open?: boolean;\n /** Uncontrolled initial state. Default closed. */\n defaultOpen?: boolean;\n /** Every request to open or close — the FAB, the X, Escape. */\n onOpenChange?: (open: boolean) => void;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.\n * From `md` up the card sits above the FAB, so it moves with it. */\n offset?: string;\n /** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.\n * `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */\n fabNativeTitle?: boolean;\n /** The FAB's kit tooltip — see {@link FloatingActionButtonProps.tooltip}. `true` for\n * keksdose's assistant launcher, which wanted the kit bubble and had no way to\n * put one on a fixed, portalled button (dev#523). */\n fabTooltip?: ReactNode;\n /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else \"Close\". */\n closeLabel?: string;\n /**\n * Where focus goes on open. Default the panel itself, so a phone's keyboard does not\n * rise over the sheet before the user has asked to type; pass the chat field for a\n * panel whose one job is typing.\n */\n initialFocus?: () => HTMLElement | null;\n /** Extra classes for the panel. */\n className?: string;\n /** Extra classes for the scrolling body. */\n bodyClassName?: string;\n /**\n * 0.48: take the FAB and the panel off screen while keeping both mounted — the open\n * state and the bottom room (`--app-float-clearance`) stay, and an open panel is back\n * when this turns `false`. keksdose's assistant launcher steps aside while an editor\n * or a modal covers its corner (dev#612); unmounting it instead would give the room\n * back and shorten the page under the reader's thumb.\n */\n hidden?: boolean;\n /**\n * 0.49 KIT-11: the FAB steps aside while a dialog, a sheet, a popover, a hover menu or\n * a list is open elsewhere (see {@link FloatingActionButtonProps.hideWhenOverlay}).\n * Default `true`. An OPEN panel stays: what opens from inside it (a select, a date\n * picker) is its own content.\n */\n hideWhenOverlay?: boolean;\n children: ReactNode;\n}\n\n/**\n * A docked, NON-MODAL panel in a corner of the screen, opened by a\n * {@link FloatingActionButton} — keksdose's assistant launcher and its feedback page,\n * which each built one.\n *\n * ## Not a Modal\n *\n * The page stays usable while it is open: that is the point of an assistant beside the\n * data it is asked about. So there is no backdrop, no scroll lock, no `aria-modal` and\n * no focus trap — Tab walks out of the panel into the page and back, and a click on the\n * page does not close it. It is `role=\"dialog\"` without `aria-modal`, which is what\n * ARIA calls a non-modal dialog, named by its heading.\n *\n * What it keeps from a dialog is the focus HANDOFF: opening moves focus into it (a\n * keyboard user who pressed the FAB should not have to hunt for what appeared), and\n * closing gives it back to the FAB — but only when focus was in the panel, so a panel\n * closed from the page (a controlled owner) never pulls focus out of the field the\n * user is typing in. Escape closes it while focus is inside, and only then: on the\n * panel element, not the document, so Escape in the page keeps meaning whatever the\n * page means by it. A layer of the kit's Escape stack open over it — a tooltip, a list —\n * is handed the key first.\n *\n * ## Layout\n *\n * From `md` up, a card above the FAB in its corner. Below `md`, a full-width sheet\n * docked to the bottom, sitting ON the `AppShell` bottom nav (`--app-nav-h`) and\n * clear of the home indicator. Pure CSS — no width is measured in JavaScript.\n *\n * The FAB carries `aria-expanded` and, while the panel exists, `aria-controls`. The\n * panel unmounts when closed; an app that must keep a draft across a close keeps it in\n * its own state, where it also survives a reload.\n */\nexport function FloatingPanel({\n title,\n fabLabel,\n fabIcon,\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n corner = \"bottom-end\",\n offset = \"1rem\",\n fabNativeTitle,\n fabTooltip,\n closeLabel,\n initialFocus,\n className,\n bodyClassName,\n hidden: hiddenProp = false,\n hideWhenOverlay = true,\n children,\n}: FloatingPanelProps) {\n const labels = useKitLabels(\n \"floatingPanel\",\n DEFAULT_FLOATING_PANEL_LABELS,\n closeLabel === undefined ? undefined : { close: closeLabel },\n );\n const [ownOpen, setOwnOpen] = useState(defaultOpen);\n const open = openProp ?? ownOpen;\n const overlay = useOverlayOpen(hideWhenOverlay && !open);\n const hidden = hiddenProp || overlay;\n const setOpen = (next: boolean) => {\n if (openProp === undefined) setOwnOpen(next);\n onOpenChange?.(next);\n };\n\n const uid = useId();\n const panelId = `${uid}-panel`;\n const titleId = `${uid}-title`;\n const fabRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [marker, dir] = usePortalDir();\n\n const initialFocusRef = useRef(initialFocus);\n useEffect(() => {\n initialFocusRef.current = initialFocus;\n });\n\n // Focus follows the open/closed TRANSITION, never the mount: a panel that renders\n // open (`defaultOpen`, a restored route) does not take the page's focus by existing.\n const wasOpen = useRef(open);\n // Whether focus was inside the panel when it last changed — read at close time, when\n // the panel (and whatever had focus in it) is already gone.\n const focusInside = useRef(false);\n useEffect(() => {\n const changed = wasOpen.current !== open;\n wasOpen.current = open;\n if (!changed) return;\n if (open) {\n (initialFocusRef.current?.() ?? panelRef.current)?.focus();\n return;\n }\n const active = document.activeElement;\n const lost = active === null || active === document.body;\n if (focusInside.current || lost) fabRef.current?.focus();\n focusInside.current = false;\n }, [open]);\n\n const onPanelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // The kit's one Escape stack first (round 0.34 Wave 2, DUP-1): a layer open above the\n // panel without focus — a tooltip, the numpad, a hover menu — is the topmost thing on\n // screen and is handed the key. This took it for itself and stopped it, so the\n // tooltip never heard it and one press closed the whole panel instead.\n if (takeEscape(e)) return;\n // Not a layer itself: it answers only while focus is inside it, and a layer would\n // also be handed an Escape pressed in the page. Kept by a child's claim (ChipInput\n // clearing its draft, `claimEscape`), and by an IME composition, as a layer is.\n if (e.defaultPrevented || isComposing(e)) return;\n // Stopped here, so an overlay underneath with a document-level Escape listener does\n // not close too — the innermost thing that has focus is the one that closes.\n e.stopPropagation();\n focusInside.current = true;\n setOpen(false);\n };\n\n return (\n <>\n <span ref={marker} hidden />\n <FloatingActionButton\n ref={fabRef}\n label={fabLabel}\n icon={fabIcon}\n corner={corner}\n offset={offset}\n nativeTitle={fabNativeTitle}\n tooltip={fabTooltip}\n hidden={hidden || undefined}\n hideWhenOverlay={false}\n aria-expanded={open}\n aria-controls={open && !hidden ? panelId : undefined}\n onClick={() => {\n // A press ON the FAB closing the panel: the FAB keeps focus by being clicked,\n // nothing to hand back.\n focusInside.current = false;\n setOpen(!open);\n }}\n />\n {open &&\n !hidden &&\n typeof document !== \"undefined\" &&\n createPortal(\n // A container for Escape bubbling up from anything inside it, as a dialog is.\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n ref={panelRef}\n id={panelId}\n role=\"dialog\"\n aria-labelledby={titleId}\n tabIndex={-1}\n dir={dir}\n data-corner={corner}\n onKeyDown={onPanelKeyDown}\n onFocus={() => {\n focusInside.current = true;\n }}\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) focusInside.current = false;\n }}\n // The phone's sheet sits on the nav; from `md` up the card floats above the\n // FAB (its offset + the 3rem button + a 0.75rem gap).\n style={\n {\n \"--fp-bottom\": `calc(${BOTTOM_CLEARANCE})`,\n \"--fp-bottom-md\": `calc(${BOTTOM_CLEARANCE} + ${offset} + 3.75rem)`,\n \"--fp-side\": SIDE_GUTTER,\n } as CSSProperties\n }\n className={cn(\n \"animate-sheet fixed z-40 flex flex-col overflow-hidden outline-none\",\n \"border border-subtle bg-surface shadow-2xl\",\n // Phone: full width, docked, rounded only where it meets the page.\n \"inset-x-0 bottom-[var(--fp-bottom)] max-h-[min(85dvh,calc(100dvh_-_var(--fp-bottom)_-_1rem))] rounded-t-xl border-x-0 border-b-0\",\n \"pb-[env(safe-area-inset-bottom,0px)] md:pb-0\",\n // md+: a card in the corner, above the FAB.\n \"md:inset-x-auto md:bottom-[var(--fp-bottom-md)] md:w-[24rem] md:max-h-[min(36rem,calc(100dvh_-_var(--fp-bottom-md)_-_1rem))] md:rounded-xl md:border\",\n corner === \"bottom-start\" ? \"md:start-[var(--fp-side)]\" : \"md:end-[var(--fp-side)]\",\n className,\n )}\n >\n <div className=\"flex shrink-0 items-center justify-between gap-2 border-b border-subtle px-4 py-3\">\n <h2 id={titleId} className=\"min-w-0 truncate text-base font-semibold text-primary\">\n {title}\n </h2>\n <button\n type=\"button\"\n onClick={() => {\n focusInside.current = true;\n setOpen(false);\n }}\n aria-label={labels.close}\n className={cn(\"-me-1.5 shrink-0 rounded p-1.5 text-muted outline-none hover:bg-hover hover:text-secondary\", FOCUS_RING)}\n >\n <X aria-hidden className=\"size-5\" />\n </button>\n </div>\n <div className={cn(\"min-h-0 flex-1 overflow-y-auto overscroll-contain p-4\", bodyClassName)}>\n {children}\n </div>\n </div>,\n document.body,\n )}\n </>\n );\n}\n\nexport interface FloatingActionGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, \"role\"> {\n /** Name the group (\"Register actions\"); a reader then introduces the members as one\n * set. A group with no name is one more unlabelled wrapper. */\n \"aria-label\"?: string;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /** Distance above the nav / home indicator — see {@link FloatingActionButtonProps.offset}. */\n offset?: string;\n /** {@link FloatingAction}s, in reading order. */\n children: ReactNode;\n /** 0.49 KIT-11: step aside while a dialog, a sheet, a popover, a hover menu or a list\n * is open — see {@link FloatingActionButtonProps.hideWhenOverlay}. Default `true`. */\n hideWhenOverlay?: boolean;\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * A floating pill of {@link FloatingAction}s — one to four `size-12` toggles, actions\n * or links with hairline dividers — pinned to a corner exactly as the\n * {@link FloatingActionButton} is: `--app-nav-h` and the home indicator cleared, a\n * logical corner, portalled.\n *\n * keksdose built it twice by hand: the /transactions phone toolbar\n * (transactions-page.tsx:644 — upcoming and pending toggles, an invoices link with a\n * pending count, and the brand \"+\") and the feedback page's \"awaiting only\" toggle\n * (feedback-page.tsx:854, one button in the group anyway, so the two pages look\n * alike). Both at `right-4 bottom-20` — `right`, not `end`, so over the page's leading\n * edge in RTL — with `divide-x`, slate hovers, and each member's `Tooltip` spelled out.\n *\n * `role=\"group\"`, NOT a toolbar: every member keeps its own tab stop, as the\n * hand-built ones did, and there is no arrow-key roving to learn. Hide it where it\n * does not belong with a class (`md:hidden`, as both keksdose pages do).\n */\nexport function FloatingActionGroup({\n corner = \"bottom-end\",\n offset = \"1rem\",\n hideWhenOverlay = true,\n hidden,\n className,\n style,\n children,\n ...rest\n}: FloatingActionGroupProps) {\n const [marker, dir] = usePortalDir();\n const overlay = useOverlayOpen(hideWhenOverlay);\n // As the FAB's (keksdose dev#612): its 3rem members plus the frame's 1px borders.\n useFloatClearance(offset, \"2px\");\n return (\n <>\n <span ref={marker} hidden />\n {typeof document !== \"undefined\" &&\n createPortal(\n <div\n {...rest}\n hidden={hidden || overlay || undefined}\n role=\"group\"\n dir={dir}\n data-corner={corner}\n style={{ ...cornerStyle(corner, offset), ...style }}\n className={cn(\n \"fixed z-40 inline-flex flex-row overflow-hidden rounded-2xl\",\n \"border border-subtle bg-surface shadow-lg\",\n // Dividers on the logical start edge of every member but the first, so\n // they sit between the same two members in RTL. The member is the\n // tooltip's wrapper span, which is the direct child.\n \"[&>*:not(:first-child)]:border-s [&>*]:border-subtle\",\n className,\n )}\n >\n {children}\n </div>,\n document.body,\n )}\n </>\n );\n}\n\n/** What {@link FloatingActionProps.renderLink} is handed. Spread it onto your router's\n * link, mapping `href` — `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */\nexport interface FloatingActionLinkProps {\n href: string;\n /** The member's whole look — keep it. */\n className: string;\n children: ReactNode;\n \"aria-label\": string;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nexport interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\" | \"onClick\"> {\n /** The accessible name, and the tooltip's text. Keep it the same in both states of a\n * toggle; `aria-pressed` says which one it is in. */\n label: string;\n icon: ReactNode;\n /** Make it a toggle: `aria-pressed` and the brand \"on\" look (the brand glyph on the\n * quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link.\n * For the state without the tint, see {@link pressedStyle}. */\n pressed?: boolean;\n /**\n * How `pressed` looks. `tint` (default): the brand \"on\" look. `plain`: the member\n * keeps its quiet look in both states and only `aria-pressed` changes — for a toggle\n * whose icon already says which state it is in (keksdose's /transactions \"Upcoming\"\n * swaps two icons; a tint on top said it twice). The same as passing `aria-pressed`\n * without `pressed`, which also rides through untinted.\n */\n pressedStyle?: \"tint\" | \"plain\";\n /**\n * `accent`: the brand-filled member, for the group's main action (the /transactions\n * \"+\"), as Button's `accent`. `default` (default): the quiet one.\n */\n variant?: \"default\" | \"accent\";\n /**\n * A count in a dot at the member's top end corner — the /transactions invoices link's\n * pending count. Nothing is drawn for `0` or less; above 99 it reads `99+`. The count\n * joins the accessible name through `floatingPanel.badge` (\"Invoices, 3 new\"), or\n * `badgeLabel` when passed.\n */\n badge?: number;\n /** The badge's words for a reader, replacing `floatingPanel.badge` — \"3 to review\". */\n badgeLabel?: string;\n /** The dot's colour. Default `warning`, the pending count's. */\n badgeTone?: \"warning\" | \"danger\" | \"brand\";\n /** Show `label` in the kit {@link Tooltip} on hover and focus. Default `true`: the\n * members are icon-only and this is their only visible label. */\n tooltip?: boolean;\n /** Where the tooltip opens. Default `top` — the group sits at the bottom of the screen. */\n tooltipSide?: TooltipSide;\n /** Make it a link — the /transactions invoices member. Drawn by `renderLink`, else\n * the `<UiKitProvider linkComponent>` (an in-app `href`), else a plain `<a>`. */\n href?: string;\n /** Render the link through your router's `Link` — see {@link FloatingActionLinkProps}. */\n renderLink?: (props: FloatingActionLinkProps) => ReactElement;\n onClick?: (event: MouseEvent<HTMLElement>) => void;\n ref?: Ref<HTMLButtonElement>;\n}\n\n// Each fill under its own foreground (0.33): warning borrowed `--text-inverse`, the\n// surface colour, which was 4.24:1 on the light warning fill.\nconst BADGE_TONE: Record<NonNullable<FloatingActionProps[\"badgeTone\"]>, string> = {\n warning: \"bg-warning text-warning-contrast\",\n danger: \"bg-danger text-danger-contrast\",\n brand: \"bg-brand text-brand-contrast\",\n};\n\n/**\n * One member of a {@link FloatingActionGroup}: a `size-12` icon button, toggle or link\n * with its label in a styled tooltip — what each keksdose member wrapped by hand in\n * `<Tooltip portal>`. The tooltip is portalled because the group clips (`overflow-hidden`\n * keeps the brand member's fill inside the pill's corners), and the focus ring is inset\n * for the same reason.\n */\nexport function FloatingAction({\n label,\n icon,\n pressed,\n pressedStyle = \"tint\",\n variant = \"default\",\n badge,\n badgeLabel,\n badgeTone = \"warning\",\n tooltip = true,\n tooltipSide = \"top\",\n href,\n renderLink,\n className,\n type = \"button\",\n onClick,\n ref,\n ...rest\n}: FloatingActionProps) {\n const labels = useKitLabels(\"floatingPanel\", DEFAULT_FLOATING_PANEL_LABELS);\n const kitLink = useKitLink();\n const counted = badge !== undefined && badge > 0;\n const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;\n const isLink = href !== undefined;\n const look = cn(\n \"relative flex size-12 items-center justify-center transition-colors\",\n \"outline-none focus-visible:ring-inset\",\n FOCUS_RING,\n \"disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-5\",\n variant === \"accent\"\n ? \"bg-brand text-brand-contrast hover:bg-brand-hover\"\n : pressed && !isLink && pressedStyle === \"tint\"\n ? \"bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted\"\n : \"text-secondary hover:bg-hover hover:text-primary\",\n className,\n );\n const body = (\n <>\n {icon}\n {counted && (\n <span\n aria-hidden\n data-badge=\"\"\n className={cn(\n \"pointer-events-none absolute end-1 top-1 h-4 min-w-4 rounded-full px-1 text-center text-micro leading-4 font-semibold\",\n BADGE_TONE[badgeTone],\n )}\n >\n {badge > 99 ? \"99+\" : badge}\n </span>\n )}\n </>\n );\n\n let member: ReactElement;\n if (isLink) {\n // The button-only attributes stay behind; `aria-*`, `data-*` and the like ride along.\n const { disabled: _disabled, form: _form, value: _value, name: _name, ...anchorRest } = rest;\n const linkProps: FloatingActionLinkProps = {\n ...(anchorRest as AnchorHTMLAttributes<HTMLAnchorElement>),\n href,\n \"aria-label\": name,\n onClick,\n className: look,\n children: body,\n };\n const { children: content, ...anchor } = linkProps;\n const render = pickLinkRenderer(renderLink, kitLink, href);\n member = render ? (\n <RenderedFloatingLink render={render} {...linkProps} />\n ) : (\n <a {...anchor}>{content}</a>\n );\n } else {\n member = (\n <button\n {...rest}\n ref={ref}\n type={type}\n aria-label={name}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n onClick={onClick}\n className={look}\n >\n {body}\n </button>\n );\n }\n if (!tooltip) return member;\n return (\n // A fragment: the bubble says the member's name, and a description that repeats\n // the name is read twice.\n <Tooltip label={label} side={tooltipSide} portal>\n <>{member}</>\n </Tooltip>\n );\n}\n\n/** Calls `renderLink` as a component, so its hooks belong to the link, not to us. */\nfunction RenderedFloatingLink({\n render,\n ...props\n}: FloatingActionLinkProps & { render: (props: FloatingActionLinkProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -17,8 +17,7 @@ import {
|
|
|
17
17
|
RenderedButtonLink,
|
|
18
18
|
HIT_AREA_LARGE,
|
|
19
19
|
TOUCH_TARGET_LARGE,
|
|
20
|
-
buttonVariantClasses
|
|
21
|
-
resolveButtonVariant
|
|
20
|
+
buttonVariantClasses
|
|
22
21
|
} from "./button-look.js";
|
|
23
22
|
const ICON_BUTTON_BASE = cn(
|
|
24
23
|
"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed",
|
|
@@ -182,7 +181,6 @@ function iconButtonLook({
|
|
|
182
181
|
toneColor,
|
|
183
182
|
withText = false
|
|
184
183
|
}) {
|
|
185
|
-
const look = variant === "overlay" || variant === "shutter" ? variant : resolveButtonVariant(variant, "IconButton variant");
|
|
186
184
|
return cn(
|
|
187
185
|
ICON_BUTTON_BASE,
|
|
188
186
|
withText ? cn("font-medium whitespace-nowrap", stretch ? ICON_BUTTON_TEXT_STRETCH_SIZES[size] : ICON_BUTTON_TEXT_SIZES[size]) : stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
|
|
@@ -190,9 +188,9 @@ function iconButtonLook({
|
|
|
190
188
|
withText ? TOUCH_TARGET_LARGE : ICON_BUTTON_TOUCH_LARGE[size],
|
|
191
189
|
glyphSize !== void 0 && ICON_BUTTON_GLYPH_SIZES[glyphSize],
|
|
192
190
|
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
193
|
-
|
|
191
|
+
variant === "overlay" ? ICON_BUTTON_OVERLAY : variant === "shutter" ? ICON_BUTTON_SHUTTER : buttonVariantClasses[variant],
|
|
194
192
|
shape === "round" && "rounded-full",
|
|
195
|
-
ICON_BUTTON_DISABLED_REST[
|
|
193
|
+
ICON_BUTTON_DISABLED_REST[variant],
|
|
196
194
|
iconButtonToneClass(tone, quiet),
|
|
197
195
|
toneColor !== void 0 && typeof toneColor !== "string" && ICON_BUTTON_TONE_PAIR
|
|
198
196
|
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/icon-button.tsx"],"sourcesContent":["import { useContext, useId } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport { useCommitReason } from \"./write-lock\";\nimport type { CommitScope } from \"./write-lock\";\nimport { hasContent } from \"../lib/presence\";\nimport { CompactControlsContext, LockedReason, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport {\n HOVER_INK,\n ICON_BUTTON_PRESSED,\n RenderedButtonLink,\n HIT_AREA_LARGE,\n TOUCH_TARGET_LARGE,\n buttonVariantClasses,\n resolveButtonVariant,\n} from \"./button-look\";\nimport type { ButtonVariant, CanonicalButtonVariant } from \"./button-look\";\n\n// Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2\n// gap-2 — so rendering a bare glyph through it gives a small icon in a wide,\n// text-shaped box. This fixes a square box instead and, crucially, forces the\n// child icon to a size set by the BOX (20px at md/sm) via `[&_svg]:size-*` so a\n// caller cannot under-size it. Use it wherever an action is a bare icon\n// (edit/delete/tools) so they all match the top-bar icon buttons and can never\n// drift apart again.\n//\n// Draws its colours from the same `buttonVariantClasses` map as <Button>, so the\n// two re-skin together with the palette.\nconst ICON_BUTTON_BASE = cn(\n \"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed\",\n FOCUS_RING_WIDTH,\n);\n\nexport type IconButtonSize = \"2xl\" | \"xl\" | \"lg\" | \"md\" | \"sm\" | \"xs\" | \"2xs\";\n\n// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon\n// that touches its edges. The two small steps are lenkbank's list-row (28px) and\n// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.\nconst ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {\n // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,\n // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.\n \"2xl\": \"size-16 [&_svg]:size-7\",\n // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step\n // past the 44px minimum rather than on it.\n xl: \"size-12 [&_svg]:size-6\",\n // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's\n // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.\n lg: \"size-11 [&_svg]:size-5\",\n md: \"size-9 [&_svg]:size-5\",\n sm: \"size-8 [&_svg]:size-5\",\n xs: \"size-7 rounded [&_svg]:size-4\",\n \"2xs\": \"size-6 rounded [&_svg]:size-3.5\",\n};\n\n// `stretch`: the same WIDTH and glyph, and a height taken from the row instead of\n// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is\n// written as a width plus a minimum height (the square it would otherwise be).\nconst ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"w-16 min-h-16 self-stretch [&_svg]:size-7\",\n xl: \"w-12 min-h-12 self-stretch [&_svg]:size-6\",\n lg: \"w-11 min-h-11 self-stretch [&_svg]:size-5\",\n md: \"w-9 min-h-9 self-stretch [&_svg]:size-5\",\n sm: \"w-8 min-h-8 self-stretch [&_svg]:size-5\",\n xs: \"w-7 min-h-7 self-stretch rounded [&_svg]:size-4\",\n \"2xs\": \"w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5\",\n};\n\n// The label as visible text beside the glyph (0.32, §4: at Large \"IconButton shows its\n// label\"): the same height and glyph as the square, a width taken from the words. A\n// map of its own rather than `w-auto` over `size-*`, which tailwind-merge cannot undo\n// in one direction.\nconst ICON_BUTTON_TEXT_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"h-16 gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"h-12 gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"h-11 gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"h-9 gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"h-8 gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"h-7 gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"h-6 gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\nconst ICON_BUTTON_TEXT_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"min-h-16 self-stretch gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"min-h-12 self-stretch gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"min-h-11 self-stretch gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"min-h-9 self-stretch gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"min-h-8 self-stretch gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"min-h-7 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"min-h-6 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\n\n// The 48 px touch target at Large (§4), per size. `lg` and up are past it already\n// (2.75rem is 55 px at Large). `md` … `xs` grow their box to it. `2xs` is the action ON\n// a chip or a tab, whose box cannot grow without growing the chip: it keeps its box and\n// gets a 48 px hit area instead — an invisible `::after` centred on it, which takes the\n// taps a finger lands round the glyph (`HIT_AREA_LARGE`).\nconst ICON_BUTTON_TOUCH_LARGE: Record<IconButtonSize, string> = {\n \"2xl\": \"\",\n xl: \"\",\n lg: \"\",\n md: TOUCH_TARGET_LARGE,\n sm: TOUCH_TARGET_LARGE,\n xs: TOUCH_TARGET_LARGE,\n \"2xs\": HIT_AREA_LARGE,\n};\n\n// A tone re-colours the glyph without changing what the variant draws around it.\n// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):\n// QUIET is placeholder grey until the pointer or focus arrives, then the tone's\n// family; TONED wears the tone's colour at rest. The defaults are the looks each tone\n// had before `quiet` existed: `danger` is quiet — an action in every row of a list\n// must not shout from every row, so the red arrives only on the one row you are about\n// to act on — while `warning` and `info` are toned, because they mark the ONE thing\n// on the row to notice (keksdose's \"needs review\" flag, its reconcile action at\n// accounts-page:867), and quiet grey would hide the very thing they point out.\n// `muted` is quiet by definition and has no toned look; `default` has no tone.\n//\n// `success` is toned at rest too: the green is the news (keksdose's \"in sync\" chip).\n// `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose\n// colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as\n// the sync moves), where swapping `tone` would do but the colour is one variable.\nexport type IconButtonTone = \"default\" | \"muted\" | \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\ntype ColouredTone = \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\nconst ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {\n danger: {\n quiet:\n \"text-placeholder hover:bg-danger-soft hover:text-danger focus-visible:ring-danger-strong\",\n toned: \"text-danger hover:bg-danger-soft focus-visible:ring-danger-strong\",\n },\n warning: {\n quiet:\n \"text-placeholder hover:bg-warning-soft hover:text-warning focus-visible:ring-warning-strong\",\n // The ring is warning's loud line (0.33, k27): `--warning-border`, amber-300, was a\n // focus ring at 1.1:1 on the light surfaces.\n toned: \"text-warning hover:bg-warning-soft focus-visible:ring-warning-strong\",\n },\n info: {\n quiet:\n \"text-placeholder hover:bg-info-soft hover:text-info focus-visible:ring-[var(--info-border)]\",\n toned: \"text-info hover:bg-info-soft focus-visible:ring-[var(--info-border)]\",\n },\n success: {\n quiet:\n \"text-placeholder hover:bg-success-soft hover:text-success focus-visible:ring-[var(--success-border)]\",\n toned: \"text-success hover:bg-success-soft focus-visible:ring-[var(--success-border)]\",\n },\n // The hover fill and the ring are MIXED from the one colour, since a custom tone\n // brings no `-bg` / `-border` pair of its own. The fallback is the body colour, so a\n // `custom` tone with no colour set is the default look rather than invisible.\n custom: {\n quiet:\n \"text-placeholder hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]\",\n toned:\n \"text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]\",\n },\n};\n\n// The translucent ink on hover, as Button's secondary/ghost (B′, see HOVER_INK).\nconst ICON_BUTTON_MUTED = `text-placeholder ${HOVER_INK} hover:text-primary`;\n\n/** Which tones sit quiet at rest when `quiet` is left out. */\nconst QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {\n danger: true,\n warning: false,\n info: false,\n success: false,\n custom: false,\n};\n\nfunction iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {\n if (tone === \"default\") return \"\";\n if (tone === \"muted\") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);\n const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];\n const look = ICON_BUTTON_TONES[tone];\n return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;\n}\n\n// A disabled button must not answer the pointer. The hover classes above are plain\n// `hover:` (so a caller's `className=\"hover:…\"` still replaces them through\n// tailwind-merge), which means they fire on a disabled button too — the old grey\n// icon lit up on hover while refusing the click. Rather than rewrite every hover as\n// `enabled:hover:` (which never matches the `<a>` that `buttonClasses` also styles,\n// and would out-rank a caller's plain `hover:` override), each variant pins its\n// RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity\n// and only ever matches a disabled button.\nconst ICON_BUTTON_DISABLED_REST: Record<CanonicalButtonVariant | \"overlay\" | \"shutter\", string> = {\n default: \"disabled:hover:bg-surface-2\",\n secondary: \"disabled:hover:bg-transparent\",\n ghost: \"disabled:hover:bg-transparent\",\n danger: \"disabled:hover:bg-danger\",\n accent: \"disabled:hover:bg-brand\",\n link: \"disabled:hover:bg-transparent disabled:hover:no-underline\",\n overlay: \"disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]\",\n shutter: \"disabled:hover:bg-media-scrim\",\n};\n\n// The quiet looks change the glyph on hover, so they pin their resting glyph the\n// same way. A toned look keeps its glyph on hover and needs no pin.\nconst ICON_BUTTON_QUIET_DISABLED_REST = \"disabled:hover:text-placeholder\";\n\n// `variant=\"overlay\"`: a round, translucent disc for a control that sits ON a photo\n// (keksdose's receipt-scan preview: close, rotate, retake over the camera image).\n// The inverse pair, not the surface one: what has to hold is the contrast between the\n// disc and its glyph, whatever the picture underneath is, and `--bg-inverse` /\n// `--text-inverse` are the one token pair defined as each other's opposite in both\n// themes. The disc is the inverse at 60% (`color-mix`, so it stays a token a palette\n// can re-point); the blur keeps a busy background from breaking the glyph's edge.\nconst ICON_BUTTON_OVERLAY =\n \"rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-inverse backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus-visible:ring-[var(--text-inverse)]\";\n\n// `variant=\"shutter\"`: the camera's release — keksdose's camera-capture.tsx:248 draws\n// it by hand as a 64px circle with a thick white ring round a translucent fill. It sits\n// on a live camera picture, which is dark whatever the theme, so it uses the MEDIA\n// tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair\n// `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would\n// merge into the ring that is part of the look.\nconst ICON_BUTTON_SHUTTER =\n \"rounded-full border-4 border-media-ink bg-media-scrim text-media-ink backdrop-blur-sm hover:bg-media-scrim-hover focus-visible:ring-media-ink focus-visible:ring-offset-2 focus-visible:ring-offset-media-scrim-hover\";\n\nexport type IconButtonVariant = ButtonVariant | \"overlay\" | \"shutter\";\n\n/**\n * The glyph's size in px, over the one the box size implies — see\n * {@link IconButtonProps.glyphSize}.\n */\nexport type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;\n\nconst ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {\n 12: \"[&_svg]:size-3\",\n 14: \"[&_svg]:size-3.5\",\n 16: \"[&_svg]:size-4\",\n 20: \"[&_svg]:size-5\",\n 24: \"[&_svg]:size-6\",\n 28: \"[&_svg]:size-7\",\n};\n\n// `pending`'s spinner, the size of the glyph it replaces (the `[&_svg]` rules above\n// size only an svg, and the spinner is a bordered span).\nconst ICON_BUTTON_SPINNER: Record<IconButtonSize, string> = {\n \"2xl\": \"size-7\",\n xl: \"size-6\",\n lg: \"size-5\",\n md: \"size-5\",\n sm: \"size-5\",\n xs: \"size-4\",\n \"2xs\": \"size-3.5\",\n};\nconst ICON_BUTTON_SPINNER_GLYPH: Record<IconButtonGlyphSize, string> = {\n 12: \"size-3\",\n 14: \"size-3.5\",\n 16: \"size-4\",\n 20: \"size-5\",\n 24: \"size-6\",\n 28: \"size-7\",\n};\n\nexport interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over\n * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed\n * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size=\"2xl\"`). */\n variant?: IconButtonVariant;\n /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px\n * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),\n * sm = 32px — all three with a 20px icon;\n * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px\n * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */\n size?: IconButtonSize;\n /**\n * The icon's size in px, when the box's own is not the one wanted. keksdose's sync\n * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —\n * `size=\"2xs\" glyphSize={20} shape=\"round\"` — where `2xs` alone draws 14px. A number\n * over a new size step because the box and the glyph are two independent choices; a\n * step per combination (`\"sm-lg\"`) would have to be named for every one.\n */\n glyphSize?: IconButtonGlyphSize;\n /**\n * A small mark on the button's corner — the sync chip's 12px check over its cloud\n * (\"all good\", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside\n * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge\n * up to the glyph's size; a bare `<Check />` comes out 12px, on a\n * `--bg-surface` disc that keeps it legible over the glyph. Decorative\n * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.\n * With a badge, `className` still lands on the button; the wrapper is the flex item.\n */\n badge?: ReactNode;\n /**\n * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary\n * cursor. For a STATUS that is also an action — the sync chip shows \"in sync\" in\n * green and refreshes on click, and is disabled while offline or syncing, when the\n * state it shows matters most; dimmed it read as broken (keksdose wrote\n * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.\n */\n disabledStyle?: \"dim\" | \"keep\";\n /**\n * Fill the height of the flex row it stands in, keeping its width — the delete at\n * the end of keksdose's split line (transaction-fields:162), level with the labelled\n * fields beside it rather than floating at half their height. {@link Button}'s\n * `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is\n * what stretches.\n */\n stretch?: boolean;\n /** `round`: a circle instead of the rounded square — a compact status chip in a top\n * bar (keksdose's sync indicator). `overlay` is always round. */\n shape?: \"square\" | \"round\";\n /**\n * The colour of `tone=\"custom\"`, as any CSS colour — a token (`var(--warning)`),\n * ideally. Sets `--icon-button-tone` on the button, so passing a different one per\n * state re-colours the glyph, its hover fill and its focus ring together. Passing it\n * implies `tone=\"custom\"`. Or leave it out and set the variable yourself from a class\n * (`className=\"[--icon-button-tone:var(--success)]\"`).\n *\n * Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads\n * on the light surface (`#be123c`) is too dark on the dark one, and an inline style\n * cannot say \"in dark mode\". See {@link IconButtonToneColor}.\n */\n toneColor?: IconButtonToneColor;\n /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on\n * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a\n * remove/delete that repeats down a list. `warning`: amber at rest — a flag that\n * wants attention. `info`: sky at rest — a notice-worthy but harmless action\n * (keksdose's reconcile). `success`: green at rest — a good state worth seeing.\n * `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for\n * turning a coloured tone's resting look the other way. */\n tone?: IconButtonTone;\n /**\n * Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:\n * `true` is placeholder grey at rest and the tone's colour on hover and focus;\n * `false` wears the tone's colour at rest. Left out, each tone keeps its own\n * default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by\n * definition) and `default` (no tone).\n *\n * `quiet={false}` on `danger` is for a destructive action that stands ALONE, where\n * hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one\n * \"delete all\" and a touch screen that never hovers, so it painted rose by hand.\n * `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated\n * down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an\n * `emphasis` scale: there are exactly two resting looks, every coloured tone has\n * both, and which one fits is a question about the SITE (alone or repeated, touch or\n * pointer), not about the tone — so it is one switch the family shares.\n */\n quiet?: boolean;\n /**\n * Make it a toggle button. `true` sets `aria-pressed=\"true\"` and draws the \"on\" look\n * (brand glyph on the quiet brand fill); `false` sets `aria-pressed=\"false\"` with the\n * normal look, so a screen reader still hears a toggle that is off. Left out, it is\n * an ordinary button with no `aria-pressed` — or whatever `aria-pressed` the caller\n * passes. For keksdose's budget share toggle (budgets-page:291), which paints its own\n * brand colour over a ghost button today. Keep the `aria-label` the same in both\n * states (\"Share budget\"): the pressed state already says whether it is on.\n */\n pressed?: boolean;\n /** Keep the click (and the Enter/Space that produces it) from reaching an\n * ancestor's handler — for an action inside a clickable table row or card.\n *\n * **Not a licence to put this inside another `<button>`.** HTML forbids any\n * interactive content, and any element with a `tabindex`, inside a button — so\n * the `<span role=\"button\" tabIndex={0}>` workaround is invalid too — and ARIA\n * makes a button's children presentational, so a screen reader flattens the\n * inner one into the outer one's name and it cannot be reached at all. Make the\n * row's main action a button that fills the row and put this one BESIDE it, on\n * top:\n *\n * ```tsx\n * <div className=\"relative\">\n * <button className=\"w-full pe-16 …\" onClick={open}>…row…</button>\n * <div className=\"absolute inset-y-0 end-2 flex items-center gap-0.5\">\n * <IconButton size=\"xs\" tone=\"danger\" aria-label=\"Delete\" onClick={remove}>\n * <Trash2 />\n * </IconButton>\n * </div>\n * </div>\n * ```\n *\n * The pair look exactly like the nested version, click exactly like it, and\n * are two tab stops a screen reader can tell apart. */\n stopPropagation?: boolean;\n /**\n * The button's name, said once: it becomes the `aria-label` AND the text of a kit\n * {@link Tooltip} round the button.\n *\n * Every app writes the pair by hand. lenkbank's nine icon actions all carry\n * `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,\n * projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,\n * shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,\n * which shows late, never on focus and never on touch, and looks like no other\n * label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)\n * and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.\n *\n * A caller's own `aria-label` still wins, for the rare name that should be longer\n * than the bubble. The bubble is visual only: it would otherwise describe the button\n * with its own name, and a screen reader would read the same word twice. Tooltip's\n * default placement applies — in place, or portalled inside a scroll container —\n * unless `tooltipPortal` says otherwise.\n */\n label?: string;\n /**\n * Show `label` as visible text beside the icon at Large and Extra large (0.32,\n * docs/text-size-harmonization.md §4, §10.8). A reader who asked for bigger type is\n * the one an unlabelled glyph fails, and on touch the tooltip never opens. Then there\n * is no tooltip: the words are on the button.\n *\n * Default `true` — except for `variant=\"overlay\"` and `\"shutter\"`, which sit ON a\n * picture, and inside {@link CompactControls} (AppShell's top bar, `RowActions`'\n * inline icons). `false`: the icon alone at every size, for an icon on content — a\n * receipt overlay, a map's zoom, a viewfinder, a photo thumbnail. That must be rare;\n * a dense row's actions belong in `RowActions`, which collapses them into a \"⋯\" menu\n * at Large instead (§10.8). Normal never shows it. `tooltip={false}` (a universally\n * read glyph, a dialog's ✕) keeps the icon alone too.\n */\n labelVisible?: boolean;\n /** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the\n * accessible name only — for a button whose glyph is universally read (a close ✕ in\n * a dialog header) or that already sits under a tooltip of its own. It also keeps the\n * label from showing as text at Large (see `labelVisible`). */\n tooltip?: boolean;\n /** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */\n tooltipSide?: TooltipSide;\n /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */\n tooltipPortal?: boolean;\n /**\n * Mount the `label` bubble only while it is up — {@link Tooltip}'s `lazy`. Default\n * `true` since 0.16.0; `false` is the always-mounted in-place bubble of before.\n *\n * Why the label bubble can be lazy when Tooltip's default is not: this bubble\n * describes nothing. It is wrapped round the button in a fragment precisely so that\n * it is NOT the button's `aria-describedby` (it would repeat the button's own name),\n * so there is no description for a lazy mount to take away — the name is the\n * `aria-label` either way, read in browse mode and on focus alike. What the\n * always-mounted bubble did do was sit in the DOM as a `role=\"tooltip\"` node full of\n * text: every IconButton in a row added one to `getAllByRole(\"tooltip\")` and its label\n * to the row's `textContent`, and two keksdose tests tripped over that (0.15.5 P6).\n * The cost is the one {@link Tooltip}'s \"Lazy in place\" note names — a test that\n * looked the bubble up without hovering now has to hover (or focus) first; pass\n * `false` while it is migrated. A `disabledReason` bubble is unaffected: that one\n * stays mounted as it was.\n */\n tooltipLazy?: boolean;\n /**\n * Busy — the action is in flight: the glyph is swapped for a spinner of the same\n * size, the button is `aria-busy`, and clicks (and the Enter/Space and form\n * submission they stand for) are swallowed, so a second press cannot start the\n * action twice. {@link Button}'s `pending`, with the same contract: `aria-disabled`\n * rather than `disabled`, so the button that was pressed keeps its focus. The name\n * (`label` / `aria-label`) stays — `aria-busy` is what says it is working. A SWAP\n * rather than Button's overlay: the box is square and fixed, so there is no width\n * for the glyph to hold, and a spinner drawn over a glyph would be two marks in one\n * 20px square.\n */\n pending?: boolean;\n /**\n * Why the action is not available — {@link Button}'s `disabledReason`, on the icon\n * button: keksdose's accounts page cannot hide an account with a balance or delete\n * one with bookings (accounts-page:879/913 — \"hide requires zero\", \"delete blocked\"),\n * nor its budgets page delete the only budget (budgets-page:330), and each wrapped a\n * natively disabled IconButton in a Tooltip that swaps its label for the reason — a\n * bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason\n * a screen reader never hears.\n *\n * The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays\n * focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed\n * — `stopPropagation` still applies, so a locked action in a clickable row does not\n * open the row either; the reason shows in the kit {@link Tooltip} and is attached\n * through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /\n * `label` — \"Delete\", described by \"The active budget cannot be deleted\" — and the\n * bubble shows the reason in place of the label, since the glyph already says what\n * the button is and the reason is the news. Shown even with `tooltip={false}`: a\n * reason nobody can see is not one. Wins over `disabled`, as on Button.\n *\n * 0.32: at Large and on a touch screen the reason is a line under the button rather\n * than a bubble — see `disabledReasonDisplay`.\n */\n disabledReason?: ReactNode;\n /** Where `disabledReason` shows — {@link Button}'s `disabledReasonDisplay`. */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** This action COMMITS — {@link Button}'s `commit`: under a locked\n * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's\n * reason. No effect without a lock. A {@link CommitScope} since 0.33. */\n commit?: CommitScope;\n /** The `<button>` element — a prop in React 19, as on {@link Button}. */\n ref?: Ref<HTMLButtonElement>;\n /** Only on the link form — see {@link IconButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon\n * actions that navigate (a row's \"open\", a card's \"edit\" that goes to the edit page, a\n * top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that\n * changes the page, with no middle click, no \"open in new tab\" and no URL on hover.\n *\n * The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app\n * `href` goes through `renderLink`, else the provider's `linkComponent`; an external one\n * or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to\n * a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →\n * `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.\n *\n * `disabled` renders an inert `<a>` with no `href` — `role=\"link\"`, `aria-disabled`, out\n * of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason` and\n * `disabledStyle` are a button's and are typed out: a link is not a toggle, and a link\n * that explains why it cannot be followed is a job for a disabled BUTTON.\n */\nexport interface IconButtonLinkProps\n extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\">,\n Pick<\n IconButtonProps,\n | \"variant\"\n | \"size\"\n | \"glyphSize\"\n | \"badge\"\n | \"stretch\"\n | \"shape\"\n | \"toneColor\"\n | \"tone\"\n | \"quiet\"\n | \"stopPropagation\"\n | \"label\"\n | \"labelVisible\"\n | \"tooltip\"\n | \"tooltipSide\"\n | \"tooltipPortal\"\n | \"tooltipLazy\"\n > {\n href: string;\n /** See {@link ButtonLinkProps.renderLink}. */\n renderLink?: KitLinkComponent;\n /** See {@link ButtonLinkProps.external}. */\n external?: boolean;\n /** See {@link ButtonLinkProps.replace}. */\n replace?: boolean;\n /** See {@link ButtonLinkProps.reloadDocument}. */\n reloadDocument?: boolean;\n /** See {@link ButtonLinkProps.disabled}. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pressed?: never;\n disabledReason?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n disabledStyle?: never;\n pending?: never;\n}\n\n/**\n * Whether an IconButton draws its `label` as text (see `labelVisible`): at Large and\n * above, with a label to draw, not switched off by the caller, the variant (`overlay`,\n * `shutter`), `tooltip={false}` or a {@link CompactControls} region.\n */\nfunction useIconButtonText(\n label: string | undefined,\n labelVisible: boolean | undefined,\n variant: IconButtonVariant,\n tooltip: boolean,\n): boolean {\n const large = useLargeText();\n const compact = useContext(CompactControlsContext);\n if (!large || label === undefined || label === \"\" || !tooltip) return false;\n const visible = labelVisible ?? (!compact && variant !== \"overlay\" && variant !== \"shutter\");\n return visible;\n}\n\n/** The visible label (see `labelVisible`). */\nfunction IconButtonText({ label }: { label: string }) {\n return <span data-slot=\"icon-button-label\">{label}</span>;\n}\n\n/**\n * `toneColor`: one CSS colour, or one per theme.\n *\n * The pair is two custom properties on the element — `--icon-button-tone-light` and\n * `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the\n * other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour\n * follows a theme switch in CSS alone, with no re-render and no JS reading the theme.\n * Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no\n * pair.\n */\nexport type IconButtonToneColor = string | { light: string; dark: string };\n\nconst ICON_BUTTON_TONE_PAIR =\n \"[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]\";\n\n/** The inline custom properties `toneColor` sets over the caller's `style`. */\nfunction toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {\n if (toneColor === undefined) return style;\n const vars: Record<string, string> =\n typeof toneColor === \"string\"\n ? { \"--icon-button-tone\": toneColor }\n : { \"--icon-button-tone-light\": toneColor.light, \"--icon-button-tone-dark\": toneColor.dark };\n return { ...style, ...vars } as CSSProperties;\n}\n\n/** Everything but the element: the look both forms of IconButton share. */\nfunction iconButtonLook({\n variant,\n size,\n stretch,\n glyphSize,\n shape,\n tone,\n quiet,\n toneColor,\n withText = false,\n}: {\n variant: IconButtonVariant;\n size: IconButtonSize;\n stretch: boolean | undefined;\n glyphSize: IconButtonGlyphSize | undefined;\n shape: \"square\" | \"round\";\n tone: IconButtonTone;\n quiet: boolean | undefined;\n toneColor: IconButtonToneColor | undefined;\n /** The label is drawn beside the glyph (see `labelVisible`). */\n withText?: boolean;\n}) {\n const look = variant === \"overlay\" || variant === \"shutter\" ? variant : resolveButtonVariant(variant, \"IconButton variant\");\n return cn(\n ICON_BUTTON_BASE,\n withText\n ? cn(\"font-medium whitespace-nowrap\", stretch ? ICON_BUTTON_TEXT_STRETCH_SIZES[size] : ICON_BUTTON_TEXT_SIZES[size])\n : stretch\n ? ICON_BUTTON_STRETCH_SIZES[size]\n : ICON_BUTTON_SIZES[size],\n // With text the pill is a 48 px target at any size; the square follows its size.\n withText ? TOUCH_TARGET_LARGE : ICON_BUTTON_TOUCH_LARGE[size],\n glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],\n // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.\n look === \"overlay\"\n ? ICON_BUTTON_OVERLAY\n : look === \"shutter\"\n ? ICON_BUTTON_SHUTTER\n : buttonVariantClasses[look],\n shape === \"round\" && \"rounded-full\",\n ICON_BUTTON_DISABLED_REST[look],\n iconButtonToneClass(tone, quiet),\n toneColor !== undefined && typeof toneColor !== \"string\" && ICON_BUTTON_TONE_PAIR,\n );\n}\n\n/** The badge beside the control, not in it — see `badge`. The wrapper is only a\n * positioning box: the control inside it keeps its own focus ring and hit area. */\nfunction withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {\n if (!hasContent(badge)) return control;\n return (\n <span className={cn(\"relative inline-flex shrink-0\", stretch && \"self-stretch\")}>\n {control}\n <span\n aria-hidden\n data-slot=\"icon-button-badge\"\n className=\"pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-surface [&_svg]:size-3\"\n >\n {badge}\n </span>\n </span>\n );\n}\n\n// Declared link-first, as Button is, so `<IconButton href>` picks the link props and\n// `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.\n/**\n * An icon-only button whose `label` is its name and its tooltip.\n *\n * Destructive actions (0.49 KIT-12): a delete, revoke or erase is NOT an icon repeated in\n * every row of a list — one stray tap away, and a bin per row draws the eye to the one\n * thing nobody should do by accident. Put it in the row's menu, `RowActions` (where a\n * `tone: \"danger\"` action goes by default and opens a `ConfirmDialog` first), or in the\n * selection bar (\"2 selected\" → Delete), and confirm it through `useConfirm`. A single\n * standalone delete — a card's own, a bulk bar's — may be an IconButton\n * `tone=\"danger\"`; it still asks first.\n */\nexport function IconButton(props: IconButtonLinkProps): ReactElement;\nexport function IconButton(props: IconButtonProps): ReactElement;\nexport function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;\n return <IconButtonElement {...(props as IconButtonProps)} />;\n}\n\nfunction IconButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n glyphSize,\n badge,\n className,\n style,\n target,\n rel,\n onClick,\n onKeyDown,\n children: glyph,\n ref,\n ...rest\n}: IconButtonLinkProps) {\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const children = withText ? (\n <>\n {glyph}\n <IconButtonText label={label!} />\n </>\n ) : (\n glyph\n );\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n // Button's rule, word for word: `reloadDocument` bypasses the router, an external\n // href or an in-page `#anchor` never reaches it.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const name = rest[\"aria-label\"] ?? label;\n // \"(opens in a new tab)\" joins the NAME here, not the body: an icon link has no text\n // for an sr-only span to follow, and `aria-label` would override one anyway.\n const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;\n const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {\n if (stopPropagation) e.stopPropagation();\n onClick?.(e);\n };\n const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {\n // A link activates on Enter only; that key still bubbles to a clickable row.\n if (stopPropagation && e.key === \"Enter\") e.stopPropagation();\n onKeyDown?.(e);\n };\n const cls = cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n // BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const look = { style: toneColorStyle(toneColor, style), className: cls };\n let control: ReactElement;\n if (disabled) {\n control = (\n <a {...rest} {...look} ref={ref} role=\"link\" aria-disabled=\"true\" aria-label={ariaLabel}>\n {children}\n </a>\n );\n } else if (external || !Link) {\n control = (\n <a\n {...rest}\n {...look}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}\n onKeyDown={handleKeyDown}\n >\n {children}\n </a>\n );\n } else {\n control = (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...look}\n {...routerLinkNavigation(replace)}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={target}\n rel={rel}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n >\n {children}\n </RenderedButtonLink>\n );\n }\n control = withIconButtonBadge(control, badge, stretch);\n // The words are on the link at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, as on the button: the bubble says what `aria-label` already does.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n\nfunction IconButtonElement(\n {\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n pressed,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n glyphSize,\n badge,\n disabledStyle = \"dim\",\n className,\n style,\n onClick,\n onKeyDown,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ref,\n ...rest\n }: IconButtonProps,\n) {\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n const keep = disabledStyle === \"keep\";\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const reasonId = useId();\n const disabledReason = useCommitReason(commit, ownDisabledReason);\n const locked = hasContent(disabledReason);\n // Pending is inert like a locked button — focusable, every activation swallowed —\n // without the locked look: the spinner is the state.\n const inert = locked || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n ref={ref}\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-label={rest[\"aria-label\"] ?? label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n style={toneColorStyle(toneColor, style)}\n onClick={(e) => {\n if (stopPropagation) e.stopPropagation();\n // `preventDefault` too: a locked (or pending) submit must not submit its form.\n if (inert) {\n e.preventDefault();\n return;\n }\n onClick?.(e);\n }}\n onKeyDown={(e) => {\n // A button turns Enter/Space into a click of its own, but the KEY still\n // bubbles — and a clickable row listening for Enter would act on it too.\n if (stopPropagation && (e.key === \"Enter\" || e.key === \" \")) e.stopPropagation();\n onKeyDown?.(e);\n }}\n className={cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n pressed && ICON_BUTTON_PRESSED,\n // The disabled look for the focusable kind of disabled, as on Button.\n locked && (keep ? \"cursor-default\" : \"cursor-not-allowed opacity-50\"),\n keep && \"disabled:cursor-default disabled:opacity-100\",\n pending && \"cursor-progress\",\n className,\n )}\n >\n {pending ? (\n // Decorative, as on Button: `aria-busy` says it. Sized to the glyph it stands\n // in for, in the glyph's colour, so the swap does not change the button's look.\n <Spinner\n label={null}\n className={cn(\n \"border-current/30 border-t-current\",\n glyphSize !== undefined ? ICON_BUTTON_SPINNER_GLYPH[glyphSize] : ICON_BUTTON_SPINNER[size],\n )}\n />\n ) : (\n children\n )}\n {withText && <IconButtonText label={label!} />}\n </button>\n );\n const control = withIconButtonBadge(button, badge, stretch);\n if (locked) {\n return (\n // Button's shape: the reason as a line under it, or the bubble with a hidden copy\n // for the description (see LockedReason).\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n side={tooltipSide}\n portal={tooltipPortal}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {control}\n </LockedReason>\n );\n }\n // The words are on the button at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, so Tooltip leaves the button's description alone: the bubble says\n // exactly what `aria-label` already does. Lazy by default — see `tooltipLazy`.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/icon-button.tsx"],"sourcesContent":["import { useContext, useId } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ButtonHTMLAttributes,\n CSSProperties,\n KeyboardEvent,\n MouseEvent,\n ReactElement,\n ReactNode,\n Ref,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { Tooltip, type TooltipSide } from \"./tooltip\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport { useCommitReason } from \"./write-lock\";\nimport type { CommitScope } from \"./write-lock\";\nimport { hasContent } from \"../lib/presence\";\nimport { CompactControlsContext, LockedReason, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport {\n HOVER_INK,\n ICON_BUTTON_PRESSED,\n RenderedButtonLink,\n HIT_AREA_LARGE,\n TOUCH_TARGET_LARGE,\n buttonVariantClasses,\n} from \"./button-look\";\nimport type { ButtonVariant } from \"./button-look\";\n\n// Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2\n// gap-2 — so rendering a bare glyph through it gives a small icon in a wide,\n// text-shaped box. This fixes a square box instead and, crucially, forces the\n// child icon to a size set by the BOX (20px at md/sm) via `[&_svg]:size-*` so a\n// caller cannot under-size it. Use it wherever an action is a bare icon\n// (edit/delete/tools) so they all match the top-bar icon buttons and can never\n// drift apart again.\n//\n// Draws its colours from the same `buttonVariantClasses` map as <Button>, so the\n// two re-skin together with the palette.\nconst ICON_BUTTON_BASE = cn(\n \"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed\",\n FOCUS_RING_WIDTH,\n);\n\nexport type IconButtonSize = \"2xl\" | \"xl\" | \"lg\" | \"md\" | \"sm\" | \"xs\" | \"2xs\";\n\n// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon\n// that touches its edges. The two small steps are lenkbank's list-row (28px) and\n// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.\nconst ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {\n // 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,\n // `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.\n \"2xl\": \"size-16 [&_svg]:size-7\",\n // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step\n // past the 44px minimum rather than on it.\n xl: \"size-12 [&_svg]:size-6\",\n // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's\n // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.\n lg: \"size-11 [&_svg]:size-5\",\n md: \"size-9 [&_svg]:size-5\",\n sm: \"size-8 [&_svg]:size-5\",\n xs: \"size-7 rounded [&_svg]:size-4\",\n \"2xs\": \"size-6 rounded [&_svg]:size-3.5\",\n};\n\n// `stretch`: the same WIDTH and glyph, and a height taken from the row instead of\n// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is\n// written as a width plus a minimum height (the square it would otherwise be).\nconst ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"w-16 min-h-16 self-stretch [&_svg]:size-7\",\n xl: \"w-12 min-h-12 self-stretch [&_svg]:size-6\",\n lg: \"w-11 min-h-11 self-stretch [&_svg]:size-5\",\n md: \"w-9 min-h-9 self-stretch [&_svg]:size-5\",\n sm: \"w-8 min-h-8 self-stretch [&_svg]:size-5\",\n xs: \"w-7 min-h-7 self-stretch rounded [&_svg]:size-4\",\n \"2xs\": \"w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5\",\n};\n\n// The label as visible text beside the glyph (0.32, §4: at Large \"IconButton shows its\n// label\"): the same height and glyph as the square, a width taken from the words. A\n// map of its own rather than `w-auto` over `size-*`, which tailwind-merge cannot undo\n// in one direction.\nconst ICON_BUTTON_TEXT_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"h-16 gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"h-12 gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"h-11 gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"h-9 gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"h-8 gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"h-7 gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"h-6 gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\nconst ICON_BUTTON_TEXT_STRETCH_SIZES: Record<IconButtonSize, string> = {\n \"2xl\": \"min-h-16 self-stretch gap-2 px-4 text-base [&_svg]:size-7\",\n xl: \"min-h-12 self-stretch gap-2 px-3.5 text-sm [&_svg]:size-6\",\n lg: \"min-h-11 self-stretch gap-2 px-3 text-sm [&_svg]:size-5\",\n md: \"min-h-9 self-stretch gap-1.5 px-2.5 text-sm [&_svg]:size-5\",\n sm: \"min-h-8 self-stretch gap-1.5 px-2 text-sm [&_svg]:size-5\",\n xs: \"min-h-7 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-4\",\n \"2xs\": \"min-h-6 self-stretch gap-1 rounded px-1.5 text-xs [&_svg]:size-3.5\",\n};\n\n// The 48 px touch target at Large (§4), per size. `lg` and up are past it already\n// (2.75rem is 55 px at Large). `md` … `xs` grow their box to it. `2xs` is the action ON\n// a chip or a tab, whose box cannot grow without growing the chip: it keeps its box and\n// gets a 48 px hit area instead — an invisible `::after` centred on it, which takes the\n// taps a finger lands round the glyph (`HIT_AREA_LARGE`).\nconst ICON_BUTTON_TOUCH_LARGE: Record<IconButtonSize, string> = {\n \"2xl\": \"\",\n xl: \"\",\n lg: \"\",\n md: TOUCH_TARGET_LARGE,\n sm: TOUCH_TARGET_LARGE,\n xs: TOUCH_TARGET_LARGE,\n \"2xs\": HIT_AREA_LARGE,\n};\n\n// A tone re-colours the glyph without changing what the variant draws around it.\n// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):\n// QUIET is placeholder grey until the pointer or focus arrives, then the tone's\n// family; TONED wears the tone's colour at rest. The defaults are the looks each tone\n// had before `quiet` existed: `danger` is quiet — an action in every row of a list\n// must not shout from every row, so the red arrives only on the one row you are about\n// to act on — while `warning` and `info` are toned, because they mark the ONE thing\n// on the row to notice (keksdose's \"needs review\" flag, its reconcile action at\n// accounts-page:867), and quiet grey would hide the very thing they point out.\n// `muted` is quiet by definition and has no toned look; `default` has no tone.\n//\n// `success` is toned at rest too: the green is the news (keksdose's \"in sync\" chip).\n// `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose\n// colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as\n// the sync moves), where swapping `tone` would do but the colour is one variable.\nexport type IconButtonTone = \"default\" | \"muted\" | \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\ntype ColouredTone = \"danger\" | \"warning\" | \"info\" | \"success\" | \"custom\";\n\nconst ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {\n danger: {\n quiet:\n \"text-placeholder hover:bg-danger-soft hover:text-danger focus-visible:ring-danger-strong\",\n toned: \"text-danger hover:bg-danger-soft focus-visible:ring-danger-strong\",\n },\n warning: {\n quiet:\n \"text-placeholder hover:bg-warning-soft hover:text-warning focus-visible:ring-warning-strong\",\n // The ring is warning's loud line (0.33, k27): `--warning-border`, amber-300, was a\n // focus ring at 1.1:1 on the light surfaces.\n toned: \"text-warning hover:bg-warning-soft focus-visible:ring-warning-strong\",\n },\n info: {\n quiet:\n \"text-placeholder hover:bg-info-soft hover:text-info focus-visible:ring-[var(--info-border)]\",\n toned: \"text-info hover:bg-info-soft focus-visible:ring-[var(--info-border)]\",\n },\n success: {\n quiet:\n \"text-placeholder hover:bg-success-soft hover:text-success focus-visible:ring-[var(--success-border)]\",\n toned: \"text-success hover:bg-success-soft focus-visible:ring-[var(--success-border)]\",\n },\n // The hover fill and the ring are MIXED from the one colour, since a custom tone\n // brings no `-bg` / `-border` pair of its own. The fallback is the body colour, so a\n // `custom` tone with no colour set is the default look rather than invisible.\n custom: {\n quiet:\n \"text-placeholder hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]\",\n toned:\n \"text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus-visible:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]\",\n },\n};\n\n// The translucent ink on hover, as Button's secondary/ghost (B′, see HOVER_INK).\nconst ICON_BUTTON_MUTED = `text-placeholder ${HOVER_INK} hover:text-primary`;\n\n/** Which tones sit quiet at rest when `quiet` is left out. */\nconst QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {\n danger: true,\n warning: false,\n info: false,\n success: false,\n custom: false,\n};\n\nfunction iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {\n if (tone === \"default\") return \"\";\n if (tone === \"muted\") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);\n const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];\n const look = ICON_BUTTON_TONES[tone];\n return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;\n}\n\n// A disabled button must not answer the pointer. The hover classes above are plain\n// `hover:` (so a caller's `className=\"hover:…\"` still replaces them through\n// tailwind-merge), which means they fire on a disabled button too — the old grey\n// icon lit up on hover while refusing the click. Rather than rewrite every hover as\n// `enabled:hover:` (which never matches the `<a>` that `buttonClasses` also styles,\n// and would out-rank a caller's plain `hover:` override), each variant pins its\n// RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity\n// and only ever matches a disabled button.\nconst ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | \"overlay\" | \"shutter\", string> = {\n default: \"disabled:hover:bg-surface-2\",\n secondary: \"disabled:hover:bg-transparent\",\n ghost: \"disabled:hover:bg-transparent\",\n danger: \"disabled:hover:bg-danger\",\n accent: \"disabled:hover:bg-brand\",\n link: \"disabled:hover:bg-transparent disabled:hover:no-underline\",\n overlay: \"disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]\",\n shutter: \"disabled:hover:bg-media-scrim\",\n};\n\n// The quiet looks change the glyph on hover, so they pin their resting glyph the\n// same way. A toned look keeps its glyph on hover and needs no pin.\nconst ICON_BUTTON_QUIET_DISABLED_REST = \"disabled:hover:text-placeholder\";\n\n// `variant=\"overlay\"`: a round, translucent disc for a control that sits ON a photo\n// (keksdose's receipt-scan preview: close, rotate, retake over the camera image).\n// The inverse pair, not the surface one: what has to hold is the contrast between the\n// disc and its glyph, whatever the picture underneath is, and `--bg-inverse` /\n// `--text-inverse` are the one token pair defined as each other's opposite in both\n// themes. The disc is the inverse at 60% (`color-mix`, so it stays a token a palette\n// can re-point); the blur keeps a busy background from breaking the glyph's edge.\nconst ICON_BUTTON_OVERLAY =\n \"rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-inverse backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus-visible:ring-[var(--text-inverse)]\";\n\n// `variant=\"shutter\"`: the camera's release — keksdose's camera-capture.tsx:248 draws\n// it by hand as a 64px circle with a thick white ring round a translucent fill. It sits\n// on a live camera picture, which is dark whatever the theme, so it uses the MEDIA\n// tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair\n// `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would\n// merge into the ring that is part of the look.\nconst ICON_BUTTON_SHUTTER =\n \"rounded-full border-4 border-media-ink bg-media-scrim text-media-ink backdrop-blur-sm hover:bg-media-scrim-hover focus-visible:ring-media-ink focus-visible:ring-offset-2 focus-visible:ring-offset-media-scrim-hover\";\n\nexport type IconButtonVariant = ButtonVariant | \"overlay\" | \"shutter\";\n\n/**\n * The glyph's size in px, over the one the box size implies — see\n * {@link IconButtonProps.glyphSize}.\n */\nexport type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;\n\nconst ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {\n 12: \"[&_svg]:size-3\",\n 14: \"[&_svg]:size-3.5\",\n 16: \"[&_svg]:size-4\",\n 20: \"[&_svg]:size-5\",\n 24: \"[&_svg]:size-6\",\n 28: \"[&_svg]:size-7\",\n};\n\n// `pending`'s spinner, the size of the glyph it replaces (the `[&_svg]` rules above\n// size only an svg, and the spinner is a bordered span).\nconst ICON_BUTTON_SPINNER: Record<IconButtonSize, string> = {\n \"2xl\": \"size-7\",\n xl: \"size-6\",\n lg: \"size-5\",\n md: \"size-5\",\n sm: \"size-5\",\n xs: \"size-4\",\n \"2xs\": \"size-3.5\",\n};\nconst ICON_BUTTON_SPINNER_GLYPH: Record<IconButtonGlyphSize, string> = {\n 12: \"size-3\",\n 14: \"size-3.5\",\n 16: \"size-4\",\n 20: \"size-5\",\n 24: \"size-6\",\n 28: \"size-7\",\n};\n\nexport interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over\n * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed\n * disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size=\"2xl\"`). */\n variant?: IconButtonVariant;\n /** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px\n * icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),\n * sm = 32px — all three with a 20px icon;\n * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px\n * icon (an action on a chip or a tab). `glyphSize` overrides the icon. */\n size?: IconButtonSize;\n /**\n * The icon's size in px, when the box's own is not the one wanted. keksdose's sync\n * chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —\n * `size=\"2xs\" glyphSize={20} shape=\"round\"` — where `2xs` alone draws 14px. A number\n * over a new size step because the box and the glyph are two independent choices; a\n * step per combination (`\"sm-lg\"`) would have to be named for every one.\n */\n glyphSize?: IconButtonGlyphSize;\n /**\n * A small mark on the button's corner — the sync chip's 12px check over its cloud\n * (\"all good\", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside\n * it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge\n * up to the glyph's size; a bare `<Check />` comes out 12px, on a\n * `--bg-surface` disc that keeps it legible over the glyph. Decorative\n * (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.\n * With a badge, `className` still lands on the button; the wrapper is the flex item.\n */\n badge?: ReactNode;\n /**\n * `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary\n * cursor. For a STATUS that is also an action — the sync chip shows \"in sync\" in\n * green and refreshes on click, and is disabled while offline or syncing, when the\n * state it shows matters most; dimmed it read as broken (keksdose wrote\n * `disabled:cursor-default` by hand and left out the dimming). Default `dim`.\n */\n disabledStyle?: \"dim\" | \"keep\";\n /**\n * Fill the height of the flex row it stands in, keeping its width — the delete at\n * the end of keksdose's split line (transaction-fields:162), level with the labelled\n * fields beside it rather than floating at half their height. {@link Button}'s\n * `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is\n * what stretches.\n */\n stretch?: boolean;\n /** `round`: a circle instead of the rounded square — a compact status chip in a top\n * bar (keksdose's sync indicator). `overlay` is always round. */\n shape?: \"square\" | \"round\";\n /**\n * The colour of `tone=\"custom\"`, as any CSS colour — a token (`var(--warning)`),\n * ideally. Sets `--icon-button-tone` on the button, so passing a different one per\n * state re-colours the glyph, its hover fill and its focus ring together. Passing it\n * implies `tone=\"custom\"`. Or leave it out and set the variable yourself from a class\n * (`className=\"[--icon-button-tone:var(--success)]\"`).\n *\n * Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads\n * on the light surface (`#be123c`) is too dark on the dark one, and an inline style\n * cannot say \"in dark mode\". See {@link IconButtonToneColor}.\n */\n toneColor?: IconButtonToneColor;\n /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on\n * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a\n * remove/delete that repeats down a list. `warning`: amber at rest — a flag that\n * wants attention. `info`: sky at rest — a notice-worthy but harmless action\n * (keksdose's reconcile). `success`: green at rest — a good state worth seeing.\n * `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for\n * turning a coloured tone's resting look the other way. */\n tone?: IconButtonTone;\n /**\n * Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:\n * `true` is placeholder grey at rest and the tone's colour on hover and focus;\n * `false` wears the tone's colour at rest. Left out, each tone keeps its own\n * default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by\n * definition) and `default` (no tone).\n *\n * `quiet={false}` on `danger` is for a destructive action that stands ALONE, where\n * hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one\n * \"delete all\" and a touch screen that never hovers, so it painted rose by hand.\n * `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated\n * down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an\n * `emphasis` scale: there are exactly two resting looks, every coloured tone has\n * both, and which one fits is a question about the SITE (alone or repeated, touch or\n * pointer), not about the tone — so it is one switch the family shares.\n */\n quiet?: boolean;\n /**\n * Make it a toggle button. `true` sets `aria-pressed=\"true\"` and draws the \"on\" look\n * (brand glyph on the quiet brand fill); `false` sets `aria-pressed=\"false\"` with the\n * normal look, so a screen reader still hears a toggle that is off. Left out, it is\n * an ordinary button with no `aria-pressed` — or whatever `aria-pressed` the caller\n * passes. For keksdose's budget share toggle (budgets-page:291), which paints its own\n * brand colour over a ghost button today. Keep the `aria-label` the same in both\n * states (\"Share budget\"): the pressed state already says whether it is on.\n */\n pressed?: boolean;\n /** Keep the click (and the Enter/Space that produces it) from reaching an\n * ancestor's handler — for an action inside a clickable table row or card.\n *\n * **Not a licence to put this inside another `<button>`.** HTML forbids any\n * interactive content, and any element with a `tabindex`, inside a button — so\n * the `<span role=\"button\" tabIndex={0}>` workaround is invalid too — and ARIA\n * makes a button's children presentational, so a screen reader flattens the\n * inner one into the outer one's name and it cannot be reached at all. Make the\n * row's main action a button that fills the row and put this one BESIDE it, on\n * top:\n *\n * ```tsx\n * <div className=\"relative\">\n * <button className=\"w-full pe-16 …\" onClick={open}>…row…</button>\n * <div className=\"absolute inset-y-0 end-2 flex items-center gap-0.5\">\n * <IconButton size=\"xs\" tone=\"danger\" aria-label=\"Delete\" onClick={remove}>\n * <Trash2 />\n * </IconButton>\n * </div>\n * </div>\n * ```\n *\n * The pair look exactly like the nested version, click exactly like it, and\n * are two tab stops a screen reader can tell apart. */\n stopPropagation?: boolean;\n /**\n * The button's name, said once: it becomes the `aria-label` AND the text of a kit\n * {@link Tooltip} round the button.\n *\n * Every app writes the pair by hand. lenkbank's nine icon actions all carry\n * `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,\n * projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,\n * shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,\n * which shows late, never on focus and never on touch, and looks like no other\n * label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)\n * and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.\n *\n * A caller's own `aria-label` still wins, for the rare name that should be longer\n * than the bubble. The bubble is visual only: it would otherwise describe the button\n * with its own name, and a screen reader would read the same word twice. Tooltip's\n * default placement applies — in place, or portalled inside a scroll container —\n * unless `tooltipPortal` says otherwise.\n */\n label?: string;\n /**\n * Show `label` as visible text beside the icon at Large and Extra large (0.32,\n * docs/text-size-harmonization.md §4, §10.8). A reader who asked for bigger type is\n * the one an unlabelled glyph fails, and on touch the tooltip never opens. Then there\n * is no tooltip: the words are on the button.\n *\n * Default `true` — except for `variant=\"overlay\"` and `\"shutter\"`, which sit ON a\n * picture, and inside {@link CompactControls} (AppShell's top bar, `RowActions`'\n * inline icons). `false`: the icon alone at every size, for an icon on content — a\n * receipt overlay, a map's zoom, a viewfinder, a photo thumbnail. That must be rare;\n * a dense row's actions belong in `RowActions`, which collapses them into a \"⋯\" menu\n * at Large instead (§10.8). Normal never shows it. `tooltip={false}` (a universally\n * read glyph, a dialog's ✕) keeps the icon alone too.\n */\n labelVisible?: boolean;\n /** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the\n * accessible name only — for a button whose glyph is universally read (a close ✕ in\n * a dialog header) or that already sits under a tooltip of its own. It also keeps the\n * label from showing as text at Large (see `labelVisible`). */\n tooltip?: boolean;\n /** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */\n tooltipSide?: TooltipSide;\n /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */\n tooltipPortal?: boolean;\n /**\n * Mount the `label` bubble only while it is up — {@link Tooltip}'s `lazy`. Default\n * `true` since 0.16.0; `false` is the always-mounted in-place bubble of before.\n *\n * Why the label bubble can be lazy when Tooltip's default is not: this bubble\n * describes nothing. It is wrapped round the button in a fragment precisely so that\n * it is NOT the button's `aria-describedby` (it would repeat the button's own name),\n * so there is no description for a lazy mount to take away — the name is the\n * `aria-label` either way, read in browse mode and on focus alike. What the\n * always-mounted bubble did do was sit in the DOM as a `role=\"tooltip\"` node full of\n * text: every IconButton in a row added one to `getAllByRole(\"tooltip\")` and its label\n * to the row's `textContent`, and two keksdose tests tripped over that (0.15.5 P6).\n * The cost is the one {@link Tooltip}'s \"Lazy in place\" note names — a test that\n * looked the bubble up without hovering now has to hover (or focus) first; pass\n * `false` while it is migrated. A `disabledReason` bubble is unaffected: that one\n * stays mounted as it was.\n */\n tooltipLazy?: boolean;\n /**\n * Busy — the action is in flight: the glyph is swapped for a spinner of the same\n * size, the button is `aria-busy`, and clicks (and the Enter/Space and form\n * submission they stand for) are swallowed, so a second press cannot start the\n * action twice. {@link Button}'s `pending`, with the same contract: `aria-disabled`\n * rather than `disabled`, so the button that was pressed keeps its focus. The name\n * (`label` / `aria-label`) stays — `aria-busy` is what says it is working. A SWAP\n * rather than Button's overlay: the box is square and fixed, so there is no width\n * for the glyph to hold, and a spinner drawn over a glyph would be two marks in one\n * 20px square.\n */\n pending?: boolean;\n /**\n * Why the action is not available — {@link Button}'s `disabledReason`, on the icon\n * button: keksdose's accounts page cannot hide an account with a balance or delete\n * one with bookings (accounts-page:879/913 — \"hide requires zero\", \"delete blocked\"),\n * nor its budgets page delete the only budget (budgets-page:330), and each wrapped a\n * natively disabled IconButton in a Tooltip that swaps its label for the reason — a\n * bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason\n * a screen reader never hears.\n *\n * The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays\n * focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed\n * — `stopPropagation` still applies, so a locked action in a clickable row does not\n * open the row either; the reason shows in the kit {@link Tooltip} and is attached\n * through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /\n * `label` — \"Delete\", described by \"The active budget cannot be deleted\" — and the\n * bubble shows the reason in place of the label, since the glyph already says what\n * the button is and the reason is the news. Shown even with `tooltip={false}`: a\n * reason nobody can see is not one. Wins over `disabled`, as on Button.\n *\n * 0.32: at Large and on a touch screen the reason is a line under the button rather\n * than a bubble — see `disabledReasonDisplay`.\n */\n disabledReason?: ReactNode;\n /** Where `disabledReason` shows — {@link Button}'s `disabledReasonDisplay`. */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** This action COMMITS — {@link Button}'s `commit`: under a locked\n * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's\n * reason. No effect without a lock. A {@link CommitScope} since 0.33. */\n commit?: CommitScope;\n /** The `<button>` element — a prop in React 19, as on {@link Button}. */\n ref?: Ref<HTMLButtonElement>;\n /** Only on the link form — see {@link IconButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon\n * actions that navigate (a row's \"open\", a card's \"edit\" that goes to the edit page, a\n * top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that\n * changes the page, with no middle click, no \"open in new tab\" and no URL on hover.\n *\n * The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app\n * `href` goes through `renderLink`, else the provider's `linkComponent`; an external one\n * or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to\n * a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →\n * `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.\n *\n * `disabled` renders an inert `<a>` with no `href` — `role=\"link\"`, `aria-disabled`, out\n * of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason` and\n * `disabledStyle` are a button's and are typed out: a link is not a toggle, and a link\n * that explains why it cannot be followed is a job for a disabled BUTTON.\n */\nexport interface IconButtonLinkProps\n extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\">,\n Pick<\n IconButtonProps,\n | \"variant\"\n | \"size\"\n | \"glyphSize\"\n | \"badge\"\n | \"stretch\"\n | \"shape\"\n | \"toneColor\"\n | \"tone\"\n | \"quiet\"\n | \"stopPropagation\"\n | \"label\"\n | \"labelVisible\"\n | \"tooltip\"\n | \"tooltipSide\"\n | \"tooltipPortal\"\n | \"tooltipLazy\"\n > {\n href: string;\n /** See {@link ButtonLinkProps.renderLink}. */\n renderLink?: KitLinkComponent;\n /** See {@link ButtonLinkProps.external}. */\n external?: boolean;\n /** See {@link ButtonLinkProps.replace}. */\n replace?: boolean;\n /** See {@link ButtonLinkProps.reloadDocument}. */\n reloadDocument?: boolean;\n /** See {@link ButtonLinkProps.disabled}. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pressed?: never;\n disabledReason?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n disabledStyle?: never;\n pending?: never;\n}\n\n/**\n * Whether an IconButton draws its `label` as text (see `labelVisible`): at Large and\n * above, with a label to draw, not switched off by the caller, the variant (`overlay`,\n * `shutter`), `tooltip={false}` or a {@link CompactControls} region.\n */\nfunction useIconButtonText(\n label: string | undefined,\n labelVisible: boolean | undefined,\n variant: IconButtonVariant,\n tooltip: boolean,\n): boolean {\n const large = useLargeText();\n const compact = useContext(CompactControlsContext);\n if (!large || label === undefined || label === \"\" || !tooltip) return false;\n const visible = labelVisible ?? (!compact && variant !== \"overlay\" && variant !== \"shutter\");\n return visible;\n}\n\n/** The visible label (see `labelVisible`). */\nfunction IconButtonText({ label }: { label: string }) {\n return <span data-slot=\"icon-button-label\">{label}</span>;\n}\n\n/**\n * `toneColor`: one CSS colour, or one per theme.\n *\n * The pair is two custom properties on the element — `--icon-button-tone-light` and\n * `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the\n * other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour\n * follows a theme switch in CSS alone, with no re-render and no JS reading the theme.\n * Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no\n * pair.\n */\nexport type IconButtonToneColor = string | { light: string; dark: string };\n\nconst ICON_BUTTON_TONE_PAIR =\n \"[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]\";\n\n/** The inline custom properties `toneColor` sets over the caller's `style`. */\nfunction toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {\n if (toneColor === undefined) return style;\n const vars: Record<string, string> =\n typeof toneColor === \"string\"\n ? { \"--icon-button-tone\": toneColor }\n : { \"--icon-button-tone-light\": toneColor.light, \"--icon-button-tone-dark\": toneColor.dark };\n return { ...style, ...vars } as CSSProperties;\n}\n\n/** Everything but the element: the look both forms of IconButton share. */\nfunction iconButtonLook({\n variant,\n size,\n stretch,\n glyphSize,\n shape,\n tone,\n quiet,\n toneColor,\n withText = false,\n}: {\n variant: IconButtonVariant;\n size: IconButtonSize;\n stretch: boolean | undefined;\n glyphSize: IconButtonGlyphSize | undefined;\n shape: \"square\" | \"round\";\n tone: IconButtonTone;\n quiet: boolean | undefined;\n toneColor: IconButtonToneColor | undefined;\n /** The label is drawn beside the glyph (see `labelVisible`). */\n withText?: boolean;\n}) {\n return cn(\n ICON_BUTTON_BASE,\n withText\n ? cn(\"font-medium whitespace-nowrap\", stretch ? ICON_BUTTON_TEXT_STRETCH_SIZES[size] : ICON_BUTTON_TEXT_SIZES[size])\n : stretch\n ? ICON_BUTTON_STRETCH_SIZES[size]\n : ICON_BUTTON_SIZES[size],\n // With text the pill is a 48 px target at any size; the square follows its size.\n withText ? TOUCH_TARGET_LARGE : ICON_BUTTON_TOUCH_LARGE[size],\n glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],\n // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.\n variant === \"overlay\"\n ? ICON_BUTTON_OVERLAY\n : variant === \"shutter\"\n ? ICON_BUTTON_SHUTTER\n : buttonVariantClasses[variant],\n shape === \"round\" && \"rounded-full\",\n ICON_BUTTON_DISABLED_REST[variant],\n iconButtonToneClass(tone, quiet),\n toneColor !== undefined && typeof toneColor !== \"string\" && ICON_BUTTON_TONE_PAIR,\n );\n}\n\n/** The badge beside the control, not in it — see `badge`. The wrapper is only a\n * positioning box: the control inside it keeps its own focus ring and hit area. */\nfunction withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {\n if (!hasContent(badge)) return control;\n return (\n <span className={cn(\"relative inline-flex shrink-0\", stretch && \"self-stretch\")}>\n {control}\n <span\n aria-hidden\n data-slot=\"icon-button-badge\"\n className=\"pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-surface [&_svg]:size-3\"\n >\n {badge}\n </span>\n </span>\n );\n}\n\n// Declared link-first, as Button is, so `<IconButton href>` picks the link props and\n// `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.\n/**\n * An icon-only button whose `label` is its name and its tooltip.\n *\n * Destructive actions (0.49 KIT-12): a delete, revoke or erase is NOT an icon repeated in\n * every row of a list — one stray tap away, and a bin per row draws the eye to the one\n * thing nobody should do by accident. Put it in the row's menu, `RowActions` (where a\n * `tone: \"danger\"` action goes by default and opens a `ConfirmDialog` first), or in the\n * selection bar (\"2 selected\" → Delete), and confirm it through `useConfirm`. A single\n * standalone delete — a card's own, a bulk bar's — may be an IconButton\n * `tone=\"danger\"`; it still asks first.\n */\nexport function IconButton(props: IconButtonLinkProps): ReactElement;\nexport function IconButton(props: IconButtonProps): ReactElement;\nexport function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;\n return <IconButtonElement {...(props as IconButtonProps)} />;\n}\n\nfunction IconButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n glyphSize,\n badge,\n className,\n style,\n target,\n rel,\n onClick,\n onKeyDown,\n children: glyph,\n ref,\n ...rest\n}: IconButtonLinkProps) {\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const children = withText ? (\n <>\n {glyph}\n <IconButtonText label={label!} />\n </>\n ) : (\n glyph\n );\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n // Button's rule, word for word: `reloadDocument` bypasses the router, an external\n // href or an in-page `#anchor` never reaches it.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const name = rest[\"aria-label\"] ?? label;\n // \"(opens in a new tab)\" joins the NAME here, not the body: an icon link has no text\n // for an sr-only span to follow, and `aria-label` would override one anyway.\n const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;\n const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {\n if (stopPropagation) e.stopPropagation();\n onClick?.(e);\n };\n const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {\n // A link activates on Enter only; that key still bubbles to a clickable row.\n if (stopPropagation && e.key === \"Enter\") e.stopPropagation();\n onKeyDown?.(e);\n };\n const cls = cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n // BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const look = { style: toneColorStyle(toneColor, style), className: cls };\n let control: ReactElement;\n if (disabled) {\n control = (\n <a {...rest} {...look} ref={ref} role=\"link\" aria-disabled=\"true\" aria-label={ariaLabel}>\n {children}\n </a>\n );\n } else if (external || !Link) {\n control = (\n <a\n {...rest}\n {...look}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}\n onKeyDown={handleKeyDown}\n >\n {children}\n </a>\n );\n } else {\n control = (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...look}\n {...routerLinkNavigation(replace)}\n ref={ref}\n href={href}\n aria-label={ariaLabel}\n target={target}\n rel={rel}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n >\n {children}\n </RenderedButtonLink>\n );\n }\n control = withIconButtonBadge(control, badge, stretch);\n // The words are on the link at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, as on the button: the bubble says what `aria-label` already does.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n\nfunction IconButtonElement(\n {\n variant = \"ghost\",\n size = \"md\",\n tone: toneProp,\n quiet,\n pressed,\n stopPropagation,\n stretch,\n shape = \"square\",\n toneColor,\n label,\n labelVisible,\n tooltip = true,\n tooltipSide,\n tooltipPortal,\n tooltipLazy = true,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n glyphSize,\n badge,\n disabledStyle = \"dim\",\n className,\n style,\n onClick,\n onKeyDown,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ref,\n ...rest\n }: IconButtonProps,\n) {\n const tone = toneProp ?? (toneColor !== undefined ? \"custom\" : \"default\");\n const keep = disabledStyle === \"keep\";\n const withText = useIconButtonText(label, labelVisible, variant, tooltip);\n const reasonId = useId();\n const disabledReason = useCommitReason(commit, ownDisabledReason);\n const locked = hasContent(disabledReason);\n // Pending is inert like a locked button — focusable, every activation swallowed —\n // without the locked look: the spinner is the state.\n const inert = locked || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n ref={ref}\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-label={rest[\"aria-label\"] ?? label}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n style={toneColorStyle(toneColor, style)}\n onClick={(e) => {\n if (stopPropagation) e.stopPropagation();\n // `preventDefault` too: a locked (or pending) submit must not submit its form.\n if (inert) {\n e.preventDefault();\n return;\n }\n onClick?.(e);\n }}\n onKeyDown={(e) => {\n // A button turns Enter/Space into a click of its own, but the KEY still\n // bubbles — and a clickable row listening for Enter would act on it too.\n if (stopPropagation && (e.key === \"Enter\" || e.key === \" \")) e.stopPropagation();\n onKeyDown?.(e);\n }}\n className={cn(\n iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor, withText }),\n pressed && ICON_BUTTON_PRESSED,\n // The disabled look for the focusable kind of disabled, as on Button.\n locked && (keep ? \"cursor-default\" : \"cursor-not-allowed opacity-50\"),\n keep && \"disabled:cursor-default disabled:opacity-100\",\n pending && \"cursor-progress\",\n className,\n )}\n >\n {pending ? (\n // Decorative, as on Button: `aria-busy` says it. Sized to the glyph it stands\n // in for, in the glyph's colour, so the swap does not change the button's look.\n <Spinner\n label={null}\n className={cn(\n \"border-current/30 border-t-current\",\n glyphSize !== undefined ? ICON_BUTTON_SPINNER_GLYPH[glyphSize] : ICON_BUTTON_SPINNER[size],\n )}\n />\n ) : (\n children\n )}\n {withText && <IconButtonText label={label!} />}\n </button>\n );\n const control = withIconButtonBadge(button, badge, stretch);\n if (locked) {\n return (\n // Button's shape: the reason as a line under it, or the bubble with a hidden copy\n // for the description (see LockedReason).\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n side={tooltipSide}\n portal={tooltipPortal}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {control}\n </LockedReason>\n );\n }\n // The words are on the button at Large: a bubble would only repeat them.\n if (!tooltip || withText || label === undefined || label === \"\") return control;\n return (\n // A fragment, so Tooltip leaves the button's description alone: the bubble says\n // exactly what `aria-label` already does. Lazy by default — see `tooltipLazy`.\n <Tooltip\n label={label}\n side={tooltipSide}\n portal={tooltipPortal}\n lazy={tooltipLazy}\n className={stretch ? \"self-stretch\" : undefined}\n >\n <>{control}</>\n </Tooltip>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -16,9 +16,10 @@ import {
|
|
|
16
16
|
FieldChevron,
|
|
17
17
|
FieldGroup,
|
|
18
18
|
FloatingField,
|
|
19
|
+
SELECT_MENU_DRESS,
|
|
20
|
+
SELECT_SM,
|
|
19
21
|
fieldBelow
|
|
20
22
|
} from "./field-base.js";
|
|
21
|
-
const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
|
|
22
23
|
const LOCKED_SELECT_KEYS = /* @__PURE__ */ new Set(["Tab", "Escape", "Shift"]);
|
|
23
24
|
const FIELD_LOCKED = "cursor-not-allowed bg-surface-2 text-muted";
|
|
24
25
|
function Select(props) {
|
|
@@ -63,7 +64,7 @@ function Select(props) {
|
|
|
63
64
|
);
|
|
64
65
|
const below = fieldBelow(hintParts, null, errorEl);
|
|
65
66
|
const chevron = rest.disabled || listBox || locked ? null : /* @__PURE__ */ jsx(FieldChevron, { className: small ? "end-1.5 size-3.5" : void 0 });
|
|
66
|
-
const dress = listBox ? "overflow-y-auto [&_option]:py-1" :
|
|
67
|
+
const dress = listBox ? "overflow-y-auto [&_option]:py-1" : SELECT_MENU_DRESS;
|
|
67
68
|
const interaction = locked ? {
|
|
68
69
|
disabled: void 0,
|
|
69
70
|
"aria-disabled": true,
|