diff --git a/agents/framework/Web/UI/WebControls/INDEX.md b/agents/framework/Web/UI/WebControls/INDEX.md
index 0d43d8776..bcd04340e 100644
--- a/agents/framework/Web/UI/WebControls/INDEX.md
+++ b/agents/framework/Web/UI/WebControls/INDEX.md
@@ -62,6 +62,7 @@ Standard HTML input, layout, data display, and validation controls for the Prado
|---|---|
| `TMultiView` / `TView` | Shows one child `TView` at a time; `ActiveViewIndex` |
| `TAccordion` | Animated expand/collapse panels; JS in `controls/accordion.js` |
+| `TSafetyCover` | Panel content behind a click-to-open overlay (slide/collapse/none, optional fade), guarding against accidental clicks; JS in `controls/safetycover.js` @since 4.4.0 |
| `TTabPanel` | Tabbed views; JS in `controls/tabpanel.js` |
| `TSlider` | Drag-and-drop range slider; JS in `controls/slider.js` |
| `TColorPicker` | HSB color picker widget; JS in `colorpicker/colorpicker.js` |
diff --git a/agents/framework/Web/UI/WebControls/SUMMARY.md b/agents/framework/Web/UI/WebControls/SUMMARY.md
index ec513afd3..a6362d830 100644
--- a/agents/framework/Web/UI/WebControls/SUMMARY.md
+++ b/agents/framework/Web/UI/WebControls/SUMMARY.md
@@ -56,6 +56,8 @@ Standard HTML input, layout, data display, and validation controls for the Prado
- **`TAccordion`** / **`TTabPanel`** / **`TSlider`** / **`TColorPicker`** / **`TDatePicker`** / **`TKeyboard`** / **`TRatingList`** — Widget controls with JS implementations.
+- **`TSafetyCover`** — Panel content behind a click-to-open overlay (slide/collapse/none, optional fade), guarding against accidental clicks; JS in `controls/safetycover.js`. @since 4.4.0
+
### Data Controls
- **`TDataGrid`** — Tabular display with paging/sorting/editing; column types: `TBoundColumn`, `TButtonColumn`, `TCheckBoxColumn`, `TDropDownListColumn`, `TTemplateColumn`.
diff --git a/agents/framework/Web/UI/WebControls/TSafetyCover.md b/agents/framework/Web/UI/WebControls/TSafetyCover.md
new file mode 100644
index 000000000..da5be840f
--- /dev/null
+++ b/agents/framework/Web/UI/WebControls/TSafetyCover.md
@@ -0,0 +1,135 @@
+# Web/UI/WebControls/TSafetyCover
+
+### Directories
+[framework](../../../INDEX.md) / [Web](../../INDEX.md) / [UI](../INDEX.md) / [WebControls](./INDEX.md) / **`TSafetyCover`**
+
+## Class Info
+**Location:** `framework/Web/UI/WebControls/TSafetyCover.php`
+**Namespace:** `Prado\Web\UI\WebControls`
+**Since:** 4.4.0
+
+## Overview
+TSafetyCover extends `TPanel` and keeps its body content behind an overlay. A click on the overlay pulses the panel and moves the cover aside, opening the content. The cover returns after `AutoCloseDelay`, or after the pointer leaves the panel. It models the hinged cover over a physical switch: it prevents accidental activation, and it re-closes itself.
+
+**Two layers (safety):** inside the slider are a transparent **guard** (`safety-cover-overlay`) and a visible **face** (`safety-cover-face`) nested within it. The guard never moves; only its `pointer-events` toggle (auto when closed → blocks, none when open), so it re-blocks the content the instant close begins. The face is the colored skin that animates open and closed over `AnimationDuration`. Decoupling them means the close animates smoothly (the face slides/fades back) while the guard already blocks every click — no window where a click reaches the content mid-close. Verified: during the close animation the topmost hit-testable element over the guarded content is always the guard.
+
+It is a UX guard, not an access control. The guarded content is present in the page and any script can call `open()`. Use `TAuthManager` and authorization rules to restrict who may act.
+
+Search terms: confirm before delete, click to unlock, guarded button, accidental click.
+
+## Key Properties/Methods
+
+- `OverlayTemplate` - `ITemplate` rendered on the overlay (e.g. "click to unlock"); instantiated into the control tree during `OnInit`
+- `OverlayColor` - CSS color of the overlay; renders as an inline `background-color` that overrides the stylesheet. Empty (default) keeps the stylesheet's translucent red. A translucent value leaves the guarded content legible behind the overlay.
+- `OverlayCssClass` - CSS class(es) added to the visible face element (alongside `safety-cover-face`), for per-instance styling beyond color — gradients, borders, background image, typography. Default empty. `OverlayColor`'s inline background still wins over the class.
+- `OverlayEffect` - `TSafetyCoverEffect`: `Slide` (default), `Collapse`, `None`. The geometric transition the overlay makes as the control opens and closes.
+- `OverlayFade` - bool (default false). Whether the overlay also fades between opaque and transparent, combined with the `OverlayEffect` geometry. An independent axis.
+- `OverlayDirection` - `TSafetyCoverDirection`: `Up` (default), `Down`, `Left`, `Right`, `Forward`, `Backward`. The edge the overlay moves/collapses toward for Slide and Collapse; ignored by None.
+- `OpenDelay` - ms between the click and the cover moving aside; the panel pulses for this whole span (default 800)
+- `AutoCloseDelay` - ms before the cover auto-closes, from opening or (with `KeepOpenWhileActive`) from the last interaction (default 6000)
+- `KeepOpenWhileActive` - bool (default false). When true, interaction inside the open panel (mousemove, keydown, pointerdown, input) resets the `AutoCloseDelay` timer and cancels a pending mouse-out close, so a complex interaction (typing, clicking a series of controls) keeps the cover open; it closes `AutoCloseDelay` after the last activity. This replaces the old naive `width*height` ms area heuristic with an idle timeout that follows the actual interaction — it only ever extends, never shortens.
+- `MouseOutTimeout` - ms after the pointer leaves before the cover returns; re-entering cancels it (default 1000)
+- `AnimationDuration` - ms the open/close animation of the face takes, via `--safety-cover-animation-duration` (default 250). Precedent: TAccordion's `AnimationDuration` is the same concept but in seconds; this one is ms to match the control's other timings.
+- `ResetDelay` - ms of cooldown *after* the close animation during which clicks stay ignored before the cover can reopen (default 0). The cover already ignores clicks for the whole close animation (`AnimationDuration`); `ResetDelay` extends that window so a click cannot reopen the cover the instant it lands closed. See the "closing cooldown" below.
+- `AccessibleLabel` - accessible-name override for the guard. Default empty → the guard is `aria-labelledby` its visible face, so the name matches the visible text (WCAG 2.5.3). Set it only for an icon-only face (and make it contain any visible text).
+- `CssUrl` - `'default'` publishes `assets/safetycover.css`; empty string registers no stylesheet
+
+## Open effects
+
+Three axes, all resolved server-side into CSS classes on the panel; the JS wrapper only toggles `safety-cover-open` and is unaware of them.
+
+**Geometry** (`OverlayEffect`):
+
+| Value | CSS mechanism | Overlay content |
+|---|---|---|
+| `Slide` | `transform: translate`, clipped by the slider's `overflow:hidden` | moves with the face |
+| `Collapse` | `clip-path: inset` | stays put, wiped edge-to-edge like a shade |
+| `None` | *(none)* | no geometric change |
+
+The geometry (and fade) apply to the **face**, not the guard; the guard never moves.
+
+**Fade** (`OverlayFade`, bool) is an independent axis: `safety-cover-fade` adds an `opacity` transition that layers on any geometry. So `Slide`+fade, `Collapse`+fade, and `None`+fade (a pure fade) all compose. `None` without fade snaps the face hidden with `visibility: hidden` and no animation.
+
+**Direction** (`OverlayDirection`): sets CSS custom properties (`--safety-cover-translate`, `--safety-cover-clip`) on the panel that the geometry rule consumes. `Slide` and `Collapse` reveal the content in the same order (the face leaves toward the named edge); the difference is whether the face's content translates or is clipped. `None` emits no direction class.
+
+`Forward`/`Backward` are logical (inline-axis) directions, resolved to `Right`/`Left` from the panel's `Direction` (`RightToLeft` flips them) in `getResolvedDirection()`. Resolving server-side avoids the `:dir()` CSS pseudo-class, which is below the project's browser baseline.
+
+The framework classes lead the rendered class attribute; `addAttributesToRender()` composes `buildCssClass()` for output only and never mutates the stored `CssClass`, so `getCssClass()` returns exactly what the author set. Author classes are preserved as-is (including any that share the `safety-cover` prefix, e.g. a theme's `safety-cover-dark`); only a literal duplicate of a framework class is dropped.
+
+**Guard vs face (safety):** the CSS transition lives on the face; the guard's `pointer-events` toggle instantly. So the close animates smoothly (face) while the guard blocks from the first frame — no exposure window. An earlier single-layer version transitioned the one overlay on both open and close, which left a ~90 ms window (measured) where the returning overlay had not yet re-covered the content and a click reached it; the two-layer split removes that window while keeping the close animation. `OverlayColor` is sanitized (`sanitizeOverlayColor()`) to a color-safe charset before rendering, so a data-bound value cannot inject extra CSS declarations.
+
+**Click-ignore windows (safety timing):** the wrapper never lets a click take effect at the wrong moment.
+- *Open delay + open animation:* the first click starts the pulse and `open()` sets `pulsing`; a further click is ignored (`open()` guards on `this.opened || this.pulsing || this.closing`) until the cover is fully open. The guard's `pointer-events` also block content clicks throughout, since the slider only clears them at open.
+- *Close animation:* `close()` on an open cover sets `closing=true` and starts a `resetTimer` for `AnimationDuration + ResetDelay`; while `closing`, `open()` is ignored, so a click during the animated return cannot reopen it. The guard re-blocks content clicks from the first close frame.
+- *Post-close cooldown (`ResetDelay`):* the same `resetTimer` extends the ignore window past the animation's end, a deliberate cooldown so a click cannot reopen the cover the instant it lands closed. Default 0 (reopenable as soon as the animation ends).
+
+The cover **resets at the end** of the close animation, not the start: `closing` clears when `resetTimer` fires (`AnimationDuration + ResetDelay` after `close()`), which is when it becomes reopenable. Cancelling a pulse that never opened (`wasOpen` false) starts no cooldown. Coverage: vitest "close cooldown" describe (fake timers — ignores reopen mid-animation, reopenable after, `ResetDelay` extends it, cancelled pulse has no cooldown) plus the `TSafetyCoverCloseCooldownTestCase` playwright spec (real click during the close animation is ignored, reopenable after the cooldown).
+
+**Reduced motion:** `@media (prefers-reduced-motion: reduce)` drops the open transition and the pulse animation; the `OpenDelay` stall (a safety feature, not decoration) still applies.
+
+## Accessibility
+
+The guard is a real button for keyboard/AT: `role="button"`, `aria-expanded`, `aria-controls`→content, and a name from the visible face by default (`aria-labelledby`→face; `AccessibleLabel` overrides to `aria-label`). Enter/Space on it opens the cover (the JS binds `keydown` alongside `click`). The guard is `tabindex="0"` when closed and drops to `-1` when open, so it is not a tabbable no-op once the content is revealed. `OverlayTemplate` content must be non-interactive — it renders inside the `role="button"` guard, where ARIA forbids interactive descendants.
+
+The `setContentGuarded()` toggle is idempotent (it tracks the current state), so the no-`inert` fallback never re-reads an already-lowered tabindex as the value to restore — repeated guarding cannot strand a control at `tabindex="-1"`. `onDone()` (teardown) restores the content, so a wrapper that later re-registers on the same DOM starts unguarded and the fallback saves the real originals. `aria-expanded` flips to `true` on activation (before the OpenDelay pulse), acknowledging the action immediately to AT; `close()` resets it if the open is cancelled.
+
+The `pointer-events` guard only blocks the mouse — keyboard focus ignores it — so the JS wrapper also marks the content `inert` while closed (with an `aria-hidden` + tabindex-sweep fallback for browsers lacking `inert`). This is the fix for the audit's headline finding: without it, a keyboard/AT user could Tab straight to the guarded button and fire it behind the cover (verified before the fix; the `TSafetyCoverAccessibilityTestCase` playwright spec now asserts the button is unfocusable while closed and reachable when open). On a keyboard open, focus moves into the content; on close, focus returns to the guard. `aria-expanded` tracks the state. Progressive enhancement: the `inert` guarding is applied by the wrapper on init, so with JS disabled the content stays reachable (unguarded) rather than permanently locked.
+
+**Pulse duration:** the `--safety-cover-open-delay` custom property carries `OpenDelay` to the keyframe animation (`calc(var / 3)` for three pulses), so the pulse spans the delay at any `OpenDelay`. The pre-effect port hardcoded the pulse at 0.75 s, which desynchronized from a non-default `OpenDelay`.
+
+## CSS contract
+
+The cover **tracks** the content because the slider is `inset:0` inside the `position:relative` panel, and the panel sizes to its one in-flow child, the content div. It **guards** because the slider stacks at `z-index:1` above the content and the guard's `pointer-events` intercept the mouse. Both properties depend on a small set of invariants; overriding any of them (through `CssClass`, a theme, or a replacement `CssUrl`) breaks the control.
+
+| Element | Must keep | Breaks if | Symptom |
+|---|---|---|---|
+| `.safety-cover` (root) | a positioned containing block: `position: relative` (default), or `absolute`/`fixed` | set to `position: static` | the slider positions against a distant ancestor — the cover lands elsewhere, or over the whole page |
+| `.safety-cover-content` | normal flow; it is the panel's size source. `isolation: isolate` (bundled) | content made `position:absolute/fixed`, `float`, or `display:none` | the panel collapses to zero → slider/overlay/face shrink to nothing → nothing is covered |
+| `.safety-cover-content` | `isolation: isolate` | removed while a positioned descendant has a high `z-index` | that descendant paints above the cover and is clickable through it (verified: without isolation a `z-index:9999` child is topmost over itself) |
+| `.safety-cover-slider` | `overflow: hidden` | set to `overflow: visible` | the Slide face shows outside the panel while sliding (Collapse/Fade unaffected) |
+| `.safety-cover-slider` | `z-index: 1` above content | content given a higher competing z-index at the same level | content pokes above the cover (mitigated by the content's `isolation`) |
+| `.safety-cover-overlay` / `-face` | `inset: 0` (fill the slider) | insets changed | the cover no longer aligns with the content box |
+| `.safety-cover-overlay` (guard) | `pointer-events` toggle: `auto` closed, `none` open | forced to a fixed value | the mouse guard stops blocking, or the content stays unreachable when open |
+
+**Also required:** the content must have real size — its own content height or an explicit `Height` — or the panel (and cover) is zero-height and invisible. Empty covers in tests set `Width`/`Height` for this reason.
+
+**Safe to customize freely:** `OverlayColor`, `OverlayCssClass`, and `OverlayTemplate` (the face's looks); the panel's padding and border (the cover extends over padding, which is fine); and any non-positioning `CssClass` on the control.
+
+Keyboard/AT guarding does **not** depend on this CSS contract — it uses `inert`, which the wrapper toggles independently of stacking and pointer-events. So even a z-index escape that defeats the mouse guard leaves the keyboard/AT guard intact.
+
+## Vocabulary
+
+The control is the safety cover; the element that hides the content is the **overlay**. The distinction is deliberate: naming the part `Cover` would match the class name, which makes `CoverColor` ambiguous about whether it colors the whole control or the moving part. `Overlay` can only mean the part, and it keeps the CSS class free of the doubled `.safety-cover-cover`.
+
+The inner elements are the slider (clips the animation), the guard `overlay` (transparent click-blocker), the `face` inside it (visible skin that animates), and the guarded content. `OverlayColor`/`OverlayTemplate` configure the face; the guard is purely functional.
+
+## Rendered Structure
+
+```html
+
+
+
+
OverlayTemplate
+
+
+
body content
+
+```
+
+The effect/direction classes and the `--safety-cover-*` variables are added by `addAttributesToRender()`. The effect class is on the panel but `safety-cover-open` is toggled on the slider, so the stylesheet's open rules are descendant (`.safety-cover-collapse .safety-cover-open .safety-cover-face`), not compound. `OverlayColor` and the `OverlayTemplate` render on the face; the guard stays transparent.
+
+## Client Side
+
+- JS class `Prado.WebUI.TSafetyCover` in `Web/Javascripts/source/prado/controls/safetycover.js`; package `safetycover` in `Web/Javascripts/packages.php`
+- Wrapper registers in `Prado.Registry[ClientID]` with `open()`, `close()`, `isOpen()`
+- Open state = `safety-cover-open` class on the slider; pulse = `safety-cover-pulsate` class on the root; `assets/safetycover.css` supplies the transitions and keyframes
+- Vitest tests: `tests/js/controls/safetycover.test.js` (adapter `tests/js/adapters/safetycover.js`)
+
+## History
+
+Port of the pre-4.x `RConfirmPanel` (Rexode application, Prototype/Scriptaculous era). The port replaces `Effect.Pulsate`/`Effect.SlideUp` with CSS animations, moves the template instantiation into the control lifecycle, and replaces the hard-coded timings and inline CSS with properties and a published stylesheet. The class was named `TConfirmPanel` during the port; `TSafetyCover` replaced it to avoid implying a `confirm()` dialog.
+
+## See Also
+
+- [TPanel](./TPanel.md)
diff --git a/framework/Web/Javascripts/packages.php b/framework/Web/Javascripts/packages.php
index 5c981f614..c1e075c5c 100644
--- a/framework/Web/Javascripts/packages.php
+++ b/framework/Web/Javascripts/packages.php
@@ -56,6 +56,10 @@
'prado/controls/webtemplate.js',
],
+ 'safetycover' => [
+ 'prado/controls/safetycover.js',
+ ],
+
'activedatepicker' => [
'prado/activecontrols/activedatepicker.js',
],
@@ -127,6 +131,7 @@
'keyboard' => ['jquery', 'prado', 'keyboard'],
'slider' => ['jquery', 'prado', 'slider'],
'webtemplate' => ['jquery', 'prado', 'webtemplate'],
+ 'safetycover' => ['jquery', 'prado', 'safetycover'],
'inlineeditor' => ['jquery', 'prado', 'ajax', 'inlineeditor'],
'accordion' => ['jquery', 'prado', 'accordion'],
'ratings' => ['jquery', 'prado', 'ajax', 'ratings'],
diff --git a/framework/Web/Javascripts/source/prado/controls/safetycover.js b/framework/Web/Javascripts/source/prado/controls/safetycover.js
new file mode 100644
index 000000000..3fc7a1fd5
--- /dev/null
+++ b/framework/Web/Javascripts/source/prado/controls/safetycover.js
@@ -0,0 +1,357 @@
+/*! PRADO TSafetyCover javascript file | github.com/pradosoft/prado */
+
+/**
+ * TSafetyCover control.
+ *
+ * Keeps the panel body content behind an overlay. A click on the overlay pulses
+ * the panel, then moves the overlay aside (per the server-set effect) to open the
+ * content. The overlay returns after `AutoCloseDelay` milliseconds, or after the
+ * pointer leaves the panel for `MouseOutTimeout` milliseconds. Re-entering the
+ * panel cancels the pending close.
+ *
+ * DOM structure rendered by the server-side control:
+ *
+ *
+ *
+ *
+ *
overlay template
+ *
+ *
+ *
body content
+ *
+ *
+ * The open state is the `safety-cover-open` CSS class on the slider element; this
+ * wrapper toggles it and binds open to the `_overlay` guard on both click and
+ * Enter/Space. The stylesheet does the visuals: the guard's pointer-events block
+ * the mouse while closed and clear the instant the cover opens, and the `_face`
+ * skin animates from the effect and direction classes the server renders on the
+ * panel (`safety-cover-slide`, `-collapse`, or `-none`, with `-up`/`-down`/
+ * `-left`/`-right`, and an optional `-fade`), over
+ * `--safety-cover-animation-duration`.
+ *
+ * Because pointer-events only stop the mouse, the wrapper also marks the `_content`
+ * element `inert` while closed (with an `aria-hidden`/tabindex fallback), so
+ * keyboard and assistive-technology users cannot reach the guarded controls
+ * behind the cover; it toggles `aria-expanded` on the guard and moves focus into
+ * the content on a keyboard open, back to the guard on close.
+ *
+ * The pulse is the `safety-cover-pulsate` class on the panel, a keyframe
+ * animation whose duration the server sets from `OpenDelay` via the
+ * `--safety-cover-open-delay` custom property.
+ *
+ * ```javascript
+ * const guard = Prado.Registry['ctl0_Content_Guarded'];
+ * guard.open(); // pulse, then reveal the content
+ * guard.close(); // re-guard the content (guard blocks at once, face animates back)
+ * guard.isOpen() // whether the content is reachable
+ * ```
+ */
+Prado.WebUI.TSafetyCover = Prado.Class(Prado.WebUI.Control,
+{
+ onInit(options) {
+ this.options = options || {};
+ this.panel = this.element;
+ this.slider = document.getElementById(this.ID + '_slider');
+ this.overlay = document.getElementById(this.ID + '_overlay');
+ this.content = document.getElementById(this.ID + '_content');
+ this.opened = false;
+ this.pulsing = false;
+ this.closing = false;
+ this.openTimer = null;
+ this.closeTimer = null;
+ this.mouseOutTimer = null;
+ this.resetTimer = null;
+ this.savedTabindex = null;
+ this.focusOnOpen = false;
+ this.contentGuarded = false;
+ this.ready = false;
+ if (!this.panel || !this.slider || !this.overlay) {
+ return;
+ }
+ this.ready = true;
+ this.observe(this.overlay, 'click', this.overlayClicked.bind(this));
+ this.observe(this.overlay, 'keydown', this.overlayKeydown.bind(this));
+ this.observe(this.panel, 'mouseleave', this.mouseLeft.bind(this));
+ this.observe(this.panel, 'mouseenter', this.mouseEntered.bind(this));
+ // When KeepOpenWhileActive, interaction inside the open panel resets the
+ // auto-close timer, so a complex interaction keeps the cover open.
+ if (this.getKeepOpenWhileActive()) {
+ const onActivity = this.onActivity.bind(this);
+ for (const type of ['mousemove', 'keydown', 'pointerdown', 'input']) {
+ this.observe(this.panel, type, onActivity);
+ }
+ }
+ // Closed at load: keep the guarded content out of the tab order and the
+ // accessibility tree until the cover opens, so keyboard and AT users cannot
+ // reach it behind the cover (the pointer-events guard only blocks the mouse).
+ this.setContentGuarded(true);
+ },
+
+ /**
+ * @return int milliseconds between the click and the overlay moving aside
+ */
+ getOpenDelay() {
+ return this.options.OpenDelay ?? 800;
+ },
+
+ /**
+ * @return int milliseconds before the cover auto-closes, from opening or, with
+ * KeepOpenWhileActive, from the last interaction
+ */
+ getAutoCloseDelay() {
+ return this.options.AutoCloseDelay ?? 6000;
+ },
+
+ /**
+ * @return int milliseconds after the pointer leaves before the overlay returns
+ */
+ getMouseOutTimeout() {
+ return this.options.MouseOutTimeout ?? 1000;
+ },
+
+ /**
+ * @return bool whether interaction with the open content resets the auto-close
+ */
+ getKeepOpenWhileActive() {
+ return this.options.KeepOpenWhileActive === true;
+ },
+
+ /**
+ * @return int milliseconds the open and close animation takes
+ */
+ getAnimationDuration() {
+ return this.options.AnimationDuration ?? 250;
+ },
+
+ /**
+ * @return int milliseconds of cooldown after the close animation before reopen
+ */
+ getResetDelay() {
+ return this.options.ResetDelay ?? 0;
+ },
+
+ /**
+ * @return bool whether the content is open and reachable
+ */
+ isOpen() {
+ return this.opened;
+ },
+
+ /**
+ * Handles the click on the guard. A mouse open does not move focus.
+ */
+ overlayClicked(event) {
+ event.preventDefault();
+ this.open(false);
+ },
+
+ /**
+ * Handles Enter/Space on the guard, the keyboard equivalent of a click. A
+ * keyboard open moves focus into the revealed content.
+ */
+ overlayKeydown(event) {
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
+ event.preventDefault();
+ this.open(true);
+ }
+ },
+
+ /**
+ * Pulses the panel, then moves the overlay aside after `OpenDelay`
+ * milliseconds, revealing the content and making it reachable again. The
+ * overlay returns on its own after `AutoCloseDelay` milliseconds.
+ * @param bool focusContentOnOpen whether to move focus into the content once open
+ */
+ open(focusContentOnOpen) {
+ if (!this.ready || this.opened || this.pulsing || this.closing) {
+ return;
+ }
+ this.focusOnOpen = focusContentOnOpen === true;
+ this.pulsing = true;
+ this.panel.classList.add('safety-cover-pulsate');
+ // Acknowledge the activation to assistive tech right away, rather than only
+ // after the OpenDelay pulse; close() resets it if the open is cancelled.
+ this.overlay.setAttribute('aria-expanded', 'true');
+ this.openTimer = setTimeout(() => {
+ this.openTimer = null;
+ this.pulsing = false;
+ this.panel.classList.remove('safety-cover-pulsate');
+ this.slider.classList.add('safety-cover-open');
+ this.opened = true;
+ // Open: the guard is done revealing, so drop it from the tab order
+ // rather than leaving a focusable button whose activation is a no-op.
+ this.overlay.setAttribute('tabindex', '-1');
+ this.setContentGuarded(false);
+ if (this.focusOnOpen) {
+ this.focusContent();
+ }
+ this.closeTimer = setTimeout(this.close.bind(this), this.getAutoCloseDelay());
+ }, this.getOpenDelay());
+ },
+
+ /**
+ * Returns the overlay over the content, re-guards the content from keyboard
+ * and assistive technology, and cancels the pending timers. Focus that was
+ * inside the content returns to the guard. While an open cover closes, the
+ * cover enters a "closing" cooldown spanning the close animation plus
+ * `ResetDelay`, during which it ignores clicks and cannot reopen; a call while
+ * already closing leaves that cooldown running.
+ */
+ close() {
+ if (!this.ready || this.closing) {
+ return;
+ }
+ const wasOpen = this.opened;
+ this.clearTimers();
+ this.pulsing = false;
+ this.panel.classList.remove('safety-cover-pulsate');
+ this.slider.classList.remove('safety-cover-open');
+ this.opened = false;
+ this.overlay.setAttribute('aria-expanded', 'false');
+ // Closed: the guard is the reveal affordance again, so it returns to the
+ // tab order.
+ this.overlay.setAttribute('tabindex', '0');
+ const focusInContent = !!this.content && this.content.contains(document.activeElement);
+ this.setContentGuarded(true);
+ if (focusInContent) {
+ this.overlay.focus();
+ }
+ // A cover that was actually open animates closed; ignore clicks until the
+ // animation (and any ResetDelay) completes, so a click cannot reopen it
+ // mid-animation. Cancelling a pulse that never opened has no animation and
+ // no cooldown.
+ if (wasOpen) {
+ this.closing = true;
+ this.resetTimer = setTimeout(() => {
+ this.resetTimer = null;
+ this.closing = false;
+ }, this.getAnimationDuration() + this.getResetDelay());
+ }
+ },
+
+ /**
+ * Guards or reveals the content for keyboard and assistive technology. When
+ * guarded, the content leaves the tab order and the accessibility tree. Uses
+ * the `inert` attribute where supported, otherwise `aria-hidden` plus a
+ * tabindex sweep of the focusable descendants. Idempotent: repeated calls in
+ * the same state are a no-op, so the fallback never re-reads an already
+ * lowered tabindex as the value to restore.
+ * @param bool guarded whether the content is closed off
+ */
+ setContentGuarded(guarded) {
+ if (!this.content || guarded === this.contentGuarded) {
+ return;
+ }
+ this.contentGuarded = guarded;
+ if ('inert' in HTMLElement.prototype) {
+ this.content.inert = guarded;
+ return;
+ }
+ if (guarded) {
+ this.content.setAttribute('aria-hidden', 'true');
+ this.savedTabindex = [];
+ const focusables = this.content.querySelectorAll('a[href], button, input, select, textarea, [tabindex]');
+ for (const el of focusables) {
+ this.savedTabindex.push([el, el.getAttribute('tabindex')]);
+ el.setAttribute('tabindex', '-1');
+ }
+ } else {
+ this.content.removeAttribute('aria-hidden');
+ for (const [el, prev] of this.savedTabindex || []) {
+ if (prev === null) {
+ el.removeAttribute('tabindex');
+ } else {
+ el.setAttribute('tabindex', prev);
+ }
+ }
+ this.savedTabindex = null;
+ }
+ },
+
+ /**
+ * Moves focus to the first focusable element in the content, or to the content
+ * itself when it holds none.
+ */
+ focusContent() {
+ if (!this.content) {
+ return;
+ }
+ const target = this.content.querySelector(
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
+ );
+ if (target) {
+ target.focus();
+ } else {
+ // Focus the content itself when it has no focusable child. The
+ // tabindex is only needed for the focus() call; remove it afterward so
+ // no stray attribute is left on the author's element (focus is retained
+ // once set).
+ this.content.setAttribute('tabindex', '-1');
+ this.content.focus();
+ this.content.removeAttribute('tabindex');
+ }
+ },
+
+ /**
+ * Schedules the overlay to return when the pointer leaves an open panel.
+ */
+ mouseLeft() {
+ if (!this.opened || this.mouseOutTimer) {
+ return;
+ }
+ this.mouseOutTimer = setTimeout(() => {
+ this.mouseOutTimer = null;
+ this.close();
+ }, this.getMouseOutTimeout());
+ },
+
+ /**
+ * Cancels the pending close when the pointer re-enters the panel.
+ */
+ mouseEntered() {
+ if (this.mouseOutTimer) {
+ clearTimeout(this.mouseOutTimer);
+ this.mouseOutTimer = null;
+ }
+ },
+
+ /**
+ * Resets the auto-close timer on interaction while open, and cancels a pending
+ * mouse-out close, so an ongoing interaction keeps the cover open. Only in
+ * effect when `KeepOpenWhileActive` is set and the cover is open.
+ */
+ onActivity() {
+ if (!this.opened) {
+ return;
+ }
+ if (this.closeTimer) {
+ clearTimeout(this.closeTimer);
+ }
+ this.closeTimer = setTimeout(this.close.bind(this), this.getAutoCloseDelay());
+ if (this.mouseOutTimer) {
+ clearTimeout(this.mouseOutTimer);
+ this.mouseOutTimer = null;
+ }
+ },
+
+ /**
+ * Clears every pending timer.
+ */
+ clearTimers() {
+ for (const name of ['openTimer', 'closeTimer', 'mouseOutTimer', 'resetTimer']) {
+ if (this[name]) {
+ clearTimeout(this[name]);
+ this[name] = null;
+ }
+ }
+ },
+
+ onDone() {
+ this.clearTimers();
+ this.closing = false;
+ // Restore the content on teardown so a wrapper that later re-registers on
+ // the same DOM starts from an unguarded state; the fallback's tabindex save
+ // then reads the real originals, not an already-lowered value.
+ this.setContentGuarded(false);
+ }
+});
diff --git a/framework/Web/UI/WebControls/TSafetyCover.php b/framework/Web/UI/WebControls/TSafetyCover.php
new file mode 100644
index 000000000..c5f5c7da3
--- /dev/null
+++ b/framework/Web/UI/WebControls/TSafetyCover.php
@@ -0,0 +1,733 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado\Web\UI\WebControls;
+
+use Prado\TPropertyValue;
+use Prado\Web\UI\IFilterRenderable;
+use Prado\Web\UI\IRenderable;
+use Prado\Web\UI\ITemplate;
+use Prado\Web\UI\TControl;
+
+/**
+ * TSafetyCover class
+ *
+ * TSafetyCover is a panel whose body content sits behind an overlay. A click on
+ * the overlay pulses the panel and moves the overlay aside, opening the content
+ * to interaction. The overlay returns after a timeout or after the pointer
+ * leaves the panel. It guards content whose controls act immediately, such as a
+ * delete button, by requiring one deliberate click before the content is
+ * reachable.
+ *
+ * The control models the hinged cover over a physical switch. It prevents
+ * accidental activation and closes itself afterward. It is not an access
+ * control: the guarded content is present in the page and any script can open
+ * it. Use {@see \Prado\Security\TAuthManager} and the authorization rules to
+ * restrict who may act.
+ *
+ * The content of the {@see setOverlayTemplate OverlayTemplate} renders on the
+ * overlay, such as a "click to unlock" message. The template instantiates into
+ * the control tree during OnInit, so template controls participate in the page
+ * lifecycle.
+ *
+ * Properties:
+ * - OverlayTemplate, {@see \Prado\Web\UI\ITemplate} — content shown on
+ * the overlay. Defaults to null.
+ * - OverlayColor, string — CSS color of the overlay, such as `#c00` or
+ * `rgba(255,0,0,0.65)`. Renders as an inline `background-color` on the
+ * overlay, overriding the stylesheet. Defaults to empty, which keeps the
+ * stylesheet color.
+ * - OverlayCssClass, string — CSS class(es) added to the visible face,
+ * for styling it per instance beyond color. Defaults to empty.
+ * - OverlayEffect, {@see TSafetyCoverEffect} — the geometric transition
+ * the overlay makes as the control opens and closes: `Slide` (default),
+ * `Collapse`, or `None`.
+ * - OverlayFade, bool — whether the overlay also fades between opaque and
+ * transparent, combined with the `OverlayEffect` geometry. Defaults to false.
+ * - OverlayDirection, {@see TSafetyCoverDirection} — the edge the overlay
+ * moves or collapses toward for the `Slide` and `Collapse` effects: `Up`
+ * (default), `Down`, `Left`, `Right`, or the content-direction-aware
+ * `Forward` and `Backward`. Ignored by `None`.
+ * - OpenDelay, int — milliseconds between the click and the overlay
+ * moving aside; the panel pulses for this whole span. Defaults to 800.
+ * - AutoCloseDelay, int — milliseconds before the cover returns on its
+ * own, measured from opening, or from the last interaction when
+ * {@see setKeepOpenWhileActive KeepOpenWhileActive} is set. Defaults to 6000.
+ * - KeepOpenWhileActive, bool — whether interaction with the open content
+ * resets the `AutoCloseDelay` timer, keeping the cover open through a complex
+ * interaction. Defaults to false.
+ * - MouseOutTimeout, int — milliseconds after the pointer leaves the
+ * panel before the overlay returns. Re-entering the panel cancels the pending
+ * close. Defaults to 1000.
+ * - AnimationDuration, int — milliseconds the open and close animation of
+ * the face takes. Defaults to 250.
+ * - ResetDelay, int — extra milliseconds after the close animation ends
+ * during which clicks stay ignored, a cooldown before the cover reopens.
+ * Defaults to 0.
+ * - AccessibleLabel, string — accessible-name override for the guard,
+ * rendered as its `aria-label`. Defaults to empty, which labels the guard from
+ * its visible face content instead.
+ * - CssUrl, string — URL of the stylesheet for the control. The value
+ * 'default' (the default) publishes the bundled stylesheet; an empty string
+ * registers no stylesheet.
+ *
+ * ## Accessibility
+ *
+ * The guard renders as a `role="button"` with `tabindex="0"`, an accessible name
+ * (from its visible face by default, or {@see getAccessibleLabel AccessibleLabel}
+ * when set), and `aria-expanded`/`aria-controls` describing and pointing at the
+ * content. It drops to `tabindex="-1"` while open. A keyboard or
+ * assistive-technology user focuses the guard and presses Enter or Space to
+ * reveal the content, the same gesture the mouse performs by clicking.
+ *
+ * The `pointer-events` guard blocks only the mouse, so the client-side wrapper
+ * additionally marks the content `inert` while the cover is closed (with an
+ * `aria-hidden` and tabindex fallback for browsers without `inert`). This keeps
+ * the guarded controls out of the tab order and the accessibility tree until the
+ * cover opens, so keyboard and AT users cannot reach them behind the cover. On a
+ * keyboard open, focus moves into the revealed content; on close, focus returns
+ * to the guard. The `@media (prefers-reduced-motion: reduce)` rule drops the
+ * animation and the pulse.
+ *
+ * The rendered structure:
+ * ```html
+ *
+ *
+ *
+ *
overlay template
+ *
+ *
+ *
body content
+ *
+ * ```
+ *
+ * The face animates both opening and closing over {@see getAnimationDuration
+ * AnimationDuration}. The guard blocks clicks the instant the cover starts to
+ * close, so the animated return never exposes the content.
+ *
+ * ## CSS contract
+ *
+ * The cover tracks the content because the slider is `inset:0` inside the
+ * `position:relative` panel, which sizes to its in-flow content, and it guards
+ * because the slider stacks at `z-index:1` above the content with the guard's
+ * `pointer-events` catching the mouse. Overriding these invariants (through
+ * `CssClass`, a theme, or a replacement {@see getCssUrl CssUrl}) breaks the
+ * control:
+ * - the root must stay positioned (`position: relative|absolute|fixed`, not
+ * `static`, or the slider positions against a distant ancestor);
+ * - the content must stay in normal flow (not `position:absolute/fixed`,
+ * `float`, or `display:none`, or the panel collapses and covers nothing) and
+ * keeps `isolation:isolate` so a positioned high-`z-index` descendant cannot
+ * paint above the cover;
+ * - the slider keeps `overflow:hidden` (clips the Slide face) and `z-index:1`;
+ * - the overlay and face keep `inset:0`, and the guard keeps its `pointer-events`
+ * toggle (auto closed, none open).
+ *
+ * The content also needs real size (its own content height or an explicit
+ * `Height`). `OverlayColor`, `OverlayCssClass`, `OverlayTemplate`, and panel
+ * padding/border are safe to change. The stylesheet header carries the same
+ * contract as a table.
+ *
+ * The client-side wrapper registers in `Prado.Registry` under the ClientID and
+ * offers `open()` and `close()` methods for script control.
+ *
+ * Template usage:
+ * ```html
+ *
+ *
+ * Click to unlock
+ *
+ *
+ *
+ * ```
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+class TSafetyCover extends TPanel
+{
+ /**
+ * @var string[] the CSS classes the control manages on its root element; used
+ * to keep them out of the author {@see getCssClass CssClass} when combining
+ */
+ private const FRAMEWORK_CLASSES = [
+ 'safety-cover',
+ 'safety-cover-slide', 'safety-cover-collapse', 'safety-cover-none',
+ 'safety-cover-up', 'safety-cover-down', 'safety-cover-left', 'safety-cover-right',
+ 'safety-cover-fade',
+ ];
+
+ /** @var ?ITemplate template for the overlay content */
+ private ?ITemplate $_overlayTemplate = null;
+
+ /** @var ?TControl container holding the instantiated overlay template */
+ private ?TControl $_overlay = null;
+
+ /**
+ * @return ?ITemplate template for the overlay content. Defaults to null.
+ */
+ public function getOverlayTemplate()
+ {
+ return $this->_overlayTemplate;
+ }
+
+ /**
+ * Set the template for the overlay content. A template set after OnInit
+ * replaces the instantiated overlay content. The content renders on the face,
+ * inside the `role="button"` guard, so it must be non-interactive — a label or
+ * decoration, not links, buttons, or active controls, which ARIA disallows as
+ * descendants of a button. It also provides the guard's accessible name when
+ * {@see setAccessibleLabel AccessibleLabel} is empty.
+ * @param ?ITemplate $value template for the overlay content
+ */
+ public function setOverlayTemplate($value)
+ {
+ $this->_overlayTemplate = $value;
+ if ($this->_overlay !== null) {
+ $overlay = $this->_overlay;
+ $this->_overlay = null;
+ $this->getControls()->remove($overlay);
+ }
+ if ($value !== null && $this->getHasInitialized()) {
+ $this->ensureOverlayControls();
+ }
+ }
+
+ /**
+ * @return string CSS color of the overlay. Defaults to empty, which keeps the
+ * stylesheet color.
+ */
+ public function getOverlayColor()
+ {
+ return $this->getViewState('OverlayColor', '');
+ }
+
+ /**
+ * Set the CSS color of the overlay, such as `#c00` or `rgba(255,0,0,0.65)`.
+ * The value renders as an inline `background-color` on the visible face element,
+ * overriding the stylesheet and any background a
+ * {@see setOverlayCssClass OverlayCssClass} declares. A translucent color
+ * leaves the guarded content legible behind the overlay. Leave it empty when
+ * an `OverlayCssClass` supplies the background.
+ * @param string $value CSS color of the overlay, or empty to keep the
+ * stylesheet color
+ */
+ public function setOverlayColor($value)
+ {
+ $this->setViewState('OverlayColor', TPropertyValue::ensureString($value), '');
+ }
+
+ /**
+ * @return string CSS class(es) added to the visible face element, for styling
+ * one cover's face beyond {@see getOverlayColor OverlayColor}. Defaults to empty.
+ */
+ public function getOverlayCssClass()
+ {
+ return $this->getViewState('OverlayCssClass', '');
+ }
+
+ /**
+ * Set CSS class(es) added to the visible face element, alongside the built-in
+ * `safety-cover-face` class. Use it to style the face per instance — gradients,
+ * borders, typography, a background image — where {@see setOverlayColor
+ * OverlayColor} only sets a background color. An inline `OverlayColor` still
+ * wins over a background the class declares.
+ * @param string $value CSS class(es) for the face
+ */
+ public function setOverlayCssClass($value)
+ {
+ $this->setViewState('OverlayCssClass', TPropertyValue::ensureString($value), '');
+ }
+
+ /**
+ * @return string the geometric transition the overlay makes, one of the
+ * {@see TSafetyCoverEffect} values. Defaults to TSafetyCoverEffect::Slide.
+ */
+ public function getOverlayEffect()
+ {
+ return $this->getViewState('OverlayEffect', TSafetyCoverEffect::Slide);
+ }
+
+ /**
+ * Set the geometric transition the overlay makes as the control opens and
+ * closes. `Slide` translates the overlay off the panel, `Collapse` clips it
+ * away in place, and `None` makes no geometric change. Combine with
+ * {@see setOverlayFade OverlayFade} for an opacity transition.
+ * @param string $value a {@see TSafetyCoverEffect} value
+ */
+ public function setOverlayEffect($value)
+ {
+ $this->setViewState('OverlayEffect', TPropertyValue::ensureEnum($value, TSafetyCoverEffect::class), TSafetyCoverEffect::Slide);
+ }
+
+ /**
+ * @return bool whether the overlay fades between opaque and transparent as the
+ * control opens and closes, combined with the {@see getOverlayEffect
+ * OverlayEffect} geometry. Defaults to false.
+ */
+ public function getOverlayFade()
+ {
+ return $this->getViewState('OverlayFade', false);
+ }
+
+ /**
+ * Set whether the overlay fades between opaque and transparent as the control
+ * opens and closes. The fade layers on any {@see setOverlayEffect
+ * OverlayEffect} geometry; with `OverlayEffect` set to `None` it is the whole
+ * transition. With `None` and no fade, the overlay snaps between states
+ * without animation.
+ * @param bool $value whether the overlay fades
+ */
+ public function setOverlayFade($value)
+ {
+ $this->setViewState('OverlayFade', TPropertyValue::ensureBoolean($value), false);
+ }
+
+ /**
+ * @return string the edge the overlay moves or collapses toward, one of the
+ * {@see TSafetyCoverDirection} values. Defaults to TSafetyCoverDirection::Up.
+ */
+ public function getOverlayDirection()
+ {
+ return $this->getViewState('OverlayDirection', TSafetyCoverDirection::Up);
+ }
+
+ /**
+ * Set the edge the overlay moves or collapses toward for the `Slide` and
+ * `Collapse` effects. `Forward` and `Backward` resolve to `Right`/`Left` from
+ * the control's {@see TPanel::getDirection Direction} during rendering. The
+ * `None` effect ignores this property.
+ * @param string $value a {@see TSafetyCoverDirection} value
+ */
+ public function setOverlayDirection($value)
+ {
+ $this->setViewState('OverlayDirection', TPropertyValue::ensureEnum($value, TSafetyCoverDirection::class), TSafetyCoverDirection::Up);
+ }
+
+ /**
+ * Resolve {@see getOverlayDirection OverlayDirection} to a physical edge. The
+ * logical `Forward` and `Backward` values map to `right`/`left` through the
+ * control's {@see TPanel::getDirection Direction}: `Forward` is `right` in
+ * left-to-right content and `left` in right-to-left content.
+ * @return string one of `up`, `down`, `left`, `right`
+ */
+ protected function getResolvedDirection(): string
+ {
+ $direction = $this->getOverlayDirection();
+ if ($direction !== TSafetyCoverDirection::Forward && $direction !== TSafetyCoverDirection::Backward) {
+ return strtolower($direction);
+ }
+ $rightToLeft = $this->getDirection() === TContentDirection::RightToLeft;
+ $forwardIsRight = !$rightToLeft;
+ if ($direction === TSafetyCoverDirection::Backward) {
+ $forwardIsRight = !$forwardIsRight;
+ }
+ return $forwardIsRight ? 'right' : 'left';
+ }
+
+ /**
+ * @return int milliseconds between the click and the overlay moving aside.
+ * Defaults to 800.
+ */
+ public function getOpenDelay()
+ {
+ return $this->getViewState('OpenDelay', 800);
+ }
+
+ /**
+ * Set the milliseconds between the click and the overlay moving aside. The
+ * panel pulses during the delay.
+ * @param int $value milliseconds before the overlay moves aside
+ */
+ public function setOpenDelay($value)
+ {
+ $this->setViewState('OpenDelay', TPropertyValue::ensureInteger($value), 800);
+ }
+
+ /**
+ * @return int milliseconds the content stays open before the overlay returns
+ * on its own. Defaults to 6000.
+ */
+ public function getAutoCloseDelay()
+ {
+ return $this->getViewState('AutoCloseDelay', 6000);
+ }
+
+ /**
+ * Set the milliseconds the content stays open before the overlay returns on
+ * its own. {@see setKeepOpenWhileActive KeepOpenWhileActive} makes this an idle
+ * timeout that interaction resets. The pointer leaving the panel returns it
+ * sooner; see {@see setMouseOutTimeout MouseOutTimeout}.
+ * @param int $value milliseconds the content stays open
+ */
+ public function setAutoCloseDelay($value)
+ {
+ $this->setViewState('AutoCloseDelay', TPropertyValue::ensureInteger($value), 6000);
+ }
+
+ /**
+ * @return bool whether interaction with the open content resets the
+ * {@see getAutoCloseDelay AutoCloseDelay} auto-close timer. Defaults to false.
+ */
+ public function getKeepOpenWhileActive()
+ {
+ return $this->getViewState('KeepOpenWhileActive', false);
+ }
+
+ /**
+ * Set whether interaction keeps the cover open. When true, a mouse move,
+ * key press, pointer press, or input within the open panel resets the
+ * {@see getAutoCloseDelay AutoCloseDelay} timer and cancels a pending mouse-out
+ * close, so the cover stays open through a complex interaction and closes
+ * `AutoCloseDelay` after the last activity. It only extends the open time, never
+ * shortens it. This replaces the naive area-scaled timeout with one that
+ * follows the actual interaction.
+ * @param bool $value whether interaction resets the auto-close timer
+ */
+ public function setKeepOpenWhileActive($value)
+ {
+ $this->setViewState('KeepOpenWhileActive', TPropertyValue::ensureBoolean($value), false);
+ }
+
+ /**
+ * @return int milliseconds the open and close animation takes. Defaults to 250.
+ */
+ public function getAnimationDuration()
+ {
+ return $this->getViewState('AnimationDuration', 250);
+ }
+
+ /**
+ * Set the milliseconds the open and close animation takes. The value drives
+ * the face's CSS transition through the `--safety-cover-animation-duration`
+ * custom property. The guard blocks clicks independently of this animation, so
+ * a longer close still re-guards instantly.
+ * @param int $value milliseconds the animation takes
+ */
+ public function setAnimationDuration($value)
+ {
+ $this->setViewState('AnimationDuration', TPropertyValue::ensureInteger($value), 250);
+ }
+
+ /**
+ * @return int milliseconds after the cover finishes closing during which it
+ * ignores clicks before it can reopen. Defaults to 0.
+ */
+ public function getResetDelay()
+ {
+ return $this->getViewState('ResetDelay', 0);
+ }
+
+ /**
+ * Set the milliseconds of cooldown after the close animation during which the
+ * cover ignores clicks and cannot reopen. The cover already ignores clicks for
+ * the whole close animation ({@see getAnimationDuration AnimationDuration}); this
+ * extends that window, so a click cannot reopen the cover the instant it lands
+ * closed. Defaults to 0.
+ * @param int $value milliseconds of post-close cooldown
+ */
+ public function setResetDelay($value)
+ {
+ $this->setViewState('ResetDelay', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * @return int milliseconds after the pointer leaves the panel before the
+ * overlay returns. Defaults to 1000.
+ */
+ public function getMouseOutTimeout()
+ {
+ return $this->getViewState('MouseOutTimeout', 1000);
+ }
+
+ /**
+ * Set the milliseconds after the pointer leaves the panel before the overlay
+ * returns. Re-entering the panel cancels the pending close.
+ * @param int $value milliseconds after the pointer leaves the panel
+ */
+ public function setMouseOutTimeout($value)
+ {
+ $this->setViewState('MouseOutTimeout', TPropertyValue::ensureInteger($value), 1000);
+ }
+
+ /**
+ * @return string URL of the stylesheet for the control. Defaults to
+ * 'default', which publishes the bundled stylesheet. An empty string
+ * registers no stylesheet.
+ */
+ public function getCssUrl()
+ {
+ return $this->getViewState('CssUrl', 'default');
+ }
+
+ /**
+ * Set the URL of the stylesheet for the control. The bundled stylesheet
+ * positions the overlay over the content and animates the opening; a
+ * replacement stylesheet provides those rules itself.
+ * @param string $value stylesheet URL, 'default' for the bundled stylesheet,
+ * or empty to register no stylesheet
+ */
+ public function setCssUrl($value)
+ {
+ $this->setViewState('CssUrl', TPropertyValue::ensureString($value), 'default');
+ }
+
+ /**
+ * @return string the accessible name override for the guard, rendered as its
+ * `aria-label`. Defaults to empty, in which case the guard is instead
+ * labelled by its visible face content ({@see setOverlayTemplate
+ * OverlayTemplate}).
+ */
+ public function getAccessibleLabel()
+ {
+ return $this->getViewState('AccessibleLabel', '');
+ }
+
+ /**
+ * Set the accessible name override for the guard. The guard renders as a
+ * `role="button"` that a keyboard or assistive-technology user activates to
+ * reveal the content. When empty (the default), the guard is labelled by its
+ * visible face content, so the accessible name matches the visible label (WCAG
+ * 2.5.3). Set this only when the face has no readable text, such as an
+ * icon-only cover, and make it contain any visible text on the face.
+ * @param string $value the accessible name override, or empty to label from the face
+ */
+ public function setAccessibleLabel($value)
+ {
+ $this->setViewState('AccessibleLabel', TPropertyValue::ensureString($value), '');
+ }
+
+ /**
+ * Instantiate the {@see setOverlayTemplate OverlayTemplate} into the control
+ * tree so its controls participate in the page lifecycle.
+ * @param mixed $param event parameter
+ */
+ public function onInit($param)
+ {
+ parent::onInit($param);
+ $this->ensureOverlayControls();
+ }
+
+ /**
+ * Create the overlay container and instantiate the template into it. The
+ * container is a child of this panel; {@see renderContents()} renders it
+ * inside the overlay element and excludes it from the body content.
+ */
+ protected function ensureOverlayControls()
+ {
+ if ($this->_overlay !== null || $this->_overlayTemplate === null) {
+ return;
+ }
+ $this->_overlay = new TControl();
+ $this->getControls()->add($this->_overlay);
+ $this->_overlayTemplate->instantiateIn($this->_overlay);
+ }
+
+ /**
+ * Register the stylesheet and the client-side wrapper script.
+ * @param mixed $param event parameter
+ */
+ public function onPreRender($param)
+ {
+ parent::onPreRender($param);
+ $this->registerStyleSheet();
+ $cs = $this->getPage()->getClientScript();
+ $cs->registerPradoScript('safetycover');
+ $cs->registerPostBackControl($this->getClientClassName(), $this->getClientOptions());
+ }
+
+ /**
+ * Register the stylesheet specified by {@see getCssUrl CssUrl}. The value
+ * 'default' publishes the bundled stylesheet; an empty string registers
+ * nothing.
+ */
+ protected function registerStyleSheet()
+ {
+ $url = $this->getCssUrl();
+ if ($url === '') {
+ return;
+ }
+ if ($url === 'default') {
+ $url = $this->getApplication()->getAssetManager()->publishFilePath(__DIR__ . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'safetycover.css');
+ }
+ $this->getPage()->getClientScript()->registerStyleSheetFile($url, $url);
+ }
+
+ /**
+ * @return string the client-side JavaScript class name
+ */
+ protected function getClientClassName()
+ {
+ return 'Prado.WebUI.TSafetyCover';
+ }
+
+ /**
+ * @return array the options passed to the client-side JavaScript class
+ */
+ protected function getClientOptions(): array
+ {
+ return [
+ 'ID' => $this->getClientID(),
+ 'OpenDelay' => $this->getOpenDelay(),
+ 'AutoCloseDelay' => $this->getAutoCloseDelay(),
+ 'MouseOutTimeout' => $this->getMouseOutTimeout(),
+ 'AnimationDuration' => $this->getAnimationDuration(),
+ 'ResetDelay' => $this->getResetDelay(),
+ 'KeepOpenWhileActive' => $this->getKeepOpenWhileActive(),
+ ];
+ }
+
+ /**
+ * Add attribute name-value pairs to the renderer. The `id` attribute always
+ * renders because the stylesheet and the client-side wrapper address the
+ * control and its inner elements by ClientID. The framework CSS classes lead
+ * any author-set {@see setCssClass CssClass}: `safety-cover`, the effect class
+ * `safety-cover-`, and, for the `Slide` and `Collapse` effects, the
+ * resolved direction class `safety-cover-`. The class
+ * attribute is composed only for rendering; the stored CssClass is left
+ * untouched. The `--safety-cover-open-delay` custom property carries
+ * {@see getOpenDelay OpenDelay} so the pulse animation spans it.
+ * @param \Prado\Web\UI\THtmlWriter $writer the renderer
+ */
+ protected function addAttributesToRender($writer)
+ {
+ $writer->addAttribute('id', $this->getClientID());
+ $writer->addStyleAttribute('--safety-cover-open-delay', $this->getOpenDelay() . 'ms');
+ $writer->addStyleAttribute('--safety-cover-animation-duration', $this->getAnimationDuration() . 'ms');
+ parent::addAttributesToRender($writer);
+ // Override the class the style renderer wrote, prepending the framework
+ // classes without persisting them into the CssClass viewstate.
+ $writer->addAttribute('class', $this->buildCssClass($this->getCssClass()));
+ }
+
+ /**
+ * Compose the class attribute value, framework classes first, then the author
+ * classes. Author classes are preserved as-is; only a literal duplicate of a
+ * framework class this control adds is dropped. Nothing is stored, so the
+ * stored {@see getCssClass CssClass} stays exactly what the author set.
+ * @param string $cssClass the author CssClass value
+ * @return string the class attribute value with framework classes first
+ */
+ protected function buildCssClass(string $cssClass): string
+ {
+ $framework = ['safety-cover', 'safety-cover-' . strtolower($this->getOverlayEffect())];
+ if ($this->getOverlayEffect() !== TSafetyCoverEffect::None) {
+ $framework[] = 'safety-cover-' . $this->getResolvedDirection();
+ }
+ if ($this->getOverlayFade()) {
+ $framework[] = 'safety-cover-fade';
+ }
+ $authored = array_filter(
+ $cssClass === '' ? [] : explode(' ', $cssClass),
+ fn ($token) => $token !== '' && !in_array($token, $framework, true),
+ );
+ return implode(' ', array_merge($framework, $authored));
+ }
+
+ /**
+ * Strip characters that could break out of the inline `background-color`
+ * declaration, guarding against CSS injection when {@see getOverlayColor
+ * OverlayColor} carries untrusted data. The retained set covers hex,
+ * `rgb()`/`rgba()`/`hsl()`, the modern slash syntax, percentages, `var()`
+ * references, and named colors.
+ * @param string $color the raw OverlayColor value
+ * @return string the color with unsafe characters removed
+ */
+ protected function sanitizeOverlayColor(string $color): string
+ {
+ return preg_replace('~[^#a-zA-Z0-9(),.%/\s-]~', '', $color);
+ }
+
+ /**
+ * Render the overlay and the body content in their wrapper elements. The
+ * slider clips the animation. Inside it are two layers: the `overlay` guard,
+ * a transparent element whose `pointer-events` block clicks whenever the cover
+ * is closed, and the `face` inside it, the visible skin that carries the color
+ * and template and animates open and closed. Decoupling the two lets the face
+ * animate the close smoothly while the guard re-blocks clicks instantly. The
+ * content element wraps the body content the overlay guards.
+ * @param \Prado\Web\UI\THtmlWriter $writer the writer used for the rendering purpose
+ */
+ public function renderContents($writer)
+ {
+ $this->ensureOverlayControls();
+ $clientID = $this->getClientID();
+ $writer->addAttribute('id', $clientID . '_slider');
+ $writer->addAttribute('class', 'safety-cover-slider');
+ $writer->renderBeginTag('div');
+ $writer->addAttribute('id', $clientID . '_overlay');
+ $writer->addAttribute('class', 'safety-cover-overlay');
+ // The guard is an accessible button: a keyboard or AT user focuses it and
+ // activates it to reveal the content, which the wrapper keeps unreachable
+ // (inert) while closed. aria-expanded reflects the state; the wrapper flips
+ // it to "true" and drops the tabindex on open. The accessible name comes
+ // from AccessibleLabel when set, otherwise from the visible face content so
+ // it matches the visible label.
+ $writer->addAttribute('role', 'button');
+ $writer->addAttribute('tabindex', '0');
+ if (($label = $this->getAccessibleLabel()) !== '') {
+ $writer->addAttribute('aria-label', $label);
+ } else {
+ $writer->addAttribute('aria-labelledby', $clientID . '_face');
+ }
+ $writer->addAttribute('aria-expanded', 'false');
+ $writer->addAttribute('aria-controls', $clientID . '_content');
+ $writer->renderBeginTag('div');
+ $writer->addAttribute('id', $clientID . '_face');
+ $faceClass = 'safety-cover-face';
+ if (($faceCss = $this->getOverlayCssClass()) !== '') {
+ $faceClass .= ' ' . $faceCss;
+ }
+ $writer->addAttribute('class', $faceClass);
+ if (($color = $this->sanitizeOverlayColor($this->getOverlayColor())) !== '') {
+ $writer->addStyleAttribute('background-color', $color);
+ }
+ $writer->renderBeginTag('div');
+ if ($this->_overlay !== null) {
+ $this->_overlay->renderControl($writer);
+ }
+ $writer->renderEndTag();
+ $writer->renderEndTag();
+ $writer->renderEndTag();
+ $writer->addAttribute('id', $clientID . '_content');
+ $writer->addAttribute('class', 'safety-cover-content');
+ $writer->renderBeginTag('div');
+ $this->renderBodyContents($writer);
+ $writer->renderEndTag();
+ }
+
+ /**
+ * Render the child controls except the overlay container. The rendering of
+ * each child matches {@see \Prado\Web\UI\TControl::renderChildren()}.
+ * @param \Prado\Web\UI\THtmlWriter $writer the writer used for the rendering purpose
+ */
+ protected function renderBodyContents($writer)
+ {
+ if (!$this->getHasControls()) {
+ return;
+ }
+ foreach ($this->getControls() as $control) {
+ if ($control === $this->_overlay) {
+ continue;
+ }
+ if (is_string($control)) {
+ $writer->write($control);
+ } elseif ($control instanceof TControl) {
+ $control->renderControl($writer);
+ } elseif ($control instanceof IFilterRenderable) {
+ $oldWriter = $this->preRenderFilter($writer, $control);
+ $control->render($writer);
+ $this->processRenderFilter($writer, $oldWriter, $control);
+ } elseif ($control instanceof IRenderable) {
+ $control->render($writer);
+ }
+ }
+ }
+}
diff --git a/framework/Web/UI/WebControls/TSafetyCoverDirection.php b/framework/Web/UI/WebControls/TSafetyCoverDirection.php
new file mode 100644
index 000000000..025cf5d8a
--- /dev/null
+++ b/framework/Web/UI/WebControls/TSafetyCoverDirection.php
@@ -0,0 +1,57 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado\Web\UI\WebControls;
+
+/**
+ * TSafetyCoverDirection enumeration.
+ *
+ * TSafetyCoverDirection specifies the direction the overlay of a
+ * {@see TSafetyCover} travels or collapses toward when the control opens. It
+ * governs the `Slide` and `Collapse` effects and is ignored by `Fade`.
+ *
+ * Four values are physical and two are logical:
+ *
+ * | Constant | Resolves to |
+ * |---|---|
+ * | `Up` | the top edge |
+ * | `Down` | the bottom edge |
+ * | `Left` | the left edge |
+ * | `Right` | the right edge |
+ * | `Forward` | the reading-end edge: `Right` in left-to-right content, `Left` in right-to-left |
+ * | `Backward` | the reading-start edge: `Left` in left-to-right content, `Right` in right-to-left |
+ *
+ * The logical values resolve from the control's {@see TPanel::getDirection Direction}
+ * during rendering, so a single template behaves correctly in both writing
+ * directions.
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+class TSafetyCoverDirection extends \Prado\TEnumerable
+{
+ /** The overlay leaves toward the top edge. */
+ public const Up = 'Up';
+
+ /** The overlay leaves toward the bottom edge. */
+ public const Down = 'Down';
+
+ /** The overlay leaves toward the left edge. */
+ public const Left = 'Left';
+
+ /** The overlay leaves toward the right edge. */
+ public const Right = 'Right';
+
+ /** The overlay leaves toward the reading-end edge, flipping with content direction. */
+ public const Forward = 'Forward';
+
+ /** The overlay leaves toward the reading-start edge, flipping with content direction. */
+ public const Backward = 'Backward';
+}
diff --git a/framework/Web/UI/WebControls/TSafetyCoverEffect.php b/framework/Web/UI/WebControls/TSafetyCoverEffect.php
new file mode 100644
index 000000000..a7a81abcf
--- /dev/null
+++ b/framework/Web/UI/WebControls/TSafetyCoverEffect.php
@@ -0,0 +1,41 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado\Web\UI\WebControls;
+
+/**
+ * TSafetyCoverEffect enumeration.
+ *
+ * TSafetyCoverEffect specifies the geometric transition the overlay of a
+ * {@see TSafetyCover} makes when the control opens. Each value renders as a
+ * `safety-cover-` class on the control, which the bundled stylesheet
+ * turns into a transition. It combines with the independent
+ * {@see TSafetyCover::getOverlayFade OverlayFade} opacity transition.
+ *
+ * | Constant | CSS mechanism | Appearance |
+ * |---|---|---|
+ * | `Slide` | `transform: translate` clipped by the container | The overlay slides off the panel toward {@see TSafetyCoverDirection}; its content moves with it. |
+ * | `Collapse` | `clip-path: inset` | The overlay is wiped away toward {@see TSafetyCoverDirection}; its content stays put, like a rolling shade. |
+ * | `None` | *(none)* | No geometric transition. Pair with `OverlayFade` for a pure fade; with neither, the overlay snaps hidden. Ignores {@see TSafetyCoverDirection}. |
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+class TSafetyCoverEffect extends \Prado\TEnumerable
+{
+ /** The overlay slides off the panel, its content moving with it. */
+ public const Slide = 'Slide';
+
+ /** The overlay is clipped away in place, its content staying put. */
+ public const Collapse = 'Collapse';
+
+ /** No geometric transition; the overlay fades ({@see TSafetyCover::getOverlayFade}) or snaps hidden. */
+ public const None = 'None';
+}
diff --git a/framework/Web/UI/WebControls/assets/safetycover.css b/framework/Web/UI/WebControls/assets/safetycover.css
new file mode 100644
index 000000000..f8315fcef
--- /dev/null
+++ b/framework/Web/UI/WebControls/assets/safetycover.css
@@ -0,0 +1,142 @@
+/*! PRADO TSafetyCover stylesheet | github.com/pradosoft/prado */
+
+/*
+ * CSS contract — the cover tracks and guards the content only while these hold.
+ * Overriding any of them (via CssClass, a theme, or a replacement CssUrl) breaks it:
+ *
+ * .safety-cover must stay a positioned containing block (position:
+ * relative | absolute | fixed). position:static makes the
+ * slider position against a distant ancestor, so the cover
+ * lands somewhere else.
+ * .safety-cover-content must stay in normal flow and give the panel its size.
+ * Taking it out of flow (position:absolute/fixed, float)
+ * or display:none collapses the panel to zero, so the
+ * cover shrinks to nothing. It also keeps isolation:isolate
+ * so its z-indexes cannot rise above the cover.
+ * .safety-cover-slider must keep overflow:hidden (clips the Slide face) and
+ * z-index:1 (stacks the cover above the content).
+ * .safety-cover-overlay/-face must keep inset:0 (fill the slider) and the guard
+ * its pointer-events toggle (auto closed / none open).
+ *
+ * Safe to change: OverlayColor / OverlayCssClass / OverlayTemplate (face looks),
+ * panel padding/border, and any non-positioning CssClass on the control.
+ */
+
+.safety-cover {
+ position: relative;
+}
+
+.safety-cover-slider {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ z-index: 1;
+}
+
+/*
+ * The content establishes its own stacking context so a positioned descendant
+ * with a high z-index cannot paint above the cover and escape the guard.
+ * isolation changes stacking only, never layout.
+ */
+.safety-cover-content {
+ isolation: isolate;
+}
+
+/*
+ * The guard: a transparent layer that blocks clicks to the content whenever the
+ * cover is closed. It never moves; only its pointer-events toggle, so the guard
+ * re-blocks the instant the cover starts closing, no matter how the face is
+ * animating. The click that opens the cover lands here.
+ */
+.safety-cover-overlay {
+ position: absolute;
+ inset: 0;
+ cursor: pointer;
+ pointer-events: auto;
+}
+
+/* Open: clicks reach the guarded content. Toggled instantly, never transitioned. */
+.safety-cover-open .safety-cover-overlay {
+ pointer-events: none;
+}
+
+/*
+ * The face: the visible skin (color + template). It animates both open and
+ * close over --safety-cover-animation-duration, and never blocks clicks itself
+ * (the guard does that). The OverlayColor property renders an inline
+ * background-color that wins over this.
+ */
+.safety-cover-face {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-color: rgba(255, 0, 0, 0.65);
+ text-align: center;
+ clip-path: inset(0 0 0 0);
+ transition:
+ transform var(--safety-cover-animation-duration, 250ms) ease-in-out,
+ clip-path var(--safety-cover-animation-duration, 250ms) ease-in-out,
+ opacity var(--safety-cover-animation-duration, 250ms) ease-in-out;
+}
+
+/*
+ * The direction class sets where the face goes; the effect class consumes it.
+ * --safety-cover-translate is comma-form for the translate() function; the slider
+ * clips the face once it moves past an edge. --safety-cover-clip is space-form
+ * for inset(), collapsing the face toward the opposite edge with its content
+ * held in place.
+ */
+.safety-cover-up { --safety-cover-translate: 0, -101%; --safety-cover-clip: 0 0 100% 0; }
+.safety-cover-down { --safety-cover-translate: 0, 101%; --safety-cover-clip: 100% 0 0 0; }
+.safety-cover-left { --safety-cover-translate: -101%, 0; --safety-cover-clip: 0 100% 0 0; }
+.safety-cover-right { --safety-cover-translate: 101%, 0; --safety-cover-clip: 0 0 0 100%; }
+
+/*
+ * The effect class is on the panel and the open class is on the slider inside
+ * it, so these are descendant selectors, not compound. The direction custom
+ * properties set on the panel inherit down to the face.
+ */
+.safety-cover-slide .safety-cover-open .safety-cover-face {
+ transform: translate(var(--safety-cover-translate, 0, -101%));
+}
+
+.safety-cover-collapse .safety-cover-open .safety-cover-face {
+ clip-path: inset(var(--safety-cover-clip, 0 0 100% 0));
+}
+
+/* Fade is an independent axis; it layers on any effect, including None. */
+.safety-cover-fade .safety-cover-open .safety-cover-face {
+ opacity: 0;
+}
+
+/* No geometry and no fade: the face snaps hidden with no animation. */
+.safety-cover-none:not(.safety-cover-fade) .safety-cover-open .safety-cover-face {
+ visibility: hidden;
+}
+
+/* The pulse spans OpenDelay: three pulses of one third each. */
+.safety-cover-pulsate {
+ animation: safety-cover-pulsate calc(var(--safety-cover-open-delay, 750ms) / 3) ease-in-out 3;
+}
+
+@keyframes safety-cover-pulsate {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.4;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .safety-cover-face {
+ transition: none;
+ }
+
+ .safety-cover-pulsate {
+ animation: none;
+ }
+}
diff --git a/framework/classes.php b/framework/classes.php
index 42dd7ce6b..98eaece7a 100644
--- a/framework/classes.php
+++ b/framework/classes.php
@@ -870,6 +870,9 @@
'TRepeatLayout' => 'Prado\Web\UI\WebControls\TRepeatLayout',
'TRequiredFieldValidator' => 'Prado\Web\UI\WebControls\TRequiredFieldValidator',
'TSafeHtml' => 'Prado\Web\UI\WebControls\TSafeHtml',
+'TSafetyCover' => 'Prado\Web\UI\WebControls\TSafetyCover',
+'TSafetyCoverDirection' => 'Prado\Web\UI\WebControls\TSafetyCoverDirection',
+'TSafetyCoverEffect' => 'Prado\Web\UI\WebControls\TSafetyCoverEffect',
'TScrollBars' => 'Prado\Web\UI\WebControls\TScrollBars',
'TSection' => 'Prado\Web\UI\WebControls\TSection',
'TServerValidateEventParameter' => 'Prado\Web\UI\WebControls\TServerValidateEventParameter',
diff --git a/tests/harness/web/protected/pages/SafetyCoverKeepOpenTest.page b/tests/harness/web/protected/pages/SafetyCoverKeepOpenTest.page
new file mode 100644
index 000000000..cb0f38b22
--- /dev/null
+++ b/tests/harness/web/protected/pages/SafetyCoverKeepOpenTest.page
@@ -0,0 +1,14 @@
+
+
Safety Cover Keep Open Test Case
+
+ <%-- KeepOpenWhileActive: interaction inside the open cover resets the short
+ AutoCloseDelay=1000, so sustained activity keeps it open and it closes once
+ idle. MouseOutTimeout is large so only inactivity closes it. --%>
+
+
+ Unlock
+
+
+
+
+
diff --git a/tests/harness/web/protected/pages/SafetyCoverKeepOpenTest.php b/tests/harness/web/protected/pages/SafetyCoverKeepOpenTest.php
new file mode 100644
index 000000000..19575ef94
--- /dev/null
+++ b/tests/harness/web/protected/pages/SafetyCoverKeepOpenTest.php
@@ -0,0 +1,5 @@
+
+
Safety Cover Matrix Test Case
+ <%-- Behavioral direction matrix: one Slide control per scenario. The test
+ opens each and reads the computed transform to confirm the overlay moves
+ toward the correct edge. AutoCloseDelay/MouseOutTimeout are huge so a
+ control stays open once clicked. IDs encode: direction _ [handedness _] fadeN. --%>
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ x
+ <%-- Collapse (clip-path) and None (visibility/opacity) coverage. --%>
+ x
+ x
+ x
+ x
+
diff --git a/tests/harness/web/protected/pages/SafetyCoverMatrixTest.php b/tests/harness/web/protected/pages/SafetyCoverMatrixTest.php
new file mode 100644
index 000000000..21da48511
--- /dev/null
+++ b/tests/harness/web/protected/pages/SafetyCoverMatrixTest.php
@@ -0,0 +1,5 @@
+
+
Safety Cover Test Case
+
+ <%-- Two independent controls, each demonstrating ONE close path with a single
+ open/close cycle. Keeping them separate means no control ever reopens, so
+ each cycle reads cleanly.
+
+ "auto" (Collapse + Forward + OverlayFade) exercises the clip-path geometry,
+ the logical direction (Forward → Right in left-to-right content), the
+ independent fade axis, and the auto-close after AutoCloseDelay=3000. --%>
+
+
+ Unlock
+
+
+
+
+
+ <%-- "mouseout" (Slide + Up, no fade) exercises the translate geometry and the
+ mouse-out close. AutoCloseDelay is large so the auto-close never fires
+ during the test; the mouse leaving the panel is what closes it. --%>
+
+
+ Unlock
+
+
+
+
+
+
+ function clicked(id) {
+ document.getElementById(id).textContent = 'clicked';
+ }
+
+
diff --git a/tests/harness/web/protected/pages/SafetyCoverTest.php b/tests/harness/web/protected/pages/SafetyCoverTest.php
new file mode 100644
index 000000000..9e554f986
--- /dev/null
+++ b/tests/harness/web/protected/pages/SafetyCoverTest.php
@@ -0,0 +1,5 @@
+
+ *
+ *
…
+ *
+ *
…
+ *
+ *
+ * The wrapper toggles the `safety-cover-open` class on the slider and the
+ * `safety-cover-pulsate` class on the panel; the stylesheet supplies the
+ * animations. Timers drive the opening and the overlay's return, so the tests run
+ * on fake timers.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { TSafetyCover } from '../adapters/safetycover.js';
+
+// ─── helpers ─────────────────────────────────────────────────────────────────
+
+/**
+ * Creates the control DOM structure and returns a registered wrapper for it.
+ *
+ * @param {object} options - extra wrapper options merged over {ID: 'guard'}
+ */
+function buildControl(options = {}) {
+ document.body.innerHTML = `
+
+
+
Click to unlock
+
+
+
`;
+ return new TSafetyCover(Object.assign({ ID: 'guard' }, options));
+}
+
+function slider() {
+ return document.getElementById('guard_slider');
+}
+
+function panel() {
+ return document.getElementById('guard');
+}
+
+function overlay() {
+ return document.getElementById('guard_overlay');
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ document.body.innerHTML = '';
+ global.Prado.Registry = {};
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ document.body.innerHTML = '';
+ global.Prado.Registry = {};
+});
+
+// ─── registration ────────────────────────────────────────────────────────────
+
+describe('registration', () => {
+ it('registers itself in Prado.Registry under its ID', () => {
+ const wrapper = buildControl();
+ expect(global.Prado.Registry['guard']).toBe(wrapper);
+ });
+
+ it('starts closed', () => {
+ const wrapper = buildControl();
+ expect(wrapper.isOpen()).toBe(false);
+ expect(slider().classList.contains('safety-cover-open')).toBe(false);
+ });
+
+ it('tolerates a missing DOM structure', () => {
+ document.body.innerHTML = '';
+ const wrapper = new TSafetyCover({ ID: 'lonely' });
+ expect(wrapper.isOpen()).toBe(false);
+ });
+});
+
+// ─── options ─────────────────────────────────────────────────────────────────
+
+describe('options', () => {
+ it('defaults OpenDelay, AutoCloseDelay and MouseOutTimeout', () => {
+ const wrapper = buildControl();
+ expect(wrapper.getOpenDelay()).toBe(800);
+ expect(wrapper.getAutoCloseDelay()).toBe(6000);
+ expect(wrapper.getMouseOutTimeout()).toBe(1000);
+ });
+
+ it('accepts option overrides', () => {
+ const wrapper = buildControl({ OpenDelay: 100, AutoCloseDelay: 2000, MouseOutTimeout: 300 });
+ expect(wrapper.getOpenDelay()).toBe(100);
+ expect(wrapper.getAutoCloseDelay()).toBe(2000);
+ expect(wrapper.getMouseOutTimeout()).toBe(300);
+ });
+});
+
+// ─── open ────────────────────────────────────────────────────────────────────
+
+describe('open', () => {
+ it('pulses first, then opens after OpenDelay', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ expect(panel().classList.contains('safety-cover-pulsate')).toBe(true);
+ expect(wrapper.isOpen()).toBe(false);
+ vi.advanceTimersByTime(800);
+ expect(panel().classList.contains('safety-cover-pulsate')).toBe(false);
+ expect(slider().classList.contains('safety-cover-open')).toBe(true);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+
+ it('opens on a click on the overlay', () => {
+ const wrapper = buildControl();
+ overlay().dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+
+ it('ignores a second open while pulsing or open', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ wrapper.open();
+ expect(wrapper.isOpen()).toBe(true);
+ });
+
+ it('closes on its own after AutoCloseDelay', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(true);
+ vi.advanceTimersByTime(6000);
+ expect(wrapper.isOpen()).toBe(false);
+ expect(slider().classList.contains('safety-cover-open')).toBe(false);
+ });
+
+ it('honors a custom AutoCloseDelay', () => {
+ const wrapper = buildControl({ AutoCloseDelay: 2000 });
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ vi.advanceTimersByTime(1999);
+ expect(wrapper.isOpen()).toBe(true);
+ vi.advanceTimersByTime(1);
+ expect(wrapper.isOpen()).toBe(false);
+ });
+});
+
+// ─── close ───────────────────────────────────────────────────────────────────
+
+describe('close', () => {
+ it('closes immediately and cancels pending timers', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ wrapper.close();
+ expect(wrapper.isOpen()).toBe(false);
+ vi.advanceTimersByTime(60000);
+ expect(wrapper.isOpen()).toBe(false);
+ });
+
+ it('cancels a pending open', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ wrapper.close();
+ vi.advanceTimersByTime(60000);
+ expect(wrapper.isOpen()).toBe(false);
+ expect(panel().classList.contains('safety-cover-pulsate')).toBe(false);
+ });
+
+ it('can open again after the close cooldown', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ wrapper.close();
+ vi.advanceTimersByTime(250); // past the close animation cooldown (AnimationDuration)
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+});
+
+// ─── close cooldown (no reopen mid-animation) ────────────────────────────────
+
+describe('close cooldown', () => {
+ it('ignores a reopen during the close animation', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ wrapper.close();
+ wrapper.open(); // click during the close animation — must be ignored
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(false);
+ });
+
+ it('becomes reopenable once the close animation ends', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ wrapper.close();
+ vi.advanceTimersByTime(249); // still within the 250ms animation cooldown
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(false); // reopen was still ignored
+ vi.advanceTimersByTime(1); // cooldown elapses
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+
+ it('ResetDelay extends the cooldown past the animation', () => {
+ const wrapper = buildControl({ AnimationDuration: 250, ResetDelay: 500 });
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ wrapper.close();
+ vi.advanceTimersByTime(600); // past the animation but within animation+ResetDelay (750)
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(false); // still in cooldown
+ vi.advanceTimersByTime(200); // now past 750
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+
+ it('a cancelled pulse has no cooldown (open never completed)', () => {
+ const wrapper = buildControl();
+ wrapper.open(); // pulsing, not yet open
+ wrapper.close(); // cancel before it opened — no animation, no cooldown
+ wrapper.open(); // should start a fresh open immediately
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+});
+
+// ─── mouse behavior ──────────────────────────────────────────────────────────
+
+describe('mouse behavior', () => {
+ it('closes after the pointer leaves an open panel', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ panel().dispatchEvent(new MouseEvent('mouseleave'));
+ vi.advanceTimersByTime(1000);
+ expect(wrapper.isOpen()).toBe(false);
+ });
+
+ it('cancels the pending close when the pointer re-enters', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ panel().dispatchEvent(new MouseEvent('mouseleave'));
+ vi.advanceTimersByTime(500);
+ panel().dispatchEvent(new MouseEvent('mouseenter'));
+ vi.advanceTimersByTime(5000);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+
+ it('ignores mouseleave while closed', () => {
+ const wrapper = buildControl();
+ panel().dispatchEvent(new MouseEvent('mouseleave'));
+ vi.advanceTimersByTime(5000);
+ expect(wrapper.isOpen()).toBe(false);
+ });
+});
+
+// ─── accessibility ─────────────────────────────────────────────────────────
+
+// The content is guarded when it is inert (modern browsers) or, in the fallback
+// path, hidden from assistive tech with aria-hidden.
+function contentGuarded() {
+ const c = document.getElementById('guard_content');
+ return c.inert === true || c.getAttribute('aria-hidden') === 'true';
+}
+
+function keydown(key) {
+ overlay().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
+}
+
+describe('accessibility', () => {
+ it('guards the content from keyboard and AT while closed', () => {
+ buildControl();
+ expect(contentGuarded()).toBe(true);
+ });
+
+ it('reveals the content on open and re-guards it on close', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ expect(contentGuarded()).toBe(false);
+ wrapper.close();
+ expect(contentGuarded()).toBe(true);
+ });
+
+ it('opens on Enter on the guard', () => {
+ const wrapper = buildControl();
+ keydown('Enter');
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+
+ it('opens on Space on the guard', () => {
+ const wrapper = buildControl();
+ keydown(' ');
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+
+ it('ignores other keys', () => {
+ const wrapper = buildControl();
+ keydown('a');
+ vi.advanceTimersByTime(800);
+ expect(wrapper.isOpen()).toBe(false);
+ });
+
+ it('reflects state in aria-expanded', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ expect(overlay().getAttribute('aria-expanded')).toBe('true');
+ wrapper.close();
+ expect(overlay().getAttribute('aria-expanded')).toBe('false');
+ });
+
+ it('moves focus into the content on a keyboard open', () => {
+ buildControl();
+ keydown('Enter');
+ vi.advanceTimersByTime(800);
+ expect(document.activeElement.id).toBe('inner-button');
+ });
+
+ it('does not move focus on a mouse open', () => {
+ const wrapper = buildControl();
+ wrapper.open(false);
+ vi.advanceTimersByTime(800);
+ expect(document.activeElement.id).not.toBe('inner-button');
+ });
+
+ it('is idempotent: repeated guarding does not corrupt the tab order (fallback)', () => {
+ // jsdom has no inert, so this exercises the aria-hidden/tabindex fallback.
+ const wrapper = buildControl();
+ wrapper.setContentGuarded(true); // second guard(true) after onInit's — must be a no-op
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ // The button's original tabindex (none) is restored, so it is reachable.
+ expect(document.getElementById('inner-button').getAttribute('tabindex')).toBeNull();
+ });
+
+ it('drops the guard from the tab order while open and restores it on close', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ expect(overlay().getAttribute('tabindex')).toBe('-1');
+ wrapper.close();
+ expect(overlay().getAttribute('tabindex')).toBe('0');
+ });
+
+ it('restores the content on deinitialize so a later wrapper starts clean', () => {
+ const wrapper = buildControl();
+ expect(contentGuarded()).toBe(true);
+ wrapper.deinitialize();
+ expect(contentGuarded()).toBe(false);
+ // fallback: the guarded button's original tabindex is restored, not left at -1
+ expect(document.getElementById('inner-button').getAttribute('tabindex')).toBeNull();
+ });
+
+ it('acknowledges activation in aria-expanded immediately, before the pulse', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ expect(overlay().getAttribute('aria-expanded')).toBe('true');
+ expect(wrapper.isOpen()).toBe(false); // still pulsing, not yet open
+ });
+
+ it('leaves no stray tabindex on content that has no focusable', () => {
+ document.body.innerHTML = `
+
+
+
+
+
no focusable
+
`;
+ new TSafetyCover({ ID: 'guard' });
+ keydown('Enter');
+ vi.advanceTimersByTime(800);
+ expect(document.getElementById('guard_content').hasAttribute('tabindex')).toBe(false);
+ });
+});
+
+// ─── keep open while active ──────────────────────────────────────────────────
+
+describe('keep open while active', () => {
+ it('resets the close timer on activity when enabled', () => {
+ const wrapper = buildControl({ KeepOpenWhileActive: true, AutoCloseDelay: 6000 });
+ wrapper.open();
+ vi.advanceTimersByTime(800); // opened; close timer set for 6000
+ vi.advanceTimersByTime(5000); // t≈5800, close pending at ≈6800
+ panel().dispatchEvent(new MouseEvent('mousemove', { bubbles: true })); // reset → 6000 more
+ vi.advanceTimersByTime(5000); // would have closed at 6800 without the reset
+ expect(wrapper.isOpen()).toBe(true);
+ vi.advanceTimersByTime(6000); // now idle past AutoCloseDelay
+ expect(wrapper.isOpen()).toBe(false);
+ });
+
+ it('ignores activity when disabled (default)', () => {
+ const wrapper = buildControl({ AutoCloseDelay: 6000 });
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ vi.advanceTimersByTime(5000);
+ panel().dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
+ vi.advanceTimersByTime(1000); // total idle 6000
+ expect(wrapper.isOpen()).toBe(false);
+ });
+
+ it('resets on keydown as well as mousemove', () => {
+ const wrapper = buildControl({ KeepOpenWhileActive: true, AutoCloseDelay: 6000 });
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ vi.advanceTimersByTime(5000);
+ panel().dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
+ vi.advanceTimersByTime(5000);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+
+ it('cancels a pending mouse-out close on activity', () => {
+ const wrapper = buildControl({ KeepOpenWhileActive: true, AutoCloseDelay: 6000, MouseOutTimeout: 1000 });
+ wrapper.open();
+ vi.advanceTimersByTime(800);
+ panel().dispatchEvent(new MouseEvent('mouseleave')); // schedule mouse-out close at +1000
+ panel().dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); // activity cancels it
+ vi.advanceTimersByTime(1500);
+ expect(wrapper.isOpen()).toBe(true);
+ });
+});
+
+// ─── deinitialize ────────────────────────────────────────────────────────────
+
+describe('deinitialize', () => {
+ it('clears pending timers on deinitialize', () => {
+ const wrapper = buildControl();
+ wrapper.open();
+ wrapper.deinitialize();
+ expect(() => vi.advanceTimersByTime(60000)).not.toThrow();
+ expect(wrapper.isOpen()).toBe(false);
+ });
+});
diff --git a/tests/playwright/web/TSafetyCoverAccessibilityTestCase.spec.js b/tests/playwright/web/TSafetyCoverAccessibilityTestCase.spec.js
new file mode 100644
index 000000000..872ce7690
--- /dev/null
+++ b/tests/playwright/web/TSafetyCoverAccessibilityTestCase.spec.js
@@ -0,0 +1,67 @@
+import { test, expect } from '@playwright/test';
+import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js';
+
+const PAGE_URL = 'web/index.php?page=SafetyCoverTest';
+
+// Accessibility: the guard is a keyboard-operable button, and the guarded content
+// is genuinely unreachable (not just mouse-blocked) while the cover is closed.
+// Uses mouseoutCover (Slide + Up, AutoCloseDelay 30000 so it stays open for the test).
+test('TSafetyCoverAccessibilityTestCase', async ({ page }) => {
+ const h = new PradoTestHelper(page, GENERIC_BASE_URL);
+ await h.url(PAGE_URL);
+ await h.assertSourceContains('Safety Cover Test Case');
+
+ const id = 'ctl0_Content_mouseoutCover';
+ const guard = page.locator(`#${id}_overlay`);
+ const button = '#mouseoutButton';
+ const result = page.locator('#mouseoutResult');
+
+ // ── The guard is exposed to AT as a button with a name and collapsed state.
+ // By default it is labelled by the visible face, so the accessible name
+ // matches the visible text (WCAG 2.5.3 Label in Name) ──
+ await expect(guard).toHaveAttribute('role', 'button');
+ await expect(guard).toHaveAttribute('tabindex', '0');
+ await expect(guard).toHaveAttribute('aria-labelledby', `${id}_face`);
+ await expect(guard).toHaveAccessibleName('Unlock');
+ await expect(guard).toHaveAttribute('aria-expanded', 'false');
+ await expect(guard).toHaveAttribute('aria-controls', `${id}_content`);
+
+ // ── While CLOSED, the guarded button cannot be focused (it is inert), so
+ // keyboard and AT users cannot reach it behind the cover ──
+ const focusedWhileClosed = await page.evaluate(() => {
+ const b = document.getElementById('mouseoutButton');
+ b.focus();
+ return document.activeElement === b;
+ });
+ expect(focusedWhileClosed, 'guarded button must not be focusable while closed').toBe(false);
+ await expect(result).toHaveText('');
+
+ // ── The cover opens from the KEYBOARD: focus the guard, press Enter ──
+ await guard.focus();
+ expect(await page.evaluate((gid) => document.activeElement === document.getElementById(gid), `${id}_overlay`)).toBe(true);
+ await page.keyboard.press('Enter');
+ await expect(page.locator(`#${id}_slider`)).toHaveClass(/safety-cover-open/, { timeout: 2000 });
+ await expect(guard).toHaveAttribute('aria-expanded', 'true');
+ // Open: the guard leaves the tab order (no tabbable no-op button) ──
+ await expect(guard).toHaveAttribute('tabindex', '-1');
+
+ // ── A keyboard open moves focus into the revealed content ──
+ await expect
+ .poll(() => page.evaluate(() => document.activeElement && document.activeElement.id), { timeout: 2000 })
+ .toBe('mouseoutButton');
+
+ // ── Now reachable: activating the button from the keyboard works ──
+ await page.locator(button).press('Enter');
+ await expect(result).toHaveText('clicked');
+
+ // ── Closing re-guards: the button is inert again and the state collapses ──
+ await page.evaluate((rid) => Prado.Registry[rid].close(), id);
+ await expect(guard).toHaveAttribute('aria-expanded', 'false');
+ await expect(guard).toHaveAttribute('tabindex', '0'); // back in the tab order
+ const focusedAfterClose = await page.evaluate(() => {
+ const b = document.getElementById('mouseoutButton');
+ b.focus();
+ return document.activeElement === b;
+ });
+ expect(focusedAfterClose, 'guarded button must not be focusable after re-guarding').toBe(false);
+});
diff --git a/tests/playwright/web/TSafetyCoverCloseCooldownTestCase.spec.js b/tests/playwright/web/TSafetyCoverCloseCooldownTestCase.spec.js
new file mode 100644
index 000000000..5705ed8d1
--- /dev/null
+++ b/tests/playwright/web/TSafetyCoverCloseCooldownTestCase.spec.js
@@ -0,0 +1,35 @@
+import { test, expect } from '@playwright/test';
+import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js';
+
+const PAGE_URL = 'web/index.php?page=SafetyCoverTest';
+
+// A real click on the cover during the close animation must be ignored — the
+// cover does not reopen mid-animation — and it becomes reopenable again once the
+// close cooldown (AnimationDuration + ResetDelay) elapses. mouseoutCover uses the
+// default AnimationDuration (250ms) and ResetDelay (0), and OpenDelay=200.
+test('TSafetyCoverCloseCooldownTestCase', async ({ page }) => {
+ const h = new PradoTestHelper(page, GENERIC_BASE_URL);
+ await h.url(PAGE_URL);
+ await h.assertSourceContains('Safety Cover Test Case');
+
+ const id = 'ctl0_Content_mouseoutCover';
+ const overlay = page.locator(`#${id}_overlay`);
+ const slider = page.locator(`#${id}_slider`);
+
+ // Open the cover.
+ await overlay.click();
+ await expect(slider).toHaveClass(/safety-cover-open/, { timeout: 2000 });
+
+ // Begin closing, then click the cover DURING the close animation.
+ await page.evaluate((cid) => Prado.Registry[cid].close(), id);
+ await overlay.click({ force: true });
+
+ // The click is ignored: no reopen. Wait past OpenDelay so a reopen, if it had
+ // started, would have shown by now.
+ await page.waitForTimeout(500);
+ await expect(slider).not.toHaveClass(/safety-cover-open/);
+
+ // After the cooldown, a click reopens the cover normally.
+ await overlay.click();
+ await expect(slider).toHaveClass(/safety-cover-open/, { timeout: 2000 });
+});
diff --git a/tests/playwright/web/TSafetyCoverKeepOpenTestCase.spec.js b/tests/playwright/web/TSafetyCoverKeepOpenTestCase.spec.js
new file mode 100644
index 000000000..3d75073a2
--- /dev/null
+++ b/tests/playwright/web/TSafetyCoverKeepOpenTestCase.spec.js
@@ -0,0 +1,41 @@
+import { test, expect } from '@playwright/test';
+import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js';
+
+const PAGE_URL = 'web/index.php?page=SafetyCoverKeepOpenTest';
+
+// KeepOpenWhileActive: interaction inside the open cover resets AutoCloseDelay,
+// so sustained activity keeps it open past the delay, and it closes once idle.
+// The activeCover control uses AutoCloseDelay=1000 with a large MouseOutTimeout
+// (so only inactivity, not the pointer leaving, closes it).
+test('TSafetyCoverKeepOpenTestCase', async ({ page }) => {
+ const h = new PradoTestHelper(page, GENERIC_BASE_URL);
+ await h.url(PAGE_URL);
+ await h.assertSourceContains('Safety Cover Keep Open Test Case');
+
+ const id = 'ctl0_Content_activeCover';
+ const slider = page.locator(`#${id}_slider`);
+ const box = await page.locator(`#${id}`).boundingBox();
+ const cx = box.x + box.width / 2;
+ const cy = box.y + box.height / 2;
+
+ // Open the cover.
+ await page.locator(`#${id}_overlay`).click();
+ await expect(slider).toHaveClass(/safety-cover-open/, { timeout: 2000 });
+
+ // Keep interacting: a mouse move inside the panel every 300ms for ~2s — twice
+ // the 1000ms AutoCloseDelay. Each move resets the timer, so it stays open the
+ // whole time despite never having a 1000ms idle gap. The moves stay inside the
+ // panel, so no mouse-out close is triggered.
+ const start = Date.now();
+ let i = 0;
+ while (Date.now() - start < 2000) {
+ const d = i % 2 === 0 ? 6 : -6; // wiggle so each move is a real change
+ await page.mouse.move(cx + d, cy + d);
+ await expect(slider).toHaveClass(/safety-cover-open/); // still open during activity
+ await page.waitForTimeout(300);
+ i++;
+ }
+
+ // Stop interacting: after one AutoCloseDelay of idle, it closes on its own.
+ await expect(slider).not.toHaveClass(/safety-cover-open/, { timeout: 2000 });
+});
diff --git a/tests/playwright/web/TSafetyCoverMatrixTestCase.spec.js b/tests/playwright/web/TSafetyCoverMatrixTestCase.spec.js
new file mode 100644
index 000000000..85fd4ad1b
--- /dev/null
+++ b/tests/playwright/web/TSafetyCoverMatrixTestCase.spec.js
@@ -0,0 +1,115 @@
+import { test, expect } from '@playwright/test';
+import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js';
+
+const PAGE_URL = 'web/index.php?page=SafetyCoverMatrixTest';
+
+// Each row: a Slide control whose overlay, once open, must translate toward the
+// named edge. `axis`/`sign` are the expected computed-transform translate:
+// y<0 up, y>0 down, x<0 left, x>0 right. `fade` is the expected OverlayFade.
+// Forward/Backward are the logical directions; they resolve through the panel's
+// content direction, so the LTR and RTL rows below prove the handedness flip.
+const MATRIX = [
+ // physical, fade off
+ { id: 'up_f0', axis: 'y', sign: -1, fade: false },
+ { id: 'down_f0', axis: 'y', sign: 1, fade: false },
+ { id: 'left_f0', axis: 'x', sign: -1, fade: false },
+ { id: 'right_f0', axis: 'x', sign: 1, fade: false },
+ // physical, fade on
+ { id: 'up_f1', axis: 'y', sign: -1, fade: true },
+ { id: 'down_f1', axis: 'y', sign: 1, fade: true },
+ { id: 'left_f1', axis: 'x', sign: -1, fade: true },
+ { id: 'right_f1', axis: 'x', sign: 1, fade: true },
+ // logical x handedness, fade off (Forward → right in LTR, left in RTL)
+ { id: 'fwd_ltr_f0', axis: 'x', sign: 1, fade: false },
+ { id: 'bwd_ltr_f0', axis: 'x', sign: -1, fade: false },
+ { id: 'fwd_rtl_f0', axis: 'x', sign: -1, fade: false },
+ { id: 'bwd_rtl_f0', axis: 'x', sign: 1, fade: false },
+ // logical x handedness, fade on
+ { id: 'fwd_ltr_f1', axis: 'x', sign: 1, fade: true },
+ { id: 'bwd_ltr_f1', axis: 'x', sign: -1, fade: true },
+ { id: 'fwd_rtl_f1', axis: 'x', sign: -1, fade: true },
+ { id: 'bwd_rtl_f1', axis: 'x', sign: 1, fade: true },
+];
+
+// Parse the translate (e = x, f = y) out of a computed `matrix(a,b,c,d,e,f)`.
+function translateOf(transform) {
+ const m = /matrix\(([^)]+)\)/.exec(transform);
+ if (!m) {
+ return { x: 0, y: 0 };
+ }
+ const p = m[1].split(',').map((n) => parseFloat(n));
+ return { x: p[4], y: p[5] };
+}
+
+test('TSafetyCoverMatrixTestCase', async ({ page }) => {
+ const h = new PradoTestHelper(page, GENERIC_BASE_URL);
+ await h.url(PAGE_URL);
+ await h.assertSourceContains('Safety Cover Matrix Test Case');
+
+ for (const row of MATRIX) {
+ const cid = `ctl0_Content_${row.id}`;
+
+ // Click the guard to open, then wait for the open class (the face animates).
+ await page.locator(`#${cid}_overlay`).click();
+ await expect(page.locator(`#${cid}_slider`)).toHaveClass(/safety-cover-open/, { timeout: 2000 });
+ await page.waitForTimeout(400); // let the slide transition settle
+
+ // The face is the animated layer; read its settled transform + opacity.
+ const { transform, opacity } = await page.locator(`#${cid}_face`).evaluate((el) => {
+ const cs = getComputedStyle(el);
+ return { transform: cs.transform, opacity: cs.opacity };
+ });
+ const t = translateOf(transform);
+
+ const moved = row.axis === 'x' ? t.x : t.y;
+ const still = row.axis === 'x' ? t.y : t.x;
+ // Moves the right way along the expected axis...
+ expect(Math.sign(moved), `${row.id}: expected ${row.axis} sign ${row.sign}, got translate x=${t.x} y=${t.y}`).toBe(row.sign);
+ expect(Math.abs(moved), `${row.id}: expected motion on ${row.axis}`).toBeGreaterThan(10);
+ // ...and does not drift on the other axis.
+ expect(Math.abs(still), `${row.id}: unexpected motion off-axis (x=${t.x} y=${t.y})`).toBeLessThan(1);
+ // Fade is independent: opacity 0 when on, 1 when off.
+ expect(opacity, `${row.id}: OverlayFade=${row.fade} opacity`).toBe(row.fade ? '0' : '1');
+ }
+
+ // Helper: open a control and return its face's settled computed style.
+ async function openAndRead(id) {
+ const cid = `ctl0_Content_${id}`;
+ await page.locator(`#${cid}_overlay`).click();
+ await expect(page.locator(`#${cid}_slider`)).toHaveClass(/safety-cover-open/, { timeout: 2000 });
+ await page.waitForTimeout(400);
+ return page.locator(`#${cid}_face`).evaluate((el) => {
+ const cs = getComputedStyle(el);
+ return { clipPath: cs.clipPath, visibility: cs.visibility, opacity: cs.opacity };
+ });
+ }
+
+ // Parse the four px insets out of `inset(t r b l)`.
+ const insetOf = (clip) => {
+ const m = /inset\(([^)]+)\)/.exec(clip);
+ if (!m) {
+ return null;
+ }
+ const p = m[1].split(/\s+/).map((n) => parseFloat(n));
+ return { top: p[0], right: p[1], bottom: p[2] ?? p[0], left: p[3] ?? (p[1] ?? p[0]) };
+ };
+
+ // ── Collapse clips toward the named edge (content held in place) ──
+ // Collapse Up clips 100% from the bottom (overlay collapses to the top edge).
+ const cu = insetOf((await openAndRead('collapse_up')).clipPath);
+ expect(cu, 'collapse_up should have an inset clip-path').not.toBeNull();
+ expect(cu.bottom, 'collapse_up: bottom inset should be the panel height').toBeGreaterThan(10);
+ expect(cu.top + cu.right + cu.left, 'collapse_up: other edges stay 0').toBeLessThan(1);
+ // Collapse Right clips 100% from the left.
+ const cr = insetOf((await openAndRead('collapse_right')).clipPath);
+ expect(cr.left, 'collapse_right: left inset should be the panel width').toBeGreaterThan(10);
+ expect(cr.top + cr.right + cr.bottom, 'collapse_right: other edges stay 0').toBeLessThan(1);
+
+ // ── None with no fade snaps hidden via visibility; None+fade stays visible
+ // and drives opacity to 0 ──
+ const np = await openAndRead('none_plain');
+ expect(np.visibility, 'none_plain: overlay snaps hidden').toBe('hidden');
+ const nf = await openAndRead('none_fade');
+ expect(nf.visibility, 'none_fade: overlay stays visible (fades)').toBe('visible');
+ expect(nf.opacity, 'none_fade: overlay fades to transparent').toBe('0');
+});
diff --git a/tests/playwright/web/TSafetyCoverTestCase.spec.js b/tests/playwright/web/TSafetyCoverTestCase.spec.js
new file mode 100644
index 000000000..5a6b1272f
--- /dev/null
+++ b/tests/playwright/web/TSafetyCoverTestCase.spec.js
@@ -0,0 +1,111 @@
+import { test, expect } from '@playwright/test';
+import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js';
+
+const PAGE_URL = 'web/index.php?page=SafetyCoverTest';
+
+// Read the two-layer state: the open flag, the guard's pointer-events (auto when
+// blocking, none when open), and the face's opacity (the animated visible skin).
+const state = (page, id) =>
+ page.evaluate((cid) => {
+ const slider = document.getElementById(`${cid}_slider`);
+ const guard = document.getElementById(`${cid}_overlay`);
+ const face = document.getElementById(`${cid}_face`);
+ return {
+ open: slider.classList.contains('safety-cover-open'),
+ guardPointerEvents: getComputedStyle(guard).pointerEvents,
+ faceOpacity: getComputedStyle(face).opacity,
+ };
+ }, id);
+
+// Wait until the cover fully guards: closed, the guard intercepts pointer events,
+// and the face is fully opaque (settled back over the content).
+async function expectGuarding(page, id, timeout = 4000) {
+ await expect
+ .poll(() => state(page, id), { timeout })
+ .toEqual({ open: false, guardPointerEvents: 'auto', faceOpacity: '1' });
+}
+
+// Wait until the cover is open: the guard lets clicks through to the content.
+async function expectOpen(page, id, timeout = 3000) {
+ await expect
+ .poll(() => state(page, id).then((s) => s.open && s.guardPointerEvents === 'none'), { timeout })
+ .toBe(true);
+}
+
+// The page has two independent controls, each demonstrating ONE close path with
+// a single open/close cycle, so neither ever reopens:
+// autoCover Collapse + Forward + OverlayFade, closes on the auto-timeout
+// mouseoutCover Slide + Up, closes when the pointer leaves the panel
+test('TSafetyCoverTestCase', async ({ page }) => {
+ const h = new PradoTestHelper(page, GENERIC_BASE_URL);
+ await h.url(PAGE_URL);
+ await h.assertSourceContains('Safety Cover Test Case');
+
+ const auto = 'ctl0_Content_autoCover';
+ const mouseout = 'ctl0_Content_mouseoutCover';
+
+ // ── Rendered effect/direction/fade classes on each control ──
+ await expect(page.locator(`#${auto}`)).toHaveClass(/safety-cover-collapse/);
+ await expect(page.locator(`#${auto}`)).toHaveClass(/safety-cover-right/); // Forward → Right in LTR
+ await expect(page.locator(`#${auto}`)).toHaveClass(/safety-cover-fade/);
+ await expect(page.locator(`#${mouseout}`)).toHaveClass(/safety-cover-slide/);
+ await expect(page.locator(`#${mouseout}`)).toHaveClass(/safety-cover-up/);
+ await expect(page.locator(`#${mouseout}`)).not.toHaveClass(/safety-cover-fade/);
+
+ // ── OpenDelay and AnimationDuration drive their custom properties ──
+ await expect(page.locator(`#${auto}`).evaluate((el) => el.style.getPropertyValue('--safety-cover-open-delay'))).resolves.toBe('200ms');
+ await expect(page.locator(`#${auto}`).evaluate((el) => el.style.getPropertyValue('--safety-cover-animation-duration'))).resolves.toBe('250ms');
+
+ // ── OverlayColor renders on the visible face, not the transparent guard ──
+ await expect(page.locator(`#${auto}_face`)).toHaveCSS('background-color', 'rgba(0, 0, 255, 0.5)');
+ await expect(page.locator(`#${auto}_overlay`)).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+
+ // ── The content isolates its stacking so a high-z-index descendant cannot
+ // paint above the cover (part of the CSS contract) ──
+ await expect(page.locator(`#${auto}_content`)).toHaveCSS('isolation', 'isolate');
+
+ // ── Both wrappers register under their ClientIDs ──
+ expect(await page.evaluate((id) => typeof Prado.Registry[id] === 'object', auto)).toBe(true);
+ expect(await page.evaluate((id) => typeof Prado.Registry[id] === 'object', mouseout)).toBe(true);
+
+ // ── auto-close control: open (face fades to transparent), content reachable,
+ // then it re-guards on its own after AutoCloseDelay ──
+ await expectGuarding(page, auto);
+ await page.locator(`#${auto}_overlay`).click();
+ await expectOpen(page, auto);
+ await expect(page.locator(`#${auto}_face`)).toHaveCSS('opacity', '0'); // OverlayFade
+ await page.locator('#autoButton').click();
+ await expect(page.locator('#autoResult')).toHaveText('clicked');
+ await expectGuarding(page, auto, 6000);
+
+ // ── mouse-out control: open, content reachable, then the pointer leaves and it
+ // closes. The payoff of the two-layer design: the guard blocks the content
+ // the INSTANT close begins and stays blocking through the whole face
+ // animation, so the button is never reachable while the cover returns. ──
+ await expectGuarding(page, mouseout);
+ await page.locator(`#${mouseout}_overlay`).click();
+ await expectOpen(page, mouseout);
+ await page.locator('#mouseoutButton').click();
+ await expect(page.locator('#mouseoutResult')).toHaveText('clicked');
+
+ await page.mouse.move(0, 0);
+ // Wait for close to begin (open class drops), then sample the topmost
+ // hit-testable element over the button through the face's return animation.
+ await expect(page.locator(`#${mouseout}_slider`)).not.toHaveClass(/safety-cover-open/, { timeout: 2000 });
+ const topmost = await page.evaluate(async (cid) => {
+ const btn = document.getElementById('mouseoutButton');
+ const r = btn.getBoundingClientRect();
+ const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
+ const seen = [];
+ for (let i = 0; i < 14; i++) {
+ const el = document.elementFromPoint(cx, cy);
+ seen.push(el ? el.id || el.className : 'null');
+ await new Promise((res) => setTimeout(res, 20));
+ }
+ return seen;
+ }, mouseout);
+ // The guarded button is never topmost while the cover is returning.
+ expect(topmost.includes('mouseoutButton'), `button exposed during close: ${topmost.join(', ')}`).toBe(false);
+
+ await expectGuarding(page, mouseout);
+});
diff --git a/tests/unit/Web/UI/WebControls/TSafetyCoverTest.php b/tests/unit/Web/UI/WebControls/TSafetyCoverTest.php
new file mode 100644
index 000000000..ad15c6274
--- /dev/null
+++ b/tests/unit/Web/UI/WebControls/TSafetyCoverTest.php
@@ -0,0 +1,639 @@
+getControls()->add('OVERLAY-CONTENT');
+ }
+
+ public function getIncludedFiles()
+ {
+ return [];
+ }
+}
+
+class TSafetyCoverTest extends TestCase
+{
+ use TWebControlRenderTrait;
+
+ private function newControl(): TSafetyCover
+ {
+ $control = new TSafetyCover();
+ $control->setID('guard');
+ return $control;
+ }
+
+ public function testExtendsPanel()
+ {
+ $this->assertInstanceOf(TPanel::class, new TSafetyCover());
+ }
+
+ // --- properties ---
+
+ public function testOverlayTemplateDefaultNull()
+ {
+ $this->assertNull($this->newControl()->getOverlayTemplate());
+ }
+
+ public function testSetOverlayTemplate()
+ {
+ $control = $this->newControl();
+ $template = new TSafetyCoverTestTemplate();
+ $control->setOverlayTemplate($template);
+ $this->assertSame($template, $control->getOverlayTemplate());
+ }
+
+ public function testOverlayColorDefaultEmpty()
+ {
+ $this->assertSame('', $this->newControl()->getOverlayColor());
+ }
+
+ public function testSetOverlayColor()
+ {
+ $control = $this->newControl();
+ $control->setOverlayColor('#c00');
+ $this->assertSame('#c00', $control->getOverlayColor());
+ }
+
+ public function testOverlayCssClassDefaultEmpty()
+ {
+ $this->assertSame('', $this->newControl()->getOverlayCssClass());
+ }
+
+ public function testSetOverlayCssClass()
+ {
+ $control = $this->newControl();
+ $control->setOverlayCssClass('danger-face');
+ $this->assertSame('danger-face', $control->getOverlayCssClass());
+ }
+
+ public function testFaceClassPlainWhenNoOverlayCssClass()
+ {
+ $output = $this->render($this->newControl());
+ $this->assertStringContainsString('id="guard_face" class="safety-cover-face"', $output);
+ }
+
+ public function testOverlayCssClassAddedToFaceClass()
+ {
+ $control = $this->newControl();
+ $control->setOverlayCssClass('danger-face fancy');
+ $output = $this->render($control);
+ $this->assertStringContainsString('id="guard_face" class="safety-cover-face danger-face fancy"', $output);
+ }
+
+ public function testOpenDelayDefault()
+ {
+ $this->assertSame(800, $this->newControl()->getOpenDelay());
+ }
+
+ public function testSetOpenDelay()
+ {
+ $control = $this->newControl();
+ $control->setOpenDelay('500');
+ $this->assertSame(500, $control->getOpenDelay());
+ }
+
+ public function testAutoCloseDelayDefault()
+ {
+ $this->assertSame(6000, $this->newControl()->getAutoCloseDelay());
+ }
+
+ public function testSetAutoCloseDelay()
+ {
+ $control = $this->newControl();
+ $control->setAutoCloseDelay('10000');
+ $this->assertSame(10000, $control->getAutoCloseDelay());
+ }
+
+ public function testKeepOpenWhileActiveDefaultFalse()
+ {
+ $this->assertFalse($this->newControl()->getKeepOpenWhileActive());
+ }
+
+ public function testSetKeepOpenWhileActive()
+ {
+ $control = $this->newControl();
+ $control->setKeepOpenWhileActive(true);
+ $this->assertTrue($control->getKeepOpenWhileActive());
+ }
+
+ public function testMouseOutTimeoutDefault()
+ {
+ $this->assertSame(1000, $this->newControl()->getMouseOutTimeout());
+ }
+
+ public function testSetMouseOutTimeout()
+ {
+ $control = $this->newControl();
+ $control->setMouseOutTimeout('2500');
+ $this->assertSame(2500, $control->getMouseOutTimeout());
+ }
+
+ public function testAnimationDurationDefault()
+ {
+ $this->assertSame(250, $this->newControl()->getAnimationDuration());
+ }
+
+ public function testSetAnimationDuration()
+ {
+ $control = $this->newControl();
+ $control->setAnimationDuration('400');
+ $this->assertSame(400, $control->getAnimationDuration());
+ }
+
+ public function testResetDelayDefaultZero()
+ {
+ $this->assertSame(0, $this->newControl()->getResetDelay());
+ }
+
+ public function testSetResetDelay()
+ {
+ $control = $this->newControl();
+ $control->setResetDelay('500');
+ $this->assertSame(500, $control->getResetDelay());
+ }
+
+ public function testCssUrlDefault()
+ {
+ $this->assertSame('default', $this->newControl()->getCssUrl());
+ }
+
+ public function testSetCssUrl()
+ {
+ $control = $this->newControl();
+ $control->setCssUrl('');
+ $this->assertSame('', $control->getCssUrl());
+ }
+
+ // --- rendering ---
+
+ public function testRendersStructure()
+ {
+ $output = $this->render($this->newControl());
+ $this->assertStringContainsString('id="guard"', $output);
+ $this->assertMatchesRegularExpression('/id="guard"[^>]*class="safety-cover /', $output);
+ $this->assertStringContainsString('id="guard_slider"', $output);
+ $this->assertStringContainsString('class="safety-cover-slider"', $output);
+ $this->assertStringContainsString('id="guard_overlay"', $output);
+ $this->assertStringContainsString('class="safety-cover-overlay"', $output);
+ $this->assertStringContainsString('id="guard_face"', $output);
+ $this->assertStringContainsString('class="safety-cover-face"', $output);
+ $this->assertStringContainsString('id="guard_content"', $output);
+ $this->assertStringContainsString('class="safety-cover-content"', $output);
+ }
+
+ public function testAnimationDurationRendersCustomProperty()
+ {
+ $control = $this->newControl();
+ $control->setAnimationDuration(400);
+ $output = $this->render($control);
+ $this->assertStringContainsString('--safety-cover-animation-duration:400ms', $output);
+ }
+
+ // --- accessibility ---
+
+ public function testAccessibleLabelDefaultEmpty()
+ {
+ $this->assertSame('', $this->newControl()->getAccessibleLabel());
+ }
+
+ public function testGuardLabelledByFaceWhenNoAccessibleLabel()
+ {
+ // Default: the guard takes its accessible name from the visible face, so
+ // the name matches the visible label (WCAG 2.5.3), not a fixed string.
+ $output = $this->render($this->newControl());
+ $this->assertMatchesRegularExpression('/id="guard_overlay"[^>]*aria-labelledby="guard_face"/', $output);
+ }
+
+ public function testAccessibleLabelOverridesWithAriaLabel()
+ {
+ $control = $this->newControl();
+ $control->setAccessibleLabel('Unlock delete');
+ $output = $this->render($control);
+ $this->assertMatchesRegularExpression('/id="guard_overlay"[^>]*aria-label="Unlock delete"/', $output);
+ $this->assertDoesNotMatchRegularExpression('/id="guard_overlay"[^>]*aria-labelledby=/', $output);
+ }
+
+ public function testSetAccessibleLabel()
+ {
+ $control = $this->newControl();
+ $control->setAccessibleLabel('Unlock delete');
+ $this->assertSame('Unlock delete', $control->getAccessibleLabel());
+ }
+
+ public function testGuardRendersAsAccessibleButton()
+ {
+ $output = $this->render($this->newControl());
+ $this->assertMatchesRegularExpression('/id="guard_overlay"[^>]*role="button"/', $output);
+ $this->assertMatchesRegularExpression('/id="guard_overlay"[^>]*tabindex="0"/', $output);
+ $this->assertMatchesRegularExpression('/id="guard_overlay"[^>]*aria-expanded="false"/', $output);
+ $this->assertMatchesRegularExpression('/id="guard_overlay"[^>]*aria-controls="guard_content"/', $output);
+ }
+
+ public function testAccessibleLabelRendersAsAriaLabel()
+ {
+ $control = $this->newControl();
+ $control->setAccessibleLabel('Unlock delete');
+ $output = $this->render($control);
+ $this->assertMatchesRegularExpression('/id="guard_overlay"[^>]*aria-label="Unlock delete"/', $output);
+ }
+
+ public function testAccessibleLabelIsHtmlEncoded()
+ {
+ $control = $this->newControl();
+ $control->setAccessibleLabel('a" onmouseover="alert(1)');
+ $output = $this->render($control);
+ $this->assertStringNotContainsString('onmouseover="alert(1)"', $output);
+ }
+
+ public function testCssClassMergedWithControlClass()
+ {
+ $control = $this->newControl();
+ $control->setCssClass('custom');
+ $output = $this->render($control);
+ // Framework classes lead; the author class follows.
+ $this->assertStringContainsString('class="safety-cover safety-cover-slide safety-cover-up custom"', $output);
+ }
+
+ public function testCssClassNotDuplicatedOnRepeatedRender()
+ {
+ $control = $this->newControl();
+ $this->render($control);
+ $output = $this->render($control);
+ // The exact root class proves slide is present once; safety-cover-up is a
+ // collision-free token (safety-cover-slide is a prefix of the slider class).
+ $this->assertStringContainsString('class="safety-cover safety-cover-slide safety-cover-up"', $output);
+ $this->assertSame(1, substr_count($output, 'safety-cover-up'));
+ }
+
+ public function testCssClassRefreshesWhenEffectChangesBetweenRenders()
+ {
+ $control = $this->newControl();
+ $this->render($control);
+ $control->setOverlayEffect(TSafetyCoverEffect::None);
+ $output = $this->render($control);
+ // The exact root class shows the stale slide/direction classes are stripped;
+ // safety-cover-up is checked directly since it collides with nothing.
+ $this->assertStringContainsString('class="safety-cover safety-cover-none"', $output);
+ $this->assertStringNotContainsString('safety-cover-up', $output);
+ }
+
+ public function testAuthorClassStartingWithSafetyCoverIsPreserved()
+ {
+ // An author theme class that happens to share the framework prefix must
+ // survive; only literal duplicates of the managed classes are dropped.
+ $control = $this->newControl();
+ $control->setCssClass('safety-cover-dark');
+ $output = $this->render($control);
+ $this->assertStringContainsString('class="safety-cover safety-cover-slide safety-cover-up safety-cover-dark"', $output);
+ }
+
+ public function testCssClassViewStateNotMutatedByRender()
+ {
+ // The framework classes are composed only for output; getCssClass() keeps
+ // exactly what the author set, before and after rendering.
+ $control = $this->newControl();
+ $control->setCssClass('custom');
+ $this->render($control);
+ $this->assertSame('custom', $control->getCssClass());
+
+ $blank = $this->newControl();
+ $this->render($blank);
+ $this->assertSame('', $blank->getCssClass());
+ }
+
+ // --- OverlayColor rendering ---
+
+ public function testOverlayColorNotRenderedByDefault()
+ {
+ $output = $this->render($this->newControl());
+ $this->assertStringNotContainsString('background-color', $output);
+ }
+
+ public function testOverlayColorRendersInlineStyleOnFace()
+ {
+ // The color renders on the visible face, not the transparent guard.
+ $control = $this->newControl();
+ $control->setOverlayColor('#c00');
+ $output = $this->render($control);
+ $this->assertMatchesRegularExpression(
+ '/id="guard_face"[^>]*background-color:#c00/',
+ $output
+ );
+ }
+
+ public function testOverlayColorAcceptsRgba()
+ {
+ $control = $this->newControl();
+ $control->setOverlayColor('rgba(0,0,255,0.5)');
+ $output = $this->render($control);
+ $this->assertStringContainsString('background-color:rgba(0,0,255,0.5)', $output);
+ }
+
+ public function testOverlayColorIsHtmlEncoded()
+ {
+ $control = $this->newControl();
+ $control->setOverlayColor('red" onmouseover="alert(1)');
+ $output = $this->render($control);
+ $this->assertStringNotContainsString('onmouseover="alert(1)"', $output);
+ }
+
+ public function testOverlayColorStripsCssInjection()
+ {
+ // A value carrying extra declarations must not inject them into the
+ // inline style; the ';' and following declaration are stripped.
+ $control = $this->newControl();
+ $control->setOverlayColor('red; position: fixed; inset: 0');
+ $output = $this->render($control);
+ $this->assertStringNotContainsString(';', substr($output, strpos($output, 'background-color'), 40));
+ $this->assertStringNotContainsString('position: fixed', $output);
+ $this->assertStringNotContainsString('position:fixed', $output);
+ }
+
+ public function testOverlayColorKeepsModernSyntax()
+ {
+ // The sanitizer must not mangle valid color syntaxes (slash, percent, var).
+ $control = $this->newControl();
+ $control->setOverlayColor('rgb(0 128 255 / 50%)');
+ $output = $this->render($control);
+ $this->assertStringContainsString('background-color:rgb(0 128 255 / 50%)', $output);
+ }
+
+ // --- content placement ---
+
+ public function testBodyContentRendersInContentElement()
+ {
+ $control = $this->newControl();
+ $label = new TLabel();
+ $label->setText('BODY-CONTENT');
+ $control->getControls()->add($label);
+ $output = $this->render($control);
+ $contentPos = strpos($output, 'id="guard_content"');
+ $bodyPos = strpos($output, 'BODY-CONTENT');
+ $this->assertNotFalse($contentPos);
+ $this->assertNotFalse($bodyPos);
+ $this->assertGreaterThan($contentPos, $bodyPos);
+ }
+
+ public function testOverlayTemplateRendersInFaceElement()
+ {
+ // The template renders inside the visible face, between the face's open tag
+ // and the content element.
+ $control = $this->newControl();
+ $control->setOverlayTemplate(new TSafetyCoverTestTemplate());
+ $output = $this->render($control);
+ $facePos = strpos($output, 'id="guard_face"');
+ $templatePos = strpos($output, 'OVERLAY-CONTENT');
+ $contentPos = strpos($output, 'id="guard_content"');
+ $this->assertNotFalse($templatePos);
+ $this->assertGreaterThan($facePos, $templatePos);
+ $this->assertLessThan($contentPos, $templatePos);
+ }
+
+ public function testOverlayTemplateNotRenderedInBodyContent()
+ {
+ $control = $this->newControl();
+ $control->setOverlayTemplate(new TSafetyCoverTestTemplate());
+ $output = $this->render($control);
+ $this->assertSame(1, substr_count($output, 'OVERLAY-CONTENT'));
+ }
+
+ public function testOnInitInstantiatesTemplate()
+ {
+ $control = $this->newControl();
+ $control->setOverlayTemplate(new TSafetyCoverTestTemplate());
+ $control->onInit(null);
+ $this->assertTrue($control->getHasControls());
+ }
+
+ public function testSetOverlayTemplateAfterInitReplacesOverlay()
+ {
+ $control = $this->newControl();
+ $control->setOverlayTemplate(new TSafetyCoverTestTemplate());
+ $control->onInit(null);
+ $control->setOverlayTemplate(new TSafetyCoverTestTemplate());
+ $output = $this->render($control);
+ $this->assertSame(1, substr_count($output, 'OVERLAY-CONTENT'));
+ }
+
+ public function testSetOverlayTemplateNullAfterInitRemovesOverlay()
+ {
+ $control = $this->newControl();
+ $control->setOverlayTemplate(new TSafetyCoverTestTemplate());
+ $control->onInit(null);
+ $control->setOverlayTemplate(null);
+ $output = $this->render($control);
+ $this->assertStringNotContainsString('OVERLAY-CONTENT', $output);
+ }
+
+ // --- open effect and direction ---
+
+ public function testOverlayEffectDefaultSlide()
+ {
+ $this->assertSame(TSafetyCoverEffect::Slide, $this->newControl()->getOverlayEffect());
+ }
+
+ public function testSetOverlayEffect()
+ {
+ $control = $this->newControl();
+ $control->setOverlayEffect(TSafetyCoverEffect::Collapse);
+ $this->assertSame(TSafetyCoverEffect::Collapse, $control->getOverlayEffect());
+ }
+
+ public function testSetOverlayEffectInvalidThrows()
+ {
+ $this->expectException(\Prado\Exceptions\TInvalidDataValueException::class);
+ $this->newControl()->setOverlayEffect('Dissolve');
+ }
+
+ public function testOverlayDirectionDefaultUp()
+ {
+ $this->assertSame(TSafetyCoverDirection::Up, $this->newControl()->getOverlayDirection());
+ }
+
+ public function testSetOverlayDirection()
+ {
+ $control = $this->newControl();
+ $control->setOverlayDirection(TSafetyCoverDirection::Down);
+ $this->assertSame(TSafetyCoverDirection::Down, $control->getOverlayDirection());
+ }
+
+ public function testSetOverlayDirectionInvalidThrows()
+ {
+ $this->expectException(\Prado\Exceptions\TInvalidDataValueException::class);
+ $this->newControl()->setOverlayDirection('Sideways');
+ }
+
+ public function testDefaultRendersSlideUpClasses()
+ {
+ $output = $this->render($this->newControl());
+ $this->assertStringContainsString('class="safety-cover safety-cover-slide safety-cover-up"', $output);
+ }
+
+ public function testCollapseDownRendersClasses()
+ {
+ $control = $this->newControl();
+ $control->setOverlayEffect(TSafetyCoverEffect::Collapse);
+ $control->setOverlayDirection(TSafetyCoverDirection::Down);
+ $output = $this->render($control);
+ $this->assertStringContainsString('class="safety-cover safety-cover-collapse safety-cover-down"', $output);
+ }
+
+ public function testNoneOmitsDirectionClass()
+ {
+ $control = $this->newControl();
+ $control->setOverlayEffect(TSafetyCoverEffect::None);
+ $output = $this->render($control);
+ $this->assertStringContainsString('class="safety-cover safety-cover-none"', $output);
+ $this->assertStringNotContainsString('safety-cover-up', $output);
+ }
+
+ // --- open fade (independent axis) ---
+
+ public function testOverlayFadeDefaultFalse()
+ {
+ $this->assertFalse($this->newControl()->getOverlayFade());
+ }
+
+ public function testSetOverlayFade()
+ {
+ $control = $this->newControl();
+ $control->setOverlayFade(true);
+ $this->assertTrue($control->getOverlayFade());
+ }
+
+ public function testFadeClassAbsentByDefault()
+ {
+ $output = $this->render($this->newControl());
+ $this->assertStringNotContainsString('safety-cover-fade', $output);
+ }
+
+ public function testFadeCombinesWithSlideDirection()
+ {
+ $control = $this->newControl();
+ $control->setOverlayFade(true);
+ $output = $this->render($control);
+ // Fade layers on the default slide/up geometry.
+ $this->assertStringContainsString('class="safety-cover safety-cover-slide safety-cover-up safety-cover-fade"', $output);
+ }
+
+ public function testFadeCombinesWithCollapse()
+ {
+ $control = $this->newControl();
+ $control->setOverlayEffect(TSafetyCoverEffect::Collapse);
+ $control->setOverlayDirection(TSafetyCoverDirection::Left);
+ $control->setOverlayFade(true);
+ $output = $this->render($control);
+ $this->assertStringContainsString('class="safety-cover safety-cover-collapse safety-cover-left safety-cover-fade"', $output);
+ }
+
+ public function testNoneWithFadeIsPureFade()
+ {
+ $control = $this->newControl();
+ $control->setOverlayEffect(TSafetyCoverEffect::None);
+ $control->setOverlayFade(true);
+ $output = $this->render($control);
+ // No geometry class, no direction class, just none + fade.
+ $this->assertStringContainsString('class="safety-cover safety-cover-none safety-cover-fade"', $output);
+ }
+
+ /**
+ * The full direction matrix: every direction, both content-direction settings
+ * for the logical values, and both fade states, asserting the exact rendered
+ * root class. Physical directions ignore content direction; the logical
+ * `Forward`/`Backward` resolve to `right`/`left` and flip under RightToLeft.
+ *
+ * @dataProvider directionMatrixProvider
+ */
+ public function testDirectionMatrixRendersExpectedClass($direction, $contentDirection, $fade, $expected)
+ {
+ $control = $this->newControl();
+ $control->setOverlayEffect(TSafetyCoverEffect::Slide);
+ $control->setOverlayDirection($direction);
+ if ($contentDirection !== null) {
+ $control->setDirection($contentDirection);
+ }
+ $control->setOverlayFade($fade);
+ $output = $this->render($control);
+ $this->assertStringContainsString('class="' . $expected . '"', $output);
+ }
+
+ public static function directionMatrixProvider(): array
+ {
+ $base = 'safety-cover safety-cover-slide safety-cover-';
+ $rows = [];
+ // Physical directions: content direction is irrelevant, tested with default.
+ foreach (['Up' => 'up', 'Down' => 'down', 'Left' => 'left', 'Right' => 'right'] as $dir => $cls) {
+ $rows["$dir fade off"] = [constant(TSafetyCoverDirection::class . "::$dir"), null, false, $base . $cls];
+ $rows["$dir fade on"] = [constant(TSafetyCoverDirection::class . "::$dir"), null, true, $base . $cls . ' safety-cover-fade'];
+ }
+ // Logical directions resolve through content direction (2x2).
+ $logical = [
+ ['Forward', TContentDirection::LeftToRight, 'right'],
+ ['Backward', TContentDirection::LeftToRight, 'left'],
+ ['Forward', TContentDirection::RightToLeft, 'left'],
+ ['Backward', TContentDirection::RightToLeft, 'right'],
+ ];
+ foreach ($logical as [$dir, $content, $cls]) {
+ $hand = $content === TContentDirection::RightToLeft ? 'rtl' : 'ltr';
+ $rows["$dir $hand fade off"] = [constant(TSafetyCoverDirection::class . "::$dir"), $content, false, $base . $cls];
+ $rows["$dir $hand fade on"] = [constant(TSafetyCoverDirection::class . "::$dir"), $content, true, $base . $cls . ' safety-cover-fade'];
+ }
+ return $rows;
+ }
+
+ // --- pulse duration ---
+
+ public function testPulseDurationVariableDefaultsToOpenDelay()
+ {
+ $output = $this->render($this->newControl());
+ $this->assertStringContainsString('--safety-cover-open-delay:800ms', $output);
+ }
+
+ public function testPulseDurationVariableFollowsOpenDelay()
+ {
+ $control = $this->newControl();
+ $control->setOpenDelay(500);
+ $output = $this->render($control);
+ $this->assertStringContainsString('--safety-cover-open-delay:500ms', $output);
+ }
+
+ // --- client options ---
+
+ public function testClientOptions()
+ {
+ $control = $this->newControl();
+ $control->setOpenDelay(400);
+ $control->setAutoCloseDelay(9000);
+ $control->setMouseOutTimeout(1500);
+ $control->setAnimationDuration(300);
+ $control->setResetDelay(700);
+ $control->setKeepOpenWhileActive(true);
+ $options = PradoUnit::invoke($control, 'getClientOptions');
+ $this->assertSame('guard', $options['ID']);
+ $this->assertSame(400, $options['OpenDelay']);
+ $this->assertSame(9000, $options['AutoCloseDelay']);
+ $this->assertSame(1500, $options['MouseOutTimeout']);
+ $this->assertSame(300, $options['AnimationDuration']);
+ $this->assertSame(700, $options['ResetDelay']);
+ $this->assertTrue($options['KeepOpenWhileActive']);
+ }
+
+ public function testClientClassName()
+ {
+ $this->assertSame('Prado.WebUI.TSafetyCover', PradoUnit::invoke($this->newControl(), 'getClientClassName'));
+ }
+}