From 2af4f0b063cf3eadaa7d0a036897f8df1c6da87c Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 24 Mar 2026 13:47:07 +0200 Subject: [PATCH 1/3] feat: Added timeline component --- src/components/timeline/item.base.scss | 191 ++++++++++ src/components/timeline/item.ts | 65 ++++ src/components/timeline/timeline.base.scss | 34 ++ src/components/timeline/timeline.ts | 62 ++++ src/index.ts | 2 + stories/timeline.stories.ts | 385 +++++++++++++++++++++ 6 files changed, 739 insertions(+) create mode 100644 src/components/timeline/item.base.scss create mode 100644 src/components/timeline/item.ts create mode 100644 src/components/timeline/timeline.base.scss create mode 100644 src/components/timeline/timeline.ts create mode 100644 stories/timeline.stories.ts diff --git a/src/components/timeline/item.base.scss b/src/components/timeline/item.base.scss new file mode 100644 index 000000000..6ca0b1943 --- /dev/null +++ b/src/components/timeline/item.base.scss @@ -0,0 +1,191 @@ +@use 'styles/common/component'; + +// ---- Vertical (default) ------------------------------------------------ + +:host { + display: grid; + grid-template-columns: 1fr var(--connector-column, 3rem) 1fr; + align-items: center; + min-height: var(--item-min-height, 5rem); + flex-shrink: 0; +} + +// Explicit grid-row: 1 on every part so auto-placement never shifts them +// to a new row regardless of DOM source order. +[part='opposite'] { + grid-column: 1; + grid-row: 1; + text-align: end; + padding-inline-end: 0.875rem; + padding-block: 0.875rem; + align-self: center; +} + +[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); + background: var( + --connector-background, + linear-gradient( + to bottom, + color-mix(in oklab, currentColor 15%, transparent), + color-mix(in oklab, currentColor 35%, transparent) + ) + ); + min-height: 0.75rem; + } +} + +[part='indicator'] { + flex-shrink: 0; + width: var(--indicator-size, 2.5rem); + height: var(--indicator-size, 2.5rem); + border-radius: 50%; + border: var(--indicator-border-width, 2px) solid + var( + --indicator-border-color, + color-mix(in oklab, currentColor 40%, transparent) + ); + display: flex; + align-items: center; + justify-content: center; + background: var(--indicator-background, transparent); + box-shadow: var( + --indicator-shadow, + 0 0 0 4px color-mix(in oklab, currentColor 8%, transparent) + ); + transition: + transform 0.25s ease, + box-shadow 0.25s ease; + z-index: 1; +} + +:host(:hover) [part='indicator'] { + transform: scale(1.12); + box-shadow: var( + --indicator-shadow-hover, + 0 0 0 6px color-mix(in oklab, currentColor 14%, transparent) + ); +} + +[part='content'] { + grid-column: 3; + grid-row: 1; + text-align: start; + padding-inline-start: 0.875rem; + padding-block: 0.875rem; + align-self: center; +} + +// Auto-alternating layout for even vertical items (not forced by position attr) +:host(:nth-child(even):not([position]):not([data-orientation='horizontal'])) { + [part='opposite'] { + grid-column: 3; + text-align: start; + padding-inline: 0.875rem 0; + } + + [part='content'] { + grid-column: 1; + text-align: end; + padding-inline: 0 0.875rem; + } +} + +// Manual position overrides +:host([position='start']) { + [part='content'] { + grid-column: 1; + text-align: end; + padding-inline: 0 0.875rem; + } + + [part='opposite'] { + grid-column: 3; + text-align: start; + padding-inline: 0.875rem 0; + } +} + +:host([position='end']) { + [part='content'] { + grid-column: 3; + text-align: start; + padding-inline: 0.875rem 0; + } + + [part='opposite'] { + grid-column: 1; + text-align: end; + padding-inline: 0 0.875rem; + } +} + +// Trim connector ends for the outermost items +:host(:first-child) [part='connector']::before { + visibility: hidden; +} + +:host(:last-child) [part='connector']::after { + visibility: hidden; +} + +// ---- Horizontal -------------------------------------------------------- + +:host([data-orientation='horizontal']) { + grid-template-columns: 1fr; + grid-template-rows: 1fr var(--connector-row, 3rem) 1fr; + min-height: unset; + min-width: var(--item-min-width, 5rem); + + [part='opposite'] { + grid-row: 1; + grid-column: 1; + align-self: end; + text-align: center; + padding-inline: 0.875rem; + padding-block: 0 0.875rem; + } + + [part='connector'] { + grid-row: 2; + grid-column: 1; + flex-direction: row; + align-self: center; + + &::before, + &::after { + width: auto; + height: var(--connector-width, 2px); + min-height: unset; + min-width: 0.75rem; + background: var( + --connector-background, + linear-gradient( + to right, + color-mix(in oklab, currentColor 15%, transparent), + color-mix(in oklab, currentColor 35%, transparent) + ) + ); + } + } + + [part='content'] { + grid-row: 3; + grid-column: 1; + align-self: start; + text-align: center; + padding-inline: 0.875rem; + padding-block: 0.875rem 0; + } +} diff --git a/src/components/timeline/item.ts b/src/components/timeline/item.ts new file mode 100644 index 000000000..e02cedee9 --- /dev/null +++ b/src/components/timeline/item.ts @@ -0,0 +1,65 @@ +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { styles } from './item.base.css.js'; + +/** + * Represents an individual event in an `igc-timeline`. + * + * @element igc-timeline-item + * + * @slot - Renders the main content of the timeline item. + * @slot indicator - Renders custom content inside the indicator circle. + * @slot opposite - Renders content on the side opposite to the main content. + * + * @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 opposite - The wrapper of the opposite-side content slot. + * + * @cssproperty --indicator-size - The diameter of the indicator circle. + * @cssproperty --connector-width - The thickness of the connector line. + * @cssproperty --connector-background - The color/gradient of the connector line. + * @cssproperty --indicator-border-color - The border color of the indicator. + * @cssproperty --indicator-background - The fill color of the indicator. + * @cssproperty --indicator-shadow - The box-shadow of the indicator. + */ +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); + } + + /** + * Controls which side the main content appears on relative to the connector line. + * When omitted the layout alternates automatically based on item position. + * @attr + */ + @property({ reflect: true }) + public position?: 'start' | 'end'; + + protected override render() { + 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..a6b72327e --- /dev/null +++ b/src/components/timeline/timeline.base.scss @@ -0,0 +1,34 @@ +@use 'styles/common/component'; + +:host { + display: flex; + position: relative; + + ::slotted(igc-timeline-item) { + transition: + opacity 0.4s ease, + transform 0.4s ease; + + @starting-style { + opacity: 0; + transform: translateY(1.25rem); + } + } +} + +:host([orientation='vertical']) { + flex-direction: column; +} + +:host([orientation='horizontal']) { + flex-direction: row; + + ::slotted(igc-timeline-item) { + flex: 1; + + @starting-style { + opacity: 0; + transform: translateX(1.25rem); + } + } +} diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts new file mode 100644 index 000000000..70ba145b5 --- /dev/null +++ b/src/components/timeline/timeline.ts @@ -0,0 +1,62 @@ +import { html, LitElement, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; +import { registerComponent } from '../common/definitions/register.js'; +import IgcTimelineItemComponent from './item.js'; +import { styles } from './timeline.base.css.js'; + +/** + * A container component that arranges `igc-timeline-item` elements along a + * vertical or horizontal axis connected by a visual line. + * + * @element igc-timeline + * + * @slot - Renders `igc-timeline-item` elements. + */ +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); + } + + private readonly _slots = addSlotController(this, { + slots: setSlots(), + onChange: this._syncOrientation, + initial: true, + }); + + @property({ reflect: true }) + public orientation: 'vertical' | 'horizontal' = 'vertical'; + + protected override updated(changed: PropertyValues): void { + if (changed.has('orientation')) { + this._syncOrientation(); + } + } + + private _syncOrientation(): void { + const items = this._slots.getAssignedElements( + '[default]', + { + selector: IgcTimelineItemComponent.tagName, + } + ); + + for (const item of items) { + item.dataset.orientation = this.orientation; + } + } + + protected override render() { + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-timeline': IgcTimelineComponent; + } +} diff --git a/src/index.ts b/src/index.ts index d62508469..b2998c083 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,8 @@ export { default as IgcStepComponent } from './components/stepper/step.js'; export { default as IgcHighlightComponent } from './components/highlight/highlight.js'; export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; export { default as IgcThemeProviderComponent } from './components/theme-provider/theme-provider.js'; +export { default as IgcTimelineComponent } from './components/timeline/timeline.js'; +export { default as IgcTimelineItemComponent } from './components/timeline/item.js'; // definitions export { defineComponents } from './components/common/definitions/defineComponents.js'; diff --git a/stories/timeline.stories.ts b/stories/timeline.stories.ts new file mode 100644 index 000000000..94005ad27 --- /dev/null +++ b/stories/timeline.stories.ts @@ -0,0 +1,385 @@ +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: '"vertical" | "horizontal"', + options: ['vertical', 'horizontal'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'vertical' } }, + }, + }, + args: { orientation: 'vertical' }, +}; + +export default metadata; + +interface IgcTimelineArgs { + orientation: 'vertical' | 'horizontal'; +} +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 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' }, + 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 +
  • +
+
+
+
+ `, +}; From c29f9af4db7cb6bb75c09f41724740be62fb6b54 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 24 Mar 2026 13:52:41 +0200 Subject: [PATCH 2/3] chore: Fixed styleline error --- src/components/timeline/item.base.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/timeline/item.base.scss b/src/components/timeline/item.base.scss index 6ca0b1943..c6dbd86c9 100644 --- a/src/components/timeline/item.base.scss +++ b/src/components/timeline/item.base.scss @@ -88,7 +88,7 @@ } // Auto-alternating layout for even vertical items (not forced by position attr) -:host(:nth-child(even):not([position]):not([data-orientation='horizontal'])) { +:host(:nth-child(even):not([position], [data-orientation='horizontal'])) { [part='opposite'] { grid-column: 3; text-align: start; From e0a3eaa887109eead866d23b085cb70b668ac87b Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 16 Sep 2026 12:43:06 +0300 Subject: [PATCH 3/3] feat(timeline): rewrite layout on CSS subgrid and add position, active and complete API The container now owns the grid tracks and every item lays its parts out through `subgrid`, so the connector stays aligned across items of any size and the `opposite` track sizes to its widest entry instead of taking half the width. The `data-orientation` propagation and the per-item `1fr 3rem 1fr` grids are gone. Items resolve their layout from the parent through a Lit context that publishes the timeline instance and expose the result as custom states (`ig-horizontal`, `ig-start`, `ig-previous-complete`) styled with `:host(:state(...))`. An item whose `complete` changes only nudges the next item, since that is the only sibling that derives from it. API: - timeline `position` (`alternate` | `start` | `end`) sets the content side for all items; item `position` still overrides per item - item `active` emphasizes the indicator and sets `aria-current` - item `complete` fills the indicator and draws the connector segment to the next item solid, so a run of complete items reads as progress - timeline is `role=list`, items are `role=listitem`, via internals - an empty `indicator` collapses to a dot and an empty `opposite` takes no space - entrance and hover motion respect `prefers-reduced-motion` Also registers both elements in `defineAllComponents` (they were missing), exports the `Timeline*` types, adds a 17-test spec with a11y audits in both orientations, updates the stories to use the container `position`, and adds an order-tracking story. --- CHANGELOG.md | 4 + src/components/timeline/item.base.scss | 257 ++++++++------- src/components/timeline/item.ts | 145 ++++++++- src/components/timeline/timeline.base.scss | 49 ++- src/components/timeline/timeline.spec.ts | 300 ++++++++++++++++++ src/components/timeline/timeline.ts | 102 ++++-- src/components/types.ts | 3 + src/internals/context.ts | 6 + .../definitions/defineAllComponents.ts | 4 + stories/timeline.stories.ts | 89 +++++- 10 files changed, 786 insertions(+), 173 deletions(-) create mode 100644 src/components/timeline/timeline.spec.ts 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 index c6dbd86c9..dd423a9a8 100644 --- a/src/components/timeline/item.base.scss +++ b/src/components/timeline/item.base.scss @@ -1,27 +1,50 @@ @use 'styles/common/component'; -// ---- Vertical (default) ------------------------------------------------ +// 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; - grid-template-columns: 1fr var(--connector-column, 3rem) 1fr; + + // 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; - min-height: var(--item-min-height, 5rem); - flex-shrink: 0; } -// Explicit grid-row: 1 on every part so auto-placement never shifts them -// to a new row regardless of DOM source order. -[part='opposite'] { +// ---- Vertical (default) ------------------------------------------------ + +[part~='opposite'] { grid-column: 1; grid-row: 1; text-align: end; - padding-inline-end: 0.875rem; - padding-block: 0.875rem; - align-self: center; + padding-inline-end: var(--_gap); + padding-block: var(--_gap); } -[part='connector'] { +[part~='connector'] { grid-column: 2; grid-row: 1; align-self: stretch; @@ -34,134 +57,88 @@ content: ''; flex: 1; width: var(--connector-width, 2px); - background: var( - --connector-background, - linear-gradient( - to bottom, - color-mix(in oklab, currentColor 15%, transparent), - color-mix(in oklab, currentColor 35%, transparent) - ) - ); min-height: 0.75rem; } + + &::before { + background: var(--_connector-before); + } + + &::after { + background: var(--_connector-after); + } } -[part='indicator'] { +[part~='indicator'] { flex-shrink: 0; - width: var(--indicator-size, 2.5rem); - height: var(--indicator-size, 2.5rem); + 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, - color-mix(in oklab, currentColor 40%, transparent) - ); - display: flex; - align-items: center; - justify-content: center; - background: var(--indicator-background, transparent); - box-shadow: var( - --indicator-shadow, - 0 0 0 4px color-mix(in oklab, currentColor 8%, transparent) - ); - transition: - transform 0.25s ease, - box-shadow 0.25s ease; - z-index: 1; -} + var(--indicator-border-color, var(--_indicator-border)); + background: var(--indicator-background, var(--_indicator-fill)); + color: var(--indicator-color, inherit); + box-shadow: var(--_indicator-shadow); -:host(:hover) [part='indicator'] { - transform: scale(1.12); - box-shadow: var( - --indicator-shadow-hover, - 0 0 0 6px color-mix(in oklab, currentColor 14%, transparent) - ); + &[part~='empty'] { + --_indicator-size: 1rem; + } } -[part='content'] { +[part~='content'] { grid-column: 3; grid-row: 1; text-align: start; - padding-inline-start: 0.875rem; - padding-block: 0.875rem; - align-self: center; + padding-inline-start: var(--_gap); + padding-block: var(--_gap); } -// Auto-alternating layout for even vertical items (not forced by position attr) -:host(:nth-child(even):not([position], [data-orientation='horizontal'])) { - [part='opposite'] { - grid-column: 3; - text-align: start; - padding-inline: 0.875rem 0; - } - - [part='content'] { - grid-column: 1; - text-align: end; - padding-inline: 0 0.875rem; - } +// An empty opposite slot takes no space, so a timeline without side content +// has no blank track. +[part~='opposite'][part~='empty'] { + display: none; } -// Manual position overrides -:host([position='start']) { - [part='content'] { +:host(:state(ig-start)) { + [part~='content'] { grid-column: 1; text-align: end; - padding-inline: 0 0.875rem; + padding-inline: 0 var(--_gap); } - [part='opposite'] { + [part~='opposite'] { grid-column: 3; text-align: start; - padding-inline: 0.875rem 0; - } -} - -:host([position='end']) { - [part='content'] { - grid-column: 3; - text-align: start; - padding-inline: 0.875rem 0; - } - - [part='opposite'] { - grid-column: 1; - text-align: end; - padding-inline: 0 0.875rem; + padding-inline: var(--_gap) 0; } } -// Trim connector ends for the outermost items -:host(:first-child) [part='connector']::before { - visibility: hidden; -} - -:host(:last-child) [part='connector']::after { - visibility: hidden; -} - // ---- Horizontal -------------------------------------------------------- -:host([data-orientation='horizontal']) { - grid-template-columns: 1fr; - grid-template-rows: 1fr var(--connector-row, 3rem) 1fr; - min-height: unset; - min-width: var(--item-min-width, 5rem); +: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'] { + [part~='opposite'] { grid-row: 1; grid-column: 1; align-self: end; text-align: center; - padding-inline: 0.875rem; - padding-block: 0 0.875rem; + padding-inline: var(--_gap); + padding-block: 0 var(--_gap); } - [part='connector'] { + [part~='connector'] { grid-row: 2; grid-column: 1; flex-direction: row; - align-self: center; + place-self: center stretch; &::before, &::after { @@ -169,23 +146,79 @@ height: var(--connector-width, 2px); min-height: unset; min-width: 0.75rem; - background: var( - --connector-background, - linear-gradient( - to right, - color-mix(in oklab, currentColor 15%, transparent), - color-mix(in oklab, currentColor 35%, transparent) - ) - ); } } - [part='content'] { + [part~='content'] { grid-row: 3; grid-column: 1; align-self: start; text-align: center; - padding-inline: 0.875rem; - padding-block: 0.875rem 0; + 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 index e02cedee9..53f2822f2 100644 --- a/src/components/timeline/item.ts +++ b/src/components/timeline/item.ts @@ -1,28 +1,59 @@ -import { html, LitElement } from 'lit'; +import { html, LitElement, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; -import { registerComponent } from '../common/definitions/register.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 circle. + * @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 opposite - The wrapper of the opposite-side content slot. + * @csspart empty - Indicates that a slot has no content. Applies to `opposite` and `indicator`. * - * @cssproperty --indicator-size - The diameter of the indicator circle. - * @cssproperty --connector-width - The thickness of the connector line. - * @cssproperty --connector-background - The color/gradient of the connector line. + * @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'; @@ -33,21 +64,111 @@ export default class IgcTimelineItemComponent extends LitElement { 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 + /** - * Controls which side the main content appears on relative to the connector line. - * When omitted the layout alternates automatically based on item position. + * 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?: 'start' | 'end'; + 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` -
+
-
+
diff --git a/src/components/timeline/timeline.base.scss b/src/components/timeline/timeline.base.scss index a6b72327e..70af01264 100644 --- a/src/components/timeline/timeline.base.scss +++ b/src/components/timeline/timeline.base.scss @@ -1,34 +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 { - display: flex; - position: relative; + --_tracks: minmax(0, 1fr) auto minmax(0, 1fr); - ::slotted(igc-timeline-item) { - transition: - opacity 0.4s ease, - transform 0.4s ease; + display: grid; + grid-template-columns: var(--_tracks); + grid-auto-rows: minmax(var(--item-min-height, 5rem), auto); +} - @starting-style { - opacity: 0; - transform: translateY(1.25rem); - } - } +:host([position='start']) { + --_tracks: minmax(0, 1fr) auto auto; } -:host([orientation='vertical']) { - flex-direction: column; +:host([position='end']) { + --_tracks: auto auto minmax(0, 1fr); } :host([orientation='horizontal']) { - flex-direction: row; + 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) { - flex: 1; + transition: + opacity 0.4s ease, + translate 0.4s ease; @starting-style { opacity: 0; - transform: translateX(1.25rem); + 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 index 70ba145b5..3c69aae71 100644 --- a/src/components/timeline/timeline.ts +++ b/src/components/timeline/timeline.ts @@ -1,17 +1,49 @@ -import { html, LitElement, type PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; -import { addSlotController, setSlots } from '../common/controllers/slot.js'; -import { registerComponent } from '../common/definitions/register.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'; @@ -22,32 +54,62 @@ export default class IgcTimelineComponent extends LitElement { 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._syncOrientation, - initial: true, + onChange: () => this._provider.publish(), }); - @property({ reflect: true }) - public orientation: 'vertical' | 'horizontal' = 'vertical'; + //#endregion - protected override updated(changed: PropertyValues): void { - if (changed.has('orientation')) { - this._syncOrientation(); - } - } + //#region Public properties - private _syncOrientation(): void { - const items = this._slots.getAssignedElements( + /** Returns all `igc-timeline-item` children of the timeline. */ + public get items(): IgcTimelineItemComponent[] { + return this._slots.getAssignedElements( '[default]', - { - selector: IgcTimelineItemComponent.tagName, - } + { 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(); - for (const item of items) { - item.dataset.orientation = this.orientation; - } + addInternalsController(this, { + initialARIA: { role: 'list' }, + }); } protected override render() { 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/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 index 94005ad27..8bb70e43f 100644 --- a/stories/timeline.stories.ts +++ b/stories/timeline.stories.ts @@ -22,19 +22,38 @@ const metadata: Meta = { }, argTypes: { orientation: { - type: '"vertical" | "horizontal"', - options: ['vertical', 'horizontal'], + 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' }, + args: { orientation: 'vertical', position: 'alternate' }, }; export default metadata; interface IgcTimelineArgs { - orientation: 'vertical' | 'horizontal'; + /** 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; @@ -45,7 +64,7 @@ const indicatorStyle = (bg: string, border: string) => export const Basic: Story = { render: (args) => html` - + Item 1 Item 2 Item 3 @@ -56,6 +75,53 @@ export const Basic: Story = { `, }; +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 โ˜• @@ -180,8 +246,9 @@ export const ProjectMilestones: Story = { font-style: italic; } - + โœ“ @@ -229,7 +296,7 @@ export const ProjectMilestones: Story = { }; export const Changelog: Story = { - args: { orientation: 'vertical' }, + args: { orientation: 'vertical', position: 'end' }, render: (args) => html` - + ๐Ÿš€ @@ -301,7 +367,6 @@ export const Changelog: Story = { โœจ @@ -325,7 +390,6 @@ export const Changelog: Story = { ๐Ÿ”ง @@ -351,7 +415,6 @@ export const Changelog: Story = { โšก