@eifi1/ui-kit 0.32.0 → 0.32.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account/delete-account-setting.d.ts +1 -1
- package/dist/admin/admin-action-confirm.d.ts +1 -1
- package/dist/admin/admin-action-log.d.ts +1 -1
- package/dist/admin/admin-action-log.js +4 -2
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/invitations-panel.d.ts +1 -1
- package/dist/admin/reviewer-scope-editor.d.ts +1 -1
- package/dist/admin/role-select.d.ts +1 -1
- package/dist/admin/transfer-ownership-dialog.d.ts +1 -1
- package/dist/admin/user-roster.d.ts +1 -1
- package/dist/auth/register-form.d.ts +1 -1
- package/dist/billing/billing-banner.d.ts +1 -1
- package/dist/billing/billing-labels.d.ts +1 -1
- package/dist/billing/billing-labels.js +3 -1
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/billing-lock.d.ts +1 -1
- package/dist/billing/billing-standing.d.ts +1 -1
- package/dist/billing/plan-card.d.ts +1 -1
- package/dist/billing/plan-card.js +1 -1
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/billing/plan-limit-notice.d.ts +1 -1
- package/dist/billing/subscription-actions.d.ts +1 -1
- package/dist/billing/subscription-status-chip.d.ts +1 -1
- package/dist/chart.d.ts +2 -2
- package/dist/chart.js.map +1 -1
- package/dist/components/alert-banner.js +1 -1
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +1 -1
- package/dist/components/appearance-labels.d.ts +6 -0
- package/dist/components/appearance-labels.js +2 -1
- package/dist/components/appearance-labels.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/button-group.js +7 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/chip.js +7 -2
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.js +1 -1
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/danger-confirm.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +2 -1
- package/dist/components/data-table-pagination.js +15 -6
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table.js +2 -2
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +11 -1
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/facing-pair.d.ts +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/iban-input.d.ts +1 -1
- package/dist/components/inline-edit-field.js +4 -1
- package/dist/components/inline-edit-field.js.map +1 -1
- package/dist/components/language-select.d.ts +1 -1
- package/dist/components/legal-page.d.ts +1 -1
- package/dist/components/legal.d.ts +1 -1
- package/dist/components/money-field.d.ts +1 -1
- package/dist/components/month-picker.js +41 -34
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/number-field.d.ts +1 -1
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/numpad-sheet.d.ts +1 -1
- package/dist/components/page-header.js +6 -1
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/phone-input.d.ts +1 -1
- package/dist/components/phone-input.js +2 -2
- package/dist/components/phone-input.js.map +1 -1
- package/dist/components/popover.d.ts +1 -1
- package/dist/components/popover.js +2 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/row-actions.d.ts +1 -1
- package/dist/components/row-actions.js +97 -37
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/series-chart.d.ts +1 -1
- package/dist/components/settings-fields.d.ts +7 -3
- package/dist/components/settings-fields.js +2 -1
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.d.ts +1 -1
- package/dist/components/share-card.js +6 -2
- package/dist/components/share-card.js.map +1 -1
- package/dist/components/status-dot.js +9 -2
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/swipeable-row.js +8 -1
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/text-link.d.ts +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/toggle-group.d.ts +9 -2
- package/dist/components/toggle-group.js +3 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -1
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review.js +2 -2
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +6 -6
- package/dist/components/ui.js.map +1 -1
- package/dist/components/wizard-stepper.js +20 -14
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{delete-account-setting-BvdQdbv3.d.ts → delete-account-setting-Dy4kJgUm.d.ts} +53 -14
- package/dist/feedback/feedback-attachment.d.ts +1 -1
- package/dist/feedback/feedback-context.d.ts +1 -1
- package/dist/feedback/feedback-crash.d.ts +1 -1
- package/dist/feedback/feedback-dialog.d.ts +1 -1
- package/dist/feedback/feedback-inbox.d.ts +1 -1
- package/dist/feedback/feedback-labels.d.ts +1 -1
- package/dist/feedback/feedback-menu.d.ts +1 -1
- package/dist/feedback/feedback-record.d.ts +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +1 -1
- package/dist/feedback/feedback-status-undo.d.ts +1 -1
- package/dist/feedback/feedback-submit.d.ts +1 -1
- package/dist/feedback/feedback-swipe.d.ts +1 -1
- package/dist/feedback/feedback-table.d.ts +1 -1
- package/dist/feedback/feedback-thread.d.ts +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/hooks/use-breakpoint.d.ts +19 -8
- package/dist/hooks/use-breakpoint.js +1 -1
- package/dist/hooks/use-breakpoint.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/german.d.ts +1 -1
- package/dist/i18n/german.js +7 -3
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/languages.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/en.d.ts +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/es.js +7 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/fr.js +7 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/hu.js +7 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/it.js +7 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/i18n/locales/zh.js +7 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/landing/landing-actions.d.ts +1 -1
- package/dist/landing/landing-sections.d.ts +1 -1
- package/dist/landing/landing-sections.js +7 -3
- package/dist/landing/landing-sections.js.map +1 -1
- package/dist/landing/public-header.d.ts +1 -1
- package/dist/landing/routing.d.ts +1 -1
- package/dist/rhf/fields.d.ts +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf.d.ts +1 -1
- package/dist/settings/settings-section.d.ts +1 -1
- package/dist/settings/use-account-appearance.d.ts +1 -1
- package/dist/settings/use-account-appearance.js.map +1 -1
- package/dist/shell/app-shell.d.ts +1 -1
- package/dist/shell/app-shell.js +2 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/theme/contrast.d.ts +3 -2
- package/dist/theme/contrast.js.map +1 -1
- package/dist/theme/text-size.d.ts +19 -3
- package/dist/theme/text-size.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard/wizard-summary.js +1 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +1 -1
- package/src/admin/admin-action-log.tsx +6 -1
- package/src/billing/billing-labels.ts +16 -0
- package/src/billing/plan-card.tsx +1 -1
- package/src/chart.ts +2 -0
- package/src/components/alert-banner.tsx +5 -1
- package/src/components/appearance-labels.ts +7 -0
- package/src/components/button-group.tsx +21 -0
- package/src/components/chip.tsx +34 -6
- package/src/components/choice-card.tsx +1 -1
- package/src/components/data-table-pagination.tsx +60 -31
- package/src/components/data-table.tsx +8 -4
- package/src/components/date-picker.tsx +32 -1
- package/src/components/inline-edit-field.tsx +20 -1
- package/src/components/month-picker.tsx +6 -1
- package/src/components/page-header.tsx +15 -1
- package/src/components/phone-input.tsx +7 -2
- package/src/components/popover.tsx +4 -0
- package/src/components/row-actions.tsx +156 -49
- package/src/components/settings-fields.tsx +8 -3
- package/src/components/share-card.tsx +21 -2
- package/src/components/status-dot.tsx +25 -2
- package/src/components/swipeable-row.tsx +8 -1
- package/src/components/toggle-group.tsx +26 -12
- package/src/components/translation-review-editor.tsx +1 -1
- package/src/components/translation-review.tsx +5 -4
- package/src/components/ui.tsx +28 -9
- package/src/components/wizard-stepper.tsx +5 -1
- package/src/hooks/use-breakpoint.ts +20 -9
- package/src/i18n/german.ts +4 -0
- package/src/i18n/locales/es.ts +4 -0
- package/src/i18n/locales/fr.ts +4 -0
- package/src/i18n/locales/hu.ts +4 -0
- package/src/i18n/locales/it.ts +4 -0
- package/src/i18n/locales/zh.ts +4 -0
- package/src/index.ts +4 -2
- package/src/landing/landing-sections.tsx +7 -3
- package/src/settings/use-account-appearance.ts +1 -1
- package/src/shell/app-shell.tsx +11 -6
- package/src/theme/contrast.ts +3 -2
- package/src/theme/text-size.ts +19 -3
- package/src/wizard/wizard-summary.tsx +4 -2
- package/tokens.css +15 -7
|
@@ -8,6 +8,7 @@ const TITLE = {
|
|
|
8
8
|
compact: "text-xl font-semibold",
|
|
9
9
|
md: "text-xl font-semibold tracking-tight sm:text-2xl"
|
|
10
10
|
};
|
|
11
|
+
const STACK_ACTIONS_AT_LARGE = "large:max-sm:flex-col large:max-sm:items-stretch large:max-sm:[&>*]:w-full";
|
|
11
12
|
const ROW = {
|
|
12
13
|
stacked: "flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4",
|
|
13
14
|
inline: "flex-row items-center justify-between gap-2 sm:gap-4"
|
|
@@ -85,7 +86,11 @@ function PageHeader({
|
|
|
85
86
|
"div",
|
|
86
87
|
{
|
|
87
88
|
ref: actionsRef,
|
|
88
|
-
className: cn(
|
|
89
|
+
className: cn(
|
|
90
|
+
"flex shrink-0 flex-wrap items-center gap-2",
|
|
91
|
+
hasSecondary && "sm:order-2",
|
|
92
|
+
mobileLayout === "stacked" && STACK_ACTIONS_AT_LARGE
|
|
93
|
+
),
|
|
89
94
|
children: actions
|
|
90
95
|
}
|
|
91
96
|
),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/page-header.tsx"],"sourcesContent":["import { useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { SECTION_LABEL_CLASS } from \"./text\";\n\n/**\n * `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a\n * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).\n *\n * `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at\n * EVERY width — budgets, rules, accounts, transactions and budget pages all write\n * exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to\n * `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's\n * titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is\n * one: the letters are taken in order, and there is none between `sm` and `md` — an\n * `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is\n * not.\n */\nexport type PageHeaderSize = \"sm\" | \"compact\" | \"md\";\n\nconst TITLE: Record<PageHeaderSize, string> = {\n sm: \"text-lg font-semibold\",\n compact: \"text-xl font-semibold\",\n md: \"text-xl font-semibold tracking-tight sm:text-2xl\",\n};\n\n/**\n * How the title block and the actions sit below the `sm` breakpoint. From `sm` up both\n * layouts are one row: title block at the start, actions at the end.\n */\nexport type PageHeaderMobileLayout = \"stacked\" | \"inline\";\n\nconst ROW: Record<PageHeaderMobileLayout, string> = {\n stacked: \"flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\",\n inline: \"flex-row items-center justify-between gap-2 sm:gap-4\",\n};\n\n/**\n * Where the actions sit on the title block's height, in the one row the header is from\n * `sm` up (and on a phone too when {@link PageHeaderMobileLayout} is `inline`, the only\n * layout that has a row there). Unset keeps each layout's own: `start` for `stacked`,\n * `center` for `inline`.\n */\nexport type PageHeaderActionsAlign = \"start\" | \"center\" | \"end\";\n\n/** Applied to the ROW, so the title block and the actions are aligned against each\n * other on one cross axis. 0.14.0 put `self-*` on the actions box alone, which moved\n * nothing when the actions were the taller item — they already filled the row, and\n * the title stayed at the top (keksdose H2, reports-page's two-line labelled\n * CurrencySelect). Per layout, because `stacked` is a column on a phone, where\n * `items-center` would centre everything HORIZONTALLY — so it aligns from `sm` up. */\nconst ROW_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {\n stacked: { start: \"sm:items-start\", center: \"sm:items-center\", end: \"sm:items-end\" },\n inline: { start: \"items-start\", center: \"items-center\", end: \"items-end\" },\n};\n\nexport interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n title: ReactNode;\n /** A sentence under the title. */\n description?: ReactNode;\n /** Buttons for the page — \"New\", \"Export\". At the title's end from `sm` up; under it,\n * wrapping, on a phone — unless {@link mobileLayout} is `inline`. */\n actions?: ReactNode;\n /** A `Breadcrumbs` trail, drawn above everything else. */\n breadcrumbs?: ReactNode;\n /** A small uppercase line above the title — the section or the kind of record\n * (\"Building\", \"Settings\"). The section-label type. */\n eyebrow?: ReactNode;\n /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a\n * header inside a page that already has its `h1` (a tab's own header). */\n as?: \"h1\" | \"h2\" | \"h3\";\n size?: PageHeaderSize;\n /**\n * `stacked` (default): on a phone the actions drop under the title and wrap, so a\n * row of three buttons never pushes the title off the screen.\n *\n * `inline`: the actions stay beside the title on a phone too, vertically centred on\n * it — keksdose's headers (transactions-page.tsx:515, `flex items-center\n * justify-between gap-2`), which carry one or two icon-sized controls and were tuned\n * for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the\n * row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is\n * for a header whose actions are known to fit beside a phone-width title (an icon\n * button, a month picker); a long title wraps to make room for them instead.\n */\n mobileLayout?: PageHeaderMobileLayout;\n /**\n * A second group of actions that gets its OWN full-width row under the header on a\n * phone, and joins the actions' row from `sm` up, between the title and\n * {@link actions}.\n *\n * keksdose's budget page (budget-page.tsx, feedback #60) is the case: title and month\n * navigation on row 1, the fold/unfold toggles on row 2 on a phone — \"two tidy rows\n * instead of the old ragged justify-between overflow\" — and one row, title | toggles |\n * month nav, on a wider screen. It builds that by hand with a wrapping flex row and\n * swapped order utilities; adopting `PageHeader` (keksdose G6b) must not lose it.\n * There: `actions` is the month navigation, this is the toggles.\n *\n * The DOM order is title, `actions`, `secondaryActions` — the phone's visual order,\n * and the one a screen reader and the Tab key follow at every width. From `sm` up the\n * two groups swap places visually only, which keeps the page's primary control (the\n * month) at the row's end, where keksdose had it, and first in reading order. The\n * breakpoint is `sm`, the one {@link mobileLayout} switches at, not keksdose's `md`:\n * one header, one breakpoint.\n */\n secondaryActions?: ReactNode;\n /**\n * The actions' vertical alignment against the title block — see\n * {@link PageHeaderActionsAlign}. Unset keeps the layout's own.\n *\n * keksdose's reports page (reports-page.tsx, keksdose G8) carries a LABELLED\n * `CurrencySelect` as its action: a field with its label on top is taller than the\n * title, and `stacked`'s default top alignment hangs it from the title's cap height\n * with its control well below the title's line. `center` sets it on the title's\n * middle, as that page's hand-written `items-center` row did. It aligns the whole row\n * (title block and actions against each other), since 0.14.1: 0.14.0 moved only the\n * actions box, which did nothing when the actions were the taller item (keksdose H2).\n */\n actionsAlign?: PageHeaderActionsAlign;\n /**\n * One line, cut with an ellipsis, instead of the default breaking of a long word onto\n * as many lines as it needs.\n *\n * keksdose's payees page (payees-page.tsx, live #263, keksdose G6a) wraps its title in\n * its own truncating span so a long name gives way to the `inline` header's actions\n * instead of pushing them. This is that span's behaviour on the heading itself; the\n * title block is already allowed to shrink below its content, which is what lets the\n * cut happen inside a flex row.\n *\n * The full title stays in the DOM as the heading's text, so a screen reader and the\n * document outline read all of it. There is deliberately no native `title` tooltip:\n * the kit does not use them (not on touch, not on keyboard focus, not styled).\n */\n truncateTitle?: boolean;\n}\n\n/**\n * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the\n * page's actions.\n *\n * kastlan's (shared/components/display/page-header.tsx) is the model — title and\n * description on one side, actions on the other, stacked on a phone — and this adds\n * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`\n * title (a status chip beside the name), and actions that WRAP when there are more\n * than fit a phone's width, instead of running off it. No outer margin: the page's\n * own rhythm (`space-y-*`, a grid gap) places it.\n */\nexport function PageHeader({\n title,\n description,\n actions,\n breadcrumbs,\n eyebrow,\n as = \"h1\",\n size = \"md\",\n mobileLayout = \"stacked\",\n secondaryActions,\n actionsAlign,\n truncateTitle = false,\n className,\n ...rest\n}: PageHeaderProps) {\n const Heading = as as ElementType;\n const hasSecondary = secondaryActions != null;\n const wrapsSecondary = hasSecondary && mobileLayout === \"inline\";\n const align = actionsAlign != null ? ROW_ALIGN[mobileLayout][actionsAlign] : undefined;\n // keksdose H1: in `inline` the title block could shrink below its longest word, and\n // `break-words` then split \"Monatsbudget\" into \"Monatsbudg\" / \"et\" beside the actions.\n // Unless it truncates, the title keeps its longest word (min-content) and the ROW\n // wraps instead: actions that do not fit go to a line of their own, as the app's old\n // `flex-wrap` header did.\n const keepsWords = mobileLayout === \"inline\" && !truncateTitle;\n // keksdose, after H1: once the actions have wrapped below the title, the phone's\n // header was THREE rows — title / actions / secondary — where the app's old header\n // shared the second row between the month nav and the toggles (~48px of phone\n // height). So the secondary group's full-width basis (G6b: a row of its own) is\n // dropped exactly when the actions already sit below the title; then it joins their\n // row if it fits, and wraps under them if not. While the actions share the title's\n // row, nothing changes: the secondary group never climbs onto the title's row.\n //\n // CSS cannot see \"the previous item wrapped\", so it is measured — by a\n // ResizeObserver, whose first callback runs on `observe`. It is stable: the\n // secondary group comes after the actions, so its basis cannot move them. No\n // ResizeObserver (jsdom) or no layout keeps the basis, the 0.14.0 layout.\n const measuresWrap = keepsWords && hasSecondary && actions != null;\n const titleRef = useRef<HTMLDivElement>(null);\n const actionsRef = useRef<HTMLDivElement>(null);\n const [actionsWrapped, setActionsWrapped] = useState(false);\n useLayoutEffect(() => {\n const titleBox = titleRef.current;\n const actionsBox = actionsRef.current;\n if (!measuresWrap || !titleBox || !actionsBox || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => {\n const t = titleBox.getBoundingClientRect();\n const a = actionsBox.getBoundingClientRect();\n // Unmeasured (display: none, no layout): keep the full-width basis.\n setActionsWrapped(t.height > 0 && a.height > 0 && a.top >= t.bottom - 1);\n });\n observer.observe(titleBox);\n observer.observe(actionsBox);\n if (titleBox.parentElement) observer.observe(titleBox.parentElement);\n return () => observer.disconnect();\n }, [measuresWrap]);\n return (\n <div {...rest} className={cn(\"flex min-w-0 flex-col gap-2\", className)}>\n {breadcrumbs}\n {/* With a second group, `inline` wraps so that group's full-width basis puts it\n on a row of its own on a phone (`stacked` is a column there already, and a\n basis would size a column item's HEIGHT); from `sm` up the row stops wrapping\n and the order utilities seat it between the title and the actions. Without\n one, nothing changes. */}\n <div\n className={cn(\n \"flex min-w-0\",\n ROW[mobileLayout],\n keepsWords ? \"flex-wrap\" : wrapsSecondary && \"flex-wrap sm:flex-nowrap\",\n align,\n )}\n >\n <div ref={titleRef} className={cn(\"flex-1\", keepsWords ? \"min-w-min\" : \"min-w-0\")}>\n {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, \"mb-1\")}>{eyebrow}</p>}\n <Heading\n className={cn(\n truncateTitle ? \"truncate\" : \"break-words\",\n \"text-[var(--text-primary)]\",\n TITLE[size],\n )}\n >\n {title}\n </Heading>\n {description != null && <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>}\n </div>\n {actions != null && (\n <div\n ref={actionsRef}\n className={cn(\"flex shrink-0 flex-wrap items-center gap-2\", hasSecondary && \"sm:order-2\")}\n >\n {actions}\n </div>\n )}\n {hasSecondary && (\n <div\n className={cn(\n \"flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0\",\n wrapsSecondary && (measuresWrap && actionsWrapped ? \"sm:basis-auto\" : \"basis-full sm:basis-auto\"),\n )}\n >\n {secondaryActions}\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/page-header.tsx"],"sourcesContent":["import { useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { SECTION_LABEL_CLASS } from \"./text\";\n\n/**\n * `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a\n * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).\n *\n * `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at\n * EVERY width — budgets, rules, accounts, transactions and budget pages all write\n * exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to\n * `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's\n * titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is\n * one: the letters are taken in order, and there is none between `sm` and `md` — an\n * `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is\n * not.\n */\nexport type PageHeaderSize = \"sm\" | \"compact\" | \"md\";\n\nconst TITLE: Record<PageHeaderSize, string> = {\n sm: \"text-lg font-semibold\",\n compact: \"text-xl font-semibold\",\n md: \"text-xl font-semibold tracking-tight sm:text-2xl\",\n};\n\n/**\n * How the title block and the actions sit below the `sm` breakpoint. From `sm` up both\n * layouts are one row: title block at the start, actions at the end.\n */\nexport type PageHeaderMobileLayout = \"stacked\" | \"inline\";\n\n/**\n * `stacked` at Large and Extra large on a phone (0.32.2, kastlan's 0.32.1 report;\n * docs/text-size-harmonization.md §4, as FormActions does): the actions take the full\n * width, one under the other. Side by side in 240 px of layout, two buttons each wrapped\n * their label onto three lines. `large:max-sm:`, the scaled phone width, so Normal and\n * every wider screen keep their row; `inline` is for actions known to fit, and stays.\n */\nconst STACK_ACTIONS_AT_LARGE =\n \"large:max-sm:flex-col large:max-sm:items-stretch large:max-sm:[&>*]:w-full\";\n\nconst ROW: Record<PageHeaderMobileLayout, string> = {\n stacked: \"flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\",\n inline: \"flex-row items-center justify-between gap-2 sm:gap-4\",\n};\n\n/**\n * Where the actions sit on the title block's height, in the one row the header is from\n * `sm` up (and on a phone too when {@link PageHeaderMobileLayout} is `inline`, the only\n * layout that has a row there). Unset keeps each layout's own: `start` for `stacked`,\n * `center` for `inline`.\n */\nexport type PageHeaderActionsAlign = \"start\" | \"center\" | \"end\";\n\n/** Applied to the ROW, so the title block and the actions are aligned against each\n * other on one cross axis. 0.14.0 put `self-*` on the actions box alone, which moved\n * nothing when the actions were the taller item — they already filled the row, and\n * the title stayed at the top (keksdose H2, reports-page's two-line labelled\n * CurrencySelect). Per layout, because `stacked` is a column on a phone, where\n * `items-center` would centre everything HORIZONTALLY — so it aligns from `sm` up. */\nconst ROW_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {\n stacked: { start: \"sm:items-start\", center: \"sm:items-center\", end: \"sm:items-end\" },\n inline: { start: \"items-start\", center: \"items-center\", end: \"items-end\" },\n};\n\nexport interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n title: ReactNode;\n /** A sentence under the title. */\n description?: ReactNode;\n /** Buttons for the page — \"New\", \"Export\". At the title's end from `sm` up; under it,\n * wrapping, on a phone — unless {@link mobileLayout} is `inline`. */\n actions?: ReactNode;\n /** A `Breadcrumbs` trail, drawn above everything else. */\n breadcrumbs?: ReactNode;\n /** A small uppercase line above the title — the section or the kind of record\n * (\"Building\", \"Settings\"). The section-label type. */\n eyebrow?: ReactNode;\n /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a\n * header inside a page that already has its `h1` (a tab's own header). */\n as?: \"h1\" | \"h2\" | \"h3\";\n size?: PageHeaderSize;\n /**\n * `stacked` (default): on a phone the actions drop under the title and wrap, so a\n * row of three buttons never pushes the title off the screen.\n *\n * `inline`: the actions stay beside the title on a phone too, vertically centred on\n * it — keksdose's headers (transactions-page.tsx:515, `flex items-center\n * justify-between gap-2`), which carry one or two icon-sized controls and were tuned\n * for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the\n * row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is\n * for a header whose actions are known to fit beside a phone-width title (an icon\n * button, a month picker); a long title wraps to make room for them instead.\n */\n mobileLayout?: PageHeaderMobileLayout;\n /**\n * A second group of actions that gets its OWN full-width row under the header on a\n * phone, and joins the actions' row from `sm` up, between the title and\n * {@link actions}.\n *\n * keksdose's budget page (budget-page.tsx, feedback #60) is the case: title and month\n * navigation on row 1, the fold/unfold toggles on row 2 on a phone — \"two tidy rows\n * instead of the old ragged justify-between overflow\" — and one row, title | toggles |\n * month nav, on a wider screen. It builds that by hand with a wrapping flex row and\n * swapped order utilities; adopting `PageHeader` (keksdose G6b) must not lose it.\n * There: `actions` is the month navigation, this is the toggles.\n *\n * The DOM order is title, `actions`, `secondaryActions` — the phone's visual order,\n * and the one a screen reader and the Tab key follow at every width. From `sm` up the\n * two groups swap places visually only, which keeps the page's primary control (the\n * month) at the row's end, where keksdose had it, and first in reading order. The\n * breakpoint is `sm`, the one {@link mobileLayout} switches at, not keksdose's `md`:\n * one header, one breakpoint.\n */\n secondaryActions?: ReactNode;\n /**\n * The actions' vertical alignment against the title block — see\n * {@link PageHeaderActionsAlign}. Unset keeps the layout's own.\n *\n * keksdose's reports page (reports-page.tsx, keksdose G8) carries a LABELLED\n * `CurrencySelect` as its action: a field with its label on top is taller than the\n * title, and `stacked`'s default top alignment hangs it from the title's cap height\n * with its control well below the title's line. `center` sets it on the title's\n * middle, as that page's hand-written `items-center` row did. It aligns the whole row\n * (title block and actions against each other), since 0.14.1: 0.14.0 moved only the\n * actions box, which did nothing when the actions were the taller item (keksdose H2).\n */\n actionsAlign?: PageHeaderActionsAlign;\n /**\n * One line, cut with an ellipsis, instead of the default breaking of a long word onto\n * as many lines as it needs.\n *\n * keksdose's payees page (payees-page.tsx, live #263, keksdose G6a) wraps its title in\n * its own truncating span so a long name gives way to the `inline` header's actions\n * instead of pushing them. This is that span's behaviour on the heading itself; the\n * title block is already allowed to shrink below its content, which is what lets the\n * cut happen inside a flex row.\n *\n * The full title stays in the DOM as the heading's text, so a screen reader and the\n * document outline read all of it. There is deliberately no native `title` tooltip:\n * the kit does not use them (not on touch, not on keyboard focus, not styled).\n */\n truncateTitle?: boolean;\n}\n\n/**\n * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the\n * page's actions.\n *\n * kastlan's (shared/components/display/page-header.tsx) is the model — title and\n * description on one side, actions on the other, stacked on a phone — and this adds\n * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`\n * title (a status chip beside the name), and actions that WRAP when there are more\n * than fit a phone's width, instead of running off it. No outer margin: the page's\n * own rhythm (`space-y-*`, a grid gap) places it.\n */\nexport function PageHeader({\n title,\n description,\n actions,\n breadcrumbs,\n eyebrow,\n as = \"h1\",\n size = \"md\",\n mobileLayout = \"stacked\",\n secondaryActions,\n actionsAlign,\n truncateTitle = false,\n className,\n ...rest\n}: PageHeaderProps) {\n const Heading = as as ElementType;\n const hasSecondary = secondaryActions != null;\n const wrapsSecondary = hasSecondary && mobileLayout === \"inline\";\n const align = actionsAlign != null ? ROW_ALIGN[mobileLayout][actionsAlign] : undefined;\n // keksdose H1: in `inline` the title block could shrink below its longest word, and\n // `break-words` then split \"Monatsbudget\" into \"Monatsbudg\" / \"et\" beside the actions.\n // Unless it truncates, the title keeps its longest word (min-content) and the ROW\n // wraps instead: actions that do not fit go to a line of their own, as the app's old\n // `flex-wrap` header did.\n const keepsWords = mobileLayout === \"inline\" && !truncateTitle;\n // keksdose, after H1: once the actions have wrapped below the title, the phone's\n // header was THREE rows — title / actions / secondary — where the app's old header\n // shared the second row between the month nav and the toggles (~48px of phone\n // height). So the secondary group's full-width basis (G6b: a row of its own) is\n // dropped exactly when the actions already sit below the title; then it joins their\n // row if it fits, and wraps under them if not. While the actions share the title's\n // row, nothing changes: the secondary group never climbs onto the title's row.\n //\n // CSS cannot see \"the previous item wrapped\", so it is measured — by a\n // ResizeObserver, whose first callback runs on `observe`. It is stable: the\n // secondary group comes after the actions, so its basis cannot move them. No\n // ResizeObserver (jsdom) or no layout keeps the basis, the 0.14.0 layout.\n const measuresWrap = keepsWords && hasSecondary && actions != null;\n const titleRef = useRef<HTMLDivElement>(null);\n const actionsRef = useRef<HTMLDivElement>(null);\n const [actionsWrapped, setActionsWrapped] = useState(false);\n useLayoutEffect(() => {\n const titleBox = titleRef.current;\n const actionsBox = actionsRef.current;\n if (!measuresWrap || !titleBox || !actionsBox || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => {\n const t = titleBox.getBoundingClientRect();\n const a = actionsBox.getBoundingClientRect();\n // Unmeasured (display: none, no layout): keep the full-width basis.\n setActionsWrapped(t.height > 0 && a.height > 0 && a.top >= t.bottom - 1);\n });\n observer.observe(titleBox);\n observer.observe(actionsBox);\n if (titleBox.parentElement) observer.observe(titleBox.parentElement);\n return () => observer.disconnect();\n }, [measuresWrap]);\n return (\n <div {...rest} className={cn(\"flex min-w-0 flex-col gap-2\", className)}>\n {breadcrumbs}\n {/* With a second group, `inline` wraps so that group's full-width basis puts it\n on a row of its own on a phone (`stacked` is a column there already, and a\n basis would size a column item's HEIGHT); from `sm` up the row stops wrapping\n and the order utilities seat it between the title and the actions. Without\n one, nothing changes. */}\n <div\n className={cn(\n \"flex min-w-0\",\n ROW[mobileLayout],\n keepsWords ? \"flex-wrap\" : wrapsSecondary && \"flex-wrap sm:flex-nowrap\",\n align,\n )}\n >\n <div ref={titleRef} className={cn(\"flex-1\", keepsWords ? \"min-w-min\" : \"min-w-0\")}>\n {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, \"mb-1\")}>{eyebrow}</p>}\n <Heading\n className={cn(\n truncateTitle ? \"truncate\" : \"break-words\",\n \"text-[var(--text-primary)]\",\n TITLE[size],\n )}\n >\n {title}\n </Heading>\n {description != null && <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>}\n </div>\n {actions != null && (\n <div\n ref={actionsRef}\n className={cn(\n \"flex shrink-0 flex-wrap items-center gap-2\",\n hasSecondary && \"sm:order-2\",\n mobileLayout === \"stacked\" && STACK_ACTIONS_AT_LARGE,\n )}\n >\n {actions}\n </div>\n )}\n {hasSecondary && (\n <div\n className={cn(\n \"flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0\",\n wrapsSecondary && (measuresWrap && actionsWrapped ? \"sm:basis-auto\" : \"basis-full sm:basis-auto\"),\n )}\n >\n {secondaryActions}\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import 'react';
|
|
2
2
|
import '../lib/phone.js';
|
|
3
|
-
export { cJ as DEFAULT_PHONE_INPUT_LABELS, eq as PhoneInput, er as PhoneInputLabels, es as PhoneInputProps } from '../delete-account-setting-
|
|
3
|
+
export { cJ as DEFAULT_PHONE_INPUT_LABELS, eq as PhoneInput, er as PhoneInputLabels, es as PhoneInputProps } from '../delete-account-setting-Dy4kJgUm.js';
|
|
4
4
|
import './tooltip.js';
|
|
5
5
|
import '../hooks/use-anchored-rect.js';
|
|
6
6
|
import '../lib/clipping.js';
|
|
@@ -119,7 +119,7 @@ const PhoneInput = forwardRef(function PhoneInput2({
|
|
|
119
119
|
role: named ? "group" : void 0,
|
|
120
120
|
"aria-label": named ? label : void 0,
|
|
121
121
|
children: [
|
|
122
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-stretch gap-2", children: [
|
|
122
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-stretch gap-2 large:max-sm:flex-wrap", children: [
|
|
123
123
|
/* @__PURE__ */ jsxs(
|
|
124
124
|
Select,
|
|
125
125
|
{
|
|
@@ -135,7 +135,7 @@ const PhoneInput = forwardRef(function PhoneInput2({
|
|
|
135
135
|
]
|
|
136
136
|
}
|
|
137
137
|
),
|
|
138
|
-
/* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1", children: /* @__PURE__ */ jsx(
|
|
138
|
+
/* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1 large:max-sm:basis-full", children: /* @__PURE__ */ jsx(
|
|
139
139
|
Input,
|
|
140
140
|
{
|
|
141
141
|
ref,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/phone-input.tsx"],"sourcesContent":["import { forwardRef, useId, useState } from \"react\";\nimport type { ChangeEvent, FocusEvent } from \"react\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport {\n PHONE_COUNTRIES,\n PHONE_DIAL_CODES,\n formatNationalPhone,\n formatPhone,\n isE164,\n parsePhone,\n} from \"../lib/phone\";\nimport type { ParsedPhone, PhoneCountry, PhoneCountryCode } from \"../lib/phone\";\nimport { hasMessage, mergeDescribedBy } from \"./choice-parts\";\nimport { Input, Select, type InputProps } from \"./ui\";\n\n// ── Labels ────────────────────────────────────────────────────────────────────\n\n/** The words {@link PhoneInput} renders on its own behalf. */\nexport interface PhoneInputLabels {\n /** The country-code select's accessible name. */\n countryCode: string;\n /** The select's last option: a number from a country not listed, typed with its\n * own \"+\". */\n other: string;\n}\n\nexport const DEFAULT_PHONE_INPUT_LABELS: PhoneInputLabels = {\n countryCode: \"Country code\",\n other: \"Other\",\n};\n\n/**\n * The `phoneInput` namespace: prop > `<UiKitProvider labels>` > English (0.22.0).\n */\nfunction usePhoneInputLabels(prop: Partial<PhoneInputLabels> | undefined): PhoneInputLabels {\n const labels: PhoneInputLabels = useKitLabels(\"phoneInput\", DEFAULT_PHONE_INPUT_LABELS, prop);\n return labels;\n}\n\n// ── Helpers ───────────────────────────────────────────────────────────────────\n\n/** The caption and the error under the row — the type every kit field uses for them\n * (Select's caption, Input's `error`). */\nconst CAPTION_CLASS = \"mt-1 text-caption leading-tight text-[var(--text-muted)]\";\nconst ERROR_CLASS = \"mt-1 text-caption leading-tight text-[var(--danger)]\";\n\nconst isTextHint = (hint: unknown): hint is string | number =>\n (typeof hint === \"string\" && hint !== \"\") || typeof hint === \"number\";\n\n/** A stored value read back: an E.164 number these rules can read, else `null`. */\nfunction storedNumber(value: string): ParsedPhone | null {\n return isE164(value) ? parsePhone(value, \"other\") : null;\n}\n\n/** The select's country for a stored E.164 value, or `null` for free text. */\nfunction countryOf(value: string): PhoneCountry | null {\n const parsed = storedNumber(value);\n return parsed ? (parsed.country ?? \"other\") : null;\n}\n\n/** What the field emits for typed text: E.164 when it reads, the text unchanged when\n * it does not. */\nfunction emitFor(raw: string, country: PhoneCountry): string {\n return parsePhone(raw, country)?.e164 ?? raw;\n}\n\n/**\n * The text a stored value shows, outside of typing. Under its own country: the national\n * number grouped (\"21 000 00 01\" beside \"CH +41\"). Under another (a \"+33\" number while\n * the select says \"Other\"): the whole international number. Free text: as it is.\n */\nfunction displayOf(value: string, country: PhoneCountry): string {\n const parsed = storedNumber(value);\n if (!parsed) return value;\n if (parsed.country !== null && parsed.country === country) {\n return formatNationalPhone(parsed.national, parsed.country);\n }\n return formatPhone(parsed.e164);\n}\n\n// ── Component ─────────────────────────────────────────────────────────────────\n\nexport interface PhoneInputProps extends Omit<InputProps, \"type\" | \"value\" | \"defaultValue\"> {\n /** The number: E.164 (\"+41210000001\") for one the field could read, or free text —\n * a value saved before the field existed is shown exactly as it is. `\"\"` is no\n * number. */\n value: string;\n /** On every change: E.164 when the typed text reads as a number of the selected\n * country (or carries its own \"+\"), otherwise the typed text UNCHANGED. Nothing is\n * refused — see {@link PhoneInput}. Plain `onChange` still fires with the raw event;\n * bind this one to a form. */\n onValueChange?: (value: string) => void;\n /** The country a number typed without \"+\" belongs to, until the value says\n * otherwise. Default `\"CH\"`. */\n defaultCountry?: PhoneCountry;\n /** The countries the select offers, in order; \"Other\" always follows. Default\n * {@link PHONE_COUNTRIES} (CH, LI, DE, AT, FR, IT). A stored number from a country\n * left out still shows under its own code. */\n countries?: readonly PhoneCountryCode[];\n /** See {@link PhoneInputLabels}. */\n labels?: Partial<PhoneInputLabels>;\n /** Classes for the whole field — the select, the input and the lines under them.\n * `inputClassName` reaches the `<input>`. */\n className?: string;\n}\n\n/**\n * A phone number field (kastlan): a country-code select and the number, stored as\n * E.164 and shown grouped.\n *\n * kastlan's contacts kept whatever was typed — \"079 …\", \"+41 (0)79 …\", \"0041 79 …\" —\n * which nobody could dial from the app or match against the next import. This field\n * turns all of those into one stored form, \"+41210000001\", without taking anything\n * away from the person typing:\n *\n * - **A country to type against.** The select (default CH, then LI, DE, AT, FR, IT\n * and \"Other\") says which country a number without \"+\" belongs to; the national\n * trunk 0 may be typed or left out. Typing a \"+\" or \"00\" overrides it, and the\n * select follows the number (\"+49 …\" switches it to DE). Changing the select moves\n * the national number to the new country code.\n * - **E.164 when it reads, the text when it does not.** A number the rules read is\n * emitted as E.164; anything else (\"ask reception\", an extension) is emitted exactly\n * as typed. Never refused, never an error of its own: a contact's number is often\n * known only roughly, and a field that will not save \"079 … (mornings)\" makes\n * people type it into the notes instead. An app that needs a dialable number\n * checks {@link isE164} on submit.\n * - **Existing values untouched.** A free-text value from before is shown exactly as\n * it is, and normalised only when someone edits it — the field never rewrites a\n * record just by being opened.\n * - **Grouped when settled.** While typing the field shows what was typed; on blur a\n * number it read is regrouped by its country (\"21 000 00 01\").\n *\n * **The limitation** — there is no libphonenumber behind this (≈150 kB of metadata in\n * every bundle): the six countries have light rules (the trunk 0, a plausible length)\n * and every other country is any \"+\" with 8–15 digits. A well-formed number that does\n * not exist passes, and German and Austrian landlines, whose area codes vary in length,\n * are grouped only where the split is known (mobile prefixes, the largest cities) and\n * otherwise shown as one block. See `lib/phone.ts`.\n *\n * `label`, `hint`, `error` and `invalid` read as on {@link Input}: the label floats in\n * the number's box, and a text hint and the error go under the whole row, attached to\n * the number with `aria-describedby`. The select is named by `labels.countryCode`, and\n * the pair is a group named by a string `label`.\n */\nexport const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput(\n {\n value,\n onValueChange,\n onChange,\n onBlur,\n defaultCountry = \"CH\",\n countries = PHONE_COUNTRIES,\n labels,\n className,\n inputClassName,\n label,\n hint,\n error,\n invalid,\n disabled,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n },\n ref,\n) {\n const text = usePhoneInputLabels(labels);\n const [country, setCountry] = useState<PhoneCountry>(() => countryOf(value) ?? defaultCountry);\n // What was typed, shown for as long as it still reads as the value (AmountInput's\n // draft): the person's own spacing stays under their fingers, and is regrouped on\n // blur.\n const [draft, setDraft] = useState<string | null>(null);\n // The last value this field emitted, to tell its own echo from a value set outside.\n const [emitted, setEmitted] = useState<string | null>(null);\n const [seen, setSeen] = useState(value);\n if (seen !== value) {\n setSeen(value);\n if (value !== emitted) {\n // Set from outside (a reset, another record): its own country, none of our text.\n const own = countryOf(value);\n if (own) setCountry(own);\n setDraft(null);\n }\n }\n\n const emit = (next: string) => {\n setEmitted(next);\n onValueChange?.(next);\n };\n\n const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n onChange?.(e);\n const raw = e.target.value;\n setDraft(raw);\n const parsed = parsePhone(raw, country);\n // A number typed with its own \"+\" takes the select with it.\n if (parsed && parsed.country !== country) setCountry(parsed.country ?? \"other\");\n emit(parsed?.e164 ?? raw);\n };\n\n const handleCountry = (next: PhoneCountry) => {\n setCountry(next);\n const stored = storedNumber(value);\n if (stored) {\n // \"Other\", the same country, or a generic number with its own \"+\": the number\n // stays, only the way it is shown changes.\n if (next === \"other\" || stored.country === null || stored.country === next) {\n setDraft(null);\n return;\n }\n // A different listed country: the national number moves to its code. If it does\n // not read there, the digits are kept as text for the person to finish.\n emit(parsePhone(stored.national, next)?.e164 ?? stored.national);\n setDraft(null);\n return;\n }\n // Free text (or a draft): read it again under the new country.\n const source = draft ?? value;\n const parsed = source.trim() === \"\" ? null : parsePhone(source, next);\n if (parsed && parsed.e164 !== value) {\n setDraft(source);\n emit(parsed.e164);\n }\n };\n\n const display = draft !== null && emitFor(draft, country) === value ? draft : displayOf(value, country);\n\n // The select offers the configured countries, plus the stored number's own if it\n // is not among them — a select cannot show a value it has no option for.\n const options: PhoneCountryCode[] = [...countries];\n if (country !== \"other\" && !options.includes(country)) options.push(country);\n\n const captionId = useId();\n const errorId = useId();\n const caption = isTextHint(hint);\n const showError = hasMessage(error);\n const named = typeof label === \"string\" && label !== \"\";\n\n return (\n <div\n className={className}\n role={named ? \"group\" : undefined}\n aria-label={named ? label : undefined}\n >\n <div className=\"flex items-stretch gap-2\">\n <Select\n aria-label={text.countryCode}\n value={country}\n disabled={disabled}\n onChange={(e) => handleCountry(e.target.value as PhoneCountry)}\n className=\"w-28 shrink-0\"\n // Stretched to the number's height: a labelled field is taller than an\n // unlabelled select, and the pair should read as one row.\n selectClassName=\"h-full tabular-nums\"\n >\n {options.map((code) => (\n <option key={code} value={code}>\n {`${code} +${PHONE_DIAL_CODES[code]}`}\n </option>\n ))}\n <option value=\"other\">{text.other}</option>\n </Select>\n {/* Its own flex item, so nothing Input wraps itself in can take the sizing. */}\n <div className=\"min-w-0 flex-1\">\n <Input\n ref={ref}\n {...rest}\n type=\"tel\"\n autoComplete={rest.autoComplete ?? \"tel\"}\n label={label}\n // A FieldHint \"?\" rides the label line; text goes under the whole row.\n hint={caption ? undefined : hint}\n invalid={invalid || showError}\n aria-describedby={mergeDescribedBy(ariaDescribedBy, caption && captionId, showError && errorId)}\n disabled={disabled}\n value={display}\n onChange={handleChange}\n onBlur={(e: FocusEvent<HTMLInputElement>) => {\n setDraft(null);\n onBlur?.(e);\n }}\n inputClassName={cn(\"tabular-nums\", inputClassName)}\n />\n </div>\n </div>\n {caption && (\n <p id={captionId} className={CAPTION_CLASS}>\n {hint}\n </p>\n )}\n {showError && (\n <p id={errorId} className={ERROR_CLASS}>\n {error}\n </p>\n )}\n </div>\n );\n});\nPhoneInput.displayName = \"PhoneInput\";\n"],"mappings":";AAqPQ,SAWI,KAXJ;AArPR,SAAS,YAAY,OAAO,gBAAgB;AAE5C,SAAS,oBAAoB;AAC7B,SAAS,UAAU;AACnB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,YAAY,wBAAwB;AAC7C,SAAS,OAAO,cAA+B;AAaxC,MAAM,6BAA+C;AAAA,EAC1D,aAAa;AAAA,EACb,OAAO;AACT;AAKA,SAAS,oBAAoB,MAA+D;AAC1F,QAAM,SAA2B,aAAa,cAAc,4BAA4B,IAAI;AAC5F,SAAO;AACT;AAMA,MAAM,gBAAgB;AACtB,MAAM,cAAc;AAEpB,MAAM,aAAa,CAAC,SACjB,OAAO,SAAS,YAAY,SAAS,MAAO,OAAO,SAAS;AAG/D,SAAS,aAAa,OAAmC;AACvD,SAAO,OAAO,KAAK,IAAI,WAAW,OAAO,OAAO,IAAI;AACtD;AAGA,SAAS,UAAU,OAAoC;AACrD,QAAM,SAAS,aAAa,KAAK;AACjC,SAAO,SAAU,OAAO,WAAW,UAAW;AAChD;AAIA,SAAS,QAAQ,KAAa,SAA+B;AAC3D,SAAO,WAAW,KAAK,OAAO,GAAG,QAAQ;AAC3C;AAOA,SAAS,UAAU,OAAe,SAA+B;AAC/D,QAAM,SAAS,aAAa,KAAK;AACjC,MAAI,CAAC,OAAQ,QAAO;AACpB,MAAI,OAAO,YAAY,QAAQ,OAAO,YAAY,SAAS;AACzD,WAAO,oBAAoB,OAAO,UAAU,OAAO,OAAO;AAAA,EAC5D;AACA,SAAO,YAAY,OAAO,IAAI;AAChC;AAkEO,MAAM,aAAa,WAA8C,SAASA,YAC/E;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,iBAAiB;AAAA,EACjB,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,oBAAoB;AAAA,EACpB,GAAG;AACL,GACA,KACA;AACA,QAAM,OAAO,oBAAoB,MAAM;AACvC,QAAM,CAAC,SAAS,UAAU,IAAI,SAAuB,MAAM,UAAU,KAAK,KAAK,cAAc;AAI7F,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAwB,IAAI;AAEtD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAwB,IAAI;AAC1D,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,MAAI,SAAS,OAAO;AAClB,YAAQ,KAAK;AACb,QAAI,UAAU,SAAS;AAErB,YAAM,MAAM,UAAU,KAAK;AAC3B,UAAI,IAAK,YAAW,GAAG;AACvB,eAAS,IAAI;AAAA,IACf;AAAA,EACF;AAEA,QAAM,OAAO,CAAC,SAAiB;AAC7B,eAAW,IAAI;AACf,oBAAgB,IAAI;AAAA,EACtB;AAEA,QAAM,eAAe,CAAC,MAAqC;AACzD,eAAW,CAAC;AACZ,UAAM,MAAM,EAAE,OAAO;AACrB,aAAS,GAAG;AACZ,UAAM,SAAS,WAAW,KAAK,OAAO;AAEtC,QAAI,UAAU,OAAO,YAAY,QAAS,YAAW,OAAO,WAAW,OAAO;AAC9E,SAAK,QAAQ,QAAQ,GAAG;AAAA,EAC1B;AAEA,QAAM,gBAAgB,CAAC,SAAuB;AAC5C,eAAW,IAAI;AACf,UAAM,SAAS,aAAa,KAAK;AACjC,QAAI,QAAQ;AAGV,UAAI,SAAS,WAAW,OAAO,YAAY,QAAQ,OAAO,YAAY,MAAM;AAC1E,iBAAS,IAAI;AACb;AAAA,MACF;AAGA,WAAK,WAAW,OAAO,UAAU,IAAI,GAAG,QAAQ,OAAO,QAAQ;AAC/D,eAAS,IAAI;AACb;AAAA,IACF;AAEA,UAAM,SAAS,SAAS;AACxB,UAAM,SAAS,OAAO,KAAK,MAAM,KAAK,OAAO,WAAW,QAAQ,IAAI;AACpE,QAAI,UAAU,OAAO,SAAS,OAAO;AACnC,eAAS,MAAM;AACf,WAAK,OAAO,IAAI;AAAA,IAClB;AAAA,EACF;AAEA,QAAM,UAAU,UAAU,QAAQ,QAAQ,OAAO,OAAO,MAAM,QAAQ,QAAQ,UAAU,OAAO,OAAO;AAItG,QAAM,UAA8B,CAAC,GAAG,SAAS;AACjD,MAAI,YAAY,WAAW,CAAC,QAAQ,SAAS,OAAO,EAAG,SAAQ,KAAK,OAAO;AAE3E,QAAM,YAAY,MAAM;AACxB,QAAM,UAAU,MAAM;AACtB,QAAM,UAAU,WAAW,IAAI;AAC/B,QAAM,YAAY,WAAW,KAAK;AAClC,QAAM,QAAQ,OAAO,UAAU,YAAY,UAAU;AAErD,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,MAAM,QAAQ,UAAU;AAAA,MACxB,cAAY,QAAQ,QAAQ;AAAA,MAE5B;AAAA,6BAAC,SAAI,WAAU,4BACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,cAAY,KAAK;AAAA,cACjB,OAAO;AAAA,cACP;AAAA,cACA,UAAU,CAAC,MAAM,cAAc,EAAE,OAAO,KAAqB;AAAA,cAC7D,WAAU;AAAA,cAGV,iBAAgB;AAAA,cAEf;AAAA,wBAAQ,IAAI,CAAC,SACZ,oBAAC,YAAkB,OAAO,MACvB,aAAG,IAAI,KAAK,iBAAiB,IAAI,CAAC,MADxB,IAEb,CACD;AAAA,gBACD,oBAAC,YAAO,OAAM,SAAS,eAAK,OAAM;AAAA;AAAA;AAAA,UACpC;AAAA,UAEA,oBAAC,SAAI,WAAU,kBACb;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACC,GAAG;AAAA,cACJ,MAAK;AAAA,cACL,cAAc,KAAK,gBAAgB;AAAA,cACnC;AAAA,cAEA,MAAM,UAAU,SAAY;AAAA,cAC5B,SAAS,WAAW;AAAA,cACpB,oBAAkB,iBAAiB,iBAAiB,WAAW,WAAW,aAAa,OAAO;AAAA,cAC9F;AAAA,cACA,OAAO;AAAA,cACP,UAAU;AAAA,cACV,QAAQ,CAAC,MAAoC;AAC3C,yBAAS,IAAI;AACb,yBAAS,CAAC;AAAA,cACZ;AAAA,cACA,gBAAgB,GAAG,gBAAgB,cAAc;AAAA;AAAA,UACnD,GACF;AAAA,WACF;AAAA,QACC,WACC,oBAAC,OAAE,IAAI,WAAW,WAAW,eAC1B,gBACH;AAAA,QAED,aACC,oBAAC,OAAE,IAAI,SAAS,WAAW,aACxB,iBACH;AAAA;AAAA;AAAA,EAEJ;AAEJ,CAAC;AACD,WAAW,cAAc;","names":["PhoneInput"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/phone-input.tsx"],"sourcesContent":["import { forwardRef, useId, useState } from \"react\";\nimport type { ChangeEvent, FocusEvent } from \"react\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport {\n PHONE_COUNTRIES,\n PHONE_DIAL_CODES,\n formatNationalPhone,\n formatPhone,\n isE164,\n parsePhone,\n} from \"../lib/phone\";\nimport type { ParsedPhone, PhoneCountry, PhoneCountryCode } from \"../lib/phone\";\nimport { hasMessage, mergeDescribedBy } from \"./choice-parts\";\nimport { Input, Select, type InputProps } from \"./ui\";\n\n// ── Labels ────────────────────────────────────────────────────────────────────\n\n/** The words {@link PhoneInput} renders on its own behalf. */\nexport interface PhoneInputLabels {\n /** The country-code select's accessible name. */\n countryCode: string;\n /** The select's last option: a number from a country not listed, typed with its\n * own \"+\". */\n other: string;\n}\n\nexport const DEFAULT_PHONE_INPUT_LABELS: PhoneInputLabels = {\n countryCode: \"Country code\",\n other: \"Other\",\n};\n\n/**\n * The `phoneInput` namespace: prop > `<UiKitProvider labels>` > English (0.22.0).\n */\nfunction usePhoneInputLabels(prop: Partial<PhoneInputLabels> | undefined): PhoneInputLabels {\n const labels: PhoneInputLabels = useKitLabels(\"phoneInput\", DEFAULT_PHONE_INPUT_LABELS, prop);\n return labels;\n}\n\n// ── Helpers ───────────────────────────────────────────────────────────────────\n\n/** The caption and the error under the row — the type every kit field uses for them\n * (Select's caption, Input's `error`). */\nconst CAPTION_CLASS = \"mt-1 text-caption leading-tight text-[var(--text-muted)]\";\nconst ERROR_CLASS = \"mt-1 text-caption leading-tight text-[var(--danger)]\";\n\nconst isTextHint = (hint: unknown): hint is string | number =>\n (typeof hint === \"string\" && hint !== \"\") || typeof hint === \"number\";\n\n/** A stored value read back: an E.164 number these rules can read, else `null`. */\nfunction storedNumber(value: string): ParsedPhone | null {\n return isE164(value) ? parsePhone(value, \"other\") : null;\n}\n\n/** The select's country for a stored E.164 value, or `null` for free text. */\nfunction countryOf(value: string): PhoneCountry | null {\n const parsed = storedNumber(value);\n return parsed ? (parsed.country ?? \"other\") : null;\n}\n\n/** What the field emits for typed text: E.164 when it reads, the text unchanged when\n * it does not. */\nfunction emitFor(raw: string, country: PhoneCountry): string {\n return parsePhone(raw, country)?.e164 ?? raw;\n}\n\n/**\n * The text a stored value shows, outside of typing. Under its own country: the national\n * number grouped (\"21 000 00 01\" beside \"CH +41\"). Under another (a \"+33\" number while\n * the select says \"Other\"): the whole international number. Free text: as it is.\n */\nfunction displayOf(value: string, country: PhoneCountry): string {\n const parsed = storedNumber(value);\n if (!parsed) return value;\n if (parsed.country !== null && parsed.country === country) {\n return formatNationalPhone(parsed.national, parsed.country);\n }\n return formatPhone(parsed.e164);\n}\n\n// ── Component ─────────────────────────────────────────────────────────────────\n\nexport interface PhoneInputProps extends Omit<InputProps, \"type\" | \"value\" | \"defaultValue\"> {\n /** The number: E.164 (\"+41210000001\") for one the field could read, or free text —\n * a value saved before the field existed is shown exactly as it is. `\"\"` is no\n * number. */\n value: string;\n /** On every change: E.164 when the typed text reads as a number of the selected\n * country (or carries its own \"+\"), otherwise the typed text UNCHANGED. Nothing is\n * refused — see {@link PhoneInput}. Plain `onChange` still fires with the raw event;\n * bind this one to a form. */\n onValueChange?: (value: string) => void;\n /** The country a number typed without \"+\" belongs to, until the value says\n * otherwise. Default `\"CH\"`. */\n defaultCountry?: PhoneCountry;\n /** The countries the select offers, in order; \"Other\" always follows. Default\n * {@link PHONE_COUNTRIES} (CH, LI, DE, AT, FR, IT). A stored number from a country\n * left out still shows under its own code. */\n countries?: readonly PhoneCountryCode[];\n /** See {@link PhoneInputLabels}. */\n labels?: Partial<PhoneInputLabels>;\n /** Classes for the whole field — the select, the input and the lines under them.\n * `inputClassName` reaches the `<input>`. */\n className?: string;\n}\n\n/**\n * A phone number field (kastlan): a country-code select and the number, stored as\n * E.164 and shown grouped.\n *\n * kastlan's contacts kept whatever was typed — \"079 …\", \"+41 (0)79 …\", \"0041 79 …\" —\n * which nobody could dial from the app or match against the next import. This field\n * turns all of those into one stored form, \"+41210000001\", without taking anything\n * away from the person typing:\n *\n * - **A country to type against.** The select (default CH, then LI, DE, AT, FR, IT\n * and \"Other\") says which country a number without \"+\" belongs to; the national\n * trunk 0 may be typed or left out. Typing a \"+\" or \"00\" overrides it, and the\n * select follows the number (\"+49 …\" switches it to DE). Changing the select moves\n * the national number to the new country code.\n * - **E.164 when it reads, the text when it does not.** A number the rules read is\n * emitted as E.164; anything else (\"ask reception\", an extension) is emitted exactly\n * as typed. Never refused, never an error of its own: a contact's number is often\n * known only roughly, and a field that will not save \"079 … (mornings)\" makes\n * people type it into the notes instead. An app that needs a dialable number\n * checks {@link isE164} on submit.\n * - **Existing values untouched.** A free-text value from before is shown exactly as\n * it is, and normalised only when someone edits it — the field never rewrites a\n * record just by being opened.\n * - **Grouped when settled.** While typing the field shows what was typed; on blur a\n * number it read is regrouped by its country (\"21 000 00 01\").\n *\n * **The limitation** — there is no libphonenumber behind this (≈150 kB of metadata in\n * every bundle): the six countries have light rules (the trunk 0, a plausible length)\n * and every other country is any \"+\" with 8–15 digits. A well-formed number that does\n * not exist passes, and German and Austrian landlines, whose area codes vary in length,\n * are grouped only where the split is known (mobile prefixes, the largest cities) and\n * otherwise shown as one block. See `lib/phone.ts`.\n *\n * `label`, `hint`, `error` and `invalid` read as on {@link Input}: the label floats in\n * the number's box, and a text hint and the error go under the whole row, attached to\n * the number with `aria-describedby`. The select is named by `labels.countryCode`, and\n * the pair is a group named by a string `label`.\n */\nexport const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput(\n {\n value,\n onValueChange,\n onChange,\n onBlur,\n defaultCountry = \"CH\",\n countries = PHONE_COUNTRIES,\n labels,\n className,\n inputClassName,\n label,\n hint,\n error,\n invalid,\n disabled,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n },\n ref,\n) {\n const text = usePhoneInputLabels(labels);\n const [country, setCountry] = useState<PhoneCountry>(() => countryOf(value) ?? defaultCountry);\n // What was typed, shown for as long as it still reads as the value (AmountInput's\n // draft): the person's own spacing stays under their fingers, and is regrouped on\n // blur.\n const [draft, setDraft] = useState<string | null>(null);\n // The last value this field emitted, to tell its own echo from a value set outside.\n const [emitted, setEmitted] = useState<string | null>(null);\n const [seen, setSeen] = useState(value);\n if (seen !== value) {\n setSeen(value);\n if (value !== emitted) {\n // Set from outside (a reset, another record): its own country, none of our text.\n const own = countryOf(value);\n if (own) setCountry(own);\n setDraft(null);\n }\n }\n\n const emit = (next: string) => {\n setEmitted(next);\n onValueChange?.(next);\n };\n\n const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n onChange?.(e);\n const raw = e.target.value;\n setDraft(raw);\n const parsed = parsePhone(raw, country);\n // A number typed with its own \"+\" takes the select with it.\n if (parsed && parsed.country !== country) setCountry(parsed.country ?? \"other\");\n emit(parsed?.e164 ?? raw);\n };\n\n const handleCountry = (next: PhoneCountry) => {\n setCountry(next);\n const stored = storedNumber(value);\n if (stored) {\n // \"Other\", the same country, or a generic number with its own \"+\": the number\n // stays, only the way it is shown changes.\n if (next === \"other\" || stored.country === null || stored.country === next) {\n setDraft(null);\n return;\n }\n // A different listed country: the national number moves to its code. If it does\n // not read there, the digits are kept as text for the person to finish.\n emit(parsePhone(stored.national, next)?.e164 ?? stored.national);\n setDraft(null);\n return;\n }\n // Free text (or a draft): read it again under the new country.\n const source = draft ?? value;\n const parsed = source.trim() === \"\" ? null : parsePhone(source, next);\n if (parsed && parsed.e164 !== value) {\n setDraft(source);\n emit(parsed.e164);\n }\n };\n\n const display = draft !== null && emitFor(draft, country) === value ? draft : displayOf(value, country);\n\n // The select offers the configured countries, plus the stored number's own if it\n // is not among them — a select cannot show a value it has no option for.\n const options: PhoneCountryCode[] = [...countries];\n if (country !== \"other\" && !options.includes(country)) options.push(country);\n\n const captionId = useId();\n const errorId = useId();\n const caption = isTextHint(hint);\n const showError = hasMessage(error);\n const named = typeof label === \"string\" && label !== \"\";\n\n return (\n <div\n className={className}\n role={named ? \"group\" : undefined}\n aria-label={named ? label : undefined}\n >\n {/* On a phone at Large the number takes a line of its own under the country (0.32.1,\n the 360 px Extra-large sweep; docs/text-size-harmonization.md §4 \"nothing\n truncates\"): the 7rem select is 168 px at 150 %, and on a 360 px phone it left\n the number 82 px — \"Ph…\" over \"021 (\". `large:max-sm:`, the scaled phone width,\n so a wider screen at Large and Normal keep the one row. */}\n <div className=\"flex items-stretch gap-2 large:max-sm:flex-wrap\">\n <Select\n aria-label={text.countryCode}\n value={country}\n disabled={disabled}\n onChange={(e) => handleCountry(e.target.value as PhoneCountry)}\n className=\"w-28 shrink-0\"\n // Stretched to the number's height: a labelled field is taller than an\n // unlabelled select, and the pair should read as one row.\n selectClassName=\"h-full tabular-nums\"\n >\n {options.map((code) => (\n <option key={code} value={code}>\n {`${code} +${PHONE_DIAL_CODES[code]}`}\n </option>\n ))}\n <option value=\"other\">{text.other}</option>\n </Select>\n {/* Its own flex item, so nothing Input wraps itself in can take the sizing. */}\n <div className=\"min-w-0 flex-1 large:max-sm:basis-full\">\n <Input\n ref={ref}\n {...rest}\n type=\"tel\"\n autoComplete={rest.autoComplete ?? \"tel\"}\n label={label}\n // A FieldHint \"?\" rides the label line; text goes under the whole row.\n hint={caption ? undefined : hint}\n invalid={invalid || showError}\n aria-describedby={mergeDescribedBy(ariaDescribedBy, caption && captionId, showError && errorId)}\n disabled={disabled}\n value={display}\n onChange={handleChange}\n onBlur={(e: FocusEvent<HTMLInputElement>) => {\n setDraft(null);\n onBlur?.(e);\n }}\n inputClassName={cn(\"tabular-nums\", inputClassName)}\n />\n </div>\n </div>\n {caption && (\n <p id={captionId} className={CAPTION_CLASS}>\n {hint}\n </p>\n )}\n {showError && (\n <p id={errorId} className={ERROR_CLASS}>\n {error}\n </p>\n )}\n </div>\n );\n});\nPhoneInput.displayName = \"PhoneInput\";\n"],"mappings":"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","names":["PhoneInput"]}
|
|
@@ -81,6 +81,6 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<"div">, "children">
|
|
|
81
81
|
* Only Tab is contained. A popover that took the page away as well would be a
|
|
82
82
|
* {@link Modal}, and there is one of those.
|
|
83
83
|
*/
|
|
84
|
-
declare function Popover({ trigger, children, width, labels, panelId, className, style, "aria-label": ariaLabel, ...rest }: PopoverProps): react.JSX.Element;
|
|
84
|
+
declare function Popover({ trigger, children, width, labels, panelId, className, style, "aria-label": ariaLabel, onKeyDown: onKeyDownProp, ...rest }: PopoverProps): react.JSX.Element;
|
|
85
85
|
|
|
86
86
|
export { DEFAULT_POPOVER_LABELS, Popover, type PopoverLabels, type PopoverProps };
|
|
@@ -26,6 +26,7 @@ function Popover({
|
|
|
26
26
|
className,
|
|
27
27
|
style,
|
|
28
28
|
"aria-label": ariaLabel,
|
|
29
|
+
onKeyDown: onKeyDownProp,
|
|
29
30
|
...rest
|
|
30
31
|
}) {
|
|
31
32
|
const triggerRef = useRef(null);
|
|
@@ -65,6 +66,7 @@ function Popover({
|
|
|
65
66
|
}
|
|
66
67
|
});
|
|
67
68
|
const handleKeyDown = (e) => {
|
|
69
|
+
onKeyDownProp?.(e);
|
|
68
70
|
if (e.key !== "Escape") return;
|
|
69
71
|
e.stopPropagation();
|
|
70
72
|
close();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/popover.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useAnchoredPanel } from \"../hooks/use-anchored-panel\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { lengthPx, useRemPx, type RemLength } from \"../hooks/use-breakpoint\";\nimport { cn } from \"../lib/cn\";\nimport { dirOf } from \"../lib/direction\";\nimport type { Direction } from \"../lib/direction\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/** The panel's accessible name. A `role=\"dialog\"` with no name announces as \"dialog\"\n * and nothing else, which is worse than the unlabelled div it replaced — so it is a\n * prop with an English default, like every other string this package renders. Callers\n * should pass the control's own name (\"Filter\", \"Choose a date\"); the default is only\n * there so an unmigrated call site is still named. */\nexport interface PopoverLabels {\n panel: string;\n}\n\n// Exported since the kit grew one label tree (`UiKitLabels`, src/i18n): the complete\n// English reference a translator works from has to be able to name every namespace.\nexport const DEFAULT_POPOVER_LABELS: PopoverLabels = {\n // \"Pop-up\", not \"Popover\": the panel's accessible name is read to USERS, and\n // \"popover\" is a developer's word for it (reported by keksdose, 0.5.0). Only a\n // bare Popover falls back to this — the kit's own pickers name their panels.\n panel: \"Pop-up\",\n};\n\n/**\n * Exported, and extending the `<div>` attributes, so a consumer wrapping this — which is\n * what `DateField` and `FilterPopover` in this very package do — can hand anything\n * through to the PANEL: a `data-tour` anchor for the kit's own guided tour, a test id,\n * an `aria-describedby`, or an `aria-label` naming this particular popover.\n *\n * `children` is omitted from those attributes rather than inherited: here it is a render\n * prop taking the panel's `close`, not a `ReactNode`, so the two cannot be reconciled.\n */\nexport interface PopoverProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n trigger: (state: { open: boolean; toggle: () => void; ref: RefObject<HTMLButtonElement | null> }) => ReactNode;\n children: (close: () => void) => ReactNode;\n /**\n * The panel's width: `\"18rem\"` (the default) grows with the text size — the panel\n * holds text, and a 288 px calendar at 150 % cut its own month off (0.32,\n * docs/text-size-harmonization.md §3.2). A plain number is px, fixed, as before.\n * Either way the panel is never wider than the viewport less 8 px a side: 18rem is\n * 432 px at Extra large, more than a 360 px phone has.\n */\n width?: number | RemLength;\n labels?: Partial<PopoverLabels>;\n /** Id for the panel, so a trigger wearing `role=\"combobox\"` can `aria-controls` it.\n * Required by ARIA on that role, and the panel is rendered here rather than by the\n * caller, so the id has to come in from outside. */\n panelId?: string;\n /** Extra classes for the PANEL. The trigger is the caller's own element. */\n className?: string;\n}\n\n/** 288 px at Normal, in rem so it follows the text size (§3.2). */\nconst POPOVER_WIDTH: RemLength = \"18rem\";\n\n/** The gap the panel keeps from either edge of the viewport, in px. */\nconst EDGE = 8;\n\n/**\n * Portal-rendered popover anchored under its trigger button. The panel is fixed\n * to the viewport (so table/overflow ancestors can't clip it) and re-aligns on\n * scroll/resize; it closes on outside-click and Escape.\n *\n * ## Trapped, not merely focused — and why, because it is the closer call\n *\n * The audit's finding was that this panel *\"portals out of the tab order with no role\n * and no focus move\"*: Tab from the trigger went straight past the panel into the page\n * behind it, so the calendar, the calculator keypad and every column filter in the kit\n * were mouse-only. Two shapes fix that, and a popover is not automatically a dialog, so\n * this was chosen rather than inherited:\n *\n * - **The light one** — move focus in on open, hand it back on close, and let Tab walk\n * OUT of the panel into whatever follows the trigger. That is the right shape for a\n * non-modal popover sitting inline in the document.\n * - **The trap** — Tab cycles inside the panel; Escape and a click outside are the\n * ways out. What is here.\n *\n * The deciding fact is the portal. This panel is rendered to the END of `<body>`, not\n * beside its trigger, so \"let Tab walk out\" does not mean \"carry on from the trigger\" —\n * it means land at the end of the document, several hundred controls from where the\n * user was. Tabbing out of it would be a worse place to arrive than the one the finding\n * complains about, and fixing that properly needs a sentinel-and-return dance that\n * nothing in this package has. The trap gets the user back to the trigger every time,\n * because `useFocusTrap` restores focus on close.\n *\n * And the panels this kit actually opens are self-contained: pick a date, tap out a\n * sum, narrow a column. Each of them commits and closes, and each already has two\n * documented ways out wired above — Escape and an outside press — so containment costs\n * a keyboard user nothing they had.\n *\n * **It stays non-modal in every other respect**, deliberately: no backdrop, no body\n * scroll lock, and no `aria-modal`, so the page behind is still readable by a screen\n * reader, still scrollable, and still clickable — clicking it is what dismisses this.\n * Only Tab is contained. A popover that took the page away as well would be a\n * {@link Modal}, and there is one of those.\n */\nexport function Popover({\n trigger,\n children,\n width = POPOVER_WIDTH,\n labels,\n panelId,\n className,\n style,\n \"aria-label\": ariaLabel,\n ...rest\n}: PopoverProps) {\n const triggerRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [open, setOpen] = useState(false);\n // The trigger's reading direction, read when it is pressed. The panel is portalled to\n // <body>, out of any `dir=\"rtl\"` subtree the trigger sits in, so without carrying it\n // across a right-to-left calendar or filter rendered left-to-right.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const close = () => setOpen(false);\n const toggle = () => {\n setDir(dirOf(triggerRef.current));\n setOpen((v) => !v);\n };\n // prop > `<UiKitProvider labels={{ popover }}>` > English. The provider's\n // `popover.panel` is only the last-resort name for a panel no caller named.\n const text = useKitLabels(\"popover\", DEFAULT_POPOVER_LABELS, labels);\n\n // Align the panel's END edge to the trigger's — right in LTR, left in RTL; it was\n // always `rect.right`, so an RTL panel hung off the wrong side — and clamp it\n // horizontally; the\n // hook owns the vertical half — below by default, flipped above (and height-capped)\n // when that is where the room is, e.g. once a mobile keyboard has eaten the bottom\n // of the screen (feedback #135). It re-measures on scroll/resize.\n const placement = useAnchoredPanel(triggerRef, open);\n const rect = placement.rect;\n // In px for the arithmetic below, at the text size in force; capped at the viewport.\n const remPx = useRemPx();\n const panelWidth = rect\n ? Math.min(lengthPx(width, remPx), Math.max(0, window.innerWidth - 2 * EDGE))\n : 0;\n const pos = rect\n ? {\n top: placement.top,\n left: Math.min(\n Math.max(EDGE, dir === \"rtl\" ? rect.left : rect.right - panelWidth),\n window.innerWidth - panelWidth - EDGE,\n ),\n }\n : null;\n\n useOutsideClick([triggerRef, panelRef], close, open);\n useEscapeKey(close, open);\n\n useFocusTrap(panelRef, {\n active: open && pos !== null,\n // The panel keeps a focus one of its own children has already taken. `FilterPopover`\n // autofocuses its text box on mount — that is what the press on \"Filter\" was FOR,\n // and it is a prop there precisely so the popover keeps it — and a trap that\n // unconditionally focused the container would take it straight back off again,\n // one effect later and invisibly.\n initialFocus: () => {\n const panel = panelRef.current;\n const active = document.activeElement;\n return panel?.contains(active) ? (active as HTMLElement) : panel;\n },\n });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // `useEscapeKey` above listens on the document and still covers the case where\n // focus somehow is not in here. This handler is for the case that focus IS, which\n // is now the normal one — and it exists to STOP the key, because a popover opened\n // from inside a `Modal` (the calculator on an amount field is exactly that) would\n // otherwise have its Escape travel on up the React tree and close the dialog\n // underneath as well. Closing twice is harmless — `close` is a state setter, not a\n // caller's callback — so the two paths cannot double-fire anything.\n e.stopPropagation();\n close();\n };\n\n return (\n <>\n {trigger({ open, toggle, ref: triggerRef })}\n {open && pos &&\n createPortal(\n /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the\n dialog container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for\n whatever inside it holds focus. */\n <div\n // `...rest` first: the role, the id the trigger's `aria-controls` points at,\n // the `tabIndex` the trap needs and the measured position are what make this\n // a popover, and a caller reaching for a `data-tour` anchor must not be able\n // to remove one of them by accident.\n {...rest}\n ref={panelRef}\n role=\"dialog\"\n id={panelId}\n // The caller's own `aria-label` wins over {@link PopoverLabels.panel}, which\n // is the package's English fallback for an unnamed panel. Both name the same\n // element; the DOM spelling is the one being standardised on, so where both\n // are present it is the one that survives.\n aria-label={ariaLabel ?? text.panel}\n dir={rest.dir ?? dir}\n // Without it the panel cannot take focus at all, and the trap above would\n // silently do nothing on a panel whose children are not yet tabbable.\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n style={{\n // Merged over the caller's, never replaced by it: these four are measured\n // against the trigger every scroll and resize, and a panel that dropped\n // them would paint at the top-left of the viewport.\n ...style,\n position: \"fixed\",\n top: pos.top,\n left: pos.left,\n width: panelWidth,\n maxHeight: placement.maxHeight,\n }}\n className={cn(\n \"z-50 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-3 shadow-lg outline-none\",\n className,\n )}\n >\n {children(close)}\n </div>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":";AAwLI,mBAOM,KAPN;AAxLJ,SAAS,QAAQ,gBAAgB;AAEjC,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AACjC,SAAS,cAAc,uBAAuB;AAC9C,SAAS,oBAAoB;AAC7B,SAAS,UAAU,gBAAgC;AACnD,SAAS,UAAU;AACnB,SAAS,aAAa;AAEtB,SAAS,oBAAoB;AAatB,MAAM,yBAAwC;AAAA;AAAA;AAAA;AAAA,EAInD,OAAO;AACT;AAgCA,MAAM,gBAA2B;AAGjC,MAAM,OAAO;AAwCN,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,GAAG;AACL,GAAiB;AACf,QAAM,aAAa,OAA0B,IAAI;AACjD,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AAItC,QAAM,CAAC,KAAK,MAAM,IAAI,SAAoB,KAAK;AAC/C,QAAM,QAAQ,MAAM,QAAQ,KAAK;AACjC,QAAM,SAAS,MAAM;AACnB,WAAO,MAAM,WAAW,OAAO,CAAC;AAChC,YAAQ,CAAC,MAAM,CAAC,CAAC;AAAA,EACnB;AAGA,QAAM,OAAO,aAAa,WAAW,wBAAwB,MAAM;AAQnE,QAAM,YAAY,iBAAiB,YAAY,IAAI;AACnD,QAAM,OAAO,UAAU;AAEvB,QAAM,QAAQ,SAAS;AACvB,QAAM,aAAa,OACf,KAAK,IAAI,SAAS,OAAO,KAAK,GAAG,KAAK,IAAI,GAAG,OAAO,aAAa,IAAI,IAAI,CAAC,IAC1E;AACJ,QAAM,MAAM,OACR;AAAA,IACE,KAAK,UAAU;AAAA,IACf,MAAM,KAAK;AAAA,MACT,KAAK,IAAI,MAAM,QAAQ,QAAQ,KAAK,OAAO,KAAK,QAAQ,UAAU;AAAA,MAClE,OAAO,aAAa,aAAa;AAAA,IACnC;AAAA,EACF,IACA;AAEJ,kBAAgB,CAAC,YAAY,QAAQ,GAAG,OAAO,IAAI;AACnD,eAAa,OAAO,IAAI;AAExB,eAAa,UAAU;AAAA,IACrB,QAAQ,QAAQ,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMxB,cAAc,MAAM;AAClB,YAAM,QAAQ,SAAS;AACvB,YAAM,SAAS,SAAS;AACxB,aAAO,OAAO,SAAS,MAAM,IAAK,SAAyB;AAAA,IAC7D;AAAA,EACF,CAAC;AAED,QAAM,gBAAgB,CAAC,MAAqC;AAC1D,QAAI,EAAE,QAAQ,SAAU;AAQxB,MAAE,gBAAgB;AAClB,UAAM;AAAA,EACR;AAEA,SACE,iCACG;AAAA,YAAQ,EAAE,MAAM,QAAQ,KAAK,WAAW,CAAC;AAAA,IACzC,QAAQ,OACP;AAAA;AAAA;AAAA;AAAA,MAIE;AAAA,QAAC;AAAA;AAAA,UAKE,GAAG;AAAA,UACJ,KAAK;AAAA,UACL,MAAK;AAAA,UACL,IAAI;AAAA,UAKJ,cAAY,aAAa,KAAK;AAAA,UAC9B,KAAK,KAAK,OAAO;AAAA,UAGjB,UAAU;AAAA,UACV,WAAW;AAAA,UACX,OAAO;AAAA;AAAA;AAAA;AAAA,YAIL,GAAG;AAAA,YACH,UAAU;AAAA,YACV,KAAK,IAAI;AAAA,YACT,MAAM,IAAI;AAAA,YACV,OAAO;AAAA,YACP,WAAW,UAAU;AAAA,UACvB;AAAA,UACA,WAAW;AAAA,YACT;AAAA,YACA;AAAA,UACF;AAAA,UAEC,mBAAS,KAAK;AAAA;AAAA,MACjB;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/popover.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useAnchoredPanel } from \"../hooks/use-anchored-panel\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { lengthPx, useRemPx, type RemLength } from \"../hooks/use-breakpoint\";\nimport { cn } from \"../lib/cn\";\nimport { dirOf } from \"../lib/direction\";\nimport type { Direction } from \"../lib/direction\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/** The panel's accessible name. A `role=\"dialog\"` with no name announces as \"dialog\"\n * and nothing else, which is worse than the unlabelled div it replaced — so it is a\n * prop with an English default, like every other string this package renders. Callers\n * should pass the control's own name (\"Filter\", \"Choose a date\"); the default is only\n * there so an unmigrated call site is still named. */\nexport interface PopoverLabels {\n panel: string;\n}\n\n// Exported since the kit grew one label tree (`UiKitLabels`, src/i18n): the complete\n// English reference a translator works from has to be able to name every namespace.\nexport const DEFAULT_POPOVER_LABELS: PopoverLabels = {\n // \"Pop-up\", not \"Popover\": the panel's accessible name is read to USERS, and\n // \"popover\" is a developer's word for it (reported by keksdose, 0.5.0). Only a\n // bare Popover falls back to this — the kit's own pickers name their panels.\n panel: \"Pop-up\",\n};\n\n/**\n * Exported, and extending the `<div>` attributes, so a consumer wrapping this — which is\n * what `DateField` and `FilterPopover` in this very package do — can hand anything\n * through to the PANEL: a `data-tour` anchor for the kit's own guided tour, a test id,\n * an `aria-describedby`, or an `aria-label` naming this particular popover.\n *\n * `children` is omitted from those attributes rather than inherited: here it is a render\n * prop taking the panel's `close`, not a `ReactNode`, so the two cannot be reconciled.\n */\nexport interface PopoverProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n trigger: (state: { open: boolean; toggle: () => void; ref: RefObject<HTMLButtonElement | null> }) => ReactNode;\n children: (close: () => void) => ReactNode;\n /**\n * The panel's width: `\"18rem\"` (the default) grows with the text size — the panel\n * holds text, and a 288 px calendar at 150 % cut its own month off (0.32,\n * docs/text-size-harmonization.md §3.2). A plain number is px, fixed, as before.\n * Either way the panel is never wider than the viewport less 8 px a side: 18rem is\n * 432 px at Extra large, more than a 360 px phone has.\n */\n width?: number | RemLength;\n labels?: Partial<PopoverLabels>;\n /** Id for the panel, so a trigger wearing `role=\"combobox\"` can `aria-controls` it.\n * Required by ARIA on that role, and the panel is rendered here rather than by the\n * caller, so the id has to come in from outside. */\n panelId?: string;\n /** Extra classes for the PANEL. The trigger is the caller's own element. */\n className?: string;\n}\n\n/** 288 px at Normal, in rem so it follows the text size (§3.2). */\nconst POPOVER_WIDTH: RemLength = \"18rem\";\n\n/** The gap the panel keeps from either edge of the viewport, in px. */\nconst EDGE = 8;\n\n/**\n * Portal-rendered popover anchored under its trigger button. The panel is fixed\n * to the viewport (so table/overflow ancestors can't clip it) and re-aligns on\n * scroll/resize; it closes on outside-click and Escape.\n *\n * ## Trapped, not merely focused — and why, because it is the closer call\n *\n * The audit's finding was that this panel *\"portals out of the tab order with no role\n * and no focus move\"*: Tab from the trigger went straight past the panel into the page\n * behind it, so the calendar, the calculator keypad and every column filter in the kit\n * were mouse-only. Two shapes fix that, and a popover is not automatically a dialog, so\n * this was chosen rather than inherited:\n *\n * - **The light one** — move focus in on open, hand it back on close, and let Tab walk\n * OUT of the panel into whatever follows the trigger. That is the right shape for a\n * non-modal popover sitting inline in the document.\n * - **The trap** — Tab cycles inside the panel; Escape and a click outside are the\n * ways out. What is here.\n *\n * The deciding fact is the portal. This panel is rendered to the END of `<body>`, not\n * beside its trigger, so \"let Tab walk out\" does not mean \"carry on from the trigger\" —\n * it means land at the end of the document, several hundred controls from where the\n * user was. Tabbing out of it would be a worse place to arrive than the one the finding\n * complains about, and fixing that properly needs a sentinel-and-return dance that\n * nothing in this package has. The trap gets the user back to the trigger every time,\n * because `useFocusTrap` restores focus on close.\n *\n * And the panels this kit actually opens are self-contained: pick a date, tap out a\n * sum, narrow a column. Each of them commits and closes, and each already has two\n * documented ways out wired above — Escape and an outside press — so containment costs\n * a keyboard user nothing they had.\n *\n * **It stays non-modal in every other respect**, deliberately: no backdrop, no body\n * scroll lock, and no `aria-modal`, so the page behind is still readable by a screen\n * reader, still scrollable, and still clickable — clicking it is what dismisses this.\n * Only Tab is contained. A popover that took the page away as well would be a\n * {@link Modal}, and there is one of those.\n */\nexport function Popover({\n trigger,\n children,\n width = POPOVER_WIDTH,\n labels,\n panelId,\n className,\n style,\n \"aria-label\": ariaLabel,\n onKeyDown: onKeyDownProp,\n ...rest\n}: PopoverProps) {\n const triggerRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [open, setOpen] = useState(false);\n // The trigger's reading direction, read when it is pressed. The panel is portalled to\n // <body>, out of any `dir=\"rtl\"` subtree the trigger sits in, so without carrying it\n // across a right-to-left calendar or filter rendered left-to-right.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const close = () => setOpen(false);\n const toggle = () => {\n setDir(dirOf(triggerRef.current));\n setOpen((v) => !v);\n };\n // prop > `<UiKitProvider labels={{ popover }}>` > English. The provider's\n // `popover.panel` is only the last-resort name for a panel no caller named.\n const text = useKitLabels(\"popover\", DEFAULT_POPOVER_LABELS, labels);\n\n // Align the panel's END edge to the trigger's — right in LTR, left in RTL; it was\n // always `rect.right`, so an RTL panel hung off the wrong side — and clamp it\n // horizontally; the\n // hook owns the vertical half — below by default, flipped above (and height-capped)\n // when that is where the room is, e.g. once a mobile keyboard has eaten the bottom\n // of the screen (feedback #135). It re-measures on scroll/resize.\n const placement = useAnchoredPanel(triggerRef, open);\n const rect = placement.rect;\n // In px for the arithmetic below, at the text size in force; capped at the viewport.\n const remPx = useRemPx();\n const panelWidth = rect\n ? Math.min(lengthPx(width, remPx), Math.max(0, window.innerWidth - 2 * EDGE))\n : 0;\n const pos = rect\n ? {\n top: placement.top,\n left: Math.min(\n Math.max(EDGE, dir === \"rtl\" ? rect.left : rect.right - panelWidth),\n window.innerWidth - panelWidth - EDGE,\n ),\n }\n : null;\n\n useOutsideClick([triggerRef, panelRef], close, open);\n useEscapeKey(close, open);\n\n useFocusTrap(panelRef, {\n active: open && pos !== null,\n // The panel keeps a focus one of its own children has already taken. `FilterPopover`\n // autofocuses its text box on mount — that is what the press on \"Filter\" was FOR,\n // and it is a prop there precisely so the popover keeps it — and a trap that\n // unconditionally focused the container would take it straight back off again,\n // one effect later and invisibly.\n initialFocus: () => {\n const panel = panelRef.current;\n const active = document.activeElement;\n return panel?.contains(active) ? (active as HTMLElement) : panel;\n },\n });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // A caller's own handler first (0.32.2: RowActions stops Enter / Space at the panel);\n // it used to be dropped, overwritten by this one.\n onKeyDownProp?.(e);\n if (e.key !== \"Escape\") return;\n // `useEscapeKey` above listens on the document and still covers the case where\n // focus somehow is not in here. This handler is for the case that focus IS, which\n // is now the normal one — and it exists to STOP the key, because a popover opened\n // from inside a `Modal` (the calculator on an amount field is exactly that) would\n // otherwise have its Escape travel on up the React tree and close the dialog\n // underneath as well. Closing twice is harmless — `close` is a state setter, not a\n // caller's callback — so the two paths cannot double-fire anything.\n e.stopPropagation();\n close();\n };\n\n return (\n <>\n {trigger({ open, toggle, ref: triggerRef })}\n {open && pos &&\n createPortal(\n /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the\n dialog container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for\n whatever inside it holds focus. */\n <div\n // `...rest` first: the role, the id the trigger's `aria-controls` points at,\n // the `tabIndex` the trap needs and the measured position are what make this\n // a popover, and a caller reaching for a `data-tour` anchor must not be able\n // to remove one of them by accident.\n {...rest}\n ref={panelRef}\n role=\"dialog\"\n id={panelId}\n // The caller's own `aria-label` wins over {@link PopoverLabels.panel}, which\n // is the package's English fallback for an unnamed panel. Both name the same\n // element; the DOM spelling is the one being standardised on, so where both\n // are present it is the one that survives.\n aria-label={ariaLabel ?? text.panel}\n dir={rest.dir ?? dir}\n // Without it the panel cannot take focus at all, and the trap above would\n // silently do nothing on a panel whose children are not yet tabbable.\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n style={{\n // Merged over the caller's, never replaced by it: these four are measured\n // against the trigger every scroll and resize, and a panel that dropped\n // them would paint at the top-left of the viewport.\n ...style,\n position: \"fixed\",\n top: pos.top,\n left: pos.left,\n width: panelWidth,\n maxHeight: placement.maxHeight,\n }}\n className={cn(\n \"z-50 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-3 shadow-lg outline-none\",\n className,\n )}\n >\n {children(close)}\n </div>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":";AA4LI,mBAOM,KAPN;AA5LJ,SAAS,QAAQ,gBAAgB;AAEjC,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AACjC,SAAS,cAAc,uBAAuB;AAC9C,SAAS,oBAAoB;AAC7B,SAAS,UAAU,gBAAgC;AACnD,SAAS,UAAU;AACnB,SAAS,aAAa;AAEtB,SAAS,oBAAoB;AAatB,MAAM,yBAAwC;AAAA;AAAA;AAAA;AAAA,EAInD,OAAO;AACT;AAgCA,MAAM,gBAA2B;AAGjC,MAAM,OAAO;AAwCN,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,WAAW;AAAA,EACX,GAAG;AACL,GAAiB;AACf,QAAM,aAAa,OAA0B,IAAI;AACjD,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AAItC,QAAM,CAAC,KAAK,MAAM,IAAI,SAAoB,KAAK;AAC/C,QAAM,QAAQ,MAAM,QAAQ,KAAK;AACjC,QAAM,SAAS,MAAM;AACnB,WAAO,MAAM,WAAW,OAAO,CAAC;AAChC,YAAQ,CAAC,MAAM,CAAC,CAAC;AAAA,EACnB;AAGA,QAAM,OAAO,aAAa,WAAW,wBAAwB,MAAM;AAQnE,QAAM,YAAY,iBAAiB,YAAY,IAAI;AACnD,QAAM,OAAO,UAAU;AAEvB,QAAM,QAAQ,SAAS;AACvB,QAAM,aAAa,OACf,KAAK,IAAI,SAAS,OAAO,KAAK,GAAG,KAAK,IAAI,GAAG,OAAO,aAAa,IAAI,IAAI,CAAC,IAC1E;AACJ,QAAM,MAAM,OACR;AAAA,IACE,KAAK,UAAU;AAAA,IACf,MAAM,KAAK;AAAA,MACT,KAAK,IAAI,MAAM,QAAQ,QAAQ,KAAK,OAAO,KAAK,QAAQ,UAAU;AAAA,MAClE,OAAO,aAAa,aAAa;AAAA,IACnC;AAAA,EACF,IACA;AAEJ,kBAAgB,CAAC,YAAY,QAAQ,GAAG,OAAO,IAAI;AACnD,eAAa,OAAO,IAAI;AAExB,eAAa,UAAU;AAAA,IACrB,QAAQ,QAAQ,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMxB,cAAc,MAAM;AAClB,YAAM,QAAQ,SAAS;AACvB,YAAM,SAAS,SAAS;AACxB,aAAO,OAAO,SAAS,MAAM,IAAK,SAAyB;AAAA,IAC7D;AAAA,EACF,CAAC;AAED,QAAM,gBAAgB,CAAC,MAAqC;AAG1D,oBAAgB,CAAC;AACjB,QAAI,EAAE,QAAQ,SAAU;AAQxB,MAAE,gBAAgB;AAClB,UAAM;AAAA,EACR;AAEA,SACE,iCACG;AAAA,YAAQ,EAAE,MAAM,QAAQ,KAAK,WAAW,CAAC;AAAA,IACzC,QAAQ,OACP;AAAA;AAAA;AAAA;AAAA,MAIE;AAAA,QAAC;AAAA;AAAA,UAKE,GAAG;AAAA,UACJ,KAAK;AAAA,UACL,MAAK;AAAA,UACL,IAAI;AAAA,UAKJ,cAAY,aAAa,KAAK;AAAA,UAC9B,KAAK,KAAK,OAAO;AAAA,UAGjB,UAAU;AAAA,UACV,WAAW;AAAA,UACX,OAAO;AAAA;AAAA;AAAA;AAAA,YAIL,GAAG;AAAA,YACH,UAAU;AAAA,YACV,KAAK,IAAI;AAAA,YACT,MAAM,IAAI;AAAA,YACV,OAAO;AAAA,YACP,WAAW,UAAU;AAAA,UACvB;AAAA,UACA,WAAW;AAAA,YACT;AAAA,YACA;AAAA,UACF;AAAA,UAEC,mBAAS,KAAK;AAAA;AAAA,MACjB;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import 'react';
|
|
2
2
|
import 'lucide-react';
|
|
3
|
-
export { cO as DEFAULT_ROW_ACTIONS_LABELS, eO as RowAction, eP as RowActionList, eQ as RowActions, eR as RowActionsCollapse, eS as RowActionsColumnOptions, eT as RowActionsLabels, eU as RowActionsProps, eV as RowActionsSize, fY as rowActionsColumn } from '../delete-account-setting-
|
|
3
|
+
export { cO as DEFAULT_ROW_ACTIONS_LABELS, eO as RowAction, eP as RowActionList, eQ as RowActions, eR as RowActionsCollapse, eS as RowActionsColumnOptions, eT as RowActionsLabels, eU as RowActionsProps, eV as RowActionsSize, fY as rowActionsColumn } from '../delete-account-setting-Dy4kJgUm.js';
|
|
4
4
|
import '../data-table-labels-kpAAVi6g.js';
|
|
5
5
|
import './tooltip.js';
|
|
6
6
|
import '../hooks/use-anchored-rect.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { isValidElement } from "react";
|
|
4
4
|
import { MoreHorizontal } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn.js";
|
|
@@ -7,6 +7,7 @@ import { useLargeText } from "../hooks/use-large-text.js";
|
|
|
7
7
|
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
8
8
|
import { Popover } from "./popover.js";
|
|
9
9
|
import { Button, CompactControls, IconButton } from "./ui.js";
|
|
10
|
+
import { useCommitReason } from "./write-lock.js";
|
|
10
11
|
const DEFAULT_ROW_ACTIONS_LABELS = {
|
|
11
12
|
actions: "Actions",
|
|
12
13
|
actionsFor: (name) => `Actions for ${name}`
|
|
@@ -62,72 +63,131 @@ function RowActions({
|
|
|
62
63
|
children: /* @__PURE__ */ jsx(MoreHorizontal, {})
|
|
63
64
|
}
|
|
64
65
|
),
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
66
|
+
onClick: stopClick,
|
|
67
|
+
onKeyDown: stopActivationKeys,
|
|
68
|
+
children: (close) => /* @__PURE__ */ jsx("ul", { "data-slot": "row-actions-menu", className: "space-y-0.5", children: entries.map((action) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(MenuAction, { action, close }) }, action.key ?? action.label)) })
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
function stopClick(e) {
|
|
73
|
+
e.stopPropagation();
|
|
74
|
+
}
|
|
75
|
+
function stopActivationKeys(e) {
|
|
76
|
+
if (e.key === "Enter" || e.key === " ") e.stopPropagation();
|
|
77
|
+
}
|
|
78
|
+
function useActionHref(action) {
|
|
79
|
+
const reason = useCommitReason(action.commit, action.disabledReason);
|
|
80
|
+
const refused = reason !== void 0 && reason !== null && reason !== false && reason !== "";
|
|
81
|
+
return action.href !== void 0 && !action.pending && !refused ? action.href : void 0;
|
|
82
|
+
}
|
|
83
|
+
function MenuAction({ action, close }) {
|
|
84
|
+
const href = useActionHref(action);
|
|
85
|
+
const glyph = glyphOf(action.icon);
|
|
86
|
+
const words = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
87
|
+
glyph && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "flex shrink-0 [&_svg]:size-4", children: glyph }),
|
|
88
|
+
action.label
|
|
89
|
+
] });
|
|
90
|
+
const tone = action.tone === "danger" ? "danger" : void 0;
|
|
91
|
+
const select = () => {
|
|
92
|
+
close();
|
|
93
|
+
action.onSelect?.();
|
|
94
|
+
};
|
|
95
|
+
if (href !== void 0) {
|
|
96
|
+
return /* @__PURE__ */ jsx(
|
|
97
|
+
Button,
|
|
98
|
+
{
|
|
99
|
+
href,
|
|
100
|
+
external: action.external,
|
|
101
|
+
variant: "ghost",
|
|
102
|
+
size: "sm",
|
|
103
|
+
stretch: true,
|
|
104
|
+
tone,
|
|
105
|
+
disabled: action.disabled,
|
|
106
|
+
className: "w-full justify-start text-start",
|
|
107
|
+
onClick: select,
|
|
108
|
+
children: words
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return /* @__PURE__ */ jsx(
|
|
113
|
+
Button,
|
|
114
|
+
{
|
|
115
|
+
type: "button",
|
|
116
|
+
variant: "ghost",
|
|
117
|
+
size: "sm",
|
|
118
|
+
stretch: true,
|
|
119
|
+
tone,
|
|
120
|
+
disabledReason: action.disabledReason,
|
|
121
|
+
commit: action.commit,
|
|
122
|
+
pending: action.pending,
|
|
123
|
+
disabled: action.disabled,
|
|
124
|
+
className: "w-full justify-start text-start",
|
|
125
|
+
onClick: select,
|
|
126
|
+
children: words
|
|
91
127
|
}
|
|
92
128
|
);
|
|
93
129
|
}
|
|
94
130
|
function InlineAction({ action, size }) {
|
|
131
|
+
const href = useActionHref(action);
|
|
95
132
|
const glyph = glyphOf(action.icon);
|
|
133
|
+
const tone = action.tone === "danger" ? "danger" : void 0;
|
|
96
134
|
if (glyph) {
|
|
135
|
+
if (href !== void 0) {
|
|
136
|
+
return /* @__PURE__ */ jsx(
|
|
137
|
+
IconButton,
|
|
138
|
+
{
|
|
139
|
+
href,
|
|
140
|
+
external: action.external,
|
|
141
|
+
size,
|
|
142
|
+
label: action.label,
|
|
143
|
+
tone,
|
|
144
|
+
disabled: action.disabled,
|
|
145
|
+
stopPropagation: true,
|
|
146
|
+
onClick: () => action.onSelect?.(),
|
|
147
|
+
children: glyph
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
}
|
|
97
151
|
return /* @__PURE__ */ jsx(
|
|
98
152
|
IconButton,
|
|
99
153
|
{
|
|
100
154
|
size,
|
|
101
155
|
label: action.label,
|
|
102
|
-
tone
|
|
156
|
+
tone,
|
|
103
157
|
disabledReason: action.disabledReason,
|
|
104
158
|
commit: action.commit,
|
|
105
159
|
pending: action.pending,
|
|
106
160
|
disabled: action.disabled,
|
|
107
161
|
stopPropagation: true,
|
|
108
|
-
onClick: action.onSelect,
|
|
162
|
+
onClick: () => action.onSelect?.(),
|
|
109
163
|
children: glyph
|
|
110
164
|
}
|
|
111
165
|
);
|
|
112
166
|
}
|
|
167
|
+
const stop = {
|
|
168
|
+
onClick: (e) => {
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
action.onSelect?.();
|
|
171
|
+
},
|
|
172
|
+
onKeyDown: (e) => {
|
|
173
|
+
if (e.key === "Enter" || e.key === " ") e.stopPropagation();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
if (href !== void 0) {
|
|
177
|
+
return /* @__PURE__ */ jsx(Button, { href, external: action.external, variant: "ghost", size: "sm", tone, disabled: action.disabled, ...stop, children: action.label });
|
|
178
|
+
}
|
|
113
179
|
return /* @__PURE__ */ jsx(
|
|
114
180
|
Button,
|
|
115
181
|
{
|
|
116
182
|
type: "button",
|
|
117
183
|
variant: "ghost",
|
|
118
184
|
size: "sm",
|
|
119
|
-
tone
|
|
185
|
+
tone,
|
|
120
186
|
disabledReason: action.disabledReason,
|
|
121
187
|
commit: action.commit,
|
|
122
188
|
pending: action.pending,
|
|
123
189
|
disabled: action.disabled,
|
|
124
|
-
|
|
125
|
-
e.stopPropagation();
|
|
126
|
-
action.onSelect();
|
|
127
|
-
},
|
|
128
|
-
onKeyDown: (e) => {
|
|
129
|
-
if (e.key === "Enter" || e.key === " ") e.stopPropagation();
|
|
130
|
-
},
|
|
190
|
+
...stop,
|
|
131
191
|
children: action.label
|
|
132
192
|
}
|
|
133
193
|
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/row-actions.tsx"],"sourcesContent":["import { isValidElement } from \"react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { MoreHorizontal } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Popover } from \"./popover\";\nimport { Button, CompactControls, IconButton } from \"./ui\";\nimport type { IconButtonSize } from \"./ui\";\nimport type { DataTableColumn } from \"./data-table\";\n\n/**\n * A row's actions — inline icons at Normal, one \"⋯\" menu at Large and above\n * (docs/text-size-harmonization.md §10.8).\n *\n * At Large an IconButton shows its label as text (§4). In a dense row — a register's\n * action column, a list row's end — every label at once does not fit: Kurvenschmiede\n * has 44 IconButtons in rows, kastlan 119. So a row's actions collapse instead, into\n * the menu the user roster has used since 0.31 (`UserRowActions`, now built on this):\n * one \"⋯\" button, and a menu with each action's icon AND words, where a locked one stays\n * listed and says why.\n *\n * The data is the roster's `UserRowActionList`, generalised: entries, and `false` /\n * `null` for the ones a condition left out (`row.locked || {…}`).\n */\n\n/** One action of a row. */\nexport interface RowAction {\n /** React key. Default: the label. */\n key?: string;\n /** The action's name: the inline icon's name and tooltip, the menu entry's words. */\n label: string;\n /** Runs it. The kit sends nothing — the app opens its confirmation or calls its API. */\n onSelect: () => void;\n /**\n * The glyph: a Lucide icon (`Pencil`), or an element (`<Pencil />`, an app's own\n * svg) — DataTable's `rowActions` hand their `icon` node through. Inline, an action\n * without one is a small text button; in the menu it is the words alone.\n */\n icon?: LucideIcon | ReactElement;\n /** `danger` for delete, revoke, erase: the quiet red of IconButton's `tone=\"danger\"`\n * inline, the danger text tone in the menu. */\n tone?: \"default\" | \"danger\";\n /** Why it is not available for this row. It stays listed, focusable, and says so — an\n * action that silently vanished would leave the reader looking for it. */\n disabledReason?: ReactNode;\n /**\n * This action COMMITS — it saves, sends, deletes: under a locked\n * {@link WriteLockProvider} it is refused with the lock's reason, as {@link Button}'s\n * `commit` is, inline and in the menu alike. No provider, or an unlocked one: no effect.\n */\n commit?: boolean;\n /**\n * In flight — the request it started has not answered. Inline, the icon turns into\n * IconButton's spinner (`pending`); collapsed, the \"⋯\" button does, since the menu\n * closed when the action was chosen. Either way nothing in the row can be pressed again\n * until it settles: the \"⋯\" opens no menu while busy.\n */\n pending?: boolean;\n /**\n * Not available for a moment, with nothing to explain — another action of the list\n * is in flight (an admin panel's `busy`). The plain `disabled`; a lasting refusal is a\n * `disabledReason`, which says why.\n */\n disabled?: boolean;\n /** Leave it out for this row. */\n hidden?: boolean;\n}\n\n/** What a row's actions may be: entries, and `false` / `null` / `undefined` for the ones\n * a condition left out. */\nexport type RowActionList = readonly (RowAction | false | null | undefined)[];\n\nexport interface RowActionsLabels {\n /** The menu button's name when the row has no name of its own; also the actions\n * column's (hidden) header in {@link rowActionsColumn}. */\n actions: string;\n /** The menu button's name, given the row's: \"Actions for Ada Example\". */\n actionsFor: (name: string) => string;\n}\n\nexport const DEFAULT_ROW_ACTIONS_LABELS: RowActionsLabels = {\n actions: \"Actions\",\n actionsFor: (name) => `Actions for ${name}`,\n};\n\n/**\n * - `\"auto\"` (default): the icons inline at Normal; the \"⋯\" menu at Large and Extra\n * large once there are two or more (§10.8). A single action stays inline at every\n * size, with its label as text at Large — one label fits a row, and a menu of one is\n * a detour.\n * - `\"menu\"`: the menu at every size — the roster's choice, whose actions grow (§2.4\n * there) and do not fit a phone card as a strip.\n * - `\"inline\"`: the icons at every size — for a row the app knows has room; at Large\n * each then shows its label (IconButton's `labelVisible`).\n */\nexport type RowActionsCollapse = \"auto\" | \"menu\" | \"inline\";\n\n/** The sizes a row action comes in: IconButton's row steps. `2xs` is DataTable's\n * compact density. At Large every one is at least a 48 px target (IconButton). */\nexport type RowActionsSize = Extract<IconButtonSize, \"md\" | \"sm\" | \"xs\" | \"2xs\">;\n\nexport interface RowActionsProps {\n actions: RowActionList;\n /** The row's name, for the menu button's name — \"Actions for Ada Example\". Without it\n * the button is \"Actions\". */\n name?: string;\n /** When the actions collapse into the menu — see {@link RowActionsCollapse}. */\n collapse?: RowActionsCollapse;\n /** The inline icons' and the \"⋯\" button's size. Default `xs`, a list row's action. */\n size?: RowActionsSize;\n /** The menu's width. Default `\"15rem\"`, in rem so it grows with the words (§3.2). */\n menuWidth?: `${number}rem`;\n /** On the wrapper: the inline strip, or the \"⋯\" button's box. */\n className?: string;\n labels?: Partial<RowActionsLabels>;\n}\n\nfunction visibleActions(actions: RowActionList): RowAction[] {\n return actions.filter((a): a is RowAction => Boolean(a) && !(a as RowAction).hidden);\n}\n\n/** An action's glyph as a node: a component is rendered, an element is used as is. */\nfunction glyphOf(icon: RowAction[\"icon\"]): ReactNode {\n if (icon === undefined) return null;\n if (isValidElement(icon)) return icon;\n const Icon = icon as LucideIcon;\n return <Icon />;\n}\n\n/**\n * A row's actions, inline or as a \"⋯\" menu — see {@link RowActionsCollapse}. Every\n * control stops its click (and the Enter/Space it stands for) from reaching the row, so\n * a clickable row or a DataTable `rowLink` does not open as well; in a DataTable column\n * also set `noRowLink: true`, as {@link rowActionsColumn} does.\n *\n * Inline, the icons sit in a {@link CompactControls} region at Normal: their labels\n * stay the names and the tooltips, and a locked one keeps its reason in the tooltip — a\n * line under one icon of a row would break the row. In the menu a locked action's\n * reason is a line under its words at Large and on touch (Button's\n * `disabledReasonDisplay`).\n *\n * The menu is portalled, so a table's scroller cannot clip it; Escape and an outside\n * press close it, and focus goes back to the \"⋯\" button, which is where a confirmation\n * dialog then returns it.\n */\nexport function RowActions({\n actions,\n name,\n collapse = \"auto\",\n size = \"xs\",\n menuWidth = \"15rem\",\n className,\n labels: labelsProp,\n}: RowActionsProps) {\n const labels = useKitLabels(\"rowActions\", DEFAULT_ROW_ACTIONS_LABELS, labelsProp);\n const large = useLargeText();\n const entries = visibleActions(actions);\n if (entries.length === 0) return null;\n const asMenu = collapse === \"menu\" || (collapse === \"auto\" && large && entries.length > 1);\n if (!asMenu) {\n const strip = (\n <div data-slot=\"row-actions\" className={cn(\"inline-flex items-center gap-0.5\", className)}>\n {entries.map((action) => (\n <InlineAction key={action.key ?? action.label} action={action} size={size} />\n ))}\n </div>\n );\n // Compact at Normal (no room for a reason line in a row); at Large an inline action\n // is the app's choice (\"inline\") or the row's only one, and shows its words.\n return large ? strip : <CompactControls>{strip}</CompactControls>;\n }\n const title = name ? labels.actionsFor(name) : labels.actions;\n // The menu closed when the action was chosen, so what is in flight shows on \"⋯\".\n const busy = entries.some((action) => action.pending);\n return (\n <Popover\n width={menuWidth}\n aria-label={title}\n className=\"p-1\"\n trigger={({ open, toggle, ref }) => (\n <IconButton\n ref={ref}\n size={size}\n label={title}\n // \"⋯\" is read by everyone as \"more actions\"; the menu it opens is the words.\n labelVisible={false}\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n pending={busy}\n stopPropagation\n onClick={toggle}\n className={className}\n >\n <MoreHorizontal />\n </IconButton>\n )}\n >\n {(close) => (\n <ul data-slot=\"row-actions-menu\" className=\"space-y-0.5\">\n {entries.map((action) => {\n const glyph = glyphOf(action.icon);\n return (\n <li key={action.key ?? action.label}>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n stretch\n tone={action.tone === \"danger\" ? \"danger\" : undefined}\n disabledReason={action.disabledReason}\n commit={action.commit}\n pending={action.pending}\n disabled={action.disabled}\n className=\"w-full justify-start text-start\"\n onClick={() => {\n close();\n action.onSelect();\n }}\n >\n {glyph && (\n <span aria-hidden className=\"flex shrink-0 [&_svg]:size-4\">\n {glyph}\n </span>\n )}\n {action.label}\n </Button>\n </li>\n );\n })}\n </ul>\n )}\n </Popover>\n );\n}\n\n/** One inline action: an IconButton with its icon, else a small text button. */\nfunction InlineAction({ action, size }: { action: RowAction; size: RowActionsSize }) {\n const glyph = glyphOf(action.icon);\n if (glyph) {\n return (\n <IconButton\n size={size}\n label={action.label}\n tone={action.tone === \"danger\" ? \"danger\" : undefined}\n disabledReason={action.disabledReason}\n commit={action.commit}\n pending={action.pending}\n disabled={action.disabled}\n stopPropagation\n onClick={action.onSelect}\n >\n {glyph}\n </IconButton>\n );\n }\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n tone={action.tone === \"danger\" ? \"danger\" : undefined}\n disabledReason={action.disabledReason}\n commit={action.commit}\n pending={action.pending}\n disabled={action.disabled}\n onClick={(e) => {\n // A row listening for clicks must not open as well (IconButton's stopPropagation).\n e.stopPropagation();\n action.onSelect();\n }}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") e.stopPropagation();\n }}\n >\n {action.label}\n </Button>\n );\n}\n\n/** The actions column's hidden header, in the provider's words. */\nfunction RowActionsHeader({ text, labels }: { text?: string; labels?: Partial<RowActionsLabels> }) {\n const resolved = useKitLabels(\"rowActions\", DEFAULT_ROW_ACTIONS_LABELS, labels);\n return <span className=\"sr-only\">{text ?? resolved.actions}</span>;\n}\n\nexport interface RowActionsColumnOptions<T> {\n /** The column's key. Default `\"actions\"`. */\n key?: string;\n /** The row's actions — `false` / `null` for the ones a condition leaves out. */\n actions: (row: T) => RowActionList;\n /** The row's name, for the \"⋯\" button's name (\"Actions for Ada Example\"). */\n name?: (row: T) => string | undefined;\n collapse?: RowActionsCollapse;\n /** Default `xs`. */\n size?: RowActionsSize;\n /**\n * The column's name as plain text — the column-settings checklist and the phone\n * card's label read it (`DataTableColumn.headerText`). Pass the app's own word; left\n * out, the header shows the provider's `rowActions.actions` to a screen reader and\n * the plain-text places fall back to the column's `key`.\n */\n headerText?: string;\n labels?: Partial<RowActionsLabels>;\n /** Anything else for the column (`mobileHidden`, `cellProps`…), merged last. */\n column?: Partial<DataTableColumn<T>>;\n}\n\n/**\n * A DataTable column of {@link RowActions} — an app's action column in one line\n * (§10.8): inline icons at Normal, the \"⋯\" menu at Large with two or more actions.\n *\n * columns={[...columns, rowActionsColumn({ actions: (r) => [...], name: (r) => r.name })]}\n *\n * The header is visually hidden (the icons say what the column is), the column hugs its\n * content at the row's end, and `noRowLink` keeps a `rowHref` anchor off it, so the\n * buttons keep their own clicks. On a phone it is a line of the card like any column;\n * set `column: { mobileHidden: true }` to drop it there (with `mobileDetailsInRow` it\n * then shows in the opened row instead).\n */\nexport function rowActionsColumn<T>({\n key = \"actions\",\n actions,\n name,\n collapse,\n size,\n headerText,\n labels,\n column,\n}: RowActionsColumnOptions<T>): DataTableColumn<T> {\n return {\n key,\n header: <RowActionsHeader text={headerText} labels={labels} />,\n headerText,\n cell: (row) => (\n <RowActions actions={actions(row)} name={name?.(row)} collapse={collapse} size={size} labels={labels} />\n ),\n className: \"w-px whitespace-nowrap text-end\",\n headClassName: \"w-px\",\n noRowLink: true,\n ...column,\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/row-actions.tsx"],"sourcesContent":["import { isValidElement } from \"react\";\nimport type { KeyboardEvent, MouseEvent, ReactElement, ReactNode } from \"react\";\nimport { MoreHorizontal } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Popover } from \"./popover\";\nimport { Button, CompactControls, IconButton } from \"./ui\";\nimport { useCommitReason } from \"./write-lock\";\nimport type { IconButtonSize } from \"./ui\";\nimport type { DataTableColumn } from \"./data-table\";\n\n/**\n * A row's actions — inline icons at Normal, one \"⋯\" menu at Large and above\n * (docs/text-size-harmonization.md §10.8).\n *\n * At Large an IconButton shows its label as text (§4). In a dense row — a register's\n * action column, a list row's end — every label at once does not fit: Kurvenschmiede\n * has 44 IconButtons in rows, kastlan 119. So a row's actions collapse instead, into\n * the menu the user roster has used since 0.31 (`UserRowActions`, now built on this):\n * one \"⋯\" button, and a menu with each action's icon AND words, where a locked one stays\n * listed and says why.\n *\n * The data is the roster's `UserRowActionList`, generalised: entries, and `false` /\n * `null` for the ones a condition left out (`row.locked || {…}`).\n */\n\n/** One action of a row. */\nexport interface RowAction {\n /** React key. Default: the label. */\n key?: string;\n /** The action's name: the inline icon's name and tooltip, the menu entry's words. */\n label: string;\n /**\n * Runs it. The kit sends nothing — the app opens its confirmation or calls its API.\n * Optional only beside `href`; an action has one or the other (or both: `onSelect`\n * then runs as the link is followed).\n */\n onSelect?: () => void;\n /**\n * The action NAVIGATES — an edit page, a mail to compose (0.32.1, kastlan's 0.32\n * report): a real link inline and in the menu, so open in a new tab, a middle click\n * and the URL on hover work, which `onSelect` + `navigate` lost. The kit's link rule\n * applies ({@link Button}'s `href`): in-app through the provider's `linkComponent`, an\n * external or `#anchor` one a plain `<a>`, `mailto:` included.\n *\n * A link cannot carry a reason or a spinner, so an action with a `disabledReason`\n * (or under a write lock, with `commit`) or `pending` stays a button and says why.\n */\n href?: string;\n /** With `href`: leaves the app — a new tab, and a screen reader is told so. */\n external?: boolean;\n /**\n * The glyph: a Lucide icon (`Pencil`), or an element (`<Pencil />`, an app's own\n * svg) — DataTable's `rowActions` hand their `icon` node through. Inline, an action\n * without one is a small text button; in the menu it is the words alone.\n */\n icon?: LucideIcon | ReactElement;\n /** `danger` for delete, revoke, erase: the quiet red of IconButton's `tone=\"danger\"`\n * inline, the danger text tone in the menu. */\n tone?: \"default\" | \"danger\";\n /** Why it is not available for this row. It stays listed, focusable, and says so — an\n * action that silently vanished would leave the reader looking for it. */\n disabledReason?: ReactNode;\n /**\n * This action COMMITS — it saves, sends, deletes: under a locked\n * {@link WriteLockProvider} it is refused with the lock's reason, as {@link Button}'s\n * `commit` is, inline and in the menu alike. No provider, or an unlocked one: no effect.\n */\n commit?: boolean;\n /**\n * In flight — the request it started has not answered. Inline, the icon turns into\n * IconButton's spinner (`pending`); collapsed, the \"⋯\" button does, since the menu\n * closed when the action was chosen. Either way nothing in the row can be pressed again\n * until it settles: the \"⋯\" opens no menu while busy.\n */\n pending?: boolean;\n /**\n * Not available for a moment, with nothing to explain — another action of the list\n * is in flight (an admin panel's `busy`). The plain `disabled`; a lasting refusal is a\n * `disabledReason`, which says why.\n */\n disabled?: boolean;\n /** Leave it out for this row. */\n hidden?: boolean;\n}\n\n/** What a row's actions may be: entries, and `false` / `null` / `undefined` for the ones\n * a condition left out. */\nexport type RowActionList = readonly (RowAction | false | null | undefined)[];\n\nexport interface RowActionsLabels {\n /** The menu button's name when the row has no name of its own; also the actions\n * column's (hidden) header in {@link rowActionsColumn}. */\n actions: string;\n /** The menu button's name, given the row's: \"Actions for Ada Example\". */\n actionsFor: (name: string) => string;\n}\n\nexport const DEFAULT_ROW_ACTIONS_LABELS: RowActionsLabels = {\n actions: \"Actions\",\n actionsFor: (name) => `Actions for ${name}`,\n};\n\n/**\n * - `\"auto\"` (default): the icons inline at Normal; the \"⋯\" menu at Large and Extra\n * large once there are two or more (§10.8). A single action stays inline at every\n * size, with its label as text at Large — one label fits a row, and a menu of one is\n * a detour.\n * - `\"menu\"`: the menu at every size — the roster's choice, whose actions grow (§2.4\n * there) and do not fit a phone card as a strip.\n * - `\"inline\"`: the icons at every size — for a row the app knows has room; at Large\n * each then shows its label (IconButton's `labelVisible`).\n */\nexport type RowActionsCollapse = \"auto\" | \"menu\" | \"inline\";\n\n/** The sizes a row action comes in: IconButton's row steps. `2xs` is DataTable's\n * compact density. At Large every one is at least a 48 px target (IconButton). */\nexport type RowActionsSize = Extract<IconButtonSize, \"md\" | \"sm\" | \"xs\" | \"2xs\">;\n\nexport interface RowActionsProps {\n actions: RowActionList;\n /** The row's name, for the menu button's name — \"Actions for Ada Example\". Without it\n * (or `null`, as API fields often are) the button is \"Actions\". */\n name?: string | null;\n /** When the actions collapse into the menu — see {@link RowActionsCollapse}. */\n collapse?: RowActionsCollapse;\n /** The inline icons' and the \"⋯\" button's size. Default `xs`, a list row's action. */\n size?: RowActionsSize;\n /** The menu's width. Default `\"15rem\"`, in rem so it grows with the words (§3.2). */\n menuWidth?: `${number}rem`;\n /** On the wrapper: the inline strip, or the \"⋯\" button's box. */\n className?: string;\n labels?: Partial<RowActionsLabels>;\n}\n\nfunction visibleActions(actions: RowActionList): RowAction[] {\n return actions.filter((a): a is RowAction => Boolean(a) && !(a as RowAction).hidden);\n}\n\n/** An action's glyph as a node: a component is rendered, an element is used as is. */\nfunction glyphOf(icon: RowAction[\"icon\"]): ReactNode {\n if (icon === undefined) return null;\n if (isValidElement(icon)) return icon;\n const Icon = icon as LucideIcon;\n return <Icon />;\n}\n\n/**\n * A row's actions, inline or as a \"⋯\" menu — see {@link RowActionsCollapse}. Every\n * control stops its click (and the Enter/Space it stands for) from reaching the row, so\n * a clickable row or a DataTable `rowLink` does not open as well; in a DataTable column\n * also set `noRowLink: true`, as {@link rowActionsColumn} does.\n *\n * Inline, the icons sit in a {@link CompactControls} region at Normal: their labels\n * stay the names and the tooltips, and a locked one keeps its reason in the tooltip — a\n * line under one icon of a row would break the row. In the menu a locked action's\n * reason is a line under its words at Large and on touch (Button's\n * `disabledReasonDisplay`).\n *\n * The menu is portalled, so a table's scroller cannot clip it; Escape and an outside\n * press close it, and focus goes back to the \"⋯\" button, which is where a confirmation\n * dialog then returns it.\n */\nexport function RowActions({\n actions,\n name,\n collapse = \"auto\",\n size = \"xs\",\n menuWidth = \"15rem\",\n className,\n labels: labelsProp,\n}: RowActionsProps) {\n const labels = useKitLabels(\"rowActions\", DEFAULT_ROW_ACTIONS_LABELS, labelsProp);\n const large = useLargeText();\n const entries = visibleActions(actions);\n if (entries.length === 0) return null;\n const asMenu = collapse === \"menu\" || (collapse === \"auto\" && large && entries.length > 1);\n if (!asMenu) {\n const strip = (\n <div data-slot=\"row-actions\" className={cn(\"inline-flex items-center gap-0.5\", className)}>\n {entries.map((action) => (\n <InlineAction key={action.key ?? action.label} action={action} size={size} />\n ))}\n </div>\n );\n // Compact at Normal (no room for a reason line in a row); at Large an inline action\n // is the app's choice (\"inline\") or the row's only one, and shows its words.\n return large ? strip : <CompactControls>{strip}</CompactControls>;\n }\n const title = name ? labels.actionsFor(name) : labels.actions;\n // The menu closed when the action was chosen, so what is in flight shows on \"⋯\".\n const busy = entries.some((action) => action.pending);\n return (\n <Popover\n width={menuWidth}\n aria-label={title}\n className=\"p-1\"\n trigger={({ open, toggle, ref }) => (\n <IconButton\n ref={ref}\n size={size}\n label={title}\n // \"⋯\" is read by everyone as \"more actions\"; the menu it opens is the words.\n labelVisible={false}\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n pending={busy}\n stopPropagation\n onClick={toggle}\n className={className}\n >\n <MoreHorizontal />\n </IconButton>\n )}\n // React carries an event out of a portal along the COMPONENT tree, so a click in\n // the menu reached a clickable row or a DataTable `onRowClick` the menu was opened\n // from, though the panel is nowhere near it in the page — only the \"⋯\" trigger\n // stopped its own (keksdose's 0.32 report). Stopped on the PANEL itself, its padding\n // and border included (0.32.2: 0.32.1 stopped it on an inner box, and a click on\n // the panel's `p-1` still went through): clicks, and the Enter / Space a row also\n // listens for. Escape goes on, to the popover's own handler that closes it.\n onClick={stopClick}\n onKeyDown={stopActivationKeys}\n >\n {(close) => (\n <ul data-slot=\"row-actions-menu\" className=\"space-y-0.5\">\n {entries.map((action) => (\n <li key={action.key ?? action.label}>\n <MenuAction action={action} close={close} />\n </li>\n ))}\n </ul>\n )}\n </Popover>\n );\n}\n\nfunction stopClick(e: MouseEvent) {\n e.stopPropagation();\n}\n\nfunction stopActivationKeys(e: KeyboardEvent) {\n if (e.key === \"Enter\" || e.key === \" \") e.stopPropagation();\n}\n\n/**\n * The `href` an action renders as a link with, or `undefined` for a button: a link\n * cannot carry a reason or a spinner (Button's link form has neither), so a refused\n * action — its own `disabledReason`, or a write lock on a `commit` — and a pending one\n * stay buttons and say so.\n */\nfunction useActionHref(action: RowAction): string | undefined {\n const reason = useCommitReason(action.commit, action.disabledReason);\n const refused = reason !== undefined && reason !== null && reason !== false && reason !== \"\";\n return action.href !== undefined && !action.pending && !refused ? action.href : undefined;\n}\n\n/** One entry of the \"⋯\" menu: the words, as a link for an `href`, else a button. */\nfunction MenuAction({ action, close }: { action: RowAction; close: () => void }) {\n const href = useActionHref(action);\n const glyph = glyphOf(action.icon);\n const words = (\n <>\n {glyph && (\n <span aria-hidden className=\"flex shrink-0 [&_svg]:size-4\">\n {glyph}\n </span>\n )}\n {action.label}\n </>\n );\n const tone = action.tone === \"danger\" ? \"danger\" : undefined;\n const select = () => {\n close();\n action.onSelect?.();\n };\n if (href !== undefined) {\n return (\n <Button\n href={href}\n external={action.external}\n variant=\"ghost\"\n size=\"sm\"\n stretch\n tone={tone}\n disabled={action.disabled}\n className=\"w-full justify-start text-start\"\n onClick={select}\n >\n {words}\n </Button>\n );\n }\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n stretch\n tone={tone}\n disabledReason={action.disabledReason}\n commit={action.commit}\n pending={action.pending}\n disabled={action.disabled}\n className=\"w-full justify-start text-start\"\n onClick={select}\n >\n {words}\n </Button>\n );\n}\n\n/** One inline action: an IconButton with its icon, else a small text button — each a\n * link for an `href` (see {@link useActionHref}). */\nfunction InlineAction({ action, size }: { action: RowAction; size: RowActionsSize }) {\n const href = useActionHref(action);\n const glyph = glyphOf(action.icon);\n const tone = action.tone === \"danger\" ? \"danger\" : undefined;\n if (glyph) {\n if (href !== undefined) {\n return (\n <IconButton\n href={href}\n external={action.external}\n size={size}\n label={action.label}\n tone={tone}\n disabled={action.disabled}\n stopPropagation\n onClick={() => action.onSelect?.()}\n >\n {glyph}\n </IconButton>\n );\n }\n return (\n <IconButton\n size={size}\n label={action.label}\n tone={tone}\n disabledReason={action.disabledReason}\n commit={action.commit}\n pending={action.pending}\n disabled={action.disabled}\n stopPropagation\n onClick={() => action.onSelect?.()}\n >\n {glyph}\n </IconButton>\n );\n }\n // A row listening for clicks must not open as well (IconButton's stopPropagation).\n const stop = {\n onClick: (e: MouseEvent) => {\n e.stopPropagation();\n action.onSelect?.();\n },\n onKeyDown: (e: KeyboardEvent) => {\n if (e.key === \"Enter\" || e.key === \" \") e.stopPropagation();\n },\n };\n if (href !== undefined) {\n return (\n <Button href={href} external={action.external} variant=\"ghost\" size=\"sm\" tone={tone} disabled={action.disabled} {...stop}>\n {action.label}\n </Button>\n );\n }\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n tone={tone}\n disabledReason={action.disabledReason}\n commit={action.commit}\n pending={action.pending}\n disabled={action.disabled}\n {...stop}\n >\n {action.label}\n </Button>\n );\n}\n\n/** The actions column's hidden header, in the provider's words. */\nfunction RowActionsHeader({ text, labels }: { text?: string; labels?: Partial<RowActionsLabels> }) {\n const resolved = useKitLabels(\"rowActions\", DEFAULT_ROW_ACTIONS_LABELS, labels);\n return <span className=\"sr-only\">{text ?? resolved.actions}</span>;\n}\n\nexport interface RowActionsColumnOptions<T> {\n /** The column's key. Default `\"actions\"`. */\n key?: string;\n /** The row's actions — `false` / `null` for the ones a condition leaves out. */\n actions: (row: T) => RowActionList;\n /** The row's name, for the \"⋯\" button's name (\"Actions for Ada Example\"). `null` and\n * `undefined` both mean \"Actions\". */\n name?: (row: T) => string | null | undefined;\n collapse?: RowActionsCollapse;\n /** Default `xs`. */\n size?: RowActionsSize;\n /**\n * The column's name as plain text — the column-settings checklist and the phone\n * card's label read it (`DataTableColumn.headerText`). Pass the app's own word; left\n * out, the header shows the provider's `rowActions.actions` to a screen reader and\n * the plain-text places fall back to the column's `key`.\n */\n headerText?: string;\n labels?: Partial<RowActionsLabels>;\n /** Anything else for the column (`mobileHidden`, `cellProps`…), merged last. */\n column?: Partial<DataTableColumn<T>>;\n}\n\n/**\n * A DataTable column of {@link RowActions} — an app's action column in one line\n * (§10.8): inline icons at Normal, the \"⋯\" menu at Large with two or more actions.\n *\n * columns={[...columns, rowActionsColumn({ actions: (r) => [...], name: (r) => r.name })]}\n *\n * The header is visually hidden (the icons say what the column is), the column hugs its\n * content at the row's end, and `noRowLink` keeps a `rowHref` anchor off it, so the\n * buttons keep their own clicks. On a phone it is a line of the card like any column;\n * set `column: { mobileHidden: true }` to drop it there (with `mobileDetailsInRow` it\n * then shows in the opened row instead).\n */\nexport function rowActionsColumn<T>({\n key = \"actions\",\n actions,\n name,\n collapse,\n size,\n headerText,\n labels,\n column,\n}: RowActionsColumnOptions<T>): DataTableColumn<T> {\n return {\n key,\n header: <RowActionsHeader text={headerText} labels={labels} />,\n headerText,\n cell: (row) => (\n <RowActions actions={actions(row)} name={name?.(row)} collapse={collapse} size={size} labels={labels} />\n ),\n className: \"w-px whitespace-nowrap text-end\",\n headClassName: \"w-px\",\n noRowLink: true,\n ...column,\n };\n}\n"],"mappings":"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","names":[]}
|