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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +184 -0
- data/LICENSE.txt +6 -0
- data/README.md +97 -7
- data/app/assets/javascripts/silex_ui/controllers/clipboard_controller.js +10 -10
- data/app/assets/javascripts/silex_ui/controllers/dropdown_controller.js +24 -0
- data/app/assets/javascripts/silex_ui/controllers/inline_edit_controller.js +39 -0
- data/app/assets/javascripts/silex_ui/controllers/remote_modal_controller.js +22 -0
- data/app/assets/javascripts/silex_ui/controllers/sidebar_controller.js +34 -0
- data/app/assets/javascripts/silex_ui/controllers/theme_controller.js +43 -0
- data/app/assets/javascripts/silex_ui/controllers/tooltip_controller.js +65 -0
- data/app/assets/javascripts/silex_ui/index.js +22 -1
- data/app/assets/stylesheets/silex_ui/theme.css +528 -42
- data/app/components/silex_ui/action_list_component.rb +76 -0
- data/app/components/silex_ui/alert_component.html.erb +14 -6
- data/app/components/silex_ui/alert_component.rb +24 -12
- data/app/components/silex_ui/avatar_component.rb +56 -0
- data/app/components/silex_ui/badge_component.rb +21 -17
- data/app/components/silex_ui/base_component.rb +10 -3
- data/app/components/silex_ui/breakdown_component.html.erb +20 -6
- data/app/components/silex_ui/breakdown_component.rb +21 -4
- data/app/components/silex_ui/button_component.rb +31 -16
- data/app/components/silex_ui/card_component.html.erb +7 -4
- data/app/components/silex_ui/card_component.rb +9 -6
- data/app/components/silex_ui/copy_button_component.rb +17 -8
- data/app/components/silex_ui/dropdown_component.html.erb +13 -0
- data/app/components/silex_ui/dropdown_component.rb +82 -0
- data/app/components/silex_ui/empty_state_component.html.erb +13 -0
- data/app/components/silex_ui/empty_state_component.rb +6 -9
- data/app/components/silex_ui/field_component.html.erb +4 -4
- data/app/components/silex_ui/field_component.rb +8 -5
- data/app/components/silex_ui/filter_bar_component.html.erb +22 -0
- data/app/components/silex_ui/filter_bar_component.rb +54 -0
- data/app/components/silex_ui/filter_field_component.html.erb +10 -0
- data/app/components/silex_ui/filter_field_component.rb +69 -0
- data/app/components/silex_ui/flash_component.html.erb +1 -1
- data/app/components/silex_ui/icon_component.rb +211 -0
- data/app/components/silex_ui/inline_edit_component.html.erb +23 -0
- data/app/components/silex_ui/inline_edit_component.rb +47 -0
- data/app/components/silex_ui/modal_component.html.erb +4 -2
- data/app/components/silex_ui/modal_component.rb +3 -2
- data/app/components/silex_ui/nav_component.html.erb +5 -4
- data/app/components/silex_ui/nav_component.rb +22 -7
- data/app/components/silex_ui/page_header_component.html.erb +38 -10
- data/app/components/silex_ui/page_header_component.rb +97 -11
- data/app/components/silex_ui/pagination_component.html.erb +11 -10
- data/app/components/silex_ui/pagination_component.rb +1 -2
- data/app/components/silex_ui/panel_component.html.erb +27 -0
- data/app/components/silex_ui/panel_component.rb +60 -0
- data/app/components/silex_ui/progress_component.html.erb +21 -0
- data/app/components/silex_ui/progress_component.rb +42 -0
- data/app/components/silex_ui/remote_modal_component.html.erb +30 -0
- data/app/components/silex_ui/remote_modal_component.rb +54 -0
- data/app/components/silex_ui/section_component.html.erb +5 -2
- data/app/components/silex_ui/section_component.rb +8 -8
- data/app/components/silex_ui/shell_component.html.erb +40 -0
- data/app/components/silex_ui/shell_component.rb +106 -0
- data/app/components/silex_ui/stat_card_component.html.erb +31 -6
- data/app/components/silex_ui/stat_card_component.rb +48 -28
- data/app/components/silex_ui/stat_grid_component.rb +8 -18
- data/app/components/silex_ui/table_component.html.erb +10 -8
- data/app/components/silex_ui/table_component.rb +52 -20
- data/app/components/silex_ui/theme_toggle_component.html.erb +11 -0
- data/app/components/silex_ui/theme_toggle_component.rb +29 -0
- data/app/components/silex_ui/tones.rb +72 -0
- data/app/views/silex_ui/catalogue/_specimen.html.erb +4 -4
- data/app/views/silex_ui/catalogue/show.html.erb +332 -187
- data/lib/silex_ui/version.rb +1 -1
- data/lib/silex_ui.rb +8 -0
- metadata +31 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SilexUi
|
|
4
|
+
# L'ossature de l'application : le rail d'icônes du Silex à gauche, la page à
|
|
5
|
+
# droite. En dessous de 768px le rail devient une barre supérieure et un
|
|
6
|
+
# panneau qui coulisse.
|
|
7
|
+
#
|
|
8
|
+
# <%= render SilexUi::ShellComponent.new do |shell| %>
|
|
9
|
+
# <% shell.with_brand do %><%= link_to root_path do %>…<% end %><% end %>
|
|
10
|
+
# <% shell.with_item(icon: "home", href: root_path, label: "Tableau de bord", active: true) %>
|
|
11
|
+
# <% shell.with_footer do %><%= render SilexUi::AvatarComponent.new(name: "Théo") %><% end %>
|
|
12
|
+
# <% shell.with_page do %>…<% end %>
|
|
13
|
+
# <% end %>
|
|
14
|
+
class ShellComponent < BaseComponent
|
|
15
|
+
renders_one :brand
|
|
16
|
+
renders_one :mobile_brand
|
|
17
|
+
renders_one :footer
|
|
18
|
+
renders_one :page
|
|
19
|
+
# Ce qui se pose au-dessus du menu utilisateur, sous un filet : la cloche
|
|
20
|
+
# des notifications, le sélecteur de thème.
|
|
21
|
+
renders_one :utilities
|
|
22
|
+
renders_many :items, "ItemComponent"
|
|
23
|
+
|
|
24
|
+
def css_classes
|
|
25
|
+
merge_class("min-h-screen")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Une icône du rail. Le libellé n'est pas décoratif : sans lui, un rail
|
|
29
|
+
# d'icônes se devine au lieu de se lire.
|
|
30
|
+
class ItemComponent < ViewComponent::Base
|
|
31
|
+
def initialize(icon:, href:, label:, active: false, add: false)
|
|
32
|
+
@icon = icon
|
|
33
|
+
@href = href
|
|
34
|
+
@label = label
|
|
35
|
+
@active = active
|
|
36
|
+
@add = add
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :href, :label
|
|
40
|
+
|
|
41
|
+
def active? = @active
|
|
42
|
+
|
|
43
|
+
def add? = @add
|
|
44
|
+
|
|
45
|
+
def call
|
|
46
|
+
link_to(href, **attributes) { safe_join([ marker, tile, mobile_label ]) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def attributes
|
|
52
|
+
{
|
|
53
|
+
class: link_classes,
|
|
54
|
+
# `title` porte le libellé : sans JavaScript, le navigateur montre sa
|
|
55
|
+
# propre infobulle ; le contrôleur la remplace par la nôtre.
|
|
56
|
+
title: label,
|
|
57
|
+
"aria-label": label,
|
|
58
|
+
"aria-current": ("page" if active?),
|
|
59
|
+
data: { controller: "silex-ui--tooltip" }
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def link_classes
|
|
64
|
+
"group/rail relative flex items-center gap-3 py-2.5 md:justify-center"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# La barre violette contre le bord : dans un rail d'icônes, c'est elle qui
|
|
68
|
+
# dit où l'on est, bien avant qu'on ait lu l'icône.
|
|
69
|
+
def marker
|
|
70
|
+
return nil unless active?
|
|
71
|
+
|
|
72
|
+
# Collée au bord gauche du lien, pas au-delà : le rail défile
|
|
73
|
+
# verticalement, et tout ce qui déborde à gauche est rogné.
|
|
74
|
+
tag.span(class: "absolute top-1/2 left-0 h-6 w-[3px] -translate-y-1/2 rounded-r bg-purple-500 " \
|
|
75
|
+
"max-md:-left-2")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Le carré du rail : 42 px, un fond violet dilué, aucune bordure. C'est la
|
|
79
|
+
# légèreté du Silex — la version bordée et cerclée est celle de ses
|
|
80
|
+
# vignettes de campagne, pas celle de sa navigation.
|
|
81
|
+
def tile
|
|
82
|
+
tag.span(render(SilexUi::IconComponent.new(name: @icon, size: :sm)), class: tile_classes)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def tile_classes
|
|
86
|
+
class_names(
|
|
87
|
+
"flex size-rail-tile items-center justify-center rounded-surface bg-purple-500/10",
|
|
88
|
+
# L'icône est indigo, jamais grise : c'est ce qui donne au rail du
|
|
89
|
+
# Silex sa couleur avant même qu'on lise une forme.
|
|
90
|
+
"text-brand-500 transition duration-200 group-hover/rail:scale-105 group-hover/rail:bg-purple-500/15",
|
|
91
|
+
("border-2 border-dashed border-purple-500/30 bg-transparent group-hover/rail:rotate-90" if add?),
|
|
92
|
+
# La section courante ne prend ni bordure ni anneau : sa barre contre
|
|
93
|
+
# le bord suffit, et une ombre portée la décolle.
|
|
94
|
+
("shadow-[0_4px_12px_rgba(139,92,246,0.4)]" if active?)
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Sur mobile le rail est déplié : le libellé se pose à côté de l'icône, et
|
|
99
|
+
# à l'intérieur du lien — un texte qui accompagne une cible sans en faire
|
|
100
|
+
# partie est un texte sur lequel on appuie pour rien.
|
|
101
|
+
def mobile_label
|
|
102
|
+
tag.span(label, class: "text-sm font-semibold text-ink-700 md:hidden")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
<% body = capture do %>
|
|
2
|
-
<span class="
|
|
3
|
-
<span class="mt-2 block text-xs font-light tracking-widest text-ink-400 uppercase"><%= label %></span>
|
|
2
|
+
<span class="absolute inset-y-0 left-0 w-1 transition-all duration-300 group-hover:w-1.5 <%= accent_class %>"></span>
|
|
4
3
|
|
|
5
|
-
<% if
|
|
6
|
-
<span class="
|
|
4
|
+
<% if icon_name.present? %>
|
|
5
|
+
<span class="relative flex size-icon-tile shrink-0 items-center justify-center rounded-surface
|
|
6
|
+
transition duration-300 group-hover:scale-105 <%= tile_class %>">
|
|
7
|
+
<%= render SilexUi::IconComponent.new(name: icon_name, size: :lg) %>
|
|
8
|
+
|
|
9
|
+
<% if alert? %>
|
|
10
|
+
<%# Ce qui réclame une action se signale de lui-même, sans attendre qu'on lise le chiffre. %>
|
|
11
|
+
<span class="absolute -top-1 -right-1 size-4 animate-pulse rounded-full border-2 border-surface-0 bg-alert-500"></span>
|
|
12
|
+
<% end %>
|
|
13
|
+
</span>
|
|
7
14
|
<% end %>
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
<span class="
|
|
16
|
+
<span class="min-w-0">
|
|
17
|
+
<span class="block text-3xl leading-tight font-extrabold <%= value_class %>"><%= value %></span>
|
|
18
|
+
<span class="mt-0.5 block truncate text-sm text-ink-500"><%= label %></span>
|
|
19
|
+
|
|
20
|
+
<% if hint.present? %>
|
|
21
|
+
<span class="mt-0.5 block text-xs text-ink-400"><%= hint %></span>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<% if trend? %>
|
|
25
|
+
<span class="mt-1 inline-flex items-center gap-1 text-xs font-semibold <%= trend_class %>">
|
|
26
|
+
<%= render SilexUi::IconComponent.new(name: trend_icon, size: :xs) if trend_icon %>
|
|
27
|
+
<%= trend_label %>
|
|
28
|
+
</span>
|
|
29
|
+
<% end %>
|
|
30
|
+
</span>
|
|
31
|
+
|
|
32
|
+
<% if href.present? %>
|
|
33
|
+
<span class="ml-auto shrink-0 text-ink-300 opacity-0 transition duration-300 group-hover:translate-x-0 group-hover:opacity-100 -translate-x-1">
|
|
34
|
+
<%= render SilexUi::IconComponent.new(name: "chevron-right", size: :sm) %>
|
|
35
|
+
</span>
|
|
11
36
|
<% end %>
|
|
12
37
|
<% end %>
|
|
13
38
|
|
|
@@ -3,58 +3,78 @@
|
|
|
3
3
|
module SilexUi
|
|
4
4
|
# Un chiffre, ce qu'il compte, et de quoi le creuser.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# La carte du Silex : une barre d'accent qui s'épaissit au survol, une icône
|
|
7
|
+
# dans un carré arrondi, et la carte qui se soulève d'un cheveu quand elle
|
|
8
|
+
# mène quelque part.
|
|
9
|
+
#
|
|
10
|
+
# grid.with_stat(value: 12, label: "Expirés", tone: :alert, icon: "alert", href: "…")
|
|
9
11
|
class StatCardComponent < BaseComponent
|
|
10
|
-
|
|
11
|
-
|
|
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)
|
|
12
|
+
def initialize(value:, label:, href: nil, tone: :neutral, icon: nil, hint: nil,
|
|
13
|
+
trend: nil, alert: false, **options)
|
|
19
14
|
@value = value
|
|
20
15
|
@label = label
|
|
21
16
|
@href = href
|
|
22
17
|
@tone = tone_of(tone)
|
|
18
|
+
@icon = icon
|
|
23
19
|
@hint = hint
|
|
24
20
|
@trend = trend
|
|
21
|
+
@alert = alert
|
|
25
22
|
super(**options)
|
|
26
23
|
end
|
|
27
24
|
|
|
28
|
-
attr_reader :value, :label, :href, :hint
|
|
25
|
+
attr_reader :value, :label, :href, :hint, :icon_name
|
|
26
|
+
|
|
27
|
+
def icon_name = @icon
|
|
28
|
+
|
|
29
|
+
def tone = @tone
|
|
30
|
+
|
|
31
|
+
def alert? = @alert
|
|
29
32
|
|
|
30
33
|
def value_class
|
|
31
|
-
|
|
34
|
+
SilexUi::Tones.text(@tone)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def accent_class
|
|
38
|
+
SilexUi::Tones.accent(@tone)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tile_class
|
|
42
|
+
SilexUi::Tones.tile(@tone)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `trend: { value: 12, positive: true }` — l'écart, et le sens dans lequel il
|
|
46
|
+
# faut le lire. Un écart nul ne mérite pas de flèche.
|
|
47
|
+
def trend?
|
|
48
|
+
@trend.present?
|
|
32
49
|
end
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
def trend_flat?
|
|
52
|
+
@trend[:value].to_f.zero?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def trend_icon
|
|
56
|
+
return nil if trend_flat?
|
|
57
|
+
|
|
58
|
+
@trend[:positive] ? "arrow-up" : "arrow-down"
|
|
59
|
+
end
|
|
38
60
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else
|
|
42
|
-
@trend[:positive] ? "↑" : "↓"
|
|
43
|
-
end
|
|
61
|
+
def trend_label
|
|
62
|
+
return "=" if trend_flat?
|
|
44
63
|
|
|
45
|
-
[
|
|
64
|
+
[ @trend[:value], @trend[:suffix] ].compact.join(" ")
|
|
46
65
|
end
|
|
47
66
|
|
|
48
67
|
def trend_class
|
|
49
|
-
return "text-ink-400" if
|
|
68
|
+
return "text-ink-400" if trend_flat?
|
|
50
69
|
|
|
51
|
-
@trend[:positive] ? "text-ok-
|
|
70
|
+
@trend[:positive] ? "text-ok-500" : "text-alert-500"
|
|
52
71
|
end
|
|
53
72
|
|
|
54
73
|
def css_classes
|
|
55
74
|
merge_class(
|
|
56
|
-
"
|
|
57
|
-
|
|
75
|
+
"group relative flex items-center gap-4 overflow-hidden rounded-card border border-surface-200",
|
|
76
|
+
"bg-surface-0 p-6 shadow-card transition duration-300",
|
|
77
|
+
("hover:-translate-y-0.5 hover:shadow-card-hover" if href.present?)
|
|
58
78
|
)
|
|
59
79
|
end
|
|
60
80
|
end
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SilexUi
|
|
4
|
-
# Rangée de compteurs.
|
|
5
|
-
#
|
|
4
|
+
# Rangée de compteurs. Les cartes du Silex respirent : elles sont séparées par
|
|
5
|
+
# un vrai espace, pas par un filet.
|
|
6
6
|
#
|
|
7
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")) %>
|
|
8
|
+
# <% grid.with_stat(value: 1240, label: "Domaines", icon: "globe", href: websites_path) %>
|
|
10
9
|
# <% end %>
|
|
11
10
|
class StatGridComponent < BaseComponent
|
|
12
11
|
renders_many :stats, "SilexUi::StatCardComponent"
|
|
13
12
|
|
|
14
|
-
def initialize(columns: 4, **options)
|
|
15
|
-
@columns = columns.to_i.clamp(1, 6)
|
|
16
|
-
super(**options)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
13
|
COLUMN_CLASSES = {
|
|
20
14
|
1 => "",
|
|
21
15
|
2 => "sm:grid-cols-2",
|
|
@@ -25,17 +19,13 @@ module SilexUi
|
|
|
25
19
|
6 => "sm:grid-cols-3 lg:grid-cols-6"
|
|
26
20
|
}.freeze
|
|
27
21
|
|
|
28
|
-
def
|
|
29
|
-
|
|
22
|
+
def initialize(columns: 4, **options)
|
|
23
|
+
@columns = columns.to_i.clamp(1, 6)
|
|
24
|
+
super(**options)
|
|
30
25
|
end
|
|
31
26
|
|
|
32
|
-
|
|
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
|
-
)
|
|
27
|
+
def call
|
|
28
|
+
tag.div(safe_join(stats), class: merge_class("grid gap-5", COLUMN_CLASSES[@columns]))
|
|
39
29
|
end
|
|
40
30
|
end
|
|
41
31
|
end
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
<% if empty? %>
|
|
2
2
|
<%= render SilexUi::EmptyStateComponent.new(message: empty_message) %>
|
|
3
3
|
<% else %>
|
|
4
|
-
<div class="
|
|
4
|
+
<div class="<%= wrapper_classes %>">
|
|
5
5
|
<table class="<%= css_classes %>">
|
|
6
|
-
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
<% if headers? %>
|
|
7
|
+
<thead>
|
|
8
|
+
<tr>
|
|
9
|
+
<% columns.each do |column| %>
|
|
10
|
+
<th class="<%= column.header_classes %>"><%= header_for(column) %></th>
|
|
11
|
+
<% end %>
|
|
12
|
+
</tr>
|
|
13
|
+
</thead>
|
|
14
|
+
<% end %>
|
|
13
15
|
|
|
14
16
|
<tbody>
|
|
15
17
|
<% rows.each do |row| %>
|
|
@@ -12,9 +12,10 @@ module SilexUi
|
|
|
12
12
|
# Comme le TableComponent du Silex, le tri est une URL fournie par l'appelant :
|
|
13
13
|
# le composant ne connaît ni les paramètres de la page, ni ses filtres.
|
|
14
14
|
class TableComponent < BaseComponent
|
|
15
|
-
def initialize(rows:, row_class: nil, empty_message: "Aucun élément à afficher", **options)
|
|
15
|
+
def initialize(rows:, row_class: nil, framed: true, empty_message: "Aucun élément à afficher", **options)
|
|
16
16
|
@rows = rows
|
|
17
17
|
@row_class = row_class
|
|
18
|
+
@framed = framed
|
|
18
19
|
@empty_message = empty_message
|
|
19
20
|
@columns = []
|
|
20
21
|
super(**options)
|
|
@@ -22,10 +23,21 @@ module SilexUi
|
|
|
22
23
|
|
|
23
24
|
attr_reader :rows, :columns, :empty_message
|
|
24
25
|
|
|
26
|
+
# Un tableau se suffit : il porte sa bordure et son rayon. Posé dans un
|
|
27
|
+
# panneau qui a déjà les siens, il les laisse tomber plutôt que de dessiner
|
|
28
|
+
# un second cadre.
|
|
29
|
+
def framed?
|
|
30
|
+
@framed
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def wrapper_classes
|
|
34
|
+
class_names("overflow-x-auto", ("rounded-surface border border-surface-200" if framed?))
|
|
35
|
+
end
|
|
36
|
+
|
|
25
37
|
# Déclare une colonne. Le bloc reçoit la ligne et rend la cellule ; il peut
|
|
26
38
|
# renvoyer une chaîne comme écrire du balisage ERB, `capture` gère les deux.
|
|
27
|
-
def with_column(header: nil, sort: nil, direction: nil, align: :left, cell_class: nil, &cell)
|
|
28
|
-
@columns << Column.new(header: header, sort: sort, direction: direction,
|
|
39
|
+
def with_column(header: nil, icon: nil, sort: nil, direction: nil, align: :left, cell_class: nil, &cell)
|
|
40
|
+
@columns << Column.new(header: header, icon: icon, sort: sort, direction: direction,
|
|
29
41
|
align: align, cell_class: cell_class, cell: cell)
|
|
30
42
|
nil
|
|
31
43
|
end
|
|
@@ -40,17 +52,32 @@ module SilexUi
|
|
|
40
52
|
rows.none?
|
|
41
53
|
end
|
|
42
54
|
|
|
55
|
+
# Un tableau dont aucune colonne n'est nommée n'a pas d'en-tête à montrer :
|
|
56
|
+
# la bande grise se lirait comme une ligne vide.
|
|
57
|
+
def headers?
|
|
58
|
+
columns.any? { |column| column.header.present? }
|
|
59
|
+
end
|
|
60
|
+
|
|
43
61
|
def row_classes(row)
|
|
44
|
-
class_names("border-b border-
|
|
62
|
+
class_names("border-b border-surface-200 align-middle transition last:border-0 hover:bg-surface-50",
|
|
63
|
+
@row_class&.call(row))
|
|
45
64
|
end
|
|
46
65
|
|
|
66
|
+
# Un en-tête tient sur une ligne : ce que le gabarit indente finit dans le
|
|
67
|
+
# texte de la cellule, et une assertion sur « Domaine » ne le retrouve plus.
|
|
47
68
|
# Un en-tête tient sur une ligne : ce que le gabarit indente finit dans le
|
|
48
69
|
# texte de la cellule, et une assertion sur « Domaine » ne le retrouve plus.
|
|
49
70
|
def header_for(column)
|
|
50
|
-
|
|
71
|
+
label = safe_join([
|
|
72
|
+
(render(SilexUi::IconComponent.new(name: column.icon, size: :xs, class: "text-brand-500")) if column.icon),
|
|
73
|
+
column.header
|
|
74
|
+
].compact)
|
|
51
75
|
|
|
52
|
-
|
|
53
|
-
|
|
76
|
+
return label unless column.sortable?
|
|
77
|
+
|
|
78
|
+
link_to(column.sort, class: "inline-flex items-center gap-1.5 transition hover:text-brand-600") do
|
|
79
|
+
safe_join([ label, render(SilexUi::IconComponent.new(name: column.sort_icon, size: :xs,
|
|
80
|
+
class: "text-ink-400")) ])
|
|
54
81
|
end
|
|
55
82
|
end
|
|
56
83
|
|
|
@@ -59,32 +86,37 @@ module SilexUi
|
|
|
59
86
|
end
|
|
60
87
|
|
|
61
88
|
def css_classes
|
|
62
|
-
merge_class("w-full border-
|
|
89
|
+
merge_class("w-full border-separate border-spacing-0 bg-surface-0 text-sm")
|
|
63
90
|
end
|
|
64
91
|
|
|
65
|
-
Column = Struct.new(:header, :sort, :direction, :align, :cell_class, :cell, keyword_init: true) do
|
|
92
|
+
Column = Struct.new(:header, :icon, :sort, :direction, :align, :cell_class, :cell, keyword_init: true) do
|
|
66
93
|
ALIGNMENTS = { left: "text-left", center: "text-center", right: "text-right" }.freeze
|
|
67
94
|
|
|
95
|
+
def sortable?
|
|
96
|
+
sort.present?
|
|
97
|
+
end
|
|
98
|
+
|
|
68
99
|
def header_classes
|
|
69
100
|
ActionView::Helpers::TagHelper.build_tag_values(
|
|
70
|
-
"
|
|
101
|
+
"sticky top-0 z-5 border-b-2 border-surface-200 bg-gradient-to-br from-surface-50 to-surface-100",
|
|
102
|
+
"px-4 py-3 text-[13px] font-bold tracking-wider text-ink-700 uppercase whitespace-nowrap",
|
|
103
|
+
alignment
|
|
71
104
|
).join(" ")
|
|
72
105
|
end
|
|
73
106
|
|
|
74
107
|
def cell_classes
|
|
75
|
-
ActionView::Helpers::TagHelper.build_tag_values(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def sortable?
|
|
79
|
-
sort.present?
|
|
108
|
+
ActionView::Helpers::TagHelper.build_tag_values(
|
|
109
|
+
"border-b border-surface-200 px-4 py-4 text-ink-900", alignment, cell_class
|
|
110
|
+
).join(" ")
|
|
80
111
|
end
|
|
81
112
|
|
|
82
|
-
#
|
|
83
|
-
|
|
113
|
+
# Une flèche quand la colonne porte le tri courant, le double chevron
|
|
114
|
+
# quand elle peut le prendre.
|
|
115
|
+
def sort_icon
|
|
84
116
|
case direction&.to_sym
|
|
85
|
-
when :asc then "
|
|
86
|
-
when :desc then "
|
|
87
|
-
else "
|
|
117
|
+
when :asc then "arrow-up"
|
|
118
|
+
when :desc then "arrow-down"
|
|
119
|
+
else "sort"
|
|
88
120
|
end
|
|
89
121
|
end
|
|
90
122
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div class="<%= css_classes %>" data-controller="silex-ui--theme" role="group" aria-label="Thème">
|
|
2
|
+
<% modes.each do |item| %>
|
|
3
|
+
<button type="button" title="<%= item[:title] %>" aria-label="<%= item[:title] %>"
|
|
4
|
+
class="<%= button_classes %>"
|
|
5
|
+
data-mode="<%= item[:mode] %>"
|
|
6
|
+
data-silex-ui--theme-target="option"
|
|
7
|
+
data-action="silex-ui--theme#select">
|
|
8
|
+
<%= render SilexUi::IconComponent.new(name: item[:icon], size: :xs) %>
|
|
9
|
+
</button>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SilexUi
|
|
4
|
+
# Le sélecteur de thème du Silex : trois positions dans une pilule — clair,
|
|
5
|
+
# automatique, sombre. « Automatique » n'est pas un défaut caché, c'est un
|
|
6
|
+
# choix qu'on peut reprendre : il suit le système et le dit.
|
|
7
|
+
#
|
|
8
|
+
# <%= render SilexUi::ThemeToggleComponent.new %>
|
|
9
|
+
class ThemeToggleComponent < BaseComponent
|
|
10
|
+
MODES = [
|
|
11
|
+
{ mode: "light", icon: "sun", title: "Mode clair" },
|
|
12
|
+
{ mode: "auto", icon: "contrast", title: "Mode automatique" },
|
|
13
|
+
{ mode: "dark", icon: "moon", title: "Mode sombre" }
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
def modes = MODES
|
|
17
|
+
|
|
18
|
+
def css_classes
|
|
19
|
+
merge_class("inline-flex gap-0.5 rounded-[14px] bg-surface-100 p-0.5")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def button_classes
|
|
23
|
+
"flex size-9 cursor-pointer items-center justify-center rounded-xl text-ink-500 transition " \
|
|
24
|
+
"hover:scale-105 hover:text-ink-900 md:size-6 " \
|
|
25
|
+
"data-[active]:scale-100 data-[active]:bg-purple-500 data-[active]:text-white " \
|
|
26
|
+
"data-[active]:shadow-[0_1px_4px_rgba(139,92,246,0.3)]"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SilexUi
|
|
4
|
+
# Les tons, traduits en classes. Une seule table pour tous les composants :
|
|
5
|
+
# une pastille verte et un compteur vert doivent tirer du même vert, sans quoi
|
|
6
|
+
# le design system se met à mentir dès le troisième écran.
|
|
7
|
+
module Tones
|
|
8
|
+
# Fond pastel, bordure, texte soutenu — l'habillage plein du Silex.
|
|
9
|
+
FILLED = {
|
|
10
|
+
neutral: "border-surface-200 bg-surface-100 text-ink-700",
|
|
11
|
+
brand: "border-brand-300 bg-brand-50 text-brand-600",
|
|
12
|
+
ok: "border-ok-300 bg-ok-50 text-ok-700",
|
|
13
|
+
warn: "border-warn-300 bg-warn-50 text-warn-700",
|
|
14
|
+
alert: "border-alert-300 bg-alert-50 text-alert-700",
|
|
15
|
+
info: "border-info-300 bg-info-50 text-info-700",
|
|
16
|
+
purple: "border-purple-300 bg-purple-50 text-purple-700",
|
|
17
|
+
pink: "border-pink-300 bg-pink-50 text-pink-700",
|
|
18
|
+
teal: "border-teal-300 bg-teal-50 text-teal-700",
|
|
19
|
+
cyan: "border-cyan-300 bg-cyan-50 text-cyan-700"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# La barre d'accent d'une carte, et le trait de gauche d'un bandeau.
|
|
23
|
+
ACCENT = {
|
|
24
|
+
neutral: "bg-ink-400",
|
|
25
|
+
brand: "bg-brand-500",
|
|
26
|
+
ok: "bg-ok-500",
|
|
27
|
+
warn: "bg-warn-500",
|
|
28
|
+
alert: "bg-alert-500",
|
|
29
|
+
info: "bg-info-500",
|
|
30
|
+
purple: "bg-purple-500",
|
|
31
|
+
pink: "bg-pink-500",
|
|
32
|
+
teal: "bg-teal-500",
|
|
33
|
+
cyan: "bg-cyan-500"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Le carré arrondi qui porte une icône : un fond très dilué, l'icône pleine.
|
|
37
|
+
TILE = {
|
|
38
|
+
neutral: "bg-surface-100 text-ink-500",
|
|
39
|
+
brand: "bg-brand-500/10 text-brand-500",
|
|
40
|
+
ok: "bg-ok-500/10 text-ok-500",
|
|
41
|
+
warn: "bg-warn-500/10 text-warn-500",
|
|
42
|
+
alert: "bg-alert-500/10 text-alert-500",
|
|
43
|
+
info: "bg-info-500/10 text-info-500",
|
|
44
|
+
purple: "bg-purple-500/10 text-purple-500",
|
|
45
|
+
pink: "bg-pink-500/10 text-pink-500",
|
|
46
|
+
teal: "bg-teal-500/10 text-teal-500",
|
|
47
|
+
cyan: "bg-cyan-500/10 text-cyan-500"
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# La couleur du chiffre d'un compteur, seul élément d'une carte à laquelle
|
|
51
|
+
# le ton donne droit à de la couleur pleine.
|
|
52
|
+
TEXT = {
|
|
53
|
+
neutral: "text-ink-900",
|
|
54
|
+
brand: "text-brand-600",
|
|
55
|
+
ok: "text-ok-700",
|
|
56
|
+
warn: "text-warn-700",
|
|
57
|
+
alert: "text-alert-700",
|
|
58
|
+
info: "text-info-700",
|
|
59
|
+
purple: "text-purple-700",
|
|
60
|
+
pink: "text-pink-700",
|
|
61
|
+
teal: "text-teal-700",
|
|
62
|
+
cyan: "text-cyan-700"
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
def filled(tone) = FILLED.fetch(tone, FILLED[:neutral])
|
|
68
|
+
def accent(tone) = ACCENT.fetch(tone, ACCENT[:neutral])
|
|
69
|
+
def tile(tone) = TILE.fetch(tone, TILE[:neutral])
|
|
70
|
+
def text(tone) = TEXT.fetch(tone, TEXT[:neutral])
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
<%# Un spécimen : ce qu'on regarde, et la ligne à recopier pour l'obtenir. %>
|
|
2
|
-
<div class="border-t border-
|
|
2
|
+
<div class="border-t border-surface-200 py-6">
|
|
3
3
|
<div class="mb-4 flex flex-wrap items-baseline justify-between gap-3">
|
|
4
|
-
<h3 class="text-sm font-
|
|
4
|
+
<h3 class="text-sm font-semibold text-ink-900"><%= title %></h3>
|
|
5
5
|
<% if local_assigns[:note].present? %>
|
|
6
|
-
<p class="text-xs
|
|
6
|
+
<p class="text-xs text-ink-400"><%= note %></p>
|
|
7
7
|
<% end %>
|
|
8
8
|
</div>
|
|
9
9
|
|
|
10
10
|
<div class="mb-4"><%= yield %></div>
|
|
11
11
|
|
|
12
12
|
<% if local_assigns[:code].present? %>
|
|
13
|
-
<pre class="overflow-x-auto rounded-
|
|
13
|
+
<pre class="overflow-x-auto rounded-badge bg-surface-100 p-3 text-xs text-ink-700"><code><%= code %></code></pre>
|
|
14
14
|
<% end %>
|
|
15
15
|
</div>
|