poetry-core 0.0.2
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 +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- metadata +267 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
# The sidecar style class for a component: the dictionary from the
|
|
6
|
+
# component's style surface (declared with `style :attr, variants:` on the
|
|
7
|
+
# component) to CSS utility classes, resolved through the in-tree
|
|
8
|
+
# {CSS::Resolver}.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class Badge::Style < Poetry::Core::Style
|
|
12
|
+
# base "inline-flex items-center rounded-md"
|
|
13
|
+
# element :icon, "size-3 shrink-0"
|
|
14
|
+
# variant :color, red: "bg-destructive/15 text-destructive",
|
|
15
|
+
# gray: "bg-muted text-muted-foreground"
|
|
16
|
+
# compound({ color: :red, mode: :dark }, "bg-destructive/25")
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Defaults are NOT declared here - they live in exactly one place, the
|
|
20
|
+
# component's `style :attr, default:` (the single source of truth; the
|
|
21
|
+
# component's ActiveModel attributes resolve them before render). A
|
|
22
|
+
# `defaults` call raises to enforce that.
|
|
23
|
+
class Style
|
|
24
|
+
class << self
|
|
25
|
+
# Each Style class owns a resolver; subclasses extend a copy of their
|
|
26
|
+
# parent's dictionary.
|
|
27
|
+
#
|
|
28
|
+
# @return [CSS::Resolver]
|
|
29
|
+
def resolver
|
|
30
|
+
@resolver ||= superclass.respond_to?(:resolver) ? superclass.resolver.dup : CSS::Resolver.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# -- The dictionary DSL (delegates to the resolver) --------------------
|
|
34
|
+
|
|
35
|
+
# Declares the root element's always-present utility classes - the
|
|
36
|
+
# dictionary's base layer, emitted before any variant classes.
|
|
37
|
+
#
|
|
38
|
+
# @param classes [String] space-separated utility classes
|
|
39
|
+
# @example
|
|
40
|
+
# base "inline-flex items-center rounded-md"
|
|
41
|
+
# @return [void]
|
|
42
|
+
def base(classes)
|
|
43
|
+
resolver.base(classes)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Declares the classes of a named inner element of the component's
|
|
47
|
+
# anatomy, resolved with `css(:name)` at render.
|
|
48
|
+
#
|
|
49
|
+
# @param name [Symbol] the element name (:icon, :label, ...)
|
|
50
|
+
# @param classes [String] space-separated utility classes
|
|
51
|
+
# @example
|
|
52
|
+
# element :icon, "size-3 shrink-0"
|
|
53
|
+
# @return [void]
|
|
54
|
+
def element(name, classes)
|
|
55
|
+
resolver.element(name, classes)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Declares one style axis: the classes each value of the component's
|
|
59
|
+
# matching `style :attr` declaration resolves to.
|
|
60
|
+
#
|
|
61
|
+
# @param attr [Symbol] the style attribute this axis resolves
|
|
62
|
+
# @param mapping [Hash{Symbol => String}] variant value => utility classes
|
|
63
|
+
# @example
|
|
64
|
+
# variant :color, red: "bg-destructive/15 text-destructive",
|
|
65
|
+
# gray: "bg-muted text-muted-foreground"
|
|
66
|
+
# @return [void]
|
|
67
|
+
def variant(attr, mapping)
|
|
68
|
+
resolver.variant(attr, mapping)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Declares classes emitted only when EVERY criteria pair matches the
|
|
72
|
+
# resolved style values - the cross-axis refinement a single
|
|
73
|
+
# variant cannot express.
|
|
74
|
+
#
|
|
75
|
+
# @param criteria [Hash{Symbol => Object}] style attribute => value pairs
|
|
76
|
+
# @param classes [String] utility classes added on a full match
|
|
77
|
+
# @example
|
|
78
|
+
# compound({ color: :red, mode: :dark }, "bg-destructive/25")
|
|
79
|
+
# @return [void]
|
|
80
|
+
def compound(criteria, classes)
|
|
81
|
+
resolver.compound(criteria, classes)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Single-source-of-defaults enforcement: defaults belong on the
|
|
85
|
+
# component (`style :attr, default:`), never in the style dictionary.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
# @raise [Poetry::Core::Error] always
|
|
89
|
+
def defaults(*)
|
|
90
|
+
raise Poetry::Core::Error,
|
|
91
|
+
"#{name} declares defaults in the style dictionary - defaults live on the component " \
|
|
92
|
+
"(`style :attr, default:`), the single source of truth"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Resolves utility classes for the given criteria (and optional
|
|
96
|
+
# element). The `class:` option appends caller classes, which win on
|
|
97
|
+
# Tailwind conflicts.
|
|
98
|
+
#
|
|
99
|
+
# @param element [Symbol, nil] a declared element name, or nil for the root
|
|
100
|
+
# @param options [Hash] style attribute => value criteria, plus `class:`
|
|
101
|
+
# @return [String] the resolved utility classes
|
|
102
|
+
def css(element = nil, **options)
|
|
103
|
+
extra = options.delete(:class)
|
|
104
|
+
resolver.render(element, extra: extra, **options)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# { attr => [values] } - the declared variant space, for previews,
|
|
108
|
+
# docs, and the registry.
|
|
109
|
+
#
|
|
110
|
+
# @return [Hash{Symbol => Array<Symbol>}]
|
|
111
|
+
def variant_options
|
|
112
|
+
resolver.variant_options
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The sibling component class by convention
|
|
116
|
+
# (Poetry::Core::Dot::Style -> Poetry::Core::Dot::Component).
|
|
117
|
+
#
|
|
118
|
+
# @return [Class, nil]
|
|
119
|
+
def component_class
|
|
120
|
+
module_parent.const_get(:Component, false)
|
|
121
|
+
rescue NameError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The BEM block this dictionary belongs to, derived from the sibling
|
|
126
|
+
# component ("poetry/core/dot" -> "poetry-core-dot").
|
|
127
|
+
#
|
|
128
|
+
# @return [String, nil]
|
|
129
|
+
def bem_block
|
|
130
|
+
component_class&.component_path&.tr("/", "-")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The capsule digest of this dictionary (the :bem leak-guard).
|
|
134
|
+
#
|
|
135
|
+
# @return [String]
|
|
136
|
+
def capsule
|
|
137
|
+
resolver.digest
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Instance-level mirror so `styler.css(...)` keeps working from the
|
|
142
|
+
# Styles concern.
|
|
143
|
+
#
|
|
144
|
+
# @return [String] the resolved utility classes (see {.css})
|
|
145
|
+
def css(...)
|
|
146
|
+
self.class.css(...)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
# Namespace of the conditional wrapper: {Wrapper::Component} renders
|
|
6
|
+
# outer HTML around a child component only when the child itself
|
|
7
|
+
# renders.
|
|
8
|
+
module Wrapper
|
|
9
|
+
# Wraps any component with custom HTML.
|
|
10
|
+
# The whole wrapper is only rendered when the child component's #render? returns true,
|
|
11
|
+
# so it can conditionally render the outer HTML for a component without
|
|
12
|
+
# conditionals in templates.
|
|
13
|
+
#
|
|
14
|
+
# Adapted from an MIT-licensed source (source and license in
|
|
15
|
+
# THIRD_PARTY_NOTICES.md).
|
|
16
|
+
#
|
|
17
|
+
# The child's render? is consulted before the child gains a view
|
|
18
|
+
# context: a render? that calls view helpers works standalone but
|
|
19
|
+
# not under the wrapper.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# render Poetry::Core::Wrapper::Component.new(badge) do |wrapper|
|
|
23
|
+
# tag.div(class: "mt-2") { wrapper.component }
|
|
24
|
+
# end
|
|
25
|
+
class Component < ViewComponent::Base
|
|
26
|
+
# Raised when the block calls #component more than once - each
|
|
27
|
+
# wrapper renders its child exactly one time.
|
|
28
|
+
class DoubleRenderError < Poetry::Core::Error
|
|
29
|
+
# Names the child in the message.
|
|
30
|
+
#
|
|
31
|
+
# @param component [ViewComponent::Base] the child rendered twice
|
|
32
|
+
def initialize(component)
|
|
33
|
+
super("A child component could only be rendered once within a wrapper: #{component}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Raised when the wrapper's block never rendered the child - the
|
|
38
|
+
# wrap would silently drop it.
|
|
39
|
+
class UnrenderedChildError < Poetry::Core::Error
|
|
40
|
+
# Names the dropped child in the message.
|
|
41
|
+
#
|
|
42
|
+
# @param component [ViewComponent::Base] the child the block skipped
|
|
43
|
+
def initialize(component)
|
|
44
|
+
super("The block never rendered the wrapped child - call #component where it belongs: #{component}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :component_instance
|
|
49
|
+
|
|
50
|
+
delegate :render?, to: :component_instance
|
|
51
|
+
|
|
52
|
+
# Wraps a single child component; intentionally does not chain to
|
|
53
|
+
# ViewComponent::Base#initialize (it only needs the child reference).
|
|
54
|
+
#
|
|
55
|
+
# @param component [ViewComponent::Base] the child component to wrap
|
|
56
|
+
def initialize(component) # rubocop:disable Lint/MissingSuper
|
|
57
|
+
raise ArgumentError, "Wrapper wraps a component instance (got nil)" if component.nil?
|
|
58
|
+
|
|
59
|
+
@component_instance = component
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the block's output, which must have rendered the child -
|
|
63
|
+
# a block that skips {#component} would drop the child silently,
|
|
64
|
+
# so it teaches instead. (An alias couldn't be used here:
|
|
65
|
+
# ViewComponent checks method presence when choosing between
|
|
66
|
+
# #call and a template.)
|
|
67
|
+
#
|
|
68
|
+
# @return [ActiveSupport::SafeBuffer, nil] the block's output
|
|
69
|
+
# @raise [UnrenderedChildError] when the block never called {#component}
|
|
70
|
+
def call
|
|
71
|
+
content.tap do
|
|
72
|
+
raise UnrenderedChildError, component_instance unless @rendered
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the rendered child component.
|
|
77
|
+
# The name is chosen for convenient usage in templates,
|
|
78
|
+
# so `= wrapper.component` reads naturally at the spot where the
|
|
79
|
+
# child belongs.
|
|
80
|
+
#
|
|
81
|
+
# @return [ActiveSupport::SafeBuffer] the rendered child HTML
|
|
82
|
+
def component
|
|
83
|
+
raise DoubleRenderError, component_instance if @rendered
|
|
84
|
+
|
|
85
|
+
@rendered = component_instance.render_in(view_context).html_safe
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { enterPresence, exitPresence, measurePresence } from "@poetry/controllers/helpers/presence"
|
|
3
|
+
import { setState, stateOf } from "@poetry/controllers/helpers/state"
|
|
4
|
+
|
|
5
|
+
// The accordion open-set machine: single (optionally collapsible)
|
|
6
|
+
// or multiple. Composes with poetry--core--roving-focus (manageTabindex:
|
|
7
|
+
// false - APG keeps every trigger tabbable) attached separately on the
|
|
8
|
+
// same root. Panels ride the presence helper; the measured
|
|
9
|
+
// --accordion-panel-height var feeds the vendored accordion-down/up
|
|
10
|
+
// keyframes.
|
|
11
|
+
|
|
12
|
+
// Safety net (ms) for clearing the data-transitioning window when
|
|
13
|
+
// animationend never arrives - reduced motion or a zero-length animation.
|
|
14
|
+
// Comfortably longer than the 0.2s accordion keyframe.
|
|
15
|
+
const TRANSITION_FALLBACK_MS = 400
|
|
16
|
+
|
|
17
|
+
// The component-facing event namespace (the poetry:<component> rule).
|
|
18
|
+
const EVENT_PREFIX = "poetry:accordion"
|
|
19
|
+
|
|
20
|
+
export default class extends Controller {
|
|
21
|
+
// The events this controller dispatches (manifest surface;
|
|
22
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
23
|
+
static events = ["poetry:accordion:change"]
|
|
24
|
+
|
|
25
|
+
static values = {
|
|
26
|
+
type: { type: String, default: "single" },
|
|
27
|
+
collapsible: { type: Boolean, default: false }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// panel -> a clear() that ends its data-transitioning window.
|
|
31
|
+
#transitions = new Map()
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Each trigger's click action: toggles its item - single mode closes
|
|
35
|
+
* the others first; single non-collapsible keeps the open item open.
|
|
36
|
+
* The change event reports the open values.
|
|
37
|
+
*
|
|
38
|
+
* @param {MouseEvent} event
|
|
39
|
+
*/
|
|
40
|
+
toggle(event) {
|
|
41
|
+
const item = event.currentTarget.closest('[data-slot="accordion-item"]')
|
|
42
|
+
if (!item) return
|
|
43
|
+
|
|
44
|
+
if (stateOf(item) === "open") {
|
|
45
|
+
if (this.typeValue === "single" && !this.collapsibleValue) return
|
|
46
|
+
this.#close(item)
|
|
47
|
+
} else {
|
|
48
|
+
if (this.typeValue === "single") this.#openItems().forEach((other) => this.#close(other))
|
|
49
|
+
this.#open(item)
|
|
50
|
+
}
|
|
51
|
+
this.#reflectDisabled()
|
|
52
|
+
this.dispatch("change", { prefix: EVENT_PREFIX, detail: { values: this.#openItems().map((i) => i.dataset.value) } })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reflects the locked-open trigger (aria-disabled) and adopts
|
|
57
|
+
* data-panel-open onto server-open triggers.
|
|
58
|
+
*/
|
|
59
|
+
connect() {
|
|
60
|
+
this.#reflectDisabled()
|
|
61
|
+
this.#reflectTriggers() // server-open items adopt data-panel-open on connect
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Flushes any in-flight transition window so its timer/listener doesn't
|
|
66
|
+
* outlive the controller (Turbo teardown, or a test between cases).
|
|
67
|
+
*/
|
|
68
|
+
disconnect() {
|
|
69
|
+
for (const clear of [...this.#transitions.values()]) clear()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Trigger reflection for the server-rendered state (the #open/#close
|
|
73
|
+
// paths carry it during interaction; this seeds it at connect so an
|
|
74
|
+
// already-open item's trigger isn't missing data-panel-open until first
|
|
75
|
+
// toggled).
|
|
76
|
+
#reflectTriggers() {
|
|
77
|
+
this.#items().forEach((item) => {
|
|
78
|
+
const trigger = this.#triggerOf(item)
|
|
79
|
+
if (trigger) setState(trigger, stateOf(item) === "open" ? "panel-open" : "panel-closed")
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#open(item) {
|
|
84
|
+
const panel = this.#panelOf(item)
|
|
85
|
+
const trigger = this.#triggerOf(item)
|
|
86
|
+
setState(item, "open")
|
|
87
|
+
trigger?.setAttribute("aria-expanded", "true")
|
|
88
|
+
if (trigger) setState(trigger, "panel-open") // the trigger wears its panel's state (data-panel-open)
|
|
89
|
+
if (!panel) return
|
|
90
|
+
panel.hidden = false
|
|
91
|
+
measurePresence(panel, { property: "--accordion-panel-height" })
|
|
92
|
+
this.#markTransition(panel)
|
|
93
|
+
enterPresence(panel)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#close(item) {
|
|
97
|
+
const panel = this.#panelOf(item)
|
|
98
|
+
const trigger = this.#triggerOf(item)
|
|
99
|
+
setState(item, "closed")
|
|
100
|
+
trigger?.setAttribute("aria-expanded", "false")
|
|
101
|
+
if (trigger) setState(trigger, "panel-closed")
|
|
102
|
+
if (!panel) return
|
|
103
|
+
measurePresence(panel, { property: "--accordion-panel-height" })
|
|
104
|
+
this.#markTransition(panel)
|
|
105
|
+
exitPresence(panel, { onRemove: () => { panel.hidden = true } })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Single non-collapsible: the open trigger is a no-op - expose that
|
|
109
|
+
// to AT instead of silently ignoring the click (the contract's call).
|
|
110
|
+
#reflectDisabled() {
|
|
111
|
+
const lockOpen = this.typeValue === "single" && !this.collapsibleValue
|
|
112
|
+
this.#items().forEach((item) => {
|
|
113
|
+
const trigger = this.#triggerOf(item)
|
|
114
|
+
if (!trigger) return
|
|
115
|
+
if (lockOpen && stateOf(item) === "open") {
|
|
116
|
+
trigger.setAttribute("aria-disabled", "true")
|
|
117
|
+
} else {
|
|
118
|
+
trigger.removeAttribute("aria-disabled")
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The resting-keyframe gate (accordion flicker fix): the panel keeps
|
|
124
|
+
// data-open the whole time it's open, so the theme's
|
|
125
|
+
// data-open:animate-accordion-down keyframe would REPLAY every time a
|
|
126
|
+
// display:none -> visible toggle restarts CSS animations (an accordion
|
|
127
|
+
// nested in a Tabs panel is the common case - the expand flickers on every
|
|
128
|
+
// return to the panel). aliases.css stops the keyframe unless
|
|
129
|
+
// data-transitioning is present; this rides it only for the open/close
|
|
130
|
+
// window, cleared on animationend (fallback for reduced-motion / zero-length
|
|
131
|
+
// animations where animationend may not fire).
|
|
132
|
+
#markTransition(panel) {
|
|
133
|
+
this.#transitions.get(panel)?.() // supersede any in-flight window
|
|
134
|
+
|
|
135
|
+
panel.setAttribute("data-transitioning", "")
|
|
136
|
+
|
|
137
|
+
const clear = () => {
|
|
138
|
+
panel.removeEventListener("animationend", onEnd)
|
|
139
|
+
clearTimeout(timer)
|
|
140
|
+
this.#transitions.delete(panel)
|
|
141
|
+
panel.removeAttribute("data-transitioning")
|
|
142
|
+
}
|
|
143
|
+
const onEnd = (event) => { if (event.target === panel) clear() }
|
|
144
|
+
const timer = setTimeout(clear, TRANSITION_FALLBACK_MS)
|
|
145
|
+
|
|
146
|
+
this.#transitions.set(panel, clear)
|
|
147
|
+
panel.addEventListener("animationend", onEnd)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#items() {
|
|
151
|
+
return [...this.element.querySelectorAll('[data-slot="accordion-item"]')]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#openItems() {
|
|
155
|
+
return this.#items().filter((item) => stateOf(item) === "open")
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#triggerOf(item) {
|
|
159
|
+
return item.querySelector('[data-slot="accordion-trigger"]')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#panelOf(item) {
|
|
163
|
+
return item.querySelector('[data-slot="accordion-content"]')
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { announce } from "@poetry/controllers/helpers/announce"
|
|
3
|
+
import { isImeKeydown } from "@poetry/controllers/helpers/escape"
|
|
4
|
+
|
|
5
|
+
// The floating bulk-actions bar (the ActionBar contract): shows
|
|
6
|
+
// while its table's selection is non-empty, and holds the contract rules -
|
|
7
|
+
// focus NEVER moves in on show; if focus was inside when the bar hides,
|
|
8
|
+
// it returns to where it was before entering (the FocusScope restoreFocus
|
|
9
|
+
// equivalent, scoped small); "Actions available." is announced ONCE per
|
|
10
|
+
// appearance; the visible count RETAINS its last non-zero value while the
|
|
11
|
+
// bar animates out (never a "None selected" flash); Escape anywhere
|
|
12
|
+
// inside clears the selection (the table's engine listens for
|
|
13
|
+
export default class ActionBarController extends Controller {
|
|
14
|
+
// The events this controller dispatches (manifest surface;
|
|
15
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
16
|
+
static events = ["poetry:data-table:clear-selection"]
|
|
17
|
+
|
|
18
|
+
static targets = ["count"]
|
|
19
|
+
static values = {
|
|
20
|
+
label: { type: String, default: "%{count} selected" }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#announced = false
|
|
24
|
+
#focusBefore = null
|
|
25
|
+
#onSelectionChange = (event) => this.#update(event.detail)
|
|
26
|
+
#onFocusin = (event) => {
|
|
27
|
+
if (!this.element.contains(event.target)) return
|
|
28
|
+
if (this.element.contains(this.#focusBefore)) return
|
|
29
|
+
|
|
30
|
+
this.#focusBefore = event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Subscribes to the table's selection-change on the shared wrapper and
|
|
35
|
+
* starts the focus bookkeeping the hide-restore needs.
|
|
36
|
+
*/
|
|
37
|
+
connect() {
|
|
38
|
+
// The bar and its table share a wrapper; listen there so the pairing
|
|
39
|
+
// is positional, never an id contract.
|
|
40
|
+
this.element.parentElement?.addEventListener(
|
|
41
|
+
"poetry:data-table:selection-change", this.#onSelectionChange
|
|
42
|
+
)
|
|
43
|
+
document.addEventListener("focusin", this.#onFocusin)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Unwires both listeners. */
|
|
47
|
+
disconnect() {
|
|
48
|
+
this.element.parentElement?.removeEventListener(
|
|
49
|
+
"poetry:data-table:selection-change", this.#onSelectionChange
|
|
50
|
+
)
|
|
51
|
+
document.removeEventListener("focusin", this.#onFocusin)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The keydown action: Escape anywhere inside clears the selection.
|
|
56
|
+
*
|
|
57
|
+
* @param {KeyboardEvent} event
|
|
58
|
+
*/
|
|
59
|
+
keydown(event) {
|
|
60
|
+
if (event.key !== "Escape" || isImeKeydown(event)) return
|
|
61
|
+
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
event.stopPropagation()
|
|
64
|
+
this.clear()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The clear affordance (and Escape): asks the table's engine - which
|
|
69
|
+
* owns the state - to clear the selection.
|
|
70
|
+
*/
|
|
71
|
+
clear() {
|
|
72
|
+
this.dispatch("clear-selection", { prefix: "poetry:data-table" })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#update({ count }) {
|
|
76
|
+
const open = count > 0
|
|
77
|
+
|
|
78
|
+
if (open) {
|
|
79
|
+
// Retain-on-exit: only NON-ZERO counts ever render.
|
|
80
|
+
if (this.hasCountTarget) {
|
|
81
|
+
this.countTarget.textContent = this.labelValue.replace("%{count}", String(count))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this.element.hidden) {
|
|
85
|
+
this.element.hidden = false
|
|
86
|
+
|
|
87
|
+
if (!this.#announced) {
|
|
88
|
+
announce(this.element.getAttribute("data-available-label") || "Actions available.")
|
|
89
|
+
this.#announced = true
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.element.setAttribute("data-open", "")
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.#announced = false
|
|
98
|
+
this.element.removeAttribute("data-open")
|
|
99
|
+
this.element.hidden = true
|
|
100
|
+
|
|
101
|
+
// Focus never moved IN on show; but if the user tabbed in and the bar
|
|
102
|
+
// just vanished under them, put focus back where it was.
|
|
103
|
+
if (this.element.contains(document.activeElement) && this.#focusBefore?.isConnected) {
|
|
104
|
+
this.#focusBefore.focus()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.#focusBefore = null
|
|
108
|
+
}
|
|
109
|
+
}
|