silex_ui 0.2.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/LICENSE.txt +6 -0
  4. data/README.md +97 -7
  5. data/app/assets/javascripts/silex_ui/controllers/clipboard_controller.js +10 -10
  6. data/app/assets/javascripts/silex_ui/controllers/dropdown_controller.js +24 -0
  7. data/app/assets/javascripts/silex_ui/controllers/inline_edit_controller.js +39 -0
  8. data/app/assets/javascripts/silex_ui/controllers/remote_modal_controller.js +22 -0
  9. data/app/assets/javascripts/silex_ui/controllers/sidebar_controller.js +34 -0
  10. data/app/assets/javascripts/silex_ui/controllers/theme_controller.js +43 -0
  11. data/app/assets/javascripts/silex_ui/controllers/tooltip_controller.js +65 -0
  12. data/app/assets/javascripts/silex_ui/index.js +22 -1
  13. data/app/assets/stylesheets/silex_ui/theme.css +528 -42
  14. data/app/components/silex_ui/action_list_component.rb +76 -0
  15. data/app/components/silex_ui/alert_component.html.erb +14 -6
  16. data/app/components/silex_ui/alert_component.rb +24 -12
  17. data/app/components/silex_ui/avatar_component.rb +56 -0
  18. data/app/components/silex_ui/badge_component.rb +21 -17
  19. data/app/components/silex_ui/base_component.rb +10 -3
  20. data/app/components/silex_ui/breakdown_component.html.erb +20 -6
  21. data/app/components/silex_ui/breakdown_component.rb +21 -4
  22. data/app/components/silex_ui/button_component.rb +31 -16
  23. data/app/components/silex_ui/card_component.html.erb +7 -4
  24. data/app/components/silex_ui/card_component.rb +9 -6
  25. data/app/components/silex_ui/copy_button_component.rb +17 -8
  26. data/app/components/silex_ui/dropdown_component.html.erb +13 -0
  27. data/app/components/silex_ui/dropdown_component.rb +82 -0
  28. data/app/components/silex_ui/empty_state_component.html.erb +13 -0
  29. data/app/components/silex_ui/empty_state_component.rb +6 -9
  30. data/app/components/silex_ui/field_component.html.erb +4 -4
  31. data/app/components/silex_ui/field_component.rb +8 -5
  32. data/app/components/silex_ui/filter_bar_component.html.erb +22 -0
  33. data/app/components/silex_ui/filter_bar_component.rb +54 -0
  34. data/app/components/silex_ui/filter_field_component.html.erb +10 -0
  35. data/app/components/silex_ui/filter_field_component.rb +69 -0
  36. data/app/components/silex_ui/flash_component.html.erb +1 -1
  37. data/app/components/silex_ui/icon_component.rb +211 -0
  38. data/app/components/silex_ui/inline_edit_component.html.erb +23 -0
  39. data/app/components/silex_ui/inline_edit_component.rb +47 -0
  40. data/app/components/silex_ui/modal_component.html.erb +4 -2
  41. data/app/components/silex_ui/modal_component.rb +3 -2
  42. data/app/components/silex_ui/nav_component.html.erb +5 -4
  43. data/app/components/silex_ui/nav_component.rb +22 -7
  44. data/app/components/silex_ui/page_header_component.html.erb +38 -10
  45. data/app/components/silex_ui/page_header_component.rb +97 -11
  46. data/app/components/silex_ui/pagination_component.html.erb +11 -10
  47. data/app/components/silex_ui/pagination_component.rb +1 -2
  48. data/app/components/silex_ui/panel_component.html.erb +27 -0
  49. data/app/components/silex_ui/panel_component.rb +60 -0
  50. data/app/components/silex_ui/progress_component.html.erb +21 -0
  51. data/app/components/silex_ui/progress_component.rb +42 -0
  52. data/app/components/silex_ui/remote_modal_component.html.erb +30 -0
  53. data/app/components/silex_ui/remote_modal_component.rb +54 -0
  54. data/app/components/silex_ui/section_component.html.erb +5 -2
  55. data/app/components/silex_ui/section_component.rb +8 -8
  56. data/app/components/silex_ui/shell_component.html.erb +40 -0
  57. data/app/components/silex_ui/shell_component.rb +106 -0
  58. data/app/components/silex_ui/stat_card_component.html.erb +31 -6
  59. data/app/components/silex_ui/stat_card_component.rb +48 -28
  60. data/app/components/silex_ui/stat_grid_component.rb +8 -18
  61. data/app/components/silex_ui/table_component.html.erb +10 -8
  62. data/app/components/silex_ui/table_component.rb +52 -20
  63. data/app/components/silex_ui/theme_toggle_component.html.erb +11 -0
  64. data/app/components/silex_ui/theme_toggle_component.rb +29 -0
  65. data/app/components/silex_ui/tones.rb +72 -0
  66. data/app/views/silex_ui/catalogue/_specimen.html.erb +4 -4
  67. data/app/views/silex_ui/catalogue/show.html.erb +332 -187
  68. data/lib/silex_ui/version.rb +1 -1
  69. data/lib/silex_ui.rb +8 -0
  70. metadata +31 -2
@@ -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="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>
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 hint.present? %>
6
- <span class="mt-1 block text-xs font-light text-ink-400"><%= hint %></span>
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
- <% if trend_text.present? %>
10
- <span class="mt-1 block text-xs font-light <%= trend_class %>"><%= trend_text %></span>
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
- # 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.
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
- 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)
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
- VALUE_TONES[@tone]
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
- # `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?
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
- arrow = if @trend[:value].to_f.zero?
40
- "="
41
- else
42
- @trend[:positive] ? "↑" : "↓"
43
- end
61
+ def trend_label
62
+ return "=" if trend_flat?
44
63
 
45
- [ arrow, @trend[:value], @trend[:suffix] ].compact.join(" ").strip
64
+ [ @trend[:value], @trend[:suffix] ].compact.join(" ")
46
65
  end
47
66
 
48
67
  def trend_class
49
- return "text-ink-400" if @trend.blank? || @trend[:value].to_f.zero?
68
+ return "text-ink-400" if trend_flat?
50
69
 
51
- @trend[:positive] ? "text-ok-700" : "text-alert-700"
70
+ @trend[:positive] ? "text-ok-500" : "text-alert-500"
52
71
  end
53
72
 
54
73
  def css_classes
55
74
  merge_class(
56
- "block bg-sand-50 px-6 py-8 transition duration-200",
57
- ("hover:bg-white" if href.present?)
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. La grille tient les séparateurs (`gap-px` sur fond
5
- # sable) pour que les tuiles se touchent sans bordure double.
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 call
29
- tag.div(safe_join(stats), class: css_classes)
22
+ def initialize(columns: 4, **options)
23
+ @columns = columns.to_i.clamp(1, 6)
24
+ super(**options)
30
25
  end
31
26
 
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
- )
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="overflow-x-auto">
4
+ <div class="<%= wrapper_classes %>">
5
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>
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-sand-100 align-top", @row_class&.call(row))
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
- return column.header unless column.sortable?
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
- link_to(column.sort, class: "inline-flex items-center gap-1 transition hover:text-ink-900") do
53
- safe_join([ column.header, tag.span(column.sort_marker, class: "text-ink-300") ], " ")
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-collapse text-sm font-light")
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
- "py-3 pr-4 font-light whitespace-nowrap", alignment
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("py-3 pr-4", alignment, cell_class).join(" ")
76
- end
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
- # ↑ / ↓ quand la colonne porte le tri courant, ↕ quand elle peut le prendre.
83
- def sort_marker
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-sand-100 py-6">
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-light text-ink-900"><%= title %></h3>
4
+ <h3 class="text-sm font-semibold text-ink-900"><%= title %></h3>
5
5
  <% if local_assigns[:note].present? %>
6
- <p class="text-xs font-light text-ink-400"><%= note %></p>
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-sm bg-sand-100 p-3 text-xs text-ink-600"><code><%= code %></code></pre>
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>