silex_ui 0.2.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/LICENSE.txt +6 -0
  4. data/README.md +97 -7
  5. data/app/assets/javascripts/silex_ui/controllers/clipboard_controller.js +10 -10
  6. data/app/assets/javascripts/silex_ui/controllers/dropdown_controller.js +24 -0
  7. data/app/assets/javascripts/silex_ui/controllers/inline_edit_controller.js +39 -0
  8. data/app/assets/javascripts/silex_ui/controllers/remote_modal_controller.js +22 -0
  9. data/app/assets/javascripts/silex_ui/controllers/sidebar_controller.js +34 -0
  10. data/app/assets/javascripts/silex_ui/controllers/theme_controller.js +43 -0
  11. data/app/assets/javascripts/silex_ui/controllers/tooltip_controller.js +65 -0
  12. data/app/assets/javascripts/silex_ui/index.js +22 -1
  13. data/app/assets/stylesheets/silex_ui/theme.css +528 -42
  14. data/app/components/silex_ui/action_list_component.rb +76 -0
  15. data/app/components/silex_ui/alert_component.html.erb +14 -6
  16. data/app/components/silex_ui/alert_component.rb +24 -12
  17. data/app/components/silex_ui/avatar_component.rb +56 -0
  18. data/app/components/silex_ui/badge_component.rb +21 -17
  19. data/app/components/silex_ui/base_component.rb +10 -3
  20. data/app/components/silex_ui/breakdown_component.html.erb +20 -6
  21. data/app/components/silex_ui/breakdown_component.rb +21 -4
  22. data/app/components/silex_ui/button_component.rb +31 -16
  23. data/app/components/silex_ui/card_component.html.erb +7 -4
  24. data/app/components/silex_ui/card_component.rb +9 -6
  25. data/app/components/silex_ui/copy_button_component.rb +17 -8
  26. data/app/components/silex_ui/dropdown_component.html.erb +13 -0
  27. data/app/components/silex_ui/dropdown_component.rb +82 -0
  28. data/app/components/silex_ui/empty_state_component.html.erb +13 -0
  29. data/app/components/silex_ui/empty_state_component.rb +6 -9
  30. data/app/components/silex_ui/field_component.html.erb +4 -4
  31. data/app/components/silex_ui/field_component.rb +8 -5
  32. data/app/components/silex_ui/filter_bar_component.html.erb +22 -0
  33. data/app/components/silex_ui/filter_bar_component.rb +54 -0
  34. data/app/components/silex_ui/filter_field_component.html.erb +10 -0
  35. data/app/components/silex_ui/filter_field_component.rb +69 -0
  36. data/app/components/silex_ui/flash_component.html.erb +1 -1
  37. data/app/components/silex_ui/icon_component.rb +211 -0
  38. data/app/components/silex_ui/inline_edit_component.html.erb +23 -0
  39. data/app/components/silex_ui/inline_edit_component.rb +47 -0
  40. data/app/components/silex_ui/modal_component.html.erb +4 -2
  41. data/app/components/silex_ui/modal_component.rb +3 -2
  42. data/app/components/silex_ui/nav_component.html.erb +5 -4
  43. data/app/components/silex_ui/nav_component.rb +22 -7
  44. data/app/components/silex_ui/page_header_component.html.erb +38 -10
  45. data/app/components/silex_ui/page_header_component.rb +97 -11
  46. data/app/components/silex_ui/pagination_component.html.erb +11 -10
  47. data/app/components/silex_ui/pagination_component.rb +1 -2
  48. data/app/components/silex_ui/panel_component.html.erb +27 -0
  49. data/app/components/silex_ui/panel_component.rb +60 -0
  50. data/app/components/silex_ui/progress_component.html.erb +21 -0
  51. data/app/components/silex_ui/progress_component.rb +42 -0
  52. data/app/components/silex_ui/remote_modal_component.html.erb +30 -0
  53. data/app/components/silex_ui/remote_modal_component.rb +54 -0
  54. data/app/components/silex_ui/section_component.html.erb +5 -2
  55. data/app/components/silex_ui/section_component.rb +8 -8
  56. data/app/components/silex_ui/shell_component.html.erb +40 -0
  57. data/app/components/silex_ui/shell_component.rb +106 -0
  58. data/app/components/silex_ui/stat_card_component.html.erb +31 -6
  59. data/app/components/silex_ui/stat_card_component.rb +48 -28
  60. data/app/components/silex_ui/stat_grid_component.rb +8 -18
  61. data/app/components/silex_ui/table_component.html.erb +10 -8
  62. data/app/components/silex_ui/table_component.rb +52 -20
  63. data/app/components/silex_ui/theme_toggle_component.html.erb +11 -0
  64. data/app/components/silex_ui/theme_toggle_component.rb +29 -0
  65. data/app/components/silex_ui/tones.rb +72 -0
  66. data/app/views/silex_ui/catalogue/_specimen.html.erb +4 -4
  67. data/app/views/silex_ui/catalogue/show.html.erb +332 -187
  68. data/lib/silex_ui/version.rb +1 -1
  69. data/lib/silex_ui.rb +8 -0
  70. metadata +31 -2
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SilexUi
4
- # Barre de navigation : la marque, les sections, et ce qui concerne l'utilisateur.
4
+ # Barre de navigation : la marque, les sections, et ce qui concerne
5
+ # l'utilisateur. Blanche et posée, comme la navbar du Silex.
5
6
  #
6
7
  # <%= render SilexUi::NavComponent.new(brand: "Network", brand_href: root_path) do |nav| %>
7
- # <% nav.with_item(label: "Sites", href: websites_path, active: controller_name == "websites") %>
8
- # <% nav.with_aside do %><%= current_user.display_name %><% end %>
8
+ # <% nav.with_item(label: "Sites", href: websites_path, icon: "globe", active: true) %>
9
+ # <% nav.with_aside do %>…<% end %>
9
10
  # <% end %>
10
11
  class NavComponent < BaseComponent
11
12
  renders_many :items, "ItemComponent"
@@ -20,24 +21,38 @@ module SilexUi
20
21
  attr_reader :brand, :brand_href
21
22
 
22
23
  def css_classes
23
- merge_class("flex flex-wrap items-center justify-between gap-5 border-b border-sand-200 py-5")
24
+ merge_class("flex flex-wrap items-center justify-between gap-5 border-b border-surface-200",
25
+ "bg-surface-0 px-6 py-3 shadow-card")
24
26
  end
25
27
 
26
28
  class ItemComponent < ViewComponent::Base
27
- def initialize(label:, href:, active: false)
29
+ def initialize(label:, href:, icon: nil, active: false)
28
30
  @label = label
29
31
  @href = href
32
+ @icon = icon
30
33
  @active = active
31
34
  end
32
35
 
33
36
  def call
34
- link_to(@label, @href, class: css_classes)
37
+ link_to(body, @href, class: css_classes, "aria-current": ("page" if @active))
35
38
  end
36
39
 
37
40
  private
38
41
 
42
+ def body
43
+ parts = []
44
+ parts << render(SilexUi::IconComponent.new(name: @icon, size: :sm)) if @icon.present?
45
+ parts << @label
46
+ safe_join(parts)
47
+ end
48
+
49
+ # L'onglet actif porte la couleur de marque sur un fond dilué ; les autres
50
+ # attendent qu'on les survole.
39
51
  def css_classes
40
- class_names("transition", @active ? "text-ink-900" : "text-ink-400 hover:text-ink-900")
52
+ class_names(
53
+ "inline-flex items-center gap-2 rounded-badge px-3 py-2 text-sm font-semibold transition",
54
+ @active ? "bg-brand-500/10 text-brand-600" : "text-ink-500 hover:bg-surface-100 hover:text-ink-900"
55
+ )
41
56
  end
42
57
  end
43
58
  end
@@ -1,17 +1,45 @@
1
1
  <header class="<%= css_classes %>">
2
- <div class="min-w-0">
3
- <h1 class="text-lg font-light tracking-[0.2em] text-ink-900 uppercase"><%= title %></h1>
2
+ <span aria-hidden="true" class="pointer-events-none absolute inset-0" style="<%= halo_style %>"></span>
4
3
 
5
- <% if subtitle.present? %>
6
- <p class="mt-2 text-sm font-light text-ink-600"><%= subtitle %></p>
7
- <% end %>
4
+ <div class="relative flex flex-wrap items-center justify-between gap-6">
5
+ <div class="flex min-w-0 items-center gap-5">
6
+ <% if icon_name.present? %>
7
+ <span class="<%= icon_classes %>">
8
+ <%= render SilexUi::IconComponent.new(name: icon_name, size: :lg) %>
9
+ </span>
10
+ <% end %>
8
11
 
9
- <% if meta? %>
10
- <div class="mt-2 text-sm font-light text-ink-600"><%= meta %></div>
11
- <% end %>
12
+ <div class="min-w-0">
13
+ <h1 class="<%= title_classes %>"><%= title %></h1>
14
+
15
+ <% if subtitle.present? %>
16
+ <p class="<%= subtitle_classes %>"><%= subtitle %></p>
17
+ <% end %>
18
+
19
+ <% if meta? %>
20
+ <div class="<%= meta_classes %>"><%= meta %></div>
21
+ <% end %>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="flex flex-wrap items-center gap-3">
26
+ <%= actions %>
27
+
28
+ <% if aside? %>
29
+ <div class="rounded-surface bg-white/15 px-6 py-4 backdrop-blur"><%= aside %></div>
30
+ <% end %>
31
+ </div>
12
32
  </div>
13
33
 
14
- <% if actions? %>
15
- <div class="flex flex-wrap items-center gap-3"><%= actions %></div>
34
+ <% if crumbs.any? %>
35
+ <nav aria-label="Fil d'Ariane" class="<%= breadcrumb_classes %>">
36
+ <% crumbs.each_with_index do |crumb, index| %>
37
+ <%# Le point de séparation, plus discret que le segment qu'il précède. %>
38
+ <% if index.positive? %>
39
+ <span class="mx-2.5 font-bold text-ink-950/30" aria-hidden="true">·</span>
40
+ <% end %>
41
+ <%= crumb %>
42
+ <% end %>
43
+ </nav>
16
44
  <% end %>
17
45
  </header>
@@ -1,35 +1,121 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SilexUi
4
- # En-tête de page : titre, sous-titre, actions à droite.
4
+ # L'en-tête de page, dans les deux formes que le Silex lui donne.
5
5
  #
6
- # <%= render SilexUi::PageHeaderComponent.new(title: "Sites", subtitle: "1 240 domaines") do |header| %>
7
- # <% header.with_actions do %>
8
- # <%= render SilexUi::ButtonComponent.new(label: "Ajouter", href: new_website_path) %>
9
- # <% end %>
10
- # <% end %>
6
+ # `:hero` — celle des pages de liste : une bande pâle sur toute la largeur,
7
+ # deux halos radiaux pour la profondeur, le titre en gras foncé et le fil
8
+ # d'Ariane dessous.
9
+ # `:banner` — celle des tableaux de bord : une carte à dégradé violet plein,
10
+ # le titre en blanc. C'est la première chose que la page dit.
11
11
  #
12
- # Le titre alimente aussi `content_for :title`, comme le PageHeaderComponent du
13
- # Silex : une page qui affiche son nom le donne aussi à l'onglet.
12
+ # <%= render SilexUi::PageHeaderComponent.new(title: "PBNs", icon: "globe") do |header| %>
13
+ # <% header.with_crumb(label: "Accueil", href: root_path) %>
14
+ # <% header.with_crumb(label: "PBNs") %>
15
+ # <% header.with_actions do %>…<% end %>
16
+ # <% end %>
14
17
  class PageHeaderComponent < BaseComponent
15
18
  renders_one :actions
16
19
  renders_one :meta
20
+ # Le fil d'Ariane est une liste de segments : le composant pose les points
21
+ # de séparation et assombrit le dernier. L'appelant n'écrit pas de `·`.
22
+ renders_many :crumbs, "CrumbComponent"
23
+ # Le bloc de droite du Silex : une plaque translucide, pour une date ou un
24
+ # chiffre qui accompagne le titre sans le concurrencer. Réservé à `:banner`.
25
+ renders_one :aside
26
+
27
+ VARIANTS = %i[hero banner].freeze
17
28
 
18
- def initialize(title:, subtitle: nil, document_title: nil, **options)
29
+ def initialize(title:, subtitle: nil, icon: nil, variant: :hero, document_title: nil, **options)
19
30
  @title = title
20
31
  @subtitle = subtitle
32
+ @icon = icon
33
+ @variant = VARIANTS.include?(variant&.to_sym) ? variant.to_sym : :hero
21
34
  @document_title = document_title
22
35
  super(**options)
23
36
  end
24
37
 
25
- attr_reader :title, :subtitle
38
+ attr_reader :title, :subtitle, :icon_name, :variant
39
+
40
+ def icon_name = @icon
41
+
42
+ def banner? = @variant == :banner
26
43
 
27
44
  def before_render
28
45
  content_for(:title, @document_title || @title) if @document_title != false
29
46
  end
30
47
 
31
48
  def css_classes
32
- merge_class("flex flex-wrap items-end justify-between gap-4 py-8")
49
+ if banner?
50
+ merge_class("relative overflow-hidden rounded-hero bg-gradient-to-br from-brand-500 to-brand-700",
51
+ "px-10 py-9 text-white")
52
+ else
53
+ # Pleine largeur, sans rayon : le hero touche les bords de la page.
54
+ merge_class("relative overflow-hidden border-b border-surface-200 bg-gradient-to-br",
55
+ "from-surface-50 via-brand-50 to-pink-50 px-10 py-9")
56
+ end
57
+ end
58
+
59
+ # La pastille : 56 px, rayon 14, dégradé indigo → violet, et son ombre
60
+ # portée colorée. C'est elle qui donne au titre son poids.
61
+ def icon_classes
62
+ class_names(
63
+ "flex size-14 shrink-0 items-center justify-center rounded-[14px]",
64
+ if banner?
65
+ "bg-white/15 backdrop-blur"
66
+ else
67
+ "bg-gradient-to-br from-brand-500 to-purple-500 text-white " \
68
+ "shadow-[0_8px_20px_rgba(102,126,234,0.3)]"
69
+ end
70
+ )
71
+ end
72
+
73
+ def title_classes
74
+ class_names("text-4xl leading-[1.2] font-extrabold tracking-[-0.02em]",
75
+ banner? ? "text-white" : "text-ink-950")
76
+ end
77
+
78
+ def subtitle_classes
79
+ class_names("mt-1.5 text-lg", banner? ? "text-white/90" : "text-ink-500")
80
+ end
81
+
82
+ def meta_classes
83
+ class_names("mt-1.5 text-sm", banner? ? "text-white/80" : "text-ink-500")
84
+ end
85
+
86
+ def breadcrumb_classes
87
+ class_names("relative mt-3 flex flex-wrap items-center text-[11px] font-semibold",
88
+ "tracking-[0.14em] uppercase", banner? ? "text-white/60" : "text-ink-950/50")
89
+ end
90
+
91
+ # Deux dégradés radiaux sur un calque, comme le `::before` du Silex : un
92
+ # cercle net ferait une tache, un halo se sent sans se voir.
93
+ def halo_style
94
+ if banner?
95
+ "background: radial-gradient(circle at 15% 30%, rgba(255,255,255,0.12), transparent 35%), " \
96
+ "radial-gradient(circle at 85% 75%, rgba(255,255,255,0.08), transparent 35%)"
97
+ else
98
+ "background: radial-gradient(circle at 15% 30%, rgba(102,126,234,0.18), transparent 35%), " \
99
+ "radial-gradient(circle at 85% 75%, rgba(236,72,153,0.12), transparent 35%)"
100
+ end
101
+ end
102
+
103
+ # Un segment du fil d'Ariane. Le point qui le précède est posé par le
104
+ # composant, pas par l'appelant.
105
+ class CrumbComponent < ViewComponent::Base
106
+ def initialize(label:, href: nil, last: false)
107
+ @label = label
108
+ @href = href
109
+ @last = last
110
+ end
111
+
112
+ attr_writer :last
113
+
114
+ def call
115
+ return tag.span(@label, class: "text-ink-950/70") if @href.blank?
116
+
117
+ link_to(@label, @href, class: "text-brand-500 transition hover:text-indigo-600")
118
+ end
33
119
  end
34
120
  end
35
121
  end
@@ -3,28 +3,29 @@
3
3
 
4
4
  <div class="flex flex-wrap items-center gap-1">
5
5
  <% if pagination.previous_page %>
6
- <%= link_to "Précédent", url_for_page(pagination.previous_page), rel: "prev",
7
- class: "px-3 py-1 uppercase transition hover:text-ink-900" %>
8
- <% else %>
9
- <span class="px-3 py-1 text-ink-300 uppercase">Précédent</span>
6
+ <%= link_to url_for_page(pagination.previous_page), rel: "prev", "aria-label": "Page précédente",
7
+ class: "inline-flex size-8 items-center justify-center rounded-badge border border-surface-200 bg-surface-0 transition hover:border-brand-500 hover:text-brand-600" do %>
8
+ <%= render SilexUi::IconComponent.new(name: "chevron-right", size: :sm, class: "rotate-180") %>
9
+ <% end %>
10
10
  <% end %>
11
11
 
12
12
  <% pagination.series.each do |entry| %>
13
13
  <% if gap?(entry) %>
14
14
  <span class="px-2 text-ink-300">…</span>
15
15
  <% elsif current?(entry) %>
16
- <span aria-current="page" class="rounded-sm border border-ink-900 px-3 py-1 text-ink-900"><%= entry %></span>
16
+ <span aria-current="page"
17
+ class="inline-flex size-8 items-center justify-center rounded-badge bg-gradient-to-br from-brand-500 to-brand-700 font-semibold text-white"><%= entry %></span>
17
18
  <% else %>
18
19
  <%= link_to entry, url_for_page(entry),
19
- class: "rounded-sm border border-transparent px-3 py-1 transition hover:border-sand-300 hover:text-ink-900" %>
20
+ class: "inline-flex size-8 items-center justify-center rounded-badge border border-transparent transition hover:border-surface-200 hover:bg-surface-0 hover:text-brand-600" %>
20
21
  <% end %>
21
22
  <% end %>
22
23
 
23
24
  <% if pagination.next_page %>
24
- <%= link_to "Suivant", url_for_page(pagination.next_page), rel: "next",
25
- class: "px-3 py-1 uppercase transition hover:text-ink-900" %>
26
- <% else %>
27
- <span class="px-3 py-1 text-ink-300 uppercase">Suivant</span>
25
+ <%= link_to url_for_page(pagination.next_page), rel: "next", "aria-label": "Page suivante",
26
+ class: "inline-flex size-8 items-center justify-center rounded-badge border border-surface-200 bg-surface-0 transition hover:border-brand-500 hover:text-brand-600" do %>
27
+ <%= render SilexUi::IconComponent.new(name: "chevron-right", size: :sm) %>
28
+ <% end %>
28
29
  <% end %>
29
30
  </div>
30
31
  </nav>
@@ -39,8 +39,7 @@ module SilexUi
39
39
 
40
40
  def css_classes
41
41
  merge_class(
42
- "flex flex-wrap items-center justify-between gap-4 border-t border-sand-200 py-6",
43
- "text-xs font-light tracking-widest text-ink-400"
42
+ "flex flex-wrap items-center justify-between gap-4 py-5 text-[13px] text-ink-500"
44
43
  )
45
44
  end
46
45
  end
@@ -0,0 +1,27 @@
1
+ <section class="<%= css_classes %>">
2
+ <% if header? %>
3
+ <div class="mb-6 flex flex-wrap items-center justify-between gap-4 border-b border-surface-200 pb-4">
4
+ <% if title.present? %>
5
+ <h2 class="flex items-center gap-3 text-xl font-bold text-ink-900">
6
+ <% if icon_name.present? %>
7
+ <span class="<%= icon_classes %>">
8
+ <%= render SilexUi::IconComponent.new(name: icon_name, size: :md) %>
9
+ </span>
10
+ <% end %>
11
+ <%= title %>
12
+ </h2>
13
+ <% end %>
14
+
15
+ <div class="flex flex-wrap items-center gap-4">
16
+ <%= safe_join(actions) %>
17
+ <%= header %>
18
+ </div>
19
+ </div>
20
+ <% end %>
21
+
22
+ <%= content %>
23
+
24
+ <% if footer? %>
25
+ <div class="mt-6 border-t border-surface-200 pt-4 text-sm text-ink-500"><%= footer %></div>
26
+ <% end %>
27
+ </section>
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Le panneau blanc du Silex — sa `.content-section`. C'est lui qui structure
5
+ # les pages : chaque bloc de contenu vit dans une surface arrondie, pas à même
6
+ # le fond. Son en-tête porte une icône en pastille dégradée, un titre franc,
7
+ # et le lien qui mène au détail.
8
+ #
9
+ # <%= render SilexUi::PanelComponent.new(title: "Progression du mois", icon: "chart") do |panel| %>
10
+ # <% panel.with_action(label: "Voir détails", href: path) %>
11
+ # …
12
+ # <% end %>
13
+ class PanelComponent < BaseComponent
14
+ renders_one :header
15
+ renders_one :footer
16
+ renders_many :actions, "ActionComponent"
17
+
18
+ def initialize(title: nil, icon: nil, tone: :brand, **options)
19
+ @title = title
20
+ @icon = icon
21
+ @tone = tone_of(tone)
22
+ super(**options)
23
+ end
24
+
25
+ attr_reader :title, :icon_name
26
+
27
+ def icon_name = @icon
28
+
29
+ def header?
30
+ title.present? || actions.any? || super
31
+ end
32
+
33
+ # La pastille de l'en-tête : un dégradé pâle, l'icône en couleur pleine.
34
+ def icon_classes
35
+ class_names("flex size-9 shrink-0 items-center justify-center rounded-surface",
36
+ SilexUi::Tones.tile(@tone))
37
+ end
38
+
39
+ def css_classes
40
+ merge_class("rounded-panel border border-surface-200 bg-surface-0 p-8 shadow-panel",
41
+ "transition duration-300 hover:shadow-panel-hover")
42
+ end
43
+
44
+ # Le lien de l'en-tête : « Voir détails → », discret mais toujours à la
45
+ # même place.
46
+ class ActionComponent < ViewComponent::Base
47
+ def initialize(label:, href:)
48
+ @label = label
49
+ @href = href
50
+ end
51
+
52
+ def call
53
+ link_to(@href, class: "inline-flex items-center gap-1.5 text-sm font-semibold text-brand-600 " \
54
+ "transition hover:gap-2.5 hover:text-brand-700") do
55
+ safe_join([ @label, render(SilexUi::IconComponent.new(name: "chevron-right", size: :sm)) ])
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,21 @@
1
+ <div class="<%= css_classes %>">
2
+ <% if label.present? %>
3
+ <div class="mb-2.5 flex items-baseline justify-between gap-4">
4
+ <span class="text-sm font-bold text-ink-900"><%= label %></span>
5
+ <span class="<%= percent_classes %>"><%= percent %>%</span>
6
+ </div>
7
+ <% end %>
8
+
9
+ <div class="h-2.5 w-full overflow-hidden rounded-full bg-surface-100"
10
+ role="progressbar" aria-valuenow="<%= percent %>" aria-valuemin="0" aria-valuemax="100"
11
+ aria-label="<%= label %>">
12
+ <div class="<%= bar_classes %>" style="width: <%= percent %>%"></div>
13
+ </div>
14
+
15
+ <% if from.present? || to.present? %>
16
+ <div class="mt-2 flex flex-wrap items-baseline justify-between gap-4 text-[13px] text-ink-500">
17
+ <span><%= from %></span>
18
+ <span><%= to %></span>
19
+ </div>
20
+ <% end %>
21
+ </div>
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Une barre de progression : un objectif, ce qui en est fait, et les deux
5
+ # bornes lisibles de part et d'autre.
6
+ #
7
+ # SilexUi::ProgressComponent.new(label: "Objectif mensuel", value: 66_865, total: 260_420,
8
+ # from: "66 865 € réalisés", to: "Objectif : 260 420 €")
9
+ class ProgressComponent < BaseComponent
10
+ def initialize(value:, total:, label: nil, from: nil, to: nil, tone: :brand, **options)
11
+ @value = value.to_f
12
+ @total = total.to_f
13
+ @label = label
14
+ @from = from
15
+ @to = to
16
+ @tone = tone_of(tone)
17
+ super(**options)
18
+ end
19
+
20
+ attr_reader :label, :from, :to
21
+
22
+ # Un total nul ne vaut pas une division : la barre est simplement vide.
23
+ def percent
24
+ return 0 if @total <= 0
25
+
26
+ ((@value / @total) * 100).clamp(0, 100).round
27
+ end
28
+
29
+ def bar_classes
30
+ class_names("h-2.5 rounded-full transition-[width] duration-500",
31
+ @tone == :brand ? "bg-gradient-to-r from-brand-500 to-brand-300" : SilexUi::Tones.accent(@tone))
32
+ end
33
+
34
+ def percent_classes
35
+ class_names("text-xl font-bold", SilexUi::Tones.text(@tone))
36
+ end
37
+
38
+ def css_classes
39
+ merge_class(nil)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,30 @@
1
+ <dialog id="<%= id %>" class="<%= css_classes %>"
2
+ data-controller="silex-ui--modal"
3
+ data-silex-ui--modal-target="dialog"
4
+ data-action="click->silex-ui--modal#closeOnBackdrop">
5
+ <div class="flex items-center justify-between gap-4 border-b border-surface-200 px-6 py-4">
6
+ <h2 class="flex items-center gap-3 text-base font-bold text-ink-900">
7
+ <% if icon_name.present? %>
8
+ <span class="flex size-9 items-center justify-center rounded-surface bg-brand-500/10 text-brand-500">
9
+ <%= render SilexUi::IconComponent.new(name: icon_name, size: :md) %>
10
+ </span>
11
+ <% end %>
12
+ <%= title %>
13
+ </h2>
14
+
15
+ <button type="button" aria-label="Fermer" data-action="silex-ui--modal#close"
16
+ class="inline-flex size-8 cursor-pointer items-center justify-center rounded-badge text-ink-400 transition hover:bg-surface-100 hover:text-ink-900">
17
+ <%= render SilexUi::IconComponent.new(name: "close", size: :md) %>
18
+ </button>
19
+ </div>
20
+
21
+ <%# Le corps arrive du serveur ; en attendant, on dit qu'on attend. %>
22
+ <%# La balise est écrite à la main : un design system n'a pas à dépendre de %>
23
+ <%# turbo-rails pour poser un élément personnalisé. %>
24
+ <turbo-frame id="<%= frame_id %>" class="block max-h-[calc(85vh-4.5rem)] overflow-y-auto">
25
+ <div class="flex items-center justify-center gap-3 px-6 py-16 text-ink-400">
26
+ <%= render SilexUi::IconComponent.new(name: "spinner", size: :md, class: "animate-spin") %>
27
+ <span><%= loading_text %></span>
28
+ </div>
29
+ </turbo-frame>
30
+ </dialog>
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Une modale dont le contenu arrive du serveur à l'ouverture.
5
+ #
6
+ # La coquille est posée une fois dans la page ; son corps est un Turbo Frame
7
+ # vide. N'importe quel lien peut la remplir :
8
+ #
9
+ # <%= render SilexUi::RemoteModalComponent.new(id: "website-modal",
10
+ # frame_id: "website_form", title: "Modifier le domaine", icon: "edit") %>
11
+ #
12
+ # <%= link_to edit_website_path(site), **SilexUi::RemoteModalComponent.trigger(
13
+ # modal: "website-modal", frame: "website_form") do %>…<% end %>
14
+ #
15
+ # Le formulaire chargé décide de la suite : renvoyé dans le frame il reste
16
+ # dans la modale, marqué `turbo_frame: "_top"` il emmène toute la page.
17
+ class RemoteModalComponent < BaseComponent
18
+ def initialize(id:, frame_id:, title:, icon: nil, loading_text: "Chargement…", **options)
19
+ @id = id
20
+ @frame_id = frame_id
21
+ @title = title
22
+ @icon = icon
23
+ @loading_text = loading_text
24
+ super(**options)
25
+ end
26
+
27
+ attr_reader :id, :frame_id, :title, :icon_name, :loading_text
28
+
29
+ def icon_name = @icon
30
+
31
+ # Les attributs à poser sur un déclencheur. Les écrire à la main sur chaque
32
+ # lien, c'est se tromper une fois sur trois d'identifiant de frame.
33
+ def self.trigger(modal:, frame:)
34
+ {
35
+ data: {
36
+ controller: "silex-ui--remote-modal",
37
+ action: "click->silex-ui--remote-modal#open",
38
+ silex_ui__remote_modal_modal_value: modal,
39
+ silex_ui__remote_modal_frame_value: frame
40
+ }
41
+ }
42
+ end
43
+
44
+ def css_classes
45
+ merge_class(
46
+ # `max-h` puis un corps qui défile : un formulaire long ne doit pas
47
+ # sortir de l'écran par le bas, où plus rien ne le rattrape.
48
+ "m-auto max-h-[85vh] w-full max-w-2xl overflow-hidden rounded-2xl border border-surface-200",
49
+ "bg-surface-0 p-0 text-sm text-ink-700 shadow-card-hover",
50
+ "backdrop:bg-slate-900/40 backdrop:backdrop-blur-sm"
51
+ )
52
+ end
53
+ end
54
+ end
@@ -1,8 +1,11 @@
1
1
  <section class="<%= css_classes %>">
2
2
  <% if title.present? || actions? %>
3
- <div class="mb-4 flex items-baseline justify-between gap-4">
3
+ <div class="mb-4 flex flex-wrap items-center justify-between gap-4">
4
4
  <% if title.present? %>
5
- <h2 class="text-xs font-light tracking-widest text-ink-400 uppercase"><%= title %></h2>
5
+ <h2 class="flex items-center gap-2 text-[13px] font-bold tracking-wider text-ink-700 uppercase">
6
+ <%= render SilexUi::IconComponent.new(name: icon_name, size: :sm, class: "text-brand-500") if icon_name.present? %>
7
+ <%= title %>
8
+ </h2>
6
9
  <% end %>
7
10
  <%= actions %>
8
11
  </div>
@@ -1,21 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SilexUi
4
- # Bloc de contenu précédé d'un titre en petites capitales — le rythme de base
5
- # des tableaux de bord du Silex.
6
- #
7
- # <%= render SilexUi::SectionComponent.new(title: "Prochaines expirations") do %>
8
- # …
9
- # <% end %>
4
+ # Bloc de contenu précédé d'un titre. Le rythme de base des tableaux de bord
5
+ # du Silex : petites capitales grasses, une icône devant quand la section a
6
+ # une nature.
10
7
  class SectionComponent < BaseComponent
11
8
  renders_one :actions
12
9
 
13
- def initialize(title: nil, **options)
10
+ def initialize(title: nil, icon: nil, **options)
14
11
  @title = title
12
+ @icon = icon
15
13
  super(**options)
16
14
  end
17
15
 
18
- attr_reader :title
16
+ attr_reader :title, :icon_name
17
+
18
+ def icon_name = @icon
19
19
 
20
20
  def css_classes
21
21
  merge_class(nil)
@@ -0,0 +1,40 @@
1
+ <div class="<%= css_classes %>" data-controller="silex-ui--sidebar">
2
+ <%# Barre supérieure mobile : le rail n'a pas la place de rester à gauche. %>
3
+ <div class="fixed inset-x-0 top-0 z-50 flex h-15 items-center justify-between border-b border-surface-200
4
+ bg-surface-0 px-4 shadow-card md:hidden">
5
+ <%= mobile_brand || brand %>
6
+
7
+ <button type="button" aria-label="Menu" data-action="silex-ui--sidebar#toggle"
8
+ data-silex-ui--sidebar-target="burger"
9
+ class="silex-ui-burger flex h-6 w-[30px] cursor-pointer flex-col justify-between">
10
+ <span></span><span></span><span></span>
11
+ </button>
12
+ </div>
13
+
14
+ <%# Fond sombre : sur mobile, le panneau ouvert prend la main sur la page. %>
15
+ <div hidden data-silex-ui--sidebar-target="overlay" data-action="click->silex-ui--sidebar#close"
16
+ class="fixed inset-0 z-40 bg-ink-900/50 md:hidden"></div>
17
+
18
+ <aside data-silex-ui--sidebar-target="panel"
19
+ class="silex-ui-panel fixed top-15 bottom-0 left-0 z-40 flex w-70 flex-col items-center gap-3
20
+ border-r border-surface-200 bg-surface-50 px-4 py-4
21
+ md:top-0 md:w-rail md:px-2 md:py-5">
22
+ <% if brand? %>
23
+ <div class="mb-3 hidden md:block"><%= brand %></div>
24
+ <% end %>
25
+
26
+ <nav class="flex w-full flex-1 flex-col items-stretch gap-3 overflow-y-auto md:items-center
27
+ [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" aria-label="Navigation principale">
28
+ <%= safe_join(items) %>
29
+ </nav>
30
+
31
+ <% if utilities? || footer? %>
32
+ <div class="mt-auto flex w-full shrink-0 flex-col items-center gap-3 border-t border-surface-200 pt-4">
33
+ <%= utilities %>
34
+ <%= footer %>
35
+ </div>
36
+ <% end %>
37
+ </aside>
38
+
39
+ <main class="min-h-screen pt-15 md:pt-0 md:pl-rail"><%= page %></main>
40
+ </div>