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.
- checksums.yaml +4 -4
- data/README.md +102 -48
- data/app/assets/tailwind/lostcoords/ui/card.css +5 -2
- data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +1 -1
- data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
- data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
- data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
- data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
- data/app/assets/tailwind/lostcoords/ui/components.css +12 -25
- data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
- data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +45 -47
- data/app/assets/tailwind/lostcoords/ui/{reading.css → prose.css} +6 -0
- data/app/assets/tailwind/lostcoords/ui/sheet.css +3 -4
- data/app/assets/tailwind/lostcoords/ui/theme.css +16 -1
- data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
- data/app/assets/tailwind/lostcoords/ui/tooltip.css +12 -11
- data/app/assets/tailwind/lostcoords/ui/typography.css +1 -7
- data/app/helpers/lostcoords/ui/badge_helper.rb +2 -2
- data/app/helpers/lostcoords/ui/button_helper.rb +12 -6
- data/app/helpers/lostcoords/ui/choice_helper.rb +11 -7
- data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
- data/app/helpers/lostcoords/ui/dialog_helper.rb +10 -5
- data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
- data/app/helpers/lostcoords/ui/feedback_helper.rb +2 -2
- data/app/helpers/lostcoords/ui/form_helper.rb +1 -1
- data/app/helpers/lostcoords/ui/glass_helper.rb +1 -1
- data/app/helpers/lostcoords/ui/menu_helper.rb +37 -2
- data/app/helpers/lostcoords/ui/navigation_helper.rb +17 -4
- data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
- data/app/helpers/lostcoords/ui/sheet_helper.rb +4 -0
- data/app/helpers/lostcoords/ui/structures_helper.rb +2 -92
- data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
- data/app/helpers/lostcoords/ui/{overlay_helper.rb → tooltip_helper.rb} +3 -3
- data/app/helpers/lostcoords/ui/typography_helper.rb +30 -0
- data/app/javascript/lostcoords/ui/controllers/lc_collapsible_controller.js +1 -1
- data/app/javascript/lostcoords/ui/controllers/lc_emoji_picker_controller.js +34 -6
- data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
- data/app/javascript/lostcoords/ui/controllers/lc_menu_controller.js +23 -1
- data/app/javascript/lostcoords/ui/controllers/lc_selection_controller.js +6 -14
- data/app/javascript/lostcoords/ui/controllers/lc_sheet_controller.js +2 -2
- data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +18 -15
- data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +46 -6
- data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +72 -20
- data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +23 -0
- data/app/javascript/lostcoords/ui/helpers/floating_panel.js +6 -7
- data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
- data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
- data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
- data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
- data/app/javascript/lostcoords/ui/index.js +6 -0
- data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +27 -27
- data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
- data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +1 -1
- data/lib/lostcoords/ui/action_view.rb +1 -0
- data/lib/lostcoords/ui/builders.rb +2 -2
- data/lib/lostcoords/ui/form_builder.rb +23 -10
- data/lib/lostcoords/ui/stylesheets.rb +25 -0
- data/lib/lostcoords/ui/version.rb +1 -1
- data/lib/tasks/lostcoords_ui.rake +11 -0
- metadata +23 -6
- data/config/helper_renames.json +0 -111
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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: "
|
|
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/
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
this.
|
|
160
|
-
|
|
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 {
|
|
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
|
|
33
|
-
if (
|
|
34
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|