@almoamendev/ngx-md3 0.2.2 → 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,6 +9,7 @@ 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';
@@ -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 });
@@ -4959,5 +5901,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImpor
4959
5901
  * Generated bundle index. Do not edit.
4960
5902
  */
4961
5903
 
4962
- 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 };
4963
5905
  //# sourceMappingURL=almoamendev-ngx-md3.mjs.map