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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. 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
+ }