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

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 +142 -0
  55. package/lib/calendar.d.ts +82 -0
  56. package/lib/calendar.js +107 -0
  57. package/lib/index.d.ts +1619 -0
  58. package/lib/index.js +3487 -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 +241 -0
  63. package/package.json +59 -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
+ /** Empreinte d'un mot de passe (scrypt, sel aléatoire) : `$scrypt$N$r$p$sel$empreinte`. */
3
+ export declare function hashPassword(password: string): Promise<string>;
4
+ /** Compare un mot de passe à une empreinte produite par `hashPassword` (en temps constant). Toute autre empreinte est refusée. */
5
+ export declare function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean>;
6
+ export declare function verifyAgainstDummy(password: string): Promise<void>;
7
+ /** Jeton signé à durée de vie limitée : `charge.signature`. `purpose` empêche de réutiliser un jeton pour un autre usage. */
8
+ export declare function signToken(key: string, purpose: string, payload: Record<string, unknown>, ttlSeconds: number): string;
9
+ /** Charge d'un jeton valide et non expiré, sinon `null`. */
10
+ export declare function readToken(key: string, purpose: string, token: unknown): Record<string, unknown> | null;
11
+ /** Empreinte courte d'une valeur secrète (mot de passe enregistré) : un jeton qui la contient meurt quand elle change. */
12
+ export declare const fingerprint: (value: unknown) => string;
13
+ /** Signature des adresses de vérification d'e-mail (comme `URL::temporarySignedRoute` de Laravel). */
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
+ /** Envoi d'un e-mail : branchez votre service (SMTP, API…). Le texte est fourni prêt à l'emploi, en lignes. */
30
+ export interface Mailer {
31
+ send(message: MailMessage): void | Promise<void>;
32
+ }
33
+ /** Rendu texte d'un message (comme les notifications « mail » de Laravel). */
34
+ export declare const mailText: (message: MailMessage) => string;
35
+ /** Par défaut : le message est écrit dans la console (développement). */
36
+ export declare const consoleMailer: Mailer;
37
+ /** Écrit chaque message sur une ligne JSON d'un fichier (tests, démonstration). */
38
+ export declare const fileMailer: (file: string) => Mailer;
39
+ /** Accès à une table : `(table) => knex(table)`. */
40
+ export type TableQuery = (table: string) => any;
41
+ /**
42
+ * Utilisateurs dans une table au format de Laravel (`users` : id, name, email, password, email_verified_at).
43
+ * Les mots de passe sont hachés par `hashPassword` (scrypt). Pour accepter d'autres empreintes (bcrypt de Laravel…),
44
+ * fournissez `verify(motDePasse, empreinte)` : il est consulté pour toute empreinte qui n'est pas au format scrypt.
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
+ * Locataires (équipes) dans une table, liés aux utilisateurs par une table pivot — comme `teams` et `team_user` de la démo Filament.
52
+ * `label` : texte au-dessus du nom dans le menu (« Current team »), comme `HasCurrentTenantLabel`.
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,142 @@
1
+ // Authentification complète : hachage des mots de passe, jetons signés (réinitialisation, vérification de l'adresse),
2
+ // envoi des e-mails, et fournisseurs prêts à l'emploi sur une base knex (utilisateurs, locataires).
3
+ import { appendFileSync } from 'node:fs';
4
+ import { createHash, createHmac, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
5
+ // ------------------------------------------------------------------ mots de passe
6
+ const SCRYPT_COST = { N: 16384, r: 8, p: 1 };
7
+ 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))));
8
+ /** Empreinte d'un mot de passe (scrypt, sel aléatoire) : `$scrypt$N$r$p$sel$empreinte`. */
9
+ export async function hashPassword(password) {
10
+ const salt = randomBytes(16);
11
+ const hash = await scrypt(password, salt, 32, SCRYPT_COST);
12
+ return `$scrypt$${SCRYPT_COST.N}$${SCRYPT_COST.r}$${SCRYPT_COST.p}$${salt.toString('base64url')}$${hash.toString('base64url')}`;
13
+ }
14
+ /** Compare un mot de passe à une empreinte produite par `hashPassword` (en temps constant). Toute autre empreinte est refusée. */
15
+ export async function verifyPassword(password, stored) {
16
+ const parts = String(stored ?? '').split('$');
17
+ if (parts.length !== 7 || parts[1] !== 'scrypt')
18
+ return false;
19
+ const [N, r, p] = parts.slice(2, 5).map(Number);
20
+ // Paramètres bornés : une empreinte forgée ne peut pas faire travailler le serveur indéfiniment.
21
+ if (![N, r, p].every(Number.isInteger) || N < 2 || N > 2 ** 20 || r < 1 || r > 32 || p < 1 || p > 16)
22
+ return false;
23
+ const expected = Buffer.from(parts[6], 'base64url');
24
+ if (expected.length < 16 || expected.length > 64)
25
+ return false;
26
+ const actual = await scrypt(password, Buffer.from(parts[5], 'base64url'), expected.length, { N, r, p });
27
+ return timingSafeEqual(expected, actual);
28
+ }
29
+ /** Empreinte de référence : vérifiée quand l'adresse est inconnue, pour que la réponse prenne le même temps. */
30
+ let dummyHash = null;
31
+ export async function verifyAgainstDummy(password) { await verifyPassword(password, await (dummyHash ??= hashPassword(randomBytes(16).toString('hex')))); }
32
+ // ------------------------------------------------------------------ jetons signés
33
+ const sign = (key, value) => createHmac('sha256', key).update(value).digest('base64url');
34
+ const sameText = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
35
+ /** Jeton signé à durée de vie limitée : `charge.signature`. `purpose` empêche de réutiliser un jeton pour un autre usage. */
36
+ export function signToken(key, purpose, payload, ttlSeconds) {
37
+ const body = Buffer.from(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds })).toString('base64url');
38
+ return `${body}.${sign(key, `${purpose}.${body}`)}`;
39
+ }
40
+ /** Charge d'un jeton valide et non expiré, sinon `null`. */
41
+ export function readToken(key, purpose, token) {
42
+ if (typeof token !== 'string' || token.length > 2000)
43
+ return null;
44
+ const [body, mac] = token.split('.');
45
+ if (!body || !mac || !sameText(mac, sign(key, `${purpose}.${body}`)))
46
+ return null;
47
+ try {
48
+ const payload = JSON.parse(Buffer.from(body, 'base64url').toString());
49
+ return typeof payload === 'object' && payload && Number(payload.exp) > Date.now() / 1000 ? payload : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** Empreinte courte d'une valeur secrète (mot de passe enregistré) : un jeton qui la contient meurt quand elle change. */
56
+ export const fingerprint = (value) => createHash('sha256').update(String(value ?? '')).digest('base64url').slice(0, 22);
57
+ /** Signature des adresses de vérification d'e-mail (comme `URL::temporarySignedRoute` de Laravel). */
58
+ export function signVerificationUrl(key, id, email, expires) {
59
+ const hash = createHash('sha1').update(email).digest('hex');
60
+ return { hash, signature: sign(key, `verify-email.${id}.${hash}.${expires}`) };
61
+ }
62
+ export function checkVerificationUrl(key, id, hash, expires, signature) {
63
+ const at = Number(expires);
64
+ return Number.isFinite(at) && at > Date.now() / 1000 && typeof signature === 'string' && sameText(signature, sign(key, `verify-email.${id}.${hash}.${at}`));
65
+ }
66
+ /** Rendu texte d'un message (comme les notifications « mail » de Laravel). */
67
+ export const mailText = (message) => [...message.lines, ...(message.action ? ['', `${message.action.label} : ${message.action.url}`, ''] : []), ...(message.outro ?? [])].join('\n');
68
+ /** Par défaut : le message est écrit dans la console (développement). */
69
+ export const consoleMailer = { send(message) { console.log(`[navette:mail] À ${message.to} — ${message.subject}\n${mailText(message)}`); } };
70
+ /** Écrit chaque message sur une ligne JSON d'un fichier (tests, démonstration). */
71
+ export const fileMailer = (file) => ({ send(message) { appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...message }) + '\n'); } });
72
+ const now = () => new Date().toISOString().slice(0, 19).replace('T', ' ');
73
+ /**
74
+ * Utilisateurs dans une table au format de Laravel (`users` : id, name, email, password, email_verified_at).
75
+ * Les mots de passe sont hachés par `hashPassword` (scrypt). Pour accepter d'autres empreintes (bcrypt de Laravel…),
76
+ * fournissez `verify(motDePasse, empreinte)` : il est consulté pour toute empreinte qui n'est pas au format scrypt.
77
+ */
78
+ export function knexAuthProvider(db, options = {}) {
79
+ const table = options.table ?? 'users';
80
+ const toUser = (row) => (row ? { id: row.id, name: row.name, email: row.email, emailVerifiedAt: row.email_verified_at ?? null } : null);
81
+ const byEmail = (email) => db(table).where('email', String(email)).first();
82
+ return {
83
+ async attempt(email, password) {
84
+ const row = await byEmail(email);
85
+ if (!row) {
86
+ await verifyAgainstDummy(password);
87
+ return null;
88
+ }
89
+ const stored = String(row.password ?? '');
90
+ const ok = stored.startsWith('$scrypt$') ? await verifyPassword(password, stored) : Boolean(await options.verify?.(password, stored));
91
+ return ok ? toUser(row) : null;
92
+ },
93
+ async find(id) { return toUser(await db(table).where('id', id).first()); },
94
+ async findByEmail(email) { return toUser(await byEmail(email)); },
95
+ async create(data) {
96
+ await db(table).insert({ name: data.name, email: data.email, password: await hashPassword(data.password), created_at: now(), updated_at: now() });
97
+ return toUser(await byEmail(data.email));
98
+ },
99
+ async update(user, data) {
100
+ const changes = { updated_at: now() };
101
+ if (typeof data.name === 'string')
102
+ changes.name = data.name;
103
+ if (typeof data.email === 'string' && data.email !== user.email) {
104
+ changes.email = data.email;
105
+ changes.email_verified_at = null;
106
+ }
107
+ if (typeof data.password === 'string' && data.password !== '')
108
+ changes.password = await hashPassword(data.password);
109
+ await db(table).where('id', user.id).update(changes);
110
+ return toUser(await db(table).where('id', user.id).first());
111
+ },
112
+ async hasVerifiedEmail(user) { return Boolean((await db(table).where('id', user.id).first())?.email_verified_at); },
113
+ async markEmailAsVerified(user) { await db(table).where('id', user.id).update({ email_verified_at: now(), updated_at: now() }); },
114
+ async getPasswordFingerprint(user) { return fingerprint((await db(table).where('id', user.id).first())?.password); },
115
+ };
116
+ }
117
+ /**
118
+ * Locataires (équipes) dans une table, liés aux utilisateurs par une table pivot — comme `teams` et `team_user` de la démo Filament.
119
+ * `label` : texte au-dessus du nom dans le menu (« Current team »), comme `HasCurrentTenantLabel`.
120
+ */
121
+ export function knexTenantProvider(db, options = {}) {
122
+ const table = options.table ?? 'teams', pivot = options.pivot ?? 'team_user';
123
+ const foreignKey = options.foreignKey ?? 'team_id', userKey = options.userKey ?? 'user_id', nameAttribute = options.nameAttribute ?? 'name';
124
+ return {
125
+ async getTenants(user) { return db(table).join(pivot, `${pivot}.${foreignKey}`, `${table}.id`).where(`${pivot}.${userKey}`, user.id).select(`${table}.*`).orderBy(`${table}.id`); },
126
+ async find(key) { return /^\d+$/.test(String(key)) ? (await db(table).where('id', Number(key)).first()) ?? null : null; },
127
+ async canAccess(user, tenant) { return Boolean(await db(pivot).where({ [userKey]: user.id, [foreignKey]: tenant.id }).first()); },
128
+ getKey: (tenant) => String(tenant.id),
129
+ getName: (tenant) => String(tenant[nameAttribute] ?? ''),
130
+ getCurrentTenantLabel: () => options.label ?? null,
131
+ async create(data, user) {
132
+ const [id] = await db(table).insert({ ...data, created_at: now(), updated_at: now() });
133
+ const tenantId = typeof id === 'object' ? id.id : id;
134
+ await db(pivot).insert({ [foreignKey]: tenantId, [userKey]: user.id, created_at: now(), updated_at: now() });
135
+ return db(table).where('id', tenantId).first();
136
+ },
137
+ async update(tenant, data) {
138
+ await db(table).where('id', tenant.id).update({ ...data, updated_at: now() });
139
+ return db(table).where('id', tenant.id).first();
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,82 @@
1
+ /** Un événement. `start` et `end` sont des dates `YYYY-MM-DD` ou des dates-heures ISO (`2026-11-12T09:30`) ; `end` est inclus. */
2
+ export type CalendarEvent = {
3
+ id: string | number;
4
+ title: string;
5
+ start: string;
6
+ end?: string | null;
7
+ /** Couleur du panel (`primary`, `success`, `warning`, `danger`, `info`, `gray`, ou une couleur déclarée). */
8
+ color?: string | null;
9
+ /** Adresse ouverte au clic. Sans adresse, le clic appelle `onEventClick()` du widget s'il est redéfini. */
10
+ url?: string | null;
11
+ openUrlInNewTab?: boolean;
12
+ /** Texte de l'infobulle ; par défaut, le titre. */
13
+ description?: string | null;
14
+ /** Données libres, à disposition de `renderEvent()` et de `onEventClick()`. Elles ne sont pas envoyées au navigateur. */
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
+ /** Premier jour de la semaine contenant `day` ; `firstDay` : 0 = dimanche, 1 = lundi… */
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
+ /** Jour (`YYYY-MM-DD`) d'une date ou d'une date-heure d'événement ; nul si la valeur est illisible. */
27
+ export declare const dayOf: (value: unknown) => string | null;
28
+ /** Heure `HH:MM` d'une date-heure d'événement, nulle pour une date seule. */
29
+ export declare const timeOf: (value: unknown) => string | null;
30
+ /** Jours affichés par une vue autour de la date d'ancrage : le mois complété en semaines entières, la semaine, ou le mois (liste). */
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
+ /** Événements d'un jour, ceux qui ont commencé plus tôt d'abord, puis par heure et par titre. */
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
+ /** Date d'ancrage et date du jour, `YYYY-MM-DD`. */
54
+ anchor: string;
55
+ today: string;
56
+ firstDay: number;
57
+ locale: string;
58
+ events: CalendarEvent[];
59
+ maxEventsPerDay: number;
60
+ /** Les numéros de jour sont des boutons (`selectDate`). */
61
+ datesClickable: boolean;
62
+ /** Les événements sans adresse sont des boutons (`selectEvent`). */
63
+ eventsClickable: boolean;
64
+ labels: CalendarLabels;
65
+ /** Remplace le rendu d'un événement : rendre du HTML déjà échappé, ou `null` pour garder le rendu par défaut. */
66
+ renderEvent?: (event: CalendarEvent, context: {
67
+ day: string;
68
+ view: CalendarView;
69
+ }) => string | null;
70
+ /** HTML ajouté au bas de la case d'un jour (total, pastille…), déjà échappé. */
71
+ renderDayFooter?: (day: string, events: CalendarEvent[]) => string;
72
+ /** HTML placé à droite de la barre d'outils (boutons d'action…), déjà échappé. */
73
+ toolbarEnd?: string;
74
+ };
75
+ /** Rendu par défaut d'un événement : un lien, un bouton ou un simple bloc, selon ce qu'un clic peut faire. */
76
+ export declare function renderCalendarEvent(event: CalendarEvent, context: {
77
+ day: string;
78
+ view: CalendarView;
79
+ clickable: boolean;
80
+ }): string;
81
+ /** Rend le calendrier complet : barre d'outils, puis la vue demandée. */
82
+ export declare function renderCalendar(model: CalendarModel): string;
@@ -0,0 +1,107 @@
1
+ // Calendrier — composant propre à Navette (Filament n'en fournit pas) : dates, grille et rendu, sans dépendance au runtime.
2
+ // Le widget `CalendarWidget` (index.ts) s'appuie sur ce module ; chaque morceau du rendu peut être remplacé.
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
+ /** Premier jour de la semaine contenant `day` ; `firstDay` : 0 = dimanche, 1 = lundi… */
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
+ /** Jour (`YYYY-MM-DD`) d'une date ou d'une date-heure d'événement ; nul si la valeur est illisible. */
22
+ export const dayOf = (value) => (typeof value === 'string' && isDate(value.slice(0, 10)) ? value.slice(0, 10) : null);
23
+ /** Heure `HH:MM` d'une date-heure d'événement, nulle pour une date seule. */
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
+ /** Jours affichés par une vue autour de la date d'ancrage : le mois complété en semaines entières, la semaine, ou le mois (liste). */
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
+ /** Événements d'un jour, ceux qui ont commencé plus tôt d'abord, puis par heure et par titre. */
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
+ /** Seules une adresse du site (`/…`) ou une adresse http(s) deviennent un lien. */
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
+ /** Rendu par défaut d'un événement : un lien, un bouton ou un simple bloc, selon ce qu'un clic peut faire. */
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
+ /** Rend le calendrier complet : barre d'outils, puis la vue demandée. */
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
+ }