@navette-js/panels 0.0.0-stage → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/THIRD-PARTY-NOTICES.md +12 -0
  4. package/dist/FILAMENT-LICENSE.md +21 -0
  5. package/dist/filament/css/filament/filament/app.css +2 -0
  6. package/dist/filament/fonts/INTER-LICENSE.txt +92 -0
  7. package/dist/filament/fonts/filament/filament/inter/index.css +1 -0
  8. package/dist/filament/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2 +0 -0
  9. package/dist/filament/fonts/filament/filament/inter/inter-cyrillic-wght-normal-DR6K5BQD.woff2 +0 -0
  10. package/dist/filament/fonts/filament/filament/inter/inter-greek-ext-wght-normal-A2J6H3EX.woff2 +0 -0
  11. package/dist/filament/fonts/filament/filament/inter/inter-greek-wght-normal-ZABHMKQG.woff2 +0 -0
  12. package/dist/filament/fonts/filament/filament/inter/inter-latin-ext-wght-normal-ZIT2UBIY.woff2 +0 -0
  13. package/dist/filament/fonts/filament/filament/inter/inter-latin-wght-normal-DIHYUR35.woff2 +0 -0
  14. package/dist/filament/fonts/filament/filament/inter/inter-vietnamese-wght-normal-VWEHJHBA.woff2 +0 -0
  15. package/dist/filament/js/filament/actions/actions.js +1 -0
  16. package/dist/filament/js/filament/filament/app.js +1 -0
  17. package/dist/filament/js/filament/filament/echo.js +13 -0
  18. package/dist/filament/js/filament/forms/components/builder.js +1 -0
  19. package/dist/filament/js/filament/forms/components/checkbox-list.js +1 -0
  20. package/dist/filament/js/filament/forms/components/code-editor.js +38 -0
  21. package/dist/filament/js/filament/forms/components/color-picker.js +1 -0
  22. package/dist/filament/js/filament/forms/components/date-time-picker.js +1 -0
  23. package/dist/filament/js/filament/forms/components/file-upload.js +116 -0
  24. package/dist/filament/js/filament/forms/components/js-field.js +1 -0
  25. package/dist/filament/js/filament/forms/components/key-value.js +1 -0
  26. package/dist/filament/js/filament/forms/components/markdown-editor.js +51 -0
  27. package/dist/filament/js/filament/forms/components/repeater.js +1 -0
  28. package/dist/filament/js/filament/forms/components/rich-editor.js +161 -0
  29. package/dist/filament/js/filament/forms/components/select.js +11 -0
  30. package/dist/filament/js/filament/forms/components/slider.js +1 -0
  31. package/dist/filament/js/filament/forms/components/tags-input.js +1 -0
  32. package/dist/filament/js/filament/forms/components/text-input/password-reveal.js +1 -0
  33. package/dist/filament/js/filament/forms/components/textarea.js +1 -0
  34. package/dist/filament/js/filament/forms/components/toggle-buttons.js +1 -0
  35. package/dist/filament/js/filament/notifications/notifications.js +1 -0
  36. package/dist/filament/js/filament/schemas/components/actions.js +1 -0
  37. package/dist/filament/js/filament/schemas/components/js-component.js +1 -0
  38. package/dist/filament/js/filament/schemas/components/tabs.js +1 -0
  39. package/dist/filament/js/filament/schemas/components/wizard.js +1 -0
  40. package/dist/filament/js/filament/schemas/schemas.js +1 -0
  41. package/dist/filament/js/filament/support/support.js +46 -0
  42. package/dist/filament/js/filament/tables/components/columns/checkbox.js +1 -0
  43. package/dist/filament/js/filament/tables/components/columns/select.js +11 -0
  44. package/dist/filament/js/filament/tables/components/columns/text-input.js +1 -0
  45. package/dist/filament/js/filament/tables/components/columns/toggle.js +1 -0
  46. package/dist/filament/js/filament/tables/tables.js +1 -0
  47. package/dist/filament/js/filament/widgets/components/chart.js +31 -0
  48. package/dist/filament/js/filament/widgets/components/js-widget.js +1 -0
  49. package/dist/filament/js/filament/widgets/components/stats-overview/stat/chart.js +20 -0
  50. package/dist/navette/navette/calendar.css +108 -0
  51. package/dist/navette/navette/motion.css +101 -0
  52. package/dist/navette/navette/motion.js +49 -0
  53. package/lib/auth.d.ts +61 -0
  54. package/lib/auth.js +140 -0
  55. package/lib/calendar.d.ts +82 -0
  56. package/lib/calendar.js +107 -0
  57. package/lib/index.d.ts +1624 -0
  58. package/lib/index.js +3532 -0
  59. package/lib/notifications.d.ts +76 -0
  60. package/lib/notifications.js +112 -0
  61. package/lib/topbar.d.ts +90 -0
  62. package/lib/topbar.js +240 -0
  63. package/package.json +58 -4
  64. package/resources/filament/LICENSE.md +21 -0
  65. package/resources/filament/README.md +2 -0
  66. package/resources/filament/packages/actions/resources/css/actions.css +30 -0
  67. package/resources/filament/packages/actions/resources/css/index.css +1 -0
  68. package/resources/filament/packages/forms/dist/index.css +1 -0
  69. package/resources/filament/packages/forms/resources/css/components/builder.css +206 -0
  70. package/resources/filament/packages/forms/resources/css/components/checkbox-list.css +55 -0
  71. package/resources/filament/packages/forms/resources/css/components/code-editor.css +79 -0
  72. package/resources/filament/packages/forms/resources/css/components/color-picker.css +17 -0
  73. package/resources/filament/packages/forms/resources/css/components/date-time-picker.css +127 -0
  74. package/resources/filament/packages/forms/resources/css/components/field.css +88 -0
  75. package/resources/filament/packages/forms/resources/css/components/file-upload/filepond-plugin-media-preview.css +104 -0
  76. package/resources/filament/packages/forms/resources/css/components/file-upload.css +199 -0
  77. package/resources/filament/packages/forms/resources/css/components/key-value.css +57 -0
  78. package/resources/filament/packages/forms/resources/css/components/markdown-editor.css +343 -0
  79. package/resources/filament/packages/forms/resources/css/components/modal-table-select.css +17 -0
  80. package/resources/filament/packages/forms/resources/css/components/radio.css +41 -0
  81. package/resources/filament/packages/forms/resources/css/components/repeater.css +401 -0
  82. package/resources/filament/packages/forms/resources/css/components/rich-editor.css +666 -0
  83. package/resources/filament/packages/forms/resources/css/components/select.css +13 -0
  84. package/resources/filament/packages/forms/resources/css/components/slider.css +54 -0
  85. package/resources/filament/packages/forms/resources/css/components/tags-input.css +33 -0
  86. package/resources/filament/packages/forms/resources/css/components/text-input.css +20 -0
  87. package/resources/filament/packages/forms/resources/css/components/textarea.css +26 -0
  88. package/resources/filament/packages/forms/resources/css/components/toggle-buttons.css +49 -0
  89. package/resources/filament/packages/forms/resources/css/index.css +32 -0
  90. package/resources/filament/packages/infolists/resources/css/components/code.css +21 -0
  91. package/resources/filament/packages/infolists/resources/css/components/color.css +34 -0
  92. package/resources/filament/packages/infolists/resources/css/components/entry.css +68 -0
  93. package/resources/filament/packages/infolists/resources/css/components/icon.css +39 -0
  94. package/resources/filament/packages/infolists/resources/css/components/image.css +161 -0
  95. package/resources/filament/packages/infolists/resources/css/components/key-value.css +24 -0
  96. package/resources/filament/packages/infolists/resources/css/components/repeatable.css +136 -0
  97. package/resources/filament/packages/infolists/resources/css/components/text.css +234 -0
  98. package/resources/filament/packages/infolists/resources/css/index.css +14 -0
  99. package/resources/filament/packages/notifications/resources/css/database-notifications.css +56 -0
  100. package/resources/filament/packages/notifications/resources/css/index.css +3 -0
  101. package/resources/filament/packages/notifications/resources/css/notification.css +109 -0
  102. package/resources/filament/packages/notifications/resources/css/notifications.css +29 -0
  103. package/resources/filament/packages/panels/resources/css/base.css +13 -0
  104. package/resources/filament/packages/panels/resources/css/components/global-search.css +93 -0
  105. package/resources/filament/packages/panels/resources/css/components/header.css +56 -0
  106. package/resources/filament/packages/panels/resources/css/components/layout.css +182 -0
  107. package/resources/filament/packages/panels/resources/css/components/logo.css +11 -0
  108. package/resources/filament/packages/panels/resources/css/components/page.css +60 -0
  109. package/resources/filament/packages/panels/resources/css/components/sidebar.css +311 -0
  110. package/resources/filament/packages/panels/resources/css/components/tenant-menu.css +49 -0
  111. package/resources/filament/packages/panels/resources/css/components/theme-switcher.css +15 -0
  112. package/resources/filament/packages/panels/resources/css/components/topbar.css +89 -0
  113. package/resources/filament/packages/panels/resources/css/components/user-menu.css +46 -0
  114. package/resources/filament/packages/panels/resources/css/index.css +40 -0
  115. package/resources/filament/packages/panels/resources/css/theme.css +2 -0
  116. package/resources/filament/packages/panels/resources/css/utilities.css +3 -0
  117. package/resources/filament/packages/panels/resources/css/widgets/account-widget.css +21 -0
  118. package/resources/filament/packages/panels/resources/css/widgets/filament-info-widget.css +21 -0
  119. package/resources/filament/packages/schemas/resources/css/components/actions.css +29 -0
  120. package/resources/filament/packages/schemas/resources/css/components/flex.css +106 -0
  121. package/resources/filament/packages/schemas/resources/css/components/form.css +7 -0
  122. package/resources/filament/packages/schemas/resources/css/components/fused-group.css +337 -0
  123. package/resources/filament/packages/schemas/resources/css/components/icon.css +11 -0
  124. package/resources/filament/packages/schemas/resources/css/components/image.css +12 -0
  125. package/resources/filament/packages/schemas/resources/css/components/section.css +24 -0
  126. package/resources/filament/packages/schemas/resources/css/components/tabs.css +77 -0
  127. package/resources/filament/packages/schemas/resources/css/components/text.css +77 -0
  128. package/resources/filament/packages/schemas/resources/css/components/unordered-list.css +15 -0
  129. package/resources/filament/packages/schemas/resources/css/components/wizard.css +177 -0
  130. package/resources/filament/packages/schemas/resources/css/index.css +12 -0
  131. package/resources/filament/packages/schemas/resources/css/schema.css +132 -0
  132. package/resources/filament/packages/support/dist/index.css +1 -0
  133. package/resources/filament/packages/support/resources/css/base.css +23 -0
  134. package/resources/filament/packages/support/resources/css/components/avatar.css +15 -0
  135. package/resources/filament/packages/support/resources/css/components/badge.css +90 -0
  136. package/resources/filament/packages/support/resources/css/components/breadcrumbs.css +25 -0
  137. package/resources/filament/packages/support/resources/css/components/button.css +214 -0
  138. package/resources/filament/packages/support/resources/css/components/callout.css +57 -0
  139. package/resources/filament/packages/support/resources/css/components/dropdown/header.css +21 -0
  140. package/resources/filament/packages/support/resources/css/components/dropdown/index.css +79 -0
  141. package/resources/filament/packages/support/resources/css/components/dropdown/list/index.css +7 -0
  142. package/resources/filament/packages/support/resources/css/components/dropdown/list/item.css +74 -0
  143. package/resources/filament/packages/support/resources/css/components/empty-state.css +67 -0
  144. package/resources/filament/packages/support/resources/css/components/fieldset.css +23 -0
  145. package/resources/filament/packages/support/resources/css/components/grid.css +511 -0
  146. package/resources/filament/packages/support/resources/css/components/icon-button.css +124 -0
  147. package/resources/filament/packages/support/resources/css/components/icon.css +31 -0
  148. package/resources/filament/packages/support/resources/css/components/input/checkbox.css +32 -0
  149. package/resources/filament/packages/support/resources/css/components/input/index.css +55 -0
  150. package/resources/filament/packages/support/resources/css/components/input/one-time-code.css +7 -0
  151. package/resources/filament/packages/support/resources/css/components/input/radio.css +11 -0
  152. package/resources/filament/packages/support/resources/css/components/input/select.css +154 -0
  153. package/resources/filament/packages/support/resources/css/components/input/wrapper.css +107 -0
  154. package/resources/filament/packages/support/resources/css/components/link.css +90 -0
  155. package/resources/filament/packages/support/resources/css/components/loading-indicator.css +3 -0
  156. package/resources/filament/packages/support/resources/css/components/loading-section.css +3 -0
  157. package/resources/filament/packages/support/resources/css/components/modal.css +494 -0
  158. package/resources/filament/packages/support/resources/css/components/pagination.css +160 -0
  159. package/resources/filament/packages/support/resources/css/components/section.css +234 -0
  160. package/resources/filament/packages/support/resources/css/components/tabs.css +70 -0
  161. package/resources/filament/packages/support/resources/css/components/toggle.css +60 -0
  162. package/resources/filament/packages/support/resources/css/components/tooltip.css +36 -0
  163. package/resources/filament/packages/support/resources/css/index.css +133 -0
  164. package/resources/filament/packages/support/resources/css/sortable.css +3 -0
  165. package/resources/filament/packages/support/resources/css/utilities.css +897 -0
  166. package/resources/filament/packages/tables/resources/css/actions.css +51 -0
  167. package/resources/filament/packages/tables/resources/css/cell.css +139 -0
  168. package/resources/filament/packages/tables/resources/css/column-manager.css +48 -0
  169. package/resources/filament/packages/tables/resources/css/columns/checkbox.css +54 -0
  170. package/resources/filament/packages/tables/resources/css/columns/color.css +76 -0
  171. package/resources/filament/packages/tables/resources/css/columns/icon.css +81 -0
  172. package/resources/filament/packages/tables/resources/css/columns/image.css +207 -0
  173. package/resources/filament/packages/tables/resources/css/columns/layout/grid.css +49 -0
  174. package/resources/filament/packages/tables/resources/css/columns/layout/panel.css +3 -0
  175. package/resources/filament/packages/tables/resources/css/columns/layout/split.css +35 -0
  176. package/resources/filament/packages/tables/resources/css/columns/layout/stack.css +29 -0
  177. package/resources/filament/packages/tables/resources/css/columns/select.css +36 -0
  178. package/resources/filament/packages/tables/resources/css/columns/summaries/icon-count.css +43 -0
  179. package/resources/filament/packages/tables/resources/css/columns/summaries/range.css +27 -0
  180. package/resources/filament/packages/tables/resources/css/columns/summaries/text.css +29 -0
  181. package/resources/filament/packages/tables/resources/css/columns/summaries/values.css +33 -0
  182. package/resources/filament/packages/tables/resources/css/columns/text-input.css +36 -0
  183. package/resources/filament/packages/tables/resources/css/columns/text.css +319 -0
  184. package/resources/filament/packages/tables/resources/css/columns/toggle.css +54 -0
  185. package/resources/filament/packages/tables/resources/css/container.css +311 -0
  186. package/resources/filament/packages/tables/resources/css/content.css +342 -0
  187. package/resources/filament/packages/tables/resources/css/empty-state.css +31 -0
  188. package/resources/filament/packages/tables/resources/css/group-header.css +54 -0
  189. package/resources/filament/packages/tables/resources/css/header-cell.css +224 -0
  190. package/resources/filament/packages/tables/resources/css/index.css +26 -0
  191. package/resources/filament/packages/tables/resources/css/row.css +47 -0
  192. package/resources/filament/packages/tables/resources/css/table.css +394 -0
  193. package/resources/filament/packages/widgets/resources/css/chart-widget.css +88 -0
  194. package/resources/filament/packages/widgets/resources/css/index.css +3 -0
  195. package/resources/filament/packages/widgets/resources/css/stats-overview-widget.css +67 -0
  196. package/resources/filament/packages/widgets/resources/css/widget.css +3 -0
  197. package/theme.css +17 -0
  198. package/views/components/logo.ejs +9 -0
  199. package/views/components/tenant-menu.ejs +13 -0
  200. package/views/components/user-menu.ejs +82 -0
  201. package/views/layout/base.ejs +129 -0
  202. package/views/layout/index.ejs +41 -0
  203. package/views/layout/simple.ejs +19 -0
  204. package/views/livewire/sidebar.ejs +123 -0
  205. package/views/livewire/topbar.ejs +47 -0
  206. package/views/pages/page.ejs +143 -0
  207. package/views/pages/simple.ejs +32 -0
@@ -0,0 +1,101 @@
1
+ /*
2
+ * Navette — mouvement et indicateurs de chargement.
3
+ *
4
+ * Écart volontaire avec FilamentPHP (décision du 2026-11-12) : cette feuille n'ajoute que du mouvement et des indicateurs,
5
+ * sans toucher au balisage ni aux dimensions. Elle se désactive avec `Panel.motion(false)`, ce qui rend l'interface de Filament
6
+ * telle quelle. Les courbes reprennent l'esprit d'iOS : ressorts doux, sorties rapides, échelle légère à la pression.
7
+ * Tout est neutralisé pour les personnes qui demandent moins d'animations (`prefers-reduced-motion`).
8
+ */
9
+ :root {
10
+ --nv-spring: cubic-bezier(0.32, 0.72, 0, 1); /* glissement amorti, sans dépassement */
11
+ --nv-bounce: cubic-bezier(0.2, 0.9, 0.3, 1.25); /* léger dépassement à l'arrivée */
12
+ --nv-out: cubic-bezier(0.4, 0, 0.2, 1);
13
+ }
14
+
15
+ @media (prefers-reduced-motion: no-preference) {
16
+ /* --- arrivée de page : le contenu monte de quelques pixels en apparaissant */
17
+ @keyframes nv-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
18
+ /* `backwards` et non `both` : une animation de `transform` qui reste appliquée après sa fin fait de l'élément le bloc conteneur de ses descendants en `position: fixed` ; le fond d'une modale ne couvrait alors que la page, pas la fenêtre. */
19
+ .fi-page { animation: nv-rise 0.42s var(--nv-spring) backwards; }
20
+ .fi-simple-main { animation: nv-rise 0.5s var(--nv-spring) backwards; }
21
+
22
+ /* --- pression : boutons, liens d'action, éléments de navigation */
23
+ .fi-btn, .fi-icon-btn, .fi-link, .fi-tabs-item, .fi-sidebar-item-btn, .fi-dropdown-list-item, .fi-pagination-item-btn {
24
+ transition: transform 0.18s var(--nv-spring), background-color 0.2s var(--nv-out), color 0.2s var(--nv-out), box-shadow 0.2s var(--nv-out), opacity 0.2s var(--nv-out);
25
+ }
26
+ .fi-btn:active:not(.fi-disabled, [disabled]), .fi-icon-btn:active:not(.fi-disabled, [disabled]) { transform: scale(0.96); }
27
+ .fi-link:active, .fi-tabs-item:active, .fi-sidebar-item-btn:active, .fi-dropdown-list-item:active:not(.fi-disabled), .fi-pagination-item-btn:active { transform: scale(0.97); }
28
+
29
+ /* --- modales : la fenêtre arrive avec un ressort ; le fond reste celui de Filament (voile sombre, sans flou) */
30
+ .fi-modal-window { transition-timing-function: var(--nv-spring) !important; transition-duration: 0.42s !important; }
31
+ .fi-modal-window.fi-transition-leave { transition-timing-function: var(--nv-out) !important; transition-duration: 0.2s !important; }
32
+
33
+ /* --- menus déroulants : ils s'ouvrent depuis leur déclencheur */
34
+ @keyframes nv-pop { from { opacity: 0; transform: scale(0.94) translateY(-4px); } to { opacity: 1; transform: none; } }
35
+ .fi-dropdown-panel:not([style*="display: none"]) { animation: nv-pop 0.26s var(--nv-bounce); transform-origin: top; }
36
+
37
+ /* --- notifications : entrée latérale amortie */
38
+ .fi-no-notification { transition-timing-function: var(--nv-spring) !important; transition-duration: 0.45s !important; }
39
+
40
+ /* --- surfaces et champs */
41
+ .fi-ta-row { transition: background-color 0.2s var(--nv-out); }
42
+ .fi-input-wrp { transition: box-shadow 0.2s var(--nv-out), background-color 0.2s var(--nv-out); }
43
+ .fi-toggle > :first-child { transition-timing-function: var(--nv-bounce) !important; transition-duration: 0.3s !important; }
44
+ .fi-badge { transition: background-color 0.2s var(--nv-out), color 0.2s var(--nv-out); }
45
+ .fi-sidebar-group-items { transition: opacity 0.25s var(--nv-out); }
46
+
47
+ /* --- indicateur de chargement des zones différées : une roue, au lieu d'un cadre vide */
48
+ @keyframes nv-spin { to { transform: rotate(360deg); } }
49
+ @keyframes nv-breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
50
+ .fi-loading-section { position: relative; animation: nv-breathe 1.6s ease-in-out infinite; }
51
+ .fi-loading-section::after {
52
+ content: ''; position: absolute; inset: 0; margin: auto; width: 1.5rem; height: 1.5rem; border-radius: 9999px;
53
+ border: 2.5px solid color-mix(in oklab, currentColor 18%, transparent); border-top-color: var(--primary-500, currentColor);
54
+ animation: nv-spin 0.7s linear infinite;
55
+ }
56
+
57
+ /* --- éléments qui arrivent après l'affichage de la page : ils se déploient au lieu d'apparaître d'un coup */
58
+ /* compteur d'onglet (chargé après coup) : il s'élargit depuis zéro, l'onglet s'étire avec lui */
59
+ @keyframes nv-expand {
60
+ from { opacity: 0; max-width: 0; padding-inline: 0; margin-inline-start: -0.5rem; transform: scale(0.6); }
61
+ 60% { opacity: 1; }
62
+ to { opacity: 1; max-width: 6rem; transform: none; }
63
+ }
64
+ .fi-tabs-item > .fi-badge { overflow: hidden; white-space: nowrap; transform-origin: left center; animation: nv-expand 0.5s var(--nv-spring) both; }
65
+ /* roue d'attente du compteur : elle s'efface en douceur */
66
+ .fi-tabs-item-badge-placeholder { transition: opacity 0.2s var(--nv-out); }
67
+ /* zone chargée à la demande (relation manager, widget) : son contenu monte en place quand il remplace la roue */
68
+ .fi-resource-relation-manager, .fi-wi-widget { animation: nv-rise 0.45s var(--nv-spring) backwards; }
69
+ /* lignes d'une table qui vient d'être rechargée, erreurs de validation, lignes ajoutées à un Repeater */
70
+ @keyframes nv-fade-in { from { opacity: 0; } to { opacity: 1; } }
71
+ @keyframes nv-unfold { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
72
+ .fi-fo-field-wrp-error-message { animation: nv-unfold 0.3s var(--nv-spring) backwards; }
73
+ /* `backwards` et non `both` : une animation d'opacité qui reste appliquée garde la ligne en contexte d'empilement ; la liste d'un sélecteur de la ligne passait alors sous le bouton d'ajout du Repeater. */
74
+ .fi-fo-table-repeater tbody tr { animation: nv-fade-in 0.3s var(--nv-out) backwards; }
75
+ .fi-ta-summary-row, .fi-ta-summary-header-row { animation: nv-fade-in 0.35s var(--nv-out) backwards; }
76
+
77
+ /* --- contenu qui se recharge : il s'estompe légèrement pendant la requête */
78
+ .fi-ta-content-ctn { transition: opacity 0.2s var(--nv-out); }
79
+ html[data-nv-busy] .fi-ta-content-ctn { opacity: 0.6; }
80
+ }
81
+
82
+ /* --- pastille d'activité : visible quand une requête dure (posée par motion.js) */
83
+ .nv-busy {
84
+ position: fixed; top: 0.75rem; left: 50%; z-index: 60; display: flex; align-items: center; gap: 0.5rem;
85
+ padding: 0.4rem 0.8rem; border-radius: 9999px; font-size: 0.8125rem; font-weight: 500; pointer-events: none;
86
+ color: var(--gray-700); background: color-mix(in oklab, white 82%, transparent);
87
+ box-shadow: 0 6px 24px rgb(0 0 0 / 0.12), 0 0 0 1px rgb(0 0 0 / 0.06);
88
+ backdrop-filter: blur(14px) saturate(1.6); -webkit-backdrop-filter: blur(14px) saturate(1.6);
89
+ opacity: 0; transform: translate(-50%, -150%); transition: transform 0.4s var(--nv-spring), opacity 0.25s var(--nv-out);
90
+ }
91
+ .dark .nv-busy { color: var(--gray-200); background: color-mix(in oklab, var(--gray-900) 78%, transparent); box-shadow: 0 6px 24px rgb(0 0 0 / 0.4), 0 0 0 1px rgb(255 255 255 / 0.08); }
92
+ html[data-nv-busy] .nv-busy { opacity: 1; transform: translate(-50%, 0); }
93
+ .nv-busy::before {
94
+ content: ''; width: 0.875rem; height: 0.875rem; border-radius: 9999px; border: 2px solid color-mix(in oklab, currentColor 22%, transparent);
95
+ border-top-color: var(--primary-500, currentColor); animation: nv-spin-always 0.7s linear infinite;
96
+ }
97
+ @keyframes nv-spin-always { to { transform: rotate(360deg); } }
98
+ @media (prefers-reduced-motion: reduce) {
99
+ .nv-busy { transition: opacity 0.2s linear; transform: translate(-50%, 0); }
100
+ .nv-busy::before { animation-duration: 1.6s; }
101
+ }
@@ -0,0 +1,49 @@
1
+ // Navette — pastille d'activité : signale une requête qui dure, pour que l'écran ne paraisse jamais figé.
2
+ // Écart volontaire avec FilamentPHP, désactivable avec `Panel.motion(false)`. Aucune dépendance : s'appuie sur les hooks de Livewire.
3
+ (() => {
4
+ const DELAY = 350; // en dessous, la réponse arrive avant qu'un indicateur soit utile
5
+ let pending = 0;
6
+ let timer = null;
7
+
8
+ const pill = () => {
9
+ let element = document.querySelector('.nv-busy');
10
+ if (!element) {
11
+ element = document.createElement('div');
12
+ element.className = 'nv-busy';
13
+ element.setAttribute('role', 'status');
14
+ element.setAttribute('aria-live', 'polite');
15
+ // Conservée d'une page à l'autre par la navigation de Livewire.
16
+ element.setAttribute('wire:ignore', '');
17
+ element.setAttribute('x-persist', 'nv-busy');
18
+ element.textContent = document.documentElement.lang?.startsWith('fr') ? 'Chargement…' : 'Loading…';
19
+ document.body.appendChild(element);
20
+ }
21
+ return element;
22
+ };
23
+ const update = () => {
24
+ if (pending > 0) {
25
+ if (timer === null && !document.documentElement.hasAttribute('data-nv-busy')) {
26
+ timer = setTimeout(() => { timer = null; if (pending > 0) { pill(); document.documentElement.setAttribute('data-nv-busy', ''); } }, DELAY);
27
+ }
28
+ return;
29
+ }
30
+ if (timer !== null) { clearTimeout(timer); timer = null; }
31
+ document.documentElement.removeAttribute('data-nv-busy');
32
+ };
33
+ const begin = () => { pending++; update(); };
34
+ const end = () => { pending = Math.max(0, pending - 1); update(); };
35
+
36
+ document.addEventListener('livewire:init', () => {
37
+ // Chaque requête de mise à jour d'un composant.
38
+ window.Livewire.hook('commit', ({ succeed, fail }) => {
39
+ begin();
40
+ let done = false;
41
+ const finish = () => { if (!done) { done = true; end(); } };
42
+ succeed(finish);
43
+ fail(finish);
44
+ });
45
+ });
46
+ // Navigation entre pages.
47
+ document.addEventListener('livewire:navigate', begin);
48
+ document.addEventListener('livewire:navigated', () => { pending = 0; update(); });
49
+ })();
package/lib/auth.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { AuthProvider, TenantProvider } from './index.ts';
2
+ /** Hashes a password with scrypt and a random salt: `$scrypt$N$r$p$salt$hash`. */
3
+ export declare function hashPassword(password: string): Promise<string>;
4
+ /** Checks a password against a `hashPassword` hash in constant time. Any other hash format is rejected. */
5
+ export declare function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean>;
6
+ export declare function verifyAgainstDummy(password: string): Promise<void>;
7
+ /** Signed, expiring token: `payload.signature`. `purpose` prevents reusing a token for another use. */
8
+ export declare function signToken(key: string, purpose: string, payload: Record<string, unknown>, ttlSeconds: number): string;
9
+ /** Payload of a valid, unexpired token, otherwise `null`. */
10
+ export declare function readToken(key: string, purpose: string, token: unknown): Record<string, unknown> | null;
11
+ /** Short fingerprint of a secret (the stored password hash): a token embedding it dies when the secret changes. */
12
+ export declare const fingerprint: (value: unknown) => string;
13
+ /** Signs an expiring email verification URL. */
14
+ export declare function signVerificationUrl(key: string, id: string | number, email: string, expires: number): {
15
+ hash: string;
16
+ signature: string;
17
+ };
18
+ export declare function checkVerificationUrl(key: string, id: string, hash: string, expires: unknown, signature: unknown): boolean;
19
+ export type MailMessage = {
20
+ to: string;
21
+ subject: string;
22
+ lines: string[];
23
+ action?: {
24
+ label: string;
25
+ url: string;
26
+ };
27
+ outro?: string[];
28
+ };
29
+ /** Sends an email: plug in your own service (SMTP, API…). The text is provided ready to use, as lines. */
30
+ export interface Mailer {
31
+ send(message: MailMessage): void | Promise<void>;
32
+ }
33
+ /** Plain-text rendering of a message. */
34
+ export declare const mailText: (message: MailMessage) => string;
35
+ /** Default mailer: writes the message to the console (development). */
36
+ export declare const consoleMailer: Mailer;
37
+ /** Appends each message to a file as one JSON line (tests, demos). */
38
+ export declare const fileMailer: (file: string) => Mailer;
39
+ /** Table accessor: `(table) => knex(table)`. */
40
+ export type TableQuery = (table: string) => any;
41
+ /**
42
+ * Users stored in a `users` table (id, name, email, password, email_verified_at).
43
+ * Passwords are hashed with `hashPassword` (scrypt). To accept other hash formats (bcrypt…), pass
44
+ * `verify(password, hash)`: it is called for any stored hash that is not in scrypt format.
45
+ */
46
+ export declare function knexAuthProvider(db: TableQuery, options?: {
47
+ table?: string;
48
+ verify?: (password: string, hash: string) => boolean | Promise<boolean>;
49
+ }): AuthProvider;
50
+ /**
51
+ * Tenants (teams) stored in a table and linked to users through a pivot table (`teams` and `team_user` by default).
52
+ * `label`: text shown above the tenant name in the menu ("Current team").
53
+ */
54
+ export declare function knexTenantProvider(db: TableQuery, options?: {
55
+ table?: string;
56
+ pivot?: string;
57
+ foreignKey?: string;
58
+ userKey?: string;
59
+ nameAttribute?: string;
60
+ label?: string | null;
61
+ }): TenantProvider;
package/lib/auth.js ADDED
@@ -0,0 +1,140 @@
1
+ // Authentication building blocks: password hashing, signed tokens (password reset, email verification),
2
+ // mailers, and ready-made knex providers (users, tenants).
3
+ import { appendFileSync } from 'node:fs';
4
+ import { createHash, createHmac, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
5
+ const SCRYPT_COST = { N: 16384, r: 8, p: 1 };
6
+ const scrypt = (password, salt, length, options) => new Promise((resolve, reject) => scryptCallback(password, salt, length, { ...options, maxmem: 256 * 1024 * 1024 }, (error, key) => (error ? reject(error) : resolve(key))));
7
+ /** Hashes a password with scrypt and a random salt: `$scrypt$N$r$p$salt$hash`. */
8
+ export async function hashPassword(password) {
9
+ const salt = randomBytes(16);
10
+ const hash = await scrypt(password, salt, 32, SCRYPT_COST);
11
+ return `$scrypt$${SCRYPT_COST.N}$${SCRYPT_COST.r}$${SCRYPT_COST.p}$${salt.toString('base64url')}$${hash.toString('base64url')}`;
12
+ }
13
+ /** Checks a password against a `hashPassword` hash in constant time. Any other hash format is rejected. */
14
+ export async function verifyPassword(password, stored) {
15
+ const parts = String(stored ?? '').split('$');
16
+ if (parts.length !== 7 || parts[1] !== 'scrypt')
17
+ return false;
18
+ const [N, r, p] = parts.slice(2, 5).map(Number);
19
+ // Bounded parameters: a forged hash cannot make the server work indefinitely.
20
+ if (![N, r, p].every(Number.isInteger) || N < 2 || N > 2 ** 20 || r < 1 || r > 32 || p < 1 || p > 16)
21
+ return false;
22
+ const expected = Buffer.from(parts[6], 'base64url');
23
+ if (expected.length < 16 || expected.length > 64)
24
+ return false;
25
+ const actual = await scrypt(password, Buffer.from(parts[5], 'base64url'), expected.length, { N, r, p });
26
+ return timingSafeEqual(expected, actual);
27
+ }
28
+ /** Verified when the email is unknown, so the response takes the same time (no user enumeration by timing). */
29
+ let dummyHash = null;
30
+ export async function verifyAgainstDummy(password) { await verifyPassword(password, await (dummyHash ??= hashPassword(randomBytes(16).toString('hex')))); }
31
+ const sign = (key, value) => createHmac('sha256', key).update(value).digest('base64url');
32
+ const sameText = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
33
+ /** Signed, expiring token: `payload.signature`. `purpose` prevents reusing a token for another use. */
34
+ export function signToken(key, purpose, payload, ttlSeconds) {
35
+ const body = Buffer.from(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds })).toString('base64url');
36
+ return `${body}.${sign(key, `${purpose}.${body}`)}`;
37
+ }
38
+ /** Payload of a valid, unexpired token, otherwise `null`. */
39
+ export function readToken(key, purpose, token) {
40
+ if (typeof token !== 'string' || token.length > 2000)
41
+ return null;
42
+ const [body, mac] = token.split('.');
43
+ if (!body || !mac || !sameText(mac, sign(key, `${purpose}.${body}`)))
44
+ return null;
45
+ try {
46
+ const payload = JSON.parse(Buffer.from(body, 'base64url').toString());
47
+ return typeof payload === 'object' && payload && Number(payload.exp) > Date.now() / 1000 ? payload : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** Short fingerprint of a secret (the stored password hash): a token embedding it dies when the secret changes. */
54
+ export const fingerprint = (value) => createHash('sha256').update(String(value ?? '')).digest('base64url').slice(0, 22);
55
+ /** Signs an expiring email verification URL. */
56
+ export function signVerificationUrl(key, id, email, expires) {
57
+ const hash = createHash('sha1').update(email).digest('hex');
58
+ return { hash, signature: sign(key, `verify-email.${id}.${hash}.${expires}`) };
59
+ }
60
+ export function checkVerificationUrl(key, id, hash, expires, signature) {
61
+ const at = Number(expires);
62
+ return Number.isFinite(at) && at > Date.now() / 1000 && typeof signature === 'string' && sameText(signature, sign(key, `verify-email.${id}.${hash}.${at}`));
63
+ }
64
+ /** Plain-text rendering of a message. */
65
+ export const mailText = (message) => [...message.lines, ...(message.action ? ['', `${message.action.label} : ${message.action.url}`, ''] : []), ...(message.outro ?? [])].join('\n');
66
+ /** Default mailer: writes the message to the console (development). */
67
+ export const consoleMailer = { send(message) { console.log(`[navette:mail] To ${message.to} — ${message.subject}\n${mailText(message)}`); } };
68
+ /** Appends each message to a file as one JSON line (tests, demos). */
69
+ export const fileMailer = (file) => ({ send(message) { appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...message }) + '\n'); } });
70
+ const now = () => new Date().toISOString().slice(0, 19).replace('T', ' ');
71
+ /**
72
+ * Users stored in a `users` table (id, name, email, password, email_verified_at).
73
+ * Passwords are hashed with `hashPassword` (scrypt). To accept other hash formats (bcrypt…), pass
74
+ * `verify(password, hash)`: it is called for any stored hash that is not in scrypt format.
75
+ */
76
+ export function knexAuthProvider(db, options = {}) {
77
+ const table = options.table ?? 'users';
78
+ const toUser = (row) => (row ? { id: row.id, name: row.name, email: row.email, emailVerifiedAt: row.email_verified_at ?? null } : null);
79
+ const byEmail = (email) => db(table).where('email', String(email)).first();
80
+ return {
81
+ async attempt(email, password) {
82
+ const row = await byEmail(email);
83
+ if (!row) {
84
+ await verifyAgainstDummy(password);
85
+ return null;
86
+ }
87
+ const stored = String(row.password ?? '');
88
+ const ok = stored.startsWith('$scrypt$') ? await verifyPassword(password, stored) : Boolean(await options.verify?.(password, stored));
89
+ return ok ? toUser(row) : null;
90
+ },
91
+ async find(id) { return toUser(await db(table).where('id', id).first()); },
92
+ async findByEmail(email) { return toUser(await byEmail(email)); },
93
+ async create(data) {
94
+ await db(table).insert({ name: data.name, email: data.email, password: await hashPassword(data.password), created_at: now(), updated_at: now() });
95
+ return toUser(await byEmail(data.email));
96
+ },
97
+ async update(user, data) {
98
+ const changes = { updated_at: now() };
99
+ if (typeof data.name === 'string')
100
+ changes.name = data.name;
101
+ if (typeof data.email === 'string' && data.email !== user.email) {
102
+ changes.email = data.email;
103
+ changes.email_verified_at = null;
104
+ }
105
+ if (typeof data.password === 'string' && data.password !== '')
106
+ changes.password = await hashPassword(data.password);
107
+ await db(table).where('id', user.id).update(changes);
108
+ return toUser(await db(table).where('id', user.id).first());
109
+ },
110
+ async hasVerifiedEmail(user) { return Boolean((await db(table).where('id', user.id).first())?.email_verified_at); },
111
+ async markEmailAsVerified(user) { await db(table).where('id', user.id).update({ email_verified_at: now(), updated_at: now() }); },
112
+ async getPasswordFingerprint(user) { return fingerprint((await db(table).where('id', user.id).first())?.password); },
113
+ };
114
+ }
115
+ /**
116
+ * Tenants (teams) stored in a table and linked to users through a pivot table (`teams` and `team_user` by default).
117
+ * `label`: text shown above the tenant name in the menu ("Current team").
118
+ */
119
+ export function knexTenantProvider(db, options = {}) {
120
+ const table = options.table ?? 'teams', pivot = options.pivot ?? 'team_user';
121
+ const foreignKey = options.foreignKey ?? 'team_id', userKey = options.userKey ?? 'user_id', nameAttribute = options.nameAttribute ?? 'name';
122
+ return {
123
+ async getTenants(user) { return db(table).join(pivot, `${pivot}.${foreignKey}`, `${table}.id`).where(`${pivot}.${userKey}`, user.id).select(`${table}.*`).orderBy(`${table}.id`); },
124
+ async find(key) { return /^\d+$/.test(String(key)) ? (await db(table).where('id', Number(key)).first()) ?? null : null; },
125
+ async canAccess(user, tenant) { return Boolean(await db(pivot).where({ [userKey]: user.id, [foreignKey]: tenant.id }).first()); },
126
+ getKey: (tenant) => String(tenant.id),
127
+ getName: (tenant) => String(tenant[nameAttribute] ?? ''),
128
+ getCurrentTenantLabel: () => options.label ?? null,
129
+ async create(data, user) {
130
+ const [id] = await db(table).insert({ ...data, created_at: now(), updated_at: now() });
131
+ const tenantId = typeof id === 'object' ? id.id : id;
132
+ await db(pivot).insert({ [foreignKey]: tenantId, [userKey]: user.id, created_at: now(), updated_at: now() });
133
+ return db(table).where('id', tenantId).first();
134
+ },
135
+ async update(tenant, data) {
136
+ await db(table).where('id', tenant.id).update({ ...data, updated_at: now() });
137
+ return db(table).where('id', tenant.id).first();
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,82 @@
1
+ /** A calendar event. `start` and `end` are `YYYY-MM-DD` dates or ISO date-times (`2026-11-12T09:30`); `end` is inclusive. */
2
+ export type CalendarEvent = {
3
+ id: string | number;
4
+ title: string;
5
+ start: string;
6
+ end?: string | null;
7
+ /** Panel color (`primary`, `success`, `warning`, `danger`, `info`, `gray`, or a registered color). */
8
+ color?: string | null;
9
+ /** URL opened on click. Without one, a click calls the widget's `onEventClick()` if it is overridden. */
10
+ url?: string | null;
11
+ openUrlInNewTab?: boolean;
12
+ /** Tooltip text; defaults to the title. */
13
+ description?: string | null;
14
+ /** Free-form data for `renderEvent()` and `onEventClick()`. It is never sent to the browser. */
15
+ extra?: Record<string, unknown>;
16
+ };
17
+ export type CalendarView = 'month' | 'week' | 'list';
18
+ export declare const CALENDAR_VIEWS: CalendarView[];
19
+ export declare const isDate: (value: unknown) => value is string;
20
+ export declare const addDays: (day: string, count: number) => string;
21
+ export declare function addMonths(day: string, count: number): string;
22
+ /** First day of the week containing `day`; `firstDay`: 0 = Sunday, 1 = Monday… */
23
+ export declare const startOfWeek: (day: string, firstDay?: number) => string;
24
+ export declare const startOfMonth: (day: string) => string;
25
+ export declare const endOfMonth: (day: string) => string;
26
+ /** Day (`YYYY-MM-DD`) of an event date or date-time; `null` if the value is unreadable. */
27
+ export declare const dayOf: (value: unknown) => string | null;
28
+ /** `HH:MM` time of an event date-time; `null` for a plain date. */
29
+ export declare const timeOf: (value: unknown) => string | null;
30
+ /** Days shown by a view around the anchor date: the month padded to whole weeks, the week, or the month (list). */
31
+ export declare function visibleRange(view: CalendarView, anchor: string, firstDay?: number): {
32
+ start: string;
33
+ end: string;
34
+ };
35
+ export declare function daysBetween(start: string, end: string): string[];
36
+ /** Events on a day: those that started earlier first, then by time and title. */
37
+ export declare function eventsOn(day: string, events: CalendarEvent[]): CalendarEvent[];
38
+ export type CalendarLabels = {
39
+ previous: string;
40
+ next: string;
41
+ today: string;
42
+ month: string;
43
+ week: string;
44
+ list: string;
45
+ more: (count: number) => string;
46
+ empty: string;
47
+ };
48
+ export declare const CALENDAR_LABELS: CalendarLabels;
49
+ export type CalendarModel = {
50
+ heading: string | null;
51
+ view: CalendarView;
52
+ views: CalendarView[];
53
+ /** Anchor date and today's date, `YYYY-MM-DD`. */
54
+ anchor: string;
55
+ today: string;
56
+ firstDay: number;
57
+ locale: string;
58
+ events: CalendarEvent[];
59
+ maxEventsPerDay: number;
60
+ /** Day numbers are buttons (`selectDate`). */
61
+ datesClickable: boolean;
62
+ /** Events without a URL are buttons (`selectEvent`). */
63
+ eventsClickable: boolean;
64
+ labels: CalendarLabels;
65
+ /** Overrides an event's rendering: return already-escaped HTML, or `null` to keep the default. */
66
+ renderEvent?: (event: CalendarEvent, context: {
67
+ day: string;
68
+ view: CalendarView;
69
+ }) => string | null;
70
+ /** Already-escaped HTML appended to the bottom of a day cell (total, badge…). */
71
+ renderDayFooter?: (day: string, events: CalendarEvent[]) => string;
72
+ /** Already-escaped HTML placed at the end of the toolbar (action buttons…). */
73
+ toolbarEnd?: string;
74
+ };
75
+ /** Default event rendering: a link, a button or a plain block, depending on what a click can do. */
76
+ export declare function renderCalendarEvent(event: CalendarEvent, context: {
77
+ day: string;
78
+ view: CalendarView;
79
+ clickable: boolean;
80
+ }): string;
81
+ /** Renders the whole calendar: toolbar, then the requested view. */
82
+ export declare function renderCalendar(model: CalendarModel): string;
@@ -0,0 +1,107 @@
1
+ // Calendar: date helpers, grid and rendering, with no runtime dependency.
2
+ // `CalendarWidget` (index.ts) builds on this module; every part of the rendering can be replaced.
3
+ import { Attributes, e, icon } from '@navette-js/support';
4
+ export const CALENDAR_VIEWS = ['month', 'week', 'list'];
5
+ const DAY = 86400000;
6
+ export const isDate = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) && toDate(value).toISOString().slice(0, 10) === value;
7
+ function toDate(day) { return new Date(`${day}T00:00:00Z`); }
8
+ const iso = (date) => date.toISOString().slice(0, 10);
9
+ export const addDays = (day, count) => iso(new Date(toDate(day).getTime() + count * DAY));
10
+ export function addMonths(day, count) {
11
+ const date = toDate(day);
12
+ const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + count, 1));
13
+ const last = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
14
+ target.setUTCDate(Math.min(date.getUTCDate(), last));
15
+ return iso(target);
16
+ }
17
+ /** First day of the week containing `day`; `firstDay`: 0 = Sunday, 1 = Monday… */
18
+ export const startOfWeek = (day, firstDay = 1) => addDays(day, -((toDate(day).getUTCDay() - firstDay + 7) % 7));
19
+ export const startOfMonth = (day) => `${day.slice(0, 8)}01`;
20
+ export const endOfMonth = (day) => { const date = toDate(day); return iso(new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0))); };
21
+ /** Day (`YYYY-MM-DD`) of an event date or date-time; `null` if the value is unreadable. */
22
+ export const dayOf = (value) => (typeof value === 'string' && isDate(value.slice(0, 10)) ? value.slice(0, 10) : null);
23
+ /** `HH:MM` time of an event date-time; `null` for a plain date. */
24
+ export const timeOf = (value) => (typeof value === 'string' ? (/^\d{4}-\d{2}-\d{2}[T ](\d{2}:\d{2})/.exec(value)?.[1] ?? null) : null);
25
+ /** Days shown by a view around the anchor date: the month padded to whole weeks, the week, or the month (list). */
26
+ export function visibleRange(view, anchor, firstDay = 1) {
27
+ if (view === 'week') {
28
+ const start = startOfWeek(anchor, firstDay);
29
+ return { start, end: addDays(start, 6) };
30
+ }
31
+ if (view === 'list')
32
+ return { start: startOfMonth(anchor), end: endOfMonth(anchor) };
33
+ const start = startOfWeek(startOfMonth(anchor), firstDay);
34
+ const end = addDays(startOfWeek(endOfMonth(anchor), firstDay), 6);
35
+ return { start, end };
36
+ }
37
+ export function daysBetween(start, end) {
38
+ const days = [];
39
+ for (let day = start; day <= end && days.length < 400; day = addDays(day, 1))
40
+ days.push(day);
41
+ return days;
42
+ }
43
+ /** Events on a day: those that started earlier first, then by time and title. */
44
+ export function eventsOn(day, events) {
45
+ return events
46
+ .filter((event) => { const start = dayOf(event.start); const end = dayOf(event.end ?? event.start) ?? start; return start !== null && start <= day && day <= (end < start ? start : end); })
47
+ .sort((a, b) => (dayOf(a.start) < dayOf(b.start) ? -1 : dayOf(a.start) > dayOf(b.start) ? 1 : (timeOf(a.start) ?? '').localeCompare(timeOf(b.start) ?? '') || a.title.localeCompare(b.title)));
48
+ }
49
+ export const CALENDAR_LABELS = { previous: 'Previous', next: 'Next', today: 'Today', month: 'Month', week: 'Week', list: 'List', more: (count) => `+${count} more`, empty: 'No events.' };
50
+ const COLOR = /^[a-z][a-z0-9-]*$/;
51
+ /** Only a same-site path (`/…`) or an http(s) URL becomes a link (no `javascript:` or protocol-relative URLs). */
52
+ const safeUrl = (url) => (typeof url === 'string' && /^(\/(?!\/)|https?:\/\/)/.test(url) ? url : null);
53
+ const call = (method, argument) => `${method}(${JSON.stringify(String(argument)).replace(/"/g, "'")})`;
54
+ /** Default event rendering: a link, a button or a plain block, depending on what a click can do. */
55
+ export function renderCalendarEvent(event, context) {
56
+ const start = dayOf(event.start);
57
+ const end = dayOf(event.end ?? event.start) ?? start;
58
+ const color = event.color && COLOR.test(event.color) ? event.color : 'primary';
59
+ const url = safeUrl(event.url);
60
+ const time = context.day === start ? timeOf(event.start) : null;
61
+ const attributes = new Attributes({
62
+ title: event.description ?? event.title, 'data-event': String(event.id),
63
+ ...(url ? { href: url, ...(event.openUrlInNewTab ? { target: '_blank', rel: 'noopener noreferrer' } : url.startsWith('/') ? { 'wire:navigate': '' } : {}) } : context.clickable ? { type: 'button', 'wire:click': call('selectEvent', event.id) } : {}),
64
+ }).class(['nv-cal-event', 'fi-color', `fi-color-${color}`, context.day > start ? 'nv-cal-event-continued' : null, context.day < end ? 'nv-cal-event-continues' : null]);
65
+ const tag = url ? 'a' : context.clickable ? 'button' : 'div';
66
+ return `<${tag} ${attributes.toHtml()}>${time ? `<time class="nv-cal-event-time">${e(time)}</time>` : ''}<span class="nv-cal-event-title">${e(event.title)}</span></${tag}>`;
67
+ }
68
+ /** Renders the whole calendar: toolbar, then the requested view. */
69
+ export function renderCalendar(model) {
70
+ const { view, anchor, today, firstDay, locale, labels } = model;
71
+ const range = visibleRange(view, anchor, firstDay);
72
+ const days = daysBetween(range.start, range.end);
73
+ const format = (options, day) => new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }).format(toDate(day));
74
+ const title = view === 'week'
75
+ ? `${format({ month: 'short', day: 'numeric' }, range.start)} – ${format({ month: 'short', day: 'numeric', year: 'numeric' }, range.end)}`
76
+ : format({ month: 'long', year: 'numeric' }, anchor);
77
+ const eventHtml = (event, day) => model.renderEvent?.(event, { day, view }) ?? renderCalendarEvent(event, { day, view, clickable: model.eventsClickable });
78
+ const dayNumber = (day, text) => (model.datesClickable
79
+ ? `<button type="button" wire:click="${call('selectDate', day)}" class="nv-cal-day-number">${e(text)}</button>`
80
+ : `<span class="nv-cal-day-number">${e(text)}</span>`);
81
+ const iconButton = (name, label, method) => `<button type="button" wire:click="${method}" title="${e(label)}" aria-label="${e(label)}" class="fi-icon-btn fi-size-md nv-cal-nav">${icon(name)}</button>`;
82
+ const switcher = model.views.length > 1
83
+ ? `<div class="nv-cal-views" role="group">${model.views.map((name) => `<button type="button" wire:click="${call('setView', name)}" aria-pressed="${name === view}" class="nv-cal-view${name === view ? ' nv-cal-view-active' : ''}">${e(labels[name])}</button>`).join('')}</div>`
84
+ : '';
85
+ const toolbar = `<div class="nv-cal-toolbar"><div class="nv-cal-nav-group">${iconButton('m-chevron-left', labels.previous, 'previousPeriod')}<button type="button" wire:click="goToToday" class="nv-cal-today-btn">${e(labels.today)}</button>${iconButton('m-chevron-right', labels.next, 'nextPeriod')}</div><h3 class="nv-cal-title" aria-live="polite">${e(model.heading ? `${model.heading} — ${title}` : title)}</h3><div class="nv-cal-toolbar-end">${switcher}${model.toolbarEnd ?? ''}</div></div>`;
86
+ let body;
87
+ if (view === 'list') {
88
+ const filled = days.map((day) => [day, eventsOn(day, model.events)]).filter(([, events]) => events.length);
89
+ body = filled.length
90
+ ? `<div class="nv-cal-list">${filled.map(([day, events]) => `<section class="nv-cal-list-day${day === today ? ' nv-cal-today' : ''}" data-date="${day}"><h4 class="nv-cal-list-date">${dayNumber(day, format({ weekday: 'long', month: 'long', day: 'numeric' }, day))}</h4><div class="nv-cal-events">${events.map((event) => eventHtml(event, day)).join('')}</div>${model.renderDayFooter?.(day, events) ?? ''}</section>`).join('')}</div>`
91
+ : `<p class="nv-cal-empty">${e(labels.empty)}</p>`;
92
+ }
93
+ else {
94
+ const month = anchor.slice(0, 7);
95
+ const head = days.slice(0, 7).map((day) => `<div role="columnheader" class="nv-cal-weekday">${e(format({ weekday: 'short' }, day))}</div>`).join('');
96
+ const cells = days.map((day) => {
97
+ const events = eventsOn(day, model.events);
98
+ const limit = view === 'month' ? Math.max(1, model.maxEventsPerDay) : events.length;
99
+ const shown = events.length > limit ? events.slice(0, limit - 1) : events;
100
+ const more = events.length - shown.length;
101
+ const label = view === 'week' ? format({ day: 'numeric' }, day) : String(Number(day.slice(8)));
102
+ return `<div role="gridcell" data-date="${day}" aria-label="${e(format({ dateStyle: 'full' }, day))}" class="nv-cal-day${view === 'month' && !day.startsWith(month) ? ' nv-cal-outside' : ''}${day === today ? ' nv-cal-today' : ''}"><div class="nv-cal-day-head">${dayNumber(day, label)}</div><div class="nv-cal-events">${shown.map((event) => eventHtml(event, day)).join('')}${more > 0 ? `<button type="button" wire:click="${call('showDay', day)}" class="nv-cal-more">${e(labels.more(more))}</button>` : ''}</div>${model.renderDayFooter?.(day, events) ?? ''}</div>`;
103
+ }).join('');
104
+ body = `<div role="grid" class="nv-cal-grid nv-cal-grid-${view}"><div role="row" class="nv-cal-weekdays">${head}</div><div class="nv-cal-days">${cells}</div></div>`;
105
+ }
106
+ return `<div class="nv-cal nv-cal-view-${view}" data-date="${anchor}" wire:loading.class="nv-cal-loading">${toolbar}${body}</div>`;
107
+ }