@eifi1/ui-kit 0.51.3 → 0.51.4

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 (96) hide show
  1. package/README.md +5 -5
  2. package/dist/components/combobox-core.js +4 -5
  3. package/dist/components/combobox-core.js.map +1 -1
  4. package/dist/components/currency-option-list.js +3 -3
  5. package/dist/components/currency-option-list.js.map +1 -1
  6. package/dist/components/data-table-fit.js +7 -5
  7. package/dist/components/data-table-fit.js.map +1 -1
  8. package/dist/components/data-table-head.js +5 -1
  9. package/dist/components/data-table-head.js.map +1 -1
  10. package/dist/components/data-table.d.ts +8 -0
  11. package/dist/components/data-table.js.map +1 -1
  12. package/dist/components/grouped-picker.js +5 -4
  13. package/dist/components/grouped-picker.js.map +1 -1
  14. package/dist/components/icon-picker.js +5 -4
  15. package/dist/components/icon-picker.js.map +1 -1
  16. package/dist/components/inline-entity-combobox.js +4 -4
  17. package/dist/components/inline-entity-combobox.js.map +1 -1
  18. package/dist/components/legal-labels.js +8 -2
  19. package/dist/components/legal-labels.js.map +1 -1
  20. package/dist/components/legal-page.d.ts +18 -1
  21. package/dist/components/legal-page.js +13 -0
  22. package/dist/components/legal-page.js.map +1 -1
  23. package/dist/components/legal.d.ts +40 -5
  24. package/dist/components/legal.js +29 -3
  25. package/dist/components/legal.js.map +1 -1
  26. package/dist/components/list-labels.js +3 -1
  27. package/dist/components/list-labels.js.map +1 -1
  28. package/dist/components/list.d.ts +18 -1
  29. package/dist/components/list.js +32 -3
  30. package/dist/components/list.js.map +1 -1
  31. package/dist/eslint/index.js +1 -1
  32. package/dist/feedback/feedback-table.d.ts +3 -1
  33. package/dist/feedback/feedback-table.js +18 -4
  34. package/dist/feedback/feedback-table.js.map +1 -1
  35. package/dist/i18n/german.js +11 -3
  36. package/dist/i18n/german.js.map +1 -1
  37. package/dist/i18n/locales/en.js +15 -3
  38. package/dist/i18n/locales/en.js.map +2 -2
  39. package/dist/i18n/locales/es.js +11 -3
  40. package/dist/i18n/locales/es.js.map +1 -1
  41. package/dist/i18n/locales/fr.js +11 -3
  42. package/dist/i18n/locales/fr.js.map +1 -1
  43. package/dist/i18n/locales/hu.js +11 -3
  44. package/dist/i18n/locales/hu.js.map +1 -1
  45. package/dist/i18n/locales/it.js +11 -3
  46. package/dist/i18n/locales/it.js.map +1 -1
  47. package/dist/i18n/locales/zh.js +11 -3
  48. package/dist/i18n/locales/zh.js.map +1 -1
  49. package/dist/i18n/review.js +2 -0
  50. package/dist/i18n/review.js.map +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/lib/text-match.d.ts +16 -0
  54. package/dist/lib/text-match.js +11 -1
  55. package/dist/lib/text-match.js.map +1 -1
  56. package/dist/settings/use-settings-focus.d.ts +7 -0
  57. package/dist/settings/use-settings-focus.js +2 -0
  58. package/dist/settings/use-settings-focus.js.map +1 -1
  59. package/dist/shell/dev-menu.d.ts +5 -0
  60. package/dist/shell/dev-menu.js +2 -1
  61. package/dist/shell/dev-menu.js.map +1 -1
  62. package/dist/shell/topbar-action-menu.d.ts +14 -1
  63. package/dist/shell/topbar-action-menu.js +85 -34
  64. package/dist/shell/topbar-action-menu.js.map +1 -1
  65. package/dist/shell.d.ts +1 -1
  66. package/dist/shell.js +2 -1
  67. package/dist/shell.js.map +1 -1
  68. package/package.json +1 -1
  69. package/src/components/combobox-core.tsx +5 -5
  70. package/src/components/currency-option-list.tsx +4 -5
  71. package/src/components/data-table-fit.ts +7 -5
  72. package/src/components/data-table-head.tsx +5 -1
  73. package/src/components/data-table.tsx +8 -0
  74. package/src/components/grouped-picker.tsx +15 -8
  75. package/src/components/icon-picker.tsx +6 -4
  76. package/src/components/inline-entity-combobox.tsx +5 -4
  77. package/src/components/legal-labels.ts +7 -1
  78. package/src/components/legal-page.tsx +39 -0
  79. package/src/components/legal.tsx +69 -5
  80. package/src/components/list-labels.ts +2 -0
  81. package/src/components/list.tsx +52 -2
  82. package/src/feedback/feedback-table.tsx +17 -1
  83. package/src/i18n/german.ts +9 -1
  84. package/src/i18n/locales/en.ts +4 -0
  85. package/src/i18n/locales/es.ts +9 -1
  86. package/src/i18n/locales/fr.ts +9 -1
  87. package/src/i18n/locales/hu.ts +9 -1
  88. package/src/i18n/locales/it.ts +9 -1
  89. package/src/i18n/locales/zh.ts +9 -1
  90. package/src/i18n/review.ts +2 -0
  91. package/src/index.ts +1 -0
  92. package/src/lib/text-match.ts +30 -0
  93. package/src/settings/use-settings-focus.ts +8 -0
  94. package/src/shell/dev-menu.tsx +6 -0
  95. package/src/shell/topbar-action-menu.tsx +122 -38
  96. package/src/shell.ts +1 -1
package/dist/shell.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/shell.ts"],"sourcesContent":["// `@eifi1/ui-kit/shell` — the composable app chrome.\n//\n// A re-slicing of the main barrel, not a new API. This entry point requires\n// `react-router`: AppShell renders NavLink and TopBarActionMenu renders Link.\nexport * from \"./shell/topbar-controls\";\nexport * from \"./shell/top-bar\";\nexport * from \"./shell/app-shell\";\nexport * from \"./shell/option-switcher-menu\";\nexport * from \"./shell/role-switcher\";\nexport * from \"./shell/topbar-action-menu\";\nexport * from \"./shell/top-bar-brand\";\nexport * from \"./shell/auth-layout\";\n// 0.40.0: the list page's fill (docs/page-layout-harmonization.md §1.3).\nexport { FillHeight, FILL_HEIGHT_CLASS } from \"./shell/fill-height\";\nexport type { FillHeightProps } from \"./shell/fill-height\";\n// 0.42.0: the account menu's \"Administration\" row, as an entry and as a HoverMenu row\n// (docs/admin-harmonization.md §2.1, §6.6).\nexport { adminMenuEntry, AdminMenuRow } from \"./shell/admin-menu\";\nexport type { AdminMenuEntryOptions, AdminMenuRowProps } from \"./shell/admin-menu\";\n// 0.43.0 (kastlan): the entry with the provider's words.\nexport { useAdminMenuEntry } from \"./shell/admin-menu\";\nexport type { UseAdminMenuEntryOptions } from \"./shell/admin-menu\";\n// 0.45.0 (docs/admin-harmonization.md §9.3.4): the row's waiting pill on its own, for\n// IconButton's `badge` (kastlan's platform button); its name is `adminArea.waitingLabel`.\nexport { AdminWaitingBadge } from \"./shell/admin-menu\";\nexport type { AdminWaitingBadgeProps } from \"./shell/admin-menu\";\n// 0.31.0: the settings and admin pages' shell (docs/settings-harmonization.md §3, §5).\nexport { SettingsLayout, SETTINGS_SEARCH_THRESHOLD } from \"./settings/settings-layout\";\nexport type { SettingsLayoutProps, SettingsLayoutWidth, SettingsDocumentTitleParts } from \"./settings/settings-layout\";\nexport {\n useSettingsRoute,\n resolveSettingsLocation,\n settingsFromListState,\n SETTINGS_FROM_LIST_STATE,\n} from \"./settings/use-settings-route\";\nexport type {\n UseSettingsRouteOptions,\n SettingsRoute,\n SettingsLayoutMode,\n SettingsLocation,\n SettingsLocationRules,\n SettingsLocationResult,\n} from \"./settings/use-settings-route\";\nexport { useSettingsFocus, SETTINGS_FOCUS_RING, SETTINGS_FOCUS_MS } from \"./settings/use-settings-focus\";\nexport { SettingsSection } from \"./settings/settings-section\";\nexport type { SettingsSectionProps } from \"./settings/settings-section\";\nexport { useSettingsLayout } from \"./settings/settings-context\";\nexport type { SettingsLayoutContextValue } from \"./settings/settings-context\";\nexport { SettingsHeadingLevel, useSettingsHeadingLevel } from \"./settings/settings-heading\";\nexport type { SettingsHeadingLevelProps, SettingsHeadingTag } from \"./settings/settings-heading\";\nexport {\n settingsSearchEntries,\n visibleSettingsGroups,\n visibleSettingsEntries,\n settingsHref,\n} from \"./settings/settings-catalogue\";\nexport type { SettingsGroup, SettingsEntry, SettingsSearchEntriesOptions } from \"./settings/settings-catalogue\";\n// 0.31.0: the public pages' header and the resume (docs/landing-demo-harmonization.md §3, §4.1).\nexport {\n RootEntry,\n RedirectIfAuthed,\n useLastVisitedPage,\n readLastVisitedPage,\n clearLastVisitedPage,\n safeNextPath,\n NEXT_PARAM,\n DEFAULT_LAST_VISITED_EXCLUDES,\n} from \"./landing/routing\";\nexport type {\n RootEntryProps,\n RedirectIfAuthedProps,\n RoutingSession,\n LastVisitedPageOptions,\n PathPattern,\n} from \"./landing/routing\";\nexport { PublicHeader } from \"./landing/public-header\";\nexport type { PublicHeaderProps, PublicHeaderBrand } from \"./landing/public-header\";\n// 0.42.0: the top bar's shared parts (docs/top-bar-harmonization.md §3.1) — the slots in\n// §2.1's order, the account menu's language sub-list, the tours menu, the sync status, the\n// local-dev switchers, and their words.\nexport { TopBarActions, TOP_BAR_ACTION_ORDER } from \"./shell/top-bar-actions\";\nexport type { TopBarActionsProps } from \"./shell/top-bar-actions\";\nexport { AccountMenuLanguage } from \"./shell/account-menu-language\";\nexport type { AccountMenuLanguageProps, AccountMenuLanguageOption } from \"./shell/account-menu-language\";\n// 0.46.0 (keksdose live #426): A− / A+ for the text size, beside the language.\nexport { AccountMenuTextSize } from \"./shell/account-menu-text-size\";\nexport type { AccountMenuTextSizeProps } from \"./shell/account-menu-text-size\";\nexport { TourMenu } from \"./shell/tour-menu\";\nexport type { TourMenuProps, TourMenuTour } from \"./shell/tour-menu\";\nexport { SyncStatusIndicator, syncState } from \"./shell/sync-status-indicator\";\nexport type { SyncStatus, SyncState, SyncStatusScope, SyncStatusIndicatorProps } from \"./shell/sync-status-indicator\";\n// 0.51 (§2.7.1, keksdose): one flask, the kit's Account section and the app's own dev\n// sections; `DevSwitchers` is a deprecated wrapper over it until 0.52.\nexport { DevMenu, DEV_ORIGINAL_ROLE_KEY } from \"./shell/dev-menu\";\nexport type { DevMenuProps, DevMenuSection } from \"./shell/dev-menu\";\nexport { DevSwitchers } from \"./shell/dev-switchers\";\nexport type { DevSwitchersProps } from \"./shell/dev-switchers\";\nexport { DEFAULT_TOP_BAR_PARTS_LABELS, useTopBarPartsLabels } from \"./shell/top-bar-parts-labels\";\nexport type {\n TopBarPartsLabels,\n TourMenuLabels,\n SyncStatusLabels,\n DevSwitchersLabels,\n} from \"./shell/top-bar-parts-labels\";\n"],"mappings":";AAIA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAEd,SAAS,YAAY,yBAAyB;AAI9C,SAAS,gBAAgB,oBAAoB;AAG7C,SAAS,yBAAyB;AAIlC,SAAS,yBAAyB;AAGlC,SAAS,gBAAgB,iCAAiC;AAE1D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASP,SAAS,kBAAkB,qBAAqB,yBAAyB;AACzE,SAAS,uBAAuB;AAEhC,SAAS,yBAAyB;AAElC,SAAS,sBAAsB,+BAA+B;AAE9D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAQP,SAAS,oBAAoB;AAK7B,SAAS,eAAe,4BAA4B;AAEpD,SAAS,2BAA2B;AAGpC,SAAS,2BAA2B;AAEpC,SAAS,gBAAgB;AAEzB,SAAS,qBAAqB,iBAAiB;AAI/C,SAAS,SAAS,6BAA6B;AAE/C,SAAS,oBAAoB;AAE7B,SAAS,8BAA8B,4BAA4B;","names":[]}
1
+ {"version":3,"sources":["../src/shell.ts"],"sourcesContent":["// `@eifi1/ui-kit/shell` — the composable app chrome.\n//\n// A re-slicing of the main barrel, not a new API. This entry point requires\n// `react-router`: AppShell renders NavLink and TopBarActionMenu renders Link.\nexport * from \"./shell/topbar-controls\";\nexport * from \"./shell/top-bar\";\nexport * from \"./shell/app-shell\";\nexport * from \"./shell/option-switcher-menu\";\nexport * from \"./shell/role-switcher\";\nexport * from \"./shell/topbar-action-menu\";\nexport * from \"./shell/top-bar-brand\";\nexport * from \"./shell/auth-layout\";\n// 0.40.0: the list page's fill (docs/page-layout-harmonization.md §1.3).\nexport { FillHeight, FILL_HEIGHT_CLASS } from \"./shell/fill-height\";\nexport type { FillHeightProps } from \"./shell/fill-height\";\n// 0.42.0: the account menu's \"Administration\" row, as an entry and as a HoverMenu row\n// (docs/admin-harmonization.md §2.1, §6.6).\nexport { adminMenuEntry, AdminMenuRow } from \"./shell/admin-menu\";\nexport type { AdminMenuEntryOptions, AdminMenuRowProps } from \"./shell/admin-menu\";\n// 0.43.0 (kastlan): the entry with the provider's words.\nexport { useAdminMenuEntry } from \"./shell/admin-menu\";\nexport type { UseAdminMenuEntryOptions } from \"./shell/admin-menu\";\n// 0.45.0 (docs/admin-harmonization.md §9.3.4): the row's waiting pill on its own, for\n// IconButton's `badge` (kastlan's platform button); its name is `adminArea.waitingLabel`.\nexport { AdminWaitingBadge } from \"./shell/admin-menu\";\nexport type { AdminWaitingBadgeProps } from \"./shell/admin-menu\";\n// 0.31.0: the settings and admin pages' shell (docs/settings-harmonization.md §3, §5).\nexport { SettingsLayout, SETTINGS_SEARCH_THRESHOLD } from \"./settings/settings-layout\";\nexport type { SettingsLayoutProps, SettingsLayoutWidth, SettingsDocumentTitleParts } from \"./settings/settings-layout\";\nexport {\n useSettingsRoute,\n resolveSettingsLocation,\n settingsFromListState,\n SETTINGS_FROM_LIST_STATE,\n} from \"./settings/use-settings-route\";\nexport type {\n UseSettingsRouteOptions,\n SettingsRoute,\n SettingsLayoutMode,\n SettingsLocation,\n SettingsLocationRules,\n SettingsLocationResult,\n} from \"./settings/use-settings-route\";\nexport { useSettingsFocus, useFocusParam, SETTINGS_FOCUS_RING, SETTINGS_FOCUS_MS } from \"./settings/use-settings-focus\";\nexport { SettingsSection } from \"./settings/settings-section\";\nexport type { SettingsSectionProps } from \"./settings/settings-section\";\nexport { useSettingsLayout } from \"./settings/settings-context\";\nexport type { SettingsLayoutContextValue } from \"./settings/settings-context\";\nexport { SettingsHeadingLevel, useSettingsHeadingLevel } from \"./settings/settings-heading\";\nexport type { SettingsHeadingLevelProps, SettingsHeadingTag } from \"./settings/settings-heading\";\nexport {\n settingsSearchEntries,\n visibleSettingsGroups,\n visibleSettingsEntries,\n settingsHref,\n} from \"./settings/settings-catalogue\";\nexport type { SettingsGroup, SettingsEntry, SettingsSearchEntriesOptions } from \"./settings/settings-catalogue\";\n// 0.31.0: the public pages' header and the resume (docs/landing-demo-harmonization.md §3, §4.1).\nexport {\n RootEntry,\n RedirectIfAuthed,\n useLastVisitedPage,\n readLastVisitedPage,\n clearLastVisitedPage,\n safeNextPath,\n NEXT_PARAM,\n DEFAULT_LAST_VISITED_EXCLUDES,\n} from \"./landing/routing\";\nexport type {\n RootEntryProps,\n RedirectIfAuthedProps,\n RoutingSession,\n LastVisitedPageOptions,\n PathPattern,\n} from \"./landing/routing\";\nexport { PublicHeader } from \"./landing/public-header\";\nexport type { PublicHeaderProps, PublicHeaderBrand } from \"./landing/public-header\";\n// 0.42.0: the top bar's shared parts (docs/top-bar-harmonization.md §3.1) — the slots in\n// §2.1's order, the account menu's language sub-list, the tours menu, the sync status, the\n// local-dev switchers, and their words.\nexport { TopBarActions, TOP_BAR_ACTION_ORDER } from \"./shell/top-bar-actions\";\nexport type { TopBarActionsProps } from \"./shell/top-bar-actions\";\nexport { AccountMenuLanguage } from \"./shell/account-menu-language\";\nexport type { AccountMenuLanguageProps, AccountMenuLanguageOption } from \"./shell/account-menu-language\";\n// 0.46.0 (keksdose live #426): A− / A+ for the text size, beside the language.\nexport { AccountMenuTextSize } from \"./shell/account-menu-text-size\";\nexport type { AccountMenuTextSizeProps } from \"./shell/account-menu-text-size\";\nexport { TourMenu } from \"./shell/tour-menu\";\nexport type { TourMenuProps, TourMenuTour } from \"./shell/tour-menu\";\nexport { SyncStatusIndicator, syncState } from \"./shell/sync-status-indicator\";\nexport type { SyncStatus, SyncState, SyncStatusScope, SyncStatusIndicatorProps } from \"./shell/sync-status-indicator\";\n// 0.51 (§2.7.1, keksdose): one flask, the kit's Account section and the app's own dev\n// sections; `DevSwitchers` is a deprecated wrapper over it until 0.52.\nexport { DevMenu, DEV_ORIGINAL_ROLE_KEY } from \"./shell/dev-menu\";\nexport type { DevMenuProps, DevMenuSection } from \"./shell/dev-menu\";\nexport { DevSwitchers } from \"./shell/dev-switchers\";\nexport type { DevSwitchersProps } from \"./shell/dev-switchers\";\nexport { DEFAULT_TOP_BAR_PARTS_LABELS, useTopBarPartsLabels } from \"./shell/top-bar-parts-labels\";\nexport type {\n TopBarPartsLabels,\n TourMenuLabels,\n SyncStatusLabels,\n DevSwitchersLabels,\n} from \"./shell/top-bar-parts-labels\";\n"],"mappings":";AAIA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAEd,SAAS,YAAY,yBAAyB;AAI9C,SAAS,gBAAgB,oBAAoB;AAG7C,SAAS,yBAAyB;AAIlC,SAAS,yBAAyB;AAGlC,SAAS,gBAAgB,iCAAiC;AAE1D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASP,SAAS,kBAAkB,eAAe,qBAAqB,yBAAyB;AACxF,SAAS,uBAAuB;AAEhC,SAAS,yBAAyB;AAElC,SAAS,sBAAsB,+BAA+B;AAE9D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAQP,SAAS,oBAAoB;AAK7B,SAAS,eAAe,4BAA4B;AAEpD,SAAS,2BAA2B;AAGpC,SAAS,2BAA2B;AAEpC,SAAS,gBAAgB;AAEzB,SAAS,qBAAqB,iBAAiB;AAI/C,SAAS,SAAS,6BAA6B;AAE/C,SAAS,oBAAoB;AAE7B,SAAS,8BAA8B,4BAA4B;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.51.3",
3
+ "version": "0.51.4",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -22,7 +22,7 @@ import { anchoredInline, useAnchoredPanel, viewportSize, type AnchoredPanel } fr
22
22
  import { pressInside, takeEscape, useDismissLayer, useOutsideClick } from "../hooks/use-dismiss";
23
23
  import { useOverlayHistory } from "../hooks/use-overlay-history";
24
24
  import { isImeEnter } from "../lib/ime";
25
- import { foldForSearch } from "../lib/text-match";
25
+ import { matchesSearch, searchWords } from "../lib/text-match";
26
26
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
27
27
  import { hasContent } from "../lib/presence";
28
28
 
@@ -283,10 +283,10 @@ export function useOptionSource<V extends string | number>({
283
283
  const clientResults = useMemo(() => {
284
284
  if (isAsync) return [];
285
285
  const src = options ?? [];
286
- const q = foldForSearch(query.trim(), locale);
287
- if (!filter || !q) return src;
288
- const hit = (s: string | undefined) => s !== undefined && foldForSearch(s, locale).includes(q);
289
- return src.filter((o) => hit(o.label) || hit(o.sublabel));
286
+ // Every word of the query, in the label or the sublabel (0.51.4): "migros zürich".
287
+ const words = searchWords(query, locale);
288
+ if (!filter || words.length === 0) return src;
289
+ return src.filter((o) => matchesSearch(words, [o.label, o.sublabel], locale));
290
290
  }, [isAsync, options, query, filter, locale]);
291
291
 
292
292
  const pending = isAsync && active && !tooShort && settled.query !== query;
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import type { ComponentPropsWithRef, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
- import { foldForSearch } from "../lib/text-match";
4
+ import { matchesSearch, searchWords } from "../lib/text-match";
5
5
  import { ComboboxPanel, type ComboboxCore, type ComboOption } from "./combobox-core";
6
6
  import { CURRENCIES, type CurrencyOption } from "../currencies";
7
7
 
@@ -136,10 +136,9 @@ export function CurrencyOptionList({
136
136
  // it is written in) and the symbol — folded as every kit list folds (DUP-5).
137
137
  const query = core.query;
138
138
  const results = useMemo(() => {
139
- const q = foldForSearch(query.trim(), locale);
140
- const hits = q
141
- ? named.filter(({ c, name }) => [c.code, name, c.symbol].some((s) => foldForSearch(s, locale).includes(q)))
142
- : named;
139
+ // Every word of the query, in the code, the name or the symbol (0.51.4).
140
+ const words = searchWords(query, locale);
141
+ const hits = words.length > 0 ? named.filter(({ c, name }) => matchesSearch(words, [c.code, name, c.symbol], locale)) : named;
143
142
  return hits.map(
144
143
  ({ c, name }): ComboOption<string> => ({
145
144
  value: c.code,
@@ -119,17 +119,19 @@ export function useColumnFit<T>({
119
119
  () => (active ? order.filter((c) => hidden.includes(c.key)).map((c) => c.key) : []),
120
120
  [active, order, hidden],
121
121
  );
122
- const growKey = columns.find((c) => c.grow && !hiddenCols.has(c.key))?.key;
122
+ const growCol = columns.find((c) => c.grow && !hiddenCols.has(c.key));
123
+ const growKey = growCol?.key;
124
+ const growMin = growCol?.growMin ?? GROW_MIN_REM;
123
125
 
124
126
  // What `measure` reads, refreshed after each render (before the measure below runs):
125
127
  // the ResizeObserver's callback is set up once and must see the current columns.
126
- const latest = useRef({ order, current, growKey, active, scroller });
128
+ const latest = useRef({ order, current, growKey, growMin, active, scroller });
127
129
  useLayoutEffect(() => {
128
- latest.current = { order, current, growKey, active, scroller };
130
+ latest.current = { order, current, growKey, growMin, active, scroller };
129
131
  });
130
132
 
131
133
  const measure = useCallback(() => {
132
- const { order, current, growKey, active, scroller } = latest.current;
134
+ const { order, current, growKey, growMin, active, scroller } = latest.current;
133
135
  // Only the measure right after a bring-back can undo it.
134
136
  const flip = lastShown.current;
135
137
  lastShown.current = null;
@@ -143,7 +145,7 @@ export function useColumnFit<T>({
143
145
  const growTh = growKey ? headRefs.get(growKey) : undefined;
144
146
  const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
145
147
  let need = growTh
146
- ? table.offsetWidth - growTh.offsetWidth + GROW_MIN_REM * rem
148
+ ? table.offsetWidth - growTh.offsetWidth + growMin * rem
147
149
  : table.offsetWidth;
148
150
  if (!growTh && need <= avail + 0.5 && current.length > 0) {
149
151
  // A `w-full` table is at least the scroller's width, which says nothing about how
@@ -125,6 +125,7 @@ export function DataTableHead<T>({
125
125
  const isEndAligned = !!headClass && END_ALIGN.test(headClass);
126
126
  const width = widths[col.key];
127
127
  const ownHead = splitCellProps(col.headProps);
128
+ const growFloor = col.grow && col.growMin != null ? `${col.growMin}rem` : undefined;
128
129
  const pinnedHead = width ? { width, minWidth: width, maxWidth: width } : undefined;
129
130
  return (
130
131
  <th
@@ -156,8 +157,11 @@ export function DataTableHead<T>({
156
157
  : undefined
157
158
  }
158
159
  style={
159
- ownHead.style || pinnedHead || lockedWidths?.[col.key] !== undefined
160
+ ownHead.style || pinnedHead || lockedWidths?.[col.key] !== undefined || growFloor
160
161
  ? {
162
+ // A `grow` column's own floor (0.51.4), over the class's 10rem; a
163
+ // locked width below wins, as it does over the class.
164
+ ...(growFloor ? { minWidth: growFloor } : null),
161
165
  ...ownHead.style,
162
166
  ...(lockedWidths?.[col.key] !== undefined ? { minWidth: lockedWidths[col.key] } : null),
163
167
  ...pinnedHead,
@@ -184,6 +184,14 @@ export interface DataTableColumn<T> {
184
184
  * broken in two to make room for it.
185
185
  */
186
186
  grow?: boolean;
187
+ /**
188
+ * The least this `grow` column keeps, in rem (0.51.4; default 10): the floor below which
189
+ * the table leaves out `fitPriority` columns, and once none is left, scrolls sideways.
190
+ * Lower it for a column whose first words carry it — the feedback inbox's Subject, 8,
191
+ * so the inbox at 768px with a sidebar keeps Subject, Status and the row actions
192
+ * without scrolling (Kurvenschmiede). In rem, so Large text asks for more.
193
+ */
194
+ growMin?: number;
187
195
  /**
188
196
  * Leave this column out while the TABLE is too narrow for it (0.48, keksdose live
189
197
  * #451) — measured on the table's own scroller, so a sidebar, the window and Large
@@ -2,7 +2,7 @@ import { useCallback, useId, useMemo } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
3
3
  import { ChevronDown } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
- import { foldForSearch } from "../lib/text-match";
5
+ import { matchesSearch, searchWords } from "../lib/text-match";
6
6
  import { ComboboxPanel, useComboboxCore, type ComboOption } from "./combobox-core";
7
7
  import { assignRef } from "./field-parts";
8
8
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
@@ -90,13 +90,20 @@ export function GroupedPicker({
90
90
  // plain `toLowerCase` gets wrong), and now the accents too — "zurich" finds Zürich.
91
91
  const locale = useKitLocale();
92
92
  const results = useMemo(() => {
93
- const q = foldForSearch(query.trim(), locale);
94
- const has = (s: string) => foldForSearch(s, locale).includes(q);
95
- const kept = q
96
- ? groups
97
- .map((g) => (has(g.label) ? g : { ...g, items: g.items.filter((i) => has(i.label)) }))
98
- .filter((g) => g.items.length > 0)
99
- : groups;
93
+ // Every word of the query (0.51.4): all in the group's name keeps the whole group;
94
+ // otherwise an item stays when each word is in its label or its group's name, so
95
+ // "migros 8004" finds Migros' 8004 shop.
96
+ const words = searchWords(query, locale);
97
+ const kept =
98
+ words.length > 0
99
+ ? groups
100
+ .map((g) =>
101
+ matchesSearch(words, [g.label], locale)
102
+ ? g
103
+ : { ...g, items: g.items.filter((i) => matchesSearch(words, [g.label, i.label], locale)) },
104
+ )
105
+ .filter((g) => g.items.length > 0)
106
+ : groups;
100
107
  return kept.flatMap((g) =>
101
108
  g.items.map(
102
109
  (item): ComboOption<string> => ({
@@ -6,7 +6,7 @@ import { SearchField } from "./search-field";
6
6
  import { TILE_SIZE, TileRadioGroup } from "./tile-radio";
7
7
  import type { TileItem, TileSize } from "./tile-radio";
8
8
  import { hasContent } from "../lib/presence";
9
- import { foldForSearch } from "../lib/text-match";
9
+ import { matchesSearch, searchWords } from "../lib/text-match";
10
10
  import { LabelStrip, useFieldMessages, type FieldHelpProps } from "./field-parts";
11
11
  import { DEFAULT_ICON_PICKER_LABELS } from "./icon-picker-labels";
12
12
 
@@ -147,16 +147,18 @@ export function IconPicker<T extends string>({
147
147
  });
148
148
  const [query, setQuery] = useState("");
149
149
  // Case- and accent-insensitive, so "cafe" finds "Café" — every kit list's fold (DUP-5).
150
- const needle = foldForSearch(query.trim(), locale);
150
+ // Every word of the query, in the label or a keyword (0.51.4).
151
+ const words = useMemo(() => searchWords(query, locale), [query, locale]);
152
+ const needle = words.length > 0;
151
153
 
152
154
  const visible = useMemo(
153
155
  () =>
154
156
  needle
155
157
  ? options.filter((o) =>
156
- [o.label, ...(o.keywords ?? [])].some((w) => foldForSearch(w, locale).includes(needle)),
158
+ matchesSearch(words, [o.label, ...(o.keywords ?? [])], locale),
157
159
  )
158
160
  : options,
159
- [options, needle, locale],
161
+ [options, needle, words, locale],
160
162
  );
161
163
  const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
162
164
  const items: TileItem<T>[] = [
@@ -24,7 +24,7 @@ import {
24
24
  type ComboOption,
25
25
  } from "./combobox-core";
26
26
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
27
- import { foldForSearch, rankMatch } from "../lib/text-match";
27
+ import { matchesSearch, rankMatch, searchWords } from "../lib/text-match";
28
28
  import {
29
29
  EndHintRow,
30
30
  FieldCaption,
@@ -311,8 +311,9 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
311
311
  const matches = useMemo(() => {
312
312
  let ranked = options;
313
313
  if (query) {
314
- const q = foldForSearch(query, locale);
315
- const hit = (s: string | undefined) => s !== undefined && foldForSearch(s, locale).includes(q);
314
+ // Every word of the query, in the label or the sublabel (0.51.4).
315
+ const words = searchWords(query, locale);
316
+ const hit = (o: ComboOption<V>) => matchesSearch(words, [o.label, o.sublabel], locale);
316
317
  // The label's whole name or its start first (`rankMatch` 0 and 1), then a match
317
318
  // anywhere in the label or the sublabel. Not the group (0.40, kk run 79): with
318
319
  // first-letter groups one typed letter matched its whole group.
@@ -321,7 +322,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
321
322
  return rank === 0 || rank === 1;
322
323
  };
323
324
  const starts = options.filter(leads);
324
- const rest = options.filter((o) => !leads(o) && (hit(o.label) || hit(o.sublabel)));
325
+ const rest = options.filter((o) => !leads(o) && hit(o));
325
326
  ranked = [...starts, ...rest];
326
327
  }
327
328
  if (!ranked.some((o) => o.group)) return ranked;
@@ -10,6 +10,8 @@ export const DEFAULT_LEGAL_LABELS: LegalLabels = {
10
10
  impressum: "Imprint",
11
11
  privacy: "Privacy Policy",
12
12
  terms: "Terms",
13
+ security: "Security",
14
+ accessibility: "Accessibility",
13
15
  },
14
16
  titles: {
15
17
  impressum: "Imprint",
@@ -29,6 +31,10 @@ export const DEFAULT_LEGAL_LABELS: LegalLabels = {
29
31
  show: (o) => `Show the ${o.bindingLanguage} version`,
30
32
  },
31
33
  updated: (date) => `Last updated: ${date}`,
34
+ summary: {
35
+ title: "In short",
36
+ note: "This summary is for orientation; only the full text is binding.",
37
+ },
32
38
  sections: {
33
39
  impressum: {
34
40
  operator: {
@@ -83,7 +89,7 @@ export const DEFAULT_LEGAL_LABELS: LegalLabels = {
83
89
  },
84
90
  changes: {
85
91
  title: "Changes to these terms",
86
- body: "We may update these terms as the service evolves. We announce a material change at least 30 days before it takes effect, by email and in the app, together with the new text. If you do not agree, you can object or delete your account before that date, and you can export your data until then. If you keep using the service after that date, the changed terms apply.",
92
+ body: "We may update these terms as the service evolves. We announce a material change at least 30 days before it takes effect, by email and in the app, together with the new text. If you do not agree, you can object or delete your account before that date, and you can export your data until then. If you keep using the service after that date, the changed terms apply. Changes that only benefit you, or that the law requires, apply without this notice and cannot be objected to. If you object to a change, we may end your account on the date it takes effect. If you have paid for a period that runs beyond that date, we refund the unused part.",
87
93
  },
88
94
  language: {
89
95
  title: "Language",
@@ -21,6 +21,7 @@ import {
21
21
  LegalSlotContext,
22
22
  LegalText,
23
23
  legalOperatorText,
24
+ resolveLegalRefs,
24
25
  useLegalLabels,
25
26
  warnLegalOnce,
26
27
  } from "./legal";
@@ -351,8 +352,24 @@ export interface LegalPageProps {
351
352
  *
352
353
  * An app that records which terms someone accepted (keksdose) stores the terms page's
353
354
  * date: it is the version the reader saw.
355
+ *
356
+ * The kit's sections are part of the page, so the date moves with their words too: a
357
+ * kit release that changes them says so in its adoption note ("Legal wording"), and
358
+ * the app sets the page's date to the day it ships that bump (§2, decision 10).
354
359
  */
355
360
  updated?: string;
361
+ /**
362
+ * 0.51.4: a plain-language summary before the first section (legal benchmark, Marcel
363
+ * 2026-10-29) — the app's three to six points, each a sentence. The kit draws them as a
364
+ * list under `legal.summary.title` ("In short", "Das Wichtigste in Kürze") and adds
365
+ * `legal.summary.note`: the summary orients, only the full text binds. Worded without
366
+ * "below", because on a translated page the text below is a translation too.
367
+ *
368
+ * A string point is running text with its `{ref:key}` resolved, as in a section:
369
+ * "You can delete your account at any time (section {ref:ending})." Unnumbered, with
370
+ * the id `summary`; the sections' numbers do not count it. Left out or empty, nothing.
371
+ */
372
+ summary?: readonly ReactNode[];
356
373
  /**
357
374
  * 0.28.1: switches the app to the operator's `bindingLanguage` — the button after the
358
375
  * translation note on a page read in another language. Without it the note stands
@@ -471,6 +488,7 @@ function slotSections(
471
488
  * What it adds to the column:
472
489
  * - the title, from `legal.titles`, and "← Back to home" pointing at `homeHref`;
473
490
  * - the as-of line under the title, from `updated` (0.51.3);
491
+ * - the plain-language summary before the sections, from `summary` (0.51.4);
474
492
  * - the default notice per page — the beta notice, or the privacy notice on the privacy
475
493
  * policy, whose technical sections describe running code (§3.2, §7.3);
476
494
  * - numbers: the privacy and terms sections are "N. Title" in render order, the
@@ -492,6 +510,7 @@ export function LegalPage({
492
510
  homeHref = "/",
493
511
  notice,
494
512
  updated,
513
+ summary,
495
514
  onShowBindingLanguage,
496
515
  locale: localeProp,
497
516
  headingAs = "h1",
@@ -550,6 +569,25 @@ export function LegalPage({
550
569
  {(labels.updated ?? DEFAULT_LEGAL_LABELS.updated!)(formatDate(updated, "long", { locale }))}
551
570
  </time>
552
571
  ) : undefined;
572
+ const summaryLabels = labels.summary ?? DEFAULT_LEGAL_LABELS.summary!;
573
+ const SummaryHeading = context.sectionHeading;
574
+ const summaryBox =
575
+ summary !== undefined && summary.length > 0 ? (
576
+ <section id="summary" className="rounded-lg border border-subtle bg-surface p-4 sm:p-5">
577
+ <SummaryHeading className="text-lg font-semibold break-words text-primary">{summaryLabels.title}</SummaryHeading>
578
+ <ul className="mt-3 space-y-2">
579
+ {summary.map((point, i) => (
580
+ <li key={i} className="flex gap-2.5 text-sm leading-relaxed text-secondary">
581
+ <span aria-hidden className="mt-2 size-1.5 shrink-0 rounded-full bg-brand" />
582
+ <span className="min-w-0 break-words">
583
+ {typeof point === "string" ? resolveLegalRefs(point, context) : point}
584
+ </span>
585
+ </li>
586
+ ))}
587
+ </ul>
588
+ <p className="mt-3 text-xs text-muted">{summaryLabels.note}</p>
589
+ </section>
590
+ ) : undefined;
553
591
 
554
592
  return (
555
593
  <UiKitProvider labels={overrides}>
@@ -567,6 +605,7 @@ export function LegalPage({
567
605
  notice={shownNotice}
568
606
  updated={updatedLine}
569
607
  translationNote={translationNote}
608
+ summary={summaryBox}
570
609
  headingAs={headingAs}
571
610
  >
572
611
  {nodes}
@@ -44,6 +44,11 @@ export const LEGAL_HREFS: Readonly<Record<LegalPageKey, string>> = Object.freeze
44
44
  terms: "/terms",
45
45
  });
46
46
 
47
+ /** 0.51.4: the two public pages an app MAY have beside the legal ones, linked after
48
+ * them in the footer (§3.3) — keksdose's /security and /accessibility. Not legal pages:
49
+ * an app draws them with `PublicPage`, in its own words, at routes of its choosing. */
50
+ export type LegalInfoPageKey = "security" | "accessibility";
51
+
47
52
  /**
48
53
  * Who runs the app — the values the kit's legal sections are filled in with (§5).
49
54
  *
@@ -179,8 +184,11 @@ export interface LegalFramedSectionLabels {
179
184
  export interface LegalLabels {
180
185
  /** The accessible name of {@link LegalLinks}' and {@link LegalFooter}'s `<nav>`. */
181
186
  navLabel: string;
182
- /** The short labels of the three links, everywhere the links appear (§3.3). */
183
- links: Record<LegalPageKey, string>;
187
+ /** The short labels of the three links, everywhere the links appear (§3.3), and since
188
+ * 0.51.4 of the two optional public pages after them (`securityHref`,
189
+ * `accessibilityHref` on {@link LegalLinks}) — optional keys, as every key added to a
190
+ * namespace an app may annotate: left out, the provider's, then English. */
191
+ links: Record<LegalPageKey, string> & Partial<Record<LegalInfoPageKey, string>>;
184
192
  /** The pages' titles — the `<h1>`, and the links of the terms checkbox. */
185
193
  titles: Record<LegalPageKey, string>;
186
194
  /** The back link above the title: "← Back to home". */
@@ -215,6 +223,15 @@ export interface LegalLabels {
215
223
  * the provider's, then English.
216
224
  */
217
225
  updated?: (date: string) => string;
226
+ /**
227
+ * 0.51.4: the plain-language summary at the top of a page ({@link LegalPage}'s
228
+ * `summary`, §3.2) — its heading, and the line under the points saying that only the
229
+ * full text binds. Optional, as `updated`.
230
+ */
231
+ summary?: {
232
+ title: string;
233
+ note: string;
234
+ };
218
235
  /** The kit's sections (§4.3), by page and section key. */
219
236
  sections: {
220
237
  impressum: {
@@ -363,6 +380,15 @@ export interface LegalLinksProps {
363
380
  /** Default (0.28): the three routes, `/impressum`, `/privacy` and `/terms`, with the
364
381
  * kit's short link labels (`legal.links`), in the contract's order. */
365
382
  links?: readonly LegalLink[];
383
+ /**
384
+ * 0.51.4: the app's security page — a "Security" link after the others, with the kit's
385
+ * label (`legal.links.security`). Left out, no link: only an app that has the page
386
+ * names its route.
387
+ */
388
+ securityHref?: string;
389
+ /** 0.51.4: the app's accessibility statement — "Accessibility", last. As
390
+ * `securityHref`. */
391
+ accessibilityHref?: string;
366
392
  /** The page being shown; its link is marked `aria-current="page"`. Usually the
367
393
  * router's pathname. */
368
394
  currentHref?: string;
@@ -387,6 +413,14 @@ function defaultLinks(labels: LegalLabels): LegalLink[] {
387
413
  }));
388
414
  }
389
415
 
416
+ /** The optional pages after them, each only where the app named its route. */
417
+ function infoLinks(labels: LegalLabels, hrefs: Partial<Record<LegalInfoPageKey, string>>): LegalLink[] {
418
+ return (["security", "accessibility"] as const).flatMap((page) => {
419
+ const href = hrefs[page];
420
+ return href ? [{ href, label: labels.links[page] ?? DEFAULT_LEGAL_LABELS.links[page] }] : [];
421
+ });
422
+ }
423
+
390
424
  /**
391
425
  * The legal pages as a row of quiet links, the one you are on marked (kastlan's
392
426
  * `LegalLinks`). Wraps on a phone.
@@ -398,6 +432,8 @@ function defaultLinks(labels: LegalLabels): LegalLink[] {
398
432
  */
399
433
  export function LegalLinks({
400
434
  links,
435
+ securityHref,
436
+ accessibilityHref,
401
437
  currentHref,
402
438
  nav = true,
403
439
  onLinkClick,
@@ -405,7 +441,10 @@ export function LegalLinks({
405
441
  className,
406
442
  }: LegalLinksProps) {
407
443
  const labels = useLegalLabels(labelsProp);
408
- const shown = links ?? defaultLinks(labels);
444
+ const shown = [
445
+ ...(links ?? defaultLinks(labels)),
446
+ ...infoLinks(labels, { security: securityHref, accessibility: accessibilityHref }),
447
+ ];
409
448
  const items = shown.flatMap((link, i) => [
410
449
  ...(i > 0 ? [<span key={`sep-${link.href}`} aria-hidden="true">·</span>] : []),
411
450
  <TextLink key={link.href} href={link.href} tone="muted" current={link.href === currentHref} onClick={onLinkClick}>
@@ -429,6 +468,10 @@ export interface LegalFooterProps {
429
468
  children?: ReactNode;
430
469
  /** Default: the three routes with the kit's link labels — see {@link LegalLinks}. */
431
470
  links?: readonly LegalLink[];
471
+ /** 0.51.4: "Security" after the others — see {@link LegalLinksProps.securityHref}. */
472
+ securityHref?: string;
473
+ /** 0.51.4: "Accessibility", last — see {@link LegalLinksProps.accessibilityHref}. */
474
+ accessibilityHref?: string;
432
475
  /** Marks that link `aria-current="page"`. {@link LegalPage}'s default footer passes
433
476
  * its own page. */
434
477
  currentHref?: string;
@@ -454,14 +497,29 @@ export interface LegalFooterProps {
454
497
  * landing, auth, legal and 404 pages; kastlan and Kurvenschmiede had them on some pages
455
498
  * only. Its palette classes (`border-slate-200 bg-white dark:…`) became the surface and
456
499
  * border tokens, so it follows the theme like the rest of the kit.
500
+ *
501
+ * 0.51.4: an app with a security page or an accessibility statement passes their routes
502
+ * (`securityHref`, `accessibilityHref`) and the row goes on with "Security · Accessibility"
503
+ * in the kit's words, so the footer reads the same in every app that has them.
457
504
  */
458
- export function LegalFooter({ children, links, currentHref, landmark = true, className, labels }: LegalFooterProps) {
505
+ export function LegalFooter({
506
+ children,
507
+ links,
508
+ securityHref,
509
+ accessibilityHref,
510
+ currentHref,
511
+ landmark = true,
512
+ className,
513
+ labels,
514
+ }: LegalFooterProps) {
459
515
  const Root = landmark ? "footer" : "div";
460
516
  return (
461
517
  <Root className={cn("border-t border-subtle bg-surface", className)}>
462
518
  <div className="mx-auto max-w-5xl px-4 py-8 text-sm text-muted">
463
519
  <LegalLinks
464
520
  links={links}
521
+ securityHref={securityHref}
522
+ accessibilityHref={accessibilityHref}
465
523
  currentHref={currentHref}
466
524
  labels={labels}
467
525
  className="justify-center gap-y-2 text-sm"
@@ -547,6 +605,8 @@ export interface LegalLayoutProps {
547
605
  /** 0.28.1: under the title, before the notice — {@link LegalPage}'s note that this
548
606
  * page is a translation and which language is binding. */
549
607
  translationNote?: ReactNode;
608
+ /** 0.51.4: before the sections — {@link LegalPage}'s plain-language summary. */
609
+ summary?: ReactNode;
550
610
  /** The sections — {@link LegalSection}s. */
551
611
  children: ReactNode;
552
612
  /** The three pages, linked at the end of the column with the current one marked.
@@ -566,6 +626,7 @@ export function LegalLayout({
566
626
  notice,
567
627
  updated,
568
628
  translationNote,
629
+ summary,
569
630
  children,
570
631
  links,
571
632
  currentHref,
@@ -584,7 +645,10 @@ export function LegalLayout({
584
645
  {notice}
585
646
  </AlertBanner>
586
647
  )}
587
- <div className="mt-8 space-y-8">{children}</div>
648
+ <div className="mt-8 space-y-8">
649
+ {summary}
650
+ {children}
651
+ </div>
588
652
  {links !== undefined && links.length > 0 && (
589
653
  <LegalLinks links={links} currentHref={currentHref} labels={labels} className="mt-12 border-t border-subtle pt-4" />
590
654
  )}
@@ -7,4 +7,6 @@ import type { ListLabels } from "./list";
7
7
  export const DEFAULT_LIST_LABELS: ListLabels = {
8
8
  unread: "Unread",
9
9
  opensInNewTab: "opens in a new tab",
10
+ showAll: (count) => `Show all (${count})`,
11
+ showFewer: "Show fewer",
10
12
  };
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useId, useState } from "react";
1
+ import { Children, createContext, useContext, useId, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, HTMLAttributes, MouseEvent, ReactElement, ReactNode, Ref } from "react";
3
3
  import { ChevronDown, ExternalLink } from "lucide-react";
4
4
  import type { LucideIcon } from "lucide-react";
@@ -14,6 +14,7 @@ import { pickLinkRenderer } from "./text-link";
14
14
  import { LockedReason, mergeDescribedBy, useLockReason, type DisabledReasonDisplay } from "./field-parts";
15
15
  import type { CommitScope } from "./write-lock";
16
16
  import { DEFAULT_LIST_LABELS } from "./list-labels";
17
+ import { Button } from "./button";
17
18
 
18
19
  /* ── Labels ───────────────────────────────────────────────────────────────── */
19
20
 
@@ -22,6 +23,12 @@ export interface ListLabels {
22
23
  unread: string;
23
24
  /** Read after an external row's title: the row leaves the app in a new tab. */
24
25
  opensInNewTab: string;
26
+ /** 0.51.4: the row under a {@link ListProps.limit}ed list that opens the rest, with
27
+ * the WHOLE list's count — "Show all (12)". Optional, as every key added to a
28
+ * namespace an app may annotate: left out, the provider's, then English. */
29
+ showAll?: (count: number) => string;
30
+ /** 0.51.4: the same row once the list is open, folding it back. */
31
+ showFewer?: string;
25
32
  }
26
33
 
27
34
  // Defined in ./list-labels, which holds no component code (0.34, KS U8).
@@ -53,8 +60,26 @@ export interface ListProps extends Omit<ComponentPropsWithoutRef<"ul">, "role">
53
60
  /** The `<ul>` (or `<ol>`). A React 19 ref prop since 0.34 (MOD-4, refactor audit
54
61
  * 2026-10); it was `forwardRef`. */
55
62
  ref?: Ref<HTMLUListElement>;
63
+ /**
64
+ * Show the first `limit` rows, then a "Show all (n)" row that opens the rest and turns
65
+ * into "Show fewer" (0.51.4, keksdose run 89: a long "Placed only roughly" list on its
66
+ * Matching report could only fold as a whole, so it opened as nothing or everything).
67
+ * A list of `limit` rows or fewer draws them all, with no such row. Rows are counted as
68
+ * `Children` counts them: one per `ListItem`, a fragment as one.
69
+ *
70
+ * The row is a button with `aria-expanded`, controlling the list; it sits in the list
71
+ * as `role="none"`, so a reader still hears "list, 5 items" for five rows.
72
+ */
73
+ limit?: number;
56
74
  }
57
75
 
76
+ /** The show-all row's inset, in step with each density's row padding (`PAD`). */
77
+ const MORE_PAD: Record<ListDensity, string> = {
78
+ compact: "px-2 py-1",
79
+ default: "px-3 py-1.5",
80
+ comfortable: "px-4 py-2",
81
+ };
82
+
58
83
  /**
59
84
  * The list of {@link ListItem}s.
60
85
  *
@@ -67,15 +92,23 @@ export function List({
67
92
  as = "ul",
68
93
  density = "default",
69
94
  separator = "gap",
95
+ limit,
70
96
  className,
71
97
  children,
72
98
  ...rest
73
99
  }: ListProps) {
74
100
  const Tag = as;
101
+ const labels = useKitLabels("list", DEFAULT_LIST_LABELS);
102
+ const [open, setOpen] = useState(false);
103
+ const generatedId = useId();
104
+ const id = rest.id ?? generatedId;
105
+ const rows = limit === undefined ? null : Children.toArray(children);
106
+ const folds = rows !== null && rows.length > Math.max(0, limit!);
75
107
  return (
76
108
  <ListContext.Provider value={{ density, separator }}>
77
109
  <Tag
78
110
  {...rest}
111
+ id={folds ? id : rest.id}
79
112
  ref={ref as Ref<HTMLUListElement & HTMLOListElement>}
80
113
  role="list"
81
114
  className={cn(
@@ -84,7 +117,24 @@ export function List({
84
117
  className,
85
118
  )}
86
119
  >
87
- {children}
120
+ {folds && !open ? rows.slice(0, Math.max(0, limit!)) : children}
121
+ {folds && (
122
+ <li role="none" className={MORE_PAD[density]}>
123
+ <Button
124
+ type="button"
125
+ variant="link"
126
+ size="sm"
127
+ className="h-auto p-0"
128
+ aria-expanded={open}
129
+ aria-controls={id}
130
+ onClick={() => setOpen((was) => !was)}
131
+ >
132
+ {open
133
+ ? (labels.showFewer ?? DEFAULT_LIST_LABELS.showFewer)
134
+ : (labels.showAll ?? DEFAULT_LIST_LABELS.showAll!)(rows.length)}
135
+ </Button>
136
+ </li>
137
+ )}
88
138
  </Tag>
89
139
  </ListContext.Provider>
90
140
  );