@eifi1/ui-kit 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +9 -6
  2. package/dist/billing/subscription-standing.d.ts +1 -1
  3. package/dist/billing/subscription-standing.js.map +1 -1
  4. package/dist/components/button.js +1 -0
  5. package/dist/components/button.js.map +1 -1
  6. package/dist/components/data-table.d.ts +16 -0
  7. package/dist/components/data-table.js +17 -0
  8. package/dist/components/data-table.js.map +1 -1
  9. package/dist/components/icon-button.d.ts +3 -3
  10. package/dist/components/icon-button.js +1 -0
  11. package/dist/components/icon-button.js.map +1 -1
  12. package/dist/components/legended-series-chart.d.ts +5 -2
  13. package/dist/components/legended-series-chart.js +9 -2
  14. package/dist/components/legended-series-chart.js.map +1 -1
  15. package/dist/components/quantity-field.d.ts +61 -0
  16. package/dist/components/quantity-field.js +95 -0
  17. package/dist/components/quantity-field.js.map +1 -0
  18. package/dist/components/quantity-input.d.ts +139 -0
  19. package/dist/components/quantity-input.js +188 -0
  20. package/dist/components/quantity-input.js.map +1 -0
  21. package/dist/components/quantity-labels.d.ts +2 -0
  22. package/dist/components/quantity-labels.js +11 -0
  23. package/dist/components/quantity-labels.js.map +1 -0
  24. package/dist/eslint/index.js +1 -1
  25. package/dist/feedback/feedback-inbox-bulk.d.ts +88 -0
  26. package/dist/feedback/feedback-inbox-bulk.js +316 -0
  27. package/dist/feedback/feedback-inbox-bulk.js.map +1 -0
  28. package/dist/feedback/feedback-inbox-page.d.ts +47 -3
  29. package/dist/feedback/feedback-inbox-page.js +19 -4
  30. package/dist/feedback/feedback-inbox-page.js.map +1 -1
  31. package/dist/feedback/feedback-row-detail.d.ts +5 -1
  32. package/dist/feedback/feedback-row-detail.js.map +1 -1
  33. package/dist/feedback/feedback-table-labels.js +10 -1
  34. package/dist/feedback/feedback-table-labels.js.map +1 -1
  35. package/dist/feedback/feedback-table.d.ts +26 -0
  36. package/dist/feedback/feedback-table.js.map +1 -1
  37. package/dist/feedback.d.ts +1 -1
  38. package/dist/feedback.js.map +1 -1
  39. package/dist/i18n/defaults.js +3 -1
  40. package/dist/i18n/defaults.js.map +1 -1
  41. package/dist/i18n/german.js +20 -1
  42. package/dist/i18n/german.js.map +1 -1
  43. package/dist/i18n/kit-labels.d.ts +4 -0
  44. package/dist/i18n/kit-labels.js.map +1 -1
  45. package/dist/i18n/locales/en.js +26 -3
  46. package/dist/i18n/locales/en.js.map +3 -3
  47. package/dist/i18n/locales/es.js +20 -1
  48. package/dist/i18n/locales/es.js.map +1 -1
  49. package/dist/i18n/locales/fr.js +25 -1
  50. package/dist/i18n/locales/fr.js.map +1 -1
  51. package/dist/i18n/locales/hu.js +22 -1
  52. package/dist/i18n/locales/hu.js.map +1 -1
  53. package/dist/i18n/locales/it.js +21 -1
  54. package/dist/i18n/locales/it.js.map +1 -1
  55. package/dist/i18n/locales/zh.js +20 -1
  56. package/dist/i18n/locales/zh.js.map +1 -1
  57. package/dist/i18n/review.js +12 -1
  58. package/dist/i18n/review.js.map +1 -1
  59. package/dist/index.d.ts +6 -0
  60. package/dist/index.js +23 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/lib/quantity.d.ts +107 -0
  63. package/dist/lib/quantity.js +166 -0
  64. package/dist/lib/quantity.js.map +1 -0
  65. package/dist/rhf/fields.d.ts +28 -0
  66. package/dist/rhf/fields.js +82 -0
  67. package/dist/rhf/fields.js.map +1 -1
  68. package/dist/rhf.js.map +1 -1
  69. package/dist/theme/contrast.d.ts +1 -1
  70. package/dist/theme/palette-store.d.ts +1 -1
  71. package/dist/theme/text-size.d.ts +1 -1
  72. package/dist/theme/theme-store.d.ts +1 -1
  73. package/package.json +3 -3
package/README.md CHANGED
@@ -579,7 +579,7 @@ columns, strings and permissions (see the note at the top of `src/feedback/feedb
579
579
 
580
580
  <!-- BEGIN GENERATED: exports — node scripts/gen-export-inventory.mjs -->
581
581
 
582
- **2199 names from 371 modules** in the main barrel — 992 values and 1207 types — and 191 more in 16 standalone entries. _Italic_ is a type-only export.
582
+ **2221 names from 374 modules** in the main barrel — 1003 values and 1218 types — and 193 more in 16 standalone entries. _Italic_ is a type-only export.
583
583
 
584
584
  Generated from `dist/index.d.ts` and each standalone entry's own `.d.ts` by
585
585
  `node scripts/gen-export-inventory.mjs`; the counts are pinned by
@@ -587,13 +587,13 @@ Generated from `dist/index.d.ts` and each standalone entry's own `.d.ts` by
587
587
 
588
588
  | Entry point | Names |
589
589
  |---|---|
590
- | `@eifi1/ui-kit` | 2199 |
590
+ | `@eifi1/ui-kit` | 2221 |
591
591
  | `@eifi1/ui-kit/chart` | 102 |
592
592
  | `@eifi1/ui-kit/shell` | 110 |
593
593
  | `@eifi1/ui-kit/data-table` | 56 |
594
594
  | `@eifi1/ui-kit/wizard` | 25 |
595
595
  | `@eifi1/ui-kit/tour` | 49 |
596
- | `@eifi1/ui-kit/feedback` | 203 |
596
+ | `@eifi1/ui-kit/feedback` | 204 |
597
597
  | `@eifi1/ui-kit/search` | 20 |
598
598
  | `@eifi1/ui-kit/dates` | 26 |
599
599
  | `@eifi1/ui-kit/table-text` | 14 |
@@ -601,7 +601,7 @@ Generated from `dist/index.d.ts` and each standalone entry's own `.d.ts` by
601
601
  | `@eifi1/ui-kit/eslint` | 1 |
602
602
  | `@eifi1/ui-kit/testing` | 15 |
603
603
  | `@eifi1/ui-kit/testing/react` | 2 |
604
- | `@eifi1/ui-kit/rhf` | 48 |
604
+ | `@eifi1/ui-kit/rhf` | 50 |
605
605
  | `@eifi1/ui-kit/offline` | 33 |
606
606
  | `@eifi1/ui-kit/sync` | 36 |
607
607
  | `@eifi1/ui-kit/sync/testing` | 3 |
@@ -638,6 +638,7 @@ Every other subpath is a re-slicing of the barrel, never a second API.
638
638
  | `lib/countries` | `COUNTRY_CODES`, `countryName` |
639
639
  | `lib/iban` | `compactIban`, `formatIban`, `IBAN_LENGTHS`, `ibanCheckDigits`, `ibanProblem`, `isQrIban`, `isValidIban`, _`IbanKind`_, _`IbanProblem`_ |
640
640
  | `lib/phone` | `formatNationalPhone`, `formatPhone`, `isE164`, `parsePhone`, `PHONE_COUNTRIES`, `PHONE_DIAL_CODES`, _`ParsedPhone`_, _`PhoneCountry`_, _`PhoneCountryCode`_ |
641
+ | `lib/quantity` | `convertQuantity`, `formatQuantity`, `parseQuantity`, `QUANTITY_DIGITS`, `QUANTITY_UNITS`, `quantityFamily`, `sumQuantities`, `toBaseQuantity`, _`BaseQuantity`_, _`FormatQuantityOptions`_, _`Quantity`_, _`QuantityBaseUnit`_, _`QuantityFamily`_, _`QuantityUnit`_, _`QuantityUnitWords`_ |
641
642
  | `lib/column-mapping` | `assignColumnRole`, `guessMapping`, `missingRoles`, `readMappedTable`, `readTextFile`, `roleOfColumn`, _`ColumnMapperResult`_, _`ColumnMapping`_, _`ColumnRole`_ |
642
643
  | `lib/document-title` | `DEFAULT_DOCUMENT_TITLE_FORMAT`, `formatDocumentTitle`, _`DocumentTitleParts`_ |
643
644
  | `lib/status-tone` | `STATUS_KINDS`, `statusTone`, _`StatusKind`_, _`StatusTone`_ |
@@ -691,6 +692,8 @@ Every other subpath is a re-slicing of the barrel, never a second API.
691
692
  | `components/slider` | `fromLogPosition`, `Slider`, `toLogPosition`, _`SliderMark`_, _`SliderProps`_ |
692
693
  | `components/time-input` | `isTimeInRange`, `normalizeTime`, `TimeInput`, _`TimeInputProps`_ |
693
694
  | `components/number-field` | `IntegerField`, `NumberField`, _`NumberFieldProps`_ |
695
+ | `components/quantity-input` | `DEFAULT_QUANTITY_LABELS`, `QuantityInput`, _`QuantityInputProps`_, _`QuantityLabels`_ |
696
+ | `components/quantity-field` | `QuantityField`, _`QuantityFieldProps`_ |
694
697
  | `components/sparkline` | `DEFAULT_SPARKLINE_LABELS`, `Sparkline`, `sparklineSummary`, _`SparklineLabels`_, _`SparklineProps`_, _`SparklineTone`_ |
695
698
  | `components/stat-tile` | `DEFAULT_STAT_TILE_LABELS`, `StatTile`, `StatTileGrid`, _`StatTileDelta`_, _`StatTileGridProps`_, _`StatTileLabels`_, _`StatTileLinkProps`_, _`StatTileProps`_, _`StatTileSubValue`_, _`StatTileTone`_ |
696
699
  | `components/signature-pad` | `DEFAULT_SIGNATURE_PAD_LABELS`, `SignaturePad`, `SignatureView`, _`SignatureDetail`_, _`SignaturePadHandle`_, _`SignaturePadLabels`_, _`SignaturePadProps`_, _`SignatureViewProps`_ |
@@ -938,7 +941,7 @@ Every other subpath is a re-slicing of the barrel, never a second API.
938
941
  | `feedback/feedback-comment-thread` | `DEFAULT_FEEDBACK_COMMENTS_LABELS`, `FeedbackCommentThread`, _`FeedbackCommentsLabels`_, _`FeedbackCommentThreadProps`_ |
939
942
  | `feedback/feedback-replies` | `DEFAULT_FEEDBACK_REPLIES_LABELS`, `FeedbackReplyComposer`, `feedbackReplyNudge`, `FeedbackReplyTimeline`, `useFeedbackRepliesLabels`, _`FeedbackRepliesLabels`_, _`FeedbackReplyComposerProps`_, _`FeedbackReplyTimelineProps`_ |
940
943
  | `feedback/feedback-entry-file` | `FeedbackEntryFile`, `useFeedbackEntryUpload`, _`FeedbackEntryFileProps`_, _`FeedbackEntryUpload`_, _`FeedbackEntryUploadOptions`_, _`FeedbackEntryUploadState`_ |
941
- | `feedback/feedback-inbox-page` | `FeedbackInbox`, `useFeedbackInbox`, _`FeedbackInboxMutation`_, _`FeedbackInboxProps`_, _`FeedbackInboxState`_, _`FeedbackReplyPost`_, _`FeedbackReworkPost`_, _`UseFeedbackInboxOptions`_ |
944
+ | `feedback/feedback-inbox-page` | `FeedbackInbox`, `useFeedbackInbox`, _`FeedbackInboxBulk`_, _`FeedbackInboxMutation`_, _`FeedbackInboxProps`_, _`FeedbackInboxState`_, _`FeedbackReplyPost`_, _`FeedbackReworkPost`_, _`UseFeedbackInboxOptions`_ |
942
945
  | `feedback/feedback-vocabulary` | `FEEDBACK_CATEGORY_META`, `FEEDBACK_CATEGORY_ORDER`, `FEEDBACK_STATUS_META`, `FEEDBACK_STATUS_ORDER`, `feedbackCategoryRank`, `nextFeedbackStatus`, `selectableFeedbackStatuses`, `visibleFeedbackStatuses`, _`FeedbackCategory`_, _`FeedbackStatus`_ |
943
946
 
944
947
  ### wizard
@@ -1121,7 +1124,7 @@ Every other subpath is a re-slicing of the barrel, never a second API.
1121
1124
  |---|---|
1122
1125
  | `rhf/form-core` | `Form` |
1123
1126
  | `rhf/use-rhf-wizard-step` | `useRhfWizardStep`, _`RhfWizardStepOptions`_ |
1124
- | `rhf/fields` | `RhfCheckbox`, `RhfCombobox`, `RhfCountrySelect`, `RhfDateField`, `RhfDateRangePicker`, `RhfField`, `RhfIbanInput`, `RhfInlineEntityCombobox`, `RhfIntegerField`, `RhfMoneyField`, `RhfMonthPicker`, `RhfNumberField`, `RhfPhoneInput`, `RhfSelect`, `RhfTextarea`, `RhfTextCombobox`, `RhfTextField`, `RhfTimeInput`, `RhfToggleGroup`, _`RhfCheckboxProps`_, _`RhfComboboxProps`_, _`RhfCountrySelectProps`_, _`RhfDateFieldProps`_, _`RhfDateRangePickerProps`_, _`RhfFieldBaseProps`_, _`RhfFieldProps`_, _`RhfFieldRenderContext`_, _`RhfIbanInputProps`_, _`RhfInlineEntityComboboxProps`_, _`RhfIntegerFieldProps`_, _`RhfMoneyFieldProps`_, _`RhfMonthPickerProps`_, _`RhfNumberFieldProps`_, _`RhfPhoneInputProps`_, _`RhfSelectOption`_, _`RhfSelectProps`_, _`RhfTextareaProps`_, _`RhfTextComboboxProps`_, _`RhfTextFieldProps`_, _`RhfTimeInputProps`_, _`RhfToggleGroupProps`_ |
1127
+ | `rhf/fields` | `RhfCheckbox`, `RhfCombobox`, `RhfCountrySelect`, `RhfDateField`, `RhfDateRangePicker`, `RhfField`, `RhfIbanInput`, `RhfInlineEntityCombobox`, `RhfIntegerField`, `RhfMoneyField`, `RhfMonthPicker`, `RhfNumberField`, `RhfPhoneInput`, `RhfQuantityField`, `RhfSelect`, `RhfTextarea`, `RhfTextCombobox`, `RhfTextField`, `RhfTimeInput`, `RhfToggleGroup`, _`RhfCheckboxProps`_, _`RhfComboboxProps`_, _`RhfCountrySelectProps`_, _`RhfDateFieldProps`_, _`RhfDateRangePickerProps`_, _`RhfFieldBaseProps`_, _`RhfFieldProps`_, _`RhfFieldRenderContext`_, _`RhfIbanInputProps`_, _`RhfInlineEntityComboboxProps`_, _`RhfIntegerFieldProps`_, _`RhfMoneyFieldProps`_, _`RhfMonthPickerProps`_, _`RhfNumberFieldProps`_, _`RhfPhoneInputProps`_, _`RhfQuantityFieldProps`_, _`RhfSelectOption`_, _`RhfSelectProps`_, _`RhfTextareaProps`_, _`RhfTextComboboxProps`_, _`RhfTextFieldProps`_, _`RhfTimeInputProps`_, _`RhfToggleGroupProps`_ |
1125
1128
  | `rhf/line-items` | `RhfLineItems`, _`RhfLineItemCellContext`_, _`RhfLineItemsColumn`_, _`RhfLineItemsProps`_ |
1126
1129
 
1127
1130
  ### @eifi1/ui-kit/offline (standalone)
@@ -9,7 +9,7 @@ import type { BillingOverview } from "./billing-overview.js";
9
9
  * knowledge laid over it: kastlan says "Ends on …" the moment its in-app cancel is asked
10
10
  * (`{ ...overview, cancel_at_period_end: view.cancelScheduled }`, `useCancelRequest`).
11
11
  */
12
- export type SubscriptionStandingOverview = Pick<BillingOverview, "status" | "in_good_standing" | "trial_ends_at" | "comped_until" | "current_period_end" | "cancel_at_period_end">;
12
+ export type SubscriptionStandingOverview = Pick<BillingOverview, "status" | "in_good_standing" | "cancel_at_period_end"> & Partial<Pick<BillingOverview, "trial_ends_at" | "comped_until" | "current_period_end">>;
13
13
  /**
14
14
  * The subscription's standing in one line — or `null` when nothing applies (kastlan reuse
15
15
  * audit 2026-11-02, proposal 3; billing §7, Marcel 2026-11-04). The FIRST that applies:
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/billing/subscription-standing.tsx"],"sourcesContent":["import type { ComponentPropsWithRef } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useDayFormatter } from \"../lib/day-format\";\nimport { formatDate } from \"../lib/format\";\nimport type { DateInput } from \"../lib/format\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\nimport type { BillingOverview } from \"./billing-overview\";\n\n/**\n * What the standing line reads of `GET /billing/overview` (docs/billing-harmonization.md\n * §4), under the wire names. Pass the overview as it came — or with the app's own\n * knowledge laid over it: kastlan says \"Ends on …\" the moment its in-app cancel is asked\n * (`{ ...overview, cancel_at_period_end: view.cancelScheduled }`, `useCancelRequest`).\n */\nexport type SubscriptionStandingOverview = Pick<\n BillingOverview,\n \"status\" | \"in_good_standing\" | \"trial_ends_at\" | \"comped_until\" | \"current_period_end\" | \"cancel_at_period_end\"\n>;\n\n/** Which of `billing.standing`'s lines applies, and the date it names. */\ntype Standing =\n | { kind: \"ended\" | \"pastDue\" | \"free\" }\n | { kind: \"trialUntil\" | \"freeUntil\" | \"endsOn\" | \"renewsOn\"; date: DateInput };\n\nconst given = (value: DateInput): boolean => value !== null && value !== undefined && value !== \"\";\n\n/** Marcel's order (billing §7, 2026-11-04): the first that applies, or none. */\nfunction standingOf(o: SubscriptionStandingOverview): Standing | null {\n // The server's word beats the status: `canceled` and `expired` are never in good\n // standing, and a lapsed `past_due` is no longer \"retrying\" (§3.3).\n if (!o.in_good_standing) return { kind: \"ended\" };\n // Still in good standing, and no date: a renewal the retries may not reach.\n if (o.status === \"past_due\") return { kind: \"pastDue\" };\n if (o.status === \"trialing\") return given(o.trial_ends_at) ? { kind: \"trialUntil\", date: o.trial_ends_at } : null;\n if (o.status === \"comped\") {\n return given(o.comped_until) ? { kind: \"freeUntil\", date: o.comped_until } : { kind: \"free\" };\n }\n if (!given(o.current_period_end)) return null;\n if (o.cancel_at_period_end) return { kind: \"endsOn\", date: o.current_period_end };\n if (o.status === \"active\") return { kind: \"renewsOn\", date: o.current_period_end };\n return null;\n}\n\nfunction standingText(s: Standing, words: BillingLabels[\"standing\"], day: (value: DateInput) => string): string {\n switch (s.kind) {\n case \"ended\":\n case \"pastDue\":\n case \"free\":\n return words[s.kind];\n default:\n return words[s.kind](day(s.date));\n }\n}\n\n/**\n * The subscription's standing in one line — or `null` when nothing applies (kastlan reuse\n * audit 2026-11-02, proposal 3; billing §7, Marcel 2026-11-04). The FIRST that applies:\n *\n * 1. not in good standing → \"Your plan has ended.\" (`billing.standing.ended`, the first\n * sentence of the plan-ended banner's);\n * 2. `past_due` → \"Payment failed — we’re retrying. Your plan stays active meanwhile.\",\n * with NO renewal date: the payment-failed banner keeps the action;\n * 3. `trialing` → \"Trial until {trial_ends_at}\" (a pure guest's trial that has not\n * started, §12.9, has no date and says nothing);\n * 4. `comped` → \"Free until {comped_until}\", or \"Free, with no end date\";\n * 5. set to cancel at the period's end → \"Ends on {current_period_end}\";\n * 6. `active` → \"Renews on {current_period_end}\";\n * 7. otherwise nothing.\n *\n * kastlan and keksdose each wrote this line, and they drifted: kastlan said nothing for\n * an ended plan or a failed payment, and keksdose's `past_due` said \"Renews on …\".\n *\n * `format` prints a date in a sentence; default the kit's `formatDate` (`\"medium\"`) in\n * the runtime's locale. Inside a component, `<SubscriptionStanding>` prints it the\n * provider's way — its `formatDate` (the app's date preference), else its locale and\n * time zone.\n */\nexport function subscriptionStanding(\n overview: SubscriptionStandingOverview,\n labels: Pick<BillingLabels, \"standing\">,\n format: (value: DateInput) => string = (value) => formatDate(value),\n): string | null {\n const s = standingOf(overview);\n return s && standingText(s, labels.standing, format);\n}\n\nexport interface SubscriptionStandingProps extends Omit<ComponentPropsWithRef<\"p\">, \"children\"> {\n /** The overview, or the fields of it the line reads. */\n overview: SubscriptionStandingOverview;\n /** The date's locale. Default: the provider's. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ billing: { standing } }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * {@link subscriptionStanding} as the muted line under the plan's name on the\n * subscription page (billing §7) — nothing at all when no line applies. The date is a\n * day in a sentence, without its weekday: the `<UiKitProvider formatDate>` (the app's\n * date preference) when it set one, else the kit's `formatDate`, `\"medium\"`, in the\n * provider's locale and `timeZone` — as `CancelSubscriptionConfirm` names the same day.\n *\n * Carries `data-standing` with the line's key (`ended`, `pastDue`, `trialUntil`,\n * `freeUntil`, `free`, `endsOn`, `renewsOn`), for a test or a style hook.\n *\n * <SubscriptionStanding overview={overview} />\n *\n * Back from a checkout whose webhook has not landed (§12.21), the ended line is the stale\n * one: render it only when `useCheckoutProcessing`'s `processing` is false, and the\n * processing banner speaks meanwhile.\n */\nexport function SubscriptionStanding({\n overview,\n locale: localeProp,\n labels: labelsProp,\n className,\n ...rest\n}: SubscriptionStandingProps) {\n const labels = useBillingLabels(labelsProp);\n const day = useDayFormatter(undefined, localeProp);\n const s = standingOf(overview);\n if (!s) return null;\n return (\n <p {...rest} data-standing={s.kind} className={cn(\"text-sm text-muted\", className)}>\n {standingText(s, labels.standing, (value) => day(value))}\n </p>\n );\n}\n"],"mappings":";AA8HI;AA3HJ,SAAS,UAAU;AACnB,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAE3B,SAAS,wBAAwB;AAoBjC,MAAM,QAAQ,CAAC,UAA8B,UAAU,QAAQ,UAAU,UAAa,UAAU;AAGhG,SAAS,WAAW,GAAkD;AAGpE,MAAI,CAAC,EAAE,iBAAkB,QAAO,EAAE,MAAM,QAAQ;AAEhD,MAAI,EAAE,WAAW,WAAY,QAAO,EAAE,MAAM,UAAU;AACtD,MAAI,EAAE,WAAW,WAAY,QAAO,MAAM,EAAE,aAAa,IAAI,EAAE,MAAM,cAAc,MAAM,EAAE,cAAc,IAAI;AAC7G,MAAI,EAAE,WAAW,UAAU;AACzB,WAAO,MAAM,EAAE,YAAY,IAAI,EAAE,MAAM,aAAa,MAAM,EAAE,aAAa,IAAI,EAAE,MAAM,OAAO;AAAA,EAC9F;AACA,MAAI,CAAC,MAAM,EAAE,kBAAkB,EAAG,QAAO;AACzC,MAAI,EAAE,qBAAsB,QAAO,EAAE,MAAM,UAAU,MAAM,EAAE,mBAAmB;AAChF,MAAI,EAAE,WAAW,SAAU,QAAO,EAAE,MAAM,YAAY,MAAM,EAAE,mBAAmB;AACjF,SAAO;AACT;AAEA,SAAS,aAAa,GAAa,OAAkC,KAA2C;AAC9G,UAAQ,EAAE,MAAM;AAAA,IACd,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO,MAAM,EAAE,IAAI;AAAA,IACrB;AACE,aAAO,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;AAAA,EACpC;AACF;AAyBO,SAAS,qBACd,UACA,QACA,SAAuC,CAAC,UAAU,WAAW,KAAK,GACnD;AACf,QAAM,IAAI,WAAW,QAAQ;AAC7B,SAAO,KAAK,aAAa,GAAG,OAAO,UAAU,MAAM;AACrD;AA2BO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAA8B;AAC5B,QAAM,SAAS,iBAAiB,UAAU;AAC1C,QAAM,MAAM,gBAAgB,QAAW,UAAU;AACjD,QAAM,IAAI,WAAW,QAAQ;AAC7B,MAAI,CAAC,EAAG,QAAO;AACf,SACE,oBAAC,OAAG,GAAG,MAAM,iBAAe,EAAE,MAAM,WAAW,GAAG,sBAAsB,SAAS,GAC9E,uBAAa,GAAG,OAAO,UAAU,CAAC,UAAU,IAAI,KAAK,CAAC,GACzD;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/billing/subscription-standing.tsx"],"sourcesContent":["import type { ComponentPropsWithRef } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useDayFormatter } from \"../lib/day-format\";\nimport { formatDate } from \"../lib/format\";\nimport type { DateInput } from \"../lib/format\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\nimport type { BillingOverview } from \"./billing-overview\";\n\n/**\n * What the standing line reads of `GET /billing/overview` (docs/billing-harmonization.md\n * §4), under the wire names. Pass the overview as it came — or with the app's own\n * knowledge laid over it: kastlan says \"Ends on …\" the moment its in-app cancel is asked\n * (`{ ...overview, cancel_at_period_end: view.cancelScheduled }`, `useCancelRequest`).\n */\nexport type SubscriptionStandingOverview = Pick<\n BillingOverview,\n \"status\" | \"in_good_standing\" | \"cancel_at_period_end\"\n> &\n // The dates may be left out, as `null` (0.55.1): an app's generated overview has them\n // optional with a default, and keksdose had to pass all three by name.\n Partial<Pick<BillingOverview, \"trial_ends_at\" | \"comped_until\" | \"current_period_end\">>;\n\n/** Which of `billing.standing`'s lines applies, and the date it names. */\ntype Standing =\n | { kind: \"ended\" | \"pastDue\" | \"free\" }\n | { kind: \"trialUntil\" | \"freeUntil\" | \"endsOn\" | \"renewsOn\"; date: DateInput };\n\nconst given = (value: DateInput): boolean => value !== null && value !== undefined && value !== \"\";\n\n/** Marcel's order (billing §7, 2026-11-04): the first that applies, or none. */\nfunction standingOf(o: SubscriptionStandingOverview): Standing | null {\n // The server's word beats the status: `canceled` and `expired` are never in good\n // standing, and a lapsed `past_due` is no longer \"retrying\" (§3.3).\n if (!o.in_good_standing) return { kind: \"ended\" };\n // Still in good standing, and no date: a renewal the retries may not reach.\n if (o.status === \"past_due\") return { kind: \"pastDue\" };\n if (o.status === \"trialing\") return given(o.trial_ends_at) ? { kind: \"trialUntil\", date: o.trial_ends_at } : null;\n if (o.status === \"comped\") {\n return given(o.comped_until) ? { kind: \"freeUntil\", date: o.comped_until } : { kind: \"free\" };\n }\n if (!given(o.current_period_end)) return null;\n if (o.cancel_at_period_end) return { kind: \"endsOn\", date: o.current_period_end };\n if (o.status === \"active\") return { kind: \"renewsOn\", date: o.current_period_end };\n return null;\n}\n\nfunction standingText(s: Standing, words: BillingLabels[\"standing\"], day: (value: DateInput) => string): string {\n switch (s.kind) {\n case \"ended\":\n case \"pastDue\":\n case \"free\":\n return words[s.kind];\n default:\n return words[s.kind](day(s.date));\n }\n}\n\n/**\n * The subscription's standing in one line — or `null` when nothing applies (kastlan reuse\n * audit 2026-11-02, proposal 3; billing §7, Marcel 2026-11-04). The FIRST that applies:\n *\n * 1. not in good standing → \"Your plan has ended.\" (`billing.standing.ended`, the first\n * sentence of the plan-ended banner's);\n * 2. `past_due` → \"Payment failed — we’re retrying. Your plan stays active meanwhile.\",\n * with NO renewal date: the payment-failed banner keeps the action;\n * 3. `trialing` → \"Trial until {trial_ends_at}\" (a pure guest's trial that has not\n * started, §12.9, has no date and says nothing);\n * 4. `comped` → \"Free until {comped_until}\", or \"Free, with no end date\";\n * 5. set to cancel at the period's end → \"Ends on {current_period_end}\";\n * 6. `active` → \"Renews on {current_period_end}\";\n * 7. otherwise nothing.\n *\n * kastlan and keksdose each wrote this line, and they drifted: kastlan said nothing for\n * an ended plan or a failed payment, and keksdose's `past_due` said \"Renews on …\".\n *\n * `format` prints a date in a sentence; default the kit's `formatDate` (`\"medium\"`) in\n * the runtime's locale. Inside a component, `<SubscriptionStanding>` prints it the\n * provider's way — its `formatDate` (the app's date preference), else its locale and\n * time zone.\n */\nexport function subscriptionStanding(\n overview: SubscriptionStandingOverview,\n labels: Pick<BillingLabels, \"standing\">,\n format: (value: DateInput) => string = (value) => formatDate(value),\n): string | null {\n const s = standingOf(overview);\n return s && standingText(s, labels.standing, format);\n}\n\nexport interface SubscriptionStandingProps extends Omit<ComponentPropsWithRef<\"p\">, \"children\"> {\n /** The overview, or the fields of it the line reads. */\n overview: SubscriptionStandingOverview;\n /** The date's locale. Default: the provider's. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ billing: { standing } }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * {@link subscriptionStanding} as the muted line under the plan's name on the\n * subscription page (billing §7) — nothing at all when no line applies. The date is a\n * day in a sentence, without its weekday: the `<UiKitProvider formatDate>` (the app's\n * date preference) when it set one, else the kit's `formatDate`, `\"medium\"`, in the\n * provider's locale and `timeZone` — as `CancelSubscriptionConfirm` names the same day.\n *\n * Carries `data-standing` with the line's key (`ended`, `pastDue`, `trialUntil`,\n * `freeUntil`, `free`, `endsOn`, `renewsOn`), for a test or a style hook.\n *\n * <SubscriptionStanding overview={overview} />\n *\n * Back from a checkout whose webhook has not landed (§12.21), the ended line is the stale\n * one: render it only when `useCheckoutProcessing`'s `processing` is false, and the\n * processing banner speaks meanwhile.\n */\nexport function SubscriptionStanding({\n overview,\n locale: localeProp,\n labels: labelsProp,\n className,\n ...rest\n}: SubscriptionStandingProps) {\n const labels = useBillingLabels(labelsProp);\n const day = useDayFormatter(undefined, localeProp);\n const s = standingOf(overview);\n if (!s) return null;\n return (\n <p {...rest} data-standing={s.kind} className={cn(\"text-sm text-muted\", className)}>\n {standingText(s, labels.standing, (value) => day(value))}\n </p>\n );\n}\n"],"mappings":";AAiII;AA9HJ,SAAS,UAAU;AACnB,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAE3B,SAAS,wBAAwB;AAuBjC,MAAM,QAAQ,CAAC,UAA8B,UAAU,QAAQ,UAAU,UAAa,UAAU;AAGhG,SAAS,WAAW,GAAkD;AAGpE,MAAI,CAAC,EAAE,iBAAkB,QAAO,EAAE,MAAM,QAAQ;AAEhD,MAAI,EAAE,WAAW,WAAY,QAAO,EAAE,MAAM,UAAU;AACtD,MAAI,EAAE,WAAW,WAAY,QAAO,MAAM,EAAE,aAAa,IAAI,EAAE,MAAM,cAAc,MAAM,EAAE,cAAc,IAAI;AAC7G,MAAI,EAAE,WAAW,UAAU;AACzB,WAAO,MAAM,EAAE,YAAY,IAAI,EAAE,MAAM,aAAa,MAAM,EAAE,aAAa,IAAI,EAAE,MAAM,OAAO;AAAA,EAC9F;AACA,MAAI,CAAC,MAAM,EAAE,kBAAkB,EAAG,QAAO;AACzC,MAAI,EAAE,qBAAsB,QAAO,EAAE,MAAM,UAAU,MAAM,EAAE,mBAAmB;AAChF,MAAI,EAAE,WAAW,SAAU,QAAO,EAAE,MAAM,YAAY,MAAM,EAAE,mBAAmB;AACjF,SAAO;AACT;AAEA,SAAS,aAAa,GAAa,OAAkC,KAA2C;AAC9G,UAAQ,EAAE,MAAM;AAAA,IACd,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO,MAAM,EAAE,IAAI;AAAA,IACrB;AACE,aAAO,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;AAAA,EACpC;AACF;AAyBO,SAAS,qBACd,UACA,QACA,SAAuC,CAAC,UAAU,WAAW,KAAK,GACnD;AACf,QAAM,IAAI,WAAW,QAAQ;AAC7B,SAAO,KAAK,aAAa,GAAG,OAAO,UAAU,MAAM;AACrD;AA2BO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAA8B;AAC5B,QAAM,SAAS,iBAAiB,UAAU;AAC1C,QAAM,MAAM,gBAAgB,QAAW,UAAU;AACjD,QAAM,IAAI,WAAW,QAAQ;AAC7B,MAAI,CAAC,EAAG,QAAO;AACf,SACE,oBAAC,OAAG,GAAG,MAAM,iBAAe,EAAE,MAAM,WAAW,GAAG,sBAAsB,SAAS,GAC9E,uBAAa,GAAG,OAAO,UAAU,CAAC,UAAU,IAAI,KAAK,CAAC,GACzD;AAEJ;","names":[]}
@@ -118,6 +118,7 @@ function ButtonElement({
118
118
  onClick: (e) => {
119
119
  if (inert) {
120
120
  e.preventDefault();
121
+ e.stopPropagation();
121
122
  return;
122
123
  }
123
124
  onClick?.(e);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/button.tsx"],"sourcesContent":["import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport type { CommitScope } from \"./write-lock\";\nimport { LockedReason, useRefusal, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport { BUTTON_LINK_PRESSED, ICON_BUTTON_PRESSED, RenderedButtonLink, buttonLook } from \"./button-look\";\nimport type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-look\";\n\n// The kit's text button, and `<Button href>`, the link that looks like one. Its look —\n// the variant, size and tone maps, and `buttonClasses` — is ./button-look, which\n// IconButton shares (0.34, MOD-2, refactor audit 2026-10).\n\n/**\n * ⚠️ No default `type`: like a native `<button>`, a `Button` inside a `<form>` SUBMITS\n * it unless you pass `type=\"button\"`. Kept native on purpose — a form's own submit\n * button relies on it, and changing the default would silently stop those forms from\n * submitting. Pass `type=\"button\"` for every other action in a form (keksdose had six\n * link buttons submitting their form).\n */\nexport interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** `default` (the quiet action, what a Button without `variant` draws), `accent` (the\n * strong call to action, solid brand), `secondary`, `ghost`, `danger` or `link`. See\n * {@link ButtonVariant}. */\n variant?: ButtonVariant;\n /** `md` (default) is the page's action button. `sm` is the compact one — 12px text\n * and `px-2 py-1` — for the secondary actions in a toolbar, a card header or a\n * table's header row (keksdose writes `px-2 py-1 text-xs` over `secondary` by hand\n * there). Every variant takes either size. */\n size?: ButtonSize;\n /** In a flex row next to a taller labelled field, fill the field's height so the\n * two line up. No effect outside a flex row. */\n stretch?: boolean;\n /** The `<button>` element. React 19 passes `ref` to a function component as an\n * ordinary prop, so it rides `...rest` onto the element with no `forwardRef` —\n * declared here only because `ButtonHTMLAttributes` does not carry it. */\n ref?: Ref<HTMLButtonElement>;\n /** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or\n * `danger` (quiet, `--danger` on hover). `danger` also makes `secondary` an\n * outlined destructive button and `default` a soft one; ignored by the other\n * variants. See {@link ButtonTone}. */\n tone?: ButtonTone;\n /**\n * Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets\n * `aria-pressed=\"true\"` and draws the \"on\" look — on a `link`, the brand colour and a\n * medium weight; on the boxed variants, the quiet brand fill IconButton uses — and\n * `false` sets `aria-pressed=\"false\"` with the ordinary look. Left out, no\n * `aria-pressed` (or the caller's own). For lenkbank's \"All speeds\" text toggle\n * (gear/hysteresis-charts.tsx:482), which is a hand-rolled `<button aria-pressed>`\n * swapping two class strings; with `variant=\"link\" tone=\"muted\"` it is this.\n */\n pressed?: boolean;\n /**\n * Why the action is not available — the button's half of DangerConfirm's\n * `lockedReason` (kastlan handover-detail-page.tsx:197 locks a signed handover).\n *\n * A disabled button that cannot say why is a dead end: the native `disabled` takes it\n * out of the tab order, so a keyboard user never lands on it, and a pointer gets a\n * `not-allowed` cursor and nothing else. With a reason the button is `aria-disabled`\n * instead — still focusable, still hoverable — clicks (and the Enter/Space and form\n * submission they stand for) do nothing, and the reason is shown in the kit\n * {@link Tooltip} and attached with `aria-describedby`, so a screen reader hears it\n * on focus. It wins over `disabled`: passing both keeps the button reachable, which is\n * the point of giving a reason.\n *\n * The description is a `hidden` copy of the reason rather than the bubble itself: the\n * bubble exists only while hovered or focused once it is portalled, and a description\n * that comes and goes is read inconsistently. The bubble is kept visual, the way\n * DangerConfirm keeps its own.\n *\n * `refused={reason}` (0.55) is the same refusal by the button's own name for it, with\n * a form for a reason said elsewhere — see `refused`.\n */\n disabledReason?: ReactNode;\n /**\n * Refuse the action and stay where the user can learn why — no permission, a\n * read-only plan, a held lock, an end already reached (0.52 code review 2026-10-31,\n * held to 0.55). Shown with `disabled`, a refused action leaves the tab order and says\n * nothing; an app's way round that was a Tooltip over the disabled button, which a\n * keyboard never opens (Kurvenschmiede's owner-only delete,\n * shared/lib/table-columns.tsx), and the kit's own arrows at an end hand-wrote\n * `aria-disabled` and a guard in their `onClick`.\n *\n * Refused, the button is `aria-disabled=\"true\"` — never the native `disabled`, which\n * it wins over — and stays focusable and hoverable, with the disabled look; every\n * activation is swallowed: the click, the Enter and Space that stand for it, and the\n * submission a submit button would make.\n *\n * - **Words** (`refused={t(\"sharing.owner_only\")}`): the reason, said the\n * `disabledReason` way — the kit {@link Tooltip}, or a line at Large and on touch\n * (`disabledReasonDisplay`) — and attached with `aria-describedby`, so a screen\n * reader hears it on focus. One path with `disabledReason`; these words win over its.\n * - **`true`**: refused with no words of its own, for a reason the screen already\n * says — a banner over the form, a line under it — which the button's own\n * `aria-describedby` names ({@link DisabledReasonLine} draws the line with its id),\n * or one that needs no saying (› on the last picture). No bubble.\n * - `false`, `null`, `undefined` or `\"\"`: not refused, as `cond && reason` gives.\n *\n * Under a locked {@link WriteLockProvider} a `commit` button says the lock's reason\n * instead, as it does over `disabledReason`.\n */\n refused?: ReactNode | boolean;\n /**\n * Where `disabledReason` shows (0.32, §4 \"No fact only in a tooltip\"): by default a\n * line under the button at Large and on a touch screen, the tooltip otherwise. See\n * {@link DisabledReasonDisplay}. A worded `refused` shows the same way.\n */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /**\n * This button COMMITS — it saves, creates, deletes. Under a locked\n * {@link WriteLockProvider} it is disabled the `disabledReason` way, with the lock's\n * reason (which wins over a `disabledReason` of its own). No provider, or an\n * unlocked one: no effect. See {@link WriteLockProvider}; a {@link CommitScope} since\n * 0.33 (`COMMIT_EXCEPT_BILLING`: every lock but a lapsed plan's).\n */\n commit?: CommitScope;\n /**\n * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.\n *\n * kastlan's FormActions (form-actions.tsx:50) disables its submit and swaps in a\n * `submittingLabel`, so the button jumps width mid-click and a screen reader hears\n * the name change instead of \"busy\". Here the label STAYS — it is still the\n * accessible name and still holds the button's width — and the spinner is drawn over\n * it in the text colour, so nothing beside the button moves.\n *\n * The same prop {@link FileButton} has (`pending`), with one difference: this one is\n * `aria-disabled`, not `disabled`. The button being pressed is the one that has focus,\n * and a native `disabled` drops that focus to `<body>` the moment the save starts;\n * clicks, and the Enter that submits a form through it, are swallowed instead.\n */\n pending?: boolean;\n /** Only on the link form — see {@link ButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<Button href>`: a link that looks like a button — for an action that NAVIGATES.\n *\n * kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the\n * invoice / lease / unit preview dialogs' \"Open\" at invoice-preview-dialog.tsx:36,\n * platform-companies-page.tsx:111, verify-email-page.tsx:86's \"Log in\",\n * billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>\n * navigate(…)}>` that are links in all but markup: no middle-click, no \"open in new\n * tab\", no URL on hover, and a screen reader announces a button that then changes the\n * page. With `href` it is an `<a>`: the provider's router link\n * ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.\n *\n * `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.\n */\nexport interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\"> {\n href: string;\n variant?: ButtonVariant;\n size?: ButtonSize;\n stretch?: boolean;\n tone?: ButtonTone;\n /** The in-app router link, over the provider's `linkComponent` (see\n * {@link KitLinkComponent}). Ignored when `external`. */\n renderLink?: KitLinkComponent;\n /** Leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`, never the\n * router link, and \"(opens in a new tab)\" read after the label — billing-history's\n * Stripe PDF. A `target=\"_blank\"` of your own is announced the same way. */\n external?: boolean;\n /**\n * Navigate by replacing the current history entry, so Back does not return here —\n * keksdose F1: the \"Continue\" out of a one-shot page (an emailed confirmation link, a\n * payment return URL) that must not be landed on again. Handed to the router link as\n * `replace` (see `KitLinkProps.replace` for mapping it); on a plain `<a>` a plain click\n * becomes `location.replace(href)`. Ignored when `external`.\n */\n replace?: boolean;\n /**\n * A plain `<a>` for an in-app `href`, so the browser loads the whole document instead\n * of the router swapping the view — keksdose F1: a route the SPA does not own (a\n * server-rendered export, a logout that must drop every in-memory cache). Wins over\n * `renderLink` and the provider's `linkComponent`.\n */\n reloadDocument?: boolean;\n /** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —\n * `role=\"link\"` and `aria-disabled`, out of the tab order like a disabled button,\n * with the disabled look. The router link is not used: it needs somewhere to go. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pending?: never;\n pressed?: never;\n disabledReason?: never;\n /** A link that explains why it cannot be followed is a refused BUTTON — see\n * {@link ButtonProps.refused}. */\n refused?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n}\n\n// Declared link-first so `<Button href>` picks the link props; the LAST signature is\n// what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the\n// `<button>` props they always did.\nexport function Button(props: ButtonLinkProps): ReactElement;\nexport function Button(props: ButtonProps): ReactElement;\nexport function Button(props: ButtonProps | ButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;\n return <ButtonElement {...(props as ButtonProps)} />;\n}\n\nfunction ButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"default\",\n size = \"md\",\n stretch,\n tone = \"default\",\n className,\n children,\n target,\n rel,\n onClick,\n ...rest\n}: ButtonLinkProps) {\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n // The same rule as every kit link (`pickLinkRenderer`): an external `href` or an\n // in-page `#anchor` is never handed to the provider's router link, even without\n // `external`.\n // `reloadDocument` is the caller saying \"not the router\" for this one link.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const cls = cn(\n buttonLook(variant, size, stretch, tone),\n // `relative` holds the sr-only note (see sr-only-containment.test).\n newTab && \"relative\",\n // BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also\n // keeps the hover fill from lighting up on something that refuses the click.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const body = (\n <>\n {children}\n {/* The space OUTSIDE the sr-only span: a name computation trims each child's\n text, so one inside it was lost (\"Download(opens…\"). In a flex box a bare\n space draws nothing. */}\n {newTab && \" \"}\n {newTab && <span className=\"sr-only\">({common.opensInNewTab})</span>}\n </>\n );\n if (disabled) {\n return (\n <a {...rest} role=\"link\" aria-disabled=\"true\" className={cls}>\n {body}\n </a>\n );\n }\n if (external || !Link) {\n return (\n <a\n {...rest}\n href={href}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? onClick : replacingClick(onClick, href, replace, target)}\n className={cls}\n >\n {body}\n </a>\n );\n }\n return (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...routerLinkNavigation(replace)}\n href={href}\n target={target}\n rel={rel}\n onClick={onClick}\n className={cls}\n >\n {body}\n </RenderedButtonLink>\n );\n}\n\nfunction ButtonElement({\n variant = \"default\",\n size = \"md\",\n stretch,\n tone = \"default\",\n pressed,\n refused: refusedProp,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n className,\n onClick,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ...rest\n}: ButtonProps) {\n // `locked`: refused WITH words, which describe it; `refused` also covers `true`.\n const { reason: disabledReason, locked, reasonId, refused } = useRefusal(refusedProp, ownDisabledReason, commit);\n const inert = refused || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n onClick={(e) => {\n // `preventDefault` as well as not calling through: a locked submit button must\n // not submit its form, and the browser does that after this handler returns.\n if (inert) {\n e.preventDefault();\n return;\n }\n onClick?.(e);\n }}\n className={cn(\n buttonLook(variant, size, stretch, tone),\n pressed && (variant === \"link\" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),\n // The disabled look for the focusable kind of disabled.\n refused && \"cursor-not-allowed opacity-50\",\n // No dimming while pending: the spinner IS the state, and at half opacity it\n // would be the faintest thing on the button. `relative` anchors it.\n pending && \"relative cursor-progress\",\n className,\n )}\n >\n {pending ? (\n <>\n {/* Transparent, not hidden: it keeps the width AND stays the accessible\n name (`visibility: hidden` would drop it from the name). `gap-[inherit]`\n keeps an icon + text spaced as the button spaces them. */}\n <span className=\"inline-flex items-center justify-center gap-[inherit] opacity-0\">{children}</span>\n {/* Decorative: `aria-busy` says it, as on FileButton. */}\n <Spinner label={null} className=\"absolute inset-0 m-auto size-4 border-current/30 border-t-current\" />\n </>\n ) : (\n children\n )}\n </button>\n );\n if (!locked) return button;\n return (\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {button}\n </LockedReason>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/button.tsx"],"sourcesContent":["import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, replacingClick, routerLinkNavigation } from \"./text-link\";\nimport type { CommitScope } from \"./write-lock\";\nimport { LockedReason, useRefusal, type DisabledReasonDisplay } from \"./field-parts\";\nimport { Spinner } from \"./spinner\";\nimport { BUTTON_LINK_PRESSED, ICON_BUTTON_PRESSED, RenderedButtonLink, buttonLook } from \"./button-look\";\nimport type { ButtonSize, ButtonTone, ButtonVariant } from \"./button-look\";\n\n// The kit's text button, and `<Button href>`, the link that looks like one. Its look —\n// the variant, size and tone maps, and `buttonClasses` — is ./button-look, which\n// IconButton shares (0.34, MOD-2, refactor audit 2026-10).\n\n/**\n * ⚠️ No default `type`: like a native `<button>`, a `Button` inside a `<form>` SUBMITS\n * it unless you pass `type=\"button\"`. Kept native on purpose — a form's own submit\n * button relies on it, and changing the default would silently stop those forms from\n * submitting. Pass `type=\"button\"` for every other action in a form (keksdose had six\n * link buttons submitting their form).\n */\nexport interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /** `default` (the quiet action, what a Button without `variant` draws), `accent` (the\n * strong call to action, solid brand), `secondary`, `ghost`, `danger` or `link`. See\n * {@link ButtonVariant}. */\n variant?: ButtonVariant;\n /** `md` (default) is the page's action button. `sm` is the compact one — 12px text\n * and `px-2 py-1` — for the secondary actions in a toolbar, a card header or a\n * table's header row (keksdose writes `px-2 py-1 text-xs` over `secondary` by hand\n * there). Every variant takes either size. */\n size?: ButtonSize;\n /** In a flex row next to a taller labelled field, fill the field's height so the\n * two line up. No effect outside a flex row. */\n stretch?: boolean;\n /** The `<button>` element. React 19 passes `ref` to a function component as an\n * ordinary prop, so it rides `...rest` onto the element with no `forwardRef` —\n * declared here only because `ButtonHTMLAttributes` does not carry it. */\n ref?: Ref<HTMLButtonElement>;\n /** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or\n * `danger` (quiet, `--danger` on hover). `danger` also makes `secondary` an\n * outlined destructive button and `default` a soft one; ignored by the other\n * variants. See {@link ButtonTone}. */\n tone?: ButtonTone;\n /**\n * Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets\n * `aria-pressed=\"true\"` and draws the \"on\" look — on a `link`, the brand colour and a\n * medium weight; on the boxed variants, the quiet brand fill IconButton uses — and\n * `false` sets `aria-pressed=\"false\"` with the ordinary look. Left out, no\n * `aria-pressed` (or the caller's own). For lenkbank's \"All speeds\" text toggle\n * (gear/hysteresis-charts.tsx:482), which is a hand-rolled `<button aria-pressed>`\n * swapping two class strings; with `variant=\"link\" tone=\"muted\"` it is this.\n */\n pressed?: boolean;\n /**\n * Why the action is not available — the button's half of DangerConfirm's\n * `lockedReason` (kastlan handover-detail-page.tsx:197 locks a signed handover).\n *\n * A disabled button that cannot say why is a dead end: the native `disabled` takes it\n * out of the tab order, so a keyboard user never lands on it, and a pointer gets a\n * `not-allowed` cursor and nothing else. With a reason the button is `aria-disabled`\n * instead — still focusable, still hoverable — clicks (and the Enter/Space and form\n * submission they stand for) do nothing, and the reason is shown in the kit\n * {@link Tooltip} and attached with `aria-describedby`, so a screen reader hears it\n * on focus. It wins over `disabled`: passing both keeps the button reachable, which is\n * the point of giving a reason.\n *\n * The description is a `hidden` copy of the reason rather than the bubble itself: the\n * bubble exists only while hovered or focused once it is portalled, and a description\n * that comes and goes is read inconsistently. The bubble is kept visual, the way\n * DangerConfirm keeps its own.\n *\n * `refused={reason}` (0.55) is the same refusal by the button's own name for it, with\n * a form for a reason said elsewhere — see `refused`.\n */\n disabledReason?: ReactNode;\n /**\n * Refuse the action and stay where the user can learn why — no permission, a\n * read-only plan, a held lock, an end already reached (0.52 code review 2026-10-31,\n * held to 0.55). Shown with `disabled`, a refused action leaves the tab order and says\n * nothing; an app's way round that was a Tooltip over the disabled button, which a\n * keyboard never opens (Kurvenschmiede's owner-only delete,\n * shared/lib/table-columns.tsx), and the kit's own arrows at an end hand-wrote\n * `aria-disabled` and a guard in their `onClick`.\n *\n * Refused, the button is `aria-disabled=\"true\"` — never the native `disabled`, which\n * it wins over — and stays focusable and hoverable, with the disabled look; every\n * activation is swallowed: the click, the Enter and Space that stand for it, and the\n * submission a submit button would make.\n *\n * - **Words** (`refused={t(\"sharing.owner_only\")}`): the reason, said the\n * `disabledReason` way — the kit {@link Tooltip}, or a line at Large and on touch\n * (`disabledReasonDisplay`) — and attached with `aria-describedby`, so a screen\n * reader hears it on focus. One path with `disabledReason`; these words win over its.\n * - **`true`**: refused with no words of its own, for a reason the screen already\n * says — a banner over the form, a line under it — which the button's own\n * `aria-describedby` names ({@link DisabledReasonLine} draws the line with its id),\n * or one that needs no saying (› on the last picture). No bubble.\n * - `false`, `null`, `undefined` or `\"\"`: not refused, as `cond && reason` gives.\n *\n * Under a locked {@link WriteLockProvider} a `commit` button says the lock's reason\n * instead, as it does over `disabledReason`.\n */\n refused?: ReactNode | boolean;\n /**\n * Where `disabledReason` shows (0.32, §4 \"No fact only in a tooltip\"): by default a\n * line under the button at Large and on a touch screen, the tooltip otherwise. See\n * {@link DisabledReasonDisplay}. A worded `refused` shows the same way.\n */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /**\n * This button COMMITS — it saves, creates, deletes. Under a locked\n * {@link WriteLockProvider} it is disabled the `disabledReason` way, with the lock's\n * reason (which wins over a `disabledReason` of its own). No provider, or an\n * unlocked one: no effect. See {@link WriteLockProvider}; a {@link CommitScope} since\n * 0.33 (`COMMIT_EXCEPT_BILLING`: every lock but a lapsed plan's).\n */\n commit?: CommitScope;\n /**\n * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.\n *\n * kastlan's FormActions (form-actions.tsx:50) disables its submit and swaps in a\n * `submittingLabel`, so the button jumps width mid-click and a screen reader hears\n * the name change instead of \"busy\". Here the label STAYS — it is still the\n * accessible name and still holds the button's width — and the spinner is drawn over\n * it in the text colour, so nothing beside the button moves.\n *\n * The same prop {@link FileButton} has (`pending`), with one difference: this one is\n * `aria-disabled`, not `disabled`. The button being pressed is the one that has focus,\n * and a native `disabled` drops that focus to `<body>` the moment the save starts;\n * clicks, and the Enter that submits a form through it, are swallowed instead.\n */\n pending?: boolean;\n /** Only on the link form — see {@link ButtonLinkProps}. */\n href?: never;\n renderLink?: never;\n external?: never;\n replace?: never;\n reloadDocument?: never;\n}\n\n/**\n * `<Button href>`: a link that looks like a button — for an action that NAVIGATES.\n *\n * kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the\n * invoice / lease / unit preview dialogs' \"Open\" at invoice-preview-dialog.tsx:36,\n * platform-companies-page.tsx:111, verify-email-page.tsx:86's \"Log in\",\n * billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>\n * navigate(…)}>` that are links in all but markup: no middle-click, no \"open in new\n * tab\", no URL on hover, and a screen reader announces a button that then changes the\n * page. With `href` it is an `<a>`: the provider's router link\n * ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.\n *\n * `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.\n */\nexport interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"type\" | \"href\"> {\n href: string;\n variant?: ButtonVariant;\n size?: ButtonSize;\n stretch?: boolean;\n tone?: ButtonTone;\n /** The in-app router link, over the provider's `linkComponent` (see\n * {@link KitLinkComponent}). Ignored when `external`. */\n renderLink?: KitLinkComponent;\n /** Leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`, never the\n * router link, and \"(opens in a new tab)\" read after the label — billing-history's\n * Stripe PDF. A `target=\"_blank\"` of your own is announced the same way. */\n external?: boolean;\n /**\n * Navigate by replacing the current history entry, so Back does not return here —\n * keksdose F1: the \"Continue\" out of a one-shot page (an emailed confirmation link, a\n * payment return URL) that must not be landed on again. Handed to the router link as\n * `replace` (see `KitLinkProps.replace` for mapping it); on a plain `<a>` a plain click\n * becomes `location.replace(href)`. Ignored when `external`.\n */\n replace?: boolean;\n /**\n * A plain `<a>` for an in-app `href`, so the browser loads the whole document instead\n * of the router swapping the view — keksdose F1: a route the SPA does not own (a\n * server-rendered export, a logout that must drop every in-memory cache). Wins over\n * `renderLink` and the provider's `linkComponent`.\n */\n reloadDocument?: boolean;\n /** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —\n * `role=\"link\"` and `aria-disabled`, out of the tab order like a disabled button,\n * with the disabled look. The router link is not used: it needs somewhere to go. */\n disabled?: boolean;\n ref?: Ref<HTMLAnchorElement>;\n type?: never;\n form?: never;\n pending?: never;\n pressed?: never;\n disabledReason?: never;\n /** A link that explains why it cannot be followed is a refused BUTTON — see\n * {@link ButtonProps.refused}. */\n refused?: never;\n disabledReasonDisplay?: never;\n commit?: never;\n}\n\n// Declared link-first so `<Button href>` picks the link props; the LAST signature is\n// what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the\n// `<button>` props they always did.\nexport function Button(props: ButtonLinkProps): ReactElement;\nexport function Button(props: ButtonProps): ReactElement;\nexport function Button(props: ButtonProps | ButtonLinkProps): ReactElement {\n if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;\n return <ButtonElement {...(props as ButtonProps)} />;\n}\n\nfunction ButtonLink({\n href,\n renderLink,\n external,\n replace,\n reloadDocument,\n disabled,\n variant = \"default\",\n size = \"md\",\n stretch,\n tone = \"default\",\n className,\n children,\n target,\n rel,\n onClick,\n ...rest\n}: ButtonLinkProps) {\n const kitLink = useKitLink();\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n // The same rule as every kit link (`pickLinkRenderer`): an external `href` or an\n // in-page `#anchor` is never handed to the provider's router link, even without\n // `external`.\n // `reloadDocument` is the caller saying \"not the router\" for this one link.\n const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);\n const newTab = !disabled && (external || target === \"_blank\");\n const cls = cn(\n buttonLook(variant, size, stretch, tone),\n // `relative` holds the sr-only note (see sr-only-containment.test).\n newTab && \"relative\",\n // BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also\n // keeps the hover fill from lighting up on something that refuses the click.\n disabled && \"pointer-events-none cursor-not-allowed opacity-50\",\n className,\n );\n const body = (\n <>\n {children}\n {/* The space OUTSIDE the sr-only span: a name computation trims each child's\n text, so one inside it was lost (\"Download(opens…\"). In a flex box a bare\n space draws nothing. */}\n {newTab && \" \"}\n {newTab && <span className=\"sr-only\">({common.opensInNewTab})</span>}\n </>\n );\n if (disabled) {\n return (\n <a {...rest} role=\"link\" aria-disabled=\"true\" className={cls}>\n {body}\n </a>\n );\n }\n if (external || !Link) {\n return (\n <a\n {...rest}\n href={href}\n target={external ? \"_blank\" : target}\n rel={external ? (rel ?? \"noopener noreferrer\") : rel}\n onClick={external ? onClick : replacingClick(onClick, href, replace, target)}\n className={cls}\n >\n {body}\n </a>\n );\n }\n return (\n <RenderedButtonLink\n render={Link}\n {...rest}\n {...routerLinkNavigation(replace)}\n href={href}\n target={target}\n rel={rel}\n onClick={onClick}\n className={cls}\n >\n {body}\n </RenderedButtonLink>\n );\n}\n\nfunction ButtonElement({\n variant = \"default\",\n size = \"md\",\n stretch,\n tone = \"default\",\n pressed,\n refused: refusedProp,\n disabledReason: ownDisabledReason,\n disabledReasonDisplay,\n commit,\n pending,\n className,\n onClick,\n children,\n href: _href,\n renderLink: _renderLink,\n external: _external,\n replace: _replace,\n reloadDocument: _reloadDocument,\n ...rest\n}: ButtonProps) {\n // `locked`: refused WITH words, which describe it; `refused` also covers `true`.\n const { reason: disabledReason, locked, reasonId, refused } = useRefusal(refusedProp, ownDisabledReason, commit);\n const inert = refused || Boolean(pending);\n const ownDescribedBy = rest[\"aria-describedby\"];\n const button = (\n <button\n {...rest}\n disabled={inert ? undefined : rest.disabled}\n aria-disabled={inert || rest[\"aria-disabled\"]}\n aria-busy={pending || rest[\"aria-busy\"]}\n aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}\n aria-pressed={pressed ?? rest[\"aria-pressed\"]}\n onClick={(e) => {\n // `preventDefault` as well as not calling through: a locked submit button must\n // not submit its form, and the browser does that after this handler returns.\n // `stopPropagation` too: a natively disabled button gets no click at all, so a\n // clickable row around it never heard one, and the focusable refusal keeps that\n // (Kurvenschmiede's 0.55 adoption: a refused delete opened its row).\n if (inert) {\n e.preventDefault();\n e.stopPropagation();\n return;\n }\n onClick?.(e);\n }}\n className={cn(\n buttonLook(variant, size, stretch, tone),\n pressed && (variant === \"link\" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),\n // The disabled look for the focusable kind of disabled.\n refused && \"cursor-not-allowed opacity-50\",\n // No dimming while pending: the spinner IS the state, and at half opacity it\n // would be the faintest thing on the button. `relative` anchors it.\n pending && \"relative cursor-progress\",\n className,\n )}\n >\n {pending ? (\n <>\n {/* Transparent, not hidden: it keeps the width AND stays the accessible\n name (`visibility: hidden` would drop it from the name). `gap-[inherit]`\n keeps an icon + text spaced as the button spaces them. */}\n <span className=\"inline-flex items-center justify-center gap-[inherit] opacity-0\">{children}</span>\n {/* Decorative: `aria-busy` says it, as on FileButton. */}\n <Spinner label={null} className=\"absolute inset-0 m-auto size-4 border-current/30 border-t-current\" />\n </>\n ) : (\n children\n )}\n </button>\n );\n if (!locked) return button;\n return (\n <LockedReason\n lock={{ reason: disabledReason, reasonId }}\n display={disabledReasonDisplay}\n className={stretch ? \"self-stretch\" : undefined}\n >\n {button}\n </LockedReason>\n );\n}\n"],"mappings":"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","names":[]}
@@ -439,6 +439,22 @@ export interface DataTableProps<T> {
439
439
  allSelected: boolean;
440
440
  someSelected: boolean;
441
441
  onToggleAll: (checked: boolean) => void;
442
+ /**
443
+ * Told which rows the table's OWN filters keep — on every page, in the order `rows`
444
+ * gave them — each time that set changes (0.57, keksdose dev #663: the feedback
445
+ * inbox's bulk edit). Optional and additive.
446
+ *
447
+ * What a select-all should take and a bulk action act on. `onToggleAll` says only
448
+ * "checked", and the filters are the table's (its state, `urlSync`'s `f.*`), out of
449
+ * the owner's sight: an owner selecting "all of `rows`" under a status filter ticked
450
+ * the rows the filter hides too, and its bulk change then reached rows nobody was
451
+ * looking at. With this the owner selects, counts and acts on these rows.
452
+ *
453
+ * Called after the commit that changed the set, not on every render — compared by
454
+ * `rowKey`, so a fresh but equal `rows` (`list.data ?? []` while a query loads)
455
+ * does not call it again, and an owner may set state in it.
456
+ */
457
+ onFilteredRowsChange?: (rows: readonly T[]) => void;
442
458
  };
443
459
  /** User-facing strings (English defaults); pass translated overrides. Merged over
444
460
  * `dataTable` from `<UiKitProvider labels>`, so a call site only states what
@@ -243,6 +243,23 @@ function DataTable({
243
243
  }
244
244
  return result;
245
245
  }, [rows, columnSig, filters, serverRows, locale]);
246
+ const notifyFiltered = !!selection?.onFilteredRowsChange;
247
+ const filteredReport = useRef({ notify: selection?.onFilteredRowsChange, rowKey });
248
+ useLayoutEffect(() => {
249
+ filteredReport.current = { notify: selection?.onFilteredRowsChange, rowKey };
250
+ });
251
+ const reportedKeys = useRef(null);
252
+ useEffect(() => {
253
+ const { notify, rowKey: keyOf } = filteredReport.current;
254
+ if (!notify) {
255
+ reportedKeys.current = null;
256
+ return;
257
+ }
258
+ const keys = filtered.map((row) => String(keyOf(row))).join("\0");
259
+ if (keys === reportedKeys.current) return;
260
+ reportedKeys.current = keys;
261
+ notify(filtered);
262
+ }, [filtered, notifyFiltered]);
246
263
  const sorted = useMemo(() => {
247
264
  if (serverRows) return filtered;
248
265
  const chain = sorts.map((s) => {