kozenet_ui 0.1.4 → 0.1.6

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +79 -16
  4. data/app/assets/fonts/kozenet_ui/inter-latin.woff2 +0 -0
  5. data/app/assets/fonts/kozenet_ui/jetbrains-mono-latin.woff2 +0 -0
  6. data/app/assets/fonts/kozenet_ui/source-serif-4-latin.woff2 +0 -0
  7. data/app/assets/javascripts/kozenet_ui/index.js +226 -17
  8. data/app/assets/stylesheets/kozenet_ui/base.css +5 -0
  9. data/app/assets/stylesheets/kozenet_ui/components/avatar.css +4 -1
  10. data/app/assets/stylesheets/kozenet_ui/components/badge.css +11 -1
  11. data/app/assets/stylesheets/kozenet_ui/components/button.css +21 -1
  12. data/app/assets/stylesheets/kozenet_ui/components/header.css +227 -38
  13. data/app/assets/stylesheets/kozenet_ui/components/utilities.css +52 -1
  14. data/app/assets/stylesheets/kozenet_ui/fonts.css +37 -0
  15. data/app/assets/stylesheets/kozenet_ui/tokens.css +150 -53
  16. data/app/components/kozenet_ui/base_component.rb +21 -1
  17. data/app/components/kozenet_ui/header_component/action_button_component.html.erb +4 -2
  18. data/app/components/kozenet_ui/header_component/action_button_component.rb +78 -4
  19. data/app/components/kozenet_ui/header_component/nav_item_component.html.erb +1 -1
  20. data/app/components/kozenet_ui/header_component/nav_item_component.rb +6 -0
  21. data/app/components/kozenet_ui/header_component/user_menu_component.html.erb +5 -7
  22. data/app/components/kozenet_ui/header_component.html.erb +39 -30
  23. data/app/components/kozenet_ui/header_component.rb +26 -4
  24. data/app/helpers/kozenet_ui/component_helper.rb +82 -8
  25. data/app/helpers/kozenet_ui/icon_helper.rb +11 -7
  26. data/docs/README.md +25 -0
  27. data/docs/components/README.md +44 -0
  28. data/docs/components/avatar.md +73 -0
  29. data/docs/components/badge.md +74 -0
  30. data/docs/components/button.md +95 -0
  31. data/docs/components/header.md +199 -0
  32. data/docs/foundations/README.md +14 -0
  33. data/docs/foundations/fonts.md +136 -0
  34. data/lib/generators/kozenet_ui/install/install_generator.rb +94 -8
  35. data/lib/generators/kozenet_ui/install/templates/kozenet_ui.rb +3 -0
  36. data/lib/kozenet_ui/configuration.rb +35 -0
  37. data/lib/kozenet_ui/engine.rb +89 -14
  38. data/lib/kozenet_ui/theme/palette.rb +21 -7
  39. data/lib/kozenet_ui/theme/variants.rb +1 -0
  40. data/lib/kozenet_ui/version.rb +1 -1
  41. data/lib/kozenet_ui.rb +2 -0
  42. metadata +34 -13
  43. data/app/assets/images/kozenet_ui/icons/cart.svg +0 -1
  44. data/app/assets/images/kozenet_ui/icons/heart.svg +0 -1
@@ -3,6 +3,8 @@
3
3
 
4
4
  @layer base {
5
5
  :root {
6
+ color-scheme: light;
7
+
6
8
  /* Spacing */
7
9
  --kz-spacing-xs: 0.25rem;
8
10
  --kz-spacing-sm: 0.5rem;
@@ -54,6 +56,7 @@
54
56
 
55
57
  /* Default Light Mode Colors */
56
58
  --kz-bg-base: #ffffff;
59
+ --kz-bg-default: var(--kz-bg-base);
57
60
  --kz-bg-elevated: #f8fafc;
58
61
  --kz-bg-muted: #f1f5f9;
59
62
  --kz-text-default: #0f172a;
@@ -69,15 +72,15 @@
69
72
  --kz-cta-text: #fff;
70
73
  --kz-cta-text-dark: #fff;
71
74
 
72
- --bg-default: #ffffff;
73
- --bg-muted: #f8fafc;
74
- --bg-elevated: #ffffff;
75
+ --bg-default: var(--kz-bg-base);
76
+ --bg-muted: var(--kz-bg-muted);
77
+ --bg-elevated: var(--kz-bg-elevated);
75
78
  --bg-accent: #0ea5e9;
76
79
  --bg-accent-hover: #0284c7;
77
- --text-default: #0f172a;
78
- --text-muted: #64748b;
79
- --border-default: #e2e8f0;
80
- --border-strong: #cbd5e1;
80
+ --text-default: var(--kz-text-default);
81
+ --text-muted: var(--kz-text-muted);
82
+ --border-default: var(--kz-border-default);
83
+ --border-strong: var(--kz-border-default);
81
84
  --focus-ring: #0ea5e9;
82
85
  --radius-xs: 2px;
83
86
  --radius-sm: 4px;
@@ -98,26 +101,28 @@
98
101
  /* Dark Mode */
99
102
  [data-theme="dark"],
100
103
  .dark {
101
- --kz-bg-base: #0f172a;
102
- --kz-bg-elevated: #1e293b;
103
- --kz-bg-muted: #334155;
104
- --kz-text-default: #f1f5f9;
105
- --kz-text-muted: #94a3b8;
106
- --kz-border-default: #334155;
107
- --kz-border-muted: #1e293b;
104
+ color-scheme: dark;
105
+ --kz-bg-base: #101318;
106
+ --kz-bg-default: var(--kz-bg-base);
107
+ --kz-bg-elevated: #181c23;
108
+ --kz-bg-muted: #242a35;
109
+ --kz-text-default: #f8fafc;
110
+ --kz-text-muted: #cbd5e1;
111
+ --kz-border-default: #303744;
112
+ --kz-border-muted: #242a35;
108
113
 
109
114
  --kz-cta-gradient: var(--kz-cta-gradient-dark);
110
115
  --kz-cta-text: var(--kz-cta-text-dark);
111
116
 
112
- --bg-default: #0b0d11;
113
- --bg-muted: #11151c;
114
- --bg-elevated: rgba(23,28,36,0.9);
117
+ --bg-default: var(--kz-bg-base);
118
+ --bg-muted: var(--kz-bg-muted);
119
+ --bg-elevated: var(--kz-bg-elevated);
115
120
  --bg-accent: #0284c7;
116
121
  --bg-accent-hover: #0369a1;
117
- --text-default: #f1f5f9;
118
- --text-muted: #64748b;
119
- --border-default: #1f242c;
120
- --border-strong: #2a3039;
122
+ --text-default: var(--kz-text-default);
123
+ --text-muted: var(--kz-text-muted);
124
+ --border-default: var(--kz-border-default);
125
+ --border-strong: var(--kz-border-default);
121
126
  --focus-ring: #0ea5e9;
122
127
  --shadow-color: 210 40% 2%;
123
128
  --gradient-base-from: #0b0d11;
@@ -129,40 +134,132 @@
129
134
  --gradient-spot-2: rgba(99,102,241,0.18);
130
135
  }
131
136
 
137
+ @media (prefers-color-scheme: dark) {
138
+ :root:not([data-theme="light"]):not(.light) {
139
+ color-scheme: dark;
140
+ --kz-bg-base: #101318;
141
+ --kz-bg-default: var(--kz-bg-base);
142
+ --kz-bg-elevated: #181c23;
143
+ --kz-bg-muted: #242a35;
144
+ --kz-text-default: #f8fafc;
145
+ --kz-text-muted: #cbd5e1;
146
+ --kz-border-default: #303744;
147
+ --kz-border-muted: #242a35;
148
+
149
+ --kz-cta-gradient: var(--kz-cta-gradient-dark);
150
+ --kz-cta-text: var(--kz-cta-text-dark);
151
+
152
+ --bg-default: var(--kz-bg-base);
153
+ --bg-muted: var(--kz-bg-muted);
154
+ --bg-elevated: var(--kz-bg-elevated);
155
+ --bg-accent: #0284c7;
156
+ --bg-accent-hover: #0369a1;
157
+ --text-default: var(--kz-text-default);
158
+ --text-muted: var(--kz-text-muted);
159
+ --border-default: var(--kz-border-default);
160
+ --border-strong: var(--kz-border-default);
161
+ --focus-ring: #0ea5e9;
162
+ --shadow-color: 210 40% 2%;
163
+ --gradient-base-from: #0b0d11;
164
+ --gradient-base-to: #11151c;
165
+ --gradient-accent-from: #1e40af;
166
+ --gradient-accent-via: #0369a1;
167
+ --gradient-accent-to: #0891b2;
168
+ --gradient-spot-1: rgba(56,189,248,0.20);
169
+ --gradient-spot-2: rgba(99,102,241,0.18);
170
+ }
171
+ }
172
+
173
+ [data-theme="light"],
174
+ .light {
175
+ color-scheme: light;
176
+ --kz-bg-base: #ffffff;
177
+ --kz-bg-default: var(--kz-bg-base);
178
+ --kz-bg-elevated: #f8fafc;
179
+ --kz-bg-muted: #f1f5f9;
180
+ --kz-text-default: #0f172a;
181
+ --kz-text-muted: #64748b;
182
+ --kz-border-default: #e2e8f0;
183
+ --kz-border-muted: #f1f5f9;
184
+
185
+ --kz-cta-gradient: linear-gradient(110deg, #6366f1, #0ea5e9 55%, #06b6d4);
186
+ --kz-cta-text: #fff;
187
+
188
+ --bg-default: var(--kz-bg-base);
189
+ --bg-muted: var(--kz-bg-muted);
190
+ --bg-elevated: var(--kz-bg-elevated);
191
+ --bg-accent: #0ea5e9;
192
+ --bg-accent-hover: #0284c7;
193
+ --text-default: var(--kz-text-default);
194
+ --text-muted: var(--kz-text-muted);
195
+ --border-default: var(--kz-border-default);
196
+ --border-strong: var(--kz-border-default);
197
+ --focus-ring: #0ea5e9;
198
+ --shadow-color: 210 40% 2%;
199
+ --gradient-base-from: #f0f9ff;
200
+ --gradient-base-to: #e0f2fe;
201
+ --gradient-accent-from: #6366f1;
202
+ --gradient-accent-via: #0ea5e9;
203
+ --gradient-accent-to: #06b6d4;
204
+ --gradient-spot-1: rgba(99,102,241,0.35);
205
+ --gradient-spot-2: rgba(14,165,233,0.30);
206
+ }
207
+
132
208
  html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
133
209
  body { background: var(--bg-default); color: var(--text-default); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); }
134
210
  * { border-color: var(--border-default); }
135
211
  ::selection { background: var(--bg-accent); color: #fff; }
136
- .app-bg {
137
- position: relative;
138
- min-height: 100vh;
139
- overflow-x: hidden;
140
- isolation: isolate;
141
- }
142
- .app-bg:not(:first-of-type) { background: none !important; }
143
- .app-bg::before {
144
- content: "";
145
- position: fixed; inset:0; pointer-events:none; z-index:-1;
146
- background:
147
- radial-gradient(circle at 18% 22%, var(--gradient-spot-1) 0, transparent 60%),
148
- radial-gradient(circle at 82% 18%, var(--gradient-spot-2) 0, transparent 62%),
149
- linear-gradient(135deg, var(--gradient-base-from) 0%, var(--gradient-base-to) 100%);
150
- background-attachment: fixed;
151
- }
152
- .dark .app-bg::before {
153
- background:
154
- radial-gradient(circle at 18% 22%, var(--gradient-spot-1) 0, transparent 60%),
155
- radial-gradient(circle at 82% 18%, var(--gradient-spot-2) 0, transparent 62%),
156
- linear-gradient(140deg, var(--gradient-base-from) 0%, var(--gradient-base-to) 100%);
157
- }
158
- .app-bg::after {
159
- content: "";
160
- position: fixed; inset:0; pointer-events:none; z-index:-1; mix-blend-mode:overlay;
161
- background: linear-gradient(120deg, var(--gradient-accent-from), var(--gradient-accent-via) 55%, var(--gradient-accent-to));
162
- opacity: .07; border-radius: inherit;
163
- }
164
- .dark .app-bg::after { opacity:.10; }
165
- @supports (-webkit-touch-callout: none) {
166
- .app-bg::before, .app-bg::after { position: absolute; }
212
+ }
213
+
214
+ /* ── App Background Utility ────────────────────────────────────────────
215
+ * Lives OUTSIDE @layer so its resets always beat browser defaults and
216
+ * un-layered host-app CSS (e.g. Rails' default `body { margin: 8px }`).
217
+ * This is intentional: .app-bg is an opt-in utility, and when a consumer
218
+ * applies it to <body>, it must guarantee zero margin/padding so that
219
+ * sticky headers, full-bleed sections, etc. work edge-to-edge.
220
+ * ──────────────────────────────────────────────────────────────────── */
221
+ .app-bg {
222
+ position: relative;
223
+ overflow-x: hidden;
224
+ isolation: isolate;
225
+ margin: 0;
226
+ padding: 0;
227
+ }
228
+
229
+ .app-bg:not(:first-of-type) { background: none !important; }
230
+
231
+ .app-bg::before {
232
+ content: "";
233
+ position: fixed; inset: 0; pointer-events: none; z-index: -1;
234
+ background:
235
+ radial-gradient(circle at 18% 22%, var(--gradient-spot-1) 0, transparent 60%),
236
+ radial-gradient(circle at 82% 18%, var(--gradient-spot-2) 0, transparent 62%),
237
+ linear-gradient(135deg, var(--gradient-base-from) 0%, var(--gradient-base-to) 100%);
238
+ background-attachment: fixed;
239
+ }
240
+
241
+ .dark .app-bg::before {
242
+ background:
243
+ radial-gradient(circle at 18% 22%, var(--gradient-spot-1) 0, transparent 60%),
244
+ radial-gradient(circle at 82% 18%, var(--gradient-spot-2) 0, transparent 62%),
245
+ linear-gradient(140deg, var(--gradient-base-from) 0%, var(--gradient-base-to) 100%);
246
+ }
247
+
248
+ .app-bg::after {
249
+ content: "";
250
+ position: fixed; inset: 0; pointer-events: none; z-index: -1; mix-blend-mode: overlay;
251
+ background: linear-gradient(120deg, var(--gradient-accent-from), var(--gradient-accent-via) 55%, var(--gradient-accent-to));
252
+ opacity: .07; border-radius: inherit;
253
+ }
254
+
255
+ .dark .app-bg::after { opacity: .10; }
256
+
257
+ @media (prefers-color-scheme: dark) {
258
+ :root:not([data-theme="light"]):not(.light) .app-bg::after {
259
+ opacity: .10;
167
260
  }
168
- }
261
+ }
262
+
263
+ @supports (-webkit-touch-callout: none) {
264
+ .app-bg::before, .app-bg::after { position: absolute; }
265
+ }
@@ -4,6 +4,8 @@ module KozenetUi
4
4
  # Base component that all Kozenet UI components inherit from
5
5
  # Provides common functionality for variant handling, class merging, etc.
6
6
  class BaseComponent < ViewComponent::Base
7
+ UNSET = Object.new.freeze
8
+
7
9
  attr_reader :variant, :size, :html_options
8
10
 
9
11
  def initialize(variant: nil, size: nil, class: nil, **html_options)
@@ -78,7 +80,25 @@ module KozenetUi
78
80
 
79
81
  # Stimulus controller name with prefix
80
82
  def stimulus_controller(name)
81
- "#{KozenetUi.configuration.stimulus_prefix}-#{name}"
83
+ "#{KozenetUi.configuration.stimulus_prefix}-#{name.to_s.tr("_", "-")}"
84
+ end
85
+
86
+ def stimulus_controllers(*names)
87
+ names.map { |name| stimulus_controller(name) }.join(" ")
88
+ end
89
+
90
+ def stimulus_action(controller, action, event:)
91
+ "#{event}->#{stimulus_controller(controller)}##{action}"
92
+ end
93
+
94
+ def stimulus_target(controller, target)
95
+ { "data-#{stimulus_controller(controller)}-target" => target.to_s.tr("_", "-") }
96
+ end
97
+
98
+ def component_option(component, option, value = UNSET, fallback: nil)
99
+ return value unless value.equal?(UNSET)
100
+
101
+ KozenetUi.configuration.component_defaults_for(component).fetch(option.to_sym, fallback)
82
102
  end
83
103
  end
84
104
  end
@@ -1,11 +1,13 @@
1
1
  <%# frozen_string_literal: true %>
2
2
  <%# ActionButtonComponent template for KozenetUi::HeaderComponent %>
3
- <%= tag.a href: @href, class: "kz-action-btn", aria: { label: @label } do %>
3
+ <%= tag.a(**action_button_attrs) do %>
4
4
  <% if @icon %>
5
5
  <span class="kz-action-btn-icon"><%= render_icon(@icon) %></span>
6
6
  <% end %>
7
7
  <% if @label %>
8
8
  <span class="kz-action-btn-label"><%= @label %></span>
9
9
  <% end %>
10
- <%= content if content.present? %>
10
+ <% if visible_text? %>
11
+ <span class="kz-action-btn-text"><%= visible_text %></span>
12
+ <% end %>
11
13
  <% end %>
@@ -6,23 +6,97 @@ module KozenetUi
6
6
  # Renders an action button (icon or text) in the header
7
7
  #
8
8
  # @example
9
- # <%= render KozenetUi::HeaderComponent::ActionButtonComponent.new(href: "/cart", icon: :cart, label: "Cart") %>
9
+ # <%= render KozenetUi::HeaderComponent::ActionButtonComponent.new(href: "/cart", icon: :shopping_cart, label: "Cart") %>
10
10
  class ActionButtonComponent < BaseComponent
11
- def initialize(href: "#", icon: nil, label: nil, **html_options)
11
+ PLACEMENTS = %i[start end].freeze
12
+ PLACEMENT_ALIASES = {
13
+ before: :start,
14
+ left: :start,
15
+ right: :end,
16
+ after: :end
17
+ }.freeze
18
+ VISIBILITIES = %i[always desktop mobile].freeze
19
+ VISIBILITY_ALIASES = {
20
+ all: :always,
21
+ both: :always
22
+ }.freeze
23
+
24
+ attr_reader :placement
25
+ attr_reader :visible_on
26
+
27
+ def initialize(
28
+ href: "#",
29
+ icon: nil,
30
+ text: nil,
31
+ label: nil,
32
+ placement: :end,
33
+ position: nil,
34
+ visible_on: :desktop,
35
+ **html_options
36
+ )
12
37
  super(**html_options)
13
38
  @href = href
14
39
  @icon = icon
40
+ @text = text
15
41
  @label = label
42
+ @placement = normalize_placement(position || placement)
43
+ @visible_on = normalize_visibility(visible_on)
16
44
  end
17
45
 
18
46
  private
19
47
 
48
+ def action_button_attrs
49
+ attrs = html_options.merge(
50
+ href: @href,
51
+ class: action_button_classes
52
+ )
53
+ attrs[:aria] = action_button_aria if action_button_aria.any?
54
+ attrs
55
+ end
56
+
20
57
  def action_button_classes
21
- "kz-action-btn"
58
+ classes = ["kz-action-btn"]
59
+ classes << "kz-action-btn-with-text" if visible_text?
60
+ classes << "kz-action-btn-placement-#{placement}"
61
+ classes << "kz-action-visible-#{visible_on}"
62
+ classes << @custom_class if defined?(@custom_class) && @custom_class
63
+ classes.join(" ")
64
+ end
65
+
66
+ def action_button_aria
67
+ aria = html_options.fetch(:aria, {}).dup
68
+ aria[:label] = @label if @label.present?
69
+ aria
70
+ end
71
+
72
+ def visible_text
73
+ @text.presence || (content if content.present?)
74
+ end
75
+
76
+ def visible_text?
77
+ visible_text.present?
22
78
  end
23
79
 
24
80
  def render_icon(icon)
25
- ApplicationController.helpers.kozenet_ui_icon(icon, class: "kz-action-btn-icon") if icon
81
+ return unless icon
82
+
83
+ helpers.kozenet_ui_icon(icon, class: "kz-action-btn-svg", size: 20)
84
+ end
85
+
86
+ def normalize_placement(value)
87
+ normalized_value = value.to_s.tr("-", "_").to_sym
88
+ normalized_value = PLACEMENT_ALIASES.fetch(normalized_value, normalized_value)
89
+ return normalized_value if PLACEMENTS.include?(normalized_value)
90
+
91
+ raise ArgumentError, "Unknown header action placement `#{value}`. Use :start or :end."
92
+ end
93
+
94
+ def normalize_visibility(value)
95
+ normalized_value = value.to_s.tr("-", "_").to_sym
96
+ normalized_value = VISIBILITY_ALIASES.fetch(normalized_value, normalized_value)
97
+ return normalized_value if VISIBILITIES.include?(normalized_value)
98
+
99
+ raise ArgumentError, "Unknown header action visibility `#{value}`. Use :always, :desktop, or :mobile."
26
100
  end
27
101
  end
28
102
  end
@@ -1,6 +1,6 @@
1
1
  <%# frozen_string_literal: true %>
2
2
  <%# NavItemComponent template for KozenetUi::HeaderComponent %>
3
- <%= tag.a href: @href, class: nav_item_classes, role: (@dropdown ? "button" : nil), data: (@dropdown ? { action: "click->kz-dropdown#toggle" } : {}) do %>
3
+ <%= tag.a href: @href, class: nav_item_classes, role: (@dropdown ? "button" : nil), data: nav_item_data do %>
4
4
  <%= content %>
5
5
  <% if @dropdown %>
6
6
  <svg class="ml-1 opacity-60" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
@@ -23,6 +23,12 @@ module KozenetUi
23
23
  classes << @custom_class if defined?(@custom_class) && @custom_class
24
24
  classes.join(" ")
25
25
  end
26
+
27
+ def nav_item_data
28
+ return {} unless @dropdown
29
+
30
+ { action: stimulus_action(:dropdown, :toggle, event: :click) }
31
+ end
26
32
  end
27
33
  end
28
34
  end
@@ -1,18 +1,16 @@
1
1
  <%# frozen_string_literal: true %>
2
2
  <%# UserMenuComponent template for KozenetUi::HeaderComponent %>
3
- <div class="kz-user-menu relative" data-controller="kz-user-menu">
4
- <button type="button" class="kz-avatar-btn flex items-center gap-2" data-action="click->kz-user-menu#toggle">
3
+ <div class="kz-user-menu" data-controller="<%= stimulus_controller(:user_menu) %>">
4
+ <button type="button" class="kz-avatar-btn" data-action="<%= stimulus_action(:user_menu, :toggle, event: :click) %>">
5
5
  <% if @avatar_url.present? %>
6
- <img src="<%= @avatar_url %>" alt="<%= @user_name %>" class="w-8 h-8 rounded-full" />
6
+ <img src="<%= @avatar_url %>" alt="<%= @user_name %>" />
7
7
  <% else %>
8
- <span class="bg-gray-300 dark:bg-gray-700 w-8 h-8 rounded-full flex items-center justify-center font-bold">
8
+ <span class="kz-user-initial">
9
9
  <%= @user_name.to_s.first.upcase %>
10
10
  </span>
11
11
  <% end %>
12
- <span class="kz-user-name"><%= @user_name %></span>
13
- <svg class="ml-1 w-4 h-4" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.06l3.71-3.83a.75.75 0 1 1 1.08 1.04l-4.25 4.39a.75.75 0 0 1-1.08 0L5.21 8.27a.75.75 0 0 1 .02-1.06z"/></svg>
14
12
  </button>
15
- <div class="kz-user-dropdown absolute right-0 mt-2 w-48 bg-white dark:bg-gray-900 rounded shadow-lg z-50 hidden" data-kz-user-menu-target="dropdown">
13
+ <div class="kz-user-dropdown hidden" <%= tag.attributes(stimulus_target(:user_menu, :dropdown)) %>>
16
14
  <%= content %>
17
15
  </div>
18
16
  </div>
@@ -1,10 +1,22 @@
1
- <header class="kz-header kz-header-sticky kz-header-blur" data-controller="kz-header kz-mobile-nav" data-action="scroll@window->kz-header#handleScroll">
2
- <div class="kz-header-bar" data-kz-header-target="container">
1
+ <header
2
+ class="<%= component_classes %>"
3
+ data-controller="<%= stimulus_controllers(:header, :mobile_nav) %>"
4
+ data-action="<%= stimulus_action(:header, :handleScroll, event: "scroll@window") %>"
5
+ >
6
+ <div class="kz-header-bar" <%= tag.attributes(stimulus_target(:header, :container)) %>>
3
7
  <!-- Brand + Navigation -->
4
8
  <div class="kz-header-start">
5
- <div class="kz-brand">
6
- <%= brand if brand? %>
7
- </div>
9
+ <% if start_action_buttons? %>
10
+ <div class="kz-header-start-actions">
11
+ <% start_action_buttons.each do |button| %>
12
+ <%= button %>
13
+ <% end %>
14
+ </div>
15
+ <% end %>
16
+
17
+ <% if brand? %>
18
+ <%= brand %>
19
+ <% end %>
8
20
  <% if nav_items? %>
9
21
  <nav class="kz-nav-links" aria-label="Primary navigation">
10
22
  <% nav_items.each do |item| %>
@@ -25,22 +37,19 @@
25
37
  <div class="kz-header-actions">
26
38
  <!-- Mobile search trigger -->
27
39
  <% if search? %>
28
- <button
29
- type="button"
30
- class="kz-action-btn md:hidden"
40
+ <button
41
+ type="button"
42
+ class="kz-action-btn md:hidden"
31
43
  aria-label="Search"
32
- data-action="click->kz-header#toggleSearch"
44
+ data-action="<%= stimulus_action(:header, :toggleSearch, event: :click) %>"
33
45
  >
34
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
35
- <circle cx="11" cy="11" r="8"/>
36
- <path d="m21 21-4.35-4.35"/>
37
- </svg>
46
+ <%= helpers.kozenet_ui_icon(:magnifying_glass, class: "kz-action-btn-svg", size: 20) %>
38
47
  </button>
39
48
  <% end %>
40
49
 
41
50
  <!-- Action buttons -->
42
- <% if action_buttons? %>
43
- <% action_buttons.each do |button| %>
51
+ <% if end_action_buttons? %>
52
+ <% end_action_buttons.each do |button| %>
44
53
  <%= button %>
45
54
  <% end %>
46
55
  <% end %>
@@ -53,29 +62,29 @@
53
62
  <% end %>
54
63
 
55
64
  <!-- Mobile menu trigger -->
56
- <button
57
- class="kz-mobile-trigger"
58
- type="button"
59
- aria-label="Menu"
60
- data-action="click->kz-mobile-nav#toggle"
61
- data-kz-mobile-nav-target="trigger"
62
- >
63
- <svg class="kz-icon-menu" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
64
- <path d="M3 6h18M3 12h18M3 18h18"/>
65
- </svg>
66
- </button>
65
+ <% if mobile_menu? %>
66
+ <button
67
+ class="kz-mobile-trigger"
68
+ type="button"
69
+ aria-label="Menu"
70
+ data-action="<%= stimulus_action(:mobile_nav, :toggle, event: :click) %>"
71
+ <%= tag.attributes(stimulus_target(:mobile_nav, :trigger)) %>
72
+ >
73
+ <%= helpers.kozenet_ui_icon(:bars_3, class: "kz-icon-menu", size: 20) %>
74
+ </button>
75
+ <% end %>
67
76
  </div>
68
77
  </div>
69
78
 
70
79
  <!-- Mobile panel -->
71
80
  <% if mobile_menu? %>
72
- <div
73
- class="kz-mobile-panel hidden scale-y-0 origin-top"
74
- data-kz-mobile-nav-target="panel"
81
+ <div
82
+ class="kz-mobile-panel hidden scale-y-0 origin-top"
83
+ <%= tag.attributes(stimulus_target(:mobile_nav, :panel)) %>
75
84
  role="dialog"
76
85
  aria-label="Mobile navigation"
77
86
  >
78
87
  <%= mobile_menu %>
79
88
  </div>
80
89
  <% end %>
81
- </header>
90
+ </header>
@@ -19,13 +19,13 @@ module KozenetUi
19
19
  renders_one :mobile_menu
20
20
 
21
21
  def initialize(
22
- sticky: true,
23
- blur: true,
22
+ sticky: BaseComponent::UNSET,
23
+ blur: BaseComponent::UNSET,
24
24
  **html_options
25
25
  )
26
26
  super(**html_options)
27
- @sticky = sticky
28
- @blur = blur
27
+ @sticky = component_option(:header, :sticky, sticky, fallback: true)
28
+ @blur = component_option(:header, :blur, blur, fallback: true)
29
29
  end
30
30
 
31
31
  private
@@ -36,5 +36,27 @@ module KozenetUi
36
36
  classes << "kz-header-blur" if @blur
37
37
  classes.join(" ")
38
38
  end
39
+
40
+ def start_action_buttons
41
+ action_buttons_for(:start)
42
+ end
43
+
44
+ def start_action_buttons?
45
+ start_action_buttons.any?
46
+ end
47
+
48
+ def end_action_buttons
49
+ action_buttons_for(:end)
50
+ end
51
+
52
+ def end_action_buttons?
53
+ end_action_buttons.any?
54
+ end
55
+
56
+ def action_buttons_for(placement)
57
+ return [] unless action_buttons?
58
+
59
+ action_buttons.select { |button| button.placement == placement }
60
+ end
39
61
  end
40
62
  end