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
|
@@ -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
|
|
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,
|
|
8
|
-
# <% nav.with_aside do
|
|
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-
|
|
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(
|
|
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(
|
|
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
|
-
<
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
15
|
-
<
|
|
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
|
-
#
|
|
4
|
+
# L'en-tête de page, dans les deux formes que le Silex lui donne.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
13
|
-
#
|
|
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
|
-
|
|
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
|
|
7
|
-
class: "
|
|
8
|
-
|
|
9
|
-
|
|
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"
|
|
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-
|
|
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
|
|
25
|
-
class: "
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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-
|
|
3
|
+
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
|
|
4
4
|
<% if title.present? %>
|
|
5
|
-
<h2 class="text-
|
|
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
|
|
5
|
-
#
|
|
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>
|