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')); + } +}