poetry-core 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec837c1c31e39d20d47e278d7ae7dde0ff26f036f17e8bc33dcf81abd6daafdd
4
- data.tar.gz: 650c0d9053220a0500ae619402dd92515248b0b6bfe960f85e874ace979f36af
3
+ metadata.gz: 9b4189654db2722eff128793c1bd4ce725c06a60ec1b7ff8bf29d5af7aa4e9ef
4
+ data.tar.gz: fb0245f3b9abff92ad3f9bdd48d3da7a5fc421531e346d40e3e53eadaf304233
5
5
  SHA512:
6
- metadata.gz: d9e82544dcb308ba9feea37d8de5f2272bae3be011c59601c584f6b2b90f1156f6bfef8864d03b0d17c62308eba42e8f82572acfc55a7dd8ce3510c95266ca8a
7
- data.tar.gz: b35b3d54fa5f02ebd98357a1c93cf4b89a0969a45c1793e15e782d23921cb670a52889fc888e0204dbde640bc0a83176c54ea8f285031f41802b12bbadbd45de
6
+ metadata.gz: 3c79e20564e016e6b3b7b34378bb6222ca8708279864235bfd0c8d2a7b899f56e229a53cb284dbec9b9601e91922fc6f9799353fc8c7ddac8d1fa5d8fba6ae0b
7
+ data.tar.gz: 73b7dc73529fb2c1782a343ef7ddac9228591fba3b73c57163a0cfab4f6066e26da0d573447af273605e0bc98e1ff40d0fe0917a770c56e873ec22714b563182
data/CHANGELOG.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.1.1] - 2026-09-08
4
+
5
+ ### Added
6
+
7
+ - `identity:` on every component: the one sanctioned way for a component that renders as another component's root to name it (its `data-component`). Universal like `key:`, never an HTML attribute.
8
+ - `poetry check` rule `reserved-attribute`: `data-component` passed through a helper, as a string key or `data: { component: }`, is an error.
9
+
10
+ ### Changed
11
+
12
+ - The passthrough contract is enforced at render. A keyword that is not an option still renders as an HTML attribute on the root, but a near miss of a declared option (`varient:`) raises in development and test with a did-you-mean and logs in production; `data-component` is never overridable (raised in development and test, dropped in production); `data-slot` stays open as a composition seam. `poetry check`'s `unknown-option` finding is an error, no longer a warning.
13
+ - `poetry--core--hover-card`: `touchGuard` (on touchstart) is replaced by `pointerDown` (on pointerdown). `poetry--core--number-field`: the `focus` action is removed. A host that wired either by hand updates the action strings; poetry-ui's components already have.
14
+ - `css_mode :bem` is documented as the mode for kits authored on the DSL that write their own templates; poetry-ui is Tailwind-native and not a `:bem` consumer.
15
+
16
+ ### Fixed
17
+
18
+ - HoverCard: a tap on the trigger keeps its click on touch devices. The touchstart guard cancelled the click; a pointerdown latch held through the tap's compatibility mouse events replaces it.
19
+ - MessageScroller: `data-pending-scroll` holds the root and the viewport until the opening position (`end` or `last-anchor`) is applied, releases at once for an empty transcript, is stripped when a Turbo morph re-stamps it, and is re-armed at `turbo:before-cache`, so a server-rendered or restored transcript never shows the top of the thread first.
20
+ - NumberField: a sideways trackpad gesture over a wheel-enabled field scrolls the page instead of stepping, Shift on the horizontal axis steps large, and an event with no movement is neither stepped nor cancelled. Focus keeps the browser's own selection (Tab selects the value, a click places the caret); the steppers park the caret at the end.
21
+ - NavigationMenu: a disabled trigger never opens, by hover or click, and the arrows step over it; ArrowDown on a trigger opens its panel with focus staying on the trigger; removing the open trigger's item, as a morph can, closes the bar and drops its orphaned panel.
22
+ - Drawer: the click that follows a press inside the panel never dismisses, so a drag against the clamp released over the backdrop no longer closes the sheet.
4
23
 
5
24
  ## [0.1.0] - 2026-09-05
6
25
 
@@ -145,14 +145,36 @@ module Poetry
145
145
  end
146
146
  end
147
147
 
148
+ # Keywords that are never options and never typos - the passthrough
149
+ # vocabulary the static check exempts too (Check::Catalog::PASSTHROUGH).
150
+ PASSTHROUGH_KEYS = %w[class id key webmcp identity data aria role style].freeze
151
+
152
+ # HTML attribute names a caller passes through on purpose. Exempt from
153
+ # the near-miss guard so `for:` never reads as a typo of a `form`
154
+ # option, `size:` on a component without a size axis stays the native
155
+ # attribute, and so on. Only keys that are NOT declared options reach
156
+ # the guard, so a declared option of the same name is untouched.
157
+ HTML_ATTRIBUTE_KEYS = %w[
158
+ accept accesskey action alt autocapitalize autocomplete autofocus checked cite cols colspan
159
+ contenteditable crossorigin datetime decoding dir dirname disabled download draggable enctype
160
+ enterkeyhint for form headers height hidden href hreflang inert inputmode is itemid itemprop
161
+ itemref itemscope itemtype label lang list loading max maxlength method min minlength multiple
162
+ name nonce novalidate open part pattern ping placeholder popover readonly referrerpolicy rel
163
+ required reversed rows rowspan scope selected size sizes slot span spellcheck src srcset start
164
+ step tabindex target title translate type value width wrap
165
+ ].freeze
166
+
148
167
  # The self-identification markup contract, the convention every
149
168
  # component follows: `data-component` on the component root maps live DOM
150
169
  # back to the component that rendered it - the hook agents, the
151
170
  # Verifier, and the browser-verification loop key on.
152
171
  #
172
+ # A component rendering as another's root passes identity: (the
173
+ # composition seam), and the root wears that name instead.
174
+ #
153
175
  # @return [Hash] e.g. { "data-component" => "button" }
154
176
  def component_data_attributes
155
- { "data-component" => self.class.component_title }
177
+ { "data-component" => (@identity || self.class.component_title).to_s }
156
178
  end
157
179
 
158
180
  # `data-slot` for a named part of the component's anatomy
@@ -224,6 +246,14 @@ module Poetry
224
246
  @webmcp = attributes[:webmcp] || attributes["webmcp"]
225
247
  attributes = attributes.except(:webmcp, "webmcp") unless @webmcp.nil?
226
248
 
249
+ # identity: is the composition seam for a component that renders AS
250
+ # another component's root (ToastTrigger renders as a Button): the
251
+ # inner root wears the outer's data-component. Universal like key:,
252
+ # never an HTML attribute - and the ONLY sanctioned way to set
253
+ # data-component (the raw attribute is reserved, see guard_passthrough).
254
+ @identity = attributes[:identity] || attributes["identity"]
255
+ attributes = attributes.except(:identity, "identity") unless @identity.nil?
256
+
227
257
  # Initialize a fresh Set for this instance
228
258
  self.registered_styles = Set.new
229
259
  self.registered_options = Set.new
@@ -249,12 +279,77 @@ module Poetry
249
279
  end
250
280
 
251
281
  @attributes = self.class._default_attributes.deep_dup
252
- html_attrs = attributes.with_indifferent_access.except(*attribute_names)
282
+ html_attrs = guard_passthrough(attributes.with_indifferent_access.except(*attribute_names))
253
283
  @html_attributes = Poetry::Core::HTML::Attributes.new(html_attrs)
254
284
 
255
285
  assign_attributes attributes.with_indifferent_access.slice(*attribute_names)
256
286
  end
257
287
 
288
+ # The passthrough contract, enforced at the seam. A keyword that is not
289
+ # an option renders as an HTML attribute on the root (title:, tabindex:,
290
+ # colspan:) - so a NEAR-MISS of a declared option (varient:) would
291
+ # silently render a bogus attribute while the default applied. That
292
+ # raises in development and test with the did-you-mean the static check
293
+ # gives, and only logs in production (the render still succeeds).
294
+ # data-component is the component's own identity - the hook agents,
295
+ # the Verifier and the browser loop key on - and is never overridable:
296
+ # dropped in production, raised the same way in development and test.
297
+ # data-slot is NOT reserved: re-slotting an embedded root is the
298
+ # composition seam a part contract allows; re-identifying one goes
299
+ # through identity:, the sanctioned spelling.
300
+ #
301
+ # @param html_attrs [ActiveSupport::HashWithIndifferentAccess]
302
+ # @return [ActiveSupport::HashWithIndifferentAccess] the attributes, minus a reserved override
303
+ def guard_passthrough(html_attrs)
304
+ problems = []
305
+
306
+ if html_attrs.key?("data-component")
307
+ problems << "data-component is #{passthrough_owner}'s own identity attribute and is never overridable"
308
+ html_attrs = html_attrs.except("data-component")
309
+ end
310
+ data = html_attrs["data"]
311
+ if data.respond_to?(:key?) && data.key?("component")
312
+ problems << "data: { component: } is #{passthrough_owner}'s own identity attribute and is never overridable"
313
+ html_attrs["data"] = data.except("component")
314
+ end
315
+
316
+ html_attrs.each_key do |key|
317
+ next if PASSTHROUGH_KEYS.include?(key) || HTML_ATTRIBUTE_KEYS.include?(key) || key.include?("-")
318
+
319
+ suggestion = option_suggestion(key)
320
+ next unless suggestion
321
+
322
+ problems << "#{passthrough_owner} has no option #{key}: (did you mean #{suggestion}:?) - " \
323
+ "unknown keywords render as HTML attributes"
324
+ end
325
+ return html_attrs if problems.empty?
326
+
327
+ raise ArgumentError, problems.join("; ") if strict_passthrough?
328
+
329
+ Rails.logger&.warn("poetry: #{problems.join("; ")}") if defined?(Rails) && Rails.respond_to?(:logger)
330
+ html_attrs
331
+ end
332
+
333
+ # The did-you-mean against this component's declared options and
334
+ # styles, the same checker the static check runs.
335
+ def option_suggestion(key)
336
+ require "did_you_mean"
337
+ DidYouMean::SpellChecker.new(dictionary: self.class.attribute_names.map(&:to_s)).correct(key.to_s).first
338
+ end
339
+
340
+ # The component named in a passthrough problem - an anonymous class
341
+ # (a test double) has no path to title.
342
+ def passthrough_owner
343
+ self.class.name ? self.class.component_title : "this component"
344
+ end
345
+
346
+ # Raise (development, test) or log (everything else).
347
+ def strict_passthrough?
348
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.local?
349
+ end
350
+
351
+ private :guard_passthrough, :option_suggestion, :passthrough_owner, :strict_passthrough?
352
+
258
353
  # Returns all component attributes, ensuring proc defaults are evaluated.
259
354
  #
260
355
  # This method overrides ActiveModel's attributes method to trigger evaluation
@@ -49,6 +49,10 @@ export default class DrawerController extends DialogController {
49
49
  }
50
50
 
51
51
  #swipe = null
52
+ // Where the last press landed (inside the panel or on the backdrop) -
53
+ // the click that follows a press is never a backdrop dismissal when the
54
+ // press started inside, however far the pointer travelled since.
55
+ #pressInside = false
52
56
  #closing = false
53
57
  #snapIndex = 0
54
58
 
@@ -114,6 +118,29 @@ export default class DrawerController extends DialogController {
114
118
  })
115
119
  }
116
120
 
121
+ /**
122
+ * The inherited backdrop click, minus the ghost: a press that started
123
+ * inside the panel can end over the backdrop (a drag against the
124
+ * clamp, a slip off an inner control) and the click the browser then
125
+ * fires is a backdrop click by target and coordinates, never by intent.
126
+ *
127
+ * @param {MouseEvent} event
128
+ */
129
+ backdropClose(event) {
130
+ if (this.#pressInside) return
131
+
132
+ super.backdropClose(event)
133
+ }
134
+
135
+ // The dialog's own inside test (a press on the backdrop targets the
136
+ // <dialog> too - only the rect tells them apart).
137
+ #insidePanel(event) {
138
+ const rect = this.dialogTarget.getBoundingClientRect()
139
+
140
+ return rect.top <= event.clientY && event.clientY <= rect.bottom &&
141
+ rect.left <= event.clientX && event.clientX <= rect.right
142
+ }
143
+
117
144
  // --- the swipe -----------------------------------------------------------
118
145
 
119
146
  /**
@@ -124,6 +151,7 @@ export default class DrawerController extends DialogController {
124
151
  * @param {PointerEvent} event
125
152
  */
126
153
  swipeStart(event) {
154
+ this.#pressInside = this.#insidePanel(event)
127
155
  if (event.button !== 0 && event.pointerType === "mouse") return
128
156
  if (this.#closing) return
129
157
 
@@ -8,8 +8,9 @@ import { tabbableWithin } from "@poetry/controllers/helpers/tabbable"
8
8
  // pointer-only enrichment behind a LINK. Two timers (open 600 / close
9
9
  // 300, over the trigger+content
10
10
  // pair, re-enter cancels - the grace window, no polygon), the touch double-guard
11
- // (pointerType 'touch' no-ops AND touchstart preventDefaults so a tap can
12
- // never synthesize a focus-open - a tap just navigates the link), the focus
11
+ // (pointerType 'touch' no-ops AND the pointerdown latch swallows the focus a
12
+ // tap or click causes, so a tap keeps its click and just navigates the
13
+ // link - cancelling touchstart would cancel that click with it), the focus
13
14
  // mirror (trigger focus opens immediately / blur closes - a keyboard user
14
15
  // SEES the card), the per-open TABINDEX STRIP (every tabbable inside is
15
16
  // forced tabindex=-1: keyboard and touch users never reach inside,
@@ -28,6 +29,11 @@ const CONTENT_SELECTOR = '[data-slot="hover-card-content"]'
28
29
  const EVENT_PREFIX = "poetry:hover-card"
29
30
 
30
31
  const DISMISSABLE = "poetry--core--dismissable"
32
+ // The pointerdown latch releases once the press has fully landed: the
33
+ // compatibility mouseup (a tap dispatches its mouse events, focus among
34
+ // them, AFTER its pointerup - so pointerup is too early), a cancelled
35
+ // touch, or any key (keyboard use is never mid-press).
36
+ const LATCH_RELEASE_EVENTS = ["mouseup", "pointercancel", "keydown"]
31
37
  const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
32
38
 
33
39
  export default class HoverCardController extends Controller {
@@ -49,7 +55,12 @@ export default class HoverCardController extends Controller {
49
55
  #containSelection = false
50
56
  #hasSelection = false
51
57
  #previousBodyUserSelect = null
58
+ #isPointerDown = false
52
59
  #onPointerup = () => this.#handlePointerup()
60
+ #releaseLatch = () => {
61
+ this.#isPointerDown = false
62
+ for (const type of LATCH_RELEASE_EVENTS) document.removeEventListener(type, this.#releaseLatch)
63
+ }
53
64
 
54
65
  /**
55
66
  * Wires the content listeners (portal-safe) and reconciles a
@@ -93,6 +104,7 @@ export default class HoverCardController extends Controller {
93
104
 
94
105
  this.#wired = []
95
106
  document.removeEventListener("pointerup", this.#onPointerup)
107
+ this.#releaseLatch()
96
108
  }
97
109
 
98
110
  /**
@@ -152,9 +164,11 @@ export default class HoverCardController extends Controller {
152
164
  /**
153
165
  * The trigger's focus action: opens IMMEDIATELY, skipping the timers -
154
166
  * a keyboard user sees the preview even though they
155
- * cannot enter it.
167
+ * cannot enter it. Focus a pointer caused (the latch) does not open.
156
168
  */
157
169
  focusOpen() {
170
+ if (this.#isPointerDown) return
171
+
158
172
  this.#clearCloseTimer()
159
173
  this.#show()
160
174
  }
@@ -169,15 +183,16 @@ export default class HoverCardController extends Controller {
169
183
  }
170
184
 
171
185
  /**
172
- * The trigger's touchstart action - the touch guard: preventDefault so
173
- * a tap can never synthesize a focus event (a focus-opened card on
174
- * touch would be unreachable). The tap still navigates the
175
- * link.
176
- *
177
- * @param {TouchEvent} event
186
+ * The trigger's pointerdown action - the latch: the focus a tap or a
187
+ * click causes must not open the card (a pointer user never gets a
188
+ * focus-opened card; on touch it would be unreachable), so focusOpen
189
+ * stays shut until the press has landed (LATCH_RELEASE_EVENTS). The
190
+ * event is never cancelled: cancelling touchstart would cancel the
191
+ * tap's click, and the tap must still navigate the link.
178
192
  */
179
- touchGuard(event) {
180
- event.preventDefault()
193
+ pointerDown() {
194
+ this.#isPointerDown = true
195
+ for (const type of LATCH_RELEASE_EVENTS) document.addEventListener(type, this.#releaseLatch)
181
196
  }
182
197
 
183
198
  // --- open / close ---
@@ -27,6 +27,17 @@ import {
27
27
  // auto-scroll animation cannot release itself.
28
28
  const AUTOSCROLLING_CLEAR_DELAY = 180
29
29
 
30
+ // The opening-position hold: data-pending-scroll sits on the root and the
31
+ // viewport until defaultScrollPosition end / last-anchor is applied (or
32
+ // skipped, on an empty transcript), so the dictionary can hide the viewport
33
+ // instead of painting the top of the thread and then jumping. The server
34
+ // renders the attribute for those two positions (a scroll container always
35
+ // opens at the top, and the first paint must not wait for this controller);
36
+ // from connect on the controller owns it. Mount-only: a live value change
37
+ // re-applies the position without hiding the viewport again.
38
+ const PENDING_SCROLL_ATTRIBUTE = "data-pending-scroll"
39
+ const HELD_SCROLL_POSITIONS = new Set(["end", "last-anchor"])
40
+
30
41
  // Viewport keys that count as deliberate scroll intent and release follow.
31
42
  const USER_SCROLL_KEYS = new Set([
32
43
  "ArrowDown",
@@ -56,6 +67,10 @@ const USER_SCROLL_KEYS = new Set([
56
67
  // anchored-to-message a turn held at the reading line while a reply streams
57
68
  // settling-jump a programmatic jump animating; intent suppressed
58
69
  //
70
+ // Presence attributes mirrored on the root AND the viewport: data-scrollable
71
+ // (which edges have room), data-autoscrolling (a programmatic scroll is
72
+ // settling), data-pending-scroll (the opening-position hold, above).
73
+ //
59
74
  // Viewport scroll/wheel/touchmove/keydown listeners are wired here (passive
60
75
  // flags need addEventListener) - do NOT also declare them as data-actions.
61
76
  // The jump button IS a data-action: click->...#jump.
@@ -101,6 +116,7 @@ export default class extends Controller {
101
116
  this.prependRestore = null
102
117
  this.pendingScrollToMessage = null
103
118
  this.defaultScrollPositionApplied = false
119
+ this.pendingScroll = HELD_SCROLL_POSITIONS.has(this.defaultScrollPositionValue)
104
120
  this.spacerHeight = 0
105
121
  this.spacerGap = getFlexGap(this.#spacer()?.parentElement ?? null)
106
122
  this.handledScrollAnchors = new WeakSet()
@@ -150,6 +166,16 @@ export default class extends Controller {
150
166
  // defaultScrollPosition once, commits scrollable state.
151
167
  this.#handleContentChange()
152
168
 
169
+ // The hold releases with the opening position (inside the mount pass),
170
+ // or right away when there is nothing to scroll. A Turbo morph that
171
+ // re-stamps the server's attribute afterwards is stripped by the
172
+ // observer; a cache snapshot re-arms it (see #rearmPendingScroll).
173
+ if (this.itemCount === 0) this.pendingScroll = false
174
+ this.#writePendingScroll()
175
+ this.#observePendingScroll(viewport)
176
+ this.onBeforeCache = () => this.#rearmPendingScroll()
177
+ document.addEventListener("turbo:before-cache", this.onBeforeCache)
178
+
153
179
  if (this.trackVisibilityValue) this.#observeVisibility()
154
180
 
155
181
  this.started = true
@@ -189,6 +215,9 @@ export default class extends Controller {
189
215
  this.contentResizeObserver = null
190
216
  this.visibilityObserver?.disconnect()
191
217
  this.visibilityObserver = null
218
+ this.pendingScrollObserver?.disconnect()
219
+ this.pendingScrollObserver = null
220
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache)
192
221
  this.observedRows.clear()
193
222
  this.visibleMessageIds.clear()
194
223
 
@@ -661,7 +690,7 @@ export default class extends Controller {
661
690
 
662
691
  if (!handled) return false
663
692
 
664
- this.defaultScrollPositionApplied = true
693
+ this.#markDefaultScrollPositionApplied()
665
694
 
666
695
  return true
667
696
  }
@@ -708,6 +737,55 @@ export default class extends Controller {
708
737
  return true
709
738
  }
710
739
 
740
+ // --- the opening-position hold ---
741
+
742
+ #markDefaultScrollPositionApplied() {
743
+ this.defaultScrollPositionApplied = true
744
+
745
+ if (!this.pendingScroll) return
746
+
747
+ this.pendingScroll = false
748
+ this.#writePendingScroll()
749
+ }
750
+
751
+ #writePendingScroll() {
752
+ for (const element of [this.element, this.#viewport()]) {
753
+ if (!element) continue
754
+
755
+ if (this.pendingScroll) element.setAttribute(PENDING_SCROLL_ATTRIBUTE, "")
756
+ else element.removeAttribute(PENDING_SCROLL_ATTRIBUTE)
757
+ }
758
+ }
759
+
760
+ // A Turbo morph re-stamps the server-rendered attribute onto the live
761
+ // elements after the position already applied: strip it. Never re-add
762
+ // through this path - an inline script that scrolled and released the
763
+ // hold before connect must keep its release.
764
+ #observePendingScroll(viewport) {
765
+ if (typeof MutationObserver === "undefined") return
766
+
767
+ this.pendingScrollObserver = new MutationObserver(() => {
768
+ if (!this.pendingScroll) this.#writePendingScroll()
769
+ })
770
+
771
+ for (const element of [this.element, viewport]) {
772
+ this.pendingScrollObserver.observe(element, {
773
+ attributes: true,
774
+ attributeFilter: [PENDING_SCROLL_ATTRIBUTE]
775
+ })
776
+ }
777
+ }
778
+
779
+ // A cached snapshot clones the elements without their scroll position, so
780
+ // a restore would paint the top of the thread until reconnect re-applies
781
+ // the opening position: re-arm the hold before Turbo caches the page.
782
+ #rearmPendingScroll() {
783
+ if (!HELD_SCROLL_POSITIONS.has(this.defaultScrollPositionValue)) return
784
+
785
+ this.pendingScroll = true
786
+ this.#writePendingScroll()
787
+ }
788
+
711
789
  // --- scroll commands (mechanics, split from the policy above as in source) ---
712
790
 
713
791
  #setAutoScrolling(autoscrolling) {
@@ -878,7 +956,7 @@ export default class extends Controller {
878
956
  if (!element) {
879
957
  if (this.itemCount === 0) {
880
958
  this.pendingScrollToMessage = { messageId, options }
881
- this.defaultScrollPositionApplied = true
959
+ this.#markDefaultScrollPositionApplied()
882
960
 
883
961
  return true
884
962
  }
@@ -886,7 +964,7 @@ export default class extends Controller {
886
964
  return false
887
965
  }
888
966
 
889
- this.defaultScrollPositionApplied = true
967
+ this.#markDefaultScrollPositionApplied()
890
968
 
891
969
  if (this.#scrollToElement(element, options)) {
892
970
  this.pendingScrollToMessage = null
@@ -910,7 +988,7 @@ export default class extends Controller {
910
988
  if (!this.#scrollToElement(element, pending.options)) return false
911
989
 
912
990
  this.pendingScrollToMessage = null
913
- this.defaultScrollPositionApplied = true
991
+ this.#markDefaultScrollPositionApplied()
914
992
 
915
993
  return true
916
994
  }
@@ -44,6 +44,7 @@ export default class NavigationMenuController extends Controller {
44
44
  #cancelExit = new Map() // value -> abandon-this-panel's-exit (per panel, not global)
45
45
  #onOutsidePress = null
46
46
  #unsubscribeBeforeCache = null
47
+ #itemsObserver = null
47
48
  #sizeGeneration = 0
48
49
 
49
50
  /**
@@ -62,6 +63,16 @@ export default class NavigationMenuController extends Controller {
62
63
  this.#close()
63
64
  flushPendingExits()
64
65
  })
66
+
67
+ // A morph or stream that removes the OPEN trigger's item takes the
68
+ // per-item panel with it but leaves an adopted viewport panel behind,
69
+ // and either way the bar still counts itself open: close as if the
70
+ // trigger had been dismissed, and drop any adopted panel no trigger
71
+ // owns any more.
72
+ if (typeof MutationObserver !== "undefined") {
73
+ this.#itemsObserver = new MutationObserver(() => this.#reconcileRemovedItems())
74
+ this.#itemsObserver.observe(this.element, { childList: true, subtree: true })
75
+ }
65
76
  }
66
77
 
67
78
  /**
@@ -73,6 +84,8 @@ export default class NavigationMenuController extends Controller {
73
84
  this.#unbindOutsidePress()
74
85
  this.#unsubscribeBeforeCache?.()
75
86
  this.#unsubscribeBeforeCache = null
87
+ this.#itemsObserver?.disconnect()
88
+ this.#itemsObserver = null
76
89
  }
77
90
 
78
91
  /**
@@ -82,7 +95,7 @@ export default class NavigationMenuController extends Controller {
82
95
  */
83
96
  toggle(event) {
84
97
  const value = this.#valueFrom(event)
85
- if (value === null) return
98
+ if (value === null || this.#disabled(value)) return
86
99
 
87
100
  this.#clearTimer()
88
101
  if (this.#openValue === value) this.#close()
@@ -101,7 +114,7 @@ export default class NavigationMenuController extends Controller {
101
114
  if (event.pointerType === "touch") return // touch is click's job
102
115
 
103
116
  const value = this.#valueFrom(event)
104
- if (value === null) return
117
+ if (value === null || this.#disabled(value)) return
105
118
  if (value === this.#openValue) {
106
119
  this.#clearTimer() // re-entering the open item cancels a pending close
107
120
  return
@@ -152,6 +165,20 @@ export default class NavigationMenuController extends Controller {
152
165
  return
153
166
  }
154
167
 
168
+ // ArrowDown on a trigger opens its panel and is consumed; focus stays
169
+ // on the trigger (Tab enters the panel). A top-level link is not a
170
+ // trigger and keeps the key.
171
+ if (event.key === "ArrowDown") {
172
+ const trigger = event.target instanceof Element ? event.target.closest(TRIGGER_SELECTOR) : null
173
+ const value = this.#valueFrom(event)
174
+ if (!trigger || value === null || this.#disabled(value)) return
175
+
176
+ event.preventDefault()
177
+ this.#clearTimer()
178
+ if (this.#openValue !== value) this.#open(value)
179
+ return
180
+ }
181
+
155
182
  if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
156
183
 
157
184
  const stops = this.#arrowStops()
@@ -211,6 +238,28 @@ export default class NavigationMenuController extends Controller {
211
238
  this.#bindOutsidePress()
212
239
  }
213
240
 
241
+ #reconcileRemovedItems() {
242
+ const viewport = this.#viewport()
243
+ if (viewport) {
244
+ for (const panel of viewport.querySelectorAll("[data-viewport-panel]")) {
245
+ if (!this.#ownerOf(panel)) panel.remove()
246
+ }
247
+ }
248
+ if (this.#openValue === null || this.#triggerFor(this.#openValue)) return
249
+
250
+ this.#clearTimer()
251
+ this.#close()
252
+ }
253
+
254
+ // The trigger whose aria-controls names this adopted panel, if it still
255
+ // exists.
256
+ #ownerOf(panel) {
257
+ if (!panel.id) return null
258
+
259
+ return [...this.element.querySelectorAll(TRIGGER_SELECTOR)]
260
+ .find((trigger) => trigger.getAttribute("aria-controls") === panel.id) ?? null
261
+ }
262
+
214
263
  #close() {
215
264
  if (this.#openValue === null) return
216
265
 
@@ -434,6 +483,12 @@ export default class NavigationMenuController extends Controller {
434
483
  return this.#itemFor(value)?.querySelector(TRIGGER_SELECTOR) ?? null
435
484
  }
436
485
 
486
+ // A disabled trigger (native disabled, data-disabled) never opens its
487
+ // panel - not by click, not by hover - and the arrows step over it.
488
+ #disabled(value) {
489
+ return this.#triggerFor(value)?.disabled === true
490
+ }
491
+
437
492
  // An adopted panel (viewport mode) no longer lives inside its item -
438
493
  // the trigger's aria-controls id finds it wherever it moved.
439
494
  #panelFor(value) {
@@ -449,10 +504,10 @@ export default class NavigationMenuController extends Controller {
449
504
  .find((item) => item.dataset.value === value) ?? null
450
505
  }
451
506
 
452
- // Arrow stops: every trigger and top-level link in the bar, DOM order.
507
+ // Arrow stops: every enabled trigger and top-level link in the bar, DOM order.
453
508
  #arrowStops() {
454
509
  return [...this.element.querySelectorAll(
455
510
  `${TRIGGER_SELECTOR}, [data-slot="navigation-menu-list"] > ${ITEM_SELECTOR} > a`
456
- )]
511
+ )].filter((stop) => !stop.disabled)
457
512
  }
458
513
  }
@@ -32,7 +32,6 @@ export default class extends Controller {
32
32
  static events = ["poetry:number-field:change", "poetry:number-field:commit"]
33
33
 
34
34
  #value = null
35
- #focusedOnce = false
36
35
  #holdTimer = null
37
36
  #holdInterval = null
38
37
  #holdChanged = false
@@ -119,15 +118,6 @@ export default class extends Controller {
119
118
  if (parsed !== null) this.#apply(parsed, { display: false })
120
119
  }
121
120
 
122
- /** The focus action: the first focus parks the caret at the end. */
123
- focus() {
124
- if (this.#focusedOnce) return
125
-
126
- this.#focusedOnce = true
127
- const end = this.inputTarget.value.length
128
- this.inputTarget.setSelectionRange(end, end)
129
- }
130
-
131
121
  /**
132
122
  * The blur action - the text commit point: empty clears, unparseable
133
123
  * text is left as typed with no commit, parseable text clamps and
@@ -161,7 +151,7 @@ export default class extends Controller {
161
151
  if (event.button !== 0 || this.inputTarget.readOnly) return
162
152
 
163
153
  // Mouse focuses the input (touch would pop the software keyboard).
164
- if (event.pointerType !== "touch") this.inputTarget.focus()
154
+ if (event.pointerType !== "touch") this.#focusInput()
165
155
  this.#pressTicked = true
166
156
  this.#holdChanged = false
167
157
  const direction = this.#directionFor(event.currentTarget)
@@ -224,6 +214,17 @@ export default class extends Controller {
224
214
  return changed
225
215
  }
226
216
 
217
+ // Programmatic focus (the steppers) parks the caret at the end BEFORE
218
+ // focusing: every engine restores the stored selection on focus, and a
219
+ // host's own focus handler can still choose differently. Keyboard and
220
+ // pointer focus keep the browser's native selection (Tab selects the
221
+ // value, a click places the caret).
222
+ #focusInput() {
223
+ const end = this.inputTarget.value.length
224
+ this.inputTarget.setSelectionRange(end, end)
225
+ this.inputTarget.focus()
226
+ }
227
+
227
228
  #directionFor(button) {
228
229
  return this.hasIncrementTarget && button === this.incrementTarget ? 1 : -1
229
230
  }
@@ -240,13 +241,22 @@ export default class extends Controller {
240
241
  this.#apply(candidate, { display: true })
241
242
  }
242
243
 
244
+ // The dominant axis decides (a precision touchpad emits sub-pixel
245
+ // cross-axis noise): a sideways gesture is page scroll and passes
246
+ // through untouched, except under Shift, which some browsers deliver
247
+ // on the horizontal axis - there the horizontal delta is the intended
248
+ // one. Positive is "down", stepping the value down.
243
249
  #wheel(event) {
244
250
  if (event.ctrlKey || document.activeElement !== this.inputTarget) return
245
251
 
246
- event.preventDefault()
247
- if (event.deltaY === 0) return
252
+ const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY)
253
+ if (horizontal && !event.shiftKey) return
254
+
255
+ const delta = horizontal ? event.deltaX : event.deltaY
256
+ if (delta === 0) return
248
257
 
249
- this.#step(event.deltaY > 0 ? -1 : 1, event)
258
+ event.preventDefault()
259
+ this.#step(delta > 0 ? -1 : 1, event)
250
260
  this.#commit()
251
261
  }
252
262
 
@@ -595,9 +595,9 @@
595
595
  "disconnect",
596
596
  "focusOpen",
597
597
  "openValueChanged",
598
+ "pointerDown",
598
599
  "pointerEnter",
599
- "pointerLeave",
600
- "touchGuard"
600
+ "pointerLeave"
601
601
  ],
602
602
  "events": [
603
603
  "poetry:hover-card:closed",
@@ -841,7 +841,6 @@
841
841
  "blur",
842
842
  "connect",
843
843
  "disconnect",
844
- "focus",
845
844
  "hiddenChanged",
846
845
  "input",
847
846
  "keydown",
@@ -47,7 +47,7 @@ module Poetry
47
47
  #
48
48
  # @api private
49
49
  class Catalog
50
- PASSTHROUGH = %w[class id key webmcp data aria role style if unless].freeze
50
+ PASSTHROUGH = %w[class id key webmcp identity data aria role style if unless].freeze
51
51
  COLOR_FAMILIES = %w[
52
52
  slate gray zinc neutral stone red orange amber yellow lime green emerald
53
53
  teal cyan sky blue indigo violet purple fuchsia pink rose
@@ -401,7 +401,7 @@ module Poetry
401
401
  unless path
402
402
  return helper_findings(helper, call, base_line) + yieldless_findings(helper, call, line) +
403
403
  helper_arity_findings(helper, call, line) + webmcp_form_findings(helper, call, line) +
404
- passthrough_findings(keyword_pairs(call), base_line) + data_findings(call, base_line)
404
+ passthrough_findings(keyword_pairs(call), base_line) + data_findings(call, base_line, helper)
405
405
  end
406
406
 
407
407
  record_binding(call, path, bindings, line)
@@ -415,7 +415,7 @@ module Poetry
415
415
  content_findings(path, helper, call, line, content_fed) +
416
416
  blockless_slot_findings(path, helper, call, pairs, line) +
417
417
  requires_any_findings(path, call, pairs, line, content_fed) +
418
- passthrough_findings(pairs, base_line) + data_findings(call, base_line)
418
+ passthrough_findings(pairs, base_line) + data_findings(call, base_line, helper)
419
419
  end
420
420
 
421
421
  # The requires_content tier: a component that raises without a
@@ -474,13 +474,19 @@ module Poetry
474
474
  known = @catalog.option_names(path)
475
475
  entry = @catalog.option_entry(path, key)
476
476
 
477
+ # The component's identity attribute: never overridable (the render
478
+ # raises in development and test, drops it in production).
479
+ return [reserved_finding("data-component", helper_of(path), line)] if key == "data-component"
480
+
477
481
  unless known.include?(key) || Catalog::PASSTHROUGH.include?(key)
478
482
  suggestion = suggest(key, known)
479
- # No suggestion => an intentional pass-through html attribute, not a typo.
483
+ # No suggestion => an intentional pass-through html attribute, not a
484
+ # typo. A suggestion is the render-time raise (development, test).
480
485
  if suggestion
481
- findings << Finding.new(rule: "unknown-option", severity: :warning,
482
- message: "#{helper_of(path)} has no option #{key}", line: line,
483
- suggestion: suggestion)
486
+ findings << Finding.new(rule: "unknown-option", severity: :error,
487
+ message: "#{helper_of(path)} has no option #{key} " \
488
+ "(unknown keywords render as HTML attributes)",
489
+ line: line, suggestion: suggestion)
484
490
  end
485
491
  end
486
492
 
@@ -640,7 +646,7 @@ module Poetry
640
646
  findings = arity_findings(entry, slot_name, call, line) +
641
647
  setter_block_findings(entry, slot_name, call, line) +
642
648
  setter_keyword_findings(entry, slot_name, call, base_line) +
643
- passthrough_findings(keyword_pairs(call), base_line) + data_findings(call, base_line)
649
+ passthrough_findings(keyword_pairs(call), base_line) + data_findings(call, base_line, label)
644
650
  component = entry["component"]
645
651
  return findings unless component
646
652
 
@@ -948,18 +954,29 @@ module Poetry
948
954
  # data key (`poetry__core__x_target:` and `"poetry--core--x-target":`
949
955
  # both render data-poetry--core--x-target), so the rendered name is
950
956
  # what gets checked. Dynamic values are left alone.
951
- def data_findings(call, base_line)
957
+ def data_findings(call, base_line, helper)
952
958
  hash = hash_argument(call, "data")
953
959
  return [] unless hash
954
960
 
955
961
  hash_pairs(hash).flat_map do |key, node|
962
+ line = base_line + node.location.start_line - 1
963
+ next [reserved_finding("data: { component: }", helper, line)] if key == "component"
964
+
956
965
  value = literal_value(node)
957
966
  next [] unless value.is_a?(String)
958
967
 
959
- stimulus_findings("data-#{key.tr("_", "-")}", value, base_line + node.location.start_line - 1)
968
+ stimulus_findings("data-#{key.tr("_", "-")}", value, line)
960
969
  end
961
970
  end
962
971
 
972
+ def reserved_finding(spelling, helper, line)
973
+ Finding.new(rule: "reserved-attribute", severity: :error,
974
+ message: "#{spelling} is #{helper}'s own identity attribute and is never overridable " \
975
+ "(a composed root takes identity: instead; the raw attribute raises at render " \
976
+ "in development)",
977
+ line: line)
978
+ end
979
+
963
980
  def controller_findings(value, line)
964
981
  value.split.filter_map do |identifier|
965
982
  next unless identifier.start_with?(POETRY_PREFIX)
@@ -56,7 +56,8 @@ module Poetry
56
56
  # actions.
57
57
  # - `css_mode` (`:tailwind`) - `:tailwind` emits resolved utility
58
58
  # classes; `:bem` emits the BEM token IR for bring-your-own-CSS
59
- # hosts.
59
+ # kits authored on this DSL (poetry-ui is Tailwind-native and is
60
+ # not a `:bem` consumer).
60
61
  # - `icon_library` (`:lucide`) - the active icon set, by the key it
61
62
  # registered under ({Poetry::Core::Icons.register}).
62
63
  # - `raise_on_missing_icon` (`nil`) - the policy for a dynamic icon
@@ -169,7 +170,9 @@ module Poetry
169
170
  #
170
171
  # @!method css_mode
171
172
  # The class emission mode: `:tailwind` resolves style values to
172
- # utility classes, `:bem` emits the BEM token IR.
173
+ # utility classes, `:bem` emits the BEM token IR - for kits authored
174
+ # on the DSL that write their own templates; poetry-ui is
175
+ # Tailwind-native and is not a `:bem` consumer.
173
176
  # @return [Symbol] :tailwind or :bem
174
177
  #
175
178
  # @!method css_mode=(mode)
@@ -4,6 +4,6 @@ module Poetry
4
4
  module Core
5
5
  # The gem version. Every gem in the family carries the same version
6
6
  # and pins poetry-core to it exactly.
7
- VERSION = "0.1.0"
7
+ VERSION = "0.1.1"
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: poetry-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Solt