poetry-core 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- metadata +267 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// The Carousel engine, decided NATIVE: no carousel library - the platform's
|
|
4
|
+
// scroll-snap owns the physics (touch, momentum, snapping, overscroll),
|
|
5
|
+
// and this controller adds only what CSS can't: prev/next paging, button
|
|
6
|
+
// state, and arrow keys. Navigation scrolls the VIEWPORT by a bounding-
|
|
7
|
+
// rect delta (RTL- and transform-safe: never scrollLeft sign
|
|
8
|
+
// conventions) - not scrollIntoView, whose alignment bubbles to
|
|
9
|
+
// scrollable ancestors and whose "nearest" no-ops when several slides
|
|
10
|
+
// fit the viewport at once (the vertical stack). Deferred with the
|
|
11
|
+
// library-grade machinery: loop (it clones slides), autoplay, and a plugin API.
|
|
12
|
+
const SLIDE_SELECTOR = '[data-slot="carousel-item"]'
|
|
13
|
+
|
|
14
|
+
// The component-facing event namespace (the poetry:<component> rule).
|
|
15
|
+
const EVENT_PREFIX = "poetry:carousel"
|
|
16
|
+
|
|
17
|
+
export default class CarouselController extends Controller {
|
|
18
|
+
// The events this controller dispatches (manifest surface;
|
|
19
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
20
|
+
static events = ["poetry:carousel:select"]
|
|
21
|
+
|
|
22
|
+
static targets = ["viewport", "previous", "next"]
|
|
23
|
+
static values = {
|
|
24
|
+
orientation: { type: String, default: "horizontal" }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#raf = null
|
|
28
|
+
|
|
29
|
+
/** Syncs button state to the initial scroll position. */
|
|
30
|
+
connect() {
|
|
31
|
+
this.#sync()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Cancels any pending sync frame. */
|
|
35
|
+
disconnect() {
|
|
36
|
+
if (this.#raf !== null) cancelAnimationFrame(this.#raf)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The viewport's scroll action - button state follows the SCROLL
|
|
41
|
+
* (finger, wheel, keyboard PageDown), not just our own paging.
|
|
42
|
+
* rAF-coalesced: scroll fires in bursts.
|
|
43
|
+
*/
|
|
44
|
+
scrolled() {
|
|
45
|
+
if (this.#raf !== null) return
|
|
46
|
+
|
|
47
|
+
this.#raf = requestAnimationFrame(() => {
|
|
48
|
+
this.#raf = null
|
|
49
|
+
this.#sync()
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The previous button's click action: pages one slide back. */
|
|
54
|
+
previous() {
|
|
55
|
+
this.#scrollTo(this.#index() - 1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The next button's click action: pages one slide forward. */
|
|
59
|
+
next() {
|
|
60
|
+
this.#scrollTo(this.#index() + 1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The region root's keydown action:
|
|
65
|
+
* arrows page the carousel, orientation-aware.
|
|
66
|
+
*
|
|
67
|
+
* @param {KeyboardEvent} event
|
|
68
|
+
*/
|
|
69
|
+
keydown(event) {
|
|
70
|
+
const [prevKey, nextKey] =
|
|
71
|
+
this.orientationValue === "vertical" ? ["ArrowUp", "ArrowDown"] : ["ArrowLeft", "ArrowRight"]
|
|
72
|
+
|
|
73
|
+
if (event.key === prevKey) {
|
|
74
|
+
event.preventDefault()
|
|
75
|
+
this.previous()
|
|
76
|
+
} else if (event.key === nextKey) {
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
this.next()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The programmatic surface: scrolls to a slide by index (clamped).
|
|
84
|
+
*
|
|
85
|
+
* @param {number | string} index - stringified numbers accepted
|
|
86
|
+
*/
|
|
87
|
+
scrollTo(index) {
|
|
88
|
+
this.#scrollTo(Number(index))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#scrollTo(index) {
|
|
92
|
+
const slides = this.#slides()
|
|
93
|
+
const target = slides[Math.max(0, Math.min(slides.length - 1, index))]
|
|
94
|
+
if (!target) return
|
|
95
|
+
|
|
96
|
+
// Rect delta start-aligns the target inside the viewport alone.
|
|
97
|
+
// Subtracting scroll-margin lands on the slide's SNAP position (the
|
|
98
|
+
// items carry negative scroll-margin to cancel their gutter from the
|
|
99
|
+
// snap area), so the smooth scroll and the CSS snap agree.
|
|
100
|
+
const vertical = this.orientationValue === "vertical"
|
|
101
|
+
const rect = target.getBoundingClientRect()
|
|
102
|
+
const viewport = this.viewportTarget.getBoundingClientRect()
|
|
103
|
+
const style = getComputedStyle(target)
|
|
104
|
+
const delta = vertical
|
|
105
|
+
? rect.top - (parseFloat(style.scrollMarginTop) || 0) - viewport.top
|
|
106
|
+
: rect.left - (parseFloat(style.scrollMarginLeft) || 0) - viewport.left
|
|
107
|
+
|
|
108
|
+
this.viewportTarget.scrollBy?.({
|
|
109
|
+
behavior: "smooth",
|
|
110
|
+
left: vertical ? 0 : delta,
|
|
111
|
+
top: vertical ? delta : 0
|
|
112
|
+
})
|
|
113
|
+
// Button state also syncs from the scroll events the smooth scroll
|
|
114
|
+
// emits; this immediate pass covers environments without them.
|
|
115
|
+
this.#sync(index)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The slide nearest the viewport's start edge - bounding rects, so RTL
|
|
119
|
+
// and transforms cost nothing.
|
|
120
|
+
#index() {
|
|
121
|
+
const viewport = this.viewportTarget.getBoundingClientRect()
|
|
122
|
+
let nearest = 0
|
|
123
|
+
let nearestDistance = Infinity
|
|
124
|
+
|
|
125
|
+
this.#slides().forEach((slide, index) => {
|
|
126
|
+
const rect = slide.getBoundingClientRect()
|
|
127
|
+
const distance = this.orientationValue === "vertical"
|
|
128
|
+
? Math.abs(rect.top - viewport.top)
|
|
129
|
+
: Math.abs(rect.left - viewport.left)
|
|
130
|
+
|
|
131
|
+
if (distance < nearestDistance) {
|
|
132
|
+
nearest = index
|
|
133
|
+
nearestDistance = distance
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return nearest
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#sync(index = this.#index()) {
|
|
141
|
+
const last = this.#slides().length - 1
|
|
142
|
+
|
|
143
|
+
if (this.hasPreviousTarget) this.previousTarget.disabled = index <= 0
|
|
144
|
+
if (this.hasNextTarget) this.nextTarget.disabled = index >= last || this.#atEnd()
|
|
145
|
+
|
|
146
|
+
this.dispatch("select", { prefix: EVENT_PREFIX, detail: { index } })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Snap points the scroller cannot reach still exist under native
|
|
150
|
+
// scroll, so next must ALSO disable at max scroll -
|
|
151
|
+
// otherwise trailing slides that cannot start-align (multi-visible
|
|
152
|
+
// layouts) leave a live button that does nothing. Math.abs keeps the
|
|
153
|
+
// check RTL-safe (scrollLeft runs negative there).
|
|
154
|
+
#atEnd() {
|
|
155
|
+
const viewport = this.viewportTarget
|
|
156
|
+
const vertical = this.orientationValue === "vertical"
|
|
157
|
+
const max = vertical
|
|
158
|
+
? viewport.scrollHeight - viewport.clientHeight
|
|
159
|
+
: viewport.scrollWidth - viewport.clientWidth
|
|
160
|
+
|
|
161
|
+
return max > 0 && Math.abs(vertical ? viewport.scrollTop : viewport.scrollLeft) >= max - 1
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#slides() {
|
|
165
|
+
return [...this.viewportTarget.querySelectorAll(SLIDE_SELECTOR)]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { stateOf } from "@poetry/controllers/helpers/state"
|
|
3
|
+
|
|
4
|
+
// The select-all recipe (a parent checkbox running the APG
|
|
5
|
+
// mixed-state pattern) over the checked family. Wrap the parent and its
|
|
6
|
+
// rows, route the bubbling per-checkbox observe event at #changed, and
|
|
7
|
+
// mark the parent (target: all) plus each row (target: item):
|
|
8
|
+
//
|
|
9
|
+
// <div data-controller="poetry--core--checkbox-group"
|
|
10
|
+
// data-action="poetry:checkbox:change->poetry--core--checkbox-group#changed">
|
|
11
|
+
//
|
|
12
|
+
// A parent toggle fans its new state out to every enabled row; a row
|
|
13
|
+
// toggle re-derives the parent (all -> checked, none -> unchecked, some ->
|
|
14
|
+
// indeterminate, re-entered via the checked controller's set() - the only
|
|
15
|
+
// path back to mixed). Every state write goes THROUGH each box's checked
|
|
16
|
+
// controller (input first, real change event, aria-checked + the checked
|
|
17
|
+
// triple together) - this controller never touches attributes itself.
|
|
18
|
+
// Disabled rows are skipped by fan-out and excluded from the derivation
|
|
19
|
+
// (the DataTable disabledBehavior 'selection' doctrine;
|
|
20
|
+
// table_selection_controller is this same recipe for NATIVE checkboxes).
|
|
21
|
+
const EVENT_PREFIX = "poetry:checkbox-group"
|
|
22
|
+
|
|
23
|
+
export default class CheckboxGroupController extends Controller {
|
|
24
|
+
// The events this controller dispatches (manifest surface;
|
|
25
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
26
|
+
static events = ["poetry:checkbox-group:change"]
|
|
27
|
+
|
|
28
|
+
static targets = ["all", "item"]
|
|
29
|
+
|
|
30
|
+
#applying = false
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The wrapper's data-action route for every bubbling checkbox change: a
|
|
34
|
+
* parent toggle fans out, a row toggle re-derives the parent, and the
|
|
35
|
+
* group's own change event reports the enabled count/total. The
|
|
36
|
+
* applying flag mutes the echoes our own set() calls dispatch (a
|
|
37
|
+
* fan-out must not re-derive per row; a parent reflection must not fan
|
|
38
|
+
* out).
|
|
39
|
+
*
|
|
40
|
+
* @param {CustomEvent} event - the per-checkbox change
|
|
41
|
+
*/
|
|
42
|
+
changed(event) {
|
|
43
|
+
if (this.#applying) return
|
|
44
|
+
|
|
45
|
+
const fromParent = this.hasAllTarget && event.target === this.allTarget
|
|
46
|
+
|
|
47
|
+
if (!fromParent && !this.itemTargets.includes(event.target)) return
|
|
48
|
+
if (fromParent) this.#fanOut(event.detail.checked)
|
|
49
|
+
|
|
50
|
+
// After a fan-out too: rows that refused (all disabled) snap the
|
|
51
|
+
// parent back to what the roster actually holds.
|
|
52
|
+
this.#reflectParent()
|
|
53
|
+
|
|
54
|
+
const enabled = this.#enabled()
|
|
55
|
+
|
|
56
|
+
this.dispatch("change", {
|
|
57
|
+
prefix: EVENT_PREFIX,
|
|
58
|
+
detail: { count: this.#checked(enabled).length, total: enabled.length }
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#fanOut(checked) {
|
|
63
|
+
this.#applying = true
|
|
64
|
+
for (const item of this.#enabled()) this.#checkedController(item)?.set(checked)
|
|
65
|
+
this.#applying = false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#reflectParent() {
|
|
69
|
+
if (!this.hasAllTarget) return
|
|
70
|
+
|
|
71
|
+
const enabled = this.#enabled()
|
|
72
|
+
const checked = this.#checked(enabled).length
|
|
73
|
+
const state = checked === 0 ? "unchecked" : (checked === enabled.length ? "checked" : "indeterminate")
|
|
74
|
+
|
|
75
|
+
// Dedupe: a row toggle that leaves the parent where it was (one of
|
|
76
|
+
// several rows unchecked) must not re-dispatch the parent's change.
|
|
77
|
+
if (stateOf(this.allTarget) === state) return
|
|
78
|
+
|
|
79
|
+
this.#applying = true
|
|
80
|
+
this.#checkedController(this.allTarget)?.set(state === "indeterminate" ? state : state === "checked")
|
|
81
|
+
this.#applying = false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#checked(items) {
|
|
85
|
+
return items.filter((item) => stateOf(item) === "checked")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#enabled() {
|
|
89
|
+
return this.itemTargets.filter(
|
|
90
|
+
(item) => !item.hasAttribute("disabled") && item.getAttribute("aria-disabled") !== "true"
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#checkedController(element) {
|
|
95
|
+
return this.application.getControllerForElementAndIdentifier(element, "poetry--core--checked")
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { setState, stateOf } from "@poetry/controllers/helpers/state"
|
|
3
|
+
|
|
4
|
+
// The toggle family's checked-state owner (Checkbox introduces it; Switch
|
|
5
|
+
// reuses it VERBATIM - zero fork, CI-asserted). The architecture is the
|
|
6
|
+
// STORE INVERSION: the hidden native <input type=checkbox> is the form
|
|
7
|
+
// participant AND the store - the visual button[role=checkbox|switch] only
|
|
8
|
+
// REFLECTS it (aria-checked incl. "mixed" + the data-checked/data-unchecked/
|
|
9
|
+
// data-indeterminate attributes on the control and every part that carries
|
|
10
|
+
// them: the checkbox indicator, the switch thumb). Every transition writes the input FIRST, dispatches a REAL
|
|
11
|
+
// bubbling change event (no synthetic prototype-setter dance - the
|
|
12
|
+
// input LEADS and the visual follows), then reflects attributes.
|
|
13
|
+
//
|
|
14
|
+
// The three states: checked / unchecked / indeterminate. Indeterminate is
|
|
15
|
+
// server/programmatic only (aria-checked=mixed, input.indeterminate - a
|
|
16
|
+
// JS-only property re-derived from the checked attributes on connect); the first user
|
|
17
|
+
// toggle resolves it to CHECKED - contractual, do not loosen. A Switch
|
|
18
|
+
// never renders it (the Ruby component raises), so that branch is
|
|
19
|
+
// simply dormant there.
|
|
20
|
+
//
|
|
21
|
+
// Enter is suppressed on role=checkbox ONLY (WAI-ARIA: checkboxes activate
|
|
22
|
+
// on Space alone); role=switch has no keydown
|
|
23
|
+
// handler, so Enter toggles via the native button click (a deliberate
|
|
24
|
+
// asymmetry, keyed off the role - no controller fork).
|
|
25
|
+
//
|
|
26
|
+
// No inputId value -> pure visual mode: state lives on the button's
|
|
27
|
+
// checked attributes alone (controlled-UI cases like DataTable row selection).
|
|
28
|
+
// The component-flavored prefixes the dynamic dispatch below emits under
|
|
29
|
+
// (data-component on the host: checkbox / switch, with the bare fallback) -
|
|
30
|
+
// the events declaration, the portal bridge, and the manifest enumerate
|
|
31
|
+
// these REAL names; the identifier-default name never fires here.
|
|
32
|
+
const EVENT_PREFIXES = ["poetry:checkbox", "poetry:switch", "poetry:checked"]
|
|
33
|
+
|
|
34
|
+
export default class CheckedController extends Controller {
|
|
35
|
+
// The events this controller dispatches (manifest surface;
|
|
36
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
37
|
+
static events = EVENT_PREFIXES.map((prefix) => `${prefix}:change`)
|
|
38
|
+
|
|
39
|
+
static values = {
|
|
40
|
+
inputId: { type: String, default: "" }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#keydown = null
|
|
44
|
+
#reset = null
|
|
45
|
+
#form = null
|
|
46
|
+
#initialIndeterminate = false
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reconcile-on-connect: derives input.checked/indeterminate from the
|
|
50
|
+
* checked attributes (the server truth), arms the form-reset restore,
|
|
51
|
+
* and installs the role-keyed Enter suppression.
|
|
52
|
+
*/
|
|
53
|
+
connect() {
|
|
54
|
+
const input = this.#input()
|
|
55
|
+
|
|
56
|
+
// Reconcile-on-connect: the checked attributes are the server truth
|
|
57
|
+
// (Turbo Stream re-render safe); input.indeterminate has no attribute -
|
|
58
|
+
// derive it.
|
|
59
|
+
if (input) {
|
|
60
|
+
const state = this.#state()
|
|
61
|
+
|
|
62
|
+
this.#initialIndeterminate = state === "indeterminate"
|
|
63
|
+
input.checked = state === "checked"
|
|
64
|
+
input.indeterminate = this.#initialIndeterminate
|
|
65
|
+
|
|
66
|
+
// Native form reset restores the server-rendered checked attribute -
|
|
67
|
+
// initial-state restore for free; rAF because
|
|
68
|
+
// reset fires BEFORE the browser restores input values.
|
|
69
|
+
this.#form = input.form
|
|
70
|
+
|
|
71
|
+
if (this.#form) {
|
|
72
|
+
this.#reset = () => window.requestAnimationFrame(() => {
|
|
73
|
+
input.indeterminate = this.#initialIndeterminate
|
|
74
|
+
this.#reflect(input.indeterminate ? "indeterminate" : (input.checked ? "checked" : "unchecked"))
|
|
75
|
+
})
|
|
76
|
+
this.#form.addEventListener("reset", this.#reset)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// WAI-ARIA: a checkbox does not activate on Enter (Space only - the
|
|
81
|
+
// form's Enter-to-submit is unaffected). Keyed off the role so the
|
|
82
|
+
// Switch reuse keeps its native Enter-toggles behavior.
|
|
83
|
+
if (this.element.getAttribute("role") === "checkbox") {
|
|
84
|
+
this.#keydown = (event) => {
|
|
85
|
+
if (event.key === "Enter") event.preventDefault()
|
|
86
|
+
}
|
|
87
|
+
this.element.addEventListener("keydown", this.#keydown)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Unwires the keydown and form-reset listeners. */
|
|
92
|
+
disconnect() {
|
|
93
|
+
if (this.#keydown) this.element.removeEventListener("keydown", this.#keydown)
|
|
94
|
+
if (this.#reset && this.#form) this.#form.removeEventListener("reset", this.#reset)
|
|
95
|
+
|
|
96
|
+
this.#keydown = null
|
|
97
|
+
this.#reset = null
|
|
98
|
+
this.#form = null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Control activation (click / Space / label-for): indeterminate
|
|
103
|
+
* resolves to checked (contractual), else flip.
|
|
104
|
+
*/
|
|
105
|
+
toggle() {
|
|
106
|
+
if (this.#disabled()) return
|
|
107
|
+
|
|
108
|
+
const state = this.#state()
|
|
109
|
+
const wasIndeterminate = state === "indeterminate"
|
|
110
|
+
|
|
111
|
+
this.#commit(wasIndeterminate ? true : state !== "checked", { wasIndeterminate })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- the programmatic controllable-state surface ---
|
|
115
|
+
|
|
116
|
+
/** Programmatically checks (see set). */
|
|
117
|
+
check() {
|
|
118
|
+
this.set(true)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Programmatically unchecks (see set). */
|
|
122
|
+
uncheck() {
|
|
123
|
+
this.set(false)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The programmatic controllable-state surface: set(true | false |
|
|
128
|
+
* "indeterminate") reaches all three states (the select-all recipe -
|
|
129
|
+
* checkbox_group_controller - re-enters indeterminate this way).
|
|
130
|
+
*
|
|
131
|
+
* @param {boolean | "indeterminate"} state
|
|
132
|
+
*/
|
|
133
|
+
set(state) {
|
|
134
|
+
if (this.#disabled()) return
|
|
135
|
+
|
|
136
|
+
if (state === "indeterminate") this.#commitIndeterminate()
|
|
137
|
+
else this.#commit(Boolean(state), { wasIndeterminate: this.#state() === "indeterminate" })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- the one write path: input first, real change event, then reflect ---
|
|
141
|
+
|
|
142
|
+
#commit(checked, { wasIndeterminate = false } = {}) {
|
|
143
|
+
const input = this.#input()
|
|
144
|
+
|
|
145
|
+
if (input) {
|
|
146
|
+
input.indeterminate = false
|
|
147
|
+
input.checked = checked
|
|
148
|
+
input.dispatchEvent(new Event("change", { bubbles: true }))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.#reflect(checked ? "checked" : "unchecked")
|
|
152
|
+
|
|
153
|
+
// The component-flavored observe surface (poetry:checkbox:change /
|
|
154
|
+
// poetry:switch:change) without forking the shared controller: the
|
|
155
|
+
// prefix derives from the host's data-component self-id.
|
|
156
|
+
this.dispatch("change", {
|
|
157
|
+
prefix: `poetry:${this.element.dataset.component ?? "checked"}`,
|
|
158
|
+
detail: { checked, was_indeterminate: wasIndeterminate }
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#commitIndeterminate() {
|
|
163
|
+
const input = this.#input()
|
|
164
|
+
|
|
165
|
+
if (input) {
|
|
166
|
+
input.checked = false
|
|
167
|
+
input.indeterminate = true
|
|
168
|
+
input.dispatchEvent(new Event("change", { bubbles: true }))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.#reflect("indeterminate")
|
|
172
|
+
this.dispatch("change", {
|
|
173
|
+
prefix: `poetry:${this.element.dataset.component ?? "checked"}`,
|
|
174
|
+
detail: { checked: false, was_indeterminate: false, indeterminate: true }
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// aria-checked (mixed mapping) and the checked attributes are written
|
|
179
|
+
// TOGETHER, on the control AND every part that mirrors the checked state
|
|
180
|
+
// (indicator / thumb) - recognized by wearing any of the family's pair
|
|
181
|
+
// attributes (data-checked / data-unchecked / data-indeterminate).
|
|
182
|
+
#reflect(state) {
|
|
183
|
+
this.element.setAttribute("aria-checked", state === "indeterminate" ? "mixed" : String(state === "checked"))
|
|
184
|
+
setState(this.element, state)
|
|
185
|
+
|
|
186
|
+
const parts = this.element.querySelectorAll("[data-checked], [data-unchecked], [data-indeterminate]")
|
|
187
|
+
|
|
188
|
+
for (const part of parts) setState(part, state)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#state() {
|
|
192
|
+
const state = stateOf(this.element)
|
|
193
|
+
|
|
194
|
+
if (state) return state
|
|
195
|
+
|
|
196
|
+
const aria = this.element.getAttribute("aria-checked")
|
|
197
|
+
|
|
198
|
+
return aria === "mixed" ? "indeterminate" : (aria === "true" ? "checked" : "unchecked")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#input() {
|
|
202
|
+
return this.inputIdValue ? document.getElementById(this.inputIdValue) : null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#disabled() {
|
|
206
|
+
return this.element.hasAttribute("disabled") || this.element.getAttribute("aria-disabled") === "true"
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { announce } from "@poetry/controllers/helpers/announce"
|
|
3
|
+
|
|
4
|
+
// ClipboardText, adapted from an MIT-licensed source (source and license
|
|
5
|
+
// in THIRD_PARTY_NOTICES.md): a read-only value with one
|
|
6
|
+
// copy affordance. navigator.clipboard is the primary path; the
|
|
7
|
+
// execCommand fallback SAVES AND RESTORES the user's own selection and
|
|
8
|
+
// focus (copying a field must never eat a selection made elsewhere on the
|
|
9
|
+
// page). Success stamps data-copied on
|
|
10
|
+
// the root for a beat (CSS swaps the copy/check glyphs off it), announces
|
|
11
|
+
// through the live-region singleton, and dispatches the copied event.
|
|
12
|
+
const EVENT_PREFIX = "poetry:clipboard-text"
|
|
13
|
+
const COPIED_MS = 1500
|
|
14
|
+
|
|
15
|
+
export default class ClipboardTextController extends Controller {
|
|
16
|
+
// The events this controller dispatches (manifest surface;
|
|
17
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
18
|
+
static events = ["poetry:clipboard-text:copied"]
|
|
19
|
+
|
|
20
|
+
// input: a field whose value is the text. source: any element whose
|
|
21
|
+
// textContent is the text (CodeBlock's rendered code - CSS-counter line
|
|
22
|
+
// numbers live in ::before and are excluded by construction).
|
|
23
|
+
static targets = ["input", "source"]
|
|
24
|
+
|
|
25
|
+
static values = {
|
|
26
|
+
// Copy override: the full value when the display truncates.
|
|
27
|
+
// Empty means "copy what the input/source shows".
|
|
28
|
+
text: String,
|
|
29
|
+
// The announcement, localized server-side.
|
|
30
|
+
message: String
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Clears the copied-beat timer. */
|
|
34
|
+
disconnect() {
|
|
35
|
+
clearTimeout(this.#timer)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The copy action: writes the text (navigator.clipboard first, then the
|
|
40
|
+
* selection-preserving execCommand fallback), stamps data-copied for a
|
|
41
|
+
* beat, announces, and dispatches the copied event with the text in the
|
|
42
|
+
* detail. A failed write changes nothing.
|
|
43
|
+
*
|
|
44
|
+
* @returns {Promise<void>}
|
|
45
|
+
*/
|
|
46
|
+
async copy() {
|
|
47
|
+
const text = this.#text
|
|
48
|
+
if (!(await this.#write(text))) return
|
|
49
|
+
|
|
50
|
+
this.element.toggleAttribute("data-copied", true)
|
|
51
|
+
clearTimeout(this.#timer)
|
|
52
|
+
this.#timer = setTimeout(() => this.element.removeAttribute("data-copied"), COPIED_MS)
|
|
53
|
+
|
|
54
|
+
if (this.messageValue !== "") announce(this.messageValue)
|
|
55
|
+
this.dispatch("copied", { prefix: EVENT_PREFIX, detail: { text } })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#timer
|
|
59
|
+
|
|
60
|
+
get #text() {
|
|
61
|
+
if (this.textValue !== "") return this.textValue
|
|
62
|
+
if (this.hasSourceTarget) return this.sourceTarget.textContent
|
|
63
|
+
|
|
64
|
+
return this.inputTarget.value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async #write(text) {
|
|
68
|
+
try {
|
|
69
|
+
await navigator.clipboard.writeText(text)
|
|
70
|
+
return true
|
|
71
|
+
} catch {
|
|
72
|
+
return this.#execCommandFallback(text)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Legacy path: an offscreen textarea receives the value and the
|
|
77
|
+
// selection, execCommand copies the LIVE selection - so the user's own
|
|
78
|
+
// selection is cloned first and restored after, and focus goes back
|
|
79
|
+
// where it was.
|
|
80
|
+
#execCommandFallback(text) {
|
|
81
|
+
const selection = document.getSelection()
|
|
82
|
+
const savedRanges = []
|
|
83
|
+
for (let index = 0; index < selection.rangeCount; index += 1) {
|
|
84
|
+
savedRanges.push(selection.getRangeAt(index).cloneRange())
|
|
85
|
+
}
|
|
86
|
+
const savedFocus = document.activeElement
|
|
87
|
+
|
|
88
|
+
const textarea = document.createElement("textarea")
|
|
89
|
+
textarea.value = text
|
|
90
|
+
textarea.setAttribute("readonly", "")
|
|
91
|
+
textarea.setAttribute("aria-hidden", "true")
|
|
92
|
+
textarea.style.position = "fixed"
|
|
93
|
+
textarea.style.opacity = "0"
|
|
94
|
+
this.element.appendChild(textarea)
|
|
95
|
+
textarea.select()
|
|
96
|
+
|
|
97
|
+
let copied = false
|
|
98
|
+
try {
|
|
99
|
+
copied = document.execCommand("copy")
|
|
100
|
+
} catch {
|
|
101
|
+
copied = false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
textarea.remove()
|
|
105
|
+
selection.removeAllRanges()
|
|
106
|
+
for (const range of savedRanges) selection.addRange(range)
|
|
107
|
+
if (savedFocus && savedFocus.isConnected) savedFocus.focus()
|
|
108
|
+
|
|
109
|
+
return copied
|
|
110
|
+
}
|
|
111
|
+
}
|