diff --git a/all.js b/all.js index 19b80b1..42c4fab 100644 --- a/all.js +++ b/all.js @@ -5,6 +5,8 @@ * Import only the individual components used for production. */ import './buttons/button.js' +import './carousel/carousel.js' +import './carousel/carousel-item.js' import './checkbox/checkbox.js' import './chips/chip.js' import './chips/chip-set.js' @@ -37,6 +39,8 @@ import './text/text-field.js' // LINT.IfChange(exports) // go/keep-sorted start export * from './buttons/button.js' +export * from './carousel/carousel.js' +export * from './carousel/carousel-item.js' export * from './checkbox/checkbox.js' export * from './chips/chip.js' export * from './chips/chip-set.js' diff --git a/carousel/README.md b/carousel/README.md new file mode 100644 index 0000000..e14b15d --- /dev/null +++ b/carousel/README.md @@ -0,0 +1,139 @@ +# Carousel + +[Material Design 3 Carousel component](https://m3.material.io/components/carousel/overview). + +Carousels show a scrollable collection of items with dynamic sizing, snapping, navigation buttons, and pagination indicators. + +## Usage + +```js +import 'material/carousel/carousel.js' +import 'material/carousel/carousel-item.js' +``` + +### Basic Multi-Browse Carousel (Default) + +```html + + + Photo 1 + + + Photo 2 + + + Photo 3 + + +``` + +### Uncontained Layout + +```html + + + +

Card 1

+

Card content

+
+
+ + +

Card 2

+

Card content

+
+
+
+``` + +### Hero Layout (Start-aligned) + +```html + + + Hero 1 + + + Hero 2 + + +``` + +### Centered-Hero Layout + +```html + + + Poster 1 + + + Poster 2 + + + Poster 3 + + +``` + +### Full-Screen Layout + +```html + + + Slide 1 + + + Slide 2 + + +``` + +--- + +## `` Properties & Attributes + +| Property / Attribute | Type | Default | Description | +| --- | --- | --- | --- | +| `layout` | `'multi-browse' \| 'uncontained' \| 'hero' \| 'centered-hero' \| 'full-screen'` | `'multi-browse'` | Carousel layout strategy. | +| `item-width` / `itemWidth` | `string` | `''` | Custom item width (e.g. `'300px'`). | +| `item-spacing` / `itemSpacing` | `number` | `8` | Spacing between items in px. | +| `navigation` | `'auto' \| 'always' \| 'none'` | `'auto'` | Controls visibility of previous/next navigation buttons. | +| `indicators` | `boolean` | `false` | When true, shows pagination indicator dots. | +| `autoplay` | `number` | `0` | Delay in milliseconds for automatic sliding (0 = disabled). | +| `loop` | `boolean` | `false` | When true, wraps navigation around when reaching edges. | +| `active-index` / `activeIndex` | `number` | `0` | 0-based index of the currently active slide. | +| `scroll-snap` / `scrollSnap` | `boolean` | `true` | Enables/disables CSS scroll-snapping. | +| `hide-scrollbar` / `hideScrollbar` | `boolean` | `true` | Hides the horizontal scrollbar. | + +### Methods + +- `next()`: Scrolls to the next slide. +- `previous()`: Scrolls to the previous slide. +- `scrollToIndex(index, behavior = 'smooth')`: Scrolls to a specific slide index. + +### Events + +- `change`: Dispatched when the active item changes. `event.detail` contains `{ index, item }`. +- `scroll`: Dispatched when the carousel is scrolled. + +--- + +## `` Properties & Attributes + +| Property / Attribute | Type | Default | Description | +| --- | --- | --- | --- | +| `shape` | `string` | `'var(--md-sys-shape-corner-extra-large, 28px)'` | Corner radius or shape (`'small'`, `'medium'`, `'large'`, `'extra-large'`, `'full'`). | +| `type` | `'filled' \| 'elevated' \| 'outlined' \| ''` | `''` | Container card style variant. | +| `interactive` | `boolean` | `false` | Enables ripple, focus-ring, and hover elevation effects. | +| `href` | `string` | `''` | Link URL when clicking the carousel item. | +| `target` | `string` | `''` | Target window for link (`'_blank'`, etc.). | +| `headline` | `string` | `''` | Headline text overlay. | +| `subhead` | `string` | `''` | Subhead text overlay. | + +### Slots + +- `(default)`: Media or card content (images, custom elements). +- `headline`: Custom headline content. +- `subhead`: Custom subhead content. +- `scrim`: Custom scrim overlay. +- `action`: Action buttons or icons in the item overlay. diff --git a/carousel/carousel-item.js b/carousel/carousel-item.js new file mode 100644 index 0000000..15c23f1 --- /dev/null +++ b/carousel/carousel-item.js @@ -0,0 +1,332 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { html, LitElement, nothing, css } from 'lit' +import '../internal/elevation/elevation.js' +import '../internal/focus/focus-ring.js' +import '../internal/ripple/ripple.js' + +/** + * An item container inside an ``. + * + * https://m3.material.io/components/carousel/overview + */ +export class CarouselItem extends LitElement { + static properties = { + shape: { type: String, reflect: true }, + type: { type: String, reflect: true }, // 'filled' | 'elevated' | 'outlined' + interactive: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + active: { type: Boolean, reflect: true }, + href: { type: String }, + target: { type: String }, + headline: { type: String }, + subhead: { type: String }, + isCarouselItem: { type: Boolean, attribute: 'md-carousel-item', reflect: true }, + } + + constructor() { + super() + this.isCarouselItem = true + this.interactive = false + this.disabled = false + this.active = false + this.type = '' + this.href = '' + this.target = '' + this.headline = '' + this.subhead = '' + } + + get isInteractive() { + return this.interactive || Boolean(this.href) + } + + render() { + const content = html` + ${this.renderElevation()} +
+
+ +
+
+ +
+ ${this.renderTextContent()} +
+ ${this.renderRippleAndFocus()} + ` + + if (this.href) { + return html` + + ${content} + + ` + } + + return html`
+ ${content} +
` + } + + renderElevation() { + if (this.type === 'elevated' || (this.interactive && this.type !== 'outlined')) { + return html`` + } + return nothing + } + + renderRippleAndFocus() { + if (!this.isInteractive) { + return nothing + } + return html` + + + ` + } + + renderTextContent() { + return html` +
+ ${this.headline ? html`
${this.headline}
` : nothing}
+ ${this.subhead ? html`
${this.subhead}
` : nothing}
+ +
+ ` + } + + static styles = [ + css` + :host { + --_item-shape: var(--md-carousel-item-shape, var(--md-sys-shape-corner-extra-large, 28px)); + --_item-color: var(--md-carousel-item-color, var(--md-sys-color-surface-container-low, #f7f2fa)); + --_item-elevation: var(--md-carousel-item-elevation, 0); + --_item-shadow-color: var(--md-sys-color-shadow, #000); + --_headline-color: var(--md-sys-color-on-surface, #1d1b20); + --_subhead-color: var(--md-sys-color-on-surface-variant, #49454f); + + display: flex; + flex-direction: column; + position: relative; + box-sizing: border-box; + border-radius: var(--_item-shape); + clip-path: inset(0 round var(--_item-shape)); + overflow: hidden; + isolation: isolate; + user-select: none; + flex-shrink: 0; + height: 100%; + scroll-snap-align: start; + transition: + width 0.35s cubic-bezier(0.2, 0, 0, 1), + min-width 0.35s cubic-bezier(0.2, 0, 0, 1), + max-width 0.35s cubic-bezier(0.2, 0, 0, 1), + flex-basis 0.35s cubic-bezier(0.2, 0, 0, 1), + transform 0.25s cubic-bezier(0.2, 0, 0, 1), + opacity 0.25s cubic-bezier(0.2, 0, 0, 1), + filter 0.25s cubic-bezier(0.2, 0, 0, 1); + -webkit-tap-highlight-color: transparent; + } + + :host([data-size='small']) .content-overlay, + :host([data-size='small']) .scrim { + opacity: 0; + pointer-events: none; + } + + :host([data-size='medium']) .subhead { + display: none; + } + + :host([data-size='medium']) .headline { + font-size: var(--md-carousel-item-medium-headline-size, 0.95rem); + } + + :host([data-size='medium']) .content-overlay { + padding: 12px; + } + + :host([shape='small']) { + --_item-shape: var(--md-sys-shape-corner-small, 8px); + } + :host([shape='medium']) { + --_item-shape: var(--md-sys-shape-corner-medium, 12px); + } + :host([shape='large']) { + --_item-shape: var(--md-sys-shape-corner-large, 16px); + } + :host([shape='extra-large']) { + --_item-shape: var(--md-sys-shape-corner-extra-large, 28px); + } + :host([shape='full']) { + --_item-shape: var(--md-sys-shape-corner-full, 9999px); + } + + .item-container, + .link { + display: flex; + flex-direction: column; + position: relative; + width: 100%; + height: 100%; + border-radius: inherit; + overflow: hidden; + box-sizing: border-box; + text-decoration: none; + color: inherit; + outline: none; + } + + :host([interactive]) .item-container, + .link { + cursor: pointer; + } + + :host([disabled]) { + pointer-events: none; + opacity: 0.38; + } + + .background { + background: var(--_item-color); + position: absolute; + inset: 0; + border-radius: inherit; + z-index: 0; + pointer-events: none; + } + + md-elevation { + position: absolute; + inset: 0; + border-radius: inherit; + z-index: 0; + pointer-events: none; + --md-elevation-level: var(--_item-elevation); + --md-elevation-shadow-color: var(--_item-shadow-color); + } + + .media-container { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + border-radius: inherit; + overflow: hidden; + } + + ::slotted(img), + ::slotted(video) { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border-radius: inherit; + pointer-events: none; + } + + .scrim { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + border-radius: inherit; + } + + .content-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 3; + padding: 16px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 4px; + pointer-events: none; + } + + .headline { + font-family: var(--md-ref-typeface-plain, 'Google Sans Flex', 'Roboto', sans-serif); + font-size: var(--md-carousel-item-headline-size, 1.125rem); + font-weight: 500; + line-height: 1.35; + color: var(--_headline-color); + } + + .subhead { + font-family: var(--md-ref-typeface-plain, 'Google Sans Flex', 'Roboto', sans-serif); + font-size: var(--md-carousel-item-subhead-size, 0.875rem); + font-weight: 400; + line-height: 1.25; + color: var(--_subhead-color); + } + + .outline { + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + border: 1px solid transparent; + z-index: 4; + } + + .outline.outlined { + border-color: var(--md-sys-color-outline-variant, #cac4d0); + } + + md-focus-ring { + z-index: 5; + --md-focus-ring-shape: var(--_item-shape); + } + + md-ripple { + z-index: 5; + } + + /* Variant Styles */ + :host([type='elevated']) { + --_item-color: var(--md-sys-color-surface-container-low, #f7f2fa); + --_item-elevation: 1; + filter: drop-shadow(0px 1px 2px var(--_item-shadow-color)); + } + + :host([type='elevated']:hover) { + --_item-elevation: 2; + } + + :host([type='filled']) { + --_item-color: var(--md-sys-color-surface-container-highest, #e6e0e9); + --_item-elevation: 0; + } + + :host([type='outlined']) { + --_item-color: var(--md-sys-color-surface, #fef7ff); + --_item-elevation: 0; + } + + /* Dark text scrim support when headline/subhead are specified on media */ + :host([headline]) .content-overlay, + :host([subhead]) .content-overlay { + background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.3) 60%, transparent 100%); + --_headline-color: #ffffff; + --_subhead-color: rgba(255, 255, 255, 0.85); + } + `, + ] +} + +customElements.define('md-carousel-item', CarouselItem) diff --git a/carousel/carousel.js b/carousel/carousel.js new file mode 100644 index 0000000..8d9ccd0 --- /dev/null +++ b/carousel/carousel.js @@ -0,0 +1,826 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { html, LitElement, nothing, css } from 'lit' +import { isRtl } from '../internal/controller/is-rtl.js' +import { queryAssignedElements } from '../utils/query.js' +import '../icon/icon.js' +import '../internal/elevation/elevation.js' +import '../internal/focus/focus-ring.js' +import '../internal/ripple/ripple.js' +import './carousel-item.js' + +/** + * A Material 3 Carousel component supporting multi-browse, uncontained, hero, + * centered-hero, and full-screen layout strategies. + * + * https://m3.material.io/components/carousel/overview + * + * @fires change {CustomEvent<{index: number, item: CarouselItem}>} Dispatched when the active item changes. + * @fires scroll {Event} Dispatched when the carousel is scrolled. + */ +export class Carousel extends LitElement { + static properties = { + layout: { type: String, reflect: true }, + itemWidth: { type: String, attribute: 'item-width', reflect: true }, + itemSpacing: { type: Number, attribute: 'item-spacing', reflect: true }, + navigation: { type: String, reflect: true }, // 'auto' | 'always' | 'none' + indicators: { type: Boolean, reflect: true }, + autoplay: { type: Number }, + loop: { type: Boolean, reflect: true }, + activeIndex: { type: Number, attribute: 'active-index', reflect: true }, + scrollSnap: { type: Boolean, attribute: 'scroll-snap', reflect: true }, + hideScrollbar: { type: Boolean, attribute: 'hide-scrollbar', reflect: true }, + ariaLabel: { type: String, attribute: 'aria-label' }, + _canScrollPrev: { state: true }, + _canScrollNext: { state: true }, + } + + constructor() { + super() + this.layout = 'multi-browse' + this.itemWidth = '' + this.itemSpacing = 8 + this.navigation = 'auto' + this.indicators = false + this.autoplay = 0 + this.loop = false + this.activeIndex = 0 + this.scrollSnap = true + this.hideScrollbar = true + this.ariaLabel = 'Carousel' + this._canScrollPrev = false + this._canScrollNext = true + + this._resizeObserver = null + this._autoplayTimer = null + this._isPointerDown = false + this._startX = 0 + this._startScrollLeft = 0 + this._hasDragged = false + + this._handleScroll = this._handleScroll.bind(this) + this._handleKeyDown = this._handleKeyDown.bind(this) + this._onPointerDown = this._onPointerDown.bind(this) + this._onPointerMove = this._onPointerMove.bind(this) + this._onPointerUp = this._onPointerUp.bind(this) + this._onPointerCancel = this._onPointerCancel.bind(this) + } + + get items() { + return queryAssignedElements(this, { flatten: true, selector: '[md-carousel-item], md-carousel-item' }) + } + + get scrollerElement() { + return this.renderRoot?.querySelector('.scroller') + } + + get activeItem() { + return this.items[this.activeIndex] ?? null + } + + get canScrollPrev() { + return this.loop || this._canScrollPrev + } + + get canScrollNext() { + return this.loop || this._canScrollNext + } + + connectedCallback() { + super.connectedCallback() + + if (typeof ResizeObserver !== 'undefined') { + this._resizeObserver = new ResizeObserver(() => { + this._updateLayout() + this._updateScrollState() + }) + this._resizeObserver.observe(this) + } + + this.addEventListener('keydown', this._handleKeyDown) + this._startAutoplay() + } + + disconnectedCallback() { + super.disconnectedCallback() + + if (this._resizeObserver) { + this._resizeObserver.disconnect() + this._resizeObserver = null + } + + this.removeEventListener('keydown', this._handleKeyDown) + this._stopAutoplay() + } + + firstUpdated() { + this._updateLayout() + this._updateScrollState() + this._syncActiveItem() + } + + updated(changedProperties) { + if (changedProperties.has('layout') || changedProperties.has('itemWidth') || changedProperties.has('itemSpacing')) { + this._updateLayout() + this._updateScrollState() + } + + if (changedProperties.has('autoplay')) { + this._startAutoplay() + } + + if (changedProperties.has('activeIndex') && changedProperties.get('activeIndex') !== undefined) { + this._applyItemSizes() + this._syncActiveItem() + } + } + + render() { + const isRtlMode = isRtl(this, false) + const showNav = this.navigation !== 'none' + const itemCount = this.items.length + + return html` + + ` + } + + /** + * Navigates to the next slide. + */ + async next() { + const total = this.items.length + if (total === 0) return + + if (this.activeIndex < total - 1) { + await this.scrollToIndex(this.activeIndex + 1) + } else if (this.loop) { + await this.scrollToIndex(0) + } + } + + /** + * Navigates to the previous slide. + */ + async previous() { + const total = this.items.length + if (total === 0) return + + if (this.activeIndex > 0) { + await this.scrollToIndex(this.activeIndex - 1) + } else if (this.loop) { + await this.scrollToIndex(total - 1) + } + } + + /** + * Scrolls smoothly to the specified slide index. + * + * @param {number} index Target item index. + * @param {'smooth' | 'auto' | 'instant'} behavior Scroll behavior. + */ + async scrollToIndex(index, behavior = 'smooth') { + await this.updateComplete + const items = this.items + if (!items.length || !this.scrollerElement) return + + const boundedIndex = Math.max(0, Math.min(index, items.length - 1)) + this.activeIndex = boundedIndex + this._isScrollingProgrammatically = true + + this._applyItemSizes() + this._syncActiveItem() + + const scroller = this.scrollerElement + const targetItem = items[boundedIndex] + if (!targetItem || !scroller) { + this._isScrollingProgrammatically = false + return + } + + let scrollTarget + if (this.layout === 'centered-hero') { + const itemCenter = targetItem.offsetLeft + targetItem.offsetWidth / 2 + const scrollerCenter = scroller.offsetWidth / 2 + scrollTarget = itemCenter - scrollerCenter + } else { + scrollTarget = targetItem.offsetLeft - scroller.offsetLeft + } + + scroller.scrollTo({ + left: Math.max(0, scrollTarget), + behavior, + }) + + this._updateScrollState() + this.dispatchEvent( + new CustomEvent('change', { + detail: { index: boundedIndex, item: targetItem }, + bubbles: true, + composed: true, + }), + ) + + if (behavior === 'smooth') { + setTimeout(() => { + this._isScrollingProgrammatically = false + this._updateScrollState() + }, 400) + } else { + this._isScrollingProgrammatically = false + } + } + + _updateLayout() { + const scroller = this.scrollerElement + if (!scroller) return + + const spacing = Number(this.itemSpacing) || 8 + scroller.style.setProperty('--_item-spacing', `${spacing}px`) + + this._applyItemSizes() + } + + _applyItemSizes() { + const items = this.items + if (!items.length) return + + const scroller = this.scrollerElement + const containerWidth = this.offsetWidth || scroller?.clientWidth || 360 + const spacing = Number(this.itemSpacing) || 8 + const activeIndex = this.activeIndex + + if (this.layout === 'multi-browse' || !this.layout) { + if (scroller) { + scroller.style.paddingLeft = '0px' + scroller.style.paddingRight = '0px' + } + + const smallWidth = containerWidth < 400 ? 40 : containerWidth < 600 ? 48 : 56 + const remaining = Math.max(120, containerWidth - smallWidth - 2 * spacing) + const mediumRatio = containerWidth < 400 ? 0.3 : 0.32 + const mediumWidth = Math.max(60, Math.round(remaining * mediumRatio)) + const largeWidth = Math.max(120, remaining - mediumWidth) + + items.forEach((item, i) => { + let sizeType = 'large' + let width = largeWidth + + if (i === activeIndex) { + sizeType = 'large' + width = largeWidth + } else if (i === activeIndex + 1) { + sizeType = 'medium' + width = mediumWidth + } else if (i === activeIndex + 2) { + sizeType = 'small' + width = smallWidth + } else { + sizeType = 'large' + width = largeWidth + } + + item.setAttribute('data-size', sizeType) + item.style.flex = `0 0 ${width}px` + item.style.width = `${width}px` + item.style.minWidth = `${width}px` + item.style.maxWidth = `${width}px` + }) + } else if (this.layout === 'hero') { + if (scroller) { + scroller.style.paddingLeft = '0px' + scroller.style.paddingRight = '0px' + } + + const smallWidth = containerWidth < 480 ? 48 : 56 + const largeWidth = Math.max(240, containerWidth - smallWidth - spacing) + + items.forEach((item, i) => { + let sizeType = 'large' + let width = largeWidth + + if (i === activeIndex) { + sizeType = 'large' + width = largeWidth + } else if (i === activeIndex + 1) { + sizeType = 'small' + width = smallWidth + } else { + sizeType = 'large' + width = largeWidth + } + + item.setAttribute('data-size', sizeType) + item.style.flex = `0 0 ${width}px` + item.style.width = `${width}px` + item.style.minWidth = `${width}px` + item.style.maxWidth = `${width}px` + }) + } else if (this.layout === 'centered-hero') { + const peekWidth = containerWidth < 480 ? 40 : 56 + const largeWidth = Math.max(220, containerWidth - 2 * peekWidth - 2 * spacing) + + if (scroller) { + scroller.style.paddingLeft = `${peekWidth + spacing}px` + scroller.style.paddingRight = `${peekWidth + spacing}px` + } + + items.forEach((item, i) => { + let sizeType = 'large' + let width = largeWidth + + if (i === activeIndex) { + sizeType = 'large' + width = largeWidth + } else if (i === activeIndex - 1 || i === activeIndex + 1) { + sizeType = 'small' + width = peekWidth + } else { + sizeType = 'large' + width = largeWidth + } + + item.setAttribute('data-size', sizeType) + item.style.flex = `0 0 ${width}px` + item.style.width = `${width}px` + item.style.minWidth = `${width}px` + item.style.maxWidth = `${width}px` + }) + } else if (this.layout === 'uncontained') { + if (scroller) { + scroller.style.paddingLeft = '0px' + scroller.style.paddingRight = '0px' + } + const customWidth = this.itemWidth + ? isNaN(Number(this.itemWidth)) + ? this.itemWidth + : `${this.itemWidth}px` + : '280px' + items.forEach((item) => { + item.setAttribute('data-size', 'large') + item.style.flex = `0 0 ${customWidth}` + item.style.width = customWidth + item.style.minWidth = customWidth + item.style.maxWidth = customWidth + }) + } else if (this.layout === 'full-screen') { + if (scroller) { + scroller.style.paddingLeft = '0px' + scroller.style.paddingRight = '0px' + } + items.forEach((item) => { + item.setAttribute('data-size', 'large') + item.style.flex = '0 0 100%' + item.style.width = '100%' + item.style.minWidth = '100%' + item.style.maxWidth = '100%' + }) + } + } + + _updateScrollState() { + const scroller = this.scrollerElement + if (!scroller) return + + const { scrollLeft, scrollWidth, clientWidth } = scroller + const maxScroll = scrollWidth - clientWidth + + this._canScrollPrev = scrollLeft > 4 + this._canScrollNext = scrollLeft < maxScroll - 4 + } + + _handleScroll() { + this._updateScrollState() + if (this._isScrollingProgrammatically) return + + const scroller = this.scrollerElement + const items = this.items + if (!scroller || !items.length) return + + const scrollerLeft = scroller.scrollLeft + const scrollerCenter = scrollerLeft + scroller.clientWidth / 2 + + let closestIndex = 0 + let minDistance = Infinity + + items.forEach((item, index) => { + let distance + if (this.layout === 'centered-hero') { + const itemCenter = item.offsetLeft + item.offsetWidth / 2 + distance = Math.abs(itemCenter - scrollerCenter) + } else { + distance = Math.abs(item.offsetLeft - scrollerLeft) + } + + if (distance < minDistance) { + minDistance = distance + closestIndex = index + } + }) + + if (this.activeIndex !== closestIndex) { + this.activeIndex = closestIndex + this._applyItemSizes() + this._syncActiveItem() + this.dispatchEvent( + new CustomEvent('change', { + detail: { index: closestIndex, item: items[closestIndex] }, + bubbles: true, + composed: true, + }), + ) + } + } + + _syncActiveItem() { + const items = this.items + items.forEach((item, i) => { + item.active = i === this.activeIndex + item.setAttribute('aria-label', `Slide ${i + 1} of ${items.length}`) + item.setAttribute('role', 'group') + item.setAttribute('aria-roledescription', 'slide') + }) + } + + _handleSlotChange() { + this._updateLayout() + this._updateScrollState() + this._syncActiveItem() + this.requestUpdate() + } + + _handleKeyDown(event) { + if (event.defaultPrevented) return + + const isRtlMode = isRtl(this, false) + const isLeft = event.key === 'ArrowLeft' + const isRight = event.key === 'ArrowRight' + const isHome = event.key === 'Home' + const isEnd = event.key === 'End' + + if (!isLeft && !isRight && !isHome && !isEnd) { + return + } + + event.preventDefault() + + const forwards = isRtlMode ? isLeft : isRight + const backwards = isRtlMode ? isRight : isLeft + + if (isHome) { + this.scrollToIndex(0) + } else if (isEnd) { + this.scrollToIndex(this.items.length - 1) + } else if (forwards) { + this.next() + } else if (backwards) { + this.previous() + } + } + + _onPointerDown(e) { + if (e.button !== 0) return // Left-click only + const scroller = this.scrollerElement + if (!scroller) return + + this._isPointerDown = true + this._startX = e.pageX - scroller.offsetLeft + this._startScrollLeft = scroller.scrollLeft + this._hasDragged = false + + try { + scroller.setPointerCapture?.(e.pointerId) + } catch {} + } + + _onPointerMove(e) { + if (!this._isPointerDown) return + const scroller = this.scrollerElement + if (!scroller) return + + const x = e.pageX - scroller.offsetLeft + const walk = (x - this._startX) * 1.2 + + if (Math.abs(walk) > 5) { + this._hasDragged = true + scroller.classList.add('is-dragging') + scroller.scrollLeft = this._startScrollLeft - walk + } + } + + _onPointerUp(e) { + if (!this._isPointerDown) return + this._isPointerDown = false + + const scroller = this.scrollerElement + if (scroller) { + scroller.classList.remove('is-dragging') + try { + if (e && scroller.hasPointerCapture?.(e.pointerId)) { + scroller.releasePointerCapture?.(e.pointerId) + } + } catch {} + } + + if (this._hasDragged) { + this._handleScroll() + } + } + + _onPointerCancel(e) { + this._isPointerDown = false + const scroller = this.scrollerElement + if (scroller) { + scroller.classList.remove('is-dragging') + try { + if (e && scroller.hasPointerCapture?.(e.pointerId)) { + scroller.releasePointerCapture?.(e.pointerId) + } + } catch {} + } + } + + _startAutoplay() { + this._stopAutoplay() + const delay = Number(this.autoplay) + if (delay && delay > 0) { + this._autoplayTimer = setInterval(() => { + this.next() + }, delay) + } + } + + _stopAutoplay() { + if (this._autoplayTimer) { + clearInterval(this._autoplayTimer) + this._autoplayTimer = null + } + } + + _pauseAutoplay() { + this._stopAutoplay() + } + + _resumeAutoplay() { + this._startAutoplay() + } + + static styles = [ + css` + :host { + display: block; + position: relative; + box-sizing: border-box; + width: 100%; + overflow: hidden; + border-radius: var(--md-carousel-shape, var(--md-sys-shape-corner-extra-large, 28px)); + --_nav-button-size: 40px; + --_nav-button-color: var(--md-sys-color-surface-container-highest, #e6e0e9); + --_nav-button-icon-color: var(--md-sys-color-on-surface, #1d1b20); + --_indicator-color: var(--md-sys-color-outline-variant, #cac4d0); + --_indicator-active-color: var(--md-sys-color-primary, #6750a4); + } + + .carousel-container { + display: flex; + flex-direction: column; + position: relative; + width: 100%; + height: 100%; + min-height: 0; + box-sizing: border-box; + overflow: hidden; + border-radius: inherit; + } + + .scroller { + display: flex; + flex-direction: row; + flex: 1; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; + gap: var(--_item-spacing, 8px); + width: 100%; + height: 100%; + min-height: 0; + box-sizing: border-box; + outline: none; + user-select: none; + touch-action: pan-y pinch-zoom; + -webkit-overflow-scrolling: touch; + } + + ::slotted([md-carousel-item]), + ::slotted(md-carousel-item) { + height: 100%; + min-height: 0; + } + + :host([scroll-snap]) .scroller:not(.is-dragging) { + scroll-snap-type: x mandatory; + } + + :host([hide-scrollbar]) .scroller { + scrollbar-width: none; + -ms-overflow-style: none; + } + :host([hide-scrollbar]) .scroller::-webkit-scrollbar { + display: none; + } + + .scroller.is-dragging { + scroll-behavior: auto; + cursor: grabbing; + } + .scroller.is-dragging ::slotted(*) { + pointer-events: none; + } + + /* Layout alignments */ + ::slotted([md-carousel-item]), + ::slotted(md-carousel-item) { + height: 100%; + min-height: 0; + scroll-snap-align: start; + } + + :host([layout='centered-hero']) ::slotted([md-carousel-item]), + :host([layout='centered-hero']) ::slotted(md-carousel-item) { + scroll-snap-align: center; + } + + /* Navigation Buttons */ + .nav-button { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: var(--_nav-button-size); + height: var(--_nav-button-size); + border-radius: 50%; + background: var(--_nav-button-color); + color: var(--_nav-button-icon-color); + border: none; + outline: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + transition: + opacity 0.2s cubic-bezier(0.2, 0, 0, 1), + transform 0.2s cubic-bezier(0.2, 0, 0, 1), + background-color 0.2s cubic-bezier(0.2, 0, 0, 1); + -webkit-tap-highlight-color: transparent; + } + + .nav-button md-elevation { + --md-elevation-level: 2; + border-radius: 50%; + } + + .nav-button md-focus-ring { + --md-focus-ring-shape: 50%; + } + + .nav-button.prev { + left: 12px; + } + + .nav-button.next { + right: 12px; + } + + .nav-button.disabled, + .nav-button:disabled { + opacity: 0; + pointer-events: none; + } + + :host([navigation='auto']) .nav-button { + opacity: 0; + } + + :host([navigation='auto']:hover) .nav-button:not(.disabled), + :host([navigation='auto']:focus-within) .nav-button:not(.disabled) { + opacity: 0.92; + } + + :host([navigation='auto']:hover) .nav-button:not(.disabled):hover { + opacity: 1; + transform: translateY(-50%) scale(1.06); + } + + :host([navigation='always']) .nav-button:not(.disabled) { + opacity: 0.92; + } + :host([navigation='always']) .nav-button:not(.disabled):hover { + opacity: 1; + transform: translateY(-50%) scale(1.06); + } + + /* Indicators */ + .indicators { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 12px; + width: 100%; + } + + .indicator-dot { + width: 8px; + height: 8px; + border-radius: 4px; + background: var(--_indicator-color); + border: none; + outline: none; + padding: 0; + cursor: pointer; + transition: + width 0.3s cubic-bezier(0.2, 0, 0, 1), + background-color 0.3s cubic-bezier(0.2, 0, 0, 1); + } + + .indicator-dot.active { + width: 24px; + background: var(--_indicator-active-color); + } + + .indicator-dot:hover:not(.active) { + background: var(--md-sys-color-outline, #79747e); + } + `, + ] +} + +customElements.define('md-carousel', Carousel) diff --git a/common.js b/common.js index 6df7603..39ebbe7 100644 --- a/common.js +++ b/common.js @@ -7,6 +7,8 @@ * for production. */ import './buttons/button.js' +import './carousel/carousel.js' +import './carousel/carousel-item.js' import './checkbox/checkbox.js' import './chips/chip.js' import './chips/chip-set.js' @@ -27,6 +29,8 @@ import './tabs/tabs.js' import './text/text-field.js' export * from './buttons/button.js' +export * from './carousel/carousel.js' +export * from './carousel/carousel-item.js' export * from './checkbox/checkbox.js' export * from './chips/chip.js' export * from './chips/chip-set.js' diff --git a/demo/carousel-demo.html b/demo/carousel-demo.html new file mode 100644 index 0000000..1fd926f --- /dev/null +++ b/demo/carousel-demo.html @@ -0,0 +1,463 @@ + + + + Material 3 Carousel Demo + + + + + + + + + + + + + + + + + + + + +
+ + arrow_back Back to Main Demo + +

Material 3 Carousel

+

+ Interactive implementation of Google's Material Design 3 Carousel specification. +

+
+ + +
+
+

Interactive Playground

+ + Active Slide: 1 / 6 + +
+

Change the layout strategy, toggle indicators, autoplay, or navigate programmatically.

+ +
+ + + + + + + + + + + +
+ Previous + Next +
+
+ + +
+ + +
+

1. Multi-Browse Layout (Default)

+

+ Displays items of varying sizes (large, medium, and small peek). Dynamically recalculates item widths as the + container resizes. +

+ + +
+ + +
+

2. Uncontained Layout with Custom Cards

+

+ Displays uniform width items scrolling across the container boundary. Excellent for cards, articles, and + text-heavy items. +

+ + +
+ + +
+

3. Hero Layout (Start-aligned)

+

Spotlights one dominant large item with a smaller peek item on the trailing side.

+ + +
+ + +
+

4. Centered Hero Layout

+

Centers one large focal item flanked by peek items on both the left and right edges.

+ + +
+ + +
+

5. Full-Screen Layout with Autoplay (4s)

+

Edge-to-edge full width slide presentation with pagination and hover-pause autoplay.

+ + +
+ + + + diff --git a/demo/index.html b/demo/index.html index 9358c93..acbdd70 100644 --- a/demo/index.html +++ b/demo/index.html @@ -65,6 +65,8 @@ import 'material/app/bar.js' import 'material/indicators/progress.js' import 'material/indicators/loading.js' + import 'material/carousel/carousel.js' + import 'material/carousel/carousel-item.js' import './components/expressive-component.js' @@ -156,6 +158,7 @@ Other demo pages: @@ -184,22 +187,22 @@

Inputs

Progress

-
+
- +

Wavy Progress Indicators

-
+
- - + +

Loading Indicator (M3 Expressive)

-
+
@@ -207,6 +210,31 @@

Loading Indicator (M3 Expressive)

+
+

Carousel (M3 Multi-Browse)

+ + View Full Carousel Demo arrow_forward + +
+
+ + + Mountain + + + Ocean + + + Forest + + + Desert + + +
+