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
@@ -1,31 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SilexUi
4
- # Bandeau d'information persistant — l'AlertBannerComponent du Silex : un ton,
5
- # un titre, un texte.
4
+ # Bandeau d'information — l'AlertBannerComponent du Silex : un trait de
5
+ # couleur à gauche, une icône, un titre, un texte.
6
6
  #
7
- # <%= render SilexUi::AlertComponent.new(tone: :warn, title: "Synchronisation en retard",
8
- # text: "Les registrars n'ont pas répondu depuis 3 jours.") %>
7
+ # SilexUi::AlertComponent.new(tone: :warn, title: "Synchronisation en retard",
8
+ # text: "Les registrars n'ont pas répondu depuis 3 jours.")
9
9
  class AlertComponent < BaseComponent
10
- TONE_CLASSES = {
11
- neutral: "border-sand-300 bg-white text-ink-900",
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"
10
+ # Chaque ton porte son icône par défaut : un bandeau rouge sans signe est un
11
+ # bandeau qu'on lit trop tard.
12
+ DEFAULT_ICONS = {
13
+ neutral: "info", brand: "sparkles", ok: "success", warn: "alert",
14
+ alert: "error", info: "info", purple: "sparkles", pink: "sparkles",
15
+ teal: "info", cyan: "info"
16
16
  }.freeze
17
17
 
18
- def initialize(title: nil, text: nil, tone: :info, **options)
18
+ def initialize(title: nil, text: nil, tone: :info, icon: nil, **options)
19
19
  @title = title
20
20
  @text = text
21
21
  @tone = tone_of(tone)
22
+ @icon = icon
22
23
  super(**options)
23
24
  end
24
25
 
25
26
  attr_reader :title, :text
26
27
 
28
+ def icon_name
29
+ @icon.nil? ? DEFAULT_ICONS[@tone] : @icon.presence
30
+ end
31
+
32
+ def accent_class
33
+ SilexUi::Tones.accent(@tone)
34
+ end
35
+
27
36
  def css_classes
28
- merge_class("rounded-sm border px-4 py-3 text-sm font-light", TONE_CLASSES[@tone])
37
+ merge_class(
38
+ "relative flex gap-3 overflow-hidden rounded-surface border py-3 pr-4 pl-5 text-sm",
39
+ SilexUi::Tones.filled(@tone)
40
+ )
29
41
  end
30
42
  end
31
43
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Un avatar : une image quand il y en a une, les initiales sinon, posées sur
5
+ # le dégradé de marque — celui du rail du Silex.
6
+ #
7
+ # SilexUi::AvatarComponent.new(name: current_user.display_name)
8
+ # SilexUi::AvatarComponent.new(name: "Théo", src: user.avatar_url, size: :lg)
9
+ class AvatarComponent < BaseComponent
10
+ SIZES = {
11
+ sm: "size-8 text-xs",
12
+ md: "size-10 text-sm",
13
+ lg: "size-12 text-base"
14
+ }.freeze
15
+
16
+ def initialize(name: nil, src: nil, size: :md, square: false, **options)
17
+ @name = name.to_s
18
+ @src = src
19
+ @size = SIZES.key?(size.to_sym) ? size.to_sym : :md
20
+ @square = square
21
+ super(**options)
22
+ end
23
+
24
+ def call
25
+ return image_tag(@src, alt: @name, class: css_classes, **html_attributes) if @src.present?
26
+
27
+ tag.span(initials, title: @name.presence, class: class_names(css_classes, gradient), **html_attributes)
28
+ end
29
+
30
+ private
31
+
32
+ # Deux lettres : la première de chaque mot quand le nom en a plusieurs, les
33
+ # deux premières sinon. Un avatar à une lettre se confond trop vite.
34
+ def initials
35
+ words = @name.split(/[\s._-]+/).reject(&:empty?)
36
+
37
+ case words.size
38
+ when 0 then "?"
39
+ when 1 then words.first[0, 2].upcase
40
+ else words.first(2).map { |word| word[0] }.join.upcase
41
+ end
42
+ end
43
+
44
+ def gradient
45
+ "bg-gradient-to-br from-brand-500 to-brand-700 text-white"
46
+ end
47
+
48
+ def css_classes
49
+ merge_class(
50
+ "inline-flex shrink-0 items-center justify-center overflow-hidden font-semibold",
51
+ @square ? "rounded-surface" : "rounded-full",
52
+ SIZES[@size]
53
+ )
54
+ end
55
+ end
56
+ end
@@ -1,41 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SilexUi
4
- # Pastille de statut. Reprend l'API du BadgeComponent du Silex — un libellé, un
5
- # ton, un lien et une infobulle facultatifs — en tons sémantiques.
4
+ # Pastille de statut : un libellé, une icône, un ton. La forme est celle du
5
+ # Silex — hauteur fixe, coins nets, fond pastel et texte soutenu.
6
6
  #
7
- # SilexUi::BadgeComponent.new(label: "expiré depuis 3 j", tone: :alert)
7
+ # SilexUi::BadgeComponent.new(label: "expiré depuis 3 j", tone: :alert, icon: "alert")
8
8
  class BadgeComponent < BaseComponent
9
- TONE_CLASSES = {
10
- neutral: "border-sand-300 bg-white text-ink-600",
11
- ok: "border-ok-300 bg-ok-50 text-ok-700",
12
- warn: "border-warn-300 bg-warn-50 text-warn-700",
13
- alert: "border-alert-300 bg-alert-50 text-alert-700",
14
- info: "border-info-300 bg-info-50 text-info-700"
15
- }.freeze
16
-
17
- def initialize(label: nil, tone: :neutral, href: nil, title: nil, **options)
9
+ def initialize(label: nil, tone: :neutral, icon: nil, href: nil, title: nil, **options)
18
10
  @label = label
19
11
  @tone = tone_of(tone)
12
+ @icon = icon
20
13
  @href = href
21
14
  @title = title.presence
22
15
  super(**options)
23
16
  end
24
17
 
25
18
  def call
26
- text = @label || content
27
19
  attributes = html_attributes.merge(class: css_classes, title: @title)
28
20
 
29
- @href.present? ? link_to(text, @href, **attributes) : tag.span(text, **attributes)
21
+ @href.present? ? link_to(body, @href, **attributes) : tag.span(body, **attributes)
30
22
  end
31
23
 
32
24
  private
33
25
 
26
+ def body
27
+ safe_join([ icon(@icon, size: :xs), (@label || content) ].compact)
28
+ end
29
+
30
+ # Sans libellé, la pastille se referme sur son icône : un carré, pas un
31
+ # rectangle à moitié vide.
32
+ def icon_only?
33
+ @label.blank? && !content? && @icon.present?
34
+ end
35
+
34
36
  def css_classes
35
37
  merge_class(
36
- "inline-block rounded-sm border px-2 py-0.5 text-xs font-light whitespace-nowrap",
37
- ("transition hover:border-ink-900 hover:text-ink-900" if @href.present?),
38
- TONE_CLASSES[@tone]
38
+ "inline-flex h-7 items-center justify-center gap-1.5 rounded-badge border",
39
+ "text-[13px] leading-none font-semibold whitespace-nowrap",
40
+ icon_only? ? "w-7 px-0" : "px-3",
41
+ ("transition hover:brightness-95" if @href.present?),
42
+ SilexUi::Tones.filled(@tone)
39
43
  )
40
44
  end
41
45
  end
@@ -7,9 +7,10 @@ module SilexUi
7
7
  # les classes de l'appelant sont ajoutées après celles du composant, donc
8
8
  # elles gagnent à spécificité égale.
9
9
  class BaseComponent < ViewComponent::Base
10
- # Les tons sont sémantiques, jamais chromatiques : on écrit `tone: :alert`,
11
- # pas `tone: :red`. Le jour où l'alerte devient violette, une seule ligne bouge.
12
- TONES = %i[neutral ok warn alert info].freeze
10
+ # Les cinq premiers tons sont sémantiques : on écrit `tone: :alert`, pas
11
+ # `tone: :red`. Les quatre suivants ne disent rien d'un état — ils servent à
12
+ # distinguer des familles de choses, comme les pastilles du Silex.
13
+ TONES = %i[neutral brand ok warn alert info purple pink teal cyan].freeze
13
14
 
14
15
  def initialize(class: nil, **html_attributes)
15
16
  @extra_class = binding.local_variable_get(:class)
@@ -28,5 +29,11 @@ module SilexUi
28
29
  def merge_class(*base)
29
30
  class_names(*base, extra_class)
30
31
  end
32
+
33
+ def icon(name, **options)
34
+ return nil if name.blank?
35
+
36
+ render SilexUi::IconComponent.new(name: name, **options)
37
+ end
31
38
  end
32
39
  end
@@ -1,17 +1,31 @@
1
1
  <% if any? %>
2
- <%= render SilexUi::SectionComponent.new(title: title) do %>
2
+ <%= render SilexUi::SectionComponent.new(title: title, icon: icon_name) do %>
3
3
  <ul class="<%= css_classes %>">
4
4
  <% items.each do |item| %>
5
- <li class="flex justify-between gap-4 border-b border-sand-100 pb-2">
5
+ <li>
6
6
  <% if item[:href].present? %>
7
- <%= link_to item[:label], item[:href], class: "truncate text-ink-600 transition hover:text-ink-900" %>
7
+ <%= link_to item[:href], class: "flex items-center justify-between gap-4 rounded-badge px-3 py-2 transition hover:bg-surface-100" do %>
8
+ <span class="truncate text-ink-700"><%= item[:label] %></span>
9
+ <span class="shrink-0 font-semibold text-ink-500"><%= item[:count] %></span>
10
+ <% end %>
8
11
  <% else %>
9
- <span class="truncate text-ink-600"><%= item[:label] %></span>
12
+ <div class="flex items-center justify-between gap-4 px-3 py-2">
13
+ <span class="truncate text-ink-700"><%= item[:label] %></span>
14
+ <span class="shrink-0 font-semibold text-ink-500"><%= item[:count] %></span>
15
+ </div>
10
16
  <% end %>
11
-
12
- <span class="shrink-0 text-ink-400"><%= item[:count] %></span>
13
17
  </li>
14
18
  <% end %>
15
19
  </ul>
20
+
21
+ <% if hidden_count.positive? %>
22
+ <p class="mt-2 px-3 text-[13px] text-ink-400">
23
+ <% if more_href.present? %>
24
+ <%= link_to "+ #{hidden_count} autres", more_href, class: "font-semibold text-brand-600 transition hover:text-brand-700" %>
25
+ <% else %>
26
+ + <%= hidden_count %> autres
27
+ <% end %>
28
+ </p>
29
+ <% end %>
16
30
  <% end %>
17
31
  <% end %>
@@ -8,20 +8,37 @@ module SilexUi
8
8
  # <%= render SilexUi::BreakdownComponent.new(title: "Par registrar",
9
9
  # items: @by_registrar.map { |name, count| { label: name.titleize, count:, href: websites_path(registrar: name) } }) %>
10
10
  class BreakdownComponent < BaseComponent
11
- def initialize(items:, title: nil, **options)
11
+ def initialize(items:, title: nil, icon: nil, limit: nil, more_href: nil, **options)
12
12
  @items = items
13
13
  @title = title
14
+ @icon = icon
15
+ @limit = limit
16
+ @more_href = more_href
14
17
  super(**options)
15
18
  end
16
19
 
17
- attr_reader :items, :title
20
+ attr_reader :title, :icon_name, :more_href
21
+
22
+ def icon_name = @icon
23
+
24
+ # Une répartition qui déroule quarante lignes n'est plus une répartition.
25
+ # Au-delà de la limite, on annonce le reste au lieu de l'étaler.
26
+ def items
27
+ @limit ? @items.first(@limit) : @items
28
+ end
29
+
30
+ def hidden_count
31
+ return 0 if @limit.nil?
32
+
33
+ [ @items.size - @limit, 0 ].max
34
+ end
18
35
 
19
36
  def any?
20
- items.any?
37
+ @items.any?
21
38
  end
22
39
 
23
40
  def css_classes
24
- merge_class("space-y-2 text-sm font-light")
41
+ merge_class("-mx-3 divide-y divide-surface-200 text-sm")
25
42
  end
26
43
  end
27
44
  end
@@ -3,7 +3,7 @@
3
3
  module SilexUi
4
4
  # Bouton ou lien : même costume, trois usages.
5
5
  #
6
- # SilexUi::ButtonComponent.new(label: "Ajouter", href: new_website_path)
6
+ # SilexUi::ButtonComponent.new(label: "Ajouter", href: new_website_path, icon: "plus")
7
7
  # SilexUi::ButtonComponent.new(label: "Supprimer", href: website_path(w), method: :delete, variant: :danger)
8
8
  # SilexUi::ButtonComponent.new(label: "Appliquer", type: :submit)
9
9
  #
@@ -11,23 +11,33 @@ module SilexUi
11
11
  # on rend un `<button>`.
12
12
  class ButtonComponent < BaseComponent
13
13
  VARIANTS = {
14
- primary: "bg-ink-900 text-sand-50 hover:bg-ink-600",
15
- secondary: "border border-sand-300 bg-white text-ink-900 hover:border-ink-900",
16
- ghost: "text-ink-400 hover:text-ink-900",
17
- danger: "border border-alert-300 bg-alert-50 text-alert-700 hover:border-alert-700"
14
+ primary: "border-transparent bg-gradient-to-br from-brand-500 to-brand-700 text-white " \
15
+ "shadow-card hover:shadow-card-hover hover:-translate-y-px",
16
+ secondary: "border-surface-200 bg-surface-0 text-ink-700 shadow-card " \
17
+ "hover:border-brand-500 hover:text-brand-600",
18
+ ghost: "border-transparent bg-transparent text-ink-500 hover:bg-surface-100 hover:text-brand-600",
19
+ danger: "border-alert-300 bg-alert-50 text-alert-700 hover:border-alert-500 hover:bg-alert-100"
18
20
  }.freeze
19
21
 
20
22
  SIZES = {
21
- sm: "px-3 py-1 text-xs",
22
- md: "px-4 py-2 text-xs",
23
- lg: "px-6 py-3 text-sm"
23
+ sm: "h-8 gap-1.5 px-3 text-xs",
24
+ md: "h-10 gap-2 px-4 text-sm",
25
+ lg: "h-12 gap-2.5 px-6 text-base"
24
26
  }.freeze
25
27
 
26
- def initialize(label: nil, href: nil, variant: :primary, size: :md, method: nil, type: :button, **options)
28
+ # Sans libellé, le bouton se referme sur son icône : un carré, pas un
29
+ # rectangle à moitié vide.
30
+ SQUARE_SIZES = { sm: "size-8", md: "size-11", lg: "size-12" }.freeze
31
+
32
+ ICON_SIZES = { sm: :sm, md: :sm, lg: :md }.freeze
33
+
34
+ def initialize(label: nil, href: nil, variant: :primary, size: :md, icon: nil, method: nil,
35
+ type: :button, **options)
27
36
  @label = label
28
37
  @href = href
29
38
  @variant = VARIANTS.key?(variant.to_sym) ? variant.to_sym : :primary
30
39
  @size = SIZES.key?(size.to_sym) ? size.to_sym : :md
40
+ @icon = icon
31
41
  @method = method
32
42
  @type = type
33
43
  super(**options)
@@ -35,28 +45,33 @@ module SilexUi
35
45
 
36
46
  def call
37
47
  if @method.present? && @href.present?
38
- button_to(text, @href, **attributes.merge(method: @method))
48
+ button_to(body, @href, **attributes.merge(method: @method))
39
49
  elsif @href.present?
40
- link_to(text, @href, **attributes)
50
+ link_to(body, @href, **attributes)
41
51
  else
42
- button_tag(text, **attributes.merge(type: @type))
52
+ button_tag(body, **attributes.merge(type: @type))
43
53
  end
44
54
  end
45
55
 
46
56
  private
47
57
 
48
- def text
49
- @label || content
58
+ def body
59
+ safe_join([ icon(@icon, size: square? ? :md : ICON_SIZES[@size]), (@label || content) ].compact)
50
60
  end
51
61
 
52
62
  def attributes
53
63
  html_attributes.merge(class: css_classes)
54
64
  end
55
65
 
66
+ def square?
67
+ @label.blank? && !content? && @icon.present?
68
+ end
69
+
56
70
  def css_classes
57
71
  merge_class(
58
- "inline-block cursor-pointer rounded-sm font-light uppercase tracking-widest transition",
59
- SIZES[@size],
72
+ "inline-flex cursor-pointer items-center justify-center rounded-surface border font-semibold",
73
+ "transition duration-200 disabled:cursor-default disabled:opacity-50",
74
+ square? ? SQUARE_SIZES[@size] : SIZES[@size],
60
75
  VARIANTS[@variant]
61
76
  )
62
77
  end
@@ -1,17 +1,20 @@
1
1
  <% body = capture do %>
2
2
  <% if title.present? || header? %>
3
- <div class="mb-3 flex items-baseline justify-between gap-4">
3
+ <div class="mb-3 flex flex-wrap items-center justify-between gap-4">
4
4
  <% if title.present? %>
5
- <h3 class="text-xs font-light tracking-widest text-ink-400 uppercase"><%= title %></h3>
5
+ <h3 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
+ </h3>
6
9
  <% end %>
7
10
  <%= header %>
8
11
  </div>
9
12
  <% end %>
10
13
 
11
- <div class="text-sm font-light text-ink-600"><%= content %></div>
14
+ <div class="text-sm text-ink-700"><%= content %></div>
12
15
 
13
16
  <% if footer? %>
14
- <div class="mt-4 border-t border-sand-100 pt-3 text-xs font-light text-ink-400"><%= footer %></div>
17
+ <div class="mt-4 border-t border-surface-200 pt-3 text-xs text-ink-500"><%= footer %></div>
15
18
  <% end %>
16
19
  <% end %>
17
20
 
@@ -1,24 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SilexUi
4
- # Surface surélevée : un encadré blanc sur le sable. Sert de conteneur à tout
5
- # ce qui doit se détacher du flux — panneau de synchronisation, fiche, aperçu.
4
+ # Surface surélevée : la carte blanche du Silex, coins arrondis et ombre
5
+ # posée. Sert de conteneur à tout ce qui doit se détacher du fond.
6
6
  class CardComponent < BaseComponent
7
7
  renders_one :header
8
8
  renders_one :footer
9
9
 
10
- def initialize(title: nil, href: nil, **options)
10
+ def initialize(title: nil, icon: nil, href: nil, **options)
11
11
  @title = title
12
+ @icon = icon
12
13
  @href = href
13
14
  super(**options)
14
15
  end
15
16
 
16
- attr_reader :title, :href
17
+ attr_reader :title, :href, :icon_name
18
+
19
+ def icon_name = @icon
17
20
 
18
21
  def css_classes
19
22
  merge_class(
20
- "block rounded-sm border border-sand-200 bg-white p-5",
21
- ("transition hover:border-ink-900" if href.present?)
23
+ "block rounded-surface border border-surface-200 bg-surface-0 p-5 shadow-card transition duration-300",
24
+ ("hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-card-hover" if href.present?)
22
25
  )
23
26
  end
24
27
  end
@@ -1,31 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SilexUi
4
- # Copier une valeur sans la sélectionner à la souris. Visuellement un badge,
5
- # comme le CopyButtonComponent du Silex, mais branché sur le contrôleur
4
+ # Copier une valeur sans la sélectionner à la souris. Visuellement une
5
+ # pastille, comme le CopyButtonComponent du Silex, branchée sur le contrôleur
6
6
  # Stimulus fourni par le gem.
7
7
  #
8
8
  # <%= render SilexUi::CopyButtonComponent.new(value: website.domain) %>
9
9
  class CopyButtonComponent < BaseComponent
10
- def initialize(value:, label: "Copier", copied_label: "Copié", tone: :neutral, **options)
10
+ def initialize(value:, label: nil, title: "Copier", copied_label: "Copié", tone: :neutral, **options)
11
11
  @value = value.to_s
12
12
  @label = label
13
+ @title = title
13
14
  @copied_label = copied_label
14
15
  @tone = tone_of(tone)
15
16
  super(**options)
16
17
  end
17
18
 
18
19
  def call
19
- button_tag(@label, type: "button", class: css_classes, **stimulus_attributes)
20
+ button_tag(body, type: "button", title: @title, class: css_classes, **stimulus_attributes)
20
21
  end
21
22
 
22
23
  private
23
24
 
25
+ def body
26
+ safe_join([
27
+ icon("copy", size: :xs, class: "silex-ui-copy-icon"),
28
+ (tag.span(@label, data: { silex_ui__clipboard_target: "label" }) if @label.present?)
29
+ ].compact)
30
+ end
31
+
24
32
  def css_classes
25
33
  merge_class(
26
- "inline-block cursor-pointer rounded-sm border px-2 py-0.5 text-xs font-light whitespace-nowrap",
27
- "transition hover:border-ink-900 hover:text-ink-900",
28
- SilexUi::BadgeComponent::TONE_CLASSES[@tone]
34
+ "inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-badge border",
35
+ "text-[13px] leading-none font-semibold whitespace-nowrap transition hover:brightness-95",
36
+ @label.present? ? "px-3" : "w-7 px-0",
37
+ SilexUi::Tones.filled(@tone)
29
38
  )
30
39
  end
31
40
 
@@ -35,7 +44,7 @@ module SilexUi
35
44
  controller: "silex-ui--clipboard",
36
45
  action: "silex-ui--clipboard#copy",
37
46
  silex_ui__clipboard_value_value: @value,
38
- silex_ui__clipboard_label_value: @label,
47
+ silex_ui__clipboard_label_value: @label.to_s,
39
48
  silex_ui__clipboard_copied_label_value: @copied_label
40
49
  }
41
50
  )
@@ -0,0 +1,13 @@
1
+ <div class="<%= css_classes %>" data-controller="silex-ui--dropdown"
2
+ data-action="click@window->silex-ui--dropdown#closeOnOutsideClick keydown.esc@window->silex-ui--dropdown#close">
3
+ <div data-action="click->silex-ui--dropdown#toggle"><%= trigger %></div>
4
+
5
+ <div class="<%= menu_classes %>" data-silex-ui--dropdown-target="menu" role="menu">
6
+ <% if header? %>
7
+ <div class="border-b border-surface-200 px-3 py-2.5"><%= header %></div>
8
+ <% end %>
9
+
10
+ <%= safe_join(items) %>
11
+ <%= content %>
12
+ </div>
13
+ </div>
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Un menu qui s'ouvre au clic et se ferme dès qu'on regarde ailleurs.
5
+ #
6
+ # <%= render SilexUi::DropdownComponent.new(placement: :up) do |menu| %>
7
+ # <% menu.with_trigger do %><%= render SilexUi::AvatarComponent.new(name: "Théo") %><% end %>
8
+ # <% menu.with_header do %>…<% end %>
9
+ # <% menu.with_item(label: "Profil", href: profile_path, icon: "users") %>
10
+ # <% menu.with_item(label: "Déconnexion", href: logout_path, method: :delete, icon: "close", tone: :alert) %>
11
+ # <% end %>
12
+ class DropdownComponent < BaseComponent
13
+ renders_one :trigger
14
+ renders_one :header
15
+ renders_many :items, "ItemComponent"
16
+
17
+ PLACEMENTS = {
18
+ down: "top-full left-0 mt-2",
19
+ down_end: "top-full right-0 mt-2",
20
+ up: "bottom-full left-0 mb-2",
21
+ up_end: "bottom-full right-0 mb-2",
22
+ right: "bottom-0 left-full ml-2"
23
+ }.freeze
24
+
25
+ def initialize(placement: :down, width: "w-70", **options)
26
+ @placement = PLACEMENTS.key?(placement.to_sym) ? placement.to_sym : :down
27
+ @width = width
28
+ super(**options)
29
+ end
30
+
31
+ def menu_classes
32
+ class_names(
33
+ "absolute z-50 hidden overflow-hidden rounded-surface border border-surface-200",
34
+ "bg-surface-0 py-1 shadow-card-hover", @width, PLACEMENTS[@placement]
35
+ )
36
+ end
37
+
38
+ def css_classes
39
+ merge_class("relative")
40
+ end
41
+
42
+ # Une entrée : une icône, un libellé, et un ton pour celles qui coupent.
43
+ class ItemComponent < ViewComponent::Base
44
+ def initialize(label:, href: nil, icon: nil, method: nil, tone: :neutral)
45
+ @label = label
46
+ @href = href
47
+ @icon = icon
48
+ @method = method
49
+ @tone = tone
50
+ end
51
+
52
+ def call
53
+ body = safe_join([ icon_tag, tag.span(@label) ].compact)
54
+
55
+ if @method.present?
56
+ # Forme à bloc : `button_to` avec une chaîne rend un `<input>` dont la
57
+ # valeur est échappée, et l'icône y finirait en texte.
58
+ button_to(@href, method: @method, class: css_classes, form_class: "block w-full") { body }
59
+ elsif @href.present?
60
+ link_to(body, @href, class: css_classes)
61
+ else
62
+ tag.span(body, class: css_classes)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def icon_tag
69
+ return nil if @icon.blank?
70
+
71
+ render SilexUi::IconComponent.new(name: @icon, size: :sm)
72
+ end
73
+
74
+ def css_classes
75
+ class_names(
76
+ "flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition",
77
+ @tone.to_sym == :alert ? "text-alert-700 hover:bg-alert-50" : "text-ink-700 hover:bg-surface-100"
78
+ )
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,13 @@
1
+ <div class="<%= merge_class("flex flex-col items-center justify-center gap-3 px-6 py-16 text-center") %>">
2
+ <% if icon_name.present? %>
3
+ <span class="flex size-14 items-center justify-center rounded-full bg-surface-100 text-ink-400">
4
+ <%= render SilexUi::IconComponent.new(name: icon_name, size: :xl) %>
5
+ </span>
6
+ <% end %>
7
+
8
+ <p class="text-sm text-ink-500"><%= message %></p>
9
+
10
+ <% if content? %>
11
+ <div class="mt-1"><%= content %></div>
12
+ <% end %>
13
+ </div>
@@ -1,20 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Ce qu'on voit quand il n'y a rien à voir : une icône pâle, un message, et si
5
+ # possible la seule action qui remplit l'écran.
6
6
  class EmptyStateComponent < BaseComponent
7
- def initialize(message:, **options)
7
+ def initialize(message:, icon: "folder", **options)
8
8
  @message = message
9
+ @icon = icon
9
10
  super(**options)
10
11
  end
11
12
 
12
- attr_reader :message
13
+ attr_reader :message, :icon_name
13
14
 
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
15
+ def icon_name = @icon
19
16
  end
20
17
  end
@@ -1,18 +1,18 @@
1
1
  <div class="<%= css_classes %>">
2
2
  <% if boolean? %>
3
3
  <%= input %>
4
- <%= form.label attribute, label_text, class: "text-sm font-light text-ink-600" %>
4
+ <%= form.label attribute, label_text, class: "cursor-pointer text-sm text-ink-700" %>
5
5
  <% else %>
6
6
  <%= form.label attribute, label_text,
7
- class: "mb-2 block text-xs font-light tracking-widest text-ink-600 uppercase" %>
7
+ class: "mb-1.5 block text-[13px] font-semibold text-ink-700" %>
8
8
  <%= input %>
9
9
  <% end %>
10
10
 
11
11
  <% if hint.present? %>
12
- <p class="mt-1 text-xs font-light text-ink-400"><%= hint %></p>
12
+ <p class="mt-1 text-xs text-ink-400"><%= hint %></p>
13
13
  <% end %>
14
14
 
15
15
  <% if errors? %>
16
- <p class="mt-1 text-xs font-light text-alert-700"><%= errors.to_sentence %></p>
16
+ <p class="mt-1 text-xs font-semibold text-alert-700"><%= errors.to_sentence %></p>
17
17
  <% end %>
18
18
  </div>