flask-construct-ui 0.12.0__tar.gz → 0.12.1__tar.gz

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 (124) hide show
  1. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/PKG-INFO +1 -1
  2. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/base.py +11 -1
  3. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/pivot.py +38 -1
  4. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/contrast.py +34 -0
  5. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/l10n/en/main.flt +2 -0
  6. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/l10n/sv/main.flt +2 -0
  7. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/menu.py +143 -19
  8. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/rcrud.py +26 -1
  9. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/css/whitelabel.css +81 -22
  10. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/designs/sandstone.css +3 -6
  11. flask_construct_ui-0.12.1/flask_construct_ui/static/js/construct-core.min.js +2 -0
  12. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/pivot_form.html +13 -6
  13. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/style/_menu.html +18 -8
  14. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui.egg-info/PKG-INFO +1 -1
  15. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/pyproject.toml +2 -2
  16. flask_construct_ui-0.12.0/flask_construct_ui/static/js/construct-core.min.js +0 -2
  17. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/LICENSE +0 -0
  18. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/README.md +0 -0
  19. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/__init__.py +0 -0
  20. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/app_hooks.py +0 -0
  21. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/auth.py +0 -0
  22. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/capabilities.py +0 -0
  23. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/cli.py +0 -0
  24. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/__init__.py +0 -0
  25. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/calendar.py +0 -0
  26. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/chart.py +0 -0
  27. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/chart_native.py +0 -0
  28. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/content.py +0 -0
  29. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/core.py +0 -0
  30. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/grid.py +0 -0
  31. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/kanban.py +0 -0
  32. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/layout.py +0 -0
  33. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/live.py +0 -0
  34. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/components/period.py +0 -0
  35. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/crud.py +0 -0
  36. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/crud_forms.py +0 -0
  37. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/csrf.py +0 -0
  38. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/data.py +0 -0
  39. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/data_sqlalchemy.py +0 -0
  40. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/dragdrop.py +0 -0
  41. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/errors.py +0 -0
  42. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/flashes.py +0 -0
  43. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/forms.py +0 -0
  44. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/grid.py +0 -0
  45. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/headless.py +0 -0
  46. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/icons.py +0 -0
  47. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/language.py +0 -0
  48. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/layout.py +0 -0
  49. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/live.py +0 -0
  50. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/navbar.py +0 -0
  51. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/navigation.py +0 -0
  52. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/orgmode.py +0 -0
  53. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/pagination.py +0 -0
  54. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/py.typed +0 -0
  55. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/resume.py +0 -0
  56. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/sanitize.py +0 -0
  57. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/schema.py +0 -0
  58. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/css/grid.css +0 -0
  59. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
  60. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/css/unpoly.min.css +0 -0
  61. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/designs/adminlte.css +0 -0
  62. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/fonts/bootstrap-icons/LICENSE.txt +0 -0
  63. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/fonts/bootstrap-icons/bootstrap-icons.min.css +0 -0
  64. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  65. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  66. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/fonts/material-symbols/LICENSE.txt +0 -0
  67. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
  68. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/fonts/material-symbols/material-symbols.css +0 -0
  69. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/fonts/material-symbols/names.txt +0 -0
  70. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/js/construct-grid.min.js +0 -0
  71. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
  72. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/js/prosemirror-bundle.min.js +0 -0
  73. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/static/js/unpoly.min.js +0 -0
  74. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/_form_field.html +0 -0
  75. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/button.html +0 -0
  76. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/calendar.html +0 -0
  77. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/calendarmini.html +0 -0
  78. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/card.html +0 -0
  79. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/chart.html +0 -0
  80. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/chart_native.html +0 -0
  81. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/form.html +0 -0
  82. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/grid.html +0 -0
  83. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/grid_layout.html +0 -0
  84. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/grid_notice.html +0 -0
  85. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/horizontal_layout.html +0 -0
  86. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/kanban.html +0 -0
  87. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/live.html +0 -0
  88. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/live_refresh.html +0 -0
  89. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/main.html +0 -0
  90. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/markup.html +0 -0
  91. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/period_nav.html +0 -0
  92. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/rich_text.html +0 -0
  93. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/table.html +0 -0
  94. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/tabs.html +0 -0
  95. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/text.html +0 -0
  96. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/components/vertical_layout.html +0 -0
  97. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/_macros.html +0 -0
  98. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/_many_related_options.html +0 -0
  99. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/_related_options.html +0 -0
  100. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/create.html +0 -0
  101. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/edit.html +0 -0
  102. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/listindex.html +0 -0
  103. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/related_crud_tab_form.html +0 -0
  104. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/related_crud_tab_list.html +0 -0
  105. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/tab_view.html +0 -0
  106. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/crud/view.html +0 -0
  107. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/errors/error.html +0 -0
  108. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/errors/forbidden.html +0 -0
  109. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/navbar/notifications.html +0 -0
  110. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/navbar/search_results.html +0 -0
  111. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/structure/base.html +0 -0
  112. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/structure/public.html +0 -0
  113. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/style/_flashes.html +0 -0
  114. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/style/_footer.html +0 -0
  115. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/style/base_full.html +0 -0
  116. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/templates/style/base_partial.html +0 -0
  117. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/testing.py +0 -0
  118. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/urls.py +0 -0
  119. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui/validation.py +0 -0
  120. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui.egg-info/SOURCES.txt +0 -0
  121. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
  122. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui.egg-info/requires.txt +0 -0
  123. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/flask_construct_ui.egg-info/top_level.txt +0 -0
  124. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flask-construct-ui
3
- Version: 0.12.0
3
+ Version: 0.12.1
4
4
  Summary: A UI toolkit for Flask with CRUD and component features.
5
5
  Author-email: Olof Tjerngren <olof@tjerngren.net>
6
6
  License-Expression: LGPL-3.0-or-later
@@ -1131,6 +1131,7 @@ class FlaskConstructUI(MenuMixin):
1131
1131
  groups=None,
1132
1132
  visible=None,
1133
1133
  weight=None,
1134
+ match=None,
1134
1135
  ):
1135
1136
  """Register a page and add it to the navigation menu.
1136
1137
 
@@ -1152,13 +1153,22 @@ class FlaskConstructUI(MenuMixin):
1152
1153
  ``weight`` orders the entry among its siblings (default 0, ties keep
1153
1154
  registration order), which is what lets a provider place entries around
1154
1155
  statically registered pages.
1156
+
1157
+ ``match`` names other URLs the entry stands for, so it is shown as
1158
+ current there too: a pattern string (``"/user/diary/*"``; ``*`` is
1159
+ anything, ``:name`` one path segment), a list of them, or a callable
1160
+ returning either per request — for an entry that redirects somewhere
1161
+ that depends on who is asking. The paths below the entry's own link
1162
+ always count; see :attr:`menu_current`.
1155
1163
  """
1156
1164
  self._pages.append(page)
1157
1165
  self._blueprint_names.add(page.endpoint)
1158
1166
  self._pages_by_endpoint[page.endpoint] = page
1159
1167
  if self.app is not None:
1160
1168
  self._register_page_blueprint(page)
1161
- self._add_page_menu_entry(page, submenu, submenu_icon, groups, visible, weight)
1169
+ self._add_page_menu_entry(
1170
+ page, submenu, submenu_icon, groups, visible, weight, match
1171
+ )
1162
1172
 
1163
1173
  def _apply_l10n_paths(self):
1164
1174
  """(Re)build the translator's search path.
@@ -37,6 +37,11 @@ from .core import Component, _t, jinja_env
37
37
  #: Tones a cell or column may carry; each maps to ``fcu-pivot--<tone>``.
38
38
  TONES = ("success", "warning", "danger", "info", "muted")
39
39
 
40
+ #: What ``unsaved=`` may ask ``core.js`` to do while boxes differ from what
41
+ #: the server rendered: mark the boxes and totals, say so beside Save, warn
42
+ #: before the page or the pivot's fragment is left.
43
+ UNSAVED = ("mark", "status", "warn")
44
+
40
45
  # Separates the name from the keys and the keys from each other. Keys are
41
46
  # percent-encoded with nothing kept safe, so neither can contain it.
42
47
  _SEP = ":"
@@ -192,7 +197,18 @@ class PivotFormComponent(Component):
192
197
  or a :class:`PivotCell` whose ``tone`` / ``css_class`` / ``title`` style
193
198
  that total; ``grand_total`` may be a zero-argument callable).
194
199
  ``total_extra`` is a slot under the totals heading, ``footer_label``
195
- heads the totals row (default: ``total_label``).
200
+ heads the totals row (default: ``total_label``). The built-in sums
201
+ follow typing in the browser (``core.js``); an app total changes only
202
+ when the server renders it again. ``live_totals=False`` keeps every
203
+ total as rendered.
204
+ * ``unsaved`` picks how the page shows typing that is not saved yet, any
205
+ of :data:`UNSAVED` (``()`` shows nothing): ``"mark"`` dashes the
206
+ changed boxes and the totals they move, ``"status"`` adds a live
207
+ "Unsaved changes" line beside Save (``unsaved_label``), ``"warn"``
208
+ asks before leaving the page or replacing the pivot. Whatever is
209
+ chosen, the form carries ``data-fcu-unsaved`` while anything is
210
+ unsaved and fires ``fcu:pivot-unsaved`` as that flips, for an app
211
+ that handles it itself.
196
212
  * ``controls`` render inside the form under the table (a row picker,
197
213
  extra submit buttons with ``formaction``), after the Save button, which
198
214
  stays the form's first submit button so Enter always means save.
@@ -214,6 +230,9 @@ class PivotFormComponent(Component):
214
230
  total_label: Optional[str] = None
215
231
  total_extra: Any = None
216
232
  footer_label: Optional[str] = None
233
+ live_totals: bool = True
234
+ unsaved: Any = ()
235
+ unsaved_label: Optional[str] = None
217
236
  controls: List[Any] = field(default_factory=list)
218
237
  save: bool = True
219
238
  save_label: Optional[str] = None
@@ -223,6 +242,12 @@ class PivotFormComponent(Component):
223
242
 
224
243
  def __post_init__(self):
225
244
  _check_name(self.name, "name")
245
+ if isinstance(self.unsaved, str):
246
+ self.unsaved = (self.unsaved,)
247
+ self.unsaved = tuple(self.unsaved)
248
+ for mode in self.unsaved:
249
+ if mode not in UNSAVED:
250
+ raise ValueError(f"PivotForm unsaved {mode!r} is not one of {UNSAVED}")
226
251
  for tone in [c.tone for c in self.columns]:
227
252
  if tone is not None and tone not in TONES:
228
253
  raise ValueError(f"PivotColumn tone {tone!r} is not one of {TONES}")
@@ -352,6 +377,13 @@ class PivotFormComponent(Component):
352
377
  row_totals=row_totals,
353
378
  column_totals=column_totals,
354
379
  grand=grand,
380
+ # Which totals are the built-in sums, which core.js keeps live
381
+ # while typing; an app total is left as rendered.
382
+ live={
383
+ "row": self.live_totals and self.row_total is None,
384
+ "column": self.live_totals and self.column_total is None,
385
+ "grand": self.live_totals and self.grand_total is None,
386
+ },
355
387
  input_name=lambda row, column: self.input_name(
356
388
  self.name, row.key, column.key
357
389
  ),
@@ -360,6 +392,11 @@ class PivotFormComponent(Component):
360
392
  "total": total_label,
361
393
  "footer": self.footer_label or total_label,
362
394
  "save": self.save_label or _t("pivot-save", default="Save"),
395
+ "unsaved": self.unsaved_label
396
+ or _t("pivot-unsaved", default="Unsaved changes"),
397
+ "leave": _t(
398
+ "pivot-unsaved-leave", default="Leave without saving your changes?"
399
+ ),
363
400
  },
364
401
  )
365
402
 
@@ -294,6 +294,40 @@ PAIRS = [
294
294
  ("input border on info tint", "--fcu-border-input", "--fcu-alert-info-bg", UI),
295
295
  ("pivot row heading", "--fcu-text", "--fcu-pivot-rowhead-bg", TEXT),
296
296
  ("pivot row heading link", "--fcu-link", "--fcu-pivot-rowhead-bg", TEXT),
297
+ # PivotForm unsaved="mark" / "status": a changed box's border and the
298
+ # "Unsaved changes" line; muted (weekend) columns sit on the alt surface.
299
+ ("pivot unsaved box border", "--fcu-pivot-unsaved-color", "--fcu-surface", UI),
300
+ (
301
+ "pivot unsaved box border on alt surface",
302
+ "--fcu-pivot-unsaved-color",
303
+ "--fcu-surface-alt",
304
+ UI,
305
+ ),
306
+ ("pivot unsaved status", "--fcu-pivot-unsaved-text", "--fcu-surface", TEXT),
307
+ (
308
+ "pivot unsaved box border on success tint",
309
+ "--fcu-pivot-unsaved-color",
310
+ "--fcu-alert-success-bg",
311
+ UI,
312
+ ),
313
+ (
314
+ "pivot unsaved box border on warning tint",
315
+ "--fcu-pivot-unsaved-color",
316
+ "--fcu-alert-warning-bg",
317
+ UI,
318
+ ),
319
+ (
320
+ "pivot unsaved box border on danger tint",
321
+ "--fcu-pivot-unsaved-color",
322
+ "--fcu-alert-danger-bg",
323
+ UI,
324
+ ),
325
+ (
326
+ "pivot unsaved box border on info tint",
327
+ "--fcu-pivot-unsaved-color",
328
+ "--fcu-alert-info-bg",
329
+ UI,
330
+ ),
297
331
  # Grid: the selection ring is the only indicator of the keyboard cursor,
298
332
  # and the error border doubles as the grid's error text colour.
299
333
  ("grid selection ring", "--fcu-grid-selected-border", "--fcu-surface", UI),
@@ -106,6 +106,8 @@ kanban-add-item = Add
106
106
  kanban-add-placeholder = New item…
107
107
 
108
108
  pivot-save = Save
109
+ pivot-unsaved = Unsaved changes
110
+ pivot-unsaved-leave = Leave without saving your changes?
109
111
  pivot-total = Total
110
112
 
111
113
  period-nav = Period
@@ -107,6 +107,8 @@ kanban-add-item = Lägg till
107
107
  kanban-add-placeholder = Ny post…
108
108
 
109
109
  pivot-save = Spara
110
+ pivot-unsaved = Osparade ändringar
111
+ pivot-unsaved-leave = Lämna utan att spara ändringarna?
110
112
  pivot-total = Summa
111
113
 
112
114
  period-nav = Period
@@ -10,6 +10,9 @@ from __future__ import annotations
10
10
 
11
11
  import hashlib
12
12
  import json
13
+ import re
14
+ from functools import lru_cache
15
+ from urllib.parse import urlparse
13
16
 
14
17
  from flask import g, has_request_context, request
15
18
 
@@ -55,12 +58,63 @@ def _menu_fingerprint(items):
55
58
  entry.get("title"),
56
59
  entry.get("link"),
57
60
  entry.get("icon"),
61
+ entry.get("match"),
58
62
  _menu_fingerprint(entry.get("subitems", [])),
59
63
  ]
60
64
  for entry in items
61
65
  ]
62
66
 
63
67
 
68
+ # --- Which entry is current -------------------------------------------------
69
+ #
70
+ # An entry matches its own link, everything below it (``link/*``) and any
71
+ # ``match`` patterns the app gave it. Of the entries matching a path, the one
72
+ # with the most specific pattern is current: an exact pattern beats a wildcard,
73
+ # then the longer literal prefix wins, then menu order. construct-core.js
74
+ # implements the same rule (`currentMenuLink`) for Unpoly navigations, which
75
+ # never redraw the sidebar; keep the two in step.
76
+ #
77
+ # Pattern syntax, shared with the client: ``*`` matches anything (slashes
78
+ # included), ``:name`` matches one path segment, everything else is literal.
79
+ # Paths and patterns are compared without trailing slashes.
80
+
81
+ _PATTERN_TOKEN = re.compile(r"(\*|:[A-Za-z][\w-]*)")
82
+
83
+
84
+ def _normalize_path(path):
85
+ """``path`` without query, fragment or trailing slashes ("/" stays)."""
86
+ path = urlparse(path).path
87
+ return path.rstrip("/") or "/"
88
+
89
+
90
+ @lru_cache(maxsize=512)
91
+ def _pattern_regex(pattern):
92
+ parts = []
93
+ for part in _PATTERN_TOKEN.split(pattern):
94
+ if part == "*":
95
+ parts.append(".*")
96
+ elif part.startswith(":") and len(part) > 1:
97
+ parts.append("[^/]+")
98
+ else:
99
+ parts.append(re.escape(part))
100
+ return re.compile("^" + "".join(parts) + "$")
101
+
102
+
103
+ def _pattern_rank(pattern):
104
+ """How specific ``pattern`` is: (exact, length of its literal prefix)."""
105
+ literal = _PATTERN_TOKEN.split(pattern, maxsplit=1)[0]
106
+ return (len(literal) == len(pattern), len(literal))
107
+
108
+
109
+ def _link_entries(items):
110
+ """Every entry in ``items`` that links somewhere, submenus flattened."""
111
+ for entry in items:
112
+ if entry.get("type") == "submenu":
113
+ yield from _link_entries(entry.get("subitems", []))
114
+ elif entry.get("link"):
115
+ yield entry
116
+
117
+
64
118
  class MenuMixin:
65
119
  """Menu state and behaviour of :class:`~.base.FlaskConstructUI`."""
66
120
 
@@ -80,7 +134,7 @@ class MenuMixin:
80
134
  self._menu_poll = poll
81
135
 
82
136
  def _add_page_menu_entry(
83
- self, page, submenu, submenu_icon, groups, visible, weight
137
+ self, page, submenu, submenu_icon, groups, visible, weight, match=None
84
138
  ):
85
139
  """Add ``page``'s menu entry; see :meth:`FlaskConstructUI.add_page`."""
86
140
  if not hasattr(page, "menu") or page.menu is False:
@@ -106,6 +160,14 @@ class MenuMixin:
106
160
  self._menu_predicates = True
107
161
  if weight is not None:
108
162
  menu["weight"] = weight
163
+ if match is not None:
164
+ menu["match"] = match
165
+ if callable(menu.get("match")):
166
+ # Like `visible`: it answers to the request, so the menu it renders
167
+ # (the patterns are on the links) can change on nothing a stamp
168
+ # key or the auth stamp describes.
169
+ self._menu_gated = True
170
+ self._menu_predicates = True
109
171
  if menu.get("groups") or menu.get("visible") is not None:
110
172
  self._menu_gated = True
111
173
  if submenu is not None:
@@ -144,14 +206,16 @@ class MenuMixin:
144
206
  self._menu_providers.append((provider, key))
145
207
  return provider
146
208
 
147
- def menu_provider(self, key=None):
209
+ def menu_provider(self, provider=None, *, key=None):
148
210
  """Decorator form of :meth:`add_menu_provider`.
149
211
 
150
212
  Usable bare (``@construct.menu_provider``) or called with a key
151
- (``@construct.menu_provider(key=lambda: feed.version)``).
213
+ (``@construct.menu_provider(key=lambda: feed.version)``). The bare
214
+ form arrives as ``provider``: a key is callable too, so telling the
215
+ two apart by "is it callable" registered the key as the provider.
152
216
  """
153
- if callable(key):
154
- return self.add_menu_provider(key)
217
+ if provider is not None:
218
+ return self.add_menu_provider(provider)
155
219
 
156
220
  def wrap(fn):
157
221
  return self.add_menu_provider(fn, key=key)
@@ -207,9 +271,77 @@ class MenuMixin:
207
271
  self._place_entry(items, dict(entry))
208
272
  items = self._visible_entries(items)
209
273
  _sort_menu(items)
274
+ for entry in _link_entries(items):
275
+ # Resolved once per request, so a callable runs once however often
276
+ # the patterns are read, and the fingerprint sees its result.
277
+ entry["match"] = self._extra_patterns(entry)
210
278
  g._construct_menu = items
211
279
  return items
212
280
 
281
+ def _extra_patterns(self, entry):
282
+ """``entry``'s own ``match`` patterns, as a list of absolute patterns.
283
+
284
+ ``match`` is a string (whitespace-separated patterns), a list of them,
285
+ or a zero-argument callable returning either, run per request. A
286
+ pattern not starting with "/" is relative to the construct URL, like a
287
+ page's link.
288
+ """
289
+ match = entry.get("match")
290
+ if callable(match):
291
+ match = match()
292
+ if not match:
293
+ return []
294
+ if isinstance(match, str):
295
+ match = match.split()
296
+ patterns = []
297
+ for pattern in match:
298
+ if not pattern.startswith("/"):
299
+ pattern = self.url.rstrip("/") + "/" + pattern
300
+ patterns.append(_normalize_path(pattern))
301
+ return patterns
302
+
303
+ def menu_patterns(self, entry):
304
+ """Every pattern that makes ``entry`` current: its link, the paths
305
+ below it, and its ``match`` patterns.
306
+
307
+ The link at the root (of the site or of the construct URL) does not
308
+ claim the paths below it — that would be every page.
309
+ """
310
+ link = entry.get("link")
311
+ if not link:
312
+ return []
313
+ base = _normalize_path(link)
314
+ patterns = [base]
315
+ if base not in ("/", _normalize_path(self.url)):
316
+ patterns.append(base + "/*")
317
+ patterns.extend(self._extra_patterns(entry))
318
+ return patterns
319
+
320
+ def _current_entry(self, items, path):
321
+ """The entry in ``items`` that ``path`` belongs to, or None."""
322
+ path = _normalize_path(path)
323
+ best, best_rank = None, None
324
+ for entry in _link_entries(items):
325
+ for pattern in self.menu_patterns(entry):
326
+ if not _pattern_regex(pattern).match(path):
327
+ continue
328
+ rank = _pattern_rank(pattern)
329
+ if best_rank is None or rank > best_rank:
330
+ best, best_rank = entry, rank
331
+ return best
332
+
333
+ @property
334
+ def menu_current(self):
335
+ """The entry of :attr:`menu` the current request belongs to, or None.
336
+
337
+ See the matching rule above ``_normalize_path``. Fragment endpoints get
338
+ None for free (nothing matches ``/_construct/menu``); the client marks
339
+ a reloaded menu against the browser's location instead.
340
+ """
341
+ if not has_request_context():
342
+ return None
343
+ return self._current_entry(self.menu, request.path)
344
+
213
345
  def _place_entry(self, items, entry):
214
346
  """Insert a provider-supplied ``entry`` into the working menu."""
215
347
  entry.setdefault("type", "item")
@@ -330,10 +462,11 @@ class MenuMixin:
330
462
 
331
463
  @property
332
464
  def titleFromLink(self):
333
- """Title of the menu entry matching the current route, or "".
465
+ """Title of the menu entry the current route belongs to, or "".
334
466
 
335
- Compared against ``request.path`` with trailing slashes normalised, the
336
- same way the sidebar decides which link is current. (This used to read
467
+ Uses the same rule as the sidebar's current link (:attr:`menu_current`),
468
+ so a record's edit view is named after its list's entry and a page an
469
+ entry claims through ``match`` after that entry. (This used to read
337
470
  a ``self.page`` attribute that has never existed on the extension, so
338
471
  it raised on every call — harmless while nothing used it, and the cause
339
472
  of an empty ``<title>`` once ``page_title`` started depending on it.)
@@ -347,17 +480,8 @@ class MenuMixin:
347
480
  """
348
481
  if not has_request_context():
349
482
  return ""
350
- here = request.path.rstrip("/")
351
- for item in self._menu:
352
- if item.get("type") == "submenu":
353
- for subitem in item.get("subitems", []):
354
- link = subitem.get("link")
355
- if link and link.rstrip("/") == here:
356
- return subitem["title"]
357
- link = item.get("link")
358
- if link and link.rstrip("/") == here:
359
- return item["title"]
360
- return ""
483
+ entry = self._current_entry(self._menu, request.path)
484
+ return entry["title"] if entry else ""
361
485
 
362
486
  def _add_menu_stamp(self, response):
363
487
  # Same problem as the auth stamp, one level down: the sidebar is
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  from functools import cached_property
5
5
  from math import ceil
6
6
  from flask import (
7
+ abort,
7
8
  current_app,
8
9
  flash,
9
10
  g,
@@ -210,6 +211,25 @@ class RelatedCrudTab:
210
211
  return verdict
211
212
  return rule(page, action, row, parent)
212
213
 
214
+ def can_list(self, construct) -> bool | str:
215
+ """Whether the tab's list may be shown under the current parent.
216
+
217
+ The list is part of the parent's page, so it follows the parent
218
+ page's own access — its ``can_edit`` or ``can_view`` for the parent —
219
+ whatever ``capabilities`` says about the children: a reader the
220
+ parent page turns away must not get the children by asking the tab
221
+ route directly. A denial returns the parent's ``can_edit`` answer, so
222
+ its message reaches the 403 page.
223
+ """
224
+ parent = self.parent_record()
225
+ if parent is None:
226
+ return False
227
+ page = self._parent_view
228
+ verdict = _capability(page, "edit", parent)
229
+ if verdict is True or _capability(page, "view", parent) is True:
230
+ return True
231
+ return verdict
232
+
213
233
  def can_add(self, construct) -> bool:
214
234
  return self._child_capability("add", construct)
215
235
 
@@ -229,7 +249,7 @@ class RelatedCrudTab:
229
249
  overriding one of those changes the controls and the routes alike.
230
250
  """
231
251
  hook = getattr(self, f"can_{action}")
232
- if action == "add":
252
+ if action in ("add", "list"):
233
253
  return hook(construct) is True
234
254
  return hook(row, construct) is True
235
255
 
@@ -373,6 +393,11 @@ class RelatedCrudTab:
373
393
  return ds, rec
374
394
 
375
395
  def list_view(id):
396
+ if rct.parent_record() is None:
397
+ abort(404)
398
+ allowed = rct.can_list(construct)
399
+ if allowed is not True:
400
+ return forbidden(parent_view, allowed)
376
401
  ds = child_dataclass()
377
402
  page, per_page, limit = get_pagination(
378
403
  getattr(rct.child_crud_class, "page_size", None)
@@ -159,6 +159,8 @@
159
159
  --fcu-pivot-input-width: 5em;
160
160
  --fcu-pivot-rowhead-bg: var(--fcu-surface);
161
161
  --fcu-pivot-total-weight: var(--fcu-font-weight-semibold);
162
+ --fcu-pivot-unsaved-color: var(--fcu-alert-warning-text);
163
+ --fcu-pivot-unsaved-text: var(--fcu-alert-warning-text);
162
164
 
163
165
  /* Value badges: Set members and ManyRelated items in lists and read-only
164
166
  views (.fcu-badge). Their ink is in contrast.py's PAIRS. */
@@ -213,6 +215,30 @@
213
215
  --fcu-sidebar-active-bg: var(--fcu-primary);
214
216
  --fcu-sidebar-active-text: var(--fcu-on-primary);
215
217
  --fcu-sidebar-focus: var(--fcu-focus);
218
+ /* The current page's mark beyond its colours: a box-shadow (so a bar costs
219
+ no layout, e.g. `inset -3px 0 0 var(--fcu-primary)`), the link's corner
220
+ radius and its weight. */
221
+ --fcu-sidebar-active-indicator: none;
222
+ --fcu-sidebar-active-radius: var(--fcu-menu-link-radius);
223
+ --fcu-sidebar-active-weight: var(--fcu-font-weight-medium);
224
+
225
+ /* Main menu geometry, so a design can make the sidebar denser, rounder or
226
+ plainer without moving the spacing scale every other control uses. A
227
+ submenu's toggle takes the top-level link metrics. */
228
+ --fcu-sidebar-padding: var(--fcu-space-xl);
229
+ --fcu-menu-gap: var(--fcu-space-2xs);
230
+ --fcu-menu-link-padding: var(--fcu-space-md) var(--fcu-space-lg);
231
+ --fcu-menu-link-gap: var(--fcu-space-lg);
232
+ --fcu-menu-link-radius: var(--fcu-radius-control);
233
+ --fcu-submenu-indent: 20px;
234
+ --fcu-submenu-gap: var(--fcu-space-3xs);
235
+ --fcu-submenu-link-padding: var(--fcu-space-sm) var(--fcu-space-lg);
236
+ --fcu-submenu-link-gap: var(--fcu-space-md);
237
+ --fcu-menu-heading-size: 11px;
238
+ --fcu-menu-heading-weight: var(--fcu-font-weight-bold);
239
+ --fcu-menu-heading-transform: uppercase;
240
+ --fcu-menu-heading-tracking: 0.05em;
241
+ --fcu-menu-heading-padding: var(--fcu-space-lg) var(--fcu-space-sm) var(--fcu-space-2xs);
216
242
 
217
243
  /* Data grid (grid.css). Derived from the semantic tokens rather than a
218
244
  fixed blue, so the grid takes the design's primary and follows dark mode
@@ -573,7 +599,7 @@ a:hover {
573
599
  background-color: var(--fcu-sidebar-bg);
574
600
  border-right: 1px solid var(--fcu-sidebar-border);
575
601
  color: var(--fcu-sidebar-text);
576
- padding: var(--fcu-space-xl);
602
+ padding: var(--fcu-sidebar-padding);
577
603
  box-sizing: border-box;
578
604
  }
579
605
 
@@ -812,15 +838,15 @@ a:hover {
812
838
  margin: 0;
813
839
  display: flex;
814
840
  flex-direction: column;
815
- gap: var(--fcu-space-2xs);
841
+ gap: var(--fcu-menu-gap);
816
842
  }
817
843
  .fcu-menu-header {
818
- font-size: 11px;
819
- font-weight: var(--fcu-font-weight-bold);
820
- text-transform: uppercase;
844
+ font-size: var(--fcu-menu-heading-size);
845
+ font-weight: var(--fcu-menu-heading-weight);
846
+ text-transform: var(--fcu-menu-heading-transform);
821
847
  color: var(--fcu-sidebar-text-muted);
822
- letter-spacing: 0.05em;
823
- padding: var(--fcu-space-lg) var(--fcu-space-sm) var(--fcu-space-2xs);
848
+ letter-spacing: var(--fcu-menu-heading-tracking);
849
+ padding: var(--fcu-menu-heading-padding);
824
850
  /* The sidebar group label is an <h2> for semantics; reset the UA heading
825
851
  margins so it keeps the flat look the class was written for. */
826
852
  margin: 0;
@@ -1001,13 +1027,13 @@ fieldset.fcu-form-section__body {
1001
1027
  .fcu-menu-link {
1002
1028
  display: flex;
1003
1029
  align-items: center;
1004
- padding: var(--fcu-space-md) var(--fcu-space-lg);
1030
+ padding: var(--fcu-menu-link-padding);
1005
1031
  text-decoration: none;
1006
1032
  color: var(--fcu-sidebar-text);
1007
- border-radius: var(--fcu-radius-control);
1033
+ border-radius: var(--fcu-menu-link-radius);
1008
1034
  transition: background-color var(--fcu-duration) var(--fcu-easing);
1009
1035
  font-weight: var(--fcu-font-weight-medium);
1010
- gap: var(--fcu-space-lg);
1036
+ gap: var(--fcu-menu-link-gap);
1011
1037
  }
1012
1038
  .fcu-menu-link:hover {
1013
1039
  background-color: var(--fcu-sidebar-hover-bg);
@@ -1017,28 +1043,38 @@ fieldset.fcu-form-section__body {
1017
1043
  .fcu-submenu-link.active {
1018
1044
  background-color: var(--fcu-sidebar-active-bg);
1019
1045
  color: var(--fcu-sidebar-active-text);
1046
+ box-shadow: var(--fcu-sidebar-active-indicator);
1047
+ border-radius: var(--fcu-sidebar-active-radius);
1048
+ font-weight: var(--fcu-sidebar-active-weight);
1020
1049
  }
1021
1050
  /* The shared --fcu-focus is only held to 3:1 against the content surfaces; a
1022
1051
  sidebar with its own ground needs its own ring. */
1023
1052
  .fcu-sidebar :focus-visible {
1024
1053
  outline-color: var(--fcu-sidebar-focus);
1025
1054
  }
1055
+ /* A submenu's toggle is a row of the menu like any link, not a group label,
1056
+ so it takes the link's metrics and hover rather than .fcu-menu-header's
1057
+ small caps; its icon then lines up with the plain links' icons. */
1026
1058
  .fcu-menu-toggle {
1027
- background: none;
1028
- border: none;
1029
- width: 100%;
1030
- text-align: left;
1059
+ padding: var(--fcu-menu-link-padding);
1060
+ color: var(--fcu-sidebar-text);
1061
+ border-radius: var(--fcu-menu-link-radius);
1062
+ transition: background-color var(--fcu-duration) var(--fcu-easing);
1063
+ font-weight: var(--fcu-font-weight-medium);
1031
1064
  cursor: pointer;
1032
1065
  box-sizing: border-box;
1033
1066
  }
1067
+ .fcu-menu-toggle:hover {
1068
+ background-color: var(--fcu-sidebar-hover-bg);
1069
+ color: var(--fcu-sidebar-hover-text);
1070
+ }
1034
1071
  /* No-JS collapsible submenu via native <details>. */
1035
1072
  .fcu-submenu-details > summary {
1036
1073
  list-style: none;
1037
1074
  cursor: pointer;
1038
1075
  display: flex;
1039
1076
  align-items: center;
1040
- justify-content: space-between;
1041
- gap: var(--fcu-space-sm);
1077
+ gap: var(--fcu-menu-link-gap);
1042
1078
  }
1043
1079
  .fcu-submenu-details > summary::-webkit-details-marker {
1044
1080
  display: none;
@@ -1047,28 +1083,31 @@ fieldset.fcu-form-section__body {
1047
1083
  color: var(--fcu-sidebar-text-muted);
1048
1084
  transition: transform var(--fcu-duration) var(--fcu-easing);
1049
1085
  flex: none;
1086
+ /* Only the arrow goes to the far edge; space-between on the summary would
1087
+ centre the title between the icon and the arrow. */
1088
+ margin-left: auto;
1050
1089
  }
1051
1090
  .fcu-submenu-details[open] > summary .fcu-submenu-arrow {
1052
1091
  transform: rotate(90deg);
1053
1092
  }
1054
1093
  .fcu-submenu {
1055
1094
  list-style: none;
1056
- padding-left: 20px;
1057
- margin: var(--fcu-space-2xs) 0 0 0;
1095
+ padding-left: var(--fcu-submenu-indent);
1096
+ margin: var(--fcu-menu-gap) 0 0 0;
1058
1097
  display: flex;
1059
1098
  flex-direction: column;
1060
- gap: var(--fcu-space-3xs);
1099
+ gap: var(--fcu-submenu-gap);
1061
1100
  }
1062
1101
  .fcu-submenu-link {
1063
1102
  display: flex;
1064
1103
  align-items: center;
1065
- padding: var(--fcu-space-sm) var(--fcu-space-lg);
1104
+ padding: var(--fcu-submenu-link-padding);
1066
1105
  text-decoration: none;
1067
1106
  color: var(--fcu-sidebar-text-muted);
1068
1107
  font-size: var(--fcu-font-size-sm);
1069
- border-radius: var(--fcu-radius-control);
1108
+ border-radius: var(--fcu-menu-link-radius);
1070
1109
  transition: all var(--fcu-duration) var(--fcu-easing);
1071
- gap: var(--fcu-space-md);
1110
+ gap: var(--fcu-submenu-link-gap);
1072
1111
  }
1073
1112
  .fcu-submenu-link:hover {
1074
1113
  color: var(--fcu-sidebar-hover-text);
@@ -1759,6 +1798,26 @@ fieldset.fcu-form-section__body {
1759
1798
  .fcu-pivot-input[data-fcu-unsent] {
1760
1799
  border-style: dashed;
1761
1800
  }
1801
+ /* PivotForm unsaved="mark": changed boxes, and totals that moved with them
1802
+ (or, for an app total, whose boxes changed), until the save. Not colour
1803
+ alone: the border and underline are dashed, the total italic. */
1804
+ .fcu-pivot-input[data-fcu-unsaved] {
1805
+ border-style: dashed;
1806
+ border-color: var(--fcu-pivot-unsaved-color);
1807
+ }
1808
+ .fcu-pivot-total[data-fcu-unsaved] {
1809
+ font-style: italic;
1810
+ text-decoration: underline dashed var(--fcu-pivot-unsaved-color);
1811
+ text-underline-offset: 0.2em;
1812
+ }
1813
+ /* PivotForm unsaved="status": the "Unsaved changes" line beside Save. */
1814
+ .fcu-pivot-status {
1815
+ color: var(--fcu-pivot-unsaved-text);
1816
+ font-weight: var(--fcu-font-weight-semibold);
1817
+ }
1818
+ .fcu-pivot-status:empty {
1819
+ display: none;
1820
+ }
1762
1821
  .fcu-pivot-controls {
1763
1822
  display: flex;
1764
1823
  flex-wrap: wrap;