Skip to content

Commit e96df32

Browse files
Merge pull request #32 from goauthentik/element/ak-timestamp
element/ak timestamp
2 parents a83ee6f + 176ffbb commit e96df32

13 files changed

+1715
-0
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ElementRest } from "../types.js";
2+
import { Timestamp } from "./ak-timestamp.component.js";
3+
4+
import { spread } from "@open-wc/lit-helpers";
5+
6+
import { html } from "lit";
7+
import { ifDefined } from "lit/directives/if-defined.js";
8+
9+
export type TimestampProps = ElementRest & Partial<Timestamp>;
10+
11+
/**
12+
* @summary Helper function to create a Timestamp component programmatically
13+
*
14+
* @returns {TemplateResult} A Lit template result containing the configured ak-timestamp element
15+
*
16+
* @see {@link Timestamp} - The underlying web component
17+
*/
18+
export function akTimestamp(options: TimestampProps = {}) {
19+
const {
20+
date,
21+
dateFormat,
22+
displaySuffix,
23+
is12Hour,
24+
locale,
25+
raw,
26+
displayUTC,
27+
timeFormat,
28+
showElapsed,
29+
...rest
30+
} = options;
31+
32+
return html`
33+
<ak-timestamp
34+
${spread(rest)}
35+
date=${ifDefined(date)}
36+
date-format=${ifDefined(dateFormat)}
37+
display-suffix=${ifDefined(displaySuffix)}
38+
?is-12-hour=${!!is12Hour}
39+
locale=${ifDefined(locale)}
40+
.raw=${ifDefined(raw)}
41+
?display-utc=${!!displayUTC}
42+
?show-elapsed=${!!showElapsed}
43+
time-format=${ifDefined(timeFormat)}
44+
></ak-timestamp>
45+
`;
46+
}
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { intersectionObserver } from "../observers/intersection-observer.js";
2+
import { mediaQuery } from "../observers/mediaquery-observer.js";
3+
import { formatElapsedTime } from "../utils/temporal.js";
4+
import styles from "./ak-timestamp.css";
5+
6+
import { observed } from "@patternfly/pfe-core/decorators/observed.js";
7+
import { parseISO } from "date-fns";
8+
import { match } from "ts-pattern";
9+
10+
import { msg } from "@lit/localize";
11+
import { html, LitElement, nothing, PropertyValues } from "lit";
12+
import { property } from "lit/decorators.js";
13+
14+
/**
15+
* Enum for timestamp format options
16+
*/
17+
18+
export const timestampFormats = ["full", "long", "medium", "short"] as const;
19+
export type TimestampFormat = (typeof timestampFormats)[number];
20+
21+
export const isValidDate = (date: Date | null): boolean => Boolean(date && !isNaN(date.getTime()));
22+
23+
const convertToUTCString = (date: Date) => date.toUTCString().slice(0, -3);
24+
25+
const checkAndValidate = (d: Date | null) => (isValidDate(d) ? d : null);
26+
27+
// For times less than a minute, check every quarter second, to avoid the "6 seconds"... "8 seconds"
28+
// phenom. Only update when it *actually* changes.
29+
const SHORT_INTERVAL = 250;
30+
31+
// For times more than a minute, just check every 30 seconds.
32+
const LONG_INTERVAL = 30 * 1000;
33+
34+
const ONE_MINUTE = 60 * 1000;
35+
36+
const mapHas = (
37+
changed: Map<PropertyKey, unknown>,
38+
keys: PropertyKey | PropertyKey[],
39+
...rest: PropertyKey[]
40+
) => (Array.isArray(keys) ? [...keys, ...rest] : [keys, ...rest]).some((key) => changed.has(key));
41+
42+
/**
43+
* @element ak-timestamp
44+
* @class Timestamp
45+
*
46+
* A component that displays a formatted date and time with extensive customization options. The
47+
* timestamp supports various date formats, time formats, localization, and UTC display.
48+
*
49+
* If the provided date is invalid or cannot be parsed, the component will display a warning
50+
* message instead of the timestamp.
51+
*
52+
* ## Features
53+
*
54+
* - Can show dates using standard locale formats (full, long, medium, short)
55+
* - Display either local time or UTC time
56+
* - Support for custom time zone suffixes
57+
* - 12/24 hour time format options
58+
* - Localization support via browser or specified locales
59+
* - Error handling for invalid date inputs
60+
* - Accessible time display with proper semantic HTML
61+
*
62+
* ## CSS Custom Properties
63+
*
64+
* @cssprop {font-size} --pf-v5-c-timestamp--FontSize - Font size for timestamp text (default: 0.875rem)
65+
* @cssprop {color} --pf-v5-c-timestamp--Color - Text color of the timestamp (default: #6a6e73)
66+
* @cssprop {color} --pf-v5-c-timestamp--Warning--Color - Text color for warning messages (default: #795600)
67+
* @cssprop {font-weight} --pf-v5-c-timestamp--Warning--FontWeight - Font weight for warning messages (default: 700)
68+
* @cssprop {length} --pf-v5-c-timestamp--OutlineOffset - Outline offset for focus states (default: 0.1875rem)
69+
*
70+
* ## Styling Hooks
71+
*
72+
* @csspart timestamp - The time element that displays the formatted date.
73+
* @csspart elapsed - If using the elapsed feature, the display of the "time since..." string.
74+
* @csspart warning - A message displayed if the time passed in is unparsable.
75+
*
76+
* ## ARIA Note
77+
*
78+
* @internal
79+
* This component honors `prefers-reduced-motion` when `show-elapsed=true` by only updating the
80+
* component every minute, not every second.
81+
*/
82+
export class Timestamp extends LitElement {
83+
public static readonly styles = [styles];
84+
85+
/**
86+
* The date as a string. May also be a timestamp number from the epoch, in milliseconds.
87+
*
88+
* @attr
89+
*/
90+
@observed
91+
@property({ type: String })
92+
public date: string = `${new Date().toISOString()}`;
93+
94+
/**
95+
* A raw Date object. Takes precendence over the string variant.
96+
*
97+
* @prop
98+
*/
99+
@observed
100+
@property({ type: Object })
101+
public raw!: Date;
102+
103+
/**
104+
* The date format, as specified is Intl.DateTimeFormatOptions.
105+
*
106+
* @attr
107+
*/
108+
@property({ type: String, attribute: "date-format" })
109+
public dateFormat?: TimestampFormat = "long";
110+
111+
/**
112+
* The time format, as specified is Intl.DateTimeFormatOptions
113+
*
114+
* @attr
115+
*/
116+
@property({ type: String, attribute: "time-format" })
117+
public timeFormat?: TimestampFormat = "long";
118+
119+
/**
120+
* Mostly to show custom timezone names.
121+
*
122+
* @attr
123+
*/
124+
@property({ type: String, attribute: "display-suffix" })
125+
public displaySuffix = "";
126+
127+
/**
128+
* Force 12-hour display. Will use the locale format if undefined.
129+
*
130+
* @attr
131+
*/
132+
@property({ type: Boolean, attribute: "is-12-hour" })
133+
public is12Hour?: boolean;
134+
135+
/**
136+
* Defaults to browser's locale if not specified
137+
*
138+
* @attr
139+
*/
140+
@property({ type: String })
141+
public locale?: string;
142+
143+
/**
144+
* @attr
145+
*/
146+
@property({ type: Boolean, attribute: "display-utc" })
147+
public displayUTC = false;
148+
149+
/**
150+
* @attr
151+
*/
152+
@property({ type: Boolean, attribute: "show-elapsed" })
153+
public showElapsed = false;
154+
155+
@intersectionObserver()
156+
public visible = false;
157+
158+
@mediaQuery("(prefers-reduced-motion: reduce)")
159+
public prefersReducedMotion = false;
160+
161+
#date: Date | null = null;
162+
163+
#timeoutID: ReturnType<typeof setTimeout> | null = null;
164+
165+
#interval = -1;
166+
167+
// Triggered by the `@observed` decorator.
168+
protected _rawChanged() {
169+
const checkedDate = checkAndValidate(this.raw);
170+
if (checkedDate) {
171+
this.date = checkedDate.toISOString();
172+
this._dateChanged();
173+
}
174+
}
175+
176+
// Triggered by the `@observed` decorator.
177+
protected _dateChanged() {
178+
this.#date = match(this.date)
179+
.when(
180+
(d: string) => /^\d+$/.test(d),
181+
(d: string) => checkAndValidate(new Date(parseInt(d, 10))),
182+
)
183+
.when(
184+
(d: string) => typeof d === "string",
185+
(d: string) => checkAndValidate(parseISO(d)),
186+
)
187+
.otherwise(() => {
188+
console.warn(`Unable to validate date ${this.date}`);
189+
return null;
190+
});
191+
}
192+
193+
public override disconnectedCallback() {
194+
super.disconnectedCallback();
195+
this.stopElapsedCounter();
196+
}
197+
198+
public get isVisible() {
199+
return Boolean(this.#date || document.visibilityState === "visible" || this.visible);
200+
}
201+
202+
public get runElapsed() {
203+
return this.#date && this.isVisible && this.showElapsed;
204+
}
205+
206+
public stopElapsedCounter = () => {
207+
if (this.#timeoutID !== null) {
208+
clearTimeout(this.#timeoutID);
209+
this.#timeoutID = null;
210+
}
211+
this.#interval = -1;
212+
};
213+
214+
#tick = () => {
215+
if (!this.runElapsed) {
216+
return;
217+
}
218+
this.requestUpdate();
219+
this.startElapsedCounter();
220+
};
221+
222+
public startElapsedCounter = () => {
223+
this.stopElapsedCounter();
224+
if (!(this.runElapsed && this.#date)) {
225+
return;
226+
}
227+
228+
const timeSince = Date.now() - this.#date.getTime();
229+
this.#interval =
230+
timeSince < ONE_MINUTE && !this.prefersReducedMotion ? SHORT_INTERVAL : LONG_INTERVAL;
231+
this.#timeoutID = setTimeout(this.#tick, this.#interval);
232+
};
233+
234+
private get formattingOptions(): Intl.DateTimeFormatOptions {
235+
const { dateFormat, is12Hour } = this;
236+
return {
237+
...(dateFormat && { dateStyle: dateFormat }),
238+
...(is12Hour !== undefined && { hour12: is12Hour }),
239+
};
240+
}
241+
242+
protected localeDate(date: Date) {
243+
const { locale, formattingOptions, timeFormat, displaySuffix } = this;
244+
const dateAsString = date.toLocaleString(locale, {
245+
...formattingOptions,
246+
...(timeFormat && { timeStyle: this.timeFormat }),
247+
});
248+
return `${dateAsString}${displaySuffix ? " " + displaySuffix : ""}`;
249+
}
250+
251+
protected utcDate(date: Date) {
252+
const { formattingOptions, timeFormat, displaySuffix, locale } = this;
253+
const utcTimeFormat = this.timeFormat !== "short" ? "medium" : "short";
254+
const dateAsString = new Date(convertToUTCString(date)).toLocaleString(locale, {
255+
...formattingOptions,
256+
...(timeFormat && { timeStyle: utcTimeFormat }),
257+
});
258+
const defaultUTCSuffix = timeFormat === "full" ? msg("Coordinated Universal Time") : "UTC";
259+
return `${dateAsString} ${displaySuffix ? displaySuffix : defaultUTCSuffix}`;
260+
}
261+
262+
private formattedDate(date: Date) {
263+
return this.displayUTC ? this.utcDate(date) : this.localeDate(date);
264+
}
265+
266+
public updated(changed: PropertyValues<this>): void {
267+
super.updated(changed);
268+
if (mapHas(changed, "date", "raw", "visible", "showElapsed")) {
269+
this.startElapsedCounter();
270+
}
271+
}
272+
273+
renderElapsedTime(date: Date) {
274+
if (!this.showElapsed) {
275+
return nothing;
276+
}
277+
const elapsed = formatElapsedTime(date);
278+
return html`<span part="elapsed">(${elapsed})</span>`;
279+
}
280+
281+
renderDate(date: Date) {
282+
return html` <time part="timestamp" datetime="${date.toISOString()}"
283+
>${this.formattedDate(date)}${this.renderElapsedTime(date)}</time
284+
>`;
285+
}
286+
287+
render() {
288+
return this.#date !== null
289+
? this.renderDate(this.#date)
290+
: html`<span part="warning">${msg("Failed to parse time")}</span>`;
291+
}
292+
}

0 commit comments

Comments
 (0)