ngwr 11.0.0 → 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action-sheet/styles/_index.scss +4 -1
- package/anchor/styles/_index.scss +2 -1
- package/calendar/styles/_index.scss +11 -2
- package/cascader/styles/_index.scss +3 -1
- package/circular-text/styles/_index.scss +11 -0
- package/command-palette/styles/_index.scss +14 -1
- package/context-menu/styles/_index.scss +2 -1
- package/falling-text/styles/_index.scss +2 -1
- package/fesm2022/ngwr-action-sheet-testing.mjs +313 -0
- package/fesm2022/ngwr-action-sheet-testing.mjs.map +1 -0
- package/fesm2022/ngwr-bar-chart-testing.mjs +89 -0
- package/fesm2022/ngwr-bar-chart-testing.mjs.map +1 -0
- package/fesm2022/ngwr-blur-text-testing.mjs +203 -0
- package/fesm2022/ngwr-blur-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-blur-text.mjs +15 -1
- package/fesm2022/ngwr-blur-text.mjs.map +1 -1
- package/fesm2022/ngwr-border-glow-testing.mjs +325 -0
- package/fesm2022/ngwr-border-glow-testing.mjs.map +1 -0
- package/fesm2022/ngwr-border-glow.mjs +2 -2
- package/fesm2022/ngwr-breadcrumbs.mjs +2 -2
- package/fesm2022/ngwr-calendar-heatmap-testing.mjs +95 -0
- package/fesm2022/ngwr-calendar-heatmap-testing.mjs.map +1 -0
- package/fesm2022/ngwr-calendar-testing.mjs +368 -0
- package/fesm2022/ngwr-calendar-testing.mjs.map +1 -0
- package/fesm2022/ngwr-calendar.mjs +43 -3
- package/fesm2022/ngwr-calendar.mjs.map +1 -1
- package/fesm2022/ngwr-card.mjs +2 -2
- package/fesm2022/ngwr-cascader.mjs +14 -2
- package/fesm2022/ngwr-cascader.mjs.map +1 -1
- package/fesm2022/ngwr-checkbox.mjs +8 -0
- package/fesm2022/ngwr-checkbox.mjs.map +1 -1
- package/fesm2022/ngwr-circular-text-testing.mjs +230 -0
- package/fesm2022/ngwr-circular-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-circular-text.mjs +2 -2
- package/fesm2022/ngwr-circular-text.mjs.map +1 -1
- package/fesm2022/ngwr-color-picker-testing.mjs +432 -0
- package/fesm2022/ngwr-color-picker-testing.mjs.map +1 -0
- package/fesm2022/ngwr-color-picker.mjs +29 -3
- package/fesm2022/ngwr-color-picker.mjs.map +1 -1
- package/fesm2022/ngwr-context-menu.mjs +25 -5
- package/fesm2022/ngwr-context-menu.mjs.map +1 -1
- package/fesm2022/ngwr-date-picker-testing.mjs +10 -79
- package/fesm2022/ngwr-date-picker-testing.mjs.map +1 -1
- package/fesm2022/ngwr-decrypt-text-testing.mjs +261 -0
- package/fesm2022/ngwr-decrypt-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-decrypt-text.mjs +6 -1
- package/fesm2022/ngwr-decrypt-text.mjs.map +1 -1
- package/fesm2022/ngwr-donut-chart-testing.mjs +93 -0
- package/fesm2022/ngwr-donut-chart-testing.mjs.map +1 -0
- package/fesm2022/ngwr-drag-drop.mjs +2 -2
- package/fesm2022/ngwr-drawer.mjs +36 -7
- package/fesm2022/ngwr-drawer.mjs.map +1 -1
- package/fesm2022/ngwr-dropdown.mjs +32 -2
- package/fesm2022/ngwr-dropdown.mjs.map +1 -1
- package/fesm2022/ngwr-event-calendar-testing.mjs +446 -0
- package/fesm2022/ngwr-event-calendar-testing.mjs.map +1 -0
- package/fesm2022/ngwr-event-calendar.mjs +43 -18
- package/fesm2022/ngwr-event-calendar.mjs.map +1 -1
- package/fesm2022/ngwr-falling-text-testing.mjs +218 -0
- package/fesm2022/ngwr-falling-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-falling-text.mjs +2 -2
- package/fesm2022/ngwr-falling-text.mjs.map +1 -1
- package/fesm2022/ngwr-fuzzy-text-testing.mjs +134 -0
- package/fesm2022/ngwr-fuzzy-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-gauge-testing.mjs +86 -0
- package/fesm2022/ngwr-gauge-testing.mjs.map +1 -0
- package/fesm2022/ngwr-glitch-text-testing.mjs +181 -0
- package/fesm2022/ngwr-glitch-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-glitch-text.mjs +2 -2
- package/fesm2022/ngwr-gradient-text-testing.mjs +223 -0
- package/fesm2022/ngwr-gradient-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-gradient-text.mjs +2 -2
- package/fesm2022/ngwr-i18n-en.mjs +14 -0
- package/fesm2022/ngwr-i18n-en.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-ru.mjs +14 -0
- package/fesm2022/ngwr-i18n-ru.mjs.map +1 -1
- package/fesm2022/ngwr-image-cropper-testing.mjs +156 -0
- package/fesm2022/ngwr-image-cropper-testing.mjs.map +1 -0
- package/fesm2022/ngwr-image-cropper.mjs +10 -3
- package/fesm2022/ngwr-image-cropper.mjs.map +1 -1
- package/fesm2022/ngwr-input-number.mjs +73 -6
- package/fesm2022/ngwr-input-number.mjs.map +1 -1
- package/fesm2022/ngwr-knob-testing.mjs +220 -0
- package/fesm2022/ngwr-knob-testing.mjs.map +1 -0
- package/fesm2022/ngwr-lightbox-testing.mjs +223 -0
- package/fesm2022/ngwr-lightbox-testing.mjs.map +1 -0
- package/fesm2022/ngwr-lightbox.mjs +9 -2
- package/fesm2022/ngwr-lightbox.mjs.map +1 -1
- package/fesm2022/ngwr-line-chart-testing.mjs +119 -0
- package/fesm2022/ngwr-line-chart-testing.mjs.map +1 -0
- package/fesm2022/ngwr-list.mjs +2 -2
- package/fesm2022/ngwr-loading-bar.mjs +2 -2
- package/fesm2022/ngwr-marquee-testing.mjs +336 -0
- package/fesm2022/ngwr-marquee-testing.mjs.map +1 -0
- package/fesm2022/ngwr-marquee.mjs +2 -2
- package/fesm2022/ngwr-meter-group-testing.mjs +80 -0
- package/fesm2022/ngwr-meter-group-testing.mjs.map +1 -0
- package/fesm2022/ngwr-overlay.mjs +28 -1
- package/fesm2022/ngwr-overlay.mjs.map +1 -1
- package/fesm2022/ngwr-popover.mjs +14 -0
- package/fesm2022/ngwr-popover.mjs.map +1 -1
- package/fesm2022/ngwr-rotating-text-testing.mjs +220 -0
- package/fesm2022/ngwr-rotating-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-rotating-text.mjs +2 -2
- package/fesm2022/ngwr-rotating-text.mjs.map +1 -1
- package/fesm2022/ngwr-select.mjs +70 -11
- package/fesm2022/ngwr-select.mjs.map +1 -1
- package/fesm2022/ngwr-shiny-text-testing.mjs +237 -0
- package/fesm2022/ngwr-shiny-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-shiny-text.mjs +2 -2
- package/fesm2022/ngwr-slider.mjs +44 -6
- package/fesm2022/ngwr-slider.mjs.map +1 -1
- package/fesm2022/ngwr-sparkline-testing.mjs +76 -0
- package/fesm2022/ngwr-sparkline-testing.mjs.map +1 -0
- package/fesm2022/ngwr-speed-dial-testing.mjs +279 -0
- package/fesm2022/ngwr-speed-dial-testing.mjs.map +1 -0
- package/fesm2022/ngwr-splash-cursor-testing.mjs +89 -0
- package/fesm2022/ngwr-splash-cursor-testing.mjs.map +1 -0
- package/fesm2022/ngwr-split-text-testing.mjs +224 -0
- package/fesm2022/ngwr-split-text-testing.mjs.map +1 -0
- package/fesm2022/ngwr-split-text.mjs +15 -1
- package/fesm2022/ngwr-split-text.mjs.map +1 -1
- package/fesm2022/ngwr-splitter-testing.mjs +245 -0
- package/fesm2022/ngwr-splitter-testing.mjs.map +1 -0
- package/fesm2022/ngwr-spotlight-card-testing.mjs +368 -0
- package/fesm2022/ngwr-spotlight-card-testing.mjs.map +1 -0
- package/fesm2022/ngwr-spotlight-card.mjs +2 -2
- package/fesm2022/ngwr-star-border-testing.mjs +172 -0
- package/fesm2022/ngwr-star-border-testing.mjs.map +1 -0
- package/fesm2022/ngwr-star-border.mjs +2 -2
- package/fesm2022/ngwr-table.mjs +13 -3
- package/fesm2022/ngwr-table.mjs.map +1 -1
- package/fesm2022/ngwr-tabs.mjs +15 -1
- package/fesm2022/ngwr-tabs.mjs.map +1 -1
- package/fesm2022/ngwr-tilt-card-testing.mjs +190 -0
- package/fesm2022/ngwr-tilt-card-testing.mjs.map +1 -0
- package/fesm2022/ngwr-tilt-card.mjs +21 -3
- package/fesm2022/ngwr-tilt-card.mjs.map +1 -1
- package/fesm2022/ngwr-toast.mjs +36 -7
- package/fesm2022/ngwr-toast.mjs.map +1 -1
- package/fesm2022/ngwr-tour-testing.mjs +173 -0
- package/fesm2022/ngwr-tour-testing.mjs.map +1 -0
- package/fesm2022/ngwr-tour.mjs +2 -2
- package/fesm2022/ngwr-tour.mjs.map +1 -1
- package/fesm2022/ngwr-tree.mjs +10 -3
- package/fesm2022/ngwr-tree.mjs.map +1 -1
- package/fesm2022/ngwr-typewriter-testing.mjs +228 -0
- package/fesm2022/ngwr-typewriter-testing.mjs.map +1 -0
- package/fesm2022/ngwr-typewriter.mjs +2 -2
- package/fesm2022/ngwr-version.mjs +1 -1
- package/fesm2022/ngwr-version.mjs.map +1 -1
- package/fesm2022/ngwr-virtual-scroll.mjs +2 -2
- package/fesm2022/ngwr-waves-testing.mjs +128 -0
- package/fesm2022/ngwr-waves-testing.mjs.map +1 -0
- package/fesm2022/ngwr-waves.mjs +2 -2
- package/fesm2022/ngwr-window-testing.mjs +361 -0
- package/fesm2022/ngwr-window-testing.mjs.map +1 -0
- package/fesm2022/ngwr-window.mjs +84 -10
- package/fesm2022/ngwr-window.mjs.map +1 -1
- package/file-upload/styles/_index.scss +7 -2
- package/i18n/en.json +16 -2
- package/i18n/ru.json +16 -2
- package/llms-full.txt +147 -0
- package/llms.txt +27 -8
- package/mcp/providers.js +29 -0
- package/mcp/tools.js +1 -29
- package/package.json +145 -1
- package/result/styles/_index.scss +3 -1
- package/segmented/styles/_index.scss +5 -1
- package/select/styles/_index.scss +3 -1
- package/skills/ngwr/SKILL.md +115 -0
- package/skills/ngwr/references/catalog.md +212 -0
- package/skills/ngwr/references/setup.md +73 -0
- package/stepper/styles/_index.scss +18 -2
- package/table/styles/_index.scss +3 -1
- package/theme/styles/_colors.scss +15 -2
- package/theme/styles/_dark.scss +24 -12
- package/tree/styles/_index.scss +4 -2
- package/types/ngwr-action-sheet-testing.d.ts +260 -0
- package/types/ngwr-bar-chart-testing.d.ts +77 -0
- package/types/ngwr-blur-text-testing.d.ts +180 -0
- package/types/ngwr-blur-text.d.ts +12 -0
- package/types/ngwr-border-glow-testing.d.ts +219 -0
- package/types/ngwr-calendar-heatmap-testing.d.ts +79 -0
- package/types/ngwr-calendar-testing.d.ts +277 -0
- package/types/ngwr-calendar.d.ts +10 -0
- package/types/ngwr-checkbox.d.ts +13 -0
- package/types/ngwr-circular-text-testing.d.ts +196 -0
- package/types/ngwr-color-picker-testing.d.ts +291 -0
- package/types/ngwr-color-picker.d.ts +13 -1
- package/types/ngwr-context-menu.d.ts +15 -1
- package/types/ngwr-date-picker-testing.d.ts +6 -66
- package/types/ngwr-decrypt-text-testing.d.ts +222 -0
- package/types/ngwr-donut-chart-testing.d.ts +79 -0
- package/types/ngwr-drawer.d.ts +12 -0
- package/types/ngwr-dropdown.d.ts +9 -0
- package/types/ngwr-event-calendar-testing.d.ts +301 -0
- package/types/ngwr-event-calendar.d.ts +9 -6
- package/types/ngwr-falling-text-testing.d.ts +186 -0
- package/types/ngwr-fuzzy-text-testing.d.ts +130 -0
- package/types/ngwr-gauge-testing.d.ts +71 -0
- package/types/ngwr-glitch-text-testing.d.ts +172 -0
- package/types/ngwr-gradient-text-testing.d.ts +173 -0
- package/types/ngwr-image-cropper-testing.d.ts +134 -0
- package/types/ngwr-image-cropper.d.ts +1 -0
- package/types/ngwr-input-number.d.ts +33 -1
- package/types/ngwr-knob-testing.d.ts +150 -0
- package/types/ngwr-lightbox-testing.d.ts +154 -0
- package/types/ngwr-lightbox.d.ts +6 -0
- package/types/ngwr-line-chart-testing.d.ts +93 -0
- package/types/ngwr-marquee-testing.d.ts +280 -0
- package/types/ngwr-meter-group-testing.d.ts +67 -0
- package/types/ngwr-overlay.d.ts +19 -1
- package/types/ngwr-rotating-text-testing.d.ts +191 -0
- package/types/ngwr-select.d.ts +32 -0
- package/types/ngwr-shiny-text-testing.d.ts +165 -0
- package/types/ngwr-slider.d.ts +15 -0
- package/types/ngwr-sparkline-testing.d.ts +69 -0
- package/types/ngwr-speed-dial-testing.d.ts +195 -0
- package/types/ngwr-splash-cursor-testing.d.ts +95 -0
- package/types/ngwr-split-text-testing.d.ts +209 -0
- package/types/ngwr-split-text.d.ts +12 -0
- package/types/ngwr-splitter-testing.d.ts +174 -0
- package/types/ngwr-spotlight-card-testing.d.ts +303 -0
- package/types/ngwr-star-border-testing.d.ts +135 -0
- package/types/ngwr-table.d.ts +10 -1
- package/types/ngwr-tilt-card-testing.d.ts +158 -0
- package/types/ngwr-tilt-card.d.ts +2 -0
- package/types/ngwr-toast.d.ts +14 -5
- package/types/ngwr-tour-testing.d.ts +143 -0
- package/types/ngwr-tree.d.ts +7 -0
- package/types/ngwr-typewriter-testing.d.ts +201 -0
- package/types/ngwr-version.d.ts +1 -1
- package/types/ngwr-waves-testing.d.ts +121 -0
- package/types/ngwr-window-testing.d.ts +263 -0
- package/types/ngwr-window.d.ts +27 -10
- package/window/styles/_index.scss +44 -16
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
*
|
|
6
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
7
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
8
|
+
*/
|
|
9
|
+
/** The animated pieces — one per character or per word, whitespace excluded. */
|
|
10
|
+
const PIECE = '.wr-split-text__piece';
|
|
11
|
+
/** The whitespace runs between them, rendered but never staggered. */
|
|
12
|
+
const SPACE = '.wr-split-text__space';
|
|
13
|
+
/** The readable copy of the string, carried once and hidden with CSS. */
|
|
14
|
+
const SR_ONLY = '.wr-split-text__sr-only';
|
|
15
|
+
/**
|
|
16
|
+
* An inline `opacity` declaration — written on every piece the tween touches.
|
|
17
|
+
*
|
|
18
|
+
* `motionToStyle` always produces an opacity, whether or not `from` declared one, and
|
|
19
|
+
* the same property is re-committed when the tween finishes. Matched off the `style`
|
|
20
|
+
* ATTRIBUTE, never through `getCssValue`: a computed style answers `1` for a piece
|
|
21
|
+
* nothing was ever written to, which is the exact case this has to distinguish.
|
|
22
|
+
*/
|
|
23
|
+
const INLINE_OPACITY = /(?:^|;)\s*opacity\s*:/;
|
|
24
|
+
/** The host's own alignment declaration, as the host binding wrote it. */
|
|
25
|
+
const INLINE_TEXT_ALIGN = /(?:^|;)\s*text-align\s*:\s*([^;]+)/;
|
|
26
|
+
/** Exactly what the element holds — untrimmed, unlike `TestElement.text()`. */
|
|
27
|
+
async function rawText(element) {
|
|
28
|
+
return (await element.getProperty('textContent')) ?? '';
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Test harness for `<wr-split-text>`.
|
|
32
|
+
*
|
|
33
|
+
* **The tween is unreadable from a test, and the absent methods are absent for that
|
|
34
|
+
* reason.** Each piece is animated from a `from` motion state to a `to` one through the
|
|
35
|
+
* Web Animations API, started by an `IntersectionObserver`. A unit test has no layout,
|
|
36
|
+
* no compositor and no WAAPI, so there is no `getOpacity()` and no `getTransform()`:
|
|
37
|
+
* both would report the frozen staged value in jsdom and a value the compositor is not
|
|
38
|
+
* painting in a browser. There is no `getFromState()` / `getToState()` either — those
|
|
39
|
+
* objects only reach the DOM as staged inline styles, on the one path that throws here,
|
|
40
|
+
* so reporting them would mean re-deriving an input from a string the component may
|
|
41
|
+
* never have written. No `isAnimating()`, no `whenAnimationComplete()`: nothing in the
|
|
42
|
+
* document says a tween is running, and completion is the `animationComplete` OUTPUT,
|
|
43
|
+
* which the host binds. No `enterViewport()` / `trigger()`, because an observer is not
|
|
44
|
+
* reachable from a `TestElement` — and observation here is additionally gated behind
|
|
45
|
+
* `document.fonts.ready`, which jsdom does not have, so a `waitForFonts()` would resolve
|
|
46
|
+
* instantly and prove nothing about the gating it claimed to test. Nothing measures
|
|
47
|
+
* either: `getLineCount()` / `didItWrap()` would need layout (and `lines` is not a split
|
|
48
|
+
* this port supports), and the `overflow: hidden` mask with its descender compensation
|
|
49
|
+
* is a stylesheet, which a test runs none of.
|
|
50
|
+
*
|
|
51
|
+
* **What is real is the split, the accessible layer and the alignment.** The string is
|
|
52
|
+
* carried once in a visually-hidden span and every drawn piece is `aria-hidden`, so
|
|
53
|
+
* {@link getAccessibleText} is what is heard while {@link getPieces} is what is drawn —
|
|
54
|
+
* two questions, and a spec asserting only the second would pass on a component that
|
|
55
|
+
* spells the text out letter by letter. {@link getRenderedText} closes the loop by
|
|
56
|
+
* putting the pieces and the spaces back together: the split has to be lossless, and
|
|
57
|
+
* that comparison is what catches a dropped space or a halved surrogate pair. Whether
|
|
58
|
+
* the hidden copy is genuinely off-screen is CSS, so nothing here claims to know.
|
|
59
|
+
*
|
|
60
|
+
* **{@link getTextAlign} is the one input this component reflects**, written straight
|
|
61
|
+
* onto the host as an inline style — an honest read with no layout involved, unlike
|
|
62
|
+
* anything else about how the text is arranged. And {@link hasStagedMotion} is the one
|
|
63
|
+
* fact about the tween worth pinning: that the pieces were taken over, not that anything
|
|
64
|
+
* is moving.
|
|
65
|
+
*
|
|
66
|
+
* Driving the reveal is the spec's job — jsdom supplies neither half of it, and the font
|
|
67
|
+
* gate defers observation by a microtask:
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* // Before TestBed.createComponent — keep the callbacks so the spec can fire them.
|
|
71
|
+
* vi.stubGlobal('IntersectionObserver', StubObserver);
|
|
72
|
+
* // Otherwise `el.animate(...)` throws mid-stagger and leaves the DOM half-staged.
|
|
73
|
+
* Reflect.set(Element.prototype, 'animate', () => ({ cancel: () => undefined, onfinish: null }));
|
|
74
|
+
* // …and always settle before tearing down: observation is queued off a promise, and
|
|
75
|
+
* // landing after destroy() registers a teardown on a dead DestroyRef (NG0911).
|
|
76
|
+
* await fixture.whenStable();
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* const hero = await loader.getHarness(WrSplitTextHarness.with({ textAlign: 'center' }));
|
|
82
|
+
*
|
|
83
|
+
* expect(await hero.getPieces()).toEqual(['H', 'e', 'l', 'l', 'o']);
|
|
84
|
+
* expect(await hero.getRenderedText()).toBe(await hero.getAccessibleText());
|
|
85
|
+
* expect(await hero.isTextHidden()).toBe(true);
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* @see https://ngwr.dev/guides/testing
|
|
89
|
+
*/
|
|
90
|
+
class WrSplitTextHarness extends ComponentHarness {
|
|
91
|
+
static hostSelector = 'wr-split-text';
|
|
92
|
+
/** Build a predicate that narrows the query — pass to `getHarness` / `getAllHarnesses`. */
|
|
93
|
+
static with(options = {}) {
|
|
94
|
+
return new HarnessPredicate(WrSplitTextHarness, options)
|
|
95
|
+
.addOption('text', options.text, (harness, text) => HarnessPredicate.stringMatches(harness.getAccessibleText(), text))
|
|
96
|
+
.addOption('pieceCount', options.pieceCount, async (harness, count) => (await harness.getPieceCount()) === count)
|
|
97
|
+
.addOption('textAlign', options.textAlign, async (harness, align) => (await harness.getTextAlign()) === align);
|
|
98
|
+
}
|
|
99
|
+
pieces = this.locatorForAll(PIECE);
|
|
100
|
+
spaces = this.locatorForAll(SPACE);
|
|
101
|
+
/**
|
|
102
|
+
* The whole string, as the one readable copy holds it.
|
|
103
|
+
*
|
|
104
|
+
* All a screen reader gets, since every drawn piece is out of the tree — lose this
|
|
105
|
+
* span and the component becomes a nameless pile of hidden spans, with no other
|
|
106
|
+
* symptom anywhere in the DOM.
|
|
107
|
+
*
|
|
108
|
+
* Untrimmed, deliberately: leading and trailing whitespace is content the split has to
|
|
109
|
+
* carry, and {@link getRenderedText} is meant to compare equal to this. A trimmed read
|
|
110
|
+
* on one side of that comparison would forgive the case it exists to catch.
|
|
111
|
+
*/
|
|
112
|
+
async getAccessibleText() {
|
|
113
|
+
return rawText(await this.locatorFor(SR_ONLY)());
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The animated pieces, in document order — one per character, or one per word.
|
|
117
|
+
*
|
|
118
|
+
* The split the stagger runs over: `['H', 'i']` at the default `splitType="chars"`
|
|
119
|
+
* against `['Hi', 'there']` for `"words"`, and `['a', '🚀']` for a surrogate pair the
|
|
120
|
+
* code-point split kept whole instead of halving into two broken glyphs. Whitespace is
|
|
121
|
+
* not here — it is drawn separately and never staggered, so see {@link getSpaceCount}.
|
|
122
|
+
*/
|
|
123
|
+
async getPieces() {
|
|
124
|
+
return Promise.all((await this.pieces()).map(rawText));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* How many pieces the stagger runs over.
|
|
128
|
+
*
|
|
129
|
+
* `delay` × this is the real length of the effect, and the only number about the
|
|
130
|
+
* animation present in the document.
|
|
131
|
+
*/
|
|
132
|
+
async getPieceCount() {
|
|
133
|
+
return (await this.pieces()).length;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* How many whitespace runs sit between the pieces.
|
|
137
|
+
*
|
|
138
|
+
* Its own method because whitespace is deliberately excluded from the animated set:
|
|
139
|
+
* reclassifying spaces as pieces would leave {@link getRenderedText} identical, change
|
|
140
|
+
* the stagger count and the rhythm of the reveal, and show up nowhere else.
|
|
141
|
+
*/
|
|
142
|
+
async getSpaceCount() {
|
|
143
|
+
return (await this.spaces()).length;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Everything the component drew, pieces and spaces together, in document order.
|
|
147
|
+
*
|
|
148
|
+
* The assertion this exists for is `getRenderedText() === getAccessibleText()`: the
|
|
149
|
+
* split must be lossless. A swallowed space, a re-split that dropped a character or a
|
|
150
|
+
* surrogate pair cut in half all leave the accessible copy intact and are invisible
|
|
151
|
+
* from anywhere else.
|
|
152
|
+
*/
|
|
153
|
+
async getRenderedText() {
|
|
154
|
+
const parts = await this.locatorForAll(`${PIECE}, ${SPACE}`)();
|
|
155
|
+
return (await Promise.all(parts.map(rawText))).join('');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Whether every drawn piece is out of the accessibility tree.
|
|
159
|
+
*
|
|
160
|
+
* The defect this component exists to avoid is a screen reader reading
|
|
161
|
+
* `H. e. l. l. o.`, and one piece missing `aria-hidden` brings it back — so this is
|
|
162
|
+
* all-or-nothing, across both element kinds: a readable space is heard as a pause in
|
|
163
|
+
* the middle of a word.
|
|
164
|
+
*
|
|
165
|
+
* Throws when the split produced nothing at all. An empty run of elements satisfies
|
|
166
|
+
* "every one of them is hidden" without anything being hidden, which is also what a
|
|
167
|
+
* component that stopped rendering its pieces would report.
|
|
168
|
+
*/
|
|
169
|
+
async isTextHidden() {
|
|
170
|
+
const parts = await this.locatorForAll(`${PIECE}, ${SPACE}`)();
|
|
171
|
+
if (parts.length === 0) {
|
|
172
|
+
throw new Error('WrSplitTextHarness.isTextHidden(): this <wr-split-text> drew no pieces, so there is nothing to be ' +
|
|
173
|
+
'hidden from a screen reader and "true" would be a vacuous answer — the same one a component that ' +
|
|
174
|
+
'stopped splitting its text would give. Check `text` is set, or ask getPieceCount().');
|
|
175
|
+
}
|
|
176
|
+
const hidden = await Promise.all(parts.map(part => part.getAttribute('aria-hidden')));
|
|
177
|
+
return hidden.every(value => value === 'true');
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The alignment the host was given, as the component wrote it.
|
|
181
|
+
*
|
|
182
|
+
* Off the inline `style` attribute rather than through `getCssValue`, and the
|
|
183
|
+
* difference is not pedantry: computed style answers for an element nobody wrote to —
|
|
184
|
+
* with the inherited or user-agent value — so a host that stopped reflecting the input
|
|
185
|
+
* altogether would keep reporting a plausible `left` or `start`. The attribute is the
|
|
186
|
+
* fact being asserted.
|
|
187
|
+
*
|
|
188
|
+
* `null` means the host carries no alignment at all, which the component never does:
|
|
189
|
+
* the binding is unconditional and the input defaults to `'center'`.
|
|
190
|
+
*/
|
|
191
|
+
async getTextAlign() {
|
|
192
|
+
const match = INLINE_TEXT_ALIGN.exec((await (await this.host()).getAttribute('style')) ?? '');
|
|
193
|
+
return match ? match[1].trim() : null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Whether the reveal has taken the pieces over — NOT whether anything is animating.
|
|
197
|
+
*
|
|
198
|
+
* Read off the inline `style` attribute the component writes itself, so it stays
|
|
199
|
+
* `true` once the reveal has finished: the tween commits its end state back to the
|
|
200
|
+
* same inline properties on `onfinish`. Anyone reaching for `isAnimating()` wants a
|
|
201
|
+
* frame, and there are no frames here.
|
|
202
|
+
*
|
|
203
|
+
* `false` is the answer worth asserting. It means no piece was written to, which is
|
|
204
|
+
* both the pre-trigger state and the reduced-motion promise — the component returns
|
|
205
|
+
* before staging anything, so a reader who asked for less motion cannot be left with
|
|
206
|
+
* text parked at `from.opacity: 0` by a tween that never runs.
|
|
207
|
+
*
|
|
208
|
+
* ANY piece counts, not all of them, and that is the conservative reading rather than
|
|
209
|
+
* a loose one: a stagger that stages the first piece and then throws leaves exactly
|
|
210
|
+
* one glyph invisible, which is a real failure — and one jsdom reproduces every time
|
|
211
|
+
* `Element.prototype.animate` is missing.
|
|
212
|
+
*/
|
|
213
|
+
async hasStagedMotion() {
|
|
214
|
+
const styles = await Promise.all((await this.pieces()).map(piece => piece.getAttribute('style')));
|
|
215
|
+
return styles.some(style => INLINE_OPACITY.test(style ?? ''));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Generated bundle index. Do not edit.
|
|
221
|
+
*/
|
|
222
|
+
|
|
223
|
+
export { WrSplitTextHarness };
|
|
224
|
+
//# sourceMappingURL=ngwr-split-text-testing.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ngwr-split-text-testing.mjs","sources":["../../../projects/lib/split-text/testing/wr-split-text-harness.ts","../../../projects/lib/split-text/testing/ngwr-split-text-testing.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\nimport { ComponentHarness, HarnessPredicate, type TestElement } from '@angular/cdk/testing';\n\nimport type { WrSplitTextHarnessFilters } from './interfaces';\n\n/** The animated pieces — one per character or per word, whitespace excluded. */\nconst PIECE = '.wr-split-text__piece';\n\n/** The whitespace runs between them, rendered but never staggered. */\nconst SPACE = '.wr-split-text__space';\n\n/** The readable copy of the string, carried once and hidden with CSS. */\nconst SR_ONLY = '.wr-split-text__sr-only';\n\n/**\n * An inline `opacity` declaration — written on every piece the tween touches.\n *\n * `motionToStyle` always produces an opacity, whether or not `from` declared one, and\n * the same property is re-committed when the tween finishes. Matched off the `style`\n * ATTRIBUTE, never through `getCssValue`: a computed style answers `1` for a piece\n * nothing was ever written to, which is the exact case this has to distinguish.\n */\nconst INLINE_OPACITY = /(?:^|;)\\s*opacity\\s*:/;\n\n/** The host's own alignment declaration, as the host binding wrote it. */\nconst INLINE_TEXT_ALIGN = /(?:^|;)\\s*text-align\\s*:\\s*([^;]+)/;\n\n/** Exactly what the element holds — untrimmed, unlike `TestElement.text()`. */\nasync function rawText(element: TestElement): Promise<string> {\n return (await element.getProperty<string | null>('textContent')) ?? '';\n}\n\n/**\n * Test harness for `<wr-split-text>`.\n *\n * **The tween is unreadable from a test, and the absent methods are absent for that\n * reason.** Each piece is animated from a `from` motion state to a `to` one through the\n * Web Animations API, started by an `IntersectionObserver`. A unit test has no layout,\n * no compositor and no WAAPI, so there is no `getOpacity()` and no `getTransform()`:\n * both would report the frozen staged value in jsdom and a value the compositor is not\n * painting in a browser. There is no `getFromState()` / `getToState()` either — those\n * objects only reach the DOM as staged inline styles, on the one path that throws here,\n * so reporting them would mean re-deriving an input from a string the component may\n * never have written. No `isAnimating()`, no `whenAnimationComplete()`: nothing in the\n * document says a tween is running, and completion is the `animationComplete` OUTPUT,\n * which the host binds. No `enterViewport()` / `trigger()`, because an observer is not\n * reachable from a `TestElement` — and observation here is additionally gated behind\n * `document.fonts.ready`, which jsdom does not have, so a `waitForFonts()` would resolve\n * instantly and prove nothing about the gating it claimed to test. Nothing measures\n * either: `getLineCount()` / `didItWrap()` would need layout (and `lines` is not a split\n * this port supports), and the `overflow: hidden` mask with its descender compensation\n * is a stylesheet, which a test runs none of.\n *\n * **What is real is the split, the accessible layer and the alignment.** The string is\n * carried once in a visually-hidden span and every drawn piece is `aria-hidden`, so\n * {@link getAccessibleText} is what is heard while {@link getPieces} is what is drawn —\n * two questions, and a spec asserting only the second would pass on a component that\n * spells the text out letter by letter. {@link getRenderedText} closes the loop by\n * putting the pieces and the spaces back together: the split has to be lossless, and\n * that comparison is what catches a dropped space or a halved surrogate pair. Whether\n * the hidden copy is genuinely off-screen is CSS, so nothing here claims to know.\n *\n * **{@link getTextAlign} is the one input this component reflects**, written straight\n * onto the host as an inline style — an honest read with no layout involved, unlike\n * anything else about how the text is arranged. And {@link hasStagedMotion} is the one\n * fact about the tween worth pinning: that the pieces were taken over, not that anything\n * is moving.\n *\n * Driving the reveal is the spec's job — jsdom supplies neither half of it, and the font\n * gate defers observation by a microtask:\n *\n * ```ts\n * // Before TestBed.createComponent — keep the callbacks so the spec can fire them.\n * vi.stubGlobal('IntersectionObserver', StubObserver);\n * // Otherwise `el.animate(...)` throws mid-stagger and leaves the DOM half-staged.\n * Reflect.set(Element.prototype, 'animate', () => ({ cancel: () => undefined, onfinish: null }));\n * // …and always settle before tearing down: observation is queued off a promise, and\n * // landing after destroy() registers a teardown on a dead DestroyRef (NG0911).\n * await fixture.whenStable();\n * ```\n *\n * @example\n * ```ts\n * const hero = await loader.getHarness(WrSplitTextHarness.with({ textAlign: 'center' }));\n *\n * expect(await hero.getPieces()).toEqual(['H', 'e', 'l', 'l', 'o']);\n * expect(await hero.getRenderedText()).toBe(await hero.getAccessibleText());\n * expect(await hero.isTextHidden()).toBe(true);\n * ```\n *\n * @see https://ngwr.dev/guides/testing\n */\nexport class WrSplitTextHarness extends ComponentHarness {\n static hostSelector = 'wr-split-text';\n\n /** Build a predicate that narrows the query — pass to `getHarness` / `getAllHarnesses`. */\n static with(options: WrSplitTextHarnessFilters = {}): HarnessPredicate<WrSplitTextHarness> {\n return new HarnessPredicate(WrSplitTextHarness, options)\n .addOption('text', options.text, (harness, text) =>\n HarnessPredicate.stringMatches(harness.getAccessibleText(), text)\n )\n .addOption('pieceCount', options.pieceCount, async (harness, count) => (await harness.getPieceCount()) === count)\n .addOption('textAlign', options.textAlign, async (harness, align) => (await harness.getTextAlign()) === align);\n }\n\n private readonly pieces = this.locatorForAll(PIECE);\n private readonly spaces = this.locatorForAll(SPACE);\n\n /**\n * The whole string, as the one readable copy holds it.\n *\n * All a screen reader gets, since every drawn piece is out of the tree — lose this\n * span and the component becomes a nameless pile of hidden spans, with no other\n * symptom anywhere in the DOM.\n *\n * Untrimmed, deliberately: leading and trailing whitespace is content the split has to\n * carry, and {@link getRenderedText} is meant to compare equal to this. A trimmed read\n * on one side of that comparison would forgive the case it exists to catch.\n */\n async getAccessibleText(): Promise<string> {\n return rawText(await this.locatorFor(SR_ONLY)());\n }\n\n /**\n * The animated pieces, in document order — one per character, or one per word.\n *\n * The split the stagger runs over: `['H', 'i']` at the default `splitType=\"chars\"`\n * against `['Hi', 'there']` for `\"words\"`, and `['a', '🚀']` for a surrogate pair the\n * code-point split kept whole instead of halving into two broken glyphs. Whitespace is\n * not here — it is drawn separately and never staggered, so see {@link getSpaceCount}.\n */\n async getPieces(): Promise<string[]> {\n return Promise.all((await this.pieces()).map(rawText));\n }\n\n /**\n * How many pieces the stagger runs over.\n *\n * `delay` × this is the real length of the effect, and the only number about the\n * animation present in the document.\n */\n async getPieceCount(): Promise<number> {\n return (await this.pieces()).length;\n }\n\n /**\n * How many whitespace runs sit between the pieces.\n *\n * Its own method because whitespace is deliberately excluded from the animated set:\n * reclassifying spaces as pieces would leave {@link getRenderedText} identical, change\n * the stagger count and the rhythm of the reveal, and show up nowhere else.\n */\n async getSpaceCount(): Promise<number> {\n return (await this.spaces()).length;\n }\n\n /**\n * Everything the component drew, pieces and spaces together, in document order.\n *\n * The assertion this exists for is `getRenderedText() === getAccessibleText()`: the\n * split must be lossless. A swallowed space, a re-split that dropped a character or a\n * surrogate pair cut in half all leave the accessible copy intact and are invisible\n * from anywhere else.\n */\n async getRenderedText(): Promise<string> {\n const parts = await this.locatorForAll(`${PIECE}, ${SPACE}`)();\n return (await Promise.all(parts.map(rawText))).join('');\n }\n\n /**\n * Whether every drawn piece is out of the accessibility tree.\n *\n * The defect this component exists to avoid is a screen reader reading\n * `H. e. l. l. o.`, and one piece missing `aria-hidden` brings it back — so this is\n * all-or-nothing, across both element kinds: a readable space is heard as a pause in\n * the middle of a word.\n *\n * Throws when the split produced nothing at all. An empty run of elements satisfies\n * \"every one of them is hidden\" without anything being hidden, which is also what a\n * component that stopped rendering its pieces would report.\n */\n async isTextHidden(): Promise<boolean> {\n const parts = await this.locatorForAll(`${PIECE}, ${SPACE}`)();\n\n if (parts.length === 0) {\n throw new Error(\n 'WrSplitTextHarness.isTextHidden(): this <wr-split-text> drew no pieces, so there is nothing to be ' +\n 'hidden from a screen reader and \"true\" would be a vacuous answer — the same one a component that ' +\n 'stopped splitting its text would give. Check `text` is set, or ask getPieceCount().'\n );\n }\n\n const hidden = await Promise.all(parts.map(part => part.getAttribute('aria-hidden')));\n return hidden.every(value => value === 'true');\n }\n\n /**\n * The alignment the host was given, as the component wrote it.\n *\n * Off the inline `style` attribute rather than through `getCssValue`, and the\n * difference is not pedantry: computed style answers for an element nobody wrote to —\n * with the inherited or user-agent value — so a host that stopped reflecting the input\n * altogether would keep reporting a plausible `left` or `start`. The attribute is the\n * fact being asserted.\n *\n * `null` means the host carries no alignment at all, which the component never does:\n * the binding is unconditional and the input defaults to `'center'`.\n */\n async getTextAlign(): Promise<string | null> {\n const match = INLINE_TEXT_ALIGN.exec((await (await this.host()).getAttribute('style')) ?? '');\n return match ? match[1].trim() : null;\n }\n\n /**\n * Whether the reveal has taken the pieces over — NOT whether anything is animating.\n *\n * Read off the inline `style` attribute the component writes itself, so it stays\n * `true` once the reveal has finished: the tween commits its end state back to the\n * same inline properties on `onfinish`. Anyone reaching for `isAnimating()` wants a\n * frame, and there are no frames here.\n *\n * `false` is the answer worth asserting. It means no piece was written to, which is\n * both the pre-trigger state and the reduced-motion promise — the component returns\n * before staging anything, so a reader who asked for less motion cannot be left with\n * text parked at `from.opacity: 0` by a tween that never runs.\n *\n * ANY piece counts, not all of them, and that is the conservative reading rather than\n * a loose one: a stagger that stages the first piece and then throws leaves exactly\n * one glyph invisible, which is a real failure — and one jsdom reproduces every time\n * `Element.prototype.animate` is missing.\n */\n async hasStagedMotion(): Promise<boolean> {\n const styles = await Promise.all((await this.pieces()).map(piece => piece.getAttribute('style')));\n return styles.some(style => INLINE_OPACITY.test(style ?? ''));\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
|
|
@@ -104,9 +104,22 @@ class WrSplitText {
|
|
|
104
104
|
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
105
105
|
platform = inject(WrPlatform);
|
|
106
106
|
hasAnimated = false;
|
|
107
|
+
/**
|
|
108
|
+
* The ONE observer, replaced rather than stacked.
|
|
109
|
+
*
|
|
110
|
+
* `startObserver()` used to `new IntersectionObserver(…)` and register its own
|
|
111
|
+
* `destroyRef.onDestroy` every time, and the effect below calls it on every
|
|
112
|
+
* `text` change after setting `hasAnimated = false` — so the guard at the top
|
|
113
|
+
* of `startObserver` never blocked that path. An off-screen element (the only
|
|
114
|
+
* case where the observer does not immediately disconnect itself) therefore
|
|
115
|
+
* leaked one live observer AND one destroy callback per change, all of them
|
|
116
|
+
* watching the same host and all of them able to fire `animate()`.
|
|
117
|
+
*/
|
|
118
|
+
observer = null;
|
|
107
119
|
constructor() {
|
|
108
120
|
if (!this.isBrowser)
|
|
109
121
|
return;
|
|
122
|
+
this.destroyRef.onDestroy(() => this.observer?.disconnect());
|
|
110
123
|
afterNextRender(() => this.startObserver());
|
|
111
124
|
// Re-run animation if `text` (and therefore the split pieces) changes.
|
|
112
125
|
effect(() => {
|
|
@@ -123,6 +136,7 @@ class WrSplitText {
|
|
|
123
136
|
const observe = () => {
|
|
124
137
|
if (this.hasAnimated)
|
|
125
138
|
return;
|
|
139
|
+
this.observer?.disconnect();
|
|
126
140
|
const io = new IntersectionObserver((entries, obs) => {
|
|
127
141
|
for (const e of entries) {
|
|
128
142
|
if (e.isIntersecting) {
|
|
@@ -132,8 +146,8 @@ class WrSplitText {
|
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
}, { threshold: this.threshold(), rootMargin: this.rootMargin() });
|
|
149
|
+
this.observer = io;
|
|
135
150
|
io.observe(host);
|
|
136
|
-
this.destroyRef.onDestroy(() => io.disconnect());
|
|
137
151
|
};
|
|
138
152
|
void ready.then(observe);
|
|
139
153
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ngwr-split-text.mjs","sources":["../../../projects/lib/split-text/split-text.ts","../../../projects/lib/split-text/split-text.html","../../../projects/lib/split-text/ngwr-split-text.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n *\n * Angular adaptation of the SplitText effect by David Haz / reactbits.dev.\n * Original: https://www.reactbits.dev/text-animations/split-text\n *\n * The reactbits version uses GSAP's paid `SplitText` plugin. This port is\n * GSAP-free — splits via plain DOM spans, animates via the Web Animations\n * API, triggers on viewport entry via `IntersectionObserver`.\n */\n\nimport { isPlatformBrowser } from '@angular/common';\nimport {\n Component,\n DestroyRef,\n ElementRef,\n PLATFORM_ID,\n ViewEncapsulation,\n afterNextRender,\n computed,\n effect,\n inject,\n input,\n output,\n} from '@angular/core';\n\nimport { WrPlatform } from 'ngwr/platform';\nimport { numAttr } from 'ngwr/utils';\n\nimport type { WrSplitTextUnit, WrSplitTextMotion } from './interfaces';\n\ntype Piece = { readonly kind: 'piece'; readonly text: string } | { readonly kind: 'space'; readonly text: string };\n\n/** Split `text` into render pieces, preserving whitespace as `kind: 'space'`. */\nfunction splitPieces(text: string, unit: WrSplitTextUnit): readonly Piece[] {\n if (!text) return [];\n if (unit === 'words') {\n // Split on whitespace, keeping spaces as separate pieces.\n return text.split(/(\\s+)/).flatMap<Piece>(seg => {\n if (seg.length === 0) return [];\n if (/^\\s+$/.test(seg)) return [{ kind: 'space', text: seg } as const];\n return [{ kind: 'piece', text: seg } as const];\n });\n }\n // chars: each character is its own piece; whitespace becomes 'space'.\n return [...text].map<Piece>(ch => (/\\s/.test(ch) ? { kind: 'space', text: ch } : { kind: 'piece', text: ch }));\n}\n\nfunction motionToStyle(m: WrSplitTextMotion): { opacity: number; transform: string } {\n const opacity = m.opacity ?? 1;\n const parts: string[] = [];\n if (m.x !== undefined || m.y !== undefined) {\n parts.push(`translate3d(${m.x ?? 0}px, ${m.y ?? 0}px, 0)`);\n }\n if (m.scale !== undefined) parts.push(`scale(${m.scale})`);\n if (m.rotate !== undefined) parts.push(`rotate(${m.rotate}deg)`);\n return { opacity, transform: parts.length ? parts.join(' ') : 'none' };\n}\n\n/**\n * Animated text reveal — splits the input string into chars or words and\n * staggers each piece in from a `from` motion state to a `to` motion\n * state when the host scrolls into view. Whitespace is preserved.\n *\n * @example\n * ```html\n * <wr-split-text\n * text=\"Hello, ngwr!\"\n * splitType=\"chars\"\n * [delay]=\"40\"\n * [duration]=\"1.2\"\n * [from]=\"{ opacity: 0, y: 40 }\"\n * [to]=\"{ opacity: 1, y: 0 }\"\n * (animationComplete)=\"onDone()\"\n * />\n * ```\n *\n * @see https://www.reactbits.dev/text-animations/split-text\n */\n@Component({\n selector: 'wr-split-text',\n templateUrl: './split-text.html',\n styleUrl: './split-text.scss',\n encapsulation: ViewEncapsulation.None,\n host: { class: 'wr-split-text', '[style.text-align]': 'textAlign()' },\n})\nexport class WrSplitText {\n /** Text to animate. Required. */\n readonly text = input.required<string>();\n\n /** Split granularity. @default 'chars' */\n readonly splitType = input<WrSplitTextUnit>('chars');\n\n /** Stagger delay between pieces in ms. @default 50 */\n readonly delay = input(50, { transform: numAttr(50) });\n\n /** Animation duration in seconds (matches reactbits' API). @default 1.25 */\n readonly duration = input(1.25, { transform: numAttr(1.25) });\n\n /** CSS easing function. @default 'cubic-bezier(0.16, 1, 0.3, 1)' (~power3.out) */\n readonly easing = input('cubic-bezier(0.16, 1, 0.3, 1)');\n\n /** Start state for each piece. @default { opacity: 0, y: 40 } */\n readonly from = input<WrSplitTextMotion>({ opacity: 0, y: 40 });\n\n /** End state for each piece. @default { opacity: 1, y: 0 } */\n readonly to = input<WrSplitTextMotion>({ opacity: 1, y: 0 });\n\n /** `IntersectionObserver.threshold` — 0..1. @default 0.1 */\n readonly threshold = input(0.1, { transform: numAttr(0.1) });\n\n /** `IntersectionObserver.rootMargin`. @default '-100px' */\n readonly rootMargin = input('-100px');\n\n /** Text alignment of the host. @default 'center' */\n readonly textAlign = input<'left' | 'center' | 'right' | 'justify'>('center');\n\n /** Emitted once all pieces finish animating. */\n readonly animationComplete = output<void>();\n\n /** Pieces to render. Each piece is `{ kind: 'piece', text }` or `{ kind: 'space' }`. */\n protected readonly pieces = computed(() => splitPieces(this.text(), this.splitType()));\n\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly destroyRef = inject(DestroyRef);\n private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));\n private readonly platform = inject(WrPlatform);\n\n private hasAnimated = false;\n\n constructor() {\n if (!this.isBrowser) return;\n\n afterNextRender(() => this.startObserver());\n\n // Re-run animation if `text` (and therefore the split pieces) changes.\n effect(() => {\n this.text();\n this.splitType();\n this.hasAnimated = false;\n queueMicrotask(() => this.startObserver());\n });\n }\n\n private startObserver(): void {\n const host = this.host.nativeElement;\n const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;\n const ready = fonts?.status === 'loaded' ? Promise.resolve() : (fonts?.ready ?? Promise.resolve());\n\n const observe = (): void => {\n if (this.hasAnimated) return;\n const io = new IntersectionObserver(\n (entries, obs) => {\n for (const e of entries) {\n if (e.isIntersecting) {\n obs.disconnect();\n this.animate();\n break;\n }\n }\n },\n { threshold: this.threshold(), rootMargin: this.rootMargin() }\n );\n io.observe(host);\n this.destroyRef.onDestroy(() => io.disconnect());\n };\n\n void ready.then(observe);\n }\n\n private animate(): void {\n if (this.hasAnimated) return;\n this.hasAnimated = true;\n\n // Reduced motion: pieces already render at their natural (final)\n // state — skip the tween and report completion right away.\n if (this.platform.prefersReducedMotion()) {\n this.animationComplete.emit();\n return;\n }\n\n const host = this.host.nativeElement;\n const targets = host.querySelectorAll<HTMLElement>('.wr-split-text__piece');\n if (targets.length === 0) {\n this.animationComplete.emit();\n return;\n }\n\n const fromStyle = motionToStyle(this.from());\n const toStyle = motionToStyle(this.to());\n const durationMs = this.duration() * 1000;\n const stagger = this.delay();\n const easing = this.easing();\n\n let remaining = targets.length;\n targets.forEach((el, i) => {\n // Set initial state immediately so there's no flash of final state\n // before the animation kicks in.\n el.style.opacity = String(fromStyle.opacity);\n el.style.transform = fromStyle.transform;\n const animation = el.animate(\n [\n { opacity: fromStyle.opacity, transform: fromStyle.transform },\n { opacity: toStyle.opacity, transform: toStyle.transform },\n ],\n { duration: durationMs, delay: i * stagger, easing, fill: 'forwards' }\n );\n animation.onfinish = (): void => {\n // Commit final styles + clean up the WAAPI tween.\n el.style.opacity = String(toStyle.opacity);\n el.style.transform = toStyle.transform;\n try {\n animation.cancel();\n } catch {\n /* noop */\n }\n remaining -= 1;\n if (remaining === 0) this.animationComplete.emit();\n };\n });\n }\n}\n\nexport type { WrSplitTextUnit, WrSplitTextMotion } from './interfaces';\n","<!-- The readable text, once: the pieces below are per-character spans. -->\n<span class=\"wr-split-text__sr-only\">{{ text() }}</span>\n@for (p of pieces(); track $index) {\n @if (p.kind === 'piece') {\n <span class=\"wr-split-text__piece\" aria-hidden=\"true\">{{ p.text }}</span>\n } @else {\n <span class=\"wr-split-text__space\" aria-hidden=\"true\">{{ p.text }}</span>\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ngwr-split-text.mjs","sources":["../../../projects/lib/split-text/split-text.ts","../../../projects/lib/split-text/split-text.html","../../../projects/lib/split-text/ngwr-split-text.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n *\n * Angular adaptation of the SplitText effect by David Haz / reactbits.dev.\n * Original: https://www.reactbits.dev/text-animations/split-text\n *\n * The reactbits version uses GSAP's paid `SplitText` plugin. This port is\n * GSAP-free — splits via plain DOM spans, animates via the Web Animations\n * API, triggers on viewport entry via `IntersectionObserver`.\n */\n\nimport { isPlatformBrowser } from '@angular/common';\nimport {\n Component,\n DestroyRef,\n ElementRef,\n PLATFORM_ID,\n ViewEncapsulation,\n afterNextRender,\n computed,\n effect,\n inject,\n input,\n output,\n} from '@angular/core';\n\nimport { WrPlatform } from 'ngwr/platform';\nimport { numAttr } from 'ngwr/utils';\n\nimport type { WrSplitTextUnit, WrSplitTextMotion } from './interfaces';\n\ntype Piece = { readonly kind: 'piece'; readonly text: string } | { readonly kind: 'space'; readonly text: string };\n\n/** Split `text` into render pieces, preserving whitespace as `kind: 'space'`. */\nfunction splitPieces(text: string, unit: WrSplitTextUnit): readonly Piece[] {\n if (!text) return [];\n if (unit === 'words') {\n // Split on whitespace, keeping spaces as separate pieces.\n return text.split(/(\\s+)/).flatMap<Piece>(seg => {\n if (seg.length === 0) return [];\n if (/^\\s+$/.test(seg)) return [{ kind: 'space', text: seg } as const];\n return [{ kind: 'piece', text: seg } as const];\n });\n }\n // chars: each character is its own piece; whitespace becomes 'space'.\n return [...text].map<Piece>(ch => (/\\s/.test(ch) ? { kind: 'space', text: ch } : { kind: 'piece', text: ch }));\n}\n\nfunction motionToStyle(m: WrSplitTextMotion): { opacity: number; transform: string } {\n const opacity = m.opacity ?? 1;\n const parts: string[] = [];\n if (m.x !== undefined || m.y !== undefined) {\n parts.push(`translate3d(${m.x ?? 0}px, ${m.y ?? 0}px, 0)`);\n }\n if (m.scale !== undefined) parts.push(`scale(${m.scale})`);\n if (m.rotate !== undefined) parts.push(`rotate(${m.rotate}deg)`);\n return { opacity, transform: parts.length ? parts.join(' ') : 'none' };\n}\n\n/**\n * Animated text reveal — splits the input string into chars or words and\n * staggers each piece in from a `from` motion state to a `to` motion\n * state when the host scrolls into view. Whitespace is preserved.\n *\n * @example\n * ```html\n * <wr-split-text\n * text=\"Hello, ngwr!\"\n * splitType=\"chars\"\n * [delay]=\"40\"\n * [duration]=\"1.2\"\n * [from]=\"{ opacity: 0, y: 40 }\"\n * [to]=\"{ opacity: 1, y: 0 }\"\n * (animationComplete)=\"onDone()\"\n * />\n * ```\n *\n * @see https://www.reactbits.dev/text-animations/split-text\n */\n@Component({\n selector: 'wr-split-text',\n templateUrl: './split-text.html',\n styleUrl: './split-text.scss',\n encapsulation: ViewEncapsulation.None,\n host: { class: 'wr-split-text', '[style.text-align]': 'textAlign()' },\n})\nexport class WrSplitText {\n /** Text to animate. Required. */\n readonly text = input.required<string>();\n\n /** Split granularity. @default 'chars' */\n readonly splitType = input<WrSplitTextUnit>('chars');\n\n /** Stagger delay between pieces in ms. @default 50 */\n readonly delay = input(50, { transform: numAttr(50) });\n\n /** Animation duration in seconds (matches reactbits' API). @default 1.25 */\n readonly duration = input(1.25, { transform: numAttr(1.25) });\n\n /** CSS easing function. @default 'cubic-bezier(0.16, 1, 0.3, 1)' (~power3.out) */\n readonly easing = input('cubic-bezier(0.16, 1, 0.3, 1)');\n\n /** Start state for each piece. @default { opacity: 0, y: 40 } */\n readonly from = input<WrSplitTextMotion>({ opacity: 0, y: 40 });\n\n /** End state for each piece. @default { opacity: 1, y: 0 } */\n readonly to = input<WrSplitTextMotion>({ opacity: 1, y: 0 });\n\n /** `IntersectionObserver.threshold` — 0..1. @default 0.1 */\n readonly threshold = input(0.1, { transform: numAttr(0.1) });\n\n /** `IntersectionObserver.rootMargin`. @default '-100px' */\n readonly rootMargin = input('-100px');\n\n /** Text alignment of the host. @default 'center' */\n readonly textAlign = input<'left' | 'center' | 'right' | 'justify'>('center');\n\n /** Emitted once all pieces finish animating. */\n readonly animationComplete = output<void>();\n\n /** Pieces to render. Each piece is `{ kind: 'piece', text }` or `{ kind: 'space' }`. */\n protected readonly pieces = computed(() => splitPieces(this.text(), this.splitType()));\n\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly destroyRef = inject(DestroyRef);\n private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));\n private readonly platform = inject(WrPlatform);\n\n private hasAnimated = false;\n\n /**\n * The ONE observer, replaced rather than stacked.\n *\n * `startObserver()` used to `new IntersectionObserver(…)` and register its own\n * `destroyRef.onDestroy` every time, and the effect below calls it on every\n * `text` change after setting `hasAnimated = false` — so the guard at the top\n * of `startObserver` never blocked that path. An off-screen element (the only\n * case where the observer does not immediately disconnect itself) therefore\n * leaked one live observer AND one destroy callback per change, all of them\n * watching the same host and all of them able to fire `animate()`.\n */\n private observer: IntersectionObserver | null = null;\n\n constructor() {\n if (!this.isBrowser) return;\n this.destroyRef.onDestroy(() => this.observer?.disconnect());\n\n afterNextRender(() => this.startObserver());\n\n // Re-run animation if `text` (and therefore the split pieces) changes.\n effect(() => {\n this.text();\n this.splitType();\n this.hasAnimated = false;\n queueMicrotask(() => this.startObserver());\n });\n }\n\n private startObserver(): void {\n const host = this.host.nativeElement;\n const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;\n const ready = fonts?.status === 'loaded' ? Promise.resolve() : (fonts?.ready ?? Promise.resolve());\n\n const observe = (): void => {\n if (this.hasAnimated) return;\n this.observer?.disconnect();\n const io = new IntersectionObserver(\n (entries, obs) => {\n for (const e of entries) {\n if (e.isIntersecting) {\n obs.disconnect();\n this.animate();\n break;\n }\n }\n },\n { threshold: this.threshold(), rootMargin: this.rootMargin() }\n );\n this.observer = io;\n io.observe(host);\n };\n\n void ready.then(observe);\n }\n\n private animate(): void {\n if (this.hasAnimated) return;\n this.hasAnimated = true;\n\n // Reduced motion: pieces already render at their natural (final)\n // state — skip the tween and report completion right away.\n if (this.platform.prefersReducedMotion()) {\n this.animationComplete.emit();\n return;\n }\n\n const host = this.host.nativeElement;\n const targets = host.querySelectorAll<HTMLElement>('.wr-split-text__piece');\n if (targets.length === 0) {\n this.animationComplete.emit();\n return;\n }\n\n const fromStyle = motionToStyle(this.from());\n const toStyle = motionToStyle(this.to());\n const durationMs = this.duration() * 1000;\n const stagger = this.delay();\n const easing = this.easing();\n\n let remaining = targets.length;\n targets.forEach((el, i) => {\n // Set initial state immediately so there's no flash of final state\n // before the animation kicks in.\n el.style.opacity = String(fromStyle.opacity);\n el.style.transform = fromStyle.transform;\n const animation = el.animate(\n [\n { opacity: fromStyle.opacity, transform: fromStyle.transform },\n { opacity: toStyle.opacity, transform: toStyle.transform },\n ],\n { duration: durationMs, delay: i * stagger, easing, fill: 'forwards' }\n );\n animation.onfinish = (): void => {\n // Commit final styles + clean up the WAAPI tween.\n el.style.opacity = String(toStyle.opacity);\n el.style.transform = toStyle.transform;\n try {\n animation.cancel();\n } catch {\n /* noop */\n }\n remaining -= 1;\n if (remaining === 0) this.animationComplete.emit();\n };\n });\n }\n}\n\nexport type { WrSplitTextUnit, WrSplitTextMotion } from './interfaces';\n","<!-- The readable text, once: the pieces below are per-character spans. -->\n<span class=\"wr-split-text__sr-only\">{{ text() }}</span>\n@for (p of pieces(); track $index) {\n @if (p.kind === 'piece') {\n <span class=\"wr-split-text__piece\" aria-hidden=\"true\">{{ p.text }}</span>\n } @else {\n <span class=\"wr-split-text__space\" aria-hidden=\"true\">{{ p.text }}</span>\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { TestKey, ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
*
|
|
6
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
7
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
8
|
+
*/
|
|
9
|
+
const ARROWS = {
|
|
10
|
+
left: TestKey.LEFT_ARROW,
|
|
11
|
+
right: TestKey.RIGHT_ARROW,
|
|
12
|
+
up: TestKey.UP_ARROW,
|
|
13
|
+
down: TestKey.DOWN_ARROW,
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Test harness for `<wr-splitter>` — two panes and the divider between them.
|
|
17
|
+
*
|
|
18
|
+
* **Everything here is the keyboard, and that is not a workaround.** The divider is
|
|
19
|
+
* dragged with a pointer in a browser, but the drag reads
|
|
20
|
+
* `getBoundingClientRect()` and turns a client coordinate into a percentage; jsdom
|
|
21
|
+
* lays nothing out, so every rect is 0×0 and a synthetic drag writes `NaN` into the
|
|
22
|
+
* position. The arrow keys reach the same state, are the accessible path anyway, and
|
|
23
|
+
* are what the WAI-ARIA separator pattern is judged on.
|
|
24
|
+
*
|
|
25
|
+
* **Two words for the axis, and they are opposites.** The component's
|
|
26
|
+
* `orientation="horizontal"` means the panes sit side by side, which needs a
|
|
27
|
+
* VERTICAL divider — so that is what `aria-orientation` says. {@link getOrientation}
|
|
28
|
+
* answers the component's word and {@link getDividerOrientation} the divider's; both
|
|
29
|
+
* are here because a spec that conflated them would look right and assert nothing.
|
|
30
|
+
*
|
|
31
|
+
* **Arrows follow the VISUAL axis.** Under `dir="rtl"` the start pane is the one on
|
|
32
|
+
* the right, so ArrowRight SHRINKS the position. {@link pressArrow} takes the key a
|
|
33
|
+
* user presses rather than a semantic direction, which is the only way a spec can
|
|
34
|
+
* pin that mirroring; {@link setPosition} walks with whichever key currently grows
|
|
35
|
+
* the number, and reports where it landed.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const splitter = await loader.getHarness(WrSplitterHarness);
|
|
40
|
+
*
|
|
41
|
+
* expect(await splitter.getPosition()).toBe(50);
|
|
42
|
+
* await splitter.setPosition(70);
|
|
43
|
+
* expect(await splitter.getPaneSizes()).toEqual({ start: 70, end: 30 });
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @see https://ngwr.dev/guides/testing
|
|
47
|
+
*/
|
|
48
|
+
class WrSplitterHarness extends ComponentHarness {
|
|
49
|
+
static hostSelector = 'wr-splitter';
|
|
50
|
+
/** Build a predicate that narrows the query — pass to `getHarness` / `getAllHarnesses`. */
|
|
51
|
+
static with(options = {}) {
|
|
52
|
+
return new HarnessPredicate(WrSplitterHarness, options)
|
|
53
|
+
.addOption('orientation', options.orientation, async (harness, orientation) => (await harness.getOrientation()) === orientation)
|
|
54
|
+
.addOption('dividerLabel', options.dividerLabel, (harness, label) => HarnessPredicate.stringMatches(harness.getDividerLabel(), label))
|
|
55
|
+
.addOption('disabled', options.disabled, async (harness, disabled) => (await harness.isDisabled()) === disabled);
|
|
56
|
+
}
|
|
57
|
+
divider = this.locatorFor('.wr-splitter__divider');
|
|
58
|
+
/**
|
|
59
|
+
* The splitter's own axis: `horizontal` when the panes sit side by side.
|
|
60
|
+
*
|
|
61
|
+
* Read from the host modifier rather than from `aria-orientation`, which says the
|
|
62
|
+
* opposite thing about the same layout — see {@link getDividerOrientation}.
|
|
63
|
+
*/
|
|
64
|
+
async getOrientation() {
|
|
65
|
+
return (await (await this.host()).hasClass('wr-splitter--vertical')) ? 'vertical' : 'horizontal';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What the divider announces — the INVERSE of {@link getOrientation}, and correct:
|
|
69
|
+
* a separator's orientation is the line it draws, not the axis it travels along.
|
|
70
|
+
*/
|
|
71
|
+
async getDividerOrientation() {
|
|
72
|
+
return (await this.divider()).getAttribute('aria-orientation');
|
|
73
|
+
}
|
|
74
|
+
/** The divider's position as a percentage, from `aria-valuenow`. */
|
|
75
|
+
async getPosition() {
|
|
76
|
+
return this.numeric('aria-valuenow');
|
|
77
|
+
}
|
|
78
|
+
/** The lowest position the divider will accept, from `aria-valuemin`. */
|
|
79
|
+
async getMinPosition() {
|
|
80
|
+
return this.numeric('aria-valuemin');
|
|
81
|
+
}
|
|
82
|
+
/** The highest position the divider will accept, from `aria-valuemax`. */
|
|
83
|
+
async getMaxPosition() {
|
|
84
|
+
return this.numeric('aria-valuemax');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* What each pane is asking for, in percent, from the `flex-basis` the component
|
|
88
|
+
* writes inline.
|
|
89
|
+
*
|
|
90
|
+
* This is the only evidence a spec without layout has that the panes followed the
|
|
91
|
+
* divider — a measured width is zero for both. They should always sum to 100:
|
|
92
|
+
* the end pane is `100 - position`, so a pair that does not is a component bug
|
|
93
|
+
* rather than a rounding artefact.
|
|
94
|
+
*/
|
|
95
|
+
async getPaneSizes() {
|
|
96
|
+
const start = await this.locatorFor('.wr-splitter__pane--start')();
|
|
97
|
+
const end = await this.locatorFor('.wr-splitter__pane--end')();
|
|
98
|
+
return {
|
|
99
|
+
start: Number.parseFloat(await start.getCssValue('flex-basis')),
|
|
100
|
+
end: Number.parseFloat(await end.getCssValue('flex-basis')),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** The start pane's projected text, trimmed. */
|
|
104
|
+
async getStartText() {
|
|
105
|
+
return (await this.locatorFor('.wr-splitter__pane--start')()).text();
|
|
106
|
+
}
|
|
107
|
+
/** The end pane's projected text, trimmed. */
|
|
108
|
+
async getEndText() {
|
|
109
|
+
return (await this.locatorFor('.wr-splitter__pane--end')()).text();
|
|
110
|
+
}
|
|
111
|
+
/** The divider's accessible name — a focusable separator is a widget and needs one. */
|
|
112
|
+
async getDividerLabel() {
|
|
113
|
+
return (await this.divider()).getAttribute('aria-label');
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether the splitter refuses to resize.
|
|
117
|
+
*
|
|
118
|
+
* From the divider's `aria-disabled`, which is the half a screen reader acts on.
|
|
119
|
+
* The component also drops the divider out of the tab order and paints a modifier
|
|
120
|
+
* on the host, and {@link isDividerFocusable} answers the first of those — worth
|
|
121
|
+
* asking separately, because a divider that is announced as disabled and still a
|
|
122
|
+
* tab stop is a control the keyboard can reach and not use.
|
|
123
|
+
*/
|
|
124
|
+
async isDisabled() {
|
|
125
|
+
return (await (await this.divider()).getAttribute('aria-disabled')) === 'true';
|
|
126
|
+
}
|
|
127
|
+
/** Whether the divider is a tab stop (`tabindex="0"`). */
|
|
128
|
+
async isDividerFocusable() {
|
|
129
|
+
return (await (await this.divider()).getAttribute('tabindex')) === '0';
|
|
130
|
+
}
|
|
131
|
+
/** Move keyboard focus to the divider. */
|
|
132
|
+
async focusDivider() {
|
|
133
|
+
return (await this.divider()).focus();
|
|
134
|
+
}
|
|
135
|
+
/** Whether the divider currently holds focus. */
|
|
136
|
+
async isDividerFocused() {
|
|
137
|
+
return (await this.divider()).isFocused();
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Press one arrow key on the divider.
|
|
141
|
+
*
|
|
142
|
+
* The key rather than a direction, deliberately: which way the position moves is a
|
|
143
|
+
* function of the key AND the reading direction, and a `grow()` / `shrink()` pair
|
|
144
|
+
* would hide exactly the behaviour worth asserting. An arrow off the splitter's own
|
|
145
|
+
* axis does nothing at all — the horizontal splitter ignores Up and Down.
|
|
146
|
+
*
|
|
147
|
+
* `shift` is the coarse step: 10 instead of 1.
|
|
148
|
+
*/
|
|
149
|
+
async pressArrow(arrow, options = {}) {
|
|
150
|
+
const divider = await this.divider();
|
|
151
|
+
await (options.shift ? divider.sendKeys({ shift: true }, ARROWS[arrow]) : divider.sendKeys(ARROWS[arrow]));
|
|
152
|
+
}
|
|
153
|
+
/** Press Home — jumping to `minPosition`, whatever the reading direction. */
|
|
154
|
+
async pressHome() {
|
|
155
|
+
return (await this.divider()).sendKeys(TestKey.HOME);
|
|
156
|
+
}
|
|
157
|
+
/** Press End — jumping to `maxPosition`. Semantic, so it never mirrors under RTL. */
|
|
158
|
+
async pressEnd() {
|
|
159
|
+
return (await this.divider()).sendKeys(TestKey.END);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Walk the divider to a position with the keyboard, and assert it landed there.
|
|
163
|
+
*
|
|
164
|
+
* Ten-unit hops first, then single steps — the same two strides a user has. It
|
|
165
|
+
* walks rather than assigning, because assigning would prove the harness can write
|
|
166
|
+
* a signal and nothing about the component: the clamp, the min / max and the
|
|
167
|
+
* mirroring all live on this path.
|
|
168
|
+
*
|
|
169
|
+
* Throws rather than half-arriving. A target outside `[min, max]` is unreachable by
|
|
170
|
+
* construction, and so is any target the walk cannot hit exactly — the position is a
|
|
171
|
+
* float that a previous drag may have left mid-step, and whole-unit keys cannot get
|
|
172
|
+
* off a fractional offset. Both say so, with where it actually stopped.
|
|
173
|
+
*/
|
|
174
|
+
async setPosition(target) {
|
|
175
|
+
const [min, max] = [await this.getMinPosition(), await this.getMaxPosition()];
|
|
176
|
+
if (target < min || target > max) {
|
|
177
|
+
throw new Error(`WrSplitterHarness.setPosition(${target}): out of range — this splitter accepts ${min}–${max}. ` +
|
|
178
|
+
'The component clamps, so the walk would stop at the bound and never reach the number asked for.');
|
|
179
|
+
}
|
|
180
|
+
if (target === min)
|
|
181
|
+
return this.pressHome();
|
|
182
|
+
if (target === max)
|
|
183
|
+
return this.pressEnd();
|
|
184
|
+
await this.focusDivider();
|
|
185
|
+
// The block axis never mirrors, so only the horizontal splitter needs the probe.
|
|
186
|
+
const vertical = (await this.getOrientation()) === 'vertical';
|
|
187
|
+
const [grow, shrink] = vertical
|
|
188
|
+
? ['down', 'up']
|
|
189
|
+
: (await this.growsWithRight())
|
|
190
|
+
? ['right', 'left']
|
|
191
|
+
: ['left', 'right'];
|
|
192
|
+
for (const shift of [true, false]) {
|
|
193
|
+
const step = shift ? 10 : 1;
|
|
194
|
+
// Recomputed each pass: the coarse hops clamp at the bounds, so the remaining
|
|
195
|
+
// distance is not what simple arithmetic on the starting point would predict.
|
|
196
|
+
let delta = target - (await this.getPosition());
|
|
197
|
+
while (Math.abs(delta) >= step) {
|
|
198
|
+
const before = await this.getPosition();
|
|
199
|
+
await this.pressArrow(delta > 0 ? grow : shrink, { shift });
|
|
200
|
+
const now = await this.getPosition();
|
|
201
|
+
if (now === before) {
|
|
202
|
+
throw new Error(`WrSplitterHarness.setPosition(${target}): the divider stopped moving at ${now}. A disabled ` +
|
|
203
|
+
'splitter ignores its arrows, which is the usual reason this happens.');
|
|
204
|
+
}
|
|
205
|
+
delta = target - now;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const landed = await this.getPosition();
|
|
209
|
+
if (landed !== target) {
|
|
210
|
+
throw new Error(`WrSplitterHarness.setPosition(${target}): the walk stopped at ${landed}. The arrows move in whole ` +
|
|
211
|
+
'units, so a position left on a fraction by a drag cannot be walked onto a whole number.');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Whether ArrowRight is currently the key that GROWS the position — true in LTR,
|
|
216
|
+
* false in RTL, where the start pane is the one on the right.
|
|
217
|
+
*
|
|
218
|
+
* Probed rather than read off `Directionality`, which a harness has no access to:
|
|
219
|
+
* press, look, and put it back. At the top of the range a growing press is clamped
|
|
220
|
+
* and would prove nothing, so the probe presses the other way there instead. The
|
|
221
|
+
* undo is conditional — a press that moved nothing has nothing to undo, and undoing
|
|
222
|
+
* it anyway would move the divider by one in the direction the probe was testing.
|
|
223
|
+
*/
|
|
224
|
+
async growsWithRight() {
|
|
225
|
+
const before = await this.getPosition();
|
|
226
|
+
const atCeiling = before >= (await this.getMaxPosition());
|
|
227
|
+
const [probe, undo] = atCeiling ? ['left', 'right'] : ['right', 'left'];
|
|
228
|
+
await this.pressArrow(probe);
|
|
229
|
+
const after = await this.getPosition();
|
|
230
|
+
if (after !== before)
|
|
231
|
+
await this.pressArrow(undo);
|
|
232
|
+
return atCeiling ? after < before : after > before;
|
|
233
|
+
}
|
|
234
|
+
async numeric(attribute) {
|
|
235
|
+
const raw = await (await this.divider()).getAttribute(attribute);
|
|
236
|
+
return raw === null ? Number.NaN : Number(raw);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generated bundle index. Do not edit.
|
|
242
|
+
*/
|
|
243
|
+
|
|
244
|
+
export { WrSplitterHarness };
|
|
245
|
+
//# sourceMappingURL=ngwr-splitter-testing.mjs.map
|