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,151 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { setState } from "@poetry/controllers/helpers/state"
|
|
3
|
+
|
|
4
|
+
// The Tabs activation machine: this controller owns ONLY the
|
|
5
|
+
// active-value state + attribute writes; the shared poetry--core--roving-focus
|
|
6
|
+
// on the tablist owns the keyboard (default tabindex-managing mode - one Tab
|
|
7
|
+
// stop). Triggers are DUMB buttons (click -> tabs#activate; with automatic
|
|
8
|
+
// activation - the APG default for tabs - focusin activates too, so arrow
|
|
9
|
+
// keys both move focus AND switch panels).
|
|
10
|
+
//
|
|
11
|
+
// The state vocabulary: the active trigger carries data-active
|
|
12
|
+
// (the styled token) + aria-selected; inactive panels carry the hidden
|
|
13
|
+
// property + data-hidden. data-activation-direction is deliberately NOT
|
|
14
|
+
// emitted - no shipped class consumes it (add it with the animated
|
|
15
|
+
// indicator, when something does).
|
|
16
|
+
//
|
|
17
|
+
// Panels are scoped to THIS root (a nested Tabs inside a panel owns its own
|
|
18
|
+
// triggers/panels - the DOM is the registry, same rule as roving-focus).
|
|
19
|
+
const TRIGGER_SELECTOR = '[data-slot="tabs-trigger"]'
|
|
20
|
+
const PANEL_SELECTOR = '[data-slot="tabs-content"]'
|
|
21
|
+
|
|
22
|
+
// The component-facing event namespace (the poetry:<component> rule).
|
|
23
|
+
const EVENT_PREFIX = "poetry:tabs"
|
|
24
|
+
|
|
25
|
+
export default class TabsController extends Controller {
|
|
26
|
+
// The events this controller dispatches (manifest surface;
|
|
27
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
28
|
+
static events = ["poetry:tabs:change"]
|
|
29
|
+
|
|
30
|
+
static values = {
|
|
31
|
+
// false = manual activation (arrows only move focus; Enter/Space - the
|
|
32
|
+
// native button click - activates). true is the APG-recommended default.
|
|
33
|
+
activateOnFocus: { type: Boolean, default: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reconcile-on-connect: derives the full vocabulary from the
|
|
38
|
+
* server-rendered data-active truth (the body comment holds the rules).
|
|
39
|
+
*/
|
|
40
|
+
connect() {
|
|
41
|
+
// Reconcile-on-connect: the server-rendered data-active trigger is the
|
|
42
|
+
// truth; aria-selected/tabindex/hidden are (re)derived from it, so a
|
|
43
|
+
// Turbo re-render can never leave mixed vocabularies. No data-active
|
|
44
|
+
// anywhere -> the first enabled trigger becomes active.
|
|
45
|
+
const active = this.#triggers().find((trigger) => trigger.hasAttribute("data-active"))
|
|
46
|
+
const fallback = this.#triggers().find((trigger) => !this.#disabled(trigger))
|
|
47
|
+
const value = (active ?? fallback)?.dataset.value
|
|
48
|
+
|
|
49
|
+
if (value !== undefined) this.#apply(value, { silent: true })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Each trigger's click action: activates the pressed trigger's value.
|
|
54
|
+
*
|
|
55
|
+
* @param {MouseEvent} event
|
|
56
|
+
*/
|
|
57
|
+
activate(event) {
|
|
58
|
+
const trigger = this.#triggerFrom(event)
|
|
59
|
+
|
|
60
|
+
if (!trigger || this.#disabled(trigger)) return
|
|
61
|
+
|
|
62
|
+
this.#apply(trigger.dataset.value)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The tablist's roving-focus entry action - automatic activation
|
|
67
|
+
* follows the roving focus via the roving controller's entry event
|
|
68
|
+
* (deterministic: never depends on the platform firing focusin for a
|
|
69
|
+
* programmatic .focus()). A raw focusin routes here too, so hand-wired
|
|
70
|
+
* hosts get the same behavior.
|
|
71
|
+
*
|
|
72
|
+
* @param {CustomEvent | FocusEvent} event
|
|
73
|
+
*/
|
|
74
|
+
focusActivate(event) {
|
|
75
|
+
if (!this.activateOnFocusValue) return
|
|
76
|
+
|
|
77
|
+
const trigger = (event.detail && event.detail.item) || this.#triggerFrom(event)
|
|
78
|
+
|
|
79
|
+
if (!trigger || this.#disabled(trigger)) return
|
|
80
|
+
if (trigger.hasAttribute("data-active")) return // already there - no re-fire
|
|
81
|
+
|
|
82
|
+
this.#apply(trigger.dataset.value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The programmatic controllable-state surface: setValue("account").
|
|
87
|
+
* Unknown values are ignored (the contract's guard). Returns the
|
|
88
|
+
* resulting state - what an agent tool reports back - so a no-op is
|
|
89
|
+
* visible as changed: false.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} value - a trigger's data-value (stringified)
|
|
92
|
+
* @returns {{ value: string, changed: boolean }} the active value after the call
|
|
93
|
+
*/
|
|
94
|
+
setValue(value) {
|
|
95
|
+
const next = String(value)
|
|
96
|
+
const known = this.#triggers().some((trigger) => trigger.dataset.value === next)
|
|
97
|
+
const before = this.#activeValue()
|
|
98
|
+
|
|
99
|
+
if (known) this.#apply(next)
|
|
100
|
+
|
|
101
|
+
return { value: known ? next : before, changed: known && next !== before }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#activeValue() {
|
|
105
|
+
return this.#triggers().find((trigger) => trigger.getAttribute("aria-selected") === "true")?.dataset.value ?? ""
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#apply(value, { silent = false } = {}) {
|
|
109
|
+
for (const trigger of this.#triggers()) {
|
|
110
|
+
const active = trigger.dataset.value === value
|
|
111
|
+
|
|
112
|
+
setState(trigger, active ? "active" : "inactive")
|
|
113
|
+
trigger.setAttribute("aria-selected", active ? "true" : "false")
|
|
114
|
+
// The active tab is the roving tab stop (re-entering the tablist
|
|
115
|
+
// lands on the selection) - the stamp roving-focus adopts.
|
|
116
|
+
trigger.setAttribute("tabindex", active ? "0" : "-1")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const panel of this.#panels()) {
|
|
120
|
+
const active = panel.dataset.value === value
|
|
121
|
+
|
|
122
|
+
panel.hidden = !active
|
|
123
|
+
if (active) panel.removeAttribute("data-hidden")
|
|
124
|
+
else panel.setAttribute("data-hidden", "")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!silent) this.dispatch("change", { prefix: EVENT_PREFIX, detail: { value } })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#triggerFrom(event) {
|
|
131
|
+
const origin = event.target instanceof Element ? event.target : null
|
|
132
|
+
|
|
133
|
+
return origin?.closest(TRIGGER_SELECTOR) ?? null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#disabled(trigger) {
|
|
137
|
+
return trigger.hasAttribute("disabled") || trigger.hasAttribute("data-disabled")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#triggers() {
|
|
141
|
+
return [...this.element.querySelectorAll(TRIGGER_SELECTOR)].filter((el) => this.#owns(el))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#panels() {
|
|
145
|
+
return [...this.element.querySelectorAll(PANEL_SELECTOR)].filter((el) => this.#owns(el))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#owns(el) {
|
|
149
|
+
return el.closest(`[data-controller~="${this.identifier}"]`) === this.element
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// The TagGroup removal engine (the tag-group contract):
|
|
4
|
+
// navigation itself rides the roving-focus controller (the toolbar
|
|
5
|
+
// precedent) - this controller owns what tags add on top:
|
|
6
|
+
//
|
|
7
|
+
// - Delete/Backspace on a focused tag removes it (row-origin keys only;
|
|
8
|
+
// keys from a tag's inner remove button must not drive the grid).
|
|
9
|
+
// - The remove button removes exactly its own tag.
|
|
10
|
+
// - Focus recovery after removal is the reference walk: FORWARD through
|
|
11
|
+
// the pre-removal order to the first surviving enabled tag, then
|
|
12
|
+
// backward; when the last tag goes, the CONTAINER takes focus, flips
|
|
13
|
+
// role grid->group, and becomes the tab stop.
|
|
14
|
+
// - The container is a live region ONLY while focus is within (polite,
|
|
15
|
+
// additions) - SRs hear tags added while working in the group without
|
|
16
|
+
// spam from elsewhere.
|
|
17
|
+
//
|
|
18
|
+
// Removal is CANCELABLE (poetry:tag-group:remove): a Turbo-driven host
|
|
19
|
+
// preventDefault()s and re-renders; otherwise this controller removes the
|
|
20
|
+
// row (and the hidden input riding it - form mode serializes name[] per
|
|
21
|
+
// tag).
|
|
22
|
+
const EVENT_PREFIX = "poetry:tag-group"
|
|
23
|
+
|
|
24
|
+
export default class TagGroupController extends Controller {
|
|
25
|
+
// The events this controller dispatches (manifest surface;
|
|
26
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
27
|
+
static events = ["poetry:tag-group:remove"]
|
|
28
|
+
|
|
29
|
+
#onFocusin = () => this.element.setAttribute("aria-live", "polite")
|
|
30
|
+
#onFocusout = (event) => {
|
|
31
|
+
if (this.element.contains(event.relatedTarget)) return
|
|
32
|
+
|
|
33
|
+
this.element.setAttribute("aria-live", "off")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wires the focus-scoped live-region toggling and reflects emptiness
|
|
38
|
+
* (role, tab stop, data-empty).
|
|
39
|
+
*/
|
|
40
|
+
connect() {
|
|
41
|
+
this.element.addEventListener("focusin", this.#onFocusin)
|
|
42
|
+
this.element.addEventListener("focusout", this.#onFocusout)
|
|
43
|
+
this.#reflectEmpty()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Unwires the focus listeners. */
|
|
47
|
+
disconnect() {
|
|
48
|
+
this.element.removeEventListener("focusin", this.#onFocusin)
|
|
49
|
+
this.element.removeEventListener("focusout", this.#onFocusout)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The container's keydown action: Delete/Backspace on a focused tag ROW
|
|
54
|
+
* removes it - row-origin keys only (keys from a tag's inner remove
|
|
55
|
+
* button must not drive the grid).
|
|
56
|
+
*
|
|
57
|
+
* @param {KeyboardEvent} event
|
|
58
|
+
*/
|
|
59
|
+
keydown(event) {
|
|
60
|
+
if (event.key !== "Delete" && event.key !== "Backspace") return
|
|
61
|
+
|
|
62
|
+
const row = event.target.closest("[data-slot='tag-group-tag']")
|
|
63
|
+
|
|
64
|
+
// Row-origin only: the remove button's own keys stay its own.
|
|
65
|
+
if (!row || event.target !== row) return
|
|
66
|
+
if (row.hasAttribute("data-disabled")) return
|
|
67
|
+
|
|
68
|
+
event.preventDefault()
|
|
69
|
+
this.#remove(row)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The remove button's click action: removes exactly its own tag.
|
|
74
|
+
*
|
|
75
|
+
* @param {MouseEvent} event
|
|
76
|
+
*/
|
|
77
|
+
remove(event) {
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
const row = event.target.closest("[data-slot='tag-group-tag']")
|
|
80
|
+
|
|
81
|
+
if (!row || row.hasAttribute("data-disabled")) return
|
|
82
|
+
|
|
83
|
+
this.#remove(row)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#remove(row) {
|
|
87
|
+
const removal = this.dispatch("remove", {
|
|
88
|
+
prefix: EVENT_PREFIX,
|
|
89
|
+
detail: { value: row.getAttribute("data-value"), id: row.id },
|
|
90
|
+
cancelable: true
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (removal.defaultPrevented) return // the host re-renders instead
|
|
94
|
+
|
|
95
|
+
const target = this.#recoveryTarget(row)
|
|
96
|
+
|
|
97
|
+
row.remove()
|
|
98
|
+
this.#reflectEmpty()
|
|
99
|
+
|
|
100
|
+
if (target) {
|
|
101
|
+
target.setAttribute("tabindex", "0")
|
|
102
|
+
target.focus()
|
|
103
|
+
} else if (this.element.contains(document.activeElement) ||
|
|
104
|
+
document.activeElement === document.body) {
|
|
105
|
+
this.element.focus()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The reference walk, in pre-removal DOM order: forward to the first
|
|
110
|
+
// surviving enabled tag, then backward.
|
|
111
|
+
#recoveryTarget(row) {
|
|
112
|
+
const rows = this.#rows()
|
|
113
|
+
const index = rows.indexOf(row)
|
|
114
|
+
const enabled = (candidate) => candidate !== row && !candidate.hasAttribute("data-disabled")
|
|
115
|
+
|
|
116
|
+
return rows.slice(index + 1).find(enabled) ??
|
|
117
|
+
rows.slice(0, index).reverse().find(enabled) ??
|
|
118
|
+
null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#reflectEmpty() {
|
|
122
|
+
const empty = this.#rows().length === 0
|
|
123
|
+
|
|
124
|
+
this.element.toggleAttribute("data-empty", empty)
|
|
125
|
+
this.element.setAttribute("role", empty ? "group" : "grid")
|
|
126
|
+
|
|
127
|
+
if (empty) this.element.setAttribute("tabindex", "0")
|
|
128
|
+
else this.element.removeAttribute("tabindex")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#rows() {
|
|
132
|
+
return Array.from(this.element.querySelectorAll("[data-slot='tag-group-tag']"))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { announce } from "@poetry/controllers/helpers/announce"
|
|
3
|
+
import { isImeKeydown } from "@poetry/controllers/helpers/escape"
|
|
4
|
+
import { exitPresence } from "@poetry/controllers/helpers/presence"
|
|
5
|
+
import { setState, stateOf } from "@poetry/controllers/helpers/state"
|
|
6
|
+
|
|
7
|
+
// One toast item (poetry's own Toast - the stacked-toaster genre with
|
|
8
|
+
// strict a11y semantics). The item is role=status aria-live=off: it
|
|
9
|
+
// never announces itself - on connect it speaks ONCE through the
|
|
10
|
+
// announce singleton at its politeness (destructive -> assertive,
|
|
11
|
+
// wired server-side via the
|
|
12
|
+
// politeness value). The auto-dismiss timer follows APG/WCAG 2.2.1 timing:
|
|
13
|
+
// it PAUSES on hover, focus-within, window blur and tab-hidden (reasons are
|
|
14
|
+
// refcounted so overlapping pauses cannot resume early), and duration <= 0
|
|
15
|
+
// means persistent (required for undo/action toasts). Dismiss flips
|
|
16
|
+
// data-open -> data-closed, dispatches poetry:toast:dismiss {id, reason} (the
|
|
17
|
+
// toaster's reflow + focus-return seam), then presence holds the node until
|
|
18
|
+
// its exit animation finishes before removal.
|
|
19
|
+
//
|
|
20
|
+
// Swipe-to-dismiss is a browser-verification-GATED enhancement (contract) -
|
|
21
|
+
// it does not ship in this pass; the swipe reason is reserved.
|
|
22
|
+
const EVENT_PREFIX = "poetry:toast"
|
|
23
|
+
|
|
24
|
+
const ACTION_SELECTOR = '[data-slot="toast-action"]'
|
|
25
|
+
const TITLE_SELECTOR = '[data-slot="toast-title"]'
|
|
26
|
+
const DESCRIPTION_SELECTOR = '[data-slot="toast-description"]'
|
|
27
|
+
|
|
28
|
+
// event.type -> pause/resume reason, so each pause source releases only
|
|
29
|
+
// its own hold (hover out must not resume a focus hold).
|
|
30
|
+
const PAUSE_REASONS = {
|
|
31
|
+
mouseenter: "hover",
|
|
32
|
+
mouseleave: "hover",
|
|
33
|
+
focusin: "focus",
|
|
34
|
+
focusout: "focus"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let toastSequence = 0
|
|
38
|
+
|
|
39
|
+
export default class ToastController extends Controller {
|
|
40
|
+
// The events this controller dispatches (manifest surface;
|
|
41
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
42
|
+
static events = ["poetry:toast:dismiss", "poetry:toast:show"]
|
|
43
|
+
|
|
44
|
+
static targets = ["action", "close"]
|
|
45
|
+
static values = {
|
|
46
|
+
duration: { type: Number, default: 5000 },
|
|
47
|
+
politeness: { type: String, default: "polite" }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#wired = []
|
|
51
|
+
#timer = null
|
|
52
|
+
#remaining = 0
|
|
53
|
+
#startedAt = null
|
|
54
|
+
#pauseReasons = new Set()
|
|
55
|
+
#dismissed = false
|
|
56
|
+
#onVisibilityChange = () => {
|
|
57
|
+
if (document.hidden) this.pause("visibility")
|
|
58
|
+
else this.resume("visibility")
|
|
59
|
+
}
|
|
60
|
+
#onWindowBlur = () => this.pause("window")
|
|
61
|
+
#onWindowFocus = () => this.resume("window")
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Assigns an id when missing, adopts the open pair, announces ONCE
|
|
65
|
+
* through the singleton, wires the pause sources and the toast-local
|
|
66
|
+
* Escape, and starts the auto-dismiss timer (queued/hidden toasts hold
|
|
67
|
+
* theirs).
|
|
68
|
+
*/
|
|
69
|
+
connect() {
|
|
70
|
+
if (!this.element.id) this.element.id = `poetry-toast-${(toastSequence += 1).toString(16)}`
|
|
71
|
+
if (!stateOf(this.element)) setState(this.element, "open")
|
|
72
|
+
|
|
73
|
+
// Announce ONCE through the singleton (the item is aria-live=off).
|
|
74
|
+
// Consumers must never announce() toast content themselves.
|
|
75
|
+
announce(this.#message(), this.politenessValue)
|
|
76
|
+
|
|
77
|
+
// APG timing: pause while the tab is hidden or the window is blurred.
|
|
78
|
+
this.#listen(document, "visibilitychange", this.#onVisibilityChange)
|
|
79
|
+
this.#listen(window, "blur", this.#onWindowBlur)
|
|
80
|
+
this.#listen(window, "focus", this.#onWindowFocus)
|
|
81
|
+
// Esc while focus is inside dismisses this toast (it reports as the
|
|
82
|
+
// close affordance: close-press).
|
|
83
|
+
this.#listen(this.element, "keydown", (event) => {
|
|
84
|
+
if (event.key !== "Escape" || isImeKeydown(event)) return
|
|
85
|
+
|
|
86
|
+
event.stopPropagation()
|
|
87
|
+
this.dismiss("close-press")
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
this.#remaining = this.durationValue
|
|
91
|
+
|
|
92
|
+
// A toast queued by the toaster (hidden at connect) holds its timer
|
|
93
|
+
// until promotion; the toaster resumes it with the "queued" reason.
|
|
94
|
+
if (this.element.hidden) this.#pauseReasons.add("queued")
|
|
95
|
+
if (document.hidden) this.#pauseReasons.add("visibility")
|
|
96
|
+
|
|
97
|
+
this.#startTimer()
|
|
98
|
+
|
|
99
|
+
this.dispatch("show", {
|
|
100
|
+
prefix: EVENT_PREFIX,
|
|
101
|
+
detail: { id: this.element.id, variant: this.element.dataset.variant ?? "default" }
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Stops the timer and unwires every listener. */
|
|
106
|
+
disconnect() {
|
|
107
|
+
this.#stopTimer()
|
|
108
|
+
|
|
109
|
+
for (const [target, type, listener] of this.#wired) target.removeEventListener(type, listener)
|
|
110
|
+
|
|
111
|
+
this.#wired = []
|
|
112
|
+
this.#pauseReasons.clear()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- timer pause/resume (markup: mouseenter/focusin -> pause,
|
|
116
|
+
// mouseleave/focusout -> resume; the toaster + window paths call with
|
|
117
|
+
// string reasons) ---
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Holds the auto-dismiss timer under a reason (hover/focus derive from
|
|
121
|
+
* events; the toaster and window paths pass strings). Reasons pool in a
|
|
122
|
+
* set, so overlapping pauses cannot resume early.
|
|
123
|
+
*
|
|
124
|
+
* @param {Event | string} [eventOrReason]
|
|
125
|
+
*/
|
|
126
|
+
pause(eventOrReason) {
|
|
127
|
+
this.#pauseReasons.add(this.#reasonFor(eventOrReason))
|
|
128
|
+
this.#stopTimer()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Releases one pause reason; the timer restarts when none remain.
|
|
133
|
+
*
|
|
134
|
+
* @param {Event | string} [eventOrReason]
|
|
135
|
+
*/
|
|
136
|
+
resume(eventOrReason) {
|
|
137
|
+
this.#pauseReasons.delete(this.#reasonFor(eventOrReason))
|
|
138
|
+
this.#startTimer()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- dismissal ---
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Dismisses the toast. Reasons: timeout | close-press | action |
|
|
145
|
+
* swipe(reserved) | manual - the family reason vocabulary plus
|
|
146
|
+
* poetry's own timeout/action/queued/manual extensions. A click on the
|
|
147
|
+
* action slot reports "action"; the close
|
|
148
|
+
* button "close-press". The dismiss event goes out BEFORE removal (the
|
|
149
|
+
* toaster's reflow + focus-return seam); presence then holds the node
|
|
150
|
+
* through its exit animation.
|
|
151
|
+
*
|
|
152
|
+
* @param {Event | string} [eventOrReason] - the triggering event (the
|
|
153
|
+
* reason derives from its origin) or an explicit reason string
|
|
154
|
+
*/
|
|
155
|
+
dismiss(eventOrReason) {
|
|
156
|
+
if (this.#dismissed) return
|
|
157
|
+
|
|
158
|
+
this.#dismissed = true
|
|
159
|
+
this.#stopTimer()
|
|
160
|
+
this.#pauseReasons.clear()
|
|
161
|
+
|
|
162
|
+
const reason = typeof eventOrReason === "string"
|
|
163
|
+
? eventOrReason
|
|
164
|
+
: this.#reasonFromEvent(eventOrReason)
|
|
165
|
+
|
|
166
|
+
// Dispatched BEFORE removal so it bubbles to the toaster (reflow +
|
|
167
|
+
// focus return); presence then holds the node through its exit.
|
|
168
|
+
this.dispatch("dismiss", {
|
|
169
|
+
prefix: EVENT_PREFIX,
|
|
170
|
+
detail: { id: this.element.id, reason }
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
exitPresence(this.element, { onRemove: () => this.element.remove() })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- internals ---
|
|
177
|
+
|
|
178
|
+
#startTimer() {
|
|
179
|
+
if (this.#timer !== null || this.#dismissed) return
|
|
180
|
+
if (this.#pauseReasons.size > 0) return
|
|
181
|
+
if (this.durationValue <= 0) return // persistent (undo toasts)
|
|
182
|
+
if (this.#remaining <= 0) return
|
|
183
|
+
|
|
184
|
+
this.#startedAt = Date.now()
|
|
185
|
+
this.#timer = window.setTimeout(() => {
|
|
186
|
+
this.#timer = null
|
|
187
|
+
this.dismiss("timeout")
|
|
188
|
+
}, this.#remaining)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#stopTimer() {
|
|
192
|
+
if (this.#timer === null) return
|
|
193
|
+
|
|
194
|
+
window.clearTimeout(this.#timer)
|
|
195
|
+
this.#timer = null
|
|
196
|
+
this.#remaining = Math.max(0, this.#remaining - (Date.now() - this.#startedAt))
|
|
197
|
+
this.#startedAt = null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#reasonFor(eventOrReason) {
|
|
201
|
+
if (typeof eventOrReason === "string") return eventOrReason
|
|
202
|
+
if (eventOrReason?.type) return PAUSE_REASONS[eventOrReason.type] ?? eventOrReason.type
|
|
203
|
+
|
|
204
|
+
return "manual"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#reasonFromEvent(event) {
|
|
208
|
+
const origin = event?.currentTarget instanceof Element ? event.currentTarget : event?.target
|
|
209
|
+
|
|
210
|
+
if (origin instanceof Element && origin.closest(ACTION_SELECTOR)) return "action"
|
|
211
|
+
|
|
212
|
+
return "close-press"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Title + description text, the announced payload (textContent only -
|
|
216
|
+
// the singleton never sees markup).
|
|
217
|
+
#message() {
|
|
218
|
+
const title = this.element.querySelector(TITLE_SELECTOR)?.textContent?.trim() ?? ""
|
|
219
|
+
const description = this.element.querySelector(DESCRIPTION_SELECTOR)?.textContent?.trim() ?? ""
|
|
220
|
+
|
|
221
|
+
return [title, description].filter(Boolean).join(" ")
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#listen(target, type, listener) {
|
|
225
|
+
target.addEventListener(type, listener)
|
|
226
|
+
this.#wired.push([target, type, listener])
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// The client-side toast delivery trigger (poetry's no-round-trip path -
|
|
4
|
+
// what a toast() JS factory does elsewhere, done with server-rendered
|
|
5
|
+
// markup): press -> dispatch poetry:toaster:stamp, and the toaster clones
|
|
6
|
+
// the addressed <template>'s toast into its region. The toast inside the
|
|
7
|
+
// template is byte-for-byte what a Turbo Stream would deliver.
|
|
8
|
+
export default class ToastTriggerController extends Controller {
|
|
9
|
+
static events = ["poetry:toaster:stamp"]
|
|
10
|
+
static values = {
|
|
11
|
+
// The <template> element id holding the rendered toast.
|
|
12
|
+
template: String,
|
|
13
|
+
// Optional toaster region id - omit for the page's toaster.
|
|
14
|
+
toaster: String
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The click action: stamps the addressed template's toast into the
|
|
19
|
+
* toaster region (the no-round-trip delivery the header describes).
|
|
20
|
+
*/
|
|
21
|
+
fire() {
|
|
22
|
+
window.dispatchEvent(new CustomEvent("poetry:toaster:stamp", {
|
|
23
|
+
detail: { template: this.templateValue, toaster: this.toasterValue || null }
|
|
24
|
+
}))
|
|
25
|
+
}
|
|
26
|
+
}
|