@almoamendev/ngx-md3 0.2.1 → 0.3.4

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.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, booleanAttribute, effect, Directive, inject, signal, computed, Injectable, InjectionToken, ViewChild, Component, ViewContainerRef, Injector, viewChild, numberAttribute, HostListener, model, contentChild, contentChildren, output, ElementRef, HostBinding, TemplateRef } from '@angular/core';
3
- import { DOCUMENT, NgClass, NgTemplateOutlet } from '@angular/common';
2
+ import { input, booleanAttribute, effect, Directive, inject, signal, computed, Injectable, InjectionToken, ViewChild, Component, ViewContainerRef, Injector, viewChild, numberAttribute, HostListener, model, contentChild, contentChildren, PLATFORM_ID, afterNextRender, output, ElementRef, HostBinding, TemplateRef } from '@angular/core';
3
+ import { DOCUMENT, isPlatformBrowser, NgClass, NgTemplateOutlet } from '@angular/common';
4
4
  import { BreakpointObserver } from '@angular/cdk/layout';
5
5
  import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
6
  import * as i1 from '@angular/router';
@@ -9,10 +9,11 @@ import { fromEvent, startWith, map, filter, Subscription, Subject, merge, take }
9
9
  import { ComponentPortal, CdkPortalOutlet } from '@angular/cdk/portal';
10
10
  import * as i2 from '@angular/cdk/scrolling';
11
11
  import { CdkScrollable } from '@angular/cdk/scrolling';
12
+ import { Directionality } from '@angular/cdk/bidi';
12
13
  import { FormControlName } from '@angular/forms';
13
14
  import { CdkDialogContainer, Dialog as Dialog$1 } from '@angular/cdk/dialog';
14
15
  import { hasModifierKey } from '@angular/cdk/keycodes';
15
- import { Overlay, OverlayConfig } from '@angular/cdk/overlay';
16
+ import { Overlay, OverlayContainer, OverlayConfig } from '@angular/cdk/overlay';
16
17
 
17
18
  class TypeDisplay {
18
19
  el;
@@ -1553,6 +1554,947 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImpor
1553
1554
  ], template: "<ng-content></ng-content>\n", styles: [":host{--background-color: rgb(var(--md-scheme-surface-container-low));--foreground-color: rgb(var(--md-scheme-on-surface));--state-color: var(--md-scheme-on-surface);--border-radius: var(--md-border-radius-medium);--border-size: 0em;--outline-color: rgb(var(--md-scheme-secondary));--outline-width: .1875em;--outline-offset: -.125em;font-size:1rem;border-radius:var(--md-border-radius-medium);display:block;width:100%;text-align:start;padding:0;border:none;text-decoration:none;background-color:var(--background-color);color:var(--foreground-color);box-shadow:var(--md-shadow-1dp);transition:all var(--md-motion-expressive-default-spatial-duration) var(--md-motion-expressive-default-spatial-easing)}:host.md3-interactive:hover{box-shadow:var(--md-shadow-3dp)}:host.md3-interactive:active,:host.md3-interactive:focus{box-shadow:var(--md-shadow-1dp)}:host.md3-filled{--background-color: rgb(var(--md-scheme-surface-container-highest));box-shadow:var(--md-shadow-0dp)}:host.md3-filled.md3-interactive:hover{box-shadow:var(--md-shadow-1dp)}:host.md3-filled.md3-interactive:active,:host.md3-filled.md3-interactive:focus{box-shadow:var(--md-shadow-0dp)}:host.md3-outlined{--background-color: rgb(var(--md-scheme-surface));--border-size: .0625em;box-shadow:var(--md-shadow-0dp);border:var(--border-size) solid rgb(var(--md-scheme-outline-variant))}:host.md3-outlined.md3-interactive:hover{box-shadow:var(--md-shadow-1dp)}:host.md3-outlined.md3-interactive:active{box-shadow:var(--md-shadow-0dp)}:host.md3-outlined.md3-interactive:focus{box-shadow:var(--md-shadow-0dp);border-color:rgb(var(--md-scheme-on-surface))}\n"] }]
1554
1555
  }], ctorParameters: () => [{ type: i0.ElementRef }, { type: StateComponent }], propDecorators: { cardType: [{ type: i0.Input, args: [{ isSignal: true, alias: "card-type", required: false }] }], isInteractive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }] } });
1555
1556
 
1557
+ /** Fraction of its own size a medium item may flex by to protect the large item's target size. */
1558
+ const MEDIUM_FLEX = 0.1;
1559
+ function clamp(value, min, max) {
1560
+ return Math.min(Math.max(value, min), max);
1561
+ }
1562
+ /**
1563
+ * Solves for the large item size given that medium items are always the mean of large and small.
1564
+ *
1565
+ * containerSize = largeSize * largeCount
1566
+ * + ((largeSize + smallSize) / 2) * mediumCount
1567
+ * + smallSize * smallCount
1568
+ */
1569
+ function solveLargeSize(containerSize, smallSize, smallCount, mediumCount, largeCount) {
1570
+ const smallContribution = (smallCount + mediumCount / 2) * smallSize;
1571
+ return (containerSize - smallContribution) / (largeCount + mediumCount / 2);
1572
+ }
1573
+ /**
1574
+ * Shrinks or grows a candidate until it exactly fills the container, disturbing the large items
1575
+ * as little as possible: small items absorb the difference first, then medium items flex, and
1576
+ * only what is left over changes the large size.
1577
+ */
1578
+ function fit(containerSize, targetLargeSize, smallSizeMin, smallSizeMax, smallCount, mediumCount, largeCount, targetSmallSize, priority) {
1579
+ let smallSize = clamp(targetSmallSize, smallSizeMin, smallSizeMax);
1580
+ let largeSize = targetLargeSize;
1581
+ let mediumSize = (targetLargeSize + smallSize) / 2;
1582
+ const occupied = largeSize * largeCount + mediumSize * mediumCount + smallSize * smallCount;
1583
+ const delta = containerSize - occupied;
1584
+ if (smallCount > 0 && delta > 0) {
1585
+ smallSize += Math.min(delta / smallCount, smallSizeMax - smallSize);
1586
+ }
1587
+ else if (smallCount > 0 && delta < 0) {
1588
+ smallSize += Math.max(delta / smallCount, smallSizeMin - smallSize);
1589
+ }
1590
+ if (smallCount === 0) {
1591
+ smallSize = 0;
1592
+ }
1593
+ largeSize = solveLargeSize(containerSize, smallSize, smallCount, mediumCount, largeCount);
1594
+ mediumSize = (largeSize + smallSize) / 2;
1595
+ // Give the large items back as much of their target size as the medium items can spare.
1596
+ if (mediumCount > 0 && largeSize !== targetLargeSize) {
1597
+ const targetAdjustment = (targetLargeSize - largeSize) * largeCount;
1598
+ const availableFlex = mediumSize * MEDIUM_FLEX * mediumCount;
1599
+ const distribute = Math.min(Math.abs(targetAdjustment), availableFlex);
1600
+ const direction = targetAdjustment > 0 ? 1 : -1;
1601
+ mediumSize -= (direction * distribute) / mediumCount;
1602
+ largeSize += (direction * distribute) / largeCount;
1603
+ }
1604
+ return {
1605
+ largeSize,
1606
+ largeCount,
1607
+ mediumSize,
1608
+ mediumCount,
1609
+ smallSize,
1610
+ smallCount,
1611
+ cost: cost(targetLargeSize, largeSize, mediumSize, smallSize, largeCount, mediumCount, smallCount, priority),
1612
+ };
1613
+ }
1614
+ function isValid(largeSize, mediumSize, smallSize, largeCount, mediumCount, smallCount) {
1615
+ if (largeSize <= 0) {
1616
+ return false;
1617
+ }
1618
+ if (largeCount > 0 && smallCount > 0 && mediumCount > 0) {
1619
+ return largeSize > mediumSize && mediumSize > smallSize;
1620
+ }
1621
+ if (largeCount > 0 && smallCount > 0) {
1622
+ return largeSize > smallSize;
1623
+ }
1624
+ return true;
1625
+ }
1626
+ function cost(targetLargeSize, largeSize, mediumSize, smallSize, largeCount, mediumCount, smallCount, priority) {
1627
+ if (!isValid(largeSize, mediumSize, smallSize, largeCount, mediumCount, smallCount)) {
1628
+ return Number.POSITIVE_INFINITY;
1629
+ }
1630
+ // Prefer arrangements that appear earlier in the priority order and that leave the large
1631
+ // item closest to the size the consumer asked for.
1632
+ return Math.abs(targetLargeSize - largeSize) * priority;
1633
+ }
1634
+ /**
1635
+ * Fits every permutation of the supplied counts and returns the cheapest.
1636
+ *
1637
+ * Permutations are generated in priority order, so a zero-cost candidate is provably optimal
1638
+ * and ends the search early.
1639
+ */
1640
+ function findLowestCostArrangement(containerSize, targetLargeSize, targetSmallSize, smallSizeMin, smallSizeMax, smallCounts, mediumCounts, largeCounts) {
1641
+ let best;
1642
+ let priority = 1;
1643
+ for (const largeCount of largeCounts) {
1644
+ for (const mediumCount of mediumCounts) {
1645
+ for (const smallCount of smallCounts) {
1646
+ const candidate = fit(containerSize, targetLargeSize, smallSizeMin, smallSizeMax, smallCount, mediumCount, largeCount, targetSmallSize, priority);
1647
+ if (!best || candidate.cost < best.cost) {
1648
+ best = candidate;
1649
+ if (best.cost === 0) {
1650
+ return best;
1651
+ }
1652
+ }
1653
+ priority++;
1654
+ }
1655
+ }
1656
+ }
1657
+ // Every permutation was invalid; fall back to a single full-width item.
1658
+ return best ?? {
1659
+ largeSize: containerSize,
1660
+ largeCount: 1,
1661
+ mediumSize: 0,
1662
+ mediumCount: 0,
1663
+ smallSize: 0,
1664
+ smallCount: 0,
1665
+ cost: Number.POSITIVE_INFINITY,
1666
+ };
1667
+ }
1668
+ /**
1669
+ * Trims keylines until there are no more of them than there are items, so a short list never
1670
+ * leaves empty slots. Small items go first, then medium — never large, since large items are
1671
+ * already fully unmasked.
1672
+ *
1673
+ * Returns true when the counts changed and the arrangement needs re-solving.
1674
+ */
1675
+ function trimToItemCount(candidate, itemCount) {
1676
+ let surplus = candidate.smallCount + candidate.mediumCount + candidate.largeCount - itemCount;
1677
+ let changed = false;
1678
+ while (surplus > 0 && (candidate.smallCount > 0 || candidate.mediumCount > 1)) {
1679
+ if (candidate.smallCount > 0) {
1680
+ candidate.smallCount--;
1681
+ }
1682
+ else {
1683
+ candidate.mediumCount--;
1684
+ }
1685
+ changed = true;
1686
+ surplus--;
1687
+ }
1688
+ return changed;
1689
+ }
1690
+ /**
1691
+ * The Material Design 3 multi-browse layout: a run of large items followed by a medium and a
1692
+ * small item, sized so the whole arrangement fills the container exactly.
1693
+ *
1694
+ * Ported from `MultiBrowseCarouselStrategy` and `Arrangement` in Material Components for
1695
+ * Android so the two implementations agree on sizing.
1696
+ */
1697
+ const multiBrowseStrategy = {
1698
+ // Multi-browse resizes items, so it only reads correctly with items resting on keylines.
1699
+ snap: true,
1700
+ arrange(context) {
1701
+ const { containerSize, itemSize, smallSizeMin, smallSizeMax, itemCount, alignment } = context;
1702
+ const maxSmallSize = Math.max(smallSizeMax, smallSizeMin);
1703
+ const targetLargeSize = Math.min(itemSize, containerSize);
1704
+ // A small item ideally reads as a third of a large one, held within its allowed range.
1705
+ const targetSmallSize = clamp(itemSize / 3, smallSizeMin, maxSmallSize);
1706
+ const targetMediumSize = (targetLargeSize + targetSmallSize) / 2;
1707
+ let smallCounts = containerSize <= smallSizeMin * 2 ? [0] : [1];
1708
+ let mediumCounts = [1, 0];
1709
+ // A centred focal range needs matching keylines on both sides of it.
1710
+ if (alignment === 'center') {
1711
+ smallCounts = smallCounts.map((count) => count * 2);
1712
+ mediumCounts = mediumCounts.map((count) => count * 2);
1713
+ }
1714
+ const minLargeSpace = containerSize
1715
+ - targetMediumSize * Math.max(...mediumCounts)
1716
+ - maxSmallSize * Math.max(...smallCounts);
1717
+ const largeCountMin = Math.max(1, Math.floor(minLargeSpace / targetLargeSize));
1718
+ const largeCountMax = Math.max(largeCountMin, Math.ceil(containerSize / targetLargeSize));
1719
+ const largeCounts = [];
1720
+ for (let count = largeCountMax; count >= largeCountMin; count--) {
1721
+ largeCounts.push(count);
1722
+ }
1723
+ let arrangement = findLowestCostArrangement(containerSize, targetLargeSize, targetSmallSize, smallSizeMin, maxSmallSize, smallCounts, mediumCounts, largeCounts);
1724
+ let resolve = trimToItemCount(arrangement, itemCount);
1725
+ // An arrangement of nothing but large items has no visual hint that the list continues,
1726
+ // so force a small item back in whenever there is room for one.
1727
+ if (arrangement.mediumCount === 0 && arrangement.smallCount === 0 && containerSize > 2 * smallSizeMin) {
1728
+ arrangement.smallCount = 1;
1729
+ resolve = true;
1730
+ }
1731
+ if (resolve) {
1732
+ arrangement = findLowestCostArrangement(containerSize, targetLargeSize, targetSmallSize, smallSizeMin, maxSmallSize, [arrangement.smallCount], [arrangement.mediumCount], [arrangement.largeCount]);
1733
+ }
1734
+ return {
1735
+ largeSize: arrangement.largeSize,
1736
+ largeCount: arrangement.largeCount,
1737
+ mediumSize: arrangement.mediumSize,
1738
+ mediumCount: arrangement.mediumCount,
1739
+ smallSize: arrangement.smallSize,
1740
+ smallCount: arrangement.smallCount,
1741
+ };
1742
+ },
1743
+ };
1744
+ /**
1745
+ * Registry of the available carousel layouts.
1746
+ *
1747
+ * Adding a layout means implementing {@link CarouselStrategy}, widening `CarouselLayout` and
1748
+ * adding the entry here — no changes to the component itself.
1749
+ */
1750
+ const CAROUSEL_STRATEGIES = {
1751
+ 'multi-browse': multiBrowseStrategy,
1752
+ };
1753
+
1754
+ /** Anchor size fallback, as a fraction of the large item, when an arrangement has no small items. */
1755
+ const ANCHOR_FALLBACK_RATIO = 0.2;
1756
+ function lerp(from, to, progress) {
1757
+ return from + (to - from) * progress;
1758
+ }
1759
+ /**
1760
+ * Lays out one shift step.
1761
+ *
1762
+ * Keylines before the focal range ascend in size towards it and keylines after it descend away
1763
+ * from it, which is what keeps the arrangement looking balanced as it shifts. An off-screen
1764
+ * anchor sits at each end so items have somewhere to shrink into rather than popping out.
1765
+ */
1766
+ function buildState(step, arrangement, anchorSize) {
1767
+ const { largeSize, largeCount, mediumSize, mediumCount, smallSize, smallCount } = arrangement;
1768
+ const before = [
1769
+ ...new Array(step.smallBefore).fill(smallSize),
1770
+ ...new Array(step.mediumBefore).fill(mediumSize),
1771
+ ];
1772
+ const after = [
1773
+ ...new Array(mediumCount - step.mediumBefore).fill(mediumSize),
1774
+ ...new Array(smallCount - step.smallBefore).fill(smallSize),
1775
+ ];
1776
+ const sizes = [
1777
+ anchorSize,
1778
+ ...before,
1779
+ ...new Array(largeCount).fill(largeSize),
1780
+ ...after,
1781
+ anchorSize,
1782
+ ];
1783
+ const firstFocalIndex = 1 + before.length;
1784
+ const lastFocalIndex = firstFocalIndex + largeCount - 1;
1785
+ // The leading anchor occupies [-anchorSize, 0] so the first real keyline starts at 0.
1786
+ let cursor = -anchorSize;
1787
+ const screenLocs = sizes.map((size) => {
1788
+ const loc = cursor + size / 2;
1789
+ cursor += size;
1790
+ return loc;
1791
+ });
1792
+ // In scroll space every item occupies exactly `largeSize`, so keylines are evenly spaced.
1793
+ // Anchoring that spacing to the first focal keyline keeps screen and scroll space aligned
1794
+ // wherever an item is fully unmasked.
1795
+ const focalLoc = screenLocs[firstFocalIndex];
1796
+ const keylines = sizes.map((size, index) => ({
1797
+ scrollLoc: focalLoc + (index - firstFocalIndex) * largeSize,
1798
+ screenLoc: screenLocs[index],
1799
+ maskedSize: size,
1800
+ isFocal: index >= firstFocalIndex && index <= lastFocalIndex,
1801
+ isAnchor: index === 0 || index === sizes.length - 1,
1802
+ }));
1803
+ return {
1804
+ keylines,
1805
+ focalStart: focalLoc - largeSize / 2,
1806
+ firstFocalIndex,
1807
+ lastFocalIndex,
1808
+ };
1809
+ }
1810
+ /**
1811
+ * Enumerates every shift step from "focal range at the start of the container" through to
1812
+ * "focal range at the end", ordered by ascending `focalStart`.
1813
+ *
1814
+ * Each step moves exactly one keyline across the focal range, which is what guarantees every
1815
+ * item passes through the focal range as the carousel scrolls. Smaller keylines migrate first,
1816
+ * so items pile up smallest-outermost at whichever edge they are collecting against.
1817
+ */
1818
+ function buildSteps(arrangement, alignment) {
1819
+ const { smallCount, mediumCount } = arrangement;
1820
+ const restingStep = alignment === 'center'
1821
+ ? { smallBefore: Math.floor(smallCount / 2), mediumBefore: Math.floor(mediumCount / 2) }
1822
+ : { smallBefore: 0, mediumBefore: 0 };
1823
+ // Walk backwards from the resting step, retiring the smallest leading keyline each time.
1824
+ const towardsStart = [restingStep];
1825
+ let { smallBefore, mediumBefore } = restingStep;
1826
+ while (smallBefore > 0 || mediumBefore > 0) {
1827
+ if (smallBefore > 0) {
1828
+ smallBefore--;
1829
+ }
1830
+ else {
1831
+ mediumBefore--;
1832
+ }
1833
+ towardsStart.push({ smallBefore, mediumBefore });
1834
+ }
1835
+ // Walk forwards, promoting the smallest trailing keyline each time.
1836
+ const towardsEnd = [restingStep];
1837
+ ({ smallBefore, mediumBefore } = restingStep);
1838
+ while (smallBefore + mediumBefore < smallCount + mediumCount) {
1839
+ if (smallBefore < smallCount) {
1840
+ smallBefore++;
1841
+ }
1842
+ else {
1843
+ mediumBefore++;
1844
+ }
1845
+ towardsEnd.push({ smallBefore, mediumBefore });
1846
+ }
1847
+ const ordered = [...towardsStart.slice(1).reverse(), ...towardsEnd];
1848
+ const anchorSize = arrangement.smallSize > 0
1849
+ ? arrangement.smallSize
1850
+ : arrangement.largeSize * ANCHOR_FALLBACK_RATIO;
1851
+ return {
1852
+ steps: ordered.map((step) => buildState(step, arrangement, anchorSize)),
1853
+ defaultStep: towardsStart.length - 1,
1854
+ };
1855
+ }
1856
+ /**
1857
+ * Turns a solved arrangement into everything the component needs to place items.
1858
+ */
1859
+ function buildGeometry(arrangement, alignment, itemCount, containerSize) {
1860
+ const { steps, defaultStep } = buildSteps(arrangement, alignment);
1861
+ const resting = steps[defaultStep];
1862
+ const startShiftRange = resting.focalStart - steps[0].focalStart;
1863
+ const endShiftRange = steps[steps.length - 1].focalStart - resting.focalStart;
1864
+ // Each item owns exactly `largeSize` of scroll range, and the final `largeCount` items share
1865
+ // the focal region, so they need no scroll range of their own.
1866
+ const lastIndex = Math.max(0, itemCount - arrangement.largeCount);
1867
+ const maxScroll = lastIndex * arrangement.largeSize;
1868
+ return {
1869
+ arrangement,
1870
+ steps,
1871
+ defaultStep,
1872
+ itemSize: arrangement.largeSize,
1873
+ startShiftRange,
1874
+ endShiftRange,
1875
+ maxScroll,
1876
+ lastIndex,
1877
+ scrollSize: maxScroll + containerSize,
1878
+ };
1879
+ }
1880
+ /**
1881
+ * Where the focal range sits for a given scroll offset.
1882
+ *
1883
+ * It rests at its default position through the middle of the list and slides towards whichever
1884
+ * edge is being approached, so the first and last items can reach the focal range without
1885
+ * detaching from the container edges.
1886
+ */
1887
+ function focalStartFor(geometry, scrollOffset) {
1888
+ const resting = geometry.steps[geometry.defaultStep].focalStart;
1889
+ const first = geometry.steps[0].focalStart;
1890
+ const last = geometry.steps[geometry.steps.length - 1].focalStart;
1891
+ const startShift = Math.max(0, geometry.startShiftRange - scrollOffset);
1892
+ const endShift = Math.max(0, scrollOffset - (geometry.maxScroll - geometry.endShiftRange));
1893
+ return Math.min(Math.max(resting - startShift + endShift, first), last);
1894
+ }
1895
+ /**
1896
+ * Interpolates the two shift steps bracketing `focalStart` into a single state.
1897
+ *
1898
+ * Every step holds the same number of keylines, so this is a straight index-by-index blend.
1899
+ */
1900
+ function stateAt(geometry, focalStart) {
1901
+ const { steps } = geometry;
1902
+ let upper = 1;
1903
+ while (upper < steps.length && steps[upper].focalStart < focalStart) {
1904
+ upper++;
1905
+ }
1906
+ if (upper >= steps.length) {
1907
+ return steps[steps.length - 1];
1908
+ }
1909
+ const from = steps[upper - 1];
1910
+ const to = steps[upper];
1911
+ const span = to.focalStart - from.focalStart;
1912
+ if (span <= 0) {
1913
+ return from;
1914
+ }
1915
+ const progress = Math.min(Math.max((focalStart - from.focalStart) / span, 0), 1);
1916
+ return {
1917
+ focalStart,
1918
+ firstFocalIndex: progress < 0.5 ? from.firstFocalIndex : to.firstFocalIndex,
1919
+ lastFocalIndex: progress < 0.5 ? from.lastFocalIndex : to.lastFocalIndex,
1920
+ keylines: from.keylines.map((keyline, index) => {
1921
+ const target = to.keylines[index];
1922
+ return {
1923
+ scrollLoc: lerp(keyline.scrollLoc, target.scrollLoc, progress),
1924
+ screenLoc: lerp(keyline.screenLoc, target.screenLoc, progress),
1925
+ maskedSize: lerp(keyline.maskedSize, target.maskedSize, progress),
1926
+ isFocal: progress < 0.5 ? keyline.isFocal : target.isFocal,
1927
+ isAnchor: keyline.isAnchor && target.isAnchor,
1928
+ };
1929
+ }),
1930
+ };
1931
+ }
1932
+ /**
1933
+ * Resolves the keyline arrangement in effect at a given scroll offset.
1934
+ */
1935
+ function resolveState(geometry, scrollOffset) {
1936
+ return stateAt(geometry, focalStartFor(geometry, scrollOffset));
1937
+ }
1938
+ /**
1939
+ * Finds the keylines either side of a scroll-space position, and how far between them it sits.
1940
+ */
1941
+ function keylineRange(keylines, scrollCenter) {
1942
+ let upper = 1;
1943
+ while (upper < keylines.length - 1 && keylines[upper].scrollLoc < scrollCenter) {
1944
+ upper++;
1945
+ }
1946
+ const from = keylines[upper - 1];
1947
+ const to = keylines[upper];
1948
+ const span = to.scrollLoc - from.scrollLoc;
1949
+ const progress = span <= 0 ? 0 : Math.min(Math.max((scrollCenter - from.scrollLoc) / span, 0), 1);
1950
+ return [from, to, progress];
1951
+ }
1952
+ /**
1953
+ * Classifies a rendered size against the arrangement's three sizes.
1954
+ *
1955
+ * Items resize continuously, so this snaps to whichever size the item currently reads as,
1956
+ * flipping at the midpoint between one size and the next.
1957
+ */
1958
+ function sizeBandFor(maskedSize, geometry) {
1959
+ const { largeSize, mediumSize, smallSize } = geometry.arrangement;
1960
+ if (maskedSize >= (largeSize + mediumSize) / 2) {
1961
+ return 'large';
1962
+ }
1963
+ if (maskedSize >= (mediumSize + smallSize) / 2) {
1964
+ return 'medium';
1965
+ }
1966
+ return 'small';
1967
+ }
1968
+ /**
1969
+ * Places one item.
1970
+ *
1971
+ * The item's centre is a plain linear function of the scroll offset in scroll space; looking
1972
+ * that position up against the current keylines is what converts it into a rendered size and
1973
+ * an on-screen position.
1974
+ */
1975
+ function resolveItemGeometry(geometry, state, index, scrollOffset) {
1976
+ const { itemSize } = geometry;
1977
+ const { keylines } = state;
1978
+ const scrollCenter = index * itemSize + itemSize / 2 - scrollOffset + state.focalStart;
1979
+ const first = keylines[0];
1980
+ const last = keylines[keylines.length - 1];
1981
+ if (scrollCenter < first.scrollLoc || scrollCenter > last.scrollLoc) {
1982
+ return {
1983
+ offset: 0,
1984
+ maskedSize: 0,
1985
+ size: 'small',
1986
+ maskRatio: 1,
1987
+ isFocal: false,
1988
+ isVisible: false,
1989
+ };
1990
+ }
1991
+ const [from, to, progress] = keylineRange(keylines, scrollCenter);
1992
+ const maskedSize = lerp(from.maskedSize, to.maskedSize, progress);
1993
+ const screenCenter = lerp(from.screenLoc, to.screenLoc, progress);
1994
+ return {
1995
+ offset: screenCenter - maskedSize / 2,
1996
+ maskedSize,
1997
+ size: sizeBandFor(maskedSize, geometry),
1998
+ maskRatio: itemSize <= 0 ? 1 : Math.min(Math.max(1 - maskedSize / itemSize, 0), 1),
1999
+ isFocal: (from.isFocal && progress < 0.5) || (to.isFocal && progress >= 0.5),
2000
+ isVisible: maskedSize > 0.5,
2001
+ };
2002
+ }
2003
+ /**
2004
+ * Clamps an index to one the carousel can actually come to rest on.
2005
+ */
2006
+ function clampIndex(geometry, index) {
2007
+ return Math.min(Math.max(Math.round(index), 0), geometry.lastIndex);
2008
+ }
2009
+ /**
2010
+ * Scroll offset at which `index` leads the focal range.
2011
+ *
2012
+ * The shifting focal range cancels out here, so this stays a simple multiple regardless of
2013
+ * alignment or how close to either end of the list the item is.
2014
+ */
2015
+ function scrollOffsetForIndex(geometry, index) {
2016
+ return clampIndex(geometry, index) * geometry.itemSize;
2017
+ }
2018
+ /**
2019
+ * The item leading the focal range at a given scroll offset.
2020
+ */
2021
+ function indexForScrollOffset(geometry, scrollOffset) {
2022
+ if (geometry.itemSize <= 0) {
2023
+ return 0;
2024
+ }
2025
+ return clampIndex(geometry, scrollOffset / geometry.itemSize);
2026
+ }
2027
+
2028
+ /**
2029
+ * A single item within an `md3-carousel`.
2030
+ *
2031
+ * The item is a clipping box whose content stays at full size, so scrolling crops the item
2032
+ * towards its centre rather than squashing it. Content should be full-bleed — an image or a
2033
+ * background that reaches the edges — or the crop will reveal empty space.
2034
+ *
2035
+ * Position and size are written straight to CSS custom properties by the parent carousel, so
2036
+ * styling never waits on change detection. The same values are mirrored onto signals and onto
2037
+ * `md3-large` / `md3-medium` / `md3-small` classes, so content can react to the item's size
2038
+ * either declaratively or from a stylesheet.
2039
+ */
2040
+ class CarouselItem {
2041
+ el;
2042
+ /** 0 while the item is fully unmasked, approaching 1 as it crops away. */
2043
+ maskRatio = signal(0, /* @ts-ignore */
2044
+ ...(ngDevMode ? [{ debugName: "maskRatio" }] : /* istanbul ignore next */ []));
2045
+ /** Current rendered size of the item, in pixels. */
2046
+ maskedSize = signal(0, /* @ts-ignore */
2047
+ ...(ngDevMode ? [{ debugName: "maskedSize" }] : /* istanbul ignore next */ []));
2048
+ /** True while the item rests in the carousel's focal range. */
2049
+ isFocal = signal(false, /* @ts-ignore */
2050
+ ...(ngDevMode ? [{ debugName: "isFocal" }] : /* istanbul ignore next */ []));
2051
+ /**
2052
+ * Which of the arrangement's three sizes the item currently reads as.
2053
+ *
2054
+ * Mirrored onto the element as `md3-large`, `md3-medium` or `md3-small`.
2055
+ */
2056
+ size = signal('large', /* @ts-ignore */
2057
+ ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
2058
+ constructor(el) {
2059
+ this.el = el;
2060
+ effect((onCleanup) => {
2061
+ const size = 'md3-' + this.size();
2062
+ this.element.classList.add(size);
2063
+ onCleanup(() => {
2064
+ this.element.classList.remove(size);
2065
+ });
2066
+ });
2067
+ }
2068
+ get element() {
2069
+ return this.el.nativeElement;
2070
+ }
2071
+ /**
2072
+ * Applies a resolved placement.
2073
+ *
2074
+ * Called from the carousel's scroll handler on every frame, so this writes to the DOM
2075
+ * directly and only touches signals when a value actually changes.
2076
+ */
2077
+ applyGeometry(geometry, itemSize) {
2078
+ const style = this.element.style;
2079
+ if (!geometry.isVisible) {
2080
+ this.element.setAttribute('hidden', '');
2081
+ return;
2082
+ }
2083
+ this.element.removeAttribute('hidden');
2084
+ style.setProperty('--md3-carousel-item-size', `${geometry.maskedSize}px`);
2085
+ style.setProperty('--md3-carousel-item-full-size', `${itemSize}px`);
2086
+ style.setProperty('--md3-carousel-item-offset', `${geometry.offset}px`);
2087
+ style.setProperty('--md3-carousel-item-mask-ratio', `${geometry.maskRatio}`);
2088
+ if (this.maskedSize() !== geometry.maskedSize) {
2089
+ this.maskedSize.set(geometry.maskedSize);
2090
+ }
2091
+ if (this.maskRatio() !== geometry.maskRatio) {
2092
+ this.maskRatio.set(geometry.maskRatio);
2093
+ }
2094
+ if (this.size() !== geometry.size) {
2095
+ this.size.set(geometry.size);
2096
+ }
2097
+ if (this.isFocal() !== geometry.isFocal) {
2098
+ this.isFocal.set(geometry.isFocal);
2099
+ this.element.classList.toggle('md3-focal', geometry.isFocal);
2100
+ }
2101
+ }
2102
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: CarouselItem, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Component });
2103
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.0", type: CarouselItem, isStandalone: true, selector: "md3-carousel-item", host: { attributes: { "role": "group", "aria-roledescription": "slide" } }, ngImport: i0, template: "<div class=\"md3-carousel-item-content\">\n <ng-content></ng-content>\n</div>\n", styles: [":host{--md3-carousel-item-size: 0px;--md3-carousel-item-full-size: 0px;--md3-carousel-item-offset: 0px;--md3-carousel-item-mask-ratio: 0;--background-color: rgb(var(--md-scheme-surface-container-high));--border-radius: var(--md-border-radius-xlarge);--md3-carousel-item-gap: var(--md3-carousel-gap, 0px);--md3-carousel-item-visible-size: max( 0px, calc(var(--md3-carousel-item-size) - var(--md3-carousel-item-gap)) );--md3-carousel-item-visible-full-size: max( 0px, calc(var(--md3-carousel-item-full-size) - var(--md3-carousel-item-gap)) );position:absolute;inset-block:0;inset-inline-start:calc(var(--md3-carousel-item-offset) + var(--md3-carousel-item-gap) / 2);inline-size:var(--md3-carousel-item-visible-size);overflow:hidden;border-radius:var(--border-radius);background-color:var(--background-color);contain:paint}:host([hidden]){display:none}.md3-carousel-item-content{position:absolute;inset-block:0;inset-inline-start:50%;inline-size:var(--md3-carousel-item-visible-full-size);margin-inline-start:calc(var(--md3-carousel-item-visible-full-size) / -2)}:host ::ng-deep .md3-carousel-item-content>img,:host ::ng-deep .md3-carousel-item-content>video,:host ::ng-deep .md3-carousel-item-content>picture{display:block;inline-size:100%;block-size:100%;object-fit:cover}\n"] });
2104
+ }
2105
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: CarouselItem, decorators: [{
2106
+ type: Component,
2107
+ args: [{ selector: 'md3-carousel-item', host: {
2108
+ 'role': 'group',
2109
+ 'aria-roledescription': 'slide',
2110
+ }, template: "<div class=\"md3-carousel-item-content\">\n <ng-content></ng-content>\n</div>\n", styles: [":host{--md3-carousel-item-size: 0px;--md3-carousel-item-full-size: 0px;--md3-carousel-item-offset: 0px;--md3-carousel-item-mask-ratio: 0;--background-color: rgb(var(--md-scheme-surface-container-high));--border-radius: var(--md-border-radius-xlarge);--md3-carousel-item-gap: var(--md3-carousel-gap, 0px);--md3-carousel-item-visible-size: max( 0px, calc(var(--md3-carousel-item-size) - var(--md3-carousel-item-gap)) );--md3-carousel-item-visible-full-size: max( 0px, calc(var(--md3-carousel-item-full-size) - var(--md3-carousel-item-gap)) );position:absolute;inset-block:0;inset-inline-start:calc(var(--md3-carousel-item-offset) + var(--md3-carousel-item-gap) / 2);inline-size:var(--md3-carousel-item-visible-size);overflow:hidden;border-radius:var(--border-radius);background-color:var(--background-color);contain:paint}:host([hidden]){display:none}.md3-carousel-item-content{position:absolute;inset-block:0;inset-inline-start:50%;inline-size:var(--md3-carousel-item-visible-full-size);margin-inline-start:calc(var(--md3-carousel-item-visible-full-size) / -2)}:host ::ng-deep .md3-carousel-item-content>img,:host ::ng-deep .md3-carousel-item-content>video,:host ::ng-deep .md3-carousel-item-content>picture{display:block;inline-size:100%;block-size:100%;object-fit:cover}\n"] }]
2111
+ }], ctorParameters: () => [{ type: i0.ElementRef }] });
2112
+
2113
+ /** Grace period after a programmatic scroll before the index is synced back from the DOM. */
2114
+ const SCROLL_END_FALLBACK = 120;
2115
+ /**
2116
+ * A Material Design 3 carousel.
2117
+ *
2118
+ * Items are laid out against a set of keylines — large, medium, small and an off-screen anchor —
2119
+ * and adopt the size of whichever keyline they are passing through, so they grow into the focal
2120
+ * range and crop away as they leave it.
2121
+ *
2122
+ * Scrolling is native, which keeps momentum, touch, snapping and the scrollbar intact. Only the
2123
+ * visual sizing is derived in TypeScript, and it is written straight to CSS custom properties so
2124
+ * scrolling never triggers change detection.
2125
+ *
2126
+ * All size inputs are in pixels.
2127
+ *
2128
+ * ```html
2129
+ * <md3-carousel [(index)]="selected" [item-size]="200">
2130
+ * @for (photo of photos(); track photo.id) {
2131
+ * <md3-carousel-item>
2132
+ * <img [src]="photo.url" [alt]="photo.alt" />
2133
+ * </md3-carousel-item>
2134
+ * }
2135
+ * </md3-carousel>
2136
+ * ```
2137
+ */
2138
+ class Carousel {
2139
+ el;
2140
+ /** Arrangement strategy. Additional Material Design layouts will widen this type. */
2141
+ carouselLayout = input('multi-browse', { ...(ngDevMode ? { debugName: "carouselLayout" } : /* istanbul ignore next */ {}), alias: 'carousel-layout' });
2142
+ /** Where the focal (large) items sit within the container. */
2143
+ alignment = input('start', { ...(ngDevMode ? { debugName: "alignment" } : /* istanbul ignore next */ {}), alias: 'alignment' });
2144
+ /** Scroll axis. Only `horizontal` is implemented today. */
2145
+ orientation = input('horizontal', { ...(ngDevMode ? { debugName: "orientation" } : /* istanbul ignore next */ {}), alias: 'orientation' });
2146
+ /** Preferred size of a fully unmasked item, in pixels. */
2147
+ itemSize = input(200, { ...(ngDevMode ? { debugName: "itemSize" } : /* istanbul ignore next */ {}), alias: 'item-size',
2148
+ transform: numberAttribute });
2149
+ /** Smallest a small item may shrink to, in pixels. */
2150
+ smallItemSizeMin = input(40, { ...(ngDevMode ? { debugName: "smallItemSizeMin" } : /* istanbul ignore next */ {}), alias: 'small-item-size-min',
2151
+ transform: numberAttribute });
2152
+ /** Largest a small item may grow to, in pixels. */
2153
+ smallItemSizeMax = input(56, { ...(ngDevMode ? { debugName: "smallItemSizeMax" } : /* istanbul ignore next */ {}), alias: 'small-item-size-max',
2154
+ transform: numberAttribute });
2155
+ /** Space between items, in pixels. */
2156
+ gap = input(8, { ...(ngDevMode ? { debugName: "gap" } : /* istanbul ignore next */ {}), alias: 'gap',
2157
+ transform: numberAttribute });
2158
+ /**
2159
+ * Index of the item leading the focal range. Two-way bindable.
2160
+ *
2161
+ * Clamped to {@link lastIndex}: the final items share the focal range and are all visible at
2162
+ * once, so there is no scroll position where any of them leads on its own. Setting a higher
2163
+ * value scrolls to the end and reads back as `lastIndex`.
2164
+ */
2165
+ index = model(0, /* @ts-ignore */
2166
+ ...(ngDevMode ? [{ debugName: "index" }] : /* istanbul ignore next */ []));
2167
+ /** Items projected into the carousel, in document order. */
2168
+ items = contentChildren(CarouselItem, { ...(ngDevMode ? { debugName: "items" } : /* istanbul ignore next */ {}), descendants: true });
2169
+ /**
2170
+ * Whether the current layout brings items to rest on keylines.
2171
+ *
2172
+ * Decided by the layout, not by the consumer: an arrangement that resizes items only reads
2173
+ * correctly when they are sitting on keylines.
2174
+ */
2175
+ snap = computed(() => CAROUSEL_STRATEGIES[this.carouselLayout()].snap, /* @ts-ignore */
2176
+ ...(ngDevMode ? [{ debugName: "snap" }] : /* istanbul ignore next */ []));
2177
+ scroller = viewChild.required('scroller', /* @ts-ignore */
2178
+ ...(ngDevMode ? [{ debugName: "scroller" }] : /* istanbul ignore next */ []));
2179
+ containerSize = signal(0, /* @ts-ignore */
2180
+ ...(ngDevMode ? [{ debugName: "containerSize" }] : /* istanbul ignore next */ []));
2181
+ directionality = inject(Directionality, { optional: true });
2182
+ /**
2183
+ * Sizes handed to the solver.
2184
+ *
2185
+ * Gaps are folded into item sizes so the solver only has one quantity to balance, then
2186
+ * removed again visually by insetting each item half a gap on both sides.
2187
+ */
2188
+ metrics = computed(() => {
2189
+ const gap = this.gap();
2190
+ return {
2191
+ gap,
2192
+ itemSize: this.itemSize() + gap,
2193
+ smallSizeMin: this.smallItemSizeMin() + gap,
2194
+ smallSizeMax: this.smallItemSizeMax() + gap,
2195
+ };
2196
+ }, /* @ts-ignore */
2197
+ ...(ngDevMode ? [{ debugName: "metrics" }] : /* istanbul ignore next */ []));
2198
+ /** The solved distribution of large, medium and small items for the current container. */
2199
+ arrangement = computed(() => {
2200
+ const containerSize = this.containerSize();
2201
+ const itemCount = this.items().length;
2202
+ const { itemSize, smallSizeMin, smallSizeMax } = this.metrics();
2203
+ if (containerSize <= 0 || itemCount === 0 || itemSize <= 0) {
2204
+ return undefined;
2205
+ }
2206
+ return CAROUSEL_STRATEGIES[this.carouselLayout()].arrange({
2207
+ containerSize,
2208
+ itemSize,
2209
+ smallSizeMin,
2210
+ smallSizeMax,
2211
+ itemCount,
2212
+ alignment: this.alignment(),
2213
+ });
2214
+ }, /* @ts-ignore */
2215
+ ...(ngDevMode ? [{ debugName: "arrangement" }] : /* istanbul ignore next */ []));
2216
+ /** Keyline geometry derived from the arrangement. */
2217
+ geometry = computed(() => {
2218
+ const arrangement = this.arrangement();
2219
+ if (!arrangement) {
2220
+ return undefined;
2221
+ }
2222
+ return buildGeometry(arrangement, this.alignment(), this.items().length, this.containerSize());
2223
+ }, /* @ts-ignore */
2224
+ ...(ngDevMode ? [{ debugName: "geometry" }] : /* istanbul ignore next */ []));
2225
+ /** Scroll offsets that items snap to, one per item. */
2226
+ snapPoints = computed(() => {
2227
+ const geometry = this.geometry();
2228
+ if (!geometry) {
2229
+ return [];
2230
+ }
2231
+ return this.items().map((_, index) => scrollOffsetForIndex(geometry, index));
2232
+ }, /* @ts-ignore */
2233
+ ...(ngDevMode ? [{ debugName: "snapPoints" }] : /* istanbul ignore next */ []));
2234
+ /**
2235
+ * Highest index the carousel can rest on.
2236
+ *
2237
+ * Lower than the last item's index, because the trailing items share the focal range once
2238
+ * the carousel is scrolled to the end. It moves as the container is resized, since a wider
2239
+ * container fits more items in the focal range.
2240
+ */
2241
+ lastIndex = computed(() => this.geometry()?.lastIndex ?? 0, /* @ts-ignore */
2242
+ ...(ngDevMode ? [{ debugName: "lastIndex" }] : /* istanbul ignore next */ []));
2243
+ atStart = computed(() => this.index() <= 0, /* @ts-ignore */
2244
+ ...(ngDevMode ? [{ debugName: "atStart" }] : /* istanbul ignore next */ []));
2245
+ /** True when the carousel cannot scroll any further towards the end. */
2246
+ atEnd = computed(() => this.index() >= this.lastIndex(), /* @ts-ignore */
2247
+ ...(ngDevMode ? [{ debugName: "atEnd" }] : /* istanbul ignore next */ []));
2248
+ isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
2249
+ scrollEndTimer;
2250
+ frame = 0;
2251
+ /**
2252
+ * Target of an in-flight programmatic scroll.
2253
+ *
2254
+ * Smooth scrolling reports intermediate offsets, and reading the index back from those would
2255
+ * fight the animation, so index syncing pauses until the target is reached or the user takes
2256
+ * over.
2257
+ */
2258
+ pendingScroll;
2259
+ constructor(el) {
2260
+ this.el = el;
2261
+ // ResizeObserver alone would leave the carousel unmeasured until its first callback, so
2262
+ // take an initial measurement as soon as the view exists.
2263
+ afterNextRender(() => this.measure());
2264
+ effect((onCleanup) => {
2265
+ if (!this.isBrowser) {
2266
+ return;
2267
+ }
2268
+ const observer = new ResizeObserver(() => this.measure());
2269
+ observer.observe(this.element);
2270
+ onCleanup(() => observer.disconnect());
2271
+ });
2272
+ effect((onCleanup) => {
2273
+ if (!this.isBrowser) {
2274
+ return;
2275
+ }
2276
+ const scroller = this.scroller().nativeElement;
2277
+ const onScroll = () => this.scheduleGeometry();
2278
+ const onScrollEnd = () => this.syncIndexFromScroll();
2279
+ const onInteract = () => {
2280
+ this.pendingScroll = undefined;
2281
+ };
2282
+ scroller.addEventListener('scroll', onScroll, { passive: true });
2283
+ scroller.addEventListener('scrollend', onScrollEnd);
2284
+ scroller.addEventListener('pointerdown', onInteract, { passive: true });
2285
+ scroller.addEventListener('wheel', onInteract, { passive: true });
2286
+ scroller.addEventListener('touchstart', onInteract, { passive: true });
2287
+ onCleanup(() => {
2288
+ clearTimeout(this.scrollEndTimer);
2289
+ cancelAnimationFrame(this.frame);
2290
+ scroller.removeEventListener('scroll', onScroll);
2291
+ scroller.removeEventListener('scrollend', onScrollEnd);
2292
+ scroller.removeEventListener('pointerdown', onInteract);
2293
+ scroller.removeEventListener('wheel', onInteract);
2294
+ scroller.removeEventListener('touchstart', onInteract);
2295
+ });
2296
+ });
2297
+ // Re-place every item whenever the arrangement or the item list changes. Reading the
2298
+ // signals here is what registers the dependency.
2299
+ effect(() => {
2300
+ this.geometry();
2301
+ this.items();
2302
+ this.applyGeometry();
2303
+ });
2304
+ // Follow the index when it is set from outside, but not while the user is scrolling —
2305
+ // `syncIndexFromScroll` only writes a value the DOM already agrees with.
2306
+ effect(() => {
2307
+ const index = this.index();
2308
+ const geometry = this.geometry();
2309
+ if (!geometry || !this.isBrowser) {
2310
+ return;
2311
+ }
2312
+ // A resize changes how many items share the focal range, so an index that was
2313
+ // reachable before may no longer be.
2314
+ const clamped = clampIndex(geometry, index);
2315
+ if (clamped !== index) {
2316
+ this.index.set(clamped);
2317
+ return;
2318
+ }
2319
+ const target = scrollOffsetForIndex(geometry, clamped);
2320
+ if (Math.abs(this.scrollOffset() - target) > 1) {
2321
+ this.scrollTo(target, 'smooth');
2322
+ }
2323
+ });
2324
+ }
2325
+ get element() {
2326
+ return this.el.nativeElement;
2327
+ }
2328
+ /** True when laid out right-to-left. */
2329
+ get isRtl() {
2330
+ return this.directionality?.value === 'rtl'
2331
+ || getComputedStyle(this.element).direction === 'rtl';
2332
+ }
2333
+ onKeydown(event) {
2334
+ // Arrow keys are direction-agnostic here: ArrowRight always moves towards the visual
2335
+ // right, which under RTL is the previous item.
2336
+ const forward = this.isRtl ? 'ArrowLeft' : 'ArrowRight';
2337
+ const backward = this.isRtl ? 'ArrowRight' : 'ArrowLeft';
2338
+ switch (event.key) {
2339
+ case forward:
2340
+ this.next();
2341
+ break;
2342
+ case backward:
2343
+ this.previous();
2344
+ break;
2345
+ case 'Home':
2346
+ this.scrollToIndex(0);
2347
+ break;
2348
+ case 'End':
2349
+ this.scrollToIndex(this.lastIndex());
2350
+ break;
2351
+ default:
2352
+ return;
2353
+ }
2354
+ event.preventDefault();
2355
+ }
2356
+ /**
2357
+ * Keeps a focused item in view.
2358
+ *
2359
+ * Items are absolutely positioned, so the browser cannot scroll them into view on its own.
2360
+ * Without this, tabbing to a link inside a cropped item would leave it invisible.
2361
+ */
2362
+ onFocusIn(event) {
2363
+ const target = event.target;
2364
+ if (!(target instanceof Node)) {
2365
+ return;
2366
+ }
2367
+ const index = this.items().findIndex((item) => item.element.contains(target));
2368
+ if (index >= 0 && index !== this.index()) {
2369
+ this.scrollToIndex(index);
2370
+ }
2371
+ }
2372
+ /** Scrolls until `index` leads the focal range, clamping to {@link lastIndex}. */
2373
+ scrollToIndex(index, behavior = 'smooth') {
2374
+ const geometry = this.geometry();
2375
+ if (!geometry) {
2376
+ return;
2377
+ }
2378
+ const clamped = clampIndex(geometry, index);
2379
+ this.index.set(clamped);
2380
+ this.scrollTo(scrollOffsetForIndex(geometry, clamped), behavior);
2381
+ }
2382
+ next() {
2383
+ this.scrollToIndex(this.index() + 1);
2384
+ }
2385
+ previous() {
2386
+ this.scrollToIndex(this.index() - 1);
2387
+ }
2388
+ measure() {
2389
+ if (!this.isBrowser) {
2390
+ return;
2391
+ }
2392
+ this.containerSize.set(this.scroller().nativeElement.clientWidth);
2393
+ }
2394
+ /**
2395
+ * Current scroll position as a positive offset from the logical start edge.
2396
+ *
2397
+ * Right-to-left scroll containers report `scrollLeft` as zero at the start and negative
2398
+ * moving away from it, so the magnitude is the logical offset in both directions.
2399
+ */
2400
+ scrollOffset() {
2401
+ return Math.abs(this.scroller().nativeElement.scrollLeft);
2402
+ }
2403
+ scrollTo(offset, behavior) {
2404
+ if (!this.isBrowser) {
2405
+ return;
2406
+ }
2407
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
2408
+ this.pendingScroll = offset;
2409
+ this.scroller().nativeElement.scrollTo({
2410
+ left: this.isRtl ? -offset : offset,
2411
+ behavior: reduceMotion ? 'auto' : behavior,
2412
+ });
2413
+ this.scheduleIndexSync();
2414
+ }
2415
+ scheduleGeometry() {
2416
+ if (this.frame) {
2417
+ return;
2418
+ }
2419
+ this.frame = requestAnimationFrame(() => {
2420
+ this.frame = 0;
2421
+ this.applyGeometry();
2422
+ });
2423
+ this.scheduleIndexSync();
2424
+ }
2425
+ /** `scrollend` is not universally supported, so back it with a timer. */
2426
+ scheduleIndexSync() {
2427
+ clearTimeout(this.scrollEndTimer);
2428
+ this.scrollEndTimer = setTimeout(() => this.syncIndexFromScroll(), SCROLL_END_FALLBACK);
2429
+ }
2430
+ syncIndexFromScroll() {
2431
+ const geometry = this.geometry();
2432
+ if (!geometry || !this.isBrowser) {
2433
+ return;
2434
+ }
2435
+ const offset = this.scrollOffset();
2436
+ // Wait for a programmatic scroll to land rather than reading the index off an
2437
+ // intermediate frame of the animation.
2438
+ if (this.pendingScroll !== undefined) {
2439
+ if (Math.abs(offset - this.pendingScroll) > 1) {
2440
+ return;
2441
+ }
2442
+ this.pendingScroll = undefined;
2443
+ }
2444
+ // Once the final items share the focal range they also share a scroll offset, so an
2445
+ // index that already resolves to this offset is left alone.
2446
+ if (Math.abs(scrollOffsetForIndex(geometry, this.index()) - offset) <= 1) {
2447
+ return;
2448
+ }
2449
+ const index = indexForScrollOffset(geometry, offset);
2450
+ if (index !== this.index()) {
2451
+ this.index.set(index);
2452
+ }
2453
+ }
2454
+ /**
2455
+ * Writes the current placement of every item.
2456
+ *
2457
+ * Runs on every scroll frame, so it touches the DOM directly and allocates nothing beyond
2458
+ * the resolved state.
2459
+ */
2460
+ applyGeometry() {
2461
+ const geometry = this.geometry();
2462
+ const items = this.items();
2463
+ if (!geometry) {
2464
+ return;
2465
+ }
2466
+ const style = this.element.style;
2467
+ style.setProperty('--md3-carousel-scroll-size', `${geometry.scrollSize}px`);
2468
+ style.setProperty('--md3-carousel-viewport-size', `${this.containerSize()}px`);
2469
+ style.setProperty('--md3-carousel-gap', `${this.metrics().gap}px`);
2470
+ const scrollOffset = this.scrollOffset();
2471
+ const state = resolveState(geometry, scrollOffset);
2472
+ const itemCount = items.length;
2473
+ items.forEach((item, index) => {
2474
+ item.applyGeometry(resolveItemGeometry(geometry, state, index, scrollOffset), geometry.itemSize);
2475
+ const label = `${index + 1} of ${itemCount}`;
2476
+ if (item.element.getAttribute('aria-label') !== label) {
2477
+ item.element.setAttribute('aria-label', label);
2478
+ }
2479
+ });
2480
+ }
2481
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: Carousel, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Component });
2482
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.0", type: Carousel, isStandalone: true, selector: "md3-carousel", inputs: { carouselLayout: { classPropertyName: "carouselLayout", publicName: "carousel-layout", isSignal: true, isRequired: false, transformFunction: null }, alignment: { classPropertyName: "alignment", publicName: "alignment", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, itemSize: { classPropertyName: "itemSize", publicName: "item-size", isSignal: true, isRequired: false, transformFunction: null }, smallItemSizeMin: { classPropertyName: "smallItemSizeMin", publicName: "small-item-size-min", isSignal: true, isRequired: false, transformFunction: null }, smallItemSizeMax: { classPropertyName: "smallItemSizeMax", publicName: "small-item-size-max", isSignal: true, isRequired: false, transformFunction: null }, gap: { classPropertyName: "gap", publicName: "gap", isSignal: true, isRequired: false, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { index: "indexChange" }, host: { attributes: { "role": "group", "aria-roledescription": "carousel" }, listeners: { "keydown": "onKeydown($event)", "focusin": "onFocusIn($event)" } }, queries: [{ propertyName: "items", predicate: CarouselItem, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "scroller", first: true, predicate: ["scroller"], descendants: true, isSignal: true }], ngImport: i0, template: "<div #scroller class=\"md3-carousel-scroller\" tabindex=\"0\" [class.md3-snap]=\"snap()\">\n <div class=\"md3-carousel-content\">\n <div class=\"md3-carousel-viewport\">\n <ng-content></ng-content>\n </div>\n\n @for (point of snapPoints(); track $index) {\n <div class=\"md3-carousel-snap\" aria-hidden=\"true\" [style.inset-inline-start.px]=\"point\"></div>\n }\n </div>\n</div>\n", styles: [":host{--md3-carousel-height: 14em;--md3-carousel-scroll-size: 100%;--md3-carousel-viewport-size: 100%;--md3-carousel-gap: 0px;--outline-color: rgb(var(--md-scheme-secondary));--outline-width: .1875em;--outline-offset: .125em;font-size:1rem;display:block;inline-size:100%;block-size:var(--md3-carousel-height)}.md3-carousel-scroller{block-size:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;outline:none}.md3-carousel-scroller::-webkit-scrollbar{display:none}.md3-carousel-scroller:focus-visible{outline:var(--outline-width) solid var(--outline-color);outline-offset:var(--outline-offset);border-radius:var(--md-border-radius-xlarge)}.md3-carousel-scroller.md3-snap{scroll-snap-type:x mandatory}.md3-carousel-content{position:relative;inline-size:var(--md3-carousel-scroll-size);block-size:100%}.md3-carousel-viewport{position:sticky;inset-inline-start:0;inset-block-start:0;inline-size:var(--md3-carousel-viewport-size);block-size:100%}.md3-carousel-snap{position:absolute;inset-block-start:0;inline-size:1px;block-size:1px;scroll-snap-align:start;pointer-events:none;visibility:hidden}@media(prefers-reduced-motion:reduce){.md3-carousel-scroller{scroll-behavior:auto}}\n"] });
2483
+ }
2484
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: Carousel, decorators: [{
2485
+ type: Component,
2486
+ args: [{ selector: 'md3-carousel', host: {
2487
+ 'role': 'group',
2488
+ 'aria-roledescription': 'carousel',
2489
+ }, template: "<div #scroller class=\"md3-carousel-scroller\" tabindex=\"0\" [class.md3-snap]=\"snap()\">\n <div class=\"md3-carousel-content\">\n <div class=\"md3-carousel-viewport\">\n <ng-content></ng-content>\n </div>\n\n @for (point of snapPoints(); track $index) {\n <div class=\"md3-carousel-snap\" aria-hidden=\"true\" [style.inset-inline-start.px]=\"point\"></div>\n }\n </div>\n</div>\n", styles: [":host{--md3-carousel-height: 14em;--md3-carousel-scroll-size: 100%;--md3-carousel-viewport-size: 100%;--md3-carousel-gap: 0px;--outline-color: rgb(var(--md-scheme-secondary));--outline-width: .1875em;--outline-offset: .125em;font-size:1rem;display:block;inline-size:100%;block-size:var(--md3-carousel-height)}.md3-carousel-scroller{block-size:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;outline:none}.md3-carousel-scroller::-webkit-scrollbar{display:none}.md3-carousel-scroller:focus-visible{outline:var(--outline-width) solid var(--outline-color);outline-offset:var(--outline-offset);border-radius:var(--md-border-radius-xlarge)}.md3-carousel-scroller.md3-snap{scroll-snap-type:x mandatory}.md3-carousel-content{position:relative;inline-size:var(--md3-carousel-scroll-size);block-size:100%}.md3-carousel-viewport{position:sticky;inset-inline-start:0;inset-block-start:0;inline-size:var(--md3-carousel-viewport-size);block-size:100%}.md3-carousel-snap{position:absolute;inset-block-start:0;inline-size:1px;block-size:1px;scroll-snap-align:start;pointer-events:none;visibility:hidden}@media(prefers-reduced-motion:reduce){.md3-carousel-scroller{scroll-behavior:auto}}\n"] }]
2490
+ }], ctorParameters: () => [{ type: i0.ElementRef }], propDecorators: { carouselLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "carousel-layout", required: false }] }], alignment: [{ type: i0.Input, args: [{ isSignal: true, alias: "alignment", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], itemSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "item-size", required: false }] }], smallItemSizeMin: [{ type: i0.Input, args: [{ isSignal: true, alias: "small-item-size-min", required: false }] }], smallItemSizeMax: [{ type: i0.Input, args: [{ isSignal: true, alias: "small-item-size-max", required: false }] }], gap: [{ type: i0.Input, args: [{ isSignal: true, alias: "gap", required: false }] }], index: [{ type: i0.Input, args: [{ isSignal: true, alias: "index", required: false }] }, { type: i0.Output, args: ["indexChange"] }], items: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => CarouselItem), { ...{ descendants: true }, isSignal: true }] }], scroller: [{ type: i0.ViewChild, args: ['scroller', { isSignal: true }] }], onKeydown: [{
2491
+ type: HostListener,
2492
+ args: ['keydown', ['$event']]
2493
+ }], onFocusIn: [{
2494
+ type: HostListener,
2495
+ args: ['focusin', ['$event']]
2496
+ }] } });
2497
+
1556
2498
  class ChipAvatar {
1557
2499
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: ChipAvatar, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1558
2500
  static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.0", type: ChipAvatar, isStandalone: true, selector: "[md3-chip-avatar]", ngImport: i0 });
@@ -3115,6 +4057,7 @@ class DialogRef {
3115
4057
  isFullScreen;
3116
4058
  closed = new Subject();
3117
4059
  closing = new Subject();
4060
+ hiddenChanges = new Subject();
3118
4061
  closePromise = null;
3119
4062
  closeStarted = false;
3120
4063
  closeSettled = false;
@@ -3134,7 +4077,7 @@ class DialogRef {
3134
4077
  get isClosing() {
3135
4078
  return this.closeStarted;
3136
4079
  }
3137
- /** Whether the dialog is currently hidden behind another dialog. */
4080
+ /** Whether the dialog is currently hidden, on request or behind another dialog. */
3138
4081
  get isHidden() {
3139
4082
  return this.hidden;
3140
4083
  }
@@ -3163,6 +4106,7 @@ class DialogRef {
3163
4106
  /**
3164
4107
  * Hides the dialog with the closing animation while keeping it alive, so
3165
4108
  * its content, form state and subscriptions survive until show() is called.
4109
+ * The page underneath becomes usable again while the dialog waits behind it.
3166
4110
  * The promise resolves once the dialog is out of sight.
3167
4111
  */
3168
4112
  hide() {
@@ -3172,6 +4116,7 @@ class DialogRef {
3172
4116
  this.hidden = true;
3173
4117
  this.toggleHiddenState(true);
3174
4118
  this.dialogInstance?.setActive(false);
4119
+ this.hiddenChanges.next(true);
3175
4120
  return this.waitForSurfaceAnimation();
3176
4121
  }
3177
4122
  /**
@@ -3187,6 +4132,7 @@ class DialogRef {
3187
4132
  this.hidden = false;
3188
4133
  this.toggleHiddenState(false);
3189
4134
  this.dialogInstance?.setActive(true);
4135
+ this.hiddenChanges.next(false);
3190
4136
  }
3191
4137
  this.dialogInstance?.recaptureFocus();
3192
4138
  }
@@ -3194,6 +4140,10 @@ class DialogRef {
3194
4140
  beforeClosed() {
3195
4141
  return this.closing.asObservable();
3196
4142
  }
4143
+ /** Emits the new value of `isHidden` every time the dialog is hidden or shown. */
4144
+ hiddenChanged() {
4145
+ return this.hiddenChanges.asObservable();
4146
+ }
3197
4147
  afterClosed() {
3198
4148
  return this.closed.asObservable();
3199
4149
  }
@@ -3204,7 +4154,9 @@ class DialogRef {
3204
4154
  this.cdkRef.containerInstance._closeInteractionType = 'mouse';
3205
4155
  this.close();
3206
4156
  });
3207
- this.cdkRef.keydownEvents.pipe(filter((event) => event.key === 'Escape' && !hasModifierKey(event)), take(1)).subscribe((event) => {
4157
+ // A hidden dialog is not the one the Escape key is meant for, even
4158
+ // though the overlay it sits in is still the one receiving the event.
4159
+ this.cdkRef.keydownEvents.pipe(filter((event) => !this.hidden && event.key === 'Escape' && !hasModifierKey(event)), take(1)).subscribe((event) => {
3208
4160
  event.preventDefault();
3209
4161
  this.cdkRef.containerInstance._closeInteractionType = 'keyboard';
3210
4162
  this.close();
@@ -3276,6 +4228,7 @@ class DialogRef {
3276
4228
  // waiting on beforeClosed() is still notified.
3277
4229
  this.startClosing();
3278
4230
  this.closeSettled = true;
4231
+ this.hiddenChanges.complete();
3279
4232
  this.closed.next(result);
3280
4233
  this.closed.complete();
3281
4234
  }
@@ -3312,13 +4265,13 @@ class Dialog extends CdkDialogContainer {
3312
4265
  this._recaptureFocus();
3313
4266
  }
3314
4267
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: Dialog, deps: null, target: i0.ɵɵFactoryTarget.Component });
3315
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "22.1.0", type: Dialog, isStandalone: true, selector: "md3-dialog", viewQueries: [{ propertyName: "surface", first: true, predicate: ["surface"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0, template: "<!-- The role and aria attributes live on the host element, applied by the CDK dialog container. -->\n<section\n #surface\n class=\"md3-dialog-container\"\n [class.md3-active]=\"isActive()\"\n [class.md-scheme-dark]=\"config.scheme == 'dark'\"\n [class.md-scheme-light]=\"config.scheme == 'light'\"\n [attr.dir]=\"config.direction ?? null\">\n <ng-template cdkPortalOutlet></ng-template>\n</section>\n", styles: ["::ng-deep .cdk-overlay-container{position:fixed;z-index:1000;inset:0;pointer-events:none;font-size:1rem}::ng-deep .cdk-overlay-container .md3-dialog-scrim{position:absolute;inset:0;pointer-events:auto;background-color:rgb(var(--md-scheme-scrim));transition:all var(--md-motion-expressive-slow-spatial-duration) var(--md-motion-expressive-slow-spatial-easing);opacity:.32}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-opening{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-closing{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-hidden{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-scrim-kept{opacity:.32;transition:none}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-scrim-instant{transition:none}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper{position:absolute;display:flex;inset:0;pointer-events:none;padding:3.5rem 1.5rem;overflow:hidden}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel{position:absolute;pointer-events:auto;max-width:35em}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel.md3-dialog-closing,::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel.md3-dialog-hidden{pointer-events:none}:host{display:block;max-width:100%;min-width:17.5em;outline:0}:host .md3-dialog-container{display:block;width:100%;max-height:inherit;overflow:auto;color:rgb(var(--md-scheme-on-surface));background-color:rgb(var(--md-scheme-surface-container-high));border-radius:var(--md-border-radius-xlarge);box-shadow:var(--md-shadow-6dp);transform-origin:center;transition:all var(--md-motion-expressive-default-spatial-duration) var(--md-motion-expressive-default-spatial-easing);opacity:0;transform:scaleY(.8);margin-top:-8rem}:host .md3-dialog-container.md3-active{opacity:1;transform:scaleY(1);margin-top:0}\n"], dependencies: [{ kind: "directive", type: CdkPortalOutlet, selector: "[cdkPortalOutlet]", inputs: ["cdkPortalOutlet"], outputs: ["attached"], exportAs: ["cdkPortalOutlet"] }] });
4268
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "22.1.0", type: Dialog, isStandalone: true, selector: "md3-dialog", viewQueries: [{ propertyName: "surface", first: true, predicate: ["surface"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0, template: "<!-- The role and aria attributes live on the host element, applied by the CDK dialog container. -->\n<section\n #surface\n class=\"md3-dialog-container\"\n [class.md3-active]=\"isActive()\"\n [class.md-scheme-dark]=\"config.scheme == 'dark'\"\n [class.md-scheme-light]=\"config.scheme == 'light'\"\n [attr.dir]=\"config.direction ?? null\">\n <ng-template cdkPortalOutlet></ng-template>\n</section>\n", styles: ["::ng-deep .cdk-overlay-container{position:fixed;z-index:1000;inset:0;pointer-events:none;font-size:1rem}::ng-deep .cdk-overlay-container .md3-dialog-scrim{position:absolute;inset:0;pointer-events:auto;background-color:rgb(var(--md-scheme-scrim));transition:all var(--md-motion-expressive-slow-spatial-duration) var(--md-motion-expressive-slow-spatial-easing);opacity:.32}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-opening{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-closing{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-hidden{opacity:0;pointer-events:none}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-scrim-kept{opacity:.32;transition:none}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-scrim-instant{transition:none}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper{position:absolute;display:flex;inset:0;pointer-events:none;padding:3.5rem 1.5rem;overflow:hidden}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel{position:absolute;pointer-events:auto;max-width:35em}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel.md3-dialog-closing,::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel.md3-dialog-hidden{pointer-events:none}:host{display:block;max-width:100%;min-width:17.5em;outline:0}:host .md3-dialog-container{display:block;width:100%;max-height:inherit;overflow:auto;color:rgb(var(--md-scheme-on-surface));background-color:rgb(var(--md-scheme-surface-container-high));border-radius:var(--md-border-radius-xlarge);box-shadow:var(--md-shadow-6dp);transform-origin:center;transition:all var(--md-motion-expressive-default-spatial-duration) var(--md-motion-expressive-default-spatial-easing);opacity:0;transform:scaleY(.8);margin-top:-8rem}:host .md3-dialog-container.md3-active{opacity:1;transform:scaleY(1);margin-top:0}\n"], dependencies: [{ kind: "directive", type: CdkPortalOutlet, selector: "[cdkPortalOutlet]", inputs: ["cdkPortalOutlet"], outputs: ["attached"], exportAs: ["cdkPortalOutlet"] }] });
3316
4269
  }
3317
4270
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: Dialog, decorators: [{
3318
4271
  type: Component,
3319
4272
  args: [{ selector: 'md3-dialog', imports: [
3320
4273
  CdkPortalOutlet,
3321
- ], template: "<!-- The role and aria attributes live on the host element, applied by the CDK dialog container. -->\n<section\n #surface\n class=\"md3-dialog-container\"\n [class.md3-active]=\"isActive()\"\n [class.md-scheme-dark]=\"config.scheme == 'dark'\"\n [class.md-scheme-light]=\"config.scheme == 'light'\"\n [attr.dir]=\"config.direction ?? null\">\n <ng-template cdkPortalOutlet></ng-template>\n</section>\n", styles: ["::ng-deep .cdk-overlay-container{position:fixed;z-index:1000;inset:0;pointer-events:none;font-size:1rem}::ng-deep .cdk-overlay-container .md3-dialog-scrim{position:absolute;inset:0;pointer-events:auto;background-color:rgb(var(--md-scheme-scrim));transition:all var(--md-motion-expressive-slow-spatial-duration) var(--md-motion-expressive-slow-spatial-easing);opacity:.32}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-opening{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-closing{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-hidden{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-scrim-kept{opacity:.32;transition:none}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-scrim-instant{transition:none}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper{position:absolute;display:flex;inset:0;pointer-events:none;padding:3.5rem 1.5rem;overflow:hidden}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel{position:absolute;pointer-events:auto;max-width:35em}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel.md3-dialog-closing,::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel.md3-dialog-hidden{pointer-events:none}:host{display:block;max-width:100%;min-width:17.5em;outline:0}:host .md3-dialog-container{display:block;width:100%;max-height:inherit;overflow:auto;color:rgb(var(--md-scheme-on-surface));background-color:rgb(var(--md-scheme-surface-container-high));border-radius:var(--md-border-radius-xlarge);box-shadow:var(--md-shadow-6dp);transform-origin:center;transition:all var(--md-motion-expressive-default-spatial-duration) var(--md-motion-expressive-default-spatial-easing);opacity:0;transform:scaleY(.8);margin-top:-8rem}:host .md3-dialog-container.md3-active{opacity:1;transform:scaleY(1);margin-top:0}\n"] }]
4274
+ ], template: "<!-- The role and aria attributes live on the host element, applied by the CDK dialog container. -->\n<section\n #surface\n class=\"md3-dialog-container\"\n [class.md3-active]=\"isActive()\"\n [class.md-scheme-dark]=\"config.scheme == 'dark'\"\n [class.md-scheme-light]=\"config.scheme == 'light'\"\n [attr.dir]=\"config.direction ?? null\">\n <ng-template cdkPortalOutlet></ng-template>\n</section>\n", styles: ["::ng-deep .cdk-overlay-container{position:fixed;z-index:1000;inset:0;pointer-events:none;font-size:1rem}::ng-deep .cdk-overlay-container .md3-dialog-scrim{position:absolute;inset:0;pointer-events:auto;background-color:rgb(var(--md-scheme-scrim));transition:all var(--md-motion-expressive-slow-spatial-duration) var(--md-motion-expressive-slow-spatial-easing);opacity:.32}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-opening{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-closing{opacity:0}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-hidden{opacity:0;pointer-events:none}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-scrim-kept{opacity:.32;transition:none}::ng-deep .cdk-overlay-container .md3-dialog-scrim.md3-dialog-scrim-instant{transition:none}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper{position:absolute;display:flex;inset:0;pointer-events:none;padding:3.5rem 1.5rem;overflow:hidden}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel{position:absolute;pointer-events:auto;max-width:35em}::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel.md3-dialog-closing,::ng-deep .cdk-overlay-container .cdk-global-overlay-wrapper .md3-dialog-panel.md3-dialog-hidden{pointer-events:none}:host{display:block;max-width:100%;min-width:17.5em;outline:0}:host .md3-dialog-container{display:block;width:100%;max-height:inherit;overflow:auto;color:rgb(var(--md-scheme-on-surface));background-color:rgb(var(--md-scheme-surface-container-high));border-radius:var(--md-border-radius-xlarge);box-shadow:var(--md-shadow-6dp);transform-origin:center;transition:all var(--md-motion-expressive-default-spatial-duration) var(--md-motion-expressive-default-spatial-easing);opacity:0;transform:scaleY(.8);margin-top:-8rem}:host .md3-dialog-container.md3-active{opacity:1;transform:scaleY(1);margin-top:0}\n"] }]
3322
4275
  }], propDecorators: { surface: [{ type: i0.ViewChild, args: ['surface', { isSignal: true }] }] } });
3323
4276
 
3324
4277
  /**
@@ -3397,10 +4350,16 @@ const DIALOG_HANDOVER_DELAY_MS = 160;
3397
4350
  class DialogService {
3398
4351
  cdkDialog = inject(Dialog$1);
3399
4352
  overlay = inject(Overlay);
4353
+ overlayContainer = inject(OverlayContainer);
3400
4354
  injector = inject(Injector);
3401
4355
  document = inject(DOCUMENT);
3402
4356
  /** Open dialogs, from the first one opened to the one currently on top. */
3403
4357
  refs = [];
4358
+ /**
4359
+ * Elements the page was made of that the CDK hid from assistive technology,
4360
+ * kept here while the dialogs are hidden and the page is given back.
4361
+ */
4362
+ pageAriaHidden = new Map();
3404
4363
  /**
3405
4364
  * Page scrolling is blocked here instead of per overlay, so stacked dialogs
3406
4365
  * cannot unblock the page while another dialog is still open.
@@ -3408,9 +4367,14 @@ class DialogService {
3408
4367
  scrollBlock = this.overlay.scrollStrategies.block();
3409
4368
  /** Element that was focused before the first dialog of the stack opened. */
3410
4369
  rootTrigger = null;
4370
+ pageStateQueued = false;
3411
4371
  get openDialogs() {
3412
4372
  return this.refs;
3413
4373
  }
4374
+ /** Open dialogs that are currently on screen, from the bottom one up. */
4375
+ get visibleDialogs() {
4376
+ return this.refs.filter((ref) => !ref.isHidden);
4377
+ }
3414
4378
  /** The full screen dialog that is open, if there is one. */
3415
4379
  get fullScreenDialog() {
3416
4380
  return this.refs.find((ref) => ref.isFullScreen && !ref.isClosing);
@@ -3546,7 +4510,7 @@ class DialogService {
3546
4510
  this.bindDataToInputs(cdkRef.componentRef, dialogConfig);
3547
4511
  }
3548
4512
  this.refs.push(dialogRef);
3549
- this.updateScrollBlock();
4513
+ this.syncPageState();
3550
4514
  if (previousRef) {
3551
4515
  setTimeout(() => this.startReplacingAnimation(dialogRef, previousRef), DIALOG_HANDOVER_DELAY_MS);
3552
4516
  }
@@ -3557,6 +4521,41 @@ class DialogService {
3557
4521
  // already coming back while this one is fading out.
3558
4522
  dialogRef.beforeClosed().subscribe(() => this.restorePreviousDialog(dialogRef));
3559
4523
  dialogRef.afterClosed().subscribe(() => this.removeRef(dialogRef));
4524
+ // Hiding and showing can also come straight from the reference, so the
4525
+ // page state is driven by what the dialogs report rather than by the
4526
+ // calls that went through this service.
4527
+ dialogRef.hiddenChanged().subscribe(() => this.syncPageState());
4528
+ }
4529
+ /**
4530
+ * Hides every dialog that is on screen, the full screen one included, with
4531
+ * the closing animation. They stay alive with their content and state, the
4532
+ * page underneath becomes usable, and showAll() puts them back. The promise
4533
+ * resolves once they are all out of sight.
4534
+ */
4535
+ hideAll() {
4536
+ const visible = this.refs.filter((ref) => !ref.isHidden && !ref.isClosing);
4537
+ return Promise.all(visible.map((ref) => ref.hide())).then(() => undefined);
4538
+ }
4539
+ /**
4540
+ * Brings the dialogs back on screen, whether they were hidden one by one or
4541
+ * all at once: the dialog that was on top returns, along with the full
4542
+ * screen dialog it sits in. Dialogs hidden behind another dialog stay
4543
+ * hidden, since that is where they belong once the stack is back.
4544
+ */
4545
+ showAll() {
4546
+ const topRef = this.topRef();
4547
+ if (!topRef) {
4548
+ return;
4549
+ }
4550
+ const fullScreenRef = this.fullScreenDialog;
4551
+ // The full screen dialog is the context the top one sits in, so it
4552
+ // comes back first and the dialog on top ends up on top, with the focus.
4553
+ if (fullScreenRef && fullScreenRef !== topRef && fullScreenRef.isHidden) {
4554
+ fullScreenRef.show();
4555
+ }
4556
+ if (topRef.isHidden) {
4557
+ topRef.show();
4558
+ }
3560
4559
  }
3561
4560
  /** Closes every open dialog, including the hidden and the full screen ones. */
3562
4561
  closeAll() {
@@ -3719,15 +4718,82 @@ class DialogService {
3719
4718
  if (this.refs.length === 0) {
3720
4719
  this.rootTrigger = null;
3721
4720
  }
3722
- this.updateScrollBlock();
4721
+ this.syncPageState();
3723
4722
  }
3724
- updateScrollBlock() {
3725
- if (this.refs.length > 0) {
4723
+ /**
4724
+ * State that belongs to the dialogs as a whole rather than to one of them:
4725
+ * page scrolling, the page being hidden from assistive technology, and
4726
+ * where focus sits. Applied in a task of its own, so a dialog handing the
4727
+ * screen over to another one does not give the page back and take it again
4728
+ * within the same frame.
4729
+ */
4730
+ syncPageState() {
4731
+ if (this.pageStateQueued) {
4732
+ return;
4733
+ }
4734
+ this.pageStateQueued = true;
4735
+ setTimeout(() => {
4736
+ this.pageStateQueued = false;
4737
+ this.applyPageState();
4738
+ });
4739
+ }
4740
+ applyPageState() {
4741
+ const hasVisibleDialog = this.refs.some((ref) => !ref.isHidden);
4742
+ if (hasVisibleDialog) {
3726
4743
  this.scrollBlock.enable();
3727
4744
  }
3728
4745
  else {
3729
4746
  this.scrollBlock.disable();
3730
4747
  }
4748
+ if (this.refs.length === 0) {
4749
+ // Nothing is open anymore: the CDK gives the page back to assistive
4750
+ // technology on its own, so there is nothing left to restore here.
4751
+ this.pageAriaHidden.clear();
4752
+ return;
4753
+ }
4754
+ if (hasVisibleDialog) {
4755
+ this.hidePageFromAssistiveTechnology();
4756
+ return;
4757
+ }
4758
+ this.exposePageToAssistiveTechnology();
4759
+ this.releaseFocus();
4760
+ }
4761
+ /**
4762
+ * Gives the page back to assistive technology while every dialog is hidden.
4763
+ * The CDK hides it for as long as a dialog is open, which would otherwise
4764
+ * leave nothing to read at all once the dialogs are out of sight.
4765
+ */
4766
+ exposePageToAssistiveTechnology() {
4767
+ if (this.pageAriaHidden.size > 0) {
4768
+ return;
4769
+ }
4770
+ const container = this.overlayContainer.getContainerElement();
4771
+ Array.from(container.parentElement?.children ?? []).forEach((sibling) => {
4772
+ const value = sibling.getAttribute('aria-hidden');
4773
+ if (sibling === container || value === null) {
4774
+ return;
4775
+ }
4776
+ this.pageAriaHidden.set(sibling, value);
4777
+ sibling.removeAttribute('aria-hidden');
4778
+ });
4779
+ }
4780
+ /** Hides the page again as soon as a dialog is back on screen. */
4781
+ hidePageFromAssistiveTechnology() {
4782
+ this.pageAriaHidden.forEach((value, element) => {
4783
+ element.setAttribute('aria-hidden', value);
4784
+ });
4785
+ this.pageAriaHidden.clear();
4786
+ }
4787
+ /**
4788
+ * Focus follows the dialogs off the screen: it goes back to whatever opened
4789
+ * the stack, so the page stays usable with the keyboard while the dialogs
4790
+ * wait behind it. Focus that already moved somewhere else is left alone.
4791
+ */
4792
+ releaseFocus() {
4793
+ const activeElement = this.document.activeElement;
4794
+ if (this.rootTrigger && (!activeElement || activeElement === this.document.body)) {
4795
+ this.rootTrigger.focus();
4796
+ }
3731
4797
  }
3732
4798
  getFocusedElement() {
3733
4799
  const activeElement = this.document.activeElement;
@@ -4835,5 +5901,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImpor
4835
5901
  * Generated bundle index. Do not edit.
4836
5902
  */
4837
5903
 
4838
- export { AppBar, AppBarLogo, Avatar, Badge, Button, ButtonGroup, Card, Checkbox, ChipAvatar, Chips, CircularProgressIndicator, DIALOG_COMPONENT, DIALOG_CONFIG, DIALOG_DATA, Dialog, DialogActions, DialogBody, DialogHeader, DialogRef, DialogService, Divider, FloatingActionButton, FullScreenDialog, FullScreenDialogHeader, Grid, GridItem, IconButton, IconElement, InputElement, LayoutService, LinearProgressIndicator, List, ListItem, ListItemPrimaryAction, ListLeading, ListSlot, LoadingIndicator, MENU_COMPONENT, MENU_CONFIG, MENU_DATA, MaterialIcon, Menu, MenuGroup, MenuItem, MenuRef, MenuService, NavigationBar, NavigationGroup, NavigationItem, NavigationRail, RadioButton, SIDE_SHEET_COMPONENT, SIDE_SHEET_CONFIG, SIDE_SHEET_DATA, SNACKBAR_ACTION_LABEL, SNACKBAR_CONFIG, SNACKBAR_MESSAGE, Scaffold, ScaffoldBar, ScaffoldPane, ScaffoldRail, SheetsService, SideSheetActions, SideSheetBody, SideSheetHeader, SideSheetRef, Slider, Snackbar, SnackbarRef, SnackbarService, SplitButton, StateComponent, SupportingText, Switch, TextField, TypeBody, TypeDisplay, TypeHeadline, TypeLabel, TypeTitle };
5904
+ export { AppBar, AppBarLogo, Avatar, Badge, Button, ButtonGroup, CAROUSEL_STRATEGIES, Card, Carousel, CarouselItem, Checkbox, ChipAvatar, Chips, CircularProgressIndicator, DIALOG_COMPONENT, DIALOG_CONFIG, DIALOG_DATA, Dialog, DialogActions, DialogBody, DialogHeader, DialogRef, DialogService, Divider, FloatingActionButton, FullScreenDialog, FullScreenDialogHeader, Grid, GridItem, IconButton, IconElement, InputElement, LayoutService, LinearProgressIndicator, List, ListItem, ListItemPrimaryAction, ListLeading, ListSlot, LoadingIndicator, MENU_COMPONENT, MENU_CONFIG, MENU_DATA, MaterialIcon, Menu, MenuGroup, MenuItem, MenuRef, MenuService, NavigationBar, NavigationGroup, NavigationItem, NavigationRail, RadioButton, SIDE_SHEET_COMPONENT, SIDE_SHEET_CONFIG, SIDE_SHEET_DATA, SNACKBAR_ACTION_LABEL, SNACKBAR_CONFIG, SNACKBAR_MESSAGE, Scaffold, ScaffoldBar, ScaffoldPane, ScaffoldRail, SheetsService, SideSheetActions, SideSheetBody, SideSheetHeader, SideSheetRef, Slider, Snackbar, SnackbarRef, SnackbarService, SplitButton, StateComponent, SupportingText, Switch, TextField, TypeBody, TypeDisplay, TypeHeadline, TypeLabel, TypeTitle, buildGeometry, clampIndex, indexForScrollOffset, multiBrowseStrategy, resolveItemGeometry, resolveState, scrollOffsetForIndex, sizeBandFor };
4839
5905
  //# sourceMappingURL=almoamendev-ngx-md3.mjs.map