diff --git a/CHANGELOG.md b/CHANGELOG.md index f1de85424..03365596a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- #### Timeline + - New `igc-timeline` and `igc-timeline-item` components. Items line up along a vertical or horizontal connector, with the main content in the default slot, side content in the `opposite` slot and a custom marker in the `indicator` slot. The timeline `position` attribute (`alternate`, `start`, `end`) sets the side of the content for all items and an item `position` overrides it. `complete` fills the indicator and draws the connector to the next item solid, and `active` emphasizes the indicator and exposes the item as `aria-current`. The layout uses CSS subgrid, thus the connector stays aligned across items of different size and the side column takes only the space it needs. + ### Fixed - #### Carousel - Indicators now carry their `aria-label` as a content attribute in addition to `ElementInternals`, thus accessibility tools that do not read internals report the tab name. diff --git a/src/components/timeline/item.base.scss b/src/components/timeline/item.base.scss new file mode 100644 index 000000000..dd423a9a8 --- /dev/null +++ b/src/components/timeline/item.base.scss @@ -0,0 +1,224 @@ +@use 'styles/common/component'; + +// Layout is resolved by the item from the parent timeline and exposed as +// custom states: +// :state(ig-horizontal) - the timeline is horizontal +// :state(ig-start) - the main content renders on the start side +// :state(ig-previous-complete) - the preceding item is `complete` +// The connector is split in two halves. `::before` runs towards the previous +// item and `::after` towards the next one, so that a segment between two +// items is drawn solid when the earlier of the two is complete. + +$muted: color-mix(in oklab, currentcolor 20%, transparent); +$strong: color-mix(in oklab, currentcolor 70%, transparent); + +:host { + --_gap: var(--connector-gap, 0.875rem); + --_indicator-size: 2.5rem; + --_indicator-fill: transparent; + --_indicator-border: color-mix(in oklab, currentcolor 40%, transparent); + --_indicator-shadow: var( + --indicator-shadow, + 0 0 0 4px color-mix(in oklab, currentcolor 8%, transparent) + ); + --_connector-before: var(--connector-background, #{$muted}); + --_connector-after: var(--connector-background, #{$muted}); + + display: grid; + + // Inside a timeline the item lays out on the shared tracks. Outside one + // `subgrid` resolves to `none` and the implicit tracks take over. + grid-template-columns: subgrid; + grid-auto-columns: minmax(0, 1fr) auto minmax(0, 1fr); + grid-column: 1 / -1; + align-items: center; +} + +// ---- Vertical (default) ------------------------------------------------ + +[part~='opposite'] { + grid-column: 1; + grid-row: 1; + text-align: end; + padding-inline-end: var(--_gap); + padding-block: var(--_gap); +} + +[part~='connector'] { + grid-column: 2; + grid-row: 1; + align-self: stretch; + display: flex; + flex-direction: column; + align-items: center; + + &::before, + &::after { + content: ''; + flex: 1; + width: var(--connector-width, 2px); + min-height: 0.75rem; + } + + &::before { + background: var(--_connector-before); + } + + &::after { + background: var(--_connector-after); + } +} + +[part~='indicator'] { + flex-shrink: 0; + display: grid; + place-items: center; + width: var(--indicator-size, var(--_indicator-size)); + height: var(--indicator-size, var(--_indicator-size)); + border-radius: 50%; + border: var(--indicator-border-width, 2px) solid + var(--indicator-border-color, var(--_indicator-border)); + background: var(--indicator-background, var(--_indicator-fill)); + color: var(--indicator-color, inherit); + box-shadow: var(--_indicator-shadow); + + &[part~='empty'] { + --_indicator-size: 1rem; + } +} + +[part~='content'] { + grid-column: 3; + grid-row: 1; + text-align: start; + padding-inline-start: var(--_gap); + padding-block: var(--_gap); +} + +// An empty opposite slot takes no space, so a timeline without side content +// has no blank track. +[part~='opposite'][part~='empty'] { + display: none; +} + +:host(:state(ig-start)) { + [part~='content'] { + grid-column: 1; + text-align: end; + padding-inline: 0 var(--_gap); + } + + [part~='opposite'] { + grid-column: 3; + text-align: start; + padding-inline: var(--_gap) 0; + } +} + +// ---- Horizontal -------------------------------------------------------- + +:host(:state(ig-horizontal)) { + grid-template-columns: none; + grid-auto-columns: auto; + grid-template-rows: subgrid; + grid-auto-rows: minmax(0, 1fr) auto minmax(0, 1fr); + grid-column: auto; + grid-row: 1 / -1; + + [part~='opposite'] { + grid-row: 1; + grid-column: 1; + align-self: end; + text-align: center; + padding-inline: var(--_gap); + padding-block: 0 var(--_gap); + } + + [part~='connector'] { + grid-row: 2; + grid-column: 1; + flex-direction: row; + place-self: center stretch; + + &::before, + &::after { + width: auto; + height: var(--connector-width, 2px); + min-height: unset; + min-width: 0.75rem; + } + } + + [part~='content'] { + grid-row: 3; + grid-column: 1; + align-self: start; + text-align: center; + padding-inline: var(--_gap); + padding-block: var(--_gap) 0; + } +} + +:host(:state(ig-horizontal):state(ig-start)) { + [part~='content'] { + grid-row: 1; + align-self: end; + padding-block: 0 var(--_gap); + } + + [part~='opposite'] { + grid-row: 3; + align-self: start; + padding-block: var(--_gap) 0; + } +} + +// The connector does not extend past the outermost items. +:host(:first-of-type) [part~='connector']::before, +:host(:last-of-type) [part~='connector']::after { + visibility: hidden; +} + +// ---- States ------------------------------------------------------------ + +:host([complete]) { + --_indicator-fill: color-mix(in oklab, currentcolor 12%, transparent); + --_indicator-border: #{$strong}; + --_connector-after: var(--connector-background-complete, #{$strong}); + + // A dot has nothing inside, so it fills solid. + [part~='indicator'][part~='empty'] { + --_indicator-fill: #{$strong}; + } +} + +:host(:state(ig-previous-complete)) { + --_connector-before: var(--connector-background-complete, #{$strong}); +} + +:host([active]) { + --_indicator-border: currentcolor; + --_indicator-shadow: var( + --indicator-shadow-active, + 0 0 0 6px color-mix(in oklab, currentcolor 16%, transparent) + ); +} + +@media (prefers-reduced-motion: no-preference) { + [part~='indicator'] { + transition: + background-color 0.25s ease, + border-color 0.25s ease, + box-shadow 0.25s ease; + } + + :host([active]) [part~='indicator'] { + animation: igc-timeline-pulse 2.4s ease-in-out infinite; + } + + @keyframes igc-timeline-pulse { + 50% { + box-shadow: 0 0 0 10px transparent; + } + } +} diff --git a/src/components/timeline/item.ts b/src/components/timeline/item.ts new file mode 100644 index 000000000..53f2822f2 --- /dev/null +++ b/src/components/timeline/item.ts @@ -0,0 +1,186 @@ +import { html, LitElement, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { timelineContext } from '#internals/context.js'; +import { createAsyncContext } from '#internals/controllers/async-consumer.js'; +import { addInternalsController } from '#internals/controllers/internals.js'; +import { addSlotController, setSlots } from '#internals/controllers/slot.js'; +import { registerComponent } from '#internals/definitions/register.js'; +import { partMap } from '#internals/part-map.js'; +import type { TimelineItemPosition } from '../types.js'; +import { styles } from './item.base.css.js'; +import type IgcTimelineComponent from './timeline.js'; + +/** + * Represents an individual event in an `igc-timeline`. + * + * @remarks + * The item resolves its layout from the parent timeline: the orientation, the + * side of the connector its content goes to and whether the preceding item is + * `complete`. The connector segment between two items is drawn solid when the + * earlier of the two is `complete`, so a run of complete items reads as + * progress along the line. + * + * @element igc-timeline-item + * + * @slot - Renders the main content of the timeline item. + * @slot indicator - Renders custom content inside the indicator. Without content the indicator collapses to a dot. + * @slot opposite - Renders content on the side opposite to the main content. + * + * @csspart opposite - The wrapper of the opposite-side content slot. + * @csspart connector - The container holding the connector line and indicator. + * @csspart indicator - The circular indicator element. + * @csspart content - The wrapper of the main content slot. + * @csspart empty - Indicates that a slot has no content. Applies to `opposite` and `indicator`. + * + * @cssproperty --indicator-size - The diameter of the indicator. Defaults to a compact dot without indicator content. + * @cssproperty --indicator-border-width - The border width of the indicator. + * @cssproperty --indicator-border-color - The border color of the indicator. + * @cssproperty --indicator-background - The fill color of the indicator. + * @cssproperty --indicator-color - The text color inside the indicator. + * @cssproperty --indicator-shadow - The box-shadow of the indicator. + * @cssproperty --indicator-shadow-active - The box-shadow of the indicator of an `active` item. + * @cssproperty --connector-width - The thickness of the connector line. + * @cssproperty --connector-background - The color/gradient of the connector line. + * @cssproperty --connector-background-complete - The color/gradient of the connector line next to a `complete` item. + * @cssproperty --connector-gap - The distance between the connector and the item content. + * + * @example + * ```html + * + * + * + *

v3.0.0

+ *

Timeline component released.

+ *
+ * ``` + */ +export default class IgcTimelineItemComponent extends LitElement { + public static readonly tagName = 'igc-timeline-item'; + public static override styles = styles; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcTimelineItemComponent); + } + + //#region Internal state + + private readonly _internals = addInternalsController(this, { + initialARIA: { role: 'listitem' }, + aria: () => ({ ariaCurrent: this.active ? 'true' : null }), + }); + + private readonly _slots = addSlotController(this, { + slots: setSlots('indicator', 'opposite'), + }); + + private _timeline?: IgcTimelineComponent; + + //#endregion + + //#region Public properties + + /** + * The side of the connector line on which the main content renders. + * Overrides the `position` of the parent timeline for this item only. + * + * @remarks + * In a `start` or `end` timeline the side tracks are shared, so the content + * of an overriding item sizes the narrow `opposite` track for every item. + * Reserve the override for short content there. + * + * @attr + */ + @property({ reflect: true }) + public position?: TimelineItemPosition; + + /** + * Marks the item as the current one. The indicator is emphasized and the + * item is exposed as `aria-current` to assistive technology. + * + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + public active = false; + + /** + * Marks the item as complete. The indicator is filled and the connector + * segment towards the next item is drawn solid. + * + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + public complete = false; + + //#endregion + + constructor() { + super(); + + createAsyncContext(this, timelineContext, (timeline) => { + this._timeline = timeline; + }); + } + + //#region Lit lifecycle + + protected override willUpdate(changed: PropertyValues): void { + const items = this._timeline?.items ?? []; + const index = items.indexOf(this); + + // The next item draws its connector from this one's `complete`. + if (changed.has('complete')) { + items[index + 1]?.requestUpdate(); + } + + this._internals.setState( + 'ig-horizontal', + this._timeline?.orientation === 'horizontal' + ); + this._internals.setState('ig-start', this._resolveSide(index) === 'start'); + this._internals.setState( + 'ig-previous-complete', + items[index - 1]?.complete ?? false + ); + } + + //#endregion + + //#region Internal API + + /** The side the main content renders on, after applying the timeline layout. */ + private _resolveSide(index: number): TimelineItemPosition { + const position = this.position ?? this._timeline?.position ?? 'end'; + return position === 'alternate' ? (index % 2 ? 'start' : 'end') : position; + } + + //#endregion + + protected override render() { + const hasIndicator = this._slots.hasAssignedElements('indicator'); + const hasOpposite = this._slots.hasAssignedElements('opposite'); + + return html` +
+ +
+
+
+ +
+
+
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-timeline-item': IgcTimelineItemComponent; + } +} diff --git a/src/components/timeline/timeline.base.scss b/src/components/timeline/timeline.base.scss new file mode 100644 index 000000000..70af01264 --- /dev/null +++ b/src/components/timeline/timeline.base.scss @@ -0,0 +1,51 @@ +@use 'styles/common/component'; + +// The timeline owns the shared tracks. Every item spans all of them and lays +// its parts out through `subgrid`, so the connector column (or row) is aligned +// across items and the `opposite` track sizes to its widest content. +// Track order is always: opposite-side, connector, content-side. Which slot +// lands on which side is decided per item (see item.base.scss). + +:host { + --_tracks: minmax(0, 1fr) auto minmax(0, 1fr); + + display: grid; + grid-template-columns: var(--_tracks); + grid-auto-rows: minmax(var(--item-min-height, 5rem), auto); +} + +:host([position='start']) { + --_tracks: minmax(0, 1fr) auto auto; +} + +:host([position='end']) { + --_tracks: auto auto minmax(0, 1fr); +} + +:host([orientation='horizontal']) { + grid-auto-flow: column; + grid-template-columns: none; + grid-auto-columns: minmax(var(--item-min-width, 8rem), 1fr); + grid-template-rows: var(--_tracks); + grid-auto-rows: auto; + overflow-x: auto; +} + +@media (prefers-reduced-motion: no-preference) { + ::slotted(igc-timeline-item) { + transition: + opacity 0.4s ease, + translate 0.4s ease; + + @starting-style { + opacity: 0; + translate: 0 1.25rem; + } + } + + :host([orientation='horizontal']) ::slotted(igc-timeline-item) { + @starting-style { + translate: 1.25rem 0; + } + } +} diff --git a/src/components/timeline/timeline.spec.ts b/src/components/timeline/timeline.spec.ts new file mode 100644 index 000000000..d60cdaa1f --- /dev/null +++ b/src/components/timeline/timeline.spec.ts @@ -0,0 +1,300 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; +import { internalsOf } from '#internals/controllers/internals.js'; +import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { finishAnimationsFor } from '#internals/testing/helpers.spec.js'; +import IgcTimelineItemComponent from './item.js'; +import IgcTimelineComponent from './timeline.js'; + +describe('Timeline', () => { + before(() => { + defineComponents(IgcTimelineComponent); + }); + + let timeline: IgcTimelineComponent; + + /** Resolves once the items have consumed the timeline context and re-rendered. */ + async function settled(): Promise { + await nextFrame(); + await Promise.all(timeline.items.map((item) => elementUpdated(item))); + } + + function states(name: string): boolean[] { + return timeline.items.map((item) => item.matches(`:state(${name})`)); + } + + function part(item: IgcTimelineItemComponent, name: string): HTMLElement { + return item.shadowRoot!.querySelector(`[part~='${name}']`)!; + } + + async function createTimeline( + template = html` + + + 08:00 + 1 + First + + Second + Third + Fourth + + ` + ): Promise { + timeline = await fixture(template); + await settled(); + } + + beforeEach(async () => { + await createTimeline(); + }); + + describe('Accessibility', () => { + it('passes the a11y audit', async () => { + finishAnimationsFor(timeline, { subtree: true }); + + await expect(timeline).to.be.accessible(); + await expect(timeline).shadowDom.to.be.accessible(); + }); + + it('passes the a11y audit in horizontal orientation', async () => { + timeline.orientation = 'horizontal'; + await settled(); + finishAnimationsFor(timeline, { subtree: true }); + + await expect(timeline).to.be.accessible(); + }); + + it('exposes list semantics through internals', () => { + expect(internalsOf(timeline)!.getARIA('role')).to.equal('list'); + + for (const item of timeline.items) { + expect(internalsOf(item)!.getARIA('role')).to.equal('listitem'); + expect(internalsOf(item)!.getARIA('ariaCurrent')).to.be.null; + } + }); + + it('marks the active item as aria-current', async () => { + const [first, second] = timeline.items; + + second.active = true; + await elementUpdated(second); + + expect(internalsOf(first)!.getARIA('ariaCurrent')).to.be.null; + expect(internalsOf(second)!.getARIA('ariaCurrent')).to.equal('true'); + + second.active = false; + await elementUpdated(second); + + expect(internalsOf(second)!.getARIA('ariaCurrent')).to.be.null; + }); + }); + + describe('Initial rendering', () => { + it('reflects default values', () => { + expect(timeline).dom.to.equal( + '', + { ignoreChildren: ['igc-timeline'] } + ); + + expect(timeline.items[1]).dom.to.equal( + 'Second' + ); + }); + + it('returns only timeline items from `items`', async () => { + await createTimeline(html` + + First +
Stray
+ Second +
+ `); + + expect(timeline.items.map((item) => item.textContent)).to.eql([ + 'First', + 'Second', + ]); + }); + + it('renders the item parts', () => { + const [first, second] = timeline.items; + + expect(first).shadowDom.to.equal(` +
+ +
+
+
+ +
+
+
+ +
+ `); + + expect(part(second, 'opposite').part.contains('empty')).to.be.true; + expect(part(second, 'indicator').part.contains('empty')).to.be.true; + }); + + it('drops the empty part when slot content arrives', async () => { + const item = timeline.items[1]; + const indicator = document.createElement('span'); + indicator.slot = 'indicator'; + item.append(indicator); + await elementUpdated(item); + + expect(part(item, 'indicator').part.contains('empty')).to.be.false; + expect(part(item, 'opposite').part.contains('empty')).to.be.true; + }); + }); + + describe('Layout resolution', () => { + it('alternates sides by default', () => { + expect(states('ig-start')).to.eql([false, true, false, true]); + }); + + it('follows the timeline `position`', async () => { + timeline.position = 'start'; + await settled(); + expect(states('ig-start')).to.eql([true, true, true, true]); + + timeline.position = 'end'; + await settled(); + expect(states('ig-start')).to.eql([false, false, false, false]); + + timeline.position = 'alternate'; + await settled(); + expect(states('ig-start')).to.eql([false, true, false, true]); + }); + + it('lets an item override the timeline `position`', async () => { + timeline.position = 'end'; + const item = timeline.items[2]; + item.position = 'start'; + await settled(); + + expect(states('ig-start')).to.eql([false, false, true, false]); + + item.position = undefined; + await settled(); + + expect(states('ig-start')).to.eql([false, false, false, false]); + }); + + it('propagates the orientation', async () => { + expect(states('ig-horizontal')).to.eql([false, false, false, false]); + + timeline.orientation = 'horizontal'; + await settled(); + expect(states('ig-horizontal')).to.eql([true, true, true, true]); + + timeline.orientation = 'vertical'; + await settled(); + expect(states('ig-horizontal')).to.eql([false, false, false, false]); + }); + + it('re-resolves when items are added or removed', async () => { + const item = document.createElement(IgcTimelineItemComponent.tagName); + item.textContent = 'Inserted'; + timeline.prepend(item); + await settled(); + + expect(timeline.items).lengthOf(5); + expect(states('ig-start')).to.eql([false, true, false, true, false]); + + item.remove(); + await settled(); + + expect(timeline.items).lengthOf(4); + expect(states('ig-start')).to.eql([false, true, false, true]); + }); + + it('resolves a standalone item as vertical end side', async () => { + const item = await fixture( + html`Alone` + ); + await nextFrame(); + + expect(item.matches(':state(ig-start)')).to.be.false; + expect(item.matches(':state(ig-horizontal)')).to.be.false; + }); + }); + + describe('Complete state', () => { + it('marks the item after a complete one', async () => { + const [first, second] = timeline.items; + + first.complete = true; + await settled(); + + expect(first.hasAttribute('complete')).to.be.true; + expect(states('ig-previous-complete')).to.eql([ + false, + true, + false, + false, + ]); + + second.complete = true; + await settled(); + expect(states('ig-previous-complete')).to.eql([false, true, true, false]); + + first.complete = false; + await settled(); + expect(states('ig-previous-complete')).to.eql([ + false, + false, + true, + false, + ]); + }); + + it('is resolved from the initial markup', async () => { + await createTimeline(html` + + First + Second + Third + Fourth + + `); + + expect(states('ig-previous-complete')).to.eql([false, true, true, false]); + expect(internalsOf(timeline.items[2])!.getARIA('ariaCurrent')).to.equal( + 'true' + ); + }); + + it('follows the item order after a reorder', async () => { + const [first, , third] = timeline.items; + + third.complete = true; + await settled(); + expect(states('ig-previous-complete')).to.eql([ + false, + false, + false, + true, + ]); + + timeline.prepend(third); + await settled(); + + expect(timeline.items[0]).to.equal(third); + expect(timeline.items[1]).to.equal(first); + expect(states('ig-previous-complete')).to.eql([ + false, + true, + false, + false, + ]); + }); + }); +}); diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts new file mode 100644 index 000000000..3c69aae71 --- /dev/null +++ b/src/components/timeline/timeline.ts @@ -0,0 +1,124 @@ +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { timelineContext } from '#internals/context.js'; +import { addContextProvider } from '#internals/controllers/context-provider.js'; +import { addInternalsController } from '#internals/controllers/internals.js'; +import { addSlotController, setSlots } from '#internals/controllers/slot.js'; +import { registerComponent } from '#internals/definitions/register.js'; +import type { TimelineOrientation, TimelinePosition } from '../types.js'; +import IgcTimelineItemComponent from './item.js'; +import { styles } from './timeline.base.css.js'; + +/* blazorAdditionalDependency: IgcTimelineItemComponent */ +/** + * A container component that arranges `igc-timeline-item` elements along a + * vertical or horizontal axis connected by a visual line. + * + * @remarks + * The timeline owns the shared grid tracks and every item lays its parts out + * on them through CSS subgrid, so the connector line stays aligned across items + * regardless of their content and the `opposite` track sizes to its widest entry. + * + * @element igc-timeline + * + * @slot - Renders `igc-timeline-item` elements. + * + * @cssproperty --item-min-height - The minimum block size of a vertical timeline item. + * @cssproperty --item-min-width - The minimum inline size of a horizontal timeline item. + * + * @example + * ```html + * + * + * 09:00 + * Order placed + * + * + * 10:30 + * Shipped + * + * + * Tomorrow + * Delivered + * + * + * ``` + */ +export default class IgcTimelineComponent extends LitElement { + public static readonly tagName = 'igc-timeline'; + public static override styles = styles; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcTimelineComponent, IgcTimelineItemComponent); + } + + //#region Internal state + + private readonly _provider = addContextProvider(this, { + context: timelineContext, + watch: ['orientation', 'position'], + value: () => this, + }); + + private readonly _slots = addSlotController(this, { + slots: setSlots(), + onChange: () => this._provider.publish(), + }); + + //#endregion + + //#region Public properties + + /** Returns all `igc-timeline-item` children of the timeline. */ + public get items(): IgcTimelineItemComponent[] { + return this._slots.getAssignedElements( + '[default]', + { selector: IgcTimelineItemComponent.tagName } + ); + } + + /** + * The axis along which the items are laid out. + * + * @attr + * @default vertical + */ + @property({ reflect: true }) + public orientation: TimelineOrientation = 'vertical'; + + /** + * The side of the connector line on which the main content of the items is + * rendered. The `opposite` slot content always renders on the other side. + * + * In vertical orientation `start` and `end` refer to the inline axis, in + * horizontal orientation to the block axis. `alternate` switches sides on + * every item. An item with its own `position` set ignores this value. + * + * @attr + * @default alternate + */ + @property({ reflect: true }) + public position: TimelinePosition = 'alternate'; + + //#endregion + + constructor() { + super(); + + addInternalsController(this, { + initialARIA: { role: 'list' }, + }); + } + + protected override render() { + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-timeline': IgcTimelineComponent; + } +} diff --git a/src/components/types.ts b/src/components/types.ts index 616ab243e..a24a7d3c8 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -62,4 +62,7 @@ export type TabsAlignment = 'start' | 'end' | 'center' | 'justify'; export type TextareaResize = 'vertical' | 'auto' | 'none'; export type TileManagerDragMode = 'none' | 'tile-header' | 'tile'; export type TileManagerResizeMode = 'none' | 'hover' | 'always'; +export type TimelineItemPosition = 'start' | 'end'; +export type TimelineOrientation = 'horizontal' | 'vertical'; +export type TimelinePosition = 'alternate' | 'start' | 'end'; //#endregion diff --git a/src/index.ts b/src/index.ts index 74ec9e7bf..9a145f861 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,8 @@ export type { VirtualScrollState, VirtualScrollDataRequest, } from './components/virtualization/types.js'; +export { default as IgcTimelineComponent } from './components/timeline/timeline.js'; +export { default as IgcTimelineItemComponent } from './components/timeline/item.js'; // definitions export { defineComponents } from '#internals/definitions/defineComponents.js'; diff --git a/src/internals/context.ts b/src/internals/context.ts index 169f0018a..1547a540e 100644 --- a/src/internals/context.ts +++ b/src/internals/context.ts @@ -5,6 +5,7 @@ import type IgcToggleButtonComponent from '../components/button-group/toggle-but import type IgcCarouselComponent from '../components/carousel/carousel.js'; import type { ChatState } from '../components/chat/chat-state.js'; import type IgcTileManagerComponent from '../components/tile-manager/tile-manager.js'; +import type IgcTimelineComponent from '../components/timeline/timeline.js'; export type ButtonGroupContext = { /** The igc-button-group instance. */ @@ -37,6 +38,10 @@ const tileManagerContext = createContext( Symbol('tile-manager-context') ); +const timelineContext = createContext( + Symbol('timeline-context') +); + const chatContext = createContext(Symbol('chat-context')); const chatUserInputContext = createContext( Symbol('chat-user-input-context') @@ -48,4 +53,5 @@ export { chatContext, chatUserInputContext, tileManagerContext, + timelineContext, }; diff --git a/src/internals/definitions/defineAllComponents.ts b/src/internals/definitions/defineAllComponents.ts index 172794fb2..fe3db3d6f 100644 --- a/src/internals/definitions/defineAllComponents.ts +++ b/src/internals/definitions/defineAllComponents.ts @@ -69,6 +69,8 @@ import IgcTextareaComponent from '../../components/textarea/textarea.js'; import IgcThemeProviderComponent from '../../components/theme-provider/theme-provider.js'; import IgcTileManagerComponent from '../../components/tile-manager/tile-manager.js'; import IgcTileComponent from '../../components/tile-manager/tile.js'; +import IgcTimelineItemComponent from '../../components/timeline/item.js'; +import IgcTimelineComponent from '../../components/timeline/timeline.js'; import IgcToastComponent from '../../components/toast/toast.js'; import IgcTooltipComponent from '../../components/tooltip/tooltip.js'; import IgcTreeItemComponent from '../../components/tree/tree-item.js'; @@ -151,6 +153,8 @@ const allComponents: IgniteComponent[] = [ IgcTextareaComponent, IgcTileComponent, IgcTileManagerComponent, + IgcTimelineComponent, + IgcTimelineItemComponent, IgcTooltipComponent, IgcQrCodeComponent, IgcVirtualScrollComponent, diff --git a/stories/timeline.stories.ts b/stories/timeline.stories.ts new file mode 100644 index 000000000..8bb70e43f --- /dev/null +++ b/stories/timeline.stories.ts @@ -0,0 +1,448 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { + defineComponents, + IgcBadgeComponent, + IgcTimelineComponent, +} from 'igniteui-webcomponents'; +import { html } from 'lit'; + +defineComponents(IgcTimelineComponent, IgcBadgeComponent); + +// region default +const metadata: Meta = { + title: 'Timeline', + component: 'igc-timeline', + parameters: { + docs: { + description: { + component: + 'A container component that arranges `igc-timeline-item` elements along a\nvertical or horizontal axis connected by a visual line.', + }, + }, + }, + argTypes: { + orientation: { + type: { name: 'enum', value: ['horizontal', 'vertical'] }, + description: 'The axis along which the items are laid out.', + options: ['horizontal', 'vertical'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'vertical' } }, + }, + position: { + type: { name: 'enum', value: ['alternate', 'start', 'end'] }, + description: + 'The side of the connector line on which the main content of the items is\nrendered. The `opposite` slot content always renders on the other side.\n\nIn vertical orientation `start` and `end` refer to the inline axis, in\nhorizontal orientation to the block axis. `alternate` switches sides on\nevery item. An item with its own `position` set ignores this value.', + options: ['alternate', 'start', 'end'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'alternate' } }, + }, + }, + args: { orientation: 'vertical', position: 'alternate' }, +}; + +export default metadata; + +interface IgcTimelineArgs { + /** The axis along which the items are laid out. */ + orientation: 'horizontal' | 'vertical'; + /** + * The side of the connector line on which the main content of the items is + * rendered. The `opposite` slot content always renders on the other side. + * + * In vertical orientation `start` and `end` refer to the inline axis, in + * horizontal orientation to the block axis. `alternate` switches sides on + * every item. An item with its own `position` set ignores this value. + */ + position: 'alternate' | 'start' | 'end'; +} +type Story = StoryObj; + +// endregion + +const indicatorStyle = (bg: string, border: string) => + `--indicator-background: ${bg}; --indicator-border-color: ${border}; --indicator-shadow: 0 0 0 4px color-mix(in oklab, ${border} 15%, transparent);`; + +export const Basic: Story = { + render: (args) => html` + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + + `, +}; + +export const OrderTracking: Story = { + args: { position: 'end' }, + render: (args) => html` + + + + +

Order placed

+

Confirmation sent to your inbox

+
+ + +

Packed

+

2 items, 1 parcel

+
+ + +

In transit

+

Left the regional hub

+
+ + +

Out for delivery

+
+ + +

Delivered

+
+
+ `, +}; + +export const DailySchedule: Story = { + render: (args) => html` + + + + 8:00 am + โ˜• +
+

Morning routine

+

Coffee, quick news scan, and today's task list

+
+
+ + + 9:15 am + ๐Ÿ—ฃ๏ธ +
+

Stand-up

+

+ 15 min sync โ€” blockers, PRs to review, deploys scheduled +

+
+
+ + + 9:30 am + ๐Ÿ”จ +
+

Deep work block

+

+ Notifications off โ€” feature branch, tests, commits +

+
+
+ + + 12:30 pm + ๐Ÿฑ +
+

Lunch

+

Away from the screen โ€” actually touch grass

+
+
+ + + 1:30 pm + ๐Ÿ‘€ +
+

Code reviews

+

Two PRs in the queue โ€” leave thorough comments

+
+
+ + + 2:30 pm + ๐Ÿ”จ +
+

Afternoon focus

+

Address review feedback, open a draft PR

+
+
+ + + 4:30 pm + ๐Ÿšจ +
+

Production incident

+

+ Memory spike in staging โ€” hotfix branch, post-mortem drafted +

+
+
+ + + 6:00 pm + ๐Ÿ““ +
+

Wrap-up

+

Update tickets, push WIP commits, close laptop

+
+
+
+ `, +}; + +export const ProjectMilestones: Story = { + render: (args) => html` + + + + โœ“ + Jan 2024 +
+

Project Kickoff

+

Requirements gathered and team assembled

+
+
+ + + โš™ + Mar 2024 +
+

Development Phase

+

Core features implemented and reviewed

+
+
+ + + ๐Ÿงช + May 2024 +
+

Testing & QA

+

All test suites passing, accessibility audited

+
+
+ + + ๐Ÿš€ + Jun 2024 +
+

Release v1.0

+

Published to npm and announced publicly

+
+
+
+ `, +}; + +export const Changelog: Story = { + args: { orientation: 'vertical', position: 'end' }, + render: (args) => html` + + + + ๐Ÿš€ + Mar 2026 +
+
+

v3.0.0

+ breaking +
+
    +
  • + Breaking: Dropped support for legacy theming + tokens +
  • +
  • + New: Timeline component with vertical & + horizontal orientation +
  • +
  • New: Tile Manager layout component
  • +
  • + Fix: Calendar year/month view keyboard navigation +
  • +
+
+
+ + + โœจ + Jan 2026 +
+
+

v2.4.0

+ new +
+
    +
  • + New: Chat component with message grouping support +
  • +
  • New: File Input drag-and-drop zone
  • +
  • + Improvement: Combo virtual scrolling performance +
  • +
  • Fix: Date Range Picker RTL alignment
  • +
+
+
+ + + ๐Ÿ”ง + Oct 2025 +
+
+

v2.3.1

+ fix +
+
    +
  • + Fix: Stepper linear mode skipping optional steps +
  • +
  • + Fix: Tree component selection propagation + edge-case +
  • +
  • + Fix: Slider thumb overlapping track on min value +
  • +
+
+
+ + + โšก + Jul 2025 +
+
+

v2.3.0

+ new +
+
    +
  • + New: Tooltip component with smart positioning +
  • +
  • + New: size CSS custom property on all + form components +
  • +
  • + Deprecation: igc-icon-button + variant โ€” use appearance instead +
  • +
  • + Fix: Select popup clipping inside overflow + containers +
  • +
+
+
+
+ `, +};