lostcoords-ui 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +111 -49
  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/popover.css +0 -52
  18. data/app/assets/tailwind/lostcoords/ui/{reading.css → prose.css} +6 -0
  19. data/app/assets/tailwind/lostcoords/ui/sheet.css +3 -64
  20. data/app/assets/tailwind/lostcoords/ui/theme.css +16 -1
  21. data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
  22. data/app/assets/tailwind/lostcoords/ui/tooltip.css +12 -11
  23. data/app/assets/tailwind/lostcoords/ui/typography.css +1 -7
  24. data/app/helpers/lostcoords/ui/badge_helper.rb +2 -2
  25. data/app/helpers/lostcoords/ui/button_helper.rb +12 -6
  26. data/app/helpers/lostcoords/ui/choice_helper.rb +11 -7
  27. data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
  28. data/app/helpers/lostcoords/ui/dialog_helper.rb +10 -5
  29. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
  30. data/app/helpers/lostcoords/ui/feedback_helper.rb +2 -2
  31. data/app/helpers/lostcoords/ui/form_helper.rb +4 -3
  32. data/app/helpers/lostcoords/ui/glass_helper.rb +1 -1
  33. data/app/helpers/lostcoords/ui/menu_helper.rb +37 -2
  34. data/app/helpers/lostcoords/ui/navigation_helper.rb +17 -4
  35. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
  36. data/app/helpers/lostcoords/ui/sheet_helper.rb +4 -0
  37. data/app/helpers/lostcoords/ui/structures_helper.rb +7 -95
  38. data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
  39. data/app/helpers/lostcoords/ui/{overlay_helper.rb → tooltip_helper.rb} +3 -3
  40. data/app/helpers/lostcoords/ui/typography_helper.rb +30 -0
  41. data/app/javascript/lostcoords/ui/controllers/lc_collapsible_controller.js +1 -1
  42. data/app/javascript/lostcoords/ui/controllers/lc_emoji_picker_controller.js +34 -6
  43. data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
  44. data/app/javascript/lostcoords/ui/controllers/lc_menu_controller.js +23 -1
  45. data/app/javascript/lostcoords/ui/controllers/lc_selection_controller.js +8 -16
  46. data/app/javascript/lostcoords/ui/controllers/lc_sheet_controller.js +15 -3
  47. data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
  48. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +18 -15
  49. data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
  50. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +46 -6
  51. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +72 -20
  52. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +23 -0
  53. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +6 -7
  54. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
  55. data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
  56. data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
  57. data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
  58. data/app/javascript/lostcoords/ui/index.js +6 -0
  59. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +27 -27
  60. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
  61. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +1 -1
  62. data/lib/lostcoords/ui/action_view.rb +1 -0
  63. data/lib/lostcoords/ui/builders.rb +2 -2
  64. data/lib/lostcoords/ui/form_builder.rb +55 -10
  65. data/lib/lostcoords/ui/stylesheets.rb +25 -0
  66. data/lib/lostcoords/ui/version.rb +1 -1
  67. data/lib/tasks/lostcoords_ui.rake +11 -0
  68. metadata +23 -6
  69. data/config/helper_renames.json +0 -111
  70. data/script/migrate_helpers.rb +0 -136
@@ -6,7 +6,7 @@ module Lostcoords::Ui
6
6
  CHOICE_LABEL = "lc-choice inline-flex min-w-0 items-start gap-related text-body text-foreground cursor-pointer has-disabled:cursor-not-allowed has-disabled:opacity-60"
7
7
 
8
8
  # card: true draws the whole label as a selectable tile, for plan or theme pickers.
9
- CHOICE_CARD = "flex w-full rounded-2xl lc-squircle border border-transparent bg-(--lc-tile-bg,var(--muted)) p-content " \
9
+ CHOICE_CARD = "flex w-full rounded-2xl lc-squircle border border-transparent bg-tile p-content " \
10
10
  "transition-[background-color,border-color] duration-(--lc-motion-quick) ease-(--lc-ease-out) hover:bg-accent " \
11
11
  "has-checked:border-primary has-checked:bg-primary-soft has-focus-visible:outline-2 has-focus-visible:outline-offset-2 " \
12
12
  "has-focus-visible:outline-(--ring,color-mix(in_oklab,var(--primary)_50%,transparent))"
@@ -42,15 +42,18 @@ module Lostcoords::Ui
42
42
  lc_choice("switch", label, hint, false, name, unchecked_value, { type: "checkbox", role: "switch", name: name, value: value, checked: checked, class: lc_switch_class }, attributes)
43
43
  end
44
44
 
45
- # A fieldset of checkboxes, radios or switches with a legend, hint and error.
46
- def lc_choice_group(legend:, hint: nil, error: nil, **attributes, &block)
45
+ # A fieldset of checkboxes, radios or switches with a legend, hint and error. With an id, the
46
+ # fieldset's aria-describedby points at the hint and error, as lc_field does for its control.
47
+ def lc_choice_group(legend:, id: nil, hint: nil, error: nil, **attributes, &block)
47
48
  lc_label!(legend, :legend)
48
49
  errors = Array(error).compact_blank
49
- tag.fieldset(**lc_attributes({ class: lc_field_class(extra: "m-0 min-w-0 border-0 p-0"), aria: { invalid: (true if errors.any?) }, data: { slot: "choice-group" } }, attributes)) do
50
+ described = [ ("#{id}-hint" if id && hint.present?), ("#{id}-error" if id && errors.any?) ].compact.presence&.join(" ")
51
+ defaults = { id: id, class: lc_field_class(extra: "m-0 min-w-0 border-0 p-0"), aria: { invalid: (true if errors.any?), describedby: described }, data: { slot: "choice-group", invalid: (true if errors.any?) } }
52
+ tag.fieldset(**lc_attributes(defaults, attributes)) do
50
53
  safe_join([
51
- tag.legend(legend, class: lc_field_label_class(extra: "p-0")),
54
+ tag.legend(legend, class: lc_field_label_class(extra: "p-0"), data: { slot: "choice-group-legend" }),
52
55
  tag.div(class: "flex flex-col gap-related", &block),
53
- *lc_field_messages(nil, hint, errors)
56
+ *lc_field_messages(id, hint, errors)
54
57
  ])
55
58
  end
56
59
  end
@@ -64,7 +67,7 @@ module Lostcoords::Ui
64
67
  end
65
68
 
66
69
  # Shared with Lostcoords::Ui::FormBuilder, which renders its own inputs.
67
- def lc_choice_label(slot, label, hint, input, card: false, **attributes, &block)
70
+ def lc_choice_label(slot, label, hint, input, card: false, errors: [], error_id: nil, **attributes, &block)
68
71
  extra = capture(&block) if block
69
72
  tag.label(**lc_attributes({ class: lc_choice_label_class(card: card), data: { slot: slot, card: (true if card) } }, attributes)) do
70
73
  safe_join([
@@ -73,6 +76,7 @@ module Lostcoords::Ui
73
76
  safe_join([
74
77
  tag.span(label, class: ("text-label" if card)),
75
78
  (tag.span(hint, class: "text-small text-muted-foreground") if hint.present?),
79
+ (tag.span(safe_join(errors, tag.br), id: error_id, class: "text-small text-destructive", data: { slot: "field-error" }) if errors.any?),
76
80
  (tag.span(extra, class: "flex min-w-0 flex-col pt-tight") if extra.present?)
77
81
  ].compact)
78
82
  end
@@ -0,0 +1,9 @@
1
+ module Lostcoords::Ui
2
+ module CollapsibleHelper
3
+ def lc_collapsible(title:, open: false, **attributes, &block)
4
+ lc_label!(title, :title)
5
+ defaults = { class: lc_card_class(extra: "lc-collapsible"), open: open, data: { slot: "collapsible", controller: "lc-collapsible", action: "click->lc-collapsible#toggle" } }
6
+ render "lostcoords/ui/components/lc_collapsible", title: title, attributes: lc_attributes(defaults, attributes), content: (capture(&block) if block)
7
+ end
8
+ end
9
+ end
@@ -14,13 +14,18 @@ module Lostcoords::Ui
14
14
  }.freeze
15
15
 
16
16
  def lc_dialog_class(kind = :modal, extra: nil)
17
- lc_tw("lc-squircle", lc_dialog_kinds.fetch(kind), extra)
17
+ lc_tw("lc-squircle", DIALOGS.fetch(lc_option!(:kind, kind, DIALOGS.keys)), extra)
18
18
  end
19
19
 
20
- private
21
-
22
- def lc_dialog_kinds
23
- DIALOGS
20
+ # Yields a builder with footer and close parts.
21
+ def lc_dialog(id:, title:, description: nil, trigger: lc_t("lostcoords.ui.dialog.trigger"), kind: :modal, detents: "fit", **attributes, &block)
22
+ lc_label!(id, :id)
23
+ lc_label!(title, :title)
24
+ lc_label!(trigger, :trigger)
25
+ defaults = { class: lc_dialog_class(kind), aria: { describedby: ("#{id}-description" if description.present?) }, data: lc_sheet_data({ slot: "dialog" }, detents: detents) }
26
+ required = { id: id, aria: { labelledby: "#{id}-title" }, data: { lc_dialog_target: "dialog" } }
27
+ render "lostcoords/ui/components/lc_dialog", id: id, title: title, description: description, trigger: trigger,
28
+ attributes: lc_attributes(defaults, attributes, required), content: (capture(Parts::Dialog.new(self, id), &block) if block)
24
29
  end
25
30
  end
26
31
  end
@@ -6,6 +6,12 @@ module Lostcoords::Ui
6
6
  module EmojiPickerHelper
7
7
  include TranslationHelper
8
8
 
9
+ def lc_emoji_picker(form:, field:, label:, catalog_url:, value: nil, quick: [], recent: [], recent_scope: nil, auto_submit: false, **attributes)
10
+ lc_label!(label, :label)
11
+ lc_label!(catalog_url, :catalog_url)
12
+ 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
13
+ end
14
+
9
15
  SEARCH_NAMES_PATH = Lostcoords::Ui.root.join("config/emoji_search_names.yml")
10
16
  SEARCH_NAMES_JA_PATH = Lostcoords::Ui.root.join("config/emoji_search_names_ja.yml")
11
17
  SEARCH_ALIASES_PATH = Lostcoords::Ui.root.join("config/emoji_search_aliases.yml")
@@ -9,13 +9,13 @@ module Lostcoords::Ui
9
9
  lg: "size-8 border-[3px]"
10
10
  }.freeze
11
11
 
12
- SKELETON_BASE = "block rounded-xl bg-muted animate-pulse"
12
+ SKELETON_BASE = "block rounded-xl bg-tile animate-pulse"
13
13
 
14
14
  EMPTY_STATE_BASE =
15
15
  "flex flex-col items-center gap-content text-center " \
16
16
  "py-section px-content"
17
17
 
18
- ALERT_BASE = "flex items-start gap-related py-heading px-content rounded-2xl lc-squircle bg-muted text-foreground text-label"
18
+ ALERT_BASE = "flex items-start gap-related py-heading px-content rounded-2xl lc-squircle bg-tile text-foreground text-label"
19
19
  ALERT_ICONS = {
20
20
  neutral: [ "information-circle", "text-muted-foreground" ],
21
21
  primary: [ "information-circle", "text-primary" ],
@@ -1,18 +1,19 @@
1
1
  module Lostcoords::Ui
2
2
  module FormHelper
3
3
  TOGGLE_GROUP_BASE =
4
- "inline-flex items-center gap-1 rounded-2xl bg-muted p-1 lc-squircle"
4
+ "inline-flex items-center gap-1 rounded-2xl bg-track p-1 lc-squircle"
5
5
 
6
6
  TOGGLE_GROUP_ITEM_BASE =
7
7
  "lc-press inline-flex items-center justify-center gap-1.5 whitespace-nowrap " \
8
8
  "h-control-sm rounded-xl border-0 bg-transparent " \
9
9
  "px-3 font-sans text-label cursor-pointer no-underline " \
10
- "text-muted-foreground hover:text-foreground " \
10
+ "text-muted-foreground not-disabled:hover:text-foreground " \
11
11
  "aria-selected:bg-background aria-selected:text-foreground " \
12
+ "aria-checked:bg-background aria-checked:text-foreground " \
12
13
  "aria-pressed:bg-background aria-pressed:text-foreground " \
13
14
  "aria-[current]:not-aria-[current=false]:bg-background aria-[current]:not-aria-[current=false]:text-foreground " \
14
15
  "transition-[background-color,color,box-shadow,transform] duration-(--lc-motion-quick) ease-(--lc-ease-out) " \
15
- "disabled:opacity-40 disabled:pointer-events-none " \
16
+ "disabled:cursor-not-allowed disabled:text-[color-mix(in_oklab,var(--foreground)_38%,transparent)] " \
16
17
  "[&_svg]:size-[1.1em] [&_svg]:shrink-0"
17
18
 
18
19
  TOGGLE_GROUP_ITEM_ACTIVE = "bg-background text-foreground hover:text-foreground"
@@ -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)
@@ -36,10 +35,12 @@ module Lostcoords::Ui
36
35
  end
37
36
  end
38
37
 
38
+ # One choice from a few options, as a segmented control. Arrows move and select, like native radios,
39
+ # so it is a radiogroup of radio buttons to assistive technology.
39
40
  def lc_toggle_group(label:, name: nil, value: nil, **attributes, &block)
40
41
  lc_label!(label, :label)
41
- 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
42
+ defaults = { class: lc_toggle_group_class, role: "radiogroup", aria: { label: label }, data: { slot: "toggle-group", controller: "lc-selection", action: "click->lc-selection#select keydown->lc-selection#navigate", lc_selection_value_value: value } }
43
+ tag.div(**lc_attributes(defaults, attributes, { role: "radiogroup", aria: { label: label } })) do
43
44
  safe_join([ (hidden_field_tag(name, value, data: { lc_selection_target: "input" }) if name), (capture(&block) if block) ].compact)
44
45
  end
45
46
  end
@@ -47,9 +48,9 @@ module Lostcoords::Ui
47
48
  def lc_toggle_group_item(label = nil, value: nil, selected: false, **attributes, &block)
48
49
  value ||= label
49
50
  lc_label!(value, :value)
50
- defaults = { class: lc_toggle_group_item_class, type: "button", aria: { pressed: selected }, data: { slot: "toggle-group-item", lc_selection_target: "option", value: value } }
51
- 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
+ defaults = { class: lc_toggle_group_item_class, aria: { checked: selected }, data: { slot: "toggle-group-item" } }
52
+ required = { type: "button", role: "radio", data: { lc_selection_target: "option", value: value } }
53
+ tag.button(block ? capture(&block) : label, **lc_attributes(defaults, attributes, required))
53
54
  end
54
55
 
55
56
  def lc_toggle(label = nil, active: false, ghost: false, size: :md, **attributes, &block)
@@ -57,94 +58,5 @@ module Lostcoords::Ui
57
58
  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
59
  tag.button(block ? capture(&block) : label, **lc_attributes(defaults, attributes).merge(type: "button"))
59
60
  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
61
  end
150
62
  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
+ }