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,197 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { acquire, release } from "@poetry/controllers/helpers/announce"
3
+ import { isEditingTarget, matchesHotkey } from "@poetry/controllers/helpers/hotkey"
4
+ import { stateOf } from "@poetry/controllers/helpers/state"
5
+
6
+ // The toast viewport (one per page): the labeled role=region <ol> that is
7
+ // the Turbo Stream append target (id=poetry-toaster, data-turbo-permanent).
8
+ // It ACQUIRES the announce singleton for its lifetime (items announce
9
+ // through it on connect), owns the F8 hotkey (focus moves to the most
10
+ // recent toast; the prior focus is remembered so dismissal returns it),
11
+ // enforces the visible LIMIT (default 3 - the oldest overflow toasts queue
12
+ // hidden with their timers held, promoted as newer ones dismiss), and owns
13
+ // the stack reflow (--poetry-toast-index, newest = 0, for the offset/scale
14
+ // stack styling).
15
+ //
16
+ // Items arrive by server render, Turbo Stream append, or a stamp (the
17
+ // poetry:toaster:stamp window event - poetry_toast_trigger's no-round-trip
18
+ // path, cloning a <template> toast into the region);
19
+ // a childList MutationObserver reconciles limit + reflow on every change,
20
+ // so no append path needs to know about the toaster.
21
+ const TOAST_SELECTOR = '[data-slot="toast"]'
22
+ const TOAST_IDENTIFIER = "poetry--core--toast"
23
+
24
+ const INDEX_PROPERTY = "--poetry-toast-index"
25
+
26
+ export default class ToasterController extends Controller {
27
+ static targets = ["item"]
28
+ static values = {
29
+ hotkey: { type: String, default: "F8" },
30
+ limit: { type: Number, default: 3 },
31
+ position: String
32
+ }
33
+
34
+ #observer = null
35
+ #returnFocusTo = null
36
+ #onKeydown = (event) => this.focusRegion(event)
37
+ #onDismiss = (event) => this.#handleDismiss(event)
38
+ #onStamp = (event) => this.#stamp(event)
39
+
40
+ /**
41
+ * Acquires the announce singleton, wires the hotkey / stamp / dismiss
42
+ * listeners, and starts the childList reconciler.
43
+ */
44
+ connect() {
45
+ acquire() // the singleton lives while a toaster is connected
46
+
47
+ window.addEventListener("keydown", this.#onKeydown)
48
+ window.addEventListener("poetry:toaster:stamp", this.#onStamp)
49
+ this.element.addEventListener("poetry:toast:dismiss", this.#onDismiss)
50
+
51
+ this.#observer = new MutationObserver(() => this.#reconcile())
52
+ this.#observer.observe(this.element, { childList: true })
53
+
54
+ this.#reconcile()
55
+ }
56
+
57
+ /** Unwires everything and releases the announce singleton. */
58
+ disconnect() {
59
+ this.#observer?.disconnect()
60
+ this.#observer = null
61
+ window.removeEventListener("keydown", this.#onKeydown)
62
+ window.removeEventListener("poetry:toaster:stamp", this.#onStamp)
63
+ this.element.removeEventListener("poetry:toast:dismiss", this.#onDismiss)
64
+ this.#returnFocusTo = null
65
+ release()
66
+ }
67
+
68
+ // The stamp delivery path: clone a <template>'s toast into the region.
69
+ // detail.toaster (an id) scopes multi-toaster pages - a stamp addressed
70
+ // elsewhere is ignored; unaddressed stamps land in every toaster that
71
+ // hears them (one per page canonically). The childList observer then
72
+ // reconciles limit + reflow like any other append.
73
+ #stamp(event) {
74
+ const { template, toaster } = event.detail || {}
75
+ if (toaster && toaster !== this.element.id) return
76
+
77
+ const node = template ? document.getElementById(template) : null
78
+ if (!(node instanceof HTMLTemplateElement)) {
79
+ console.warn(`poetry toaster: stamp template ${JSON.stringify(template)} not found or not a <template>`)
80
+ return
81
+ }
82
+
83
+ this.element.append(node.content.cloneNode(true))
84
+ }
85
+
86
+ /**
87
+ * The hotkey's landing (F8 default, configurable): moves focus into the
88
+ * region - onto the most recent visible toast, else the region itself.
89
+ * The prior focus is remembered for the dismiss return.
90
+ *
91
+ * @param {Event} [event] - the window keydown when the hotkey drove it;
92
+ * programmatic calls may omit it
93
+ */
94
+ focusRegion(event) {
95
+ if (event instanceof KeyboardEvent) {
96
+ // The full descriptor grammar + the editing-target rule (the hotkey
97
+ // controller's idiom): a bare key comparison fires on stray-modifier
98
+ // chords and while the user types in a field.
99
+ if (event.defaultPrevented || !matchesHotkey(event, this.hotkeyValue)) return
100
+ if (isEditingTarget(event) && !(event.metaKey || event.ctrlKey || event.altKey)) return
101
+
102
+ event.preventDefault()
103
+ }
104
+
105
+ const active = document.activeElement
106
+
107
+ if (active instanceof HTMLElement && !this.element.contains(active)) {
108
+ this.#returnFocusTo = active
109
+ }
110
+
111
+ const target = this.#visibleToasts().at(-1) ?? this.element
112
+
113
+ target.focus()
114
+ }
115
+
116
+ /**
117
+ * Stack reflow: newest toast = index 0 (the front of the stack),
118
+ * written as the offset/scale custom property.
119
+ */
120
+ reflow() {
121
+ const visible = this.#visibleToasts()
122
+
123
+ visible.forEach((toast, index) => {
124
+ toast.style.setProperty(INDEX_PROPERTY, String(visible.length - 1 - index))
125
+ })
126
+ }
127
+
128
+ // --- internals ---
129
+
130
+ // Limit + queue + reflow, run on every childList change: the newest
131
+ // `limit` open toasts are visible; older overflow queues hidden (timer
132
+ // held via the toast controller's "queued" pause) until slots free up.
133
+ #reconcile() {
134
+ const open = this.#openToasts()
135
+ const overflow = Math.max(0, open.length - this.limitValue)
136
+
137
+ open.forEach((toast, index) => {
138
+ if (index < overflow) this.#queue(toast)
139
+ else this.#promote(toast)
140
+ })
141
+
142
+ this.reflow()
143
+ this.#restoreFocusIfLost()
144
+ }
145
+
146
+ #queue(toast) {
147
+ if (toast.hasAttribute("data-queued")) return
148
+
149
+ toast.setAttribute("data-queued", "")
150
+ toast.hidden = true
151
+ this.#toastController(toast)?.pause("queued")
152
+ }
153
+
154
+ #promote(toast) {
155
+ if (!toast.hasAttribute("data-queued")) return
156
+
157
+ toast.removeAttribute("data-queued")
158
+ toast.hidden = false
159
+ this.#toastController(toast)?.resume("queued")
160
+ }
161
+
162
+ // Dismissal focus return: when the dismissed toast held focus (hotkey
163
+ // entry), focus goes back to the remembered prior element.
164
+ #handleDismiss(event) {
165
+ const toast = event.target instanceof Element ? event.target.closest(TOAST_SELECTOR) : null
166
+
167
+ if (toast?.contains(document.activeElement) && this.#returnFocusTo?.isConnected) {
168
+ this.#returnFocusTo.focus()
169
+ this.#returnFocusTo = null
170
+ }
171
+ }
172
+
173
+ // A removed toast that carried focus drops it on <body>; hand it back.
174
+ #restoreFocusIfLost() {
175
+ if (document.activeElement !== document.body) return
176
+ if (!this.#returnFocusTo?.isConnected) return
177
+
178
+ this.#returnFocusTo.focus()
179
+ this.#returnFocusTo = null
180
+ }
181
+
182
+ #toasts() {
183
+ return Array.from(this.element.querySelectorAll(TOAST_SELECTOR))
184
+ }
185
+
186
+ #openToasts() {
187
+ return this.#toasts().filter((toast) => stateOf(toast) !== "closed")
188
+ }
189
+
190
+ #visibleToasts() {
191
+ return this.#openToasts().filter((toast) => !toast.hidden)
192
+ }
193
+
194
+ #toastController(toast) {
195
+ return this.application.getControllerForElementAndIdentifier(toast, TOAST_IDENTIFIER)
196
+ }
197
+ }
@@ -0,0 +1,206 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { setState, stateOf } from "@poetry/controllers/helpers/state"
3
+
4
+ // The ToggleGroup value-set machine (the Accordion composition, second
5
+ // consumer): this controller owns ONLY the pressed-values set + attribute
6
+ // writes; the shared poetry--core--roving-focus on the same root owns the
7
+ // keyboard (default tabindex-managing mode - one Tab stop). Items are DUMB
8
+ // buttons (data-action -> group#toggle; poetry--core--pressed is NOT
9
+ // attached in group context - one owner, no event soup).
10
+ //
11
+ // The role/vocabulary split, enforced here: type=single is
12
+ // RADIO semantics - items carry aria-checked (aria-pressed stripped) and
13
+ // re-pressing the sole pressed item deselects to EMPTY;
14
+ // type=multiple is toolbar semantics - independent aria-pressed items. The
15
+ // controller reads type once and never mixes vocabularies; the bare
16
+ // data-pressed presence boolean styles both types identically (Toggle's
17
+ // classes just work).
18
+ //
19
+ // After every transition the PRESSED item becomes the roving tab stop
20
+ // (re-entering the group lands on the selection) -
21
+ // written directly as the tabindex stamp roving-focus adopts.
22
+ const ITEM_SELECTOR = '[data-slot="toggle-group-item"]'
23
+
24
+ export default class ToggleGroupController extends Controller {
25
+ // The events this controller dispatches (manifest surface;
26
+ // events_declaration.test.js enforces the list stays honest).
27
+ static events = ["poetry:toggle-group:change"]
28
+
29
+ static values = {
30
+ type: { type: String, default: "single" }
31
+ }
32
+
33
+ /**
34
+ * Reconcile-on-connect: re-derives the type-correct aria vocabulary
35
+ * from the server-rendered data-pressed truth, then prefers the pressed
36
+ * tab stop.
37
+ */
38
+ connect() {
39
+ // Reconcile-on-connect: server-rendered data-pressed is the truth; the
40
+ // type-correct aria attribute is (re)derived from it, so a Turbo Stream
41
+ // re-render (or a hand-patched item) can never mix vocabularies.
42
+ for (const item of this.#items()) this.#write(item, stateOf(item) === "pressed")
43
+
44
+ this.#preferPressedTabStop()
45
+ }
46
+
47
+ // --- item activation (click / Space / Enter via native button click) ---
48
+
49
+ /**
50
+ * Each item's click action (Space/Enter arrive via native button
51
+ * activation): single mode is radio semantics with deselect-to-empty;
52
+ * multiple XORs the value in and out.
53
+ *
54
+ * @param {MouseEvent} event
55
+ */
56
+ toggle(event) {
57
+ const origin = event.currentTarget instanceof Element ? event.currentTarget : event.target
58
+ const item = origin instanceof Element ? origin.closest(ITEM_SELECTOR) : null
59
+
60
+ if (!item || this.#disabled(item)) return
61
+
62
+ const value = item.dataset.value
63
+
64
+ if (this.#single()) this.#toggleSingle(value)
65
+ else this.#toggleMultiple(value)
66
+ }
67
+
68
+ // --- the programmatic controllable-state surface ---
69
+
70
+ /**
71
+ * The programmatic controllable-state surface: setValue("b") in single
72
+ * mode ("" / null clears); setValue(["a", "b"]) in multiple mode.
73
+ * Validated against type; unknown values are ignored (logged in dev -
74
+ * the contract's guard).
75
+ *
76
+ * @param {string | string[] | null} value
77
+ */
78
+ setValue(value) {
79
+ const values = this.#known(Array.isArray(value) ? value : (value ? [value] : []))
80
+
81
+ if (this.#single()) {
82
+ if (Array.isArray(value) && value.length > 1) {
83
+ console.warn("poetry--core--toggle-group: setValue(array) is invalid with type=single")
84
+ return
85
+ }
86
+
87
+ this.#apply(new Set(values.slice(0, 1)))
88
+ } else {
89
+ if (!Array.isArray(value) && value != null && value !== "") {
90
+ console.warn("poetry--core--toggle-group: setValue expects an array with type=multiple")
91
+ return
92
+ }
93
+
94
+ this.#apply(new Set(values))
95
+ }
96
+ }
97
+
98
+ // --- the set machine ---
99
+
100
+ // single: S == {v} -> {} (deselect-to-empty, contractual), else -> {v}.
101
+ #toggleSingle(value) {
102
+ const pressed = this.#pressedValues()
103
+
104
+ this.#apply(new Set(pressed.has(value) ? [] : [value]))
105
+ }
106
+
107
+ // multiple: S -> S XOR {v}.
108
+ #toggleMultiple(value) {
109
+ const pressed = this.#pressedValues()
110
+
111
+ if (pressed.has(value)) pressed.delete(value)
112
+ else pressed.add(value)
113
+
114
+ this.#apply(pressed)
115
+ }
116
+
117
+ #apply(next) {
118
+ const pressed = []
119
+ const unpressed = []
120
+
121
+ for (const item of this.#items()) {
122
+ const on = next.has(item.dataset.value)
123
+ const was = stateOf(item) === "pressed"
124
+
125
+ this.#write(item, on)
126
+
127
+ if (on && !was) pressed.push(item.dataset.value)
128
+ if (!on && was) unpressed.push(item.dataset.value)
129
+ }
130
+
131
+ if (pressed.length === 0 && unpressed.length === 0) return
132
+
133
+ this.#preferPressedTabStop()
134
+
135
+ const values = [...next]
136
+
137
+ this.dispatch("change", {
138
+ prefix: "poetry:toggle-group",
139
+ detail: {
140
+ type: this.typeValue,
141
+ ...(this.#single() ? { value: values[0] ?? null } : { values }),
142
+ pressed,
143
+ unpressed
144
+ }
145
+ })
146
+ }
147
+
148
+ // data-pressed and the TYPE-CORRECT aria attribute, written together:
149
+ // single items are radios (aria-checked only - a stale aria-pressed
150
+ // would double-voice the state); multiple items are toggle buttons.
151
+ #write(item, on) {
152
+ setState(item, on ? "pressed" : "unpressed")
153
+
154
+ if (this.#single()) {
155
+ item.setAttribute("aria-checked", String(on))
156
+ item.removeAttribute("aria-pressed")
157
+ } else {
158
+ item.setAttribute("aria-pressed", String(on))
159
+ item.removeAttribute("aria-checked")
160
+ }
161
+ }
162
+
163
+ // The pressed item is the preferred tab stop.
164
+ // Written as the tabindex stamp the roving-focus controller adopts (its
165
+ // current-stop scan reads tabindex="0"); only when roving already manages
166
+ // tabindex here (the stamp exists), so a manage-tabindex:false host is
167
+ // left untouched.
168
+ #preferPressedTabStop() {
169
+ const items = this.#items()
170
+
171
+ if (!items.some((item) => item.hasAttribute("tabindex"))) return
172
+
173
+ const target = items.find((item) => stateOf(item) === "pressed" && !this.#disabled(item)) ??
174
+ items.find((item) => !this.#disabled(item))
175
+
176
+ if (!target) return
177
+
178
+ for (const item of items) item.setAttribute("tabindex", item === target ? "0" : "-1")
179
+ }
180
+
181
+ #pressedValues() {
182
+ return new Set(this.#items().filter((item) => stateOf(item) === "pressed").map((item) => item.dataset.value))
183
+ }
184
+
185
+ #known(values) {
186
+ const valid = new Set(this.#items().map((item) => item.dataset.value))
187
+ const unknown = values.filter((value) => !valid.has(value))
188
+
189
+ if (unknown.length > 0) console.debug(`poetry--core--toggle-group: unknown value(s) ${unknown.join(", ")}`)
190
+
191
+ return values.filter((value) => valid.has(value))
192
+ }
193
+
194
+ #items() {
195
+ return Array.from(this.element.querySelectorAll(ITEM_SELECTOR))
196
+ }
197
+
198
+ #single() {
199
+ return this.typeValue !== "multiple"
200
+ }
201
+
202
+ #disabled(item) {
203
+ return item.hasAttribute("disabled") || item.hasAttribute("data-disabled") ||
204
+ this.element.hasAttribute("data-disabled")
205
+ }
206
+ }