silex_ui 0.1.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +157 -0
  5. data/app/assets/javascripts/silex_ui/controllers/clipboard_controller.js +42 -0
  6. data/app/assets/javascripts/silex_ui/controllers/modal_controller.js +23 -0
  7. data/app/assets/javascripts/silex_ui/index.js +11 -0
  8. data/app/assets/stylesheets/silex_ui/theme.css +59 -0
  9. data/app/components/silex_ui/alert_component.html.erb +11 -0
  10. data/app/components/silex_ui/alert_component.rb +31 -0
  11. data/app/components/silex_ui/badge_component.rb +42 -0
  12. data/app/components/silex_ui/base_component.rb +32 -0
  13. data/app/components/silex_ui/breakdown_component.html.erb +17 -0
  14. data/app/components/silex_ui/breakdown_component.rb +27 -0
  15. data/app/components/silex_ui/button_component.rb +64 -0
  16. data/app/components/silex_ui/card_component.html.erb +22 -0
  17. data/app/components/silex_ui/card_component.rb +25 -0
  18. data/app/components/silex_ui/copy_button_component.rb +44 -0
  19. data/app/components/silex_ui/empty_state_component.rb +20 -0
  20. data/app/components/silex_ui/field_component.html.erb +18 -0
  21. data/app/components/silex_ui/field_component.rb +88 -0
  22. data/app/components/silex_ui/flash_component.html.erb +6 -0
  23. data/app/components/silex_ui/flash_component.rb +42 -0
  24. data/app/components/silex_ui/modal_component.html.erb +24 -0
  25. data/app/components/silex_ui/modal_component.rb +33 -0
  26. data/app/components/silex_ui/nav_component.html.erb +15 -0
  27. data/app/components/silex_ui/nav_component.rb +44 -0
  28. data/app/components/silex_ui/page_header_component.html.erb +17 -0
  29. data/app/components/silex_ui/page_header_component.rb +35 -0
  30. data/app/components/silex_ui/pagination_component.html.erb +30 -0
  31. data/app/components/silex_ui/pagination_component.rb +47 -0
  32. data/app/components/silex_ui/section_component.html.erb +12 -0
  33. data/app/components/silex_ui/section_component.rb +24 -0
  34. data/app/components/silex_ui/stat_card_component.html.erb +18 -0
  35. data/app/components/silex_ui/stat_card_component.rb +61 -0
  36. data/app/components/silex_ui/stat_grid_component.rb +41 -0
  37. data/app/components/silex_ui/table_component.html.erb +25 -0
  38. data/app/components/silex_ui/table_component.rb +98 -0
  39. data/config/importmap.rb +5 -0
  40. data/lib/generators/silex_ui/install/install_generator.rb +68 -0
  41. data/lib/silex_ui/engine.rb +31 -0
  42. data/lib/silex_ui/sync.rb +43 -0
  43. data/lib/silex_ui/version.rb +5 -0
  44. data/lib/silex_ui.rb +37 -0
  45. data/lib/tasks/silex_ui.rake +19 -0
  46. metadata +131 -0
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Ce qu'on voit quand il n'y a rien à voir. Un message, et si possible la seule
5
+ # action qui remplit l'écran.
6
+ class EmptyStateComponent < BaseComponent
7
+ def initialize(message:, **options)
8
+ @message = message
9
+ super(**options)
10
+ end
11
+
12
+ attr_reader :message
13
+
14
+ def call
15
+ tag.div(class: merge_class("py-20 text-center text-sm font-light text-ink-400")) do
16
+ safe_join([ tag.p(message), (tag.div(content, class: "mt-4") if content?) ].compact)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ <div class="<%= css_classes %>">
2
+ <% if boolean? %>
3
+ <%= input %>
4
+ <%= form.label attribute, label_text, class: "text-sm font-light text-ink-600" %>
5
+ <% else %>
6
+ <%= form.label attribute, label_text,
7
+ class: "mb-2 block text-xs font-light tracking-widest text-ink-600 uppercase" %>
8
+ <%= input %>
9
+ <% end %>
10
+
11
+ <% if hint.present? %>
12
+ <p class="mt-1 text-xs font-light text-ink-400"><%= hint %></p>
13
+ <% end %>
14
+
15
+ <% if errors? %>
16
+ <p class="mt-1 text-xs font-light text-alert-700"><%= errors.to_sentence %></p>
17
+ <% end %>
18
+ </div>
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Champ de formulaire : libellé, champ, aide, erreurs. Version Tailwind, très
5
+ # réduite, du FormInputComponent du Silex — un seul chemin par type de champ.
6
+ #
7
+ # <%= render SilexUi::FieldComponent.new(form: f, attribute: :domain, label: "Domaine") %>
8
+ # <%= render SilexUi::FieldComponent.new(form: f, attribute: :registrar, type: :select,
9
+ # collection: Website.registrars.keys, include_blank: "—") %>
10
+ class FieldComponent < BaseComponent
11
+ INPUT_CLASS = "w-full rounded-sm border border-sand-300 bg-white text-sm font-light " \
12
+ "text-ink-900 placeholder:text-ink-400 focus:border-ink-900 focus:outline-none"
13
+
14
+ # Deux densités : serrée pour les barres de filtres, aérée pour les pages de
15
+ # formulaire où le champ est le sujet.
16
+ SIZES = { md: "px-3 py-2", lg: "px-4 py-3" }.freeze
17
+
18
+ TEXT_TYPES = %i[text email password url tel number date datetime search].freeze
19
+
20
+ def initialize(form:, attribute:, label: nil, type: :text, size: :md, hint: nil, collection: nil,
21
+ include_blank: nil, selected: nil, input_html: {}, **options)
22
+ @form = form
23
+ @attribute = attribute
24
+ @label = label
25
+ @type = type.to_sym
26
+ @size = SIZES.key?(size.to_sym) ? size.to_sym : :md
27
+ @hint = hint
28
+ @collection = collection
29
+ @include_blank = include_blank
30
+ @selected = selected
31
+ @input_html = input_html
32
+ super(**options)
33
+ end
34
+
35
+ attr_reader :form, :attribute, :hint
36
+
37
+ def label_text
38
+ @label || attribute.to_s.humanize
39
+ end
40
+
41
+ def errors
42
+ object = form.object
43
+ return [] unless object.respond_to?(:errors)
44
+
45
+ object.errors.full_messages_for(attribute)
46
+ end
47
+
48
+ def errors?
49
+ errors.any?
50
+ end
51
+
52
+ def input
53
+ case @type
54
+ when :select
55
+ raise ArgumentError, "collection is required for a select field" if @collection.nil?
56
+
57
+ form.select(attribute, @collection, { include_blank: @include_blank, selected: @selected }, field_html)
58
+ when :textarea
59
+ form.text_area(attribute, field_html)
60
+ when :boolean
61
+ form.check_box(attribute, field_html.merge(class: checkbox_class))
62
+ when *TEXT_TYPES
63
+ form.public_send(:"#{@type == :text ? 'text' : @type}_field", attribute, field_html)
64
+ else
65
+ form.text_field(attribute, field_html)
66
+ end
67
+ end
68
+
69
+ def boolean?
70
+ @type == :boolean
71
+ end
72
+
73
+ def checkbox_class
74
+ "cursor-pointer rounded-sm border-sand-300 text-ink-900 focus:ring-0"
75
+ end
76
+
77
+ def css_classes
78
+ merge_class(boolean? ? "flex flex-wrap items-center gap-2" : "block")
79
+ end
80
+
81
+ private
82
+
83
+ def field_html
84
+ classes = class_names(INPUT_CLASS, SIZES[@size], ("border-alert-700" if errors?), @input_html[:class])
85
+ @input_html.merge(class: classes)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,6 @@
1
+ <div class="<%= css_classes %>">
2
+ <% messages.each do |item| %>
3
+ <%= render SilexUi::AlertComponent.new(text: item[:message], tone: item[:tone],
4
+ class: "w-full max-w-md shadow-sm") %>
5
+ <% end %>
6
+ </div>
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Les messages flash de Rails, traduits en tons. À poser une fois dans le
5
+ # layout ; le composant ne rend rien quand il n'y a rien à dire.
6
+ #
7
+ # <%= render SilexUi::FlashComponent.new(flash: flash) %>
8
+ class FlashComponent < BaseComponent
9
+ # Rails ne connaît que `notice` et `alert` ; les applications ajoutent le
10
+ # reste. Tout ce qui n'est pas reconnu passe en neutre plutôt qu'en erreur.
11
+ TONE_FOR = {
12
+ "notice" => :ok,
13
+ "success" => :ok,
14
+ "info" => :info,
15
+ "warning" => :warn,
16
+ "warn" => :warn,
17
+ "alert" => :alert,
18
+ "error" => :alert
19
+ }.freeze
20
+
21
+ def initialize(flash:, **options)
22
+ @flash = flash
23
+ super(**options)
24
+ end
25
+
26
+ def messages
27
+ @flash.to_h.filter_map do |type, message|
28
+ next if message.blank?
29
+
30
+ { tone: TONE_FOR.fetch(type.to_s, :neutral), message: message }
31
+ end
32
+ end
33
+
34
+ def render?
35
+ messages.any?
36
+ end
37
+
38
+ def css_classes
39
+ merge_class("fixed inset-x-0 top-0 z-50 flex flex-col items-center gap-2 p-4")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ <div data-controller="silex-ui--modal">
2
+ <% if trigger? %>
3
+ <span data-action="click->silex-ui--modal#open"><%= trigger %></span>
4
+ <% end %>
5
+
6
+ <dialog id="<%= id %>" class="<%= css_classes %>"
7
+ data-silex-ui--modal-target="dialog"
8
+ data-action="click->silex-ui--modal#closeOnBackdrop">
9
+ <div class="flex items-baseline justify-between gap-4">
10
+ <% if title.present? %>
11
+ <h2 class="text-xs font-light tracking-widest text-ink-400 uppercase"><%= title %></h2>
12
+ <% end %>
13
+
14
+ <button type="button" aria-label="Fermer" data-action="silex-ui--modal#close"
15
+ class="cursor-pointer text-ink-400 transition hover:text-ink-900">&times;</button>
16
+ </div>
17
+
18
+ <div class="mt-4"><%= content %></div>
19
+
20
+ <% if footer? %>
21
+ <div class="mt-6 flex flex-wrap items-center justify-end gap-3"><%= footer %></div>
22
+ <% end %>
23
+ </dialog>
24
+ </div>
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Fenêtre modale bâtie sur `<dialog>` : le navigateur gère le focus, la touche
5
+ # Échap et le fond inerte ; le contrôleur Stimulus ne fait qu'ouvrir et fermer.
6
+ #
7
+ # <%= render SilexUi::ModalComponent.new(id: "confirm", title: "Supprimer ?") do |modal| %>
8
+ # <% modal.with_trigger do %>
9
+ # <%= render SilexUi::ButtonComponent.new(label: "Supprimer", variant: :danger) %>
10
+ # <% end %>
11
+ # <p>Cette action est définitive.</p>
12
+ # <% modal.with_footer do %>…<% end %>
13
+ # <% end %>
14
+ class ModalComponent < BaseComponent
15
+ renders_one :trigger
16
+ renders_one :footer
17
+
18
+ def initialize(id:, title: nil, **options)
19
+ @id = id
20
+ @title = title
21
+ super(**options)
22
+ end
23
+
24
+ attr_reader :id, :title
25
+
26
+ def css_classes
27
+ merge_class(
28
+ "m-auto w-full max-w-lg rounded-sm border border-sand-200 bg-white p-6",
29
+ "text-sm font-light text-ink-600 backdrop:bg-ink-900/30"
30
+ )
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ <nav class="<%= css_classes %>">
2
+ <div class="flex min-w-0 flex-wrap items-baseline gap-x-8 gap-y-4">
3
+ <% if brand.present? %>
4
+ <%= link_to brand, brand_href || "/", class: "text-sm font-light tracking-[0.2em] text-ink-900 uppercase" %>
5
+ <% end %>
6
+
7
+ <% if items.any? %>
8
+ <div class="flex flex-wrap gap-x-6 gap-y-3 text-sm font-light"><%= safe_join(items) %></div>
9
+ <% end %>
10
+ </div>
11
+
12
+ <% if aside? %>
13
+ <div class="flex items-center gap-5 text-sm font-light text-ink-600"><%= aside %></div>
14
+ <% end %>
15
+ </nav>
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Barre de navigation : la marque, les sections, et ce qui concerne l'utilisateur.
5
+ #
6
+ # <%= 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 %>
9
+ # <% end %>
10
+ class NavComponent < BaseComponent
11
+ renders_many :items, "ItemComponent"
12
+ renders_one :aside
13
+
14
+ def initialize(brand: nil, brand_href: nil, **options)
15
+ @brand = brand
16
+ @brand_href = brand_href
17
+ super(**options)
18
+ end
19
+
20
+ attr_reader :brand, :brand_href
21
+
22
+ def css_classes
23
+ merge_class("flex flex-wrap items-center justify-between gap-5 border-b border-sand-200 py-5")
24
+ end
25
+
26
+ class ItemComponent < ViewComponent::Base
27
+ def initialize(label:, href:, active: false)
28
+ @label = label
29
+ @href = href
30
+ @active = active
31
+ end
32
+
33
+ def call
34
+ link_to(@label, @href, class: css_classes)
35
+ end
36
+
37
+ private
38
+
39
+ def css_classes
40
+ class_names("transition", @active ? "text-ink-900" : "text-ink-400 hover:text-ink-900")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
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>
4
+
5
+ <% if subtitle.present? %>
6
+ <p class="mt-2 text-sm font-light text-ink-600"><%= subtitle %></p>
7
+ <% end %>
8
+
9
+ <% if meta? %>
10
+ <div class="mt-2 text-sm font-light text-ink-600"><%= meta %></div>
11
+ <% end %>
12
+ </div>
13
+
14
+ <% if actions? %>
15
+ <div class="flex flex-wrap items-center gap-3"><%= actions %></div>
16
+ <% end %>
17
+ </header>
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # En-tête de page : titre, sous-titre, actions à droite.
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 %>
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.
14
+ class PageHeaderComponent < BaseComponent
15
+ renders_one :actions
16
+ renders_one :meta
17
+
18
+ def initialize(title:, subtitle: nil, document_title: nil, **options)
19
+ @title = title
20
+ @subtitle = subtitle
21
+ @document_title = document_title
22
+ super(**options)
23
+ end
24
+
25
+ attr_reader :title, :subtitle
26
+
27
+ def before_render
28
+ content_for(:title, @document_title || @title) if @document_title != false
29
+ end
30
+
31
+ def css_classes
32
+ merge_class("flex flex-wrap items-end justify-between gap-4 py-8")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ <nav aria-label="Pagination" class="<%= css_classes %>">
2
+ <p><%= pagination.from %>–<%= pagination.to %> sur <%= pagination.count %></p>
3
+
4
+ <div class="flex flex-wrap items-center gap-1">
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>
10
+ <% end %>
11
+
12
+ <% pagination.series.each do |entry| %>
13
+ <% if gap?(entry) %>
14
+ <span class="px-2 text-ink-300">…</span>
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>
17
+ <% else %>
18
+ <%= 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
+ <% end %>
21
+ <% end %>
22
+
23
+ <% 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>
28
+ <% end %>
29
+ </div>
30
+ </nav>
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Navigation de pagination. Le composant sait dessiner une série de pages ; il
5
+ # ne sait pas construire une URL — c'est `page_url`, fourni par la page, qui
6
+ # décide quels filtres survivent au changement de page.
7
+ #
8
+ # <%= render SilexUi::PaginationComponent.new(pagination: @pagination,
9
+ # page_url: ->(page) { websites_path(websites_page_params(page)) }) %>
10
+ #
11
+ # `pagination` est n'importe quel objet répondant à `many?`, `page`, `series`,
12
+ # `previous_page`, `next_page`, `from`, `to` et `count` — dont le Pagination
13
+ # maison de Network. Dans `series`, toute entrée qui n'est pas un entier est
14
+ # rendue comme une ellipse.
15
+ class PaginationComponent < BaseComponent
16
+ def initialize(pagination:, page_url:, **options)
17
+ @pagination = pagination
18
+ @page_url = page_url
19
+ super(**options)
20
+ end
21
+
22
+ attr_reader :pagination
23
+
24
+ def render?
25
+ pagination.many?
26
+ end
27
+
28
+ def url_for_page(page)
29
+ @page_url.call(page)
30
+ end
31
+
32
+ def current?(page)
33
+ page == pagination.page
34
+ end
35
+
36
+ def gap?(entry)
37
+ !entry.is_a?(Integer)
38
+ end
39
+
40
+ def css_classes
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"
44
+ )
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,12 @@
1
+ <section class="<%= css_classes %>">
2
+ <% if title.present? || actions? %>
3
+ <div class="mb-4 flex items-baseline justify-between gap-4">
4
+ <% if title.present? %>
5
+ <h2 class="text-xs font-light tracking-widest text-ink-400 uppercase"><%= title %></h2>
6
+ <% end %>
7
+ <%= actions %>
8
+ </div>
9
+ <% end %>
10
+
11
+ <%= content %>
12
+ </section>
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
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 %>
10
+ class SectionComponent < BaseComponent
11
+ renders_one :actions
12
+
13
+ def initialize(title: nil, **options)
14
+ @title = title
15
+ super(**options)
16
+ end
17
+
18
+ attr_reader :title
19
+
20
+ def css_classes
21
+ merge_class(nil)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ <% body = capture do %>
2
+ <span class="block text-3xl font-light <%= value_class %>"><%= value %></span>
3
+ <span class="mt-2 block text-xs font-light tracking-widest text-ink-400 uppercase"><%= label %></span>
4
+
5
+ <% if hint.present? %>
6
+ <span class="mt-1 block text-xs font-light text-ink-400"><%= hint %></span>
7
+ <% end %>
8
+
9
+ <% if trend_text.present? %>
10
+ <span class="mt-1 block text-xs font-light <%= trend_class %>"><%= trend_text %></span>
11
+ <% end %>
12
+ <% end %>
13
+
14
+ <% if href.present? %>
15
+ <%= link_to href, class: css_classes do %><%= body %><% end %>
16
+ <% else %>
17
+ <div class="<%= css_classes %>"><%= body %></div>
18
+ <% end %>
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Un chiffre, ce qu'il compte, et de quoi le creuser.
5
+ #
6
+ # Reprend l'API du StatCardComponent du Silex — valeur, libellé, lien,
7
+ # évolution, valeur secondaire — mais un seul élément a droit à la couleur :
8
+ # le chiffre, et seulement quand il appelle une action.
9
+ class StatCardComponent < BaseComponent
10
+ VALUE_TONES = {
11
+ neutral: "text-ink-900",
12
+ ok: "text-ok-700",
13
+ warn: "text-warn-700",
14
+ alert: "text-alert-700",
15
+ info: "text-info-700"
16
+ }.freeze
17
+
18
+ def initialize(value:, label:, href: nil, tone: :neutral, hint: nil, trend: nil, **options)
19
+ @value = value
20
+ @label = label
21
+ @href = href
22
+ @tone = tone_of(tone)
23
+ @hint = hint
24
+ @trend = trend
25
+ super(**options)
26
+ end
27
+
28
+ attr_reader :value, :label, :href, :hint
29
+
30
+ def value_class
31
+ VALUE_TONES[@tone]
32
+ end
33
+
34
+ # `trend: { value: 12, positive: true }` — le pourcentage ou l'écart, et le
35
+ # sens dans lequel il faut le lire. Un écart nul ne mérite pas de flèche.
36
+ def trend_text
37
+ return nil if @trend.blank?
38
+
39
+ arrow = if @trend[:value].to_f.zero?
40
+ "="
41
+ else
42
+ @trend[:positive] ? "↑" : "↓"
43
+ end
44
+
45
+ [ arrow, @trend[:value], @trend[:suffix] ].compact.join(" ").strip
46
+ end
47
+
48
+ def trend_class
49
+ return "text-ink-400" if @trend.blank? || @trend[:value].to_f.zero?
50
+
51
+ @trend[:positive] ? "text-ok-700" : "text-alert-700"
52
+ end
53
+
54
+ def css_classes
55
+ merge_class(
56
+ "block bg-sand-50 px-6 py-8 transition duration-200",
57
+ ("hover:bg-white" if href.present?)
58
+ )
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Rangée de compteurs. La grille tient les séparateurs (`gap-px` sur fond
5
+ # sable) pour que les tuiles se touchent sans bordure double.
6
+ #
7
+ # <%= render SilexUi::StatGridComponent.new do |grid| %>
8
+ # <% grid.with_stat(value: 1240, label: "Domaines", href: websites_path) %>
9
+ # <% grid.with_stat(value: 12, label: "Expirés", tone: :alert, href: websites_path(filter: "expire")) %>
10
+ # <% end %>
11
+ class StatGridComponent < BaseComponent
12
+ renders_many :stats, "SilexUi::StatCardComponent"
13
+
14
+ def initialize(columns: 4, **options)
15
+ @columns = columns.to_i.clamp(1, 6)
16
+ super(**options)
17
+ end
18
+
19
+ COLUMN_CLASSES = {
20
+ 1 => "",
21
+ 2 => "sm:grid-cols-2",
22
+ 3 => "sm:grid-cols-2 lg:grid-cols-3",
23
+ 4 => "sm:grid-cols-2 lg:grid-cols-4",
24
+ 5 => "sm:grid-cols-2 lg:grid-cols-5",
25
+ 6 => "sm:grid-cols-3 lg:grid-cols-6"
26
+ }.freeze
27
+
28
+ def call
29
+ tag.div(safe_join(stats), class: css_classes)
30
+ end
31
+
32
+ private
33
+
34
+ def css_classes
35
+ merge_class(
36
+ "grid gap-px overflow-hidden rounded-sm border border-sand-200 bg-sand-200",
37
+ COLUMN_CLASSES[@columns]
38
+ )
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,25 @@
1
+ <% if empty? %>
2
+ <%= render SilexUi::EmptyStateComponent.new(message: empty_message) %>
3
+ <% else %>
4
+ <div class="overflow-x-auto">
5
+ <table class="<%= css_classes %>">
6
+ <thead>
7
+ <tr class="border-b border-sand-200 text-xs font-light tracking-widest text-ink-400 uppercase">
8
+ <% columns.each do |column| %>
9
+ <th class="<%= column.header_classes %>"><%= header_for(column) %></th>
10
+ <% end %>
11
+ </tr>
12
+ </thead>
13
+
14
+ <tbody>
15
+ <% rows.each do |row| %>
16
+ <tr class="<%= row_classes(row) %>">
17
+ <% columns.each do |column| %>
18
+ <td class="<%= column.cell_classes %>"><%= cell_for(column, row) %></td>
19
+ <% end %>
20
+ </tr>
21
+ <% end %>
22
+ </tbody>
23
+ </table>
24
+ </div>
25
+ <% end %>