Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
aec94e8
feat(lukso-timeline): scaffold component with properties
federico-freddi Apr 16, 2026
5bb099e
feat(lukso-timeline): add state, progress, and formatting helpers
federico-freddi Apr 16, 2026
f1a235d
fix(lukso-timeline): remove space between time and am/pm in _formatTime
federico-freddi Apr 16, 2026
368143e
fix(lukso-timeline): guard _state and _progressPercent against invali…
federico-freddi Apr 16, 2026
cc0c8b8
feat(lukso-timeline): add _barTemplate and _dateLabelTemplate sub-tem…
federico-freddi Apr 16, 2026
4bea0cc
fix(lukso-timeline): use flex-grow on right bar segment to avoid sub-…
federico-freddi Apr 16, 2026
07a59be
feat(lukso-timeline): implement endDateTemplate and foreverTemplate
federico-freddi Apr 16, 2026
e966317
fix(lukso-timeline): trim endDate before template dispatch to handle …
federico-freddi Apr 16, 2026
af2f821
feat(lukso-timeline): add Storybook stories for all 5 states
federico-freddi Apr 16, 2026
fedbe53
refactor(lukso-timeline): convert stories to Template.bind pattern fo…
federico-freddi Apr 16, 2026
093b77d
chore: regenerate component index after adding lukso-timeline
federico-freddi Apr 16, 2026
da211b1
fix(lukso-timeline): guard templates against invalid Date from malfor…
federico-freddi Apr 16, 2026
820d176
fix: package.json
federico-freddi Apr 16, 2026
4739bf6
feat: luksoi-timeline
federico-freddi Apr 16, 2026
d6f603f
Merge branch 'main' into feat-lukso-timeline
federico-freddi Apr 16, 2026
a366d02
fix: date type
federico-freddi Apr 16, 2026
0cb2330
fix: endate
federico-freddi Apr 16, 2026
9044ef7
chore: package json
federico-freddi Apr 20, 2026
694aa8d
feat: use lukso/core for intl
federico-freddi Apr 20, 2026
f194b8a
fix: copilot review
federico-freddi Apr 20, 2026
13ded89
chore: update lukso-core
federico-freddi Apr 20, 2026
d38109a
fix: reviews
federico-freddi Apr 20, 2026
2ceeba7
fix: style
federico-freddi Apr 20, 2026
2713de6
fix: style and stories
federico-freddi Apr 20, 2026
0776546
fix: review
federico-freddi Apr 20, 2026
163deb3
Merge branch 'main' into feat-lukso-timeline
federico-freddi Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion package/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lukso/web-components",
"version": "1.188.0",
"version": "1.189.0",
"type": "module",
"files": [
"dist",
Expand Down Expand Up @@ -449,6 +449,16 @@
"import": "./dist/components/lukso-time-picker/index.js",
"types": "./dist/components/lukso-time-picker/index.d.ts"
},
"./dist/components/lukso-timeline": {
"require": "./dist/components/lukso-timeline/index.cjs",
"import": "./dist/components/lukso-timeline/index.js",
"types": "./dist/components/lukso-timeline/index.d.ts"
},
"./components/lukso-timeline": {
"require": "./dist/components/lukso-timeline/index.cjs",
"import": "./dist/components/lukso-timeline/index.js",
"types": "./dist/components/lukso-timeline/index.d.ts"
},
"./dist/components/lukso-tooltip": {
"require": "./dist/components/lukso-tooltip/index.cjs",
"import": "./dist/components/lukso-tooltip/index.js",
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export * from './lukso-tag/index'
export * from './lukso-terms/index'
export * from './lukso-textarea/index'
export * from './lukso-time-picker/index'
export * from './lukso-timeline/index'
export * from './lukso-tooltip/index'
export * from './lukso-username/index'
export * from './lukso-wizard/index'
265 changes: 265 additions & 0 deletions src/components/lukso-timeline/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// src/components/lukso-timeline/index.ts
import { html } from 'lit'
import { property } from 'lit/decorators.js'
import { styleMap } from 'lit-html/directives/style-map.js'
Comment thread
federico-freddi marked this conversation as resolved.
Outdated

import { safeCustomElement } from '@/shared/safe-custom-element'
import '@/components/lukso-icon'
import { TailwindStyledElement } from '@/shared/tailwind-element'
import style from './style.css?inline'

type TimelineState = 'before-start' | 'in-range' | 'after-end'

const GREY_STYLE = { backgroundColor: '#cddae4' }
const GREEN_STYLE = { backgroundColor: '#47cd68' }
const STRIPED_STYLE = {
backgroundImage:
'repeating-linear-gradient(145deg, gray 0px, gray 5px, #ccc 5px, #ccc 10px, gray 10px)',
}
const FOREVER_GREEN_PCT = 35

/**
* Displays the temporal state of an event as a horizontal progress bar with date labels.
*/
@safeCustomElement('lukso-timeline')
export class LuksoTimeline extends TailwindStyledElement(style) {
@property({ type: Date, attribute: 'start-date' })
startDate = ''

@property({ type: Date, attribute: 'end-date' })
endDate = ''
Comment thread
federico-freddi marked this conversation as resolved.
Outdated

@property({ type: String })
locale = 'en-US'

// ── Computed state ──────────────────────────────────────────────────────

private get _state(): TimelineState {
if (!this.startDate) return 'before-start'
const now = Date.now()
const start = new Date(this.startDate).getTime()
if (isNaN(start) || now < start) return 'before-start'
if (!this.endDate) return 'in-range'
const end = new Date(this.endDate).getTime()
if (!isNaN(end) && now > end) return 'after-end'
return 'in-range'
}

private get _progressPercent(): number {
if (!this.startDate || !this.endDate) return 0
const now = Date.now()
const start = new Date(this.startDate).getTime()
const end = new Date(this.endDate).getTime()
if (isNaN(start) || isNaN(end) || end <= start) return 0
if (now <= start) return 0
if (now >= end) return 100
return ((now - start) / (end - start)) * 100
}

// ── Formatting helpers ──────────────────────────────────────────────────

private _ordinal(n: number): string {
if (n >= 11 && n <= 13) return 'th'
switch (n % 10) {
case 1:
return 'st'
case 2:
return 'nd'
case 3:
return 'rd'
default:
return 'th'
}
}

private _formatDate(date: Date): string {
const day = date.getDate()
const month = new Intl.DateTimeFormat(this.locale, {
month: 'short',
}).format(date)
return `${day}${this._ordinal(day)} ${month} ${date.getFullYear()}`
}

private _formatTime(date: Date): string {
return new Intl.DateTimeFormat(this.locale, {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
.format(date)
.toLowerCase()
.replace(/[\s\u202f]/g, '')
Comment thread
federico-freddi marked this conversation as resolved.
Outdated
}

private _relativeTime(date: Date): string {
const diffMs = date.getTime() - Date.now()
const abs = Math.abs(diffMs)
const fmt = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' })
if (abs < 60_000) return fmt.format(Math.round(diffMs / 1_000), 'second')
if (abs < 3_600_000)
return fmt.format(Math.round(diffMs / 60_000), 'minute')
if (abs < 86_400_000)
return fmt.format(Math.round(diffMs / 3_600_000), 'hour')
return fmt.format(Math.round(diffMs / 86_400_000), 'day')
}

// ── Sub-templates ───────────────────────────────────────────────────────

/**
* Renders the horizontal bar with endpoint dots and two colour segments.
*
* @param leftPercent - width of the left (coloured) segment, 0–100
* @param leftStyle - CSSStyleDeclaration-style object for the left segment
* @param rightStyle - CSSStyleDeclaration-style object for the right segment
*/
private _barTemplate(
leftPercent: number,
leftStyle: Record<string, string>,
rightStyle: Record<string, string>,
isForever: boolean = false
) {
const rightInset = isForever ? 'right-1/20' : ''
return html`
<div class="relative flex items-center w-full" style="height: 2.5rem">
<!-- Track line full-width -->
<div
class="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-neutral-85"
></div>

<!-- Left endpoint dot -->
<div
class="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-neutral-85 z-10"
></div>

<!-- Progress bar, inset 10% -->
<div
class="absolute inset-x-[10%] ${rightInset} top-1/3 flex h-2 overflow-hidden z-10"
>
Comment thread
federico-freddi marked this conversation as resolved.
Outdated
<div
style=${styleMap({ width: `${leftPercent}%`, ...leftStyle })}
></div>
<div style=${styleMap({ flexGrow: '1', ...rightStyle })}></div>
</div>

<!-- Vertical tick left (start date) -->
<div
class="absolute left-[10%] top-1/2 w-px h-10 bg-neutral-85 z-10"
></div>

<!-- Vertical tick right(end date) -->
${!isForever
? html`<div
class="absolute right-[10%] top-1/2 w-px h-10 bg-neutral-85 z-10"
></div>`
: ''}

<!-- Arrow right (replaces the right dot) -->
<div
class="absolute -right-2 top-1/2 -translate-y-1/2 z-10 flex items-center"
>
<lukso-icon name="arrow-right-sm" size="small"></lukso-icon>
</div>
</div>
`
}

/**
* Renders a date's label block: formatted date, time, and relative time.
*
* @param date - the Date to format
* @param align - 'start' (left-aligned) or 'end' (right-aligned)
*/
private _dateLabelTemplate(date: Date, align: 'start' | 'end') {
const cls = align === 'end' ? 'items-end text-right' : 'items-start'
return html`
<div class="flex flex-col ${cls} left-[12%] right-[12%] absolute ">
<span class="text-sm font-semibold text-neutral-50"
>${this._formatDate(date)}</span
>
<span class="text-sm text-neutral-50">${this._formatTime(date)}</span>
<span class="text-xs text-neutral-70">${this._relativeTime(date)}</span>
</div>
`
}

// ── Main templates ──────────────────────────────────────────────────────

private endDateTemplate() {
if (!this.startDate || !this.endDate) return html``
const start = new Date(this.startDate)
const end = new Date(this.endDate)
if (isNaN(start.getTime()) || isNaN(end.getTime())) return html``
const pct = this._progressPercent

const bar =
this._state === 'before-start'
? this._barTemplate(0, GREY_STYLE, GREY_STYLE)
: this._state === 'in-range'
? this._barTemplate(pct, GREEN_STYLE, GREY_STYLE)
: this._barTemplate(100, GREEN_STYLE, GREEN_STYLE)

return html`
<div class="flex flex-col w-full">
${bar}
<div class="flex items-start w-full">
${this._dateLabelTemplate(start, 'start')}
<div class="flex-1 flex justify-center pt-1">
<lukso-icon name="arrow-right-sm" size="small"></lukso-icon>
</div>
${this._dateLabelTemplate(end, 'end')}
</div>
</div>
`
}

private foreverTemplate() {
if (!this.startDate) return html``
const start = new Date(this.startDate)
if (isNaN(start.getTime())) return html``

const bar =
this._state === 'before-start'
? this._barTemplate(
0,
STRIPED_STYLE,
STRIPED_STYLE,
this.endDate === ''
)
: this._barTemplate(
FOREVER_GREEN_PCT,
GREEN_STYLE,
STRIPED_STYLE,
this.endDate === ''
)

return html`
<div class="flex flex-col w-full">
${bar}
<div class="flex items-start w-full">
${this._dateLabelTemplate(start, 'start')}
<div class="flex-1 flex justify-center pt-1">
<lukso-icon name="arrow-right-sm" size="small"></lukso-icon>
</div>
<div class="flex flex-col items-end text-right">
<span class="text-sm font-semibold text-neutral-20">Forever</span>
<span class="text-sm text-neutral-20">-</span>
</div>
</div>
</div>
`
}

render() {
return html`
<div class="flex w-full">
${this.endDate ? this.endDateTemplate() : this.foreverTemplate()}
Comment thread
federico-freddi marked this conversation as resolved.
Outdated
</div>
`
}
}

declare global {
interface HTMLElementTagNameMap {
'lukso-timeline': LuksoTimeline
}
}
Loading
Loading