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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +157 -0
- data/app/assets/javascripts/silex_ui/controllers/clipboard_controller.js +42 -0
- data/app/assets/javascripts/silex_ui/controllers/modal_controller.js +23 -0
- data/app/assets/javascripts/silex_ui/index.js +11 -0
- data/app/assets/stylesheets/silex_ui/theme.css +59 -0
- data/app/components/silex_ui/alert_component.html.erb +11 -0
- data/app/components/silex_ui/alert_component.rb +31 -0
- data/app/components/silex_ui/badge_component.rb +42 -0
- data/app/components/silex_ui/base_component.rb +32 -0
- data/app/components/silex_ui/breakdown_component.html.erb +17 -0
- data/app/components/silex_ui/breakdown_component.rb +27 -0
- data/app/components/silex_ui/button_component.rb +64 -0
- data/app/components/silex_ui/card_component.html.erb +22 -0
- data/app/components/silex_ui/card_component.rb +25 -0
- data/app/components/silex_ui/copy_button_component.rb +44 -0
- data/app/components/silex_ui/empty_state_component.rb +20 -0
- data/app/components/silex_ui/field_component.html.erb +18 -0
- data/app/components/silex_ui/field_component.rb +88 -0
- data/app/components/silex_ui/flash_component.html.erb +6 -0
- data/app/components/silex_ui/flash_component.rb +42 -0
- data/app/components/silex_ui/modal_component.html.erb +24 -0
- data/app/components/silex_ui/modal_component.rb +33 -0
- data/app/components/silex_ui/nav_component.html.erb +15 -0
- data/app/components/silex_ui/nav_component.rb +44 -0
- data/app/components/silex_ui/page_header_component.html.erb +17 -0
- data/app/components/silex_ui/page_header_component.rb +35 -0
- data/app/components/silex_ui/pagination_component.html.erb +30 -0
- data/app/components/silex_ui/pagination_component.rb +47 -0
- data/app/components/silex_ui/section_component.html.erb +12 -0
- data/app/components/silex_ui/section_component.rb +24 -0
- data/app/components/silex_ui/stat_card_component.html.erb +18 -0
- data/app/components/silex_ui/stat_card_component.rb +61 -0
- data/app/components/silex_ui/stat_grid_component.rb +41 -0
- data/app/components/silex_ui/table_component.html.erb +25 -0
- data/app/components/silex_ui/table_component.rb +98 -0
- data/config/importmap.rb +5 -0
- data/lib/generators/silex_ui/install/install_generator.rb +68 -0
- data/lib/silex_ui/engine.rb +31 -0
- data/lib/silex_ui/sync.rb +43 -0
- data/lib/silex_ui/version.rb +5 -0
- data/lib/silex_ui.rb +37 -0
- data/lib/tasks/silex_ui.rake +19 -0
- 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,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">×</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 %>
|