silex_ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +157 -0
  5. data/app/assets/javascripts/silex_ui/controllers/clipboard_controller.js +42 -0
  6. data/app/assets/javascripts/silex_ui/controllers/modal_controller.js +23 -0
  7. data/app/assets/javascripts/silex_ui/index.js +11 -0
  8. data/app/assets/stylesheets/silex_ui/theme.css +59 -0
  9. data/app/components/silex_ui/alert_component.html.erb +11 -0
  10. data/app/components/silex_ui/alert_component.rb +31 -0
  11. data/app/components/silex_ui/badge_component.rb +42 -0
  12. data/app/components/silex_ui/base_component.rb +32 -0
  13. data/app/components/silex_ui/breakdown_component.html.erb +17 -0
  14. data/app/components/silex_ui/breakdown_component.rb +27 -0
  15. data/app/components/silex_ui/button_component.rb +64 -0
  16. data/app/components/silex_ui/card_component.html.erb +22 -0
  17. data/app/components/silex_ui/card_component.rb +25 -0
  18. data/app/components/silex_ui/copy_button_component.rb +44 -0
  19. data/app/components/silex_ui/empty_state_component.rb +20 -0
  20. data/app/components/silex_ui/field_component.html.erb +18 -0
  21. data/app/components/silex_ui/field_component.rb +88 -0
  22. data/app/components/silex_ui/flash_component.html.erb +6 -0
  23. data/app/components/silex_ui/flash_component.rb +42 -0
  24. data/app/components/silex_ui/modal_component.html.erb +24 -0
  25. data/app/components/silex_ui/modal_component.rb +33 -0
  26. data/app/components/silex_ui/nav_component.html.erb +15 -0
  27. data/app/components/silex_ui/nav_component.rb +44 -0
  28. data/app/components/silex_ui/page_header_component.html.erb +17 -0
  29. data/app/components/silex_ui/page_header_component.rb +35 -0
  30. data/app/components/silex_ui/pagination_component.html.erb +30 -0
  31. data/app/components/silex_ui/pagination_component.rb +47 -0
  32. data/app/components/silex_ui/section_component.html.erb +12 -0
  33. data/app/components/silex_ui/section_component.rb +24 -0
  34. data/app/components/silex_ui/stat_card_component.html.erb +18 -0
  35. data/app/components/silex_ui/stat_card_component.rb +61 -0
  36. data/app/components/silex_ui/stat_grid_component.rb +41 -0
  37. data/app/components/silex_ui/table_component.html.erb +25 -0
  38. data/app/components/silex_ui/table_component.rb +98 -0
  39. data/config/importmap.rb +5 -0
  40. data/lib/generators/silex_ui/install/install_generator.rb +68 -0
  41. data/lib/silex_ui/engine.rb +31 -0
  42. data/lib/silex_ui/sync.rb +43 -0
  43. data/lib/silex_ui/version.rb +5 -0
  44. data/lib/silex_ui.rb +37 -0
  45. data/lib/tasks/silex_ui.rake +19 -0
  46. metadata +131 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 84ca6a3d2f412fa7ff808b0d7a0513e87257123a852e26dbd098795085b76ff3
4
+ data.tar.gz: e237663991a4a318eb7190b48c9b84c11eedbd69e18f027abf491bd1ee71fa87
5
+ SHA512:
6
+ metadata.gz: ba83d0d1d6ae11d0ca91c0e6009a5c2970dd90c513714d4bae1c8cf22b363f7789df8c5fae2771d7029856a017dcef72bcde92a93d8be2ae3fcd202226f3ae53
7
+ data.tar.gz: e37f9d24f41677ba8d0d06db94624e32a759b1478845915af2d92aa7abbb2bbd34ded84507b71b4fb5f0351261bf13fe86fa9544588b05cfc0b7146c5f1df8fa
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ Toutes les évolutions notables de silex_ui. Le format suit
4
+ [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le versionnage
5
+ [SemVer](https://semver.org/lang/fr/).
6
+
7
+ ## [Non publié]
8
+
9
+ ## [0.1.0] — 2026-09-10
10
+
11
+ Première version.
12
+
13
+ ### Ajouté
14
+
15
+ - Design tokens Tailwind v4 : familles `sand`, `ink`, `alert`, `warn`, `ok`, `info`.
16
+ - 16 ViewComponents : navigation, en-tête de page, section, carte, compteurs,
17
+ répartition, tableau, pagination, badge, alerte, flash, état vide, champ de
18
+ formulaire, bouton, bouton de copie, modale.
19
+ - Contrôleurs Stimulus `silex-ui--clipboard` et `silex-ui--modal`.
20
+ - Générateur `silex_ui:install` et tâche `silex_ui:sync`, branchée sur
21
+ `tailwindcss:build`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Théo Galais — Le Silex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # silex_ui
2
+
3
+ Design system partagé des applications Le Silex : **design tokens Tailwind v4**,
4
+ **ViewComponents** et **contrôleurs Stimulus**, dans un moteur Rails.
5
+
6
+ Une seule source de vérité pour la palette, la typographie et les composants —
7
+ au lieu d'un dossier de partials recopié d'une application à l'autre.
8
+
9
+ ## Prérequis
10
+
11
+ - Rails ≥ 7.1
12
+ - Tailwind CSS **v4** (`tailwindcss-rails` ≥ 4.0)
13
+ - ViewComponent ≥ 3.0
14
+
15
+ ## Installation
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ gem "silex_ui"
20
+ ```
21
+
22
+ ```bash
23
+ bundle install
24
+ bin/rails generate silex_ui:install
25
+ ```
26
+
27
+ Le générateur :
28
+
29
+ 1. copie les tokens dans `app/assets/tailwind/silex_ui/theme.css` ;
30
+ 2. copie une image plate des sources du gem dans `app/assets/tailwind/silex_ui/sources.html` ;
31
+ 3. déclare les deux fichiers dans `app/assets/tailwind/application.css`.
32
+
33
+ ```css
34
+ @import "tailwindcss";
35
+
36
+ @import "./silex_ui/theme.css";
37
+ @source "./silex_ui/sources.html";
38
+ ```
39
+
40
+ Les deux fichiers sont **versionnés avec l'application**. C'est délibéré : le
41
+ chemin d'installation d'un gem change entre le poste de dev, la CI et le
42
+ conteneur, alors qu'un `@source` doit pointer sur quelque chose de stable.
43
+
44
+ Après chaque montée de version du gem :
45
+
46
+ ```bash
47
+ bin/rails silex_ui:sync
48
+ ```
49
+
50
+ La tâche est branchée sur `tailwindcss:build`, donc un build de CSS repart
51
+ toujours de sources à jour.
52
+
53
+ ### Contrôleurs Stimulus
54
+
55
+ ```js
56
+ // app/javascript/controllers/index.js
57
+ import { registerSilexUiControllers } from "silex_ui"
58
+ registerSilexUiControllers(application)
59
+ ```
60
+
61
+ Ils sont enregistrés sous le préfixe `silex-ui--`, hors de portée des
62
+ contrôleurs de l'application.
63
+
64
+ ## Tokens
65
+
66
+ | Famille | Rôle | Nuances |
67
+ |---|---|---|
68
+ | `sand` | le sol : fond de page, bordures | 50 · 100 · 200 · 300 |
69
+ | `ink` | le texte, du plus discret au plus plein | 300 · 400 · 600 · 900 |
70
+ | `alert` | ce qui a lapsé, ce qui est cassé | 50 · 100 · 300 · 700 |
71
+ | `warn` | ce qui est sur le point de basculer | 50 · 100 · 300 · 700 |
72
+ | `ok` | ce qui est en règle | 50 · 100 · 300 · 700 |
73
+ | `info` | neutre, factuel, sans urgence | 50 · 100 · 300 · 700 |
74
+
75
+ Les tons sont **sémantiques** : on écrit `tone: :alert`, jamais `tone: :red`.
76
+ Le jour où l'alerte devient violette, une seule ligne bouge.
77
+
78
+ ## Composants
79
+
80
+ | Composant | Ce qu'il fait |
81
+ |---|---|
82
+ | `NavComponent` | barre de navigation : marque, sections, zone utilisateur |
83
+ | `PageHeaderComponent` | titre de page, sous-titre, actions — alimente aussi `content_for :title` |
84
+ | `SectionComponent` | bloc précédé d'un titre en petites capitales |
85
+ | `CardComponent` | surface surélevée, avec en-tête et pied optionnels |
86
+ | `StatGridComponent` / `StatCardComponent` | rangée de compteurs, avec lien, ton, évolution |
87
+ | `BreakdownComponent` | répartition libellé / compte, chaque ligne cliquable |
88
+ | `TableComponent` | tableau à colonnes déclarées, tri, état vide |
89
+ | `PaginationComponent` | navigation de pages, l'URL restant à la charge de l'appelant |
90
+ | `BadgeComponent` | pastille de statut |
91
+ | `AlertComponent` | bandeau d'information persistant |
92
+ | `FlashComponent` | messages flash de Rails traduits en tons |
93
+ | `EmptyStateComponent` | ce qu'on voit quand il n'y a rien à voir |
94
+ | `FieldComponent` | champ de formulaire : libellé, champ, aide, erreurs |
95
+ | `ButtonComponent` | bouton ou lien, quatre variantes |
96
+ | `CopyButtonComponent` | copie dans le presse-papiers, avec confirmation |
97
+ | `ModalComponent` | fenêtre modale sur `<dialog>` |
98
+
99
+ Tous acceptent `class:` : les classes de l'appelant sont ajoutées après celles
100
+ du composant, donc elles gagnent à spécificité égale.
101
+
102
+ ## Exemples
103
+
104
+ ```erb
105
+ <%= render SilexUi::NavComponent.new(brand: "Network", brand_href: root_path) do |nav| %>
106
+ <% nav.with_item(label: "Sites", href: websites_path, active: controller_name == "websites") %>
107
+ <% nav.with_aside do %><%= current_user.display_name %><% end %>
108
+ <% end %>
109
+
110
+ <%= render SilexUi::PageHeaderComponent.new(title: "Sites", subtitle: "1 240 domaines") do |header| %>
111
+ <% header.with_actions do %>
112
+ <%= render SilexUi::ButtonComponent.new(label: "Ajouter un domaine", href: new_website_path) %>
113
+ <% end %>
114
+ <% end %>
115
+
116
+ <%= render SilexUi::StatGridComponent.new do |grid| %>
117
+ <% grid.with_stat(value: @total, label: "Domaines", href: websites_path) %>
118
+ <% grid.with_stat(value: @expired, label: "Expirés", tone: :alert, href: websites_path(filter: "expire")) %>
119
+ <% end %>
120
+
121
+ <%= render SilexUi::TableComponent.new(rows: @websites) do |table| %>
122
+ <% table.with_column(header: "Domaine") { |site| site.domain } %>
123
+ <% table.with_column(header: "Registrar") { |site| site.registrar.titleize } %>
124
+ <% table.with_column(header: "", align: :right) do |site| %>
125
+ <%= render SilexUi::ButtonComponent.new(label: "Éditer", href: edit_website_path(site), variant: :ghost, size: :sm) %>
126
+ <% end %>
127
+ <% end %>
128
+ ```
129
+
130
+ ## Développement
131
+
132
+ ```bash
133
+ bundle install
134
+ bundle exec rspec
135
+ ```
136
+
137
+ Pour travailler sur le gem depuis une application, pointer le `Gemfile` de
138
+ celle-ci sur la copie locale :
139
+
140
+ ```ruby
141
+ gem "silex_ui", path: "../silex_ui"
142
+ ```
143
+
144
+ ## Publication
145
+
146
+ ```bash
147
+ # 1. faire monter SilexUi::VERSION et compléter le CHANGELOG
148
+ # 2. puis :
149
+ bundle exec rake release
150
+ ```
151
+
152
+ `rake release` pose le tag git, pousse la branche et envoie le `.gem` sur
153
+ rubygems.org. La MFA est exigée sur ce gem.
154
+
155
+ ## Licence
156
+
157
+ MIT — voir `LICENSE.txt`.
@@ -0,0 +1,42 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Copie une valeur dans le presse-papiers et le dit — un bouton qui ne confirme
4
+ // rien laisse l'utilisateur cliquer trois fois.
5
+ export default class extends Controller {
6
+ static values = {
7
+ value: String,
8
+ label: { type: String, default: "Copier" },
9
+ copiedLabel: { type: String, default: "Copié" },
10
+ resetAfter: { type: Number, default: 1500 }
11
+ }
12
+
13
+ async copy(event) {
14
+ event.preventDefault()
15
+
16
+ try {
17
+ await navigator.clipboard.writeText(this.valueValue)
18
+ this.#confirm()
19
+ } catch {
20
+ // Presse-papiers refusé (contexte non sécurisé, permission) : on laisse la
21
+ // valeur sélectionnable plutôt que de mentir sur le succès.
22
+ this.element.textContent = "Échec"
23
+ this.#restoreLater()
24
+ }
25
+ }
26
+
27
+ #confirm() {
28
+ this.element.textContent = this.copiedLabelValue
29
+ this.#restoreLater()
30
+ }
31
+
32
+ #restoreLater() {
33
+ clearTimeout(this.timeout)
34
+ this.timeout = setTimeout(() => {
35
+ this.element.textContent = this.labelValue
36
+ }, this.resetAfterValue)
37
+ }
38
+
39
+ disconnect() {
40
+ clearTimeout(this.timeout)
41
+ }
42
+ }
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Ouverture et fermeture d'un <dialog>. Le focus, la touche Échap et l'inertie
4
+ // du fond sont l'affaire du navigateur ; on ne réimplémente rien de tout ça.
5
+ export default class extends Controller {
6
+ static targets = ["dialog"]
7
+
8
+ open(event) {
9
+ event?.preventDefault()
10
+ this.dialogTarget.showModal()
11
+ }
12
+
13
+ close(event) {
14
+ event?.preventDefault()
15
+ this.dialogTarget.close()
16
+ }
17
+
18
+ // Un clic hors du panneau ferme : dans un <dialog>, ce clic atteint le dialog
19
+ // lui-même, jamais son contenu.
20
+ closeOnBackdrop(event) {
21
+ if (event.target === this.dialogTarget) this.dialogTarget.close()
22
+ }
23
+ }
@@ -0,0 +1,11 @@
1
+ import ClipboardController from "silex_ui/controllers/clipboard_controller"
2
+ import ModalController from "silex_ui/controllers/modal_controller"
3
+
4
+ // Enregistre les contrôleurs du design system sous le préfixe `silex-ui--`,
5
+ // pour qu'ils ne puissent jamais entrer en collision avec ceux de l'application.
6
+ export function registerSilexUiControllers(application) {
7
+ application.register("silex-ui--clipboard", ClipboardController)
8
+ application.register("silex-ui--modal", ModalController)
9
+ }
10
+
11
+ export { ClipboardController, ModalController }
@@ -0,0 +1,59 @@
1
+ /* Silex UI — design tokens.
2
+ *
3
+ * Une seule source de vérité pour la palette, la typographie et les rayons des
4
+ * applications Le Silex. Les noms suivent la convention Tailwind v4 : déclarer
5
+ * `--color-ink-900` suffit à rendre `text-ink-900`, `bg-ink-900`, `border-ink-900`
6
+ * disponibles dans l'application comme dans les composants du gem.
7
+ *
8
+ * Le ton : un fond sable chaud, une encre presque noire, et des accents qui se
9
+ * posent sur le sable au lieu de crier par-dessus. */
10
+
11
+ @theme {
12
+ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
13
+
14
+ /* Sable — le sol. Du plus clair (fond de page) au plus soutenu (bordures). */
15
+ --color-sand-50: #faf8f4;
16
+ --color-sand-100: #f4f0e8;
17
+ --color-sand-200: #e8e1d4;
18
+ --color-sand-300: #d6ccb9;
19
+
20
+ /* Encre — jamais un noir pur, du texte discret au texte plein. */
21
+ --color-ink-300: #bdb9b3;
22
+ --color-ink-400: #9a9691;
23
+ --color-ink-600: #6b6763;
24
+ --color-ink-900: #1c1a17;
25
+
26
+ /* Alerte — terracotta : ce qui a lapsé, ce qui est cassé. */
27
+ --color-alert-50: #fbf1ee;
28
+ --color-alert-100: #f2dcd5;
29
+ --color-alert-300: #d99e8c;
30
+ --color-alert-700: #9c3b26;
31
+
32
+ /* Attention — ocre : ce qui est sur le point de basculer. */
33
+ --color-warn-50: #fbf5e8;
34
+ --color-warn-100: #f0e5c8;
35
+ --color-warn-300: #d8bd7c;
36
+ --color-warn-700: #8a6318;
37
+
38
+ /* Validation — sauge : ce qui est en règle. */
39
+ --color-ok-50: #f1f5ef;
40
+ --color-ok-100: #dfe8da;
41
+ --color-ok-300: #a8bf9c;
42
+ --color-ok-700: #4a6b3d;
43
+
44
+ /* Information — bleu ardoise : neutre, factuel, sans urgence. */
45
+ --color-info-50: #eff2f6;
46
+ --color-info-100: #dde4ee;
47
+ --color-info-300: #9db0cc;
48
+ --color-info-700: #3c5578;
49
+ }
50
+
51
+ /* Rendu par défaut du document. L'application peut l'écraser, mais partir de là
52
+ * évite que chaque app redécouvre son fond et son antialiasing. */
53
+ @layer base {
54
+ body {
55
+ background-color: var(--color-sand-50);
56
+ color: var(--color-ink-900);
57
+ -webkit-font-smoothing: antialiased;
58
+ }
59
+ }
@@ -0,0 +1,11 @@
1
+ <div class="<%= css_classes %>" role="status">
2
+ <% if title.present? %>
3
+ <p class="text-xs font-light tracking-widest uppercase"><%= title %></p>
4
+ <% end %>
5
+
6
+ <% if text.present? %>
7
+ <p class="<%= "mt-1" if title.present? %>"><%= text %></p>
8
+ <% end %>
9
+
10
+ <%= content %>
11
+ </div>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Bandeau d'information persistant — l'AlertBannerComponent du Silex : un ton,
5
+ # un titre, un texte.
6
+ #
7
+ # <%= render SilexUi::AlertComponent.new(tone: :warn, title: "Synchronisation en retard",
8
+ # text: "Les registrars n'ont pas répondu depuis 3 jours.") %>
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"
16
+ }.freeze
17
+
18
+ def initialize(title: nil, text: nil, tone: :info, **options)
19
+ @title = title
20
+ @text = text
21
+ @tone = tone_of(tone)
22
+ super(**options)
23
+ end
24
+
25
+ attr_reader :title, :text
26
+
27
+ def css_classes
28
+ merge_class("rounded-sm border px-4 py-3 text-sm font-light", TONE_CLASSES[@tone])
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
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.
6
+ #
7
+ # SilexUi::BadgeComponent.new(label: "expiré depuis 3 j", tone: :alert)
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)
18
+ @label = label
19
+ @tone = tone_of(tone)
20
+ @href = href
21
+ @title = title.presence
22
+ super(**options)
23
+ end
24
+
25
+ def call
26
+ text = @label || content
27
+ attributes = html_attributes.merge(class: css_classes, title: @title)
28
+
29
+ @href.present? ? link_to(text, @href, **attributes) : tag.span(text, **attributes)
30
+ end
31
+
32
+ private
33
+
34
+ def css_classes
35
+ 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]
39
+ )
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Socle commun : la palette de tons et la fusion de classes.
5
+ #
6
+ # Chaque composant accepte `class:` pour ajuster son rendu sans le forker —
7
+ # les classes de l'appelant sont ajoutées après celles du composant, donc
8
+ # elles gagnent à spécificité égale.
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
13
+
14
+ def initialize(class: nil, **html_attributes)
15
+ @extra_class = binding.local_variable_get(:class)
16
+ @html_attributes = html_attributes
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :extra_class, :html_attributes
22
+
23
+ def tone_of(value)
24
+ TONES.include?(value&.to_sym) ? value.to_sym : :neutral
25
+ end
26
+
27
+ # `class_names` (alias de `token_list`) élimine les nil et les doublons.
28
+ def merge_class(*base)
29
+ class_names(*base, extra_class)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ <% if any? %>
2
+ <%= render SilexUi::SectionComponent.new(title: title) do %>
3
+ <ul class="<%= css_classes %>">
4
+ <% items.each do |item| %>
5
+ <li class="flex justify-between gap-4 border-b border-sand-100 pb-2">
6
+ <% if item[:href].present? %>
7
+ <%= link_to item[:label], item[:href], class: "truncate text-ink-600 transition hover:text-ink-900" %>
8
+ <% else %>
9
+ <span class="truncate text-ink-600"><%= item[:label] %></span>
10
+ <% end %>
11
+
12
+ <span class="shrink-0 text-ink-400"><%= item[:count] %></span>
13
+ </li>
14
+ <% end %>
15
+ </ul>
16
+ <% end %>
17
+ <% end %>
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Répartition : une liste libellé / compte, chaque ligne cliquable vers la
5
+ # liste filtrée correspondante. Le motif se répète sur tous les tableaux de
6
+ # bord du Silex (par hébergeur, par registrar, par tag).
7
+ #
8
+ # <%= render SilexUi::BreakdownComponent.new(title: "Par registrar",
9
+ # items: @by_registrar.map { |name, count| { label: name.titleize, count:, href: websites_path(registrar: name) } }) %>
10
+ class BreakdownComponent < BaseComponent
11
+ def initialize(items:, title: nil, **options)
12
+ @items = items
13
+ @title = title
14
+ super(**options)
15
+ end
16
+
17
+ attr_reader :items, :title
18
+
19
+ def any?
20
+ items.any?
21
+ end
22
+
23
+ def css_classes
24
+ merge_class("space-y-2 text-sm font-light")
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilexUi
4
+ # Bouton ou lien : même costume, trois usages.
5
+ #
6
+ # SilexUi::ButtonComponent.new(label: "Ajouter", href: new_website_path)
7
+ # SilexUi::ButtonComponent.new(label: "Supprimer", href: website_path(w), method: :delete, variant: :danger)
8
+ # SilexUi::ButtonComponent.new(label: "Appliquer", type: :submit)
9
+ #
10
+ # `method:` bascule sur `button_to` (formulaire + jeton CSRF) ; sans `href`,
11
+ # on rend un `<button>`.
12
+ class ButtonComponent < BaseComponent
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"
18
+ }.freeze
19
+
20
+ 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"
24
+ }.freeze
25
+
26
+ def initialize(label: nil, href: nil, variant: :primary, size: :md, method: nil, type: :button, **options)
27
+ @label = label
28
+ @href = href
29
+ @variant = VARIANTS.key?(variant.to_sym) ? variant.to_sym : :primary
30
+ @size = SIZES.key?(size.to_sym) ? size.to_sym : :md
31
+ @method = method
32
+ @type = type
33
+ super(**options)
34
+ end
35
+
36
+ def call
37
+ if @method.present? && @href.present?
38
+ button_to(text, @href, **attributes.merge(method: @method))
39
+ elsif @href.present?
40
+ link_to(text, @href, **attributes)
41
+ else
42
+ button_tag(text, **attributes.merge(type: @type))
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def text
49
+ @label || content
50
+ end
51
+
52
+ def attributes
53
+ html_attributes.merge(class: css_classes)
54
+ end
55
+
56
+ def css_classes
57
+ merge_class(
58
+ "inline-block cursor-pointer rounded-sm font-light uppercase tracking-widest transition",
59
+ SIZES[@size],
60
+ VARIANTS[@variant]
61
+ )
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,22 @@
1
+ <% body = capture do %>
2
+ <% if title.present? || header? %>
3
+ <div class="mb-3 flex items-baseline justify-between gap-4">
4
+ <% if title.present? %>
5
+ <h3 class="text-xs font-light tracking-widest text-ink-400 uppercase"><%= title %></h3>
6
+ <% end %>
7
+ <%= header %>
8
+ </div>
9
+ <% end %>
10
+
11
+ <div class="text-sm font-light text-ink-600"><%= content %></div>
12
+
13
+ <% if footer? %>
14
+ <div class="mt-4 border-t border-sand-100 pt-3 text-xs font-light text-ink-400"><%= footer %></div>
15
+ <% end %>
16
+ <% end %>
17
+
18
+ <% if href.present? %>
19
+ <%= link_to href, class: css_classes do %><%= body %><% end %>
20
+ <% else %>
21
+ <div class="<%= css_classes %>"><%= body %></div>
22
+ <% end %>
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
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.
6
+ class CardComponent < BaseComponent
7
+ renders_one :header
8
+ renders_one :footer
9
+
10
+ def initialize(title: nil, href: nil, **options)
11
+ @title = title
12
+ @href = href
13
+ super(**options)
14
+ end
15
+
16
+ attr_reader :title, :href
17
+
18
+ def css_classes
19
+ merge_class(
20
+ "block rounded-sm border border-sand-200 bg-white p-5",
21
+ ("transition hover:border-ink-900" if href.present?)
22
+ )
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
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
6
+ # Stimulus fourni par le gem.
7
+ #
8
+ # <%= render SilexUi::CopyButtonComponent.new(value: website.domain) %>
9
+ class CopyButtonComponent < BaseComponent
10
+ def initialize(value:, label: "Copier", copied_label: "Copié", tone: :neutral, **options)
11
+ @value = value.to_s
12
+ @label = label
13
+ @copied_label = copied_label
14
+ @tone = tone_of(tone)
15
+ super(**options)
16
+ end
17
+
18
+ def call
19
+ button_tag(@label, type: "button", class: css_classes, **stimulus_attributes)
20
+ end
21
+
22
+ private
23
+
24
+ def css_classes
25
+ 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]
29
+ )
30
+ end
31
+
32
+ def stimulus_attributes
33
+ html_attributes.deep_merge(
34
+ data: {
35
+ controller: "silex-ui--clipboard",
36
+ action: "silex-ui--clipboard#copy",
37
+ silex_ui__clipboard_value_value: @value,
38
+ silex_ui__clipboard_label_value: @label,
39
+ silex_ui__clipboard_copied_label_value: @copied_label
40
+ }
41
+ )
42
+ end
43
+ end
44
+ end