lostcoords-ui 0.4.1 → 0.5.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -89
  3. data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
  4. data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
  5. data/app/assets/tailwind/lostcoords/ui/card.css +20 -22
  6. data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
  7. data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
  8. data/app/assets/tailwind/lostcoords/ui/components.css +25 -0
  9. data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
  10. data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
  11. data/app/assets/tailwind/lostcoords/ui/disclosure.css +5 -5
  12. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +43 -43
  13. data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
  14. data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
  15. data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
  16. data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
  17. data/app/assets/tailwind/lostcoords/ui/reading.css +24 -24
  18. data/app/assets/tailwind/lostcoords/ui/sheet.css +60 -60
  19. data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
  20. data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
  21. data/app/assets/tailwind/lostcoords/ui/theme.css +194 -225
  22. data/app/assets/tailwind/lostcoords/ui/tooltip.css +50 -0
  23. data/app/assets/tailwind/lostcoords/ui/typography.css +37 -61
  24. data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
  25. data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
  26. data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
  27. data/app/helpers/lostcoords/ui/button_helper.rb +36 -33
  28. data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
  29. data/app/helpers/lostcoords/ui/choice_helper.rb +83 -0
  30. data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
  31. data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
  32. data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
  33. data/app/helpers/lostcoords/ui/dialog_helper.rb +6 -13
  34. data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
  35. data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
  36. data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
  37. data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
  38. data/app/helpers/lostcoords/ui/menu_helper.rb +8 -8
  39. data/app/helpers/lostcoords/ui/navigation_helper.rb +131 -0
  40. data/app/helpers/lostcoords/ui/overlay_helper.rb +17 -0
  41. data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
  42. data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
  43. data/app/helpers/lostcoords/ui/sheet_helper.rb +3 -45
  44. data/app/helpers/lostcoords/ui/structures_helper.rb +55 -64
  45. data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
  46. data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
  47. data/app/helpers/lostcoords/ui/typography_helper.rb +11 -11
  48. data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
  49. data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +3 -3
  50. data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
  51. data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
  52. data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +4 -4
  53. data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +3 -3
  54. data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
  55. data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +1 -1
  56. data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +15 -15
  57. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +39 -0
  58. data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
  59. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +14 -0
  60. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +5 -5
  61. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
  62. data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
  63. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +7 -7
  64. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +2 -2
  65. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
  66. data/app/javascript/lostcoords/ui/index.js +51 -0
  67. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +30 -30
  68. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
  69. data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
  70. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
  71. data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
  72. data/config/helper_renames.json +23 -25
  73. data/config/importmap.rb +10 -2
  74. data/config/locales/en.yml +23 -0
  75. data/config/locales/es.yml +23 -0
  76. data/config/locales/ja.yml +23 -0
  77. data/lib/lostcoords/ui/action_view.rb +5 -3
  78. data/lib/lostcoords/ui/builders.rb +1 -1
  79. data/lib/lostcoords/ui/form_builder.rb +113 -0
  80. data/lib/lostcoords/ui/parts.rb +154 -0
  81. data/lib/lostcoords/ui/tokens.rb +24 -1
  82. data/lib/lostcoords/ui/version.rb +1 -1
  83. metadata +33 -17
  84. data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
  85. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
  86. data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
  87. data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
  88. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
  89. /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
  90. /data/app/javascript/lostcoords/ui/controllers/{ui_toggle_controller.js → lc_toggle_controller.js} +0 -0
@@ -0,0 +1,8 @@
1
+ <%# locals: (id:, label:, title:, controller:, role:, trigger_variant:, attributes:, content:) %>
2
+ <%= tag.div(class: "relative inline-flex", data: { controller: controller }) do %>
3
+ <%= lc_button label, variant: trigger_variant, id: "#{id}-trigger", aria: { haspopup: role, expanded: false, controls: id }, data: { "#{controller}_target" => "trigger", action: "#{controller}#toggle" } %>
4
+ <%= tag.div(**attributes) do %>
5
+ <% if title %><h2 id="<%= id %>-title" class="<%= lc_heading_class(size: :subheading) %>"><%= title %></h2><% end %>
6
+ <%= content %>
7
+ <% end %>
8
+ <% end %>
@@ -6,7 +6,7 @@
6
6
  "button_class": "lc_button_class",
7
7
  "button_variants": "lc_button_variants",
8
8
  "card_class": "lc_card_class",
9
- "card_surface_class": "lc_card_surface_class",
9
+ "card_surface_class": "lc_card_class",
10
10
  "container_class": "lc_container_class",
11
11
  "dialog_class": "lc_dialog_class",
12
12
  "dialog_kinds": "lc_dialog_kinds",
@@ -20,30 +20,29 @@
20
20
  "emoji_picker_toneable?": "lc_emoji_picker_toneable?",
21
21
  "emoji_picker_version": "lc_emoji_picker_version",
22
22
  "empty_state_class": "lc_empty_state_class",
23
- "eyebrow_class": "lc_eyebrow_class",
24
23
  "field_class": "lc_field_class",
25
24
  "field_label_class": "lc_field_label_class",
26
- "form_actions_class": "lc_form_actions_class",
27
- "form_alert_class": "lc_form_alert_class",
28
- "form_alert_icon": "lc_form_alert_icon",
29
- "form_card_class": "lc_form_card_class",
25
+ "form_actions_class": "lc_inline_class",
26
+ "form_alert_class": "lc_alert_class",
27
+ "form_alert_icon": "lc_alert_icon",
28
+ "form_card_class": "lc_card_class",
30
29
  "glass_class": "lc_glass_class",
31
30
  "heading_class": "lc_heading_class",
32
31
  "heroicon": "lc_icon",
33
32
  "icon_badge_class": "lc_icon_badge_class",
34
33
  "icon_button_class": "lc_icon_button_class",
35
- "icon_medallion_class": "lc_icon_medallion_class",
34
+ "icon_medallion_class": "lc_icon_badge_class",
36
35
  "inline_class": "lc_inline_class",
37
36
  "input_class": "lc_input_class",
38
37
  "menu_option_class": "lc_menu_option_class",
39
38
  "menu_popover_class": "lc_menu_popover_class",
40
- "pill_class": "lc_pill_class",
41
- "pill_row_class": "lc_pill_row_class",
39
+ "pill_class": "lc_toggle_class",
40
+ "pill_row_class": "lc_inline_class",
42
41
  "popover_class": "lc_popover_class",
43
42
  "prose_class": "lc_prose_class",
44
43
  "section_header_class": "lc_section_header_class",
45
- "segmented_class": "lc_segmented_class",
46
- "segmented_tab_class": "lc_segmented_tab_class",
44
+ "segmented_class": "lc_toggle_group_class",
45
+ "segmented_tab_class": "lc_toggle_group_item_class",
47
46
  "select_class": "lc_select_class",
48
47
  "sheet_chip_class": "lc_sheet_chip_class",
49
48
  "sheet_data": "lc_sheet_data",
@@ -54,7 +53,7 @@
54
53
  "skeleton_class": "lc_skeleton_class",
55
54
  "spinner_class": "lc_spinner_class",
56
55
  "stack_class": "lc_stack_class",
57
- "status_pill_class": "lc_status_pill_class",
56
+ "status_pill_class": "lc_badge_class",
58
57
  "text_class": "lc_text_class",
59
58
  "textarea_class": "lc_textarea_class",
60
59
  "tw": "lc_tw",
@@ -68,45 +67,44 @@
68
67
  "ui_button_link_to": "lc_button_link_to",
69
68
  "ui_button_to": "lc_button_to",
70
69
  "ui_card": "lc_card",
71
- "ui_card_surface": "lc_card_surface",
70
+ "ui_card_surface": "lc_card",
72
71
  "ui_container": "lc_container",
73
72
  "ui_copy_button": "lc_copy_button",
74
73
  "ui_copy_content": "lc_copy_content",
75
74
  "ui_dialog": "lc_dialog",
76
- "ui_disclosure": "lc_disclosure",
75
+ "ui_disclosure": "lc_collapsible",
77
76
  "ui_emoji_picker": "lc_emoji_picker",
78
77
  "ui_empty_state": "lc_empty_state",
79
- "ui_eyebrow": "lc_eyebrow",
80
78
  "ui_field": "lc_field",
81
- "ui_form_actions": "lc_form_actions",
82
- "ui_form_alert": "lc_form_alert",
83
- "ui_form_card": "lc_form_card",
79
+ "ui_form_actions": "lc_inline",
80
+ "ui_form_alert": "lc_alert",
81
+ "ui_form_card": "lc_card",
84
82
  "ui_heading": "lc_heading",
85
83
  "ui_icon_badge": "lc_icon_badge",
86
84
  "ui_icon_button": "lc_icon_button",
87
85
  "ui_inline": "lc_inline",
88
86
  "ui_input": "lc_input",
89
87
  "ui_label!": "lc_label!",
90
- "ui_medallion": "lc_medallion",
88
+ "ui_medallion": "lc_icon_badge",
91
89
  "ui_menu": "lc_menu",
92
90
  "ui_menu_option": "lc_menu_option",
93
91
  "ui_normalize_attributes": "lc_normalize_attributes",
94
92
  "ui_option!": "lc_option!",
95
- "ui_pill": "lc_pill",
96
- "ui_pill_row": "lc_pill_row",
93
+ "ui_pill": "lc_toggle",
94
+ "ui_pill_row": "lc_inline",
97
95
  "ui_popover": "lc_popover",
98
96
  "ui_prose": "lc_prose",
99
97
  "ui_render_dialog": "lc_render_dialog",
100
98
  "ui_render_panel": "lc_render_panel",
101
99
  "ui_section": "lc_section",
102
- "ui_segmented": "lc_segmented",
103
- "ui_segmented_tab": "lc_segmented_tab",
100
+ "ui_segmented": "lc_toggle_group",
101
+ "ui_segmented_tab": "lc_toggle_group_item",
104
102
  "ui_select": "lc_select",
105
- "ui_sheet": "lc_sheet",
103
+ "ui_sheet": "lc_dialog",
106
104
  "ui_skeleton": "lc_skeleton",
107
105
  "ui_spinner": "lc_spinner",
108
106
  "ui_stack": "lc_stack",
109
- "ui_status_pill": "lc_status_pill",
107
+ "ui_status_pill": "lc_badge",
110
108
  "ui_text": "lc_text",
111
109
  "ui_textarea": "lc_textarea",
112
110
  "ui_toast": "lc_toast"
data/config/importmap.rb CHANGED
@@ -1,3 +1,11 @@
1
- # Keys match the host's own helpers/ and controllers/ pins, so imports and stimulus-loading resolve the same way.
1
+ # Everything lives under lostcoords/ui/*. Controllers are also pinned under the host's controllers/* key,
2
+ # so eagerLoadControllersFrom("controllers", application) registers them as lc-* identifiers. A host file
3
+ # with the same name, such as app/javascript/controllers/lc_dialog_controller.js, replaces the gem's.
4
+ pin "lostcoords/ui", to: "lostcoords/ui/index.js", preload: false
2
5
  pin_all_from Lostcoords::Ui::Engine.root.join("app/javascript/lostcoords/ui/controllers"), under: "controllers", to: "lostcoords/ui/controllers", preload: false
3
- pin_all_from Lostcoords::Ui::Engine.root.join("app/javascript/lostcoords/ui/helpers"), under: "helpers", to: "lostcoords/ui/helpers", preload: false
6
+ pin_all_from Lostcoords::Ui::Engine.root.join("app/javascript/lostcoords/ui/helpers"), under: "lostcoords/ui/helpers", to: "lostcoords/ui/helpers", preload: false
7
+ # pin_all_from keeps one mapping per directory, so the namespaced controller keys are pinned one by one.
8
+ Dir[Lostcoords::Ui::Engine.root.join("app/javascript/lostcoords/ui/controllers/*.js")].each do |path|
9
+ name = File.basename(path, ".js")
10
+ pin "lostcoords/ui/controllers/#{name}", to: "lostcoords/ui/controllers/#{name}.js", preload: false
11
+ end
@@ -1,6 +1,29 @@
1
1
  en:
2
2
  lostcoords:
3
3
  ui:
4
+ pager:
5
+ label: "Pages"
6
+ previous: "Previous"
7
+ next: "Next"
8
+ skip_link:
9
+ label: "Skip to content"
10
+ spinner:
11
+ label: "Loading…"
12
+ dialog:
13
+ trigger: "Open dialog"
14
+ cancel: "Cancel"
15
+ close: "Close %{title}"
16
+ menu:
17
+ label: "Actions"
18
+ popover:
19
+ trigger: "Open popover"
20
+ breadcrumb:
21
+ label: "Breadcrumb"
22
+ code_block:
23
+ label: "Source code"
24
+ copy: "Copy %{label}"
25
+ expand: "Show all code"
26
+ collapse: "Show less code"
4
27
  copy_button:
5
28
  label: "Copy"
6
29
  copied: "Copied"
@@ -1,6 +1,29 @@
1
1
  es:
2
2
  lostcoords:
3
3
  ui:
4
+ pager:
5
+ label: "Páginas"
6
+ previous: "Anterior"
7
+ next: "Siguiente"
8
+ skip_link:
9
+ label: "Saltar al contenido"
10
+ spinner:
11
+ label: "Cargando…"
12
+ dialog:
13
+ trigger: "Abrir diálogo"
14
+ cancel: "Cancelar"
15
+ close: "Cerrar %{title}"
16
+ menu:
17
+ label: "Acciones"
18
+ popover:
19
+ trigger: "Abrir panel"
20
+ breadcrumb:
21
+ label: "Ruta de navegación"
22
+ code_block:
23
+ label: "Código fuente"
24
+ copy: "Copiar %{label}"
25
+ expand: "Mostrar todo el código"
26
+ collapse: "Mostrar menos código"
4
27
  copy_button:
5
28
  label: "Copiar"
6
29
  copied: "Copiado"
@@ -1,6 +1,29 @@
1
1
  ja:
2
2
  lostcoords:
3
3
  ui:
4
+ pager:
5
+ label: "ページ"
6
+ previous: "前へ"
7
+ next: "次へ"
8
+ skip_link:
9
+ label: "本文へ移動"
10
+ spinner:
11
+ label: "読み込み中…"
12
+ dialog:
13
+ trigger: "ダイアログを開く"
14
+ cancel: "キャンセル"
15
+ close: "%{title}を閉じる"
16
+ menu:
17
+ label: "操作"
18
+ popover:
19
+ trigger: "パネルを開く"
20
+ breadcrumb:
21
+ label: "パンくずリスト"
22
+ code_block:
23
+ label: "ソースコード"
24
+ copy: "%{label}をコピー"
25
+ expand: "コードをすべて表示"
26
+ collapse: "コードを折りたたむ"
4
27
  copy_button:
5
28
  label: "コピー"
6
29
  copied: "コピー済み"
@@ -3,8 +3,10 @@ require "action_view"
3
3
  require "active_support/core_ext/hash"
4
4
  require "active_support/core_ext/object/blank"
5
5
  require "tailwind_merge"
6
- require "lostcoords/ui/resources"
7
- require "lostcoords/ui/icons"
8
- require "lostcoords/ui/helpers"
6
+ require_relative "resources"
7
+ require_relative "icons"
8
+ require_relative "parts"
9
+ require_relative "form_builder"
10
+ require_relative "helpers"
9
11
 
10
12
  Lostcoords::Ui.load_translations!
@@ -10,7 +10,7 @@ module Lostcoords::Ui
10
10
 
11
11
  def component?
12
12
  !name.to_s.end_with?("_class") &&
13
- !%i[lc_tw lc_sheet_data lc_form_alert_icon].include?(name) &&
13
+ !%i[lc_tw lc_sheet_data lc_alert_icon].include?(name) &&
14
14
  helper != "EmojiPickerHelper"
15
15
  end
16
16
  end
@@ -0,0 +1,113 @@
1
+ module Lostcoords::Ui
2
+ # Rails form builder that renders the library's controls and connects model errors to them.
3
+ #
4
+ # <%= lc_form_with model: @project do |form| %>
5
+ # <%= form.field :name, hint: "Shown to members" %>
6
+ # <%= form.field :email, as: :email_field %>
7
+ # <%= form.switch :public %>
8
+ # <%= form.submit %>
9
+ # <% end %>
10
+ #
11
+ # Every standard helper keeps its Rails signature. A symbol `size:` (:sm, :md, :lg) picks the control
12
+ # size; an integer still sets the HTML size attribute.
13
+ class FormBuilder < ActionView::Helpers::FormBuilder
14
+ INPUTS = %i[
15
+ text_field email_field password_field telephone_field phone_field url_field search_field number_field
16
+ date_field time_field datetime_field datetime_local_field month_field week_field
17
+ ].freeze
18
+
19
+ INPUTS.each do |selector|
20
+ define_method(selector) do |method, options = {}|
21
+ size = control_size!(options)
22
+ super(method, control_options(method, options, "input", @template.lc_input_class(size: size)))
23
+ end
24
+ end
25
+
26
+ def text_area(method, options = {})
27
+ super(method, control_options(method, options, "textarea", @template.lc_textarea_class))
28
+ end
29
+ alias textarea text_area
30
+
31
+ def select(method, choices = nil, options = {}, html_options = {}, &block)
32
+ size = control_size!(html_options)
33
+ super(method, choices, options, control_options(method, html_options, "select", @template.lc_select_class(size: size)), &block)
34
+ end
35
+
36
+ def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
37
+ super(method, control_options(method, options, "checkbox-input", @template.lc_checkbox_class), checked_value, unchecked_value)
38
+ end
39
+ alias checkbox check_box
40
+
41
+ def radio_button(method, tag_value, options = {})
42
+ super(method, tag_value, control_options(method, options, "radio-input", @template.lc_radio_class))
43
+ end
44
+
45
+ # A checkbox drawn as a switch, with its label beside it.
46
+ def switch(method, label: nil, hint: nil, checked_value: "1", unchecked_value: "0", **options)
47
+ input = check_box(method, { role: "switch", class: @template.lc_switch_class(extra: options.delete(:class)), data: { slot: "switch-input" } }.merge(options), checked_value, unchecked_value)
48
+ choice_label(method, label, hint, "switch", input)
49
+ end
50
+
51
+ # A checkbox with its label beside it.
52
+ def choice(method, label: nil, hint: nil, checked_value: "1", unchecked_value: "0", **options)
53
+ choice_label(method, label, hint, "checkbox", check_box(method, options, checked_value, unchecked_value))
54
+ end
55
+
56
+ def label(method, text = nil, options = {}, &block)
57
+ if text.is_a?(Hash)
58
+ options = text
59
+ text = nil
60
+ end
61
+ super(method, text, options.merge(class: @template.lc_field_label_class(extra: options[:class])), &block)
62
+ end
63
+
64
+ def submit(value = nil, options = {})
65
+ if value.is_a?(Hash)
66
+ options = value
67
+ value = nil
68
+ end
69
+ variant = options.delete(:variant) || :primary
70
+ size = options.delete(:size) || :md
71
+ super(value, options.merge(class: @template.lc_button_class(variant: variant, size: size, extra: options[:class])))
72
+ end
73
+
74
+ # Label, control, hint and the attribute's errors in one field. `as:` names the control helper;
75
+ # a block renders a custom control and receives its attributes to splat with `**control`.
76
+ def field(method, label: nil, hint: nil, as: :text_field, **options, &block)
77
+ @template.lc_field(label: label || human_name(method), id: field_id(method), hint: hint, error: errors_for(method)) do |control|
78
+ if block
79
+ @template.capture(control, &block)
80
+ else
81
+ public_send(as, method, **options, **control.to_hash.except(:id))
82
+ end
83
+ end
84
+ end
85
+
86
+ private
87
+ def control_size!(options)
88
+ options[:size].is_a?(Symbol) || options[:size].is_a?(String) && options[:size].match?(/\A[a-z]+\z/) ? options.delete(:size).to_sym : :md
89
+ end
90
+
91
+ def control_options(method, options, slot, classes)
92
+ options = options.dup
93
+ aria = (options.delete(:aria) || {}).dup
94
+ aria[:invalid] = true if errors_for(method).any? && !aria.key?(:invalid)
95
+ data = { slot: slot }.merge(options.delete(:data) || {})
96
+ options.merge(class: @template.lc_tw(classes, options[:class]), aria: aria, data: data)
97
+ end
98
+
99
+ def choice_label(method, text, hint, slot, input)
100
+ @template.send(:lc_choice_label, slot, text || human_name(method), hint, input, for: field_id(method))
101
+ end
102
+
103
+ def errors_for(method)
104
+ return [] unless object.respond_to?(:errors)
105
+
106
+ object.errors.full_messages_for(method)
107
+ end
108
+
109
+ def human_name(method)
110
+ object.class.respond_to?(:human_attribute_name) ? object.class.human_attribute_name(method) : method.to_s.humanize
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,154 @@
1
+ module Lostcoords::Ui
2
+ # Builders yielded by compound components, so their parts are scoped and discoverable:
3
+ #
4
+ # <%= lc_card do |card| %>
5
+ # <%= card.header title: "Members", description: "People in this workspace" %>
6
+ # <%= card.content do %>…<% end %>
7
+ # <%= card.footer do %>…<% end %>
8
+ # <% end %>
9
+ #
10
+ # Every part carries data-slot, so a context can style it without matching classes.
11
+ module Parts
12
+ class Base
13
+ def initialize(view)
14
+ @view = view
15
+ end
16
+
17
+ private
18
+ attr_reader :view
19
+
20
+ def merge(defaults, attributes)
21
+ view.send(:lc_attributes, defaults, attributes)
22
+ end
23
+
24
+ def slot(name, attributes, defaults = {})
25
+ merge(defaults.merge(data: { slot: name }), attributes)
26
+ end
27
+
28
+ def part(tag_name, name, classes, attributes, &block)
29
+ view.content_tag(tag_name, slot(name, attributes, class: classes), &block)
30
+ end
31
+ end
32
+
33
+ class Card < Base
34
+ def header(title: nil, description: nil, level: 3, **attributes, &actions)
35
+ heading = [ (self.title(title, level: level) if title), (self.description(description) if description) ].compact
36
+ action = view.capture(&actions) if actions
37
+ part(:header, "card-header", "flex min-w-0 items-start justify-between gap-content", attributes) do
38
+ view.safe_join([
39
+ (view.tag.div(view.safe_join(heading), class: "flex min-w-0 flex-col gap-tight", data: { slot: "card-heading" }) if heading.any?),
40
+ (view.tag.div(action, class: "flex shrink-0 items-center gap-related", data: { slot: "card-action" }) if action.present?)
41
+ ].compact)
42
+ end
43
+ end
44
+
45
+ def title(content = nil, level: 3, **attributes, &block)
46
+ view.lc_heading(content, level: level, size: :subheading, **slot("card-title", attributes), &block)
47
+ end
48
+
49
+ def description(content = nil, **attributes, &block)
50
+ view.lc_text(content, size: :small, tone: :muted, **slot("card-description", attributes), &block)
51
+ end
52
+
53
+ def content(**attributes, &block)
54
+ part(:div, "card-content", "flex min-w-0 flex-col gap-content", attributes, &block)
55
+ end
56
+
57
+ def footer(**attributes, &block)
58
+ part(:div, "card-footer", "flex min-w-0 flex-wrap items-center justify-end gap-related", attributes, &block)
59
+ end
60
+ end
61
+
62
+ # Splats into a control as `**field`, so the control gets the field's id and validation attributes.
63
+ class Field < Base
64
+ attr_reader :id
65
+
66
+ def initialize(view, id, control)
67
+ super(view)
68
+ @id = id
69
+ @control = control
70
+ end
71
+
72
+ def to_hash = @control
73
+ end
74
+
75
+ class Dialog < Base
76
+ def initialize(view, id)
77
+ super(view)
78
+ @id = id
79
+ end
80
+
81
+ def footer(**attributes, &block)
82
+ part(:div, "dialog-footer", "flex flex-wrap items-center justify-end gap-related", attributes, &block)
83
+ end
84
+
85
+ def close(label = nil, variant: :secondary, **attributes)
86
+ label ||= view.send(:lc_t, "lostcoords.ui.dialog.cancel")
87
+ view.lc_button(label, variant: variant, **merge({ data: { action: "lc-dialog#close", slot: "dialog-close" } }, attributes))
88
+ end
89
+ end
90
+
91
+ class Alert < Base
92
+ def actions(**attributes, &block)
93
+ part(:div, "alert-actions", "flex flex-wrap items-center gap-related pt-tight", attributes, &block)
94
+ end
95
+ end
96
+
97
+ class Tabs < Base
98
+ Panel = Data.define(:value, :label, :content, :attributes)
99
+
100
+ attr_reader :panels
101
+
102
+ def initialize(view)
103
+ super
104
+ @panels = []
105
+ end
106
+
107
+ # Records a panel; lc_tabs renders the tab list and panels after the block.
108
+ def panel(label, value: nil, **attributes, &block)
109
+ value = (value || label).to_s
110
+ raise ArgumentError, "tab label must not be blank" if label.blank?
111
+ raise ArgumentError, "duplicate tab value: #{value}" if @panels.any? { |panel| panel.value == value }
112
+
113
+ @panels << Panel.new(value, label, (view.capture(&block) if block), attributes)
114
+ nil
115
+ end
116
+ end
117
+
118
+ class NavList < Base
119
+ def initialize(view, compact)
120
+ super(view)
121
+ @compact = compact
122
+ end
123
+
124
+ # current: marks the page the reader is on with aria-current="page".
125
+ def link(label, url, current: false, indent: false, **attributes)
126
+ view.tag.li(class: ("ps-content" if indent), data: { slot: "nav-item" }) do
127
+ view.link_to(label, url, **merge({ class: view.lc_nav_link_class(compact: @compact), aria: { current: ("page" if current) }, data: { slot: "nav-link" } }, attributes))
128
+ end
129
+ end
130
+
131
+ # The list is named with aria-label rather than an id, so the same navigation can render twice,
132
+ # as in a sidebar and a phone menu.
133
+ def group(title, **attributes, &block)
134
+ part(:li, "nav-group", "flex flex-col gap-tight not-first:pt-section", attributes) do
135
+ view.safe_join([
136
+ view.tag.span(title, class: "px-3 pb-tight text-caption font-semibold text-foreground", aria: { hidden: true }, data: { slot: "nav-group-title" }),
137
+ view.tag.ul(view.capture(&block), class: "m-0 flex list-none flex-col gap-px p-0", aria: { label: title })
138
+ ])
139
+ end
140
+ end
141
+ end
142
+
143
+ class DescriptionList < Base
144
+ def item(term, detail = nil, **attributes, &block)
145
+ part(:div, "description-item", "grid min-w-0 gap-tight py-heading sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] sm:gap-content", attributes) do
146
+ view.safe_join([
147
+ view.tag.dt(term, class: "text-label text-muted-foreground", data: { slot: "description-term" }),
148
+ view.tag.dd(block ? view.capture(&block) : detail, class: "m-0 min-w-0 text-body text-foreground [overflow-wrap:anywhere]", data: { slot: "description-detail" })
149
+ ])
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -4,9 +4,32 @@ module Lostcoords::Ui
4
4
  module Tokens
5
5
  THEME_PATH = File.expand_path("../../../app/assets/tailwind/lostcoords/ui/theme.css", __dir__)
6
6
  DECLARATION = /(?<![\w-])(--[\w-]+)\s*:/
7
+ COLORS = {
8
+ "--background" => "Page background",
9
+ "--foreground" => "Main text and headings",
10
+ "--card" => "Cards and grouped content",
11
+ "--card-foreground" => "Text on cards",
12
+ "--popover" => "Menus, popovers and dialogs",
13
+ "--popover-foreground" => "Text on menus, popovers and dialogs",
14
+ "--primary" => "Primary actions, links, selected states and keyboard focus",
15
+ "--primary-foreground" => "Text and icons on a solid primary background",
16
+ "--secondary" => "Secondary buttons",
17
+ "--secondary-foreground" => "Text on secondary buttons",
18
+ "--muted" => "Subtle backgrounds: badges, code, skeletons, table headers",
19
+ "--muted-foreground" => "Secondary text, hints and placeholders",
20
+ "--accent" => "Hovered rows, menu items and inline code",
21
+ "--accent-foreground" => "Text on accent backgrounds",
22
+ "--destructive" => "Errors and destructive actions",
23
+ "--success" => "Successful actions and positive feedback",
24
+ "--warning" => "Warnings and items needing attention",
25
+ "--border" => "Dividers and outlines",
26
+ "--input" => "Input and toggle fills",
27
+ "--ring" => "Keyboard focus ring; unset, it follows --primary"
28
+ }.freeze
29
+ FONTS = %w[--font-sans --font-display --font-mono --font-japanese].freeze
7
30
 
8
31
  def self.names
9
- File.read(THEME_PATH).scan(DECLARATION).flatten.uniq.sort
32
+ (COLORS.keys + FONTS).sort
10
33
  end
11
34
  end
12
35
 
@@ -1,5 +1,5 @@
1
1
  module Lostcoords
2
2
  module Ui
3
- VERSION = "0.4.1"
3
+ VERSION = "0.5.0"
4
4
  end
5
5
  end