lostcoords-ui 0.5.0 → 0.6.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +102 -48
  3. data/app/assets/tailwind/lostcoords/ui/card.css +5 -2
  4. data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +1 -1
  5. data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
  6. data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
  7. data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
  8. data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
  9. data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
  10. data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
  11. data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
  12. data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
  13. data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
  14. data/app/assets/tailwind/lostcoords/ui/components.css +12 -25
  15. data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
  16. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +45 -47
  17. data/app/assets/tailwind/lostcoords/ui/{reading.css → prose.css} +6 -0
  18. data/app/assets/tailwind/lostcoords/ui/sheet.css +3 -4
  19. data/app/assets/tailwind/lostcoords/ui/theme.css +16 -1
  20. data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
  21. data/app/assets/tailwind/lostcoords/ui/tooltip.css +12 -11
  22. data/app/assets/tailwind/lostcoords/ui/typography.css +1 -7
  23. data/app/helpers/lostcoords/ui/badge_helper.rb +2 -2
  24. data/app/helpers/lostcoords/ui/button_helper.rb +12 -6
  25. data/app/helpers/lostcoords/ui/choice_helper.rb +11 -7
  26. data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
  27. data/app/helpers/lostcoords/ui/dialog_helper.rb +10 -5
  28. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
  29. data/app/helpers/lostcoords/ui/feedback_helper.rb +2 -2
  30. data/app/helpers/lostcoords/ui/form_helper.rb +1 -1
  31. data/app/helpers/lostcoords/ui/glass_helper.rb +1 -1
  32. data/app/helpers/lostcoords/ui/menu_helper.rb +37 -2
  33. data/app/helpers/lostcoords/ui/navigation_helper.rb +17 -4
  34. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
  35. data/app/helpers/lostcoords/ui/sheet_helper.rb +4 -0
  36. data/app/helpers/lostcoords/ui/structures_helper.rb +2 -92
  37. data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
  38. data/app/helpers/lostcoords/ui/{overlay_helper.rb → tooltip_helper.rb} +3 -3
  39. data/app/helpers/lostcoords/ui/typography_helper.rb +30 -0
  40. data/app/javascript/lostcoords/ui/controllers/lc_collapsible_controller.js +1 -1
  41. data/app/javascript/lostcoords/ui/controllers/lc_emoji_picker_controller.js +34 -6
  42. data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
  43. data/app/javascript/lostcoords/ui/controllers/lc_menu_controller.js +23 -1
  44. data/app/javascript/lostcoords/ui/controllers/lc_selection_controller.js +6 -14
  45. data/app/javascript/lostcoords/ui/controllers/lc_sheet_controller.js +2 -2
  46. data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
  47. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +18 -15
  48. data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
  49. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +46 -6
  50. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +72 -20
  51. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +23 -0
  52. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +6 -7
  53. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
  54. data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
  55. data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
  56. data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
  57. data/app/javascript/lostcoords/ui/index.js +6 -0
  58. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +27 -27
  59. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
  60. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +1 -1
  61. data/lib/lostcoords/ui/action_view.rb +1 -0
  62. data/lib/lostcoords/ui/builders.rb +2 -2
  63. data/lib/lostcoords/ui/form_builder.rb +23 -10
  64. data/lib/lostcoords/ui/stylesheets.rb +25 -0
  65. data/lib/lostcoords/ui/version.rb +1 -1
  66. data/lib/tasks/lostcoords_ui.rake +11 -0
  67. metadata +23 -6
  68. data/config/helper_renames.json +0 -111
  69. data/script/migrate_helpers.rb +0 -136
@@ -19,7 +19,7 @@ module Lostcoords::Ui
19
19
  "dark:max-md:[--lc-glass-fill:color-mix(in_oklab,var(--muted)_92%,transparent)]"
20
20
 
21
21
  def lc_glass_class(shape: :bar, thick: false, extra: nil)
22
- lc_tw("lc-glass", GLASS_SHAPES.fetch(shape.to_sym, GLASS_SHAPES[:bar]), (GLASS_THICK if thick), extra)
22
+ lc_tw("lc-glass", GLASS_SHAPES.fetch(lc_option!(:shape, shape, GLASS_SHAPES.keys)), (GLASS_THICK if thick), extra)
23
23
  end
24
24
  end
25
25
  end
@@ -27,7 +27,7 @@ module Lostcoords::Ui
27
27
  }.freeze
28
28
 
29
29
  def lc_popover_class(align: :end, extra: nil)
30
- lc_glass_class(shape: :menu, extra: lc_tw(POPOVER_BASE, MENU_POPOVER_ALIGN.fetch(align), extra))
30
+ lc_glass_class(shape: :menu, extra: lc_tw(POPOVER_BASE, MENU_POPOVER_ALIGN.fetch(lc_option!(:align, align, MENU_POPOVER_ALIGN.keys)), extra))
31
31
  end
32
32
 
33
33
  def lc_menu_popover_class(align: :end, extra: nil)
@@ -35,7 +35,42 @@ module Lostcoords::Ui
35
35
  end
36
36
 
37
37
  def lc_menu_option_class(tone: :default, extra: nil)
38
- lc_tw(MENU_OPTION_BASE, MENU_OPTION_TONES.fetch(tone), extra)
38
+ lc_tw(MENU_OPTION_BASE, MENU_OPTION_TONES.fetch(lc_option!(:tone, tone, MENU_OPTION_TONES.keys)), extra)
39
+ end
40
+
41
+ def lc_menu(id:, label: lc_t("lostcoords.ui.menu.label"), align: :end, **attributes, &block)
42
+ align = lc_option!(:align, align, MENU_POPOVER_ALIGN.keys)
43
+ lc_render_panel(id, label, nil, "lc-menu", "menu", lc_menu_popover_class(align: align), lc_attributes({ data: { slot: "menu" } }, attributes), &block)
44
+ end
45
+
46
+ def lc_popover(id:, label: lc_t("lostcoords.ui.popover.trigger"), title: nil, align: :end, trigger_variant: :secondary, **attributes, &block)
47
+ title = title.presence
48
+ align = lc_option!(:align, align, MENU_POPOVER_ALIGN.keys)
49
+ classes = lc_popover_class(align: align, extra: "data-open:flex flex-col gap-content p-inset z-(--lc-z-popover) min-w-64 max-w-[calc(100vw-2rem)]")
50
+ lc_render_panel(id, label, title, "lc-popover", "dialog", classes, lc_attributes({ data: { slot: "popover" } }, attributes), trigger_variant: trigger_variant, &block)
51
+ end
52
+
53
+ def lc_menu_option(label = nil, href: nil, tone: :default, **attributes, &block)
54
+ tone = lc_option!(:tone, tone, MENU_OPTION_TONES.keys)
55
+ content = tag.span(block ? capture(&block) : label, class: "col-span-3")
56
+ defaults = { class: lc_menu_option_class(tone: tone), role: "menuitem", data: { slot: "menu-item", action: "click->lc-menu#close" } }
57
+ if href
58
+ raise ArgumentError, "Links cannot be disabled; use a menu button instead" if attributes[:disabled] || attributes["disabled"]
59
+
60
+ tag.a(content, **lc_attributes(defaults.merge(href: href), attributes))
61
+ else
62
+ tag.button(content, **lc_attributes(defaults.merge(type: "button"), attributes))
63
+ end
64
+ end
65
+
66
+ private
67
+ def lc_render_panel(id, label, title, controller, role, classes, attributes, trigger_variant: :secondary, &block)
68
+ lc_label!(id, :id)
69
+ lc_label!(label, :label)
70
+ required = { id: id, role: role, aria: { labelledby: "#{id}-#{title ? 'title' : 'trigger'}" }, data: { "#{controller}_target" => "popover" } }
71
+ required[:tabindex] = -1 if role == "dialog"
72
+ merged = lc_attributes({ class: classes }, attributes, required)
73
+ render "lostcoords/ui/components/lc_floating_panel", id: id, label: label, title: title, controller: controller, role: role, trigger_variant: trigger_variant, attributes: merged, content: (capture(&block) if block)
39
74
  end
40
75
  end
41
76
  end
@@ -99,10 +99,20 @@ module Lostcoords::Ui
99
99
  end
100
100
  end
101
101
 
102
+ TABS_ORIENTATIONS = {
103
+ horizontal: { root: "flex min-w-0 flex-col gap-content", list: "max-w-full overflow-x-auto lc-scroll-fade-x" },
104
+ vertical: { root: "flex min-w-0 flex-row items-start gap-content", list: "shrink-0 flex-col items-stretch [&>[role=tab]]:justify-start" }
105
+ }.freeze
106
+
102
107
  # Yields a builder; each `tabs.panel "Label" do … end` adds a tab and its panel.
103
- def lc_tabs(id:, label:, selected: nil, **attributes, &block)
108
+ # orientation: :vertical stacks the tabs beside the panels and moves them with Arrow Up and Down.
109
+ # activation: :manual moves focus with the arrows and selects with Enter or Space.
110
+ def lc_tabs(id:, label:, selected: nil, orientation: :horizontal, activation: :automatic, **attributes, &block)
104
111
  lc_label!(id, :id)
105
112
  lc_label!(label, :label)
113
+ orientation = lc_option!(:orientation, orientation, TABS_ORIENTATIONS.keys)
114
+ activation = lc_option!(:activation, activation, %i[automatic manual])
115
+ layout = TABS_ORIENTATIONS.fetch(orientation)
106
116
  tabs = Parts::Tabs.new(self)
107
117
  capture(tabs, &block) if block
108
118
  raise ArgumentError, "lc_tabs needs at least one panel" if tabs.panels.empty?
@@ -111,7 +121,9 @@ module Lostcoords::Ui
111
121
  raise ArgumentError, "selected tab #{selected.inspect} has no panel" unless tabs.panels.any? { |panel| panel.value == selected }
112
122
 
113
123
  key = ->(panel) { "#{id}-#{panel.value.gsub(/[^\w-]+/, '-')}" }
114
- list = tag.div(role: "tablist", aria: { label: label }, class: lc_toggle_group_class(extra: "max-w-full overflow-x-auto lc-scroll-fade-x"), data: { slot: "tabs-list" }) do
124
+ ids = tabs.panels.map(&key)
125
+ raise ArgumentError, "tab values #{tabs.panels.map(&:value).inspect} produce duplicate ids" unless ids.uniq.size == ids.size
126
+ list = tag.div(role: "tablist", aria: { label: label, orientation: (orientation if orientation == :vertical) }, class: lc_toggle_group_class(extra: layout[:list]), data: { slot: "tabs-list" }) do
115
127
  safe_join(tabs.panels.map do |panel|
116
128
  active = panel.value == selected
117
129
  tag.button(panel.label, type: "button", role: "tab", id: "#{key.(panel)}-tab", tabindex: (active ? 0 : -1),
@@ -121,9 +133,10 @@ module Lostcoords::Ui
121
133
  end
122
134
  panels = tabs.panels.map do |panel|
123
135
  tag.div(panel.content, **lc_attributes({ role: "tabpanel", id: "#{key.(panel)}-panel", tabindex: 0, hidden: (panel.value != selected),
124
- aria: { labelledby: "#{key.(panel)}-tab" }, class: "min-w-0 rounded-lg outline-offset-4", data: { slot: "tabs-panel", lc_tabs_target: "panel" } }, panel.attributes))
136
+ aria: { labelledby: "#{key.(panel)}-tab" }, class: lc_tw("min-w-0 rounded-lg outline-offset-4", ("flex-1" if orientation == :vertical)), data: { slot: "tabs-panel", lc_tabs_target: "panel" } }, panel.attributes))
125
137
  end
126
- tag.div(**lc_attributes({ id: id, class: "flex min-w-0 flex-col gap-content", data: { slot: "tabs", controller: "lc-tabs" } }, attributes)) do
138
+ data = { slot: "tabs", controller: "lc-tabs", lc_tabs_activation_value: (activation.to_s if activation == :manual) }.compact
139
+ tag.div(**lc_attributes({ id: id, class: layout[:root], data: data }, attributes)) do
127
140
  safe_join([ list, *panels ])
128
141
  end
129
142
  end
@@ -1,7 +1,10 @@
1
1
  module Lostcoords::Ui
2
2
  module RenderAttributesHelper
3
3
  private
4
- def lc_attributes(defaults, attributes)
4
+ # required: attributes the component needs to work, merged last so a caller can't override them.
5
+ def lc_attributes(defaults, attributes, required = nil)
6
+ return lc_attributes(lc_attributes(defaults, attributes), required) if required
7
+
5
8
  defaults = lc_normalize_attributes(defaults)
6
9
  attributes = lc_normalize_attributes(attributes)
7
10
  merged = defaults.merge(attributes)
@@ -36,6 +39,19 @@ module Lostcoords::Ui
36
39
  normalized
37
40
  end
38
41
 
42
+ def lc_label!(value, name)
43
+ raise ArgumentError, "#{name} must not be blank" if value.blank?
44
+ end
45
+
46
+ # The hint and error paragraphs under a field or choice group. With an id, controls can point
47
+ # aria-describedby at them.
48
+ def lc_field_messages(id, hint, errors)
49
+ [
50
+ (tag.p(hint, id: ("#{id}-hint" if id), class: "m-0 text-small text-muted-foreground", data: { slot: "field-description" }) if hint.present?),
51
+ (tag.p(safe_join(errors, tag.br), id: ("#{id}-error" if id), class: "m-0 text-small text-destructive", role: "alert", data: { slot: "field-error" }) if errors.any?)
52
+ ].compact
53
+ end
54
+
39
55
  def lc_option!(name, value, allowed)
40
56
  normalized = value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
41
57
  return normalized if allowed.include?(normalized)
@@ -2,7 +2,11 @@ module Lostcoords::Ui
2
2
  module SheetHelper
3
3
  # Makes a <dialog> an iOS sheet on phones (lc-sheet controller), keeping the dialog's own
4
4
  # controllers. detents: "fit", "medium large" or "large".
5
+ SHEET_DETENTS = [ "fit", "medium large", "large" ].freeze
6
+
5
7
  def lc_sheet_data(data = {}, detents: "fit")
8
+ detents = lc_option!(:detents, detents, SHEET_DETENTS.map(&:to_sym)).to_s
9
+ data = data.to_h.symbolize_keys
6
10
  data.merge(
7
11
  controller: [ data[:controller], "lc-sheet" ].compact.join(" "),
8
12
  lc_sheet_detents_value: detents
@@ -1,7 +1,6 @@
1
1
  module Lostcoords::Ui
2
2
  module StructuresHelper
3
3
  INPUT_TYPES = %i[text search email password tel url number date time datetime-local month week].freeze
4
- SHEET_DETENTS = [ "fit", "medium large", "large" ].freeze
5
4
 
6
5
  def lc_input(name: nil, value: nil, type: :text, size: :md, **attributes)
7
6
  type = lc_option!(:type, type, INPUT_TYPES)
@@ -39,7 +38,7 @@ module Lostcoords::Ui
39
38
  def lc_toggle_group(label:, name: nil, value: nil, **attributes, &block)
40
39
  lc_label!(label, :label)
41
40
  defaults = { class: lc_toggle_group_class, role: "group", aria: { label: label }, data: { slot: "toggle-group", controller: "lc-selection", action: "click->lc-selection#select keydown->lc-selection#navigate", lc_selection_value_value: value } }
42
- tag.div(**lc_attributes(lc_attributes(defaults, attributes), { role: "group", aria: { label: label } })) do
41
+ tag.div(**lc_attributes(defaults, attributes, { role: "group", aria: { label: label } })) do
43
42
  safe_join([ (hidden_field_tag(name, value, data: { lc_selection_target: "input" }) if name), (capture(&block) if block) ].compact)
44
43
  end
45
44
  end
@@ -49,7 +48,7 @@ module Lostcoords::Ui
49
48
  lc_label!(value, :value)
50
49
  defaults = { class: lc_toggle_group_item_class, type: "button", aria: { pressed: selected }, data: { slot: "toggle-group-item", lc_selection_target: "option", value: value } }
51
50
  required = { type: "button", data: { lc_selection_target: "option", value: value } }
52
- tag.button(block ? capture(&block) : label, **lc_attributes(lc_attributes(defaults, attributes), required))
51
+ tag.button(block ? capture(&block) : label, **lc_attributes(defaults, attributes, required))
53
52
  end
54
53
 
55
54
  def lc_toggle(label = nil, active: false, ghost: false, size: :md, **attributes, &block)
@@ -57,94 +56,5 @@ module Lostcoords::Ui
57
56
  defaults = { class: lc_toggle_class(ghost: ghost, size: size), type: "button", aria: { pressed: active }, data: { slot: "toggle", controller: "lc-toggle", action: "click->lc-toggle#toggle" } }
58
57
  tag.button(block ? capture(&block) : label, **lc_attributes(defaults, attributes).merge(type: "button"))
59
58
  end
60
-
61
- # Yields a builder with footer and close parts.
62
- def lc_dialog(id:, title:, description: nil, trigger: lc_t("lostcoords.ui.dialog.trigger"), kind: :modal, detents: "fit", **attributes, &block)
63
- kind = lc_option!(:kind, kind, DialogHelper::DIALOGS.keys)
64
- detents = detents.to_s
65
- raise ArgumentError, "detents must be one of #{SHEET_DETENTS.join(', ')}" unless SHEET_DETENTS.include?(detents)
66
- defaults = { class: lc_dialog_class(kind), aria: { describedby: ("#{id}-description" if description.present?) }, data: lc_sheet_data({ slot: "dialog" }, detents: detents) }
67
- lc_render_dialog(id, title, description, trigger, defaults, attributes, &block)
68
- end
69
-
70
- def lc_menu(id:, label: lc_t("lostcoords.ui.menu.label"), align: :end, **attributes, &block)
71
- align = lc_option!(:align, align, MenuHelper::MENU_POPOVER_ALIGN.keys)
72
- lc_render_panel(id, label, nil, "lc-menu", "menu", lc_menu_popover_class(align: align), lc_attributes({ data: { slot: "menu" } }, attributes), &block)
73
- end
74
-
75
- def lc_popover(id:, label: lc_t("lostcoords.ui.popover.trigger"), title: nil, align: :end, trigger_variant: :secondary, **attributes, &block)
76
- title = title.presence
77
- align = lc_option!(:align, align, MenuHelper::MENU_POPOVER_ALIGN.keys)
78
- classes = lc_popover_class(align: align, extra: "data-open:flex flex-col gap-content p-inset z-(--lc-z-popover) min-w-64 max-w-[calc(100vw-2rem)]")
79
- lc_render_panel(id, label, title, "lc-popover", "dialog", classes, lc_attributes({ data: { slot: "popover" } }, attributes), trigger_variant: trigger_variant, &block)
80
- end
81
-
82
- def lc_menu_option(label = nil, href: nil, tone: :default, **attributes, &block)
83
- tone = lc_option!(:tone, tone, MenuHelper::MENU_OPTION_TONES.keys)
84
- content = tag.span(block ? capture(&block) : label, class: "col-span-3")
85
- defaults = { class: lc_menu_option_class(tone: tone), role: "menuitem", data: { slot: "menu-item", action: "click->lc-menu#close" } }
86
- if href
87
- raise ArgumentError, "Links cannot be disabled; use a menu button instead" if attributes[:disabled] || attributes["disabled"]
88
-
89
- tag.a(content, **lc_attributes(defaults.merge(href: href), attributes))
90
- else
91
- tag.button(content, **lc_attributes(defaults.merge(type: "button"), attributes))
92
- end
93
- end
94
-
95
- def lc_toast(message = nil, tone: :success, timeout: 4000, **attributes, &block)
96
- tone = lc_option!(:tone, tone, FeedbackHelper::ALERT_ICONS.keys)
97
- raise ArgumentError, "timeout must be a nonnegative integer" unless timeout.is_a?(Integer) && timeout >= 0
98
-
99
- defaults = { class: lc_glass_class(shape: :hud, extra: "flex items-center gap-related px-content py-heading text-label font-semibold transition-opacity data-leaving:opacity-0"), role: tone == :destructive ? "alert" : "status", data: { slot: "toast", tone: tone, controller: "lc-toast", action: "click->lc-toast#dismiss", lc_toast_timeout_value: timeout } }
100
- tag.div(**lc_attributes(defaults, attributes)) do
101
- safe_join([ lc_alert_icon(tone: tone), tag.span(block ? capture(&block) : message) ])
102
- end
103
- end
104
-
105
- def lc_collapsible(title:, open: false, **attributes, &block)
106
- lc_label!(title, :title)
107
- defaults = { class: lc_card_class(extra: "lc-collapsible"), open: open, data: { slot: "collapsible", controller: "lc-collapsible", action: "click->lc-collapsible#toggle" } }
108
- render "lostcoords/ui/components/lc_collapsible", title: title, attributes: lc_attributes(defaults, attributes), content: (capture(&block) if block)
109
- end
110
-
111
- def lc_emoji_picker(form:, field:, label:, catalog_url:, value: nil, quick: [], recent: [], recent_scope: nil, auto_submit: false, **attributes)
112
- lc_label!(label, :label)
113
- lc_label!(catalog_url, :catalog_url)
114
- render "lostcoords/ui/components/emoji_picker", form: form, field: field, label: label, catalog_url: catalog_url, value: value, quick: quick, recent: recent, recent_scope: recent_scope, auto_submit: auto_submit, attributes: attributes
115
- end
116
-
117
- private
118
- # The hint and error paragraphs under a field or choice group. With an id, controls can point
119
- # aria-describedby at them.
120
- def lc_field_messages(id, hint, errors)
121
- [
122
- (tag.p(hint, id: ("#{id}-hint" if id), class: "m-0 text-small text-muted-foreground", data: { slot: "field-description" }) if hint.present?),
123
- (tag.p(safe_join(errors, tag.br), id: ("#{id}-error" if id), class: "m-0 text-small text-destructive", role: "alert", data: { slot: "field-error" }) if errors.any?)
124
- ].compact
125
- end
126
-
127
-
128
- def lc_label!(value, name)
129
- raise ArgumentError, "#{name} must not be blank" if value.blank?
130
- end
131
-
132
- def lc_render_dialog(id, title, description, trigger, defaults, attributes, &block)
133
- lc_label!(id, :id)
134
- lc_label!(title, :title)
135
- lc_label!(trigger, :trigger)
136
- required = { id: id, aria: { labelledby: "#{id}-title" }, data: (defaults[:data] || {}).merge(lc_dialog_target: "dialog") }
137
- merged = lc_attributes(lc_attributes(defaults, attributes), required)
138
- render "lostcoords/ui/components/lc_dialog", id: id, title: title, description: description, trigger: trigger, attributes: merged, content: (capture(Parts::Dialog.new(self, id), &block) if block)
139
- end
140
-
141
- def lc_render_panel(id, label, title, controller, role, classes, attributes, trigger_variant: :secondary, &block)
142
- lc_label!(id, :id)
143
- lc_label!(label, :label)
144
- required = { id: id, role: role, aria: { labelledby: "#{id}-#{title ? 'title' : 'trigger'}" }, data: { "#{controller}_target" => "popover" } }
145
- required[:tabindex] = -1 if role == "dialog"
146
- merged = lc_attributes(lc_attributes({ class: classes }, attributes), required)
147
- render "lostcoords/ui/components/lc_floating_panel", id: id, label: label, title: title, controller: controller, role: role, trigger_variant: trigger_variant, attributes: merged, content: (capture(&block) if block)
148
- end
149
59
  end
150
60
  end
@@ -0,0 +1,22 @@
1
+ module Lostcoords::Ui
2
+ module ToastHelper
3
+ # Render toasts inside lc_toast_region, the live region that announces them. A destructive toast
4
+ # is also an alert, so it interrupts.
5
+ def lc_toast(message = nil, tone: :success, timeout: 4000, **attributes, &block)
6
+ tone = lc_option!(:tone, tone, FeedbackHelper::ALERT_ICONS.keys)
7
+ raise ArgumentError, "timeout must be a nonnegative integer" unless timeout.is_a?(Integer) && timeout >= 0
8
+
9
+ defaults = { class: lc_glass_class(shape: :hud, extra: "flex items-center gap-related px-content py-heading text-label font-semibold transition-opacity data-leaving:opacity-0"), role: ("alert" if tone == :destructive), data: { slot: "toast", tone: tone, controller: "lc-toast", action: "click->lc-toast#dismiss", lc_toast_timeout_value: timeout } }
10
+ tag.div(**lc_attributes(defaults, attributes)) do
11
+ safe_join([ lc_alert_icon(tone: tone), tag.span(block ? capture(&block) : message) ])
12
+ end
13
+ end
14
+
15
+ # Fixed corner that holds toasts. Render it once in the layout and append toasts to it, from flash
16
+ # or a Turbo Stream; it shows in the top layer, above dialogs and sticky chrome.
17
+ def lc_toast_region(**attributes, &block)
18
+ defaults = { class: "lc-toast-region", role: "status", aria: { live: "polite" }, data: { slot: "toast-region", controller: "lc-toast-region" } }
19
+ tag.div(**lc_attributes(defaults, attributes)) { capture(&block) if block }
20
+ end
21
+ end
22
+ end
@@ -1,5 +1,5 @@
1
1
  module Lostcoords::Ui
2
- module OverlayHelper
2
+ module TooltipHelper
3
3
  TOOLTIP_SIDES = %i[top bottom].freeze
4
4
 
5
5
  # Yields attributes for the trigger; splat them with `**tip` so the trigger is described by the tooltip.
@@ -9,8 +9,8 @@ module Lostcoords::Ui
9
9
  lc_label!(id, :id)
10
10
  side = lc_option!(:side, side, TOOLTIP_SIDES)
11
11
  trigger = capture({ aria: { describedby: id } }, &block) if block
12
- tag.span(**lc_attributes({ class: "lc-tooltip relative inline-flex", data: { slot: "tooltip", side: side, controller: "lc-tooltip", action: "keydown@window->lc-tooltip#dismiss mouseleave->lc-tooltip#reset focusout->lc-tooltip#reset" } }, attributes)) do
13
- safe_join([ trigger, tag.span(text, id: id, role: "tooltip", class: "lc-tooltip__content", data: { slot: "tooltip-content" }) ].compact)
12
+ tag.span(**lc_attributes({ class: "lc-tooltip relative inline-flex", data: { slot: "tooltip", side: side, controller: "lc-tooltip", action: "mouseenter->lc-tooltip#show focusin->lc-tooltip#show mouseleave->lc-tooltip#hide focusout->lc-tooltip#hide" } }, attributes)) do
13
+ safe_join([ trigger, tag.span(text, id: id, role: "tooltip", class: "lc-tooltip__content", data: { slot: "tooltip-content", lc_tooltip_target: "content" }) ].compact)
14
14
  end
15
15
  end
16
16
  end
@@ -3,6 +3,10 @@ module Lostcoords::Ui
3
3
  HEADING_SIZES = { display: "text-display", title: "text-title", heading: "text-heading", subheading: "text-subheading" }.freeze
4
4
  TEXT_SIZES = { body: "text-body", small: "text-small", label: "text-label", caption: "text-caption" }.freeze
5
5
  TEXT_TONES = { default: "text-foreground", muted: "text-muted-foreground" }.freeze
6
+ KBD_BASE = "lc-kbd inline-flex items-center rounded-md bg-tile px-1.5 font-sans text-caption leading-5 whitespace-nowrap text-muted-foreground"
7
+ KBD_NAMES = { "mod" => "Ctrl", "ctrl" => "Ctrl", "alt" => "Alt", "shift" => "Shift", "meta" => "Meta", "escape" => "Esc", "enter" => "↵",
8
+ "arrowup" => "↑", "arrowdown" => "↓", "arrowleft" => "←", "arrowright" => "→" }.freeze
9
+ KBD_MODIFIERS = %w[mod ctrl alt shift meta].freeze
6
10
 
7
11
  def lc_heading_class(size: :heading, trim: false, extra: nil)
8
12
  size = lc_option!(:size, size, HEADING_SIZES.keys)
@@ -15,6 +19,10 @@ module Lostcoords::Ui
15
19
  lc_tw("lc-text m-0", TEXT_SIZES.fetch(size), TEXT_TONES.fetch(tone), ("lc-trim" if trim), extra)
16
20
  end
17
21
 
22
+ def lc_kbd_class(extra: nil)
23
+ lc_tw(KBD_BASE, extra)
24
+ end
25
+
18
26
  def lc_heading(content = nil, level: 2, size: :heading, trim: false, **attributes, &block)
19
27
  raise ArgumentError, "Invalid level: #{level.inspect}. Expected an integer from 1 to 6" unless (1..6).include?(level) && level.is_a?(Integer)
20
28
 
@@ -24,5 +32,27 @@ module Lostcoords::Ui
24
32
  def lc_text(content = nil, size: :body, tone: :default, trim: false, **attributes, &block)
25
33
  tag.p(content, **lc_attributes({ class: lc_text_class(size: size, tone: tone, trim: trim) }, attributes), &block)
26
34
  end
35
+
36
+ # A key hint, such as lc_kbd("mod+k"). "mod" renders Ctrl and reads ⌘ on Apple platforms once JS runs.
37
+ def lc_kbd(keys, **attributes)
38
+ lc_label!(keys, :keys)
39
+ text = keys.split.map do |combo|
40
+ *modifiers, key = combo.downcase.split(/\+(?!\z)/)
41
+ names = modifiers.map { |modifier| KBD_MODIFIERS.include?(modifier) ? KBD_NAMES.fetch(modifier) : raise(ArgumentError, "Unknown modifier #{modifier.inspect} in #{keys.inspect}. Expected one of: #{KBD_MODIFIERS.join(', ')}") }
42
+ # A symbol such as ? already implies Shift; a letter or digit keeps it.
43
+ names.delete("Shift") if key.match?(/\A[^[:alnum:]]\z/)
44
+ [ *names, KBD_NAMES.fetch(key) { key.length == 1 ? key.upcase : key.capitalize } ].join(" ")
45
+ end.join(" / ")
46
+ tag.kbd(text, **lc_attributes({ class: lc_kbd_class, data: { controller: "lc-kbd", lc_kbd_keys_value: keys } }, attributes))
47
+ end
48
+
49
+ # Data for the element a page-wide shortcut focuses (fields) or clicks (anything else):
50
+ # lc_button "Search", data: lc_shortcut_data("mod+k"). Shortcuts without mod, ctrl or alt
51
+ # don't fire while typing in a field; inside an open modal, only its own shortcuts do.
52
+ def lc_shortcut_data(keys, data = {})
53
+ lc_label!(keys, :keys)
54
+ data = data.to_h.symbolize_keys
55
+ data.merge(controller: [ data[:controller], "lc-shortcut" ].compact.join(" "), lc_shortcut_keys_value: keys)
56
+ end
27
57
  end
28
58
  end
@@ -3,7 +3,7 @@ import { prefersReducedMotion } from "lostcoords/ui/helpers/viewport"
3
3
 
4
4
  // Animates a <details> both ways and keeps what it reveals on screen. Only
5
5
  // user toggles go through here: programmatic opens (validation) already scroll
6
- // to the field they care about. Durations live in ui/disclosure.css.
6
+ // to the field they care about. Durations live in ui/collapsible.css.
7
7
  export default class extends Controller {
8
8
  // Where the panel's height can't ease to `auto` in CSS (Safari), ease the
9
9
  // <details> itself between measured heights.
@@ -1,4 +1,7 @@
1
1
  import { CatalogPicker } from "lostcoords/ui/helpers/catalog_picker"
2
+ import { registerDismissable } from "lostcoords/ui/helpers/dismiss_stack"
3
+ import { menuItems, navigateMenu } from "lostcoords/ui/helpers/menu_keys"
4
+ import { navigate } from "lostcoords/ui/helpers/roving_focus"
2
5
  import { prefersReducedMotion } from "lostcoords/ui/helpers/viewport"
3
6
 
4
7
  const TONE_MODIFIERS = ["", "\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"]
@@ -25,7 +28,7 @@ export default class extends CatalogPicker {
25
28
  }
26
29
 
27
30
  static optionKey = "emoji"
28
- static catalogClass = "emoji-picker__catalog"
31
+ static catalogClass = "lc-emoji-picker__catalog"
29
32
  static previewTargets = ["previewEmoji", "previewName"]
30
33
 
31
34
  connect() {
@@ -36,6 +39,7 @@ export default class extends CatalogPicker {
36
39
  }
37
40
 
38
41
  disconnect() {
42
+ this.unregisterTones?.()
39
43
  this.clearPopover()
40
44
  super.disconnect()
41
45
  }
@@ -151,13 +155,33 @@ export default class extends CatalogPicker {
151
155
  })
152
156
  }
153
157
 
158
+ navigateTabs(event) {
159
+ navigate(event, this.tabTargets, { orientation: "horizontal", wrap: true })
160
+ }
161
+
162
+ // Escape closes the tone menu before the picker around it.
154
163
  toggleTones(event) {
155
164
  event.preventDefault()
156
165
  event.stopPropagation()
157
- const open = this.toneMenuTarget.hidden
158
- this.toneMenuTarget.hidden = !open
159
- this.toneToggleTarget.setAttribute("aria-expanded", String(open))
160
- if (open) this.syncTones()
166
+ if (!this.toneMenuTarget.hidden) return this.closeTones()
167
+
168
+ this.toneMenuTarget.hidden = false
169
+ this.toneToggleTarget.setAttribute("aria-expanded", "true")
170
+ this.syncTones()
171
+ this.unregisterTones = registerDismissable({
172
+ element: this.toneMenuTarget,
173
+ type: "popover",
174
+ close: () => this.closeTones(),
175
+ restoreFocus: () => this.toneToggleTarget.focus({ preventScroll: true }),
176
+ })
177
+ if (event.detail === 0) {
178
+ const items = menuItems(this.toneMenuTarget)
179
+ ;(items.find((item) => item.getAttribute("aria-current") === "true") || items[0])?.focus()
180
+ }
181
+ }
182
+
183
+ navigateTones(event) {
184
+ navigateMenu(event, this.toneMenuTarget, { orientation: "both" })
161
185
  }
162
186
 
163
187
  selectTone(event) {
@@ -174,7 +198,11 @@ export default class extends CatalogPicker {
174
198
 
175
199
  closeTones() {
176
200
  if (!this.hasToneMenuTarget) return
201
+ this.unregisterTones?.()
202
+ this.unregisterTones = null
203
+ const hadFocus = this.toneMenuTarget.contains(document.activeElement)
177
204
  this.toneMenuTarget.hidden = true
205
+ if (hadFocus) this.toneToggleTarget.focus({ preventScroll: true })
178
206
  this.toneToggleTarget.setAttribute("aria-expanded", "false")
179
207
  }
180
208
 
@@ -255,7 +283,7 @@ export default class extends CatalogPicker {
255
283
  renderRecent(emojis) {
256
284
  if (!this.hasRecentTarget) return
257
285
 
258
- const grid = this.recentTarget.querySelector(".emoji-picker__grid")
286
+ const grid = this.recentTarget.querySelector(".lc-emoji-picker__grid")
259
287
  const count = this.recentTarget.querySelector(".count")
260
288
  if (!grid) return
261
289
 
@@ -0,0 +1,12 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { formatShortcut } from "lostcoords/ui/helpers/shortcuts"
3
+
4
+ // Shows a key hint in the platform's own notation: "mod+k" reads ⌘ K on
5
+ // Apple platforms. The server renders the Ctrl form, which stays without JS.
6
+ export default class extends Controller {
7
+ static values = { keys: String }
8
+
9
+ keysValueChanged() {
10
+ if (this.keysValue) this.element.textContent = formatShortcut(this.keysValue)
11
+ }
12
+ }
@@ -1,5 +1,9 @@
1
1
  import { FloatingPanel } from "lostcoords/ui/helpers/floating_panel"
2
- import { focusFirstMenuItem, navigateMenu } from "lostcoords/ui/helpers/menu_keys"
2
+ import {
3
+ focusFirstMenuItem,
4
+ focusLastMenuItem,
5
+ navigateMenu,
6
+ } from "lostcoords/ui/helpers/menu_keys"
3
7
 
4
8
  // The phone dock's action stack lays itself out over the fixed nav, so it
5
9
  // passes lift: false and stays in place instead of lifting into the top layer.
@@ -9,15 +13,33 @@ export default class extends FloatingPanel {
9
13
  this.focusOutHandler = this.handleFocusOut.bind(this)
10
14
  this.pointerDownHandler = this.handlePointerDown.bind(this)
11
15
  this.keydownHandler = (event) => navigateMenu(event, this.popoverTarget)
16
+ this.triggerKeydownHandler = this.handleTriggerKeydown.bind(this)
12
17
  this.focusOutSuppressed = false
13
18
  this.focusOutSuppressionTimer = null
14
19
  }
15
20
 
21
+ connect() {
22
+ super.connect()
23
+ this.triggerTarget.addEventListener("keydown", this.triggerKeydownHandler)
24
+ }
25
+
16
26
  disconnect() {
27
+ this.triggerTarget.removeEventListener("keydown", this.triggerKeydownHandler)
17
28
  this.clearFocusOutSuppression()
18
29
  super.disconnect()
19
30
  }
20
31
 
32
+ // Arrow Down opens the menu on its first item, Arrow Up on its last.
33
+ handleTriggerKeydown(event) {
34
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
35
+ if (event.altKey || event.ctrlKey || event.metaKey) return
36
+
37
+ event.preventDefault()
38
+ this.open()
39
+ if (event.key === "ArrowDown") focusFirstMenuItem(this.popoverTarget)
40
+ else focusLastMenuItem(this.popoverTarget)
41
+ }
42
+
21
43
  toggle(event) {
22
44
  event.preventDefault()
23
45
  event.stopPropagation()
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { navigate } from "lostcoords/ui/helpers/roving_focus"
2
3
 
3
4
  export default class extends Controller {
4
5
  static targets = ["option", "input"]
@@ -28,21 +29,12 @@ export default class extends Controller {
28
29
  this.changed(option)
29
30
  }
30
31
 
32
+ // All four arrows move and select, wrapping at the ends; Left and Right
33
+ // follow the text direction.
31
34
  navigate(event) {
32
- const current = this.options.indexOf(event.target)
33
- if (current < 0) return
34
- const next = {
35
- ArrowRight: (current + 1) % this.options.length,
36
- ArrowDown: (current + 1) % this.options.length,
37
- ArrowLeft: (current - 1 + this.options.length) % this.options.length,
38
- ArrowUp: (current - 1 + this.options.length) % this.options.length,
39
- Home: 0,
40
- End: this.options.length - 1,
41
- }[event.key]
42
- if (next === undefined) return
43
- event.preventDefault()
44
- const option = this.options[next]
45
- option.focus()
35
+ const option = navigate(event, this.optionTargets, { orientation: "both", wrap: true })
36
+ if (!option) return
37
+
46
38
  this.apply(option)
47
39
  this.changed(option)
48
40
  }
@@ -340,7 +340,7 @@ export default class extends Controller {
340
340
  this.releaseScroll = holdDocumentScroll()
341
341
  const lockedTop = -parseFloat(document.body.style.top || "0")
342
342
  root.style.setProperty("--lc-sheet-page-origin", `${window.scrollY + lockedTop}px`)
343
- root.dataset.sheetPage = ""
343
+ root.dataset.lcSheetPage = ""
344
344
  this.ownsPage = true
345
345
  }
346
346
  root.style.setProperty("--lc-sheet-page-p", p.toFixed(3))
@@ -361,7 +361,7 @@ export default class extends Controller {
361
361
  if (!this.ownsPage) return
362
362
  this.ownsPage = false
363
363
  const root = document.documentElement
364
- delete root.dataset.sheetPage
364
+ delete root.dataset.lcSheetPage
365
365
  for (const name of ["p", "scale", "origin", "transition"])
366
366
  root.style.removeProperty(`--lc-sheet-page-${name}`)
367
367
  this.releaseScroll()
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { isEditable } from "lostcoords/ui/helpers/roving_focus"
3
+ import { ariaKeyShortcuts, registerShortcut } from "lostcoords/ui/helpers/shortcuts"
4
+
5
+ // Binds page-wide keys to the element: a field takes focus and selects its
6
+ // text, anything else is clicked. The element announces its keys through
7
+ // aria-keyshortcuts. See helpers/shortcuts.js for the key syntax.
8
+ export default class extends Controller {
9
+ static values = { keys: String }
10
+
11
+ // Stimulus keeps values across a reconnect (a moved or permanent element)
12
+ // without calling keysValueChanged again, so connect registers too.
13
+ connect() {
14
+ this.register()
15
+ }
16
+
17
+ keysValueChanged() {
18
+ if (this.connected) this.register()
19
+ }
20
+
21
+ register() {
22
+ this.connected = true
23
+ this.unregister?.()
24
+ this.unregister = null
25
+ if (!this.keysValue) return
26
+
27
+ this.element.setAttribute("aria-keyshortcuts", ariaKeyShortcuts(this.keysValue))
28
+ this.unregister = registerShortcut(this.element, this.keysValue, () => this.run())
29
+ }
30
+
31
+ disconnect() {
32
+ this.connected = false
33
+ this.unregister?.()
34
+ this.unregister = null
35
+ }
36
+
37
+ run() {
38
+ if (isEditable(this.element)) {
39
+ this.element.focus()
40
+ this.element.select?.()
41
+ } else {
42
+ this.element.click()
43
+ }
44
+ }
45
+ }