} wire values. */
+final class AttributeValues {
+
+ private AttributeValues() {}
+
+ /**
+ * Returns the enum constant's name in lower case (e.g. {@code FUTURE} → {@code "future"}),
+ * the default wire form for enums whose attribute value matches the constant name.
+ */
+ static String ofName(Enum> value) {
+ return value.name().toLowerCase(Locale.ROOT);
+ }
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/relativetime/DateTimePartStyle.java b/src/main/java/com/flowingcode/vaadin/addons/relativetime/DateTimePartStyle.java
new file mode 100644
index 0000000..4796fe5
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/relativetime/DateTimePartStyle.java
@@ -0,0 +1,51 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.flowingcode.vaadin.addons.relativetime;
+
+/**
+ * Values for the {@code Intl.DateTimeFormat} date-part attributes ({@code year}, {@code month},
+ * {@code day}, {@code hour}, {@code minute}, {@code second}, {@code weekday}). Not every part
+ * accepts every value; each setter on {@link RelativeTime} documents its valid subset.
+ *
+ * Only relevant when {@link Format#DATETIME} is in effect.
+ */
+public enum DateTimePartStyle {
+ /** Numeric ("1", "2", "12"). Accepted by year, month, day, hour, minute, second. */
+ NUMERIC("numeric"),
+ /** Two-digit, zero-padded ("01", "12"). Accepted by year, month, day, hour, minute, second. */
+ TWO_DIGIT("2-digit"),
+ /** Narrow ("M", "T"). Accepted by month and weekday. */
+ NARROW("narrow"),
+ /** Short ("Mon", "Tue"). Accepted by month and weekday. */
+ SHORT("short"),
+ /** Long ("Monday", "Tuesday"). Accepted by month and weekday. */
+ LONG("long");
+
+ private final String attributeValue;
+
+ DateTimePartStyle(String attributeValue) {
+ this.attributeValue = attributeValue;
+ }
+
+ String attributeValue() {
+ return attributeValue;
+ }
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/relativetime/Format.java b/src/main/java/com/flowingcode/vaadin/addons/relativetime/Format.java
new file mode 100644
index 0000000..7971ea4
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/relativetime/Format.java
@@ -0,0 +1,66 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.flowingcode.vaadin.addons.relativetime;
+
+/** Values for the {@code format} attribute on {@code }. */
+public enum Format {
+ /**
+ * Relative phrase ("3 days ago"). Uses {@code Intl.RelativeTimeFormat} under the hood and
+ * collapses past times under ~55 seconds to "now" regardless of the {@link Precision}
+ * setting. This is a signed-int threshold in the upstream element, not something the wrapper
+ * can override. If you need continuous second-by-second ticking from zero, use {@link #DURATION}
+ * or {@link #MICRO}.
+ */
+ RELATIVE,
+ /**
+ * Duration breakdown ("3d 4h", "1m 5s"). Ticks every second from zero with no "now" threshold,
+ * making it the right choice for live elapsed-time displays. Re-renders on every second
+ * boundary while the displayed value contains seconds.
+ */
+ DURATION,
+ /**
+ * Absolute formatted date-time. Does not tick: once rendered, the displayed string
+ * doesn't change unless the inputs do. Use {@link RelativeTime#setTimeZone setTimeZone},
+ * {@link RelativeTime#setTimeZoneName setTimeZoneName}, and the per-part setters
+ * ({@link RelativeTime#setYear setYear}, etc.) to shape the output.
+ */
+ DATETIME,
+ /**
+ * Alias for {@link #RELATIVE} (the upstream default). Same behaviour, same "now" plateau for
+ * past times under ~55 seconds.
+ */
+ AUTO,
+ /**
+ * Elapsed-time style. Routes through the same duration-format path as {@link #DURATION}; ticks
+ * second-by-second with no "now" threshold.
+ */
+ ELAPSED,
+ /**
+ * Compact single-unit form ("3d", "5h", "2m"). Re-renders only when the displayed unit changes,
+ * so it ticks less frequently than {@link #DURATION}. Good when space is tight (badges, table
+ * cells).
+ */
+ MICRO;
+
+ String attributeValue() {
+ return AttributeValues.ofName(this);
+ }
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/template/TemplateAddon.java b/src/main/java/com/flowingcode/vaadin/addons/relativetime/FormatStyle.java
similarity index 54%
rename from src/main/java/com/flowingcode/vaadin/addons/template/TemplateAddon.java
rename to src/main/java/com/flowingcode/vaadin/addons/relativetime/FormatStyle.java
index a318158..771cedc 100644
--- a/src/main/java/com/flowingcode/vaadin/addons/template/TemplateAddon.java
+++ b/src/main/java/com/flowingcode/vaadin/addons/relativetime/FormatStyle.java
@@ -1,8 +1,8 @@
/*-
* #%L
- * Template Add-on
+ * Relative Time Add-On
* %%
- * Copyright (C) 2025 Flowing Code
+ * Copyright (C) 2026 Flowing Code
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -18,15 +18,18 @@
* #L%
*/
-package com.flowingcode.vaadin.addons.template;
+package com.flowingcode.vaadin.addons.relativetime;
-import com.vaadin.flow.component.Tag;
-import com.vaadin.flow.component.dependency.JsModule;
-import com.vaadin.flow.component.dependency.NpmPackage;
-import com.vaadin.flow.component.html.Div;
+/** Values for the {@code format-style} attribute on {@code }. */
+public enum FormatStyle {
+ /** Long phrasing ("3 days ago"). */
+ LONG,
+ /** Short phrasing ("3 days ago" with abbreviations where the locale supports it). */
+ SHORT,
+ /** Narrow phrasing ("3d ago"). */
+ NARROW;
-@SuppressWarnings("serial")
-@NpmPackage(value = "@polymer/paper-input", version = "3.2.1")
-@JsModule("@polymer/paper-input/paper-input.js")
-@Tag("paper-input")
-public class TemplateAddon extends Div {}
+ String attributeValue() {
+ return AttributeValues.ofName(this);
+ }
+}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemo.java b/src/main/java/com/flowingcode/vaadin/addons/relativetime/Precision.java
similarity index 53%
rename from src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemo.java
rename to src/main/java/com/flowingcode/vaadin/addons/relativetime/Precision.java
index 294ae97..97d3c6c 100644
--- a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemo.java
+++ b/src/main/java/com/flowingcode/vaadin/addons/relativetime/Precision.java
@@ -1,15 +1,15 @@
/*-
* #%L
- * Template Add-on
+ * Relative Time Add-On
* %%
- * Copyright (C) 2025 Flowing Code
+ * Copyright (C) 2026 Flowing Code
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
- *
+ *
* http://www.apache.org/licenses/LICENSE-2.0
- *
+ *
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -17,20 +17,22 @@
* limitations under the License.
* #L%
*/
-package com.flowingcode.vaadin.addons.template;
-import com.flowingcode.vaadin.addons.demo.DemoSource;
-import com.vaadin.flow.component.html.Div;
-import com.vaadin.flow.router.PageTitle;
-import com.vaadin.flow.router.Route;
+package com.flowingcode.vaadin.addons.relativetime;
-@DemoSource
-@PageTitle("Template Add-on Demo")
-@SuppressWarnings("serial")
-@Route(value = "demo", layout = TemplateDemoView.class)
-public class TemplateDemo extends Div {
+/**
+ * Values for the {@code precision} attribute on {@code }. The element rounds the
+ * displayed phrase to the chosen unit.
+ */
+public enum Precision {
+ YEAR,
+ MONTH,
+ DAY,
+ HOUR,
+ MINUTE,
+ SECOND;
- public TemplateDemo() {
- add(new TemplateAddon());
+ String attributeValue() {
+ return AttributeValues.ofName(this);
}
}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/relativetime/RelativeTime.java b/src/main/java/com/flowingcode/vaadin/addons/relativetime/RelativeTime.java
new file mode 100644
index 0000000..c990a3f
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/relativetime/RelativeTime.java
@@ -0,0 +1,406 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.flowingcode.vaadin.addons.relativetime;
+
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.Tag;
+import com.vaadin.flow.component.dependency.JsModule;
+import com.vaadin.flow.component.dependency.NpmPackage;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Renders a date/time as a human-readable relative string ("4 hours ago", "in 2 weeks") that
+ * updates in the browser as time passes and is shown in the viewer's local time.
+ *
+ * Thin wrapper around the {@code @github/relative-time-element}
+ * custom element. The Java setters write the corresponding attributes on the element and the
+ * browser does all the formatting and updating work.
+ *
+ *
Client-side rendering. The rendered text reflects the viewer's clock and locale, not
+ * the server's. There is no server-side API to read the displayed string; the string lives only
+ * in the DOM.
+ *
+ *
Attributes, not properties. Setters write HTML attributes
+ * ({@code setAttribute}), not DOM properties, because the upstream element is
+ * attribute-driven and its kebab-case attribute names match the upstream docs
+ * one-to-one. {@code setProperty} would force diverging camelCase names.
+ *
+ *
{@code LocalDateTime} and {@code LocalDate} inputs are interpreted in the server's
+ * {@link ZoneId#systemDefault() default zone} before being serialised as an instant.
+ */
+@Tag("relative-time")
+@NpmPackage(value = "@github/relative-time-element", version = "5.0.0")
+@JsModule("@github/relative-time-element")
+@SuppressWarnings("serial")
+public class RelativeTime extends Component {
+
+ private static final String ATTR_DATETIME = "datetime";
+ private static final String ATTR_TENSE = "tense";
+ private static final String ATTR_FORMAT = "format";
+ private static final String ATTR_PRECISION = "precision";
+ private static final String ATTR_FORMAT_STYLE = "format-style";
+ private static final String ATTR_THRESHOLD = "threshold";
+ private static final String ATTR_PREFIX = "prefix";
+ private static final String ATTR_NO_TITLE = "no-title";
+ private static final String ATTR_LANG = "lang";
+ private static final String ATTR_TIME_ZONE = "time-zone";
+ private static final String ATTR_TIME_ZONE_NAME = "time-zone-name";
+ private static final String ATTR_YEAR = "year";
+ private static final String ATTR_MONTH = "month";
+ private static final String ATTR_DAY = "day";
+ private static final String ATTR_WEEKDAY = "weekday";
+ private static final String ATTR_HOUR = "hour";
+ private static final String ATTR_MINUTE = "minute";
+ private static final String ATTR_SECOND = "second";
+
+ /** Styles accepted by the numeric date-part attributes (year, day, hour, minute, second). */
+ private static final Set NUMERIC_STYLES = Collections.unmodifiableSet(
+ EnumSet.of(DateTimePartStyle.NUMERIC, DateTimePartStyle.TWO_DIGIT));
+ /** Styles accepted by the textual date-part attribute (weekday). */
+ private static final Set TEXT_STYLES = Collections.unmodifiableSet(
+ EnumSet.of(DateTimePartStyle.NARROW, DateTimePartStyle.SHORT, DateTimePartStyle.LONG));
+ /** Styles accepted by the month attribute (numeric and textual). */
+ private static final Set ALL_STYLES = Collections.unmodifiableSet(
+ EnumSet.allOf(DateTimePartStyle.class));
+
+ private Instant lastDateTime;
+
+ /** Creates an empty component. {@link #setDateTime} can be called later. */
+ public RelativeTime() {}
+
+ /** Creates a component bound to the given instant. */
+ public RelativeTime(Instant datetime) {
+ setDateTime(datetime);
+ }
+
+ /** Creates a component bound to the given offset date-time. */
+ public RelativeTime(OffsetDateTime datetime) {
+ setDateTime(datetime);
+ }
+
+ /** Creates a component bound to the given zoned date-time. */
+ public RelativeTime(ZonedDateTime datetime) {
+ setDateTime(datetime);
+ }
+
+ /**
+ * Creates a component bound to the given local date-time, interpreted in the server's
+ * {@link ZoneId#systemDefault() default zone}.
+ */
+ public RelativeTime(LocalDateTime datetime) {
+ setDateTime(datetime);
+ }
+
+ /**
+ * Creates a component bound to midnight of the given date, in the server's
+ * {@link ZoneId#systemDefault() default zone}.
+ */
+ public RelativeTime(LocalDate date) {
+ setDateTime(date);
+ }
+
+ /**
+ * Sets the target instant. {@code null} clears the {@code datetime} attribute and the component
+ * renders as empty.
+ */
+ public RelativeTime setDateTime(Instant datetime) {
+ lastDateTime = datetime;
+ setOrRemove(ATTR_DATETIME, datetime == null ? null : datetime.toString());
+ return this;
+ }
+
+ /**
+ * Sets the target as an {@link OffsetDateTime}. The offset is applied via {@code toInstant()};
+ * the wire string is the resulting UTC instant (the offset itself is not retained).
+ */
+ public RelativeTime setDateTime(OffsetDateTime datetime) {
+ return setDateTime(datetime == null ? null : datetime.toInstant());
+ }
+
+ /**
+ * Sets the target as a {@link ZonedDateTime}. The zone is applied via {@code toInstant()}; the
+ * wire string is the resulting UTC instant (the zone itself is not retained).
+ */
+ public RelativeTime setDateTime(ZonedDateTime datetime) {
+ return setDateTime(datetime == null ? null : datetime.toInstant());
+ }
+
+ /**
+ * Sets the target as a {@link LocalDateTime}, interpreted in the server's
+ * {@link ZoneId#systemDefault() default zone}.
+ */
+ public RelativeTime setDateTime(LocalDateTime datetime) {
+ return setDateTime(
+ datetime == null ? null : datetime.atZone(ZoneId.systemDefault()).toInstant());
+ }
+
+ /**
+ * Sets the target to midnight of the given date, in the server's
+ * {@link ZoneId#systemDefault() default zone}.
+ */
+ public RelativeTime setDateTime(LocalDate date) {
+ return setDateTime(
+ date == null ? null : date.atStartOfDay(ZoneId.systemDefault()).toInstant());
+ }
+
+ /**
+ * Clears the target datetime: removes the {@code datetime} attribute and the component renders
+ * as empty. Equivalent to {@code setDateTime((Instant) null)} but without the cast that the
+ * overloaded {@code setDateTime(null)} would otherwise require.
+ */
+ public RelativeTime clear() {
+ return setDateTime((Instant) null);
+ }
+
+ /**
+ * Returns the last instant applied via any of the {@code setDateTime} overloads, normalised to
+ * UTC. Returns {@code null} if no datetime has been set or it was cleared.
+ */
+ public Instant getDateTime() {
+ return lastDateTime;
+ }
+
+ /**
+ * Sets the {@code tense} attribute (default {@code auto}). Ignored when {@link #setFormat
+ * format} is {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setTense(Tense tense) {
+ setOrRemove(ATTR_TENSE, tense == null ? null : tense.attributeValue());
+ return this;
+ }
+
+ /**
+ * Sets the {@code format} attribute (default {@code auto}, which is an alias for
+ * {@link Format#RELATIVE}). {@code null} removes the attribute.
+ */
+ public RelativeTime setFormat(Format format) {
+ setOrRemove(ATTR_FORMAT, format == null ? null : format.attributeValue());
+ return this;
+ }
+
+ /**
+ * Sets the {@code precision} attribute (default {@code second}). Ignored when {@link #setFormat
+ * format} is {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setPrecision(Precision precision) {
+ setOrRemove(ATTR_PRECISION, precision == null ? null : precision.attributeValue());
+ return this;
+ }
+
+ /**
+ * Sets the {@code format-style} attribute. The default depends on the {@link #setFormat format}
+ * in effect: {@code narrow} for {@link Format#ELAPSED}/{@link Format#MICRO}, {@code short} for
+ * {@link Format#DATETIME}, {@code long} for {@link Format#RELATIVE}/{@link Format#AUTO}/{@link
+ * Format#DURATION}. {@code null} removes the attribute.
+ */
+ public RelativeTime setFormatStyle(FormatStyle style) {
+ setOrRemove(ATTR_FORMAT_STYLE, style == null ? null : style.attributeValue());
+ return this;
+ }
+
+ /**
+ * Sets the {@code threshold} attribute as an ISO-8601 duration ({@code P30D}, {@code PT24H}).
+ * Default is {@code P30D}. Once the gap to the target exceeds this duration the element switches
+ * from a relative phrase to an absolute date. {@code null} removes the attribute.
+ *
+ * Only consulted when {@link #setFormat format} is {@link Format#AUTO}/{@link
+ * Format#RELATIVE} AND {@link #setTense tense} is {@link Tense#AUTO}. Pairing this with
+ * {@code setTense(PAST)} or {@code setTense(FUTURE)} silently makes the threshold inert,
+ * because the element then commits to relative phrasing regardless of distance.
+ *
+ * @throws IllegalArgumentException if {@code threshold} is negative. Java serialises a negative
+ * duration as {@code PT-nS}, which the upstream element's duration parser rejects, silently
+ * reverting to the default {@code P30D}. Zero is allowed (it means "always absolute").
+ */
+ public RelativeTime setThreshold(Duration threshold) {
+ if (threshold != null && threshold.isNegative()) {
+ throw new IllegalArgumentException("threshold must not be negative: " + threshold);
+ }
+ setOrRemove(ATTR_THRESHOLD, threshold == null ? null : threshold.toString());
+ return this;
+ }
+
+ /**
+ * Sets the {@code prefix} attribute (default {@code "on"}), the word prepended to absolute dates
+ * when the relative-format threshold is crossed. Pass an empty string to drop the prefix
+ * entirely; {@code null} removes the attribute and restores the default.
+ */
+ public RelativeTime setPrefix(String prefix) {
+ setOrRemove(ATTR_PREFIX, prefix);
+ return this;
+ }
+
+ /**
+ * When {@code true}, adds the {@code no-title} attribute, suppressing the automatic absolute-date
+ * tooltip. {@code false} removes it (the element's default behaviour shows the tooltip).
+ */
+ public RelativeTime setNoTitle(boolean noTitle) {
+ getElement().setAttribute(ATTR_NO_TITLE, noTitle);
+ return this;
+ }
+
+ /**
+ * Sets the {@code lang} attribute from a {@link Locale} (rendered as a BCP 47 language tag).
+ * The browser uses {@code Intl.RelativeTimeFormat} / {@code Intl.DateTimeFormat} to localise the
+ * phrasing. {@code null} removes the attribute; when unset, the upstream walks DOM ancestors
+ * via {@code closest('[lang]')} and falls back to the document's {@code lang}, so an app-wide
+ * locale on {@code } is picked up automatically.
+ *
+ *
Note: {@link Locale#ROOT} (and {@code new Locale("")}) serialises to {@code lang="und"},
+ * which the browser treats as "undetermined" and silently resolves to the document/default
+ * locale, so passing the root locale looks like it was ignored. Pass {@code null} to clear
+ * instead.
+ */
+ public RelativeTime setLocale(Locale locale) {
+ setOrRemove(ATTR_LANG, locale == null ? null : locale.toLanguageTag());
+ return this;
+ }
+
+ /**
+ * Sets the {@code time-zone} attribute from a {@link ZoneId} (e.g. {@code America/New_York}).
+ * When set, the element formats the date in this zone instead of the viewer's. {@code null}
+ * removes the attribute; when unset, the upstream walks DOM ancestors via {@code
+ * closest('[time-zone]')} and falls back to the document's {@code time-zone} attribute (and,
+ * absent that, the browser default).
+ *
+ * @throws IllegalArgumentException if {@code timeZone} is not an IANA region zone. Offset-based
+ * zones (a {@link java.time.ZoneOffset} such as {@code +02:00} or {@code Z}, or a
+ * {@code GMT+02:00} form) are written verbatim into the attribute and rejected by the
+ * browser's {@code Intl.DateTimeFormat} with a {@code RangeError}, which breaks rendering.
+ * The accepted set is {@link ZoneId#getAvailableZoneIds()} (which includes {@code UTC}).
+ */
+ public RelativeTime setTimeZone(ZoneId timeZone) {
+ if (timeZone != null && !ZoneId.getAvailableZoneIds().contains(timeZone.getId())) {
+ throw new IllegalArgumentException(
+ "time-zone must be an IANA zone ID (e.g. America/New_York); offset-based zones like "
+ + timeZone.getId() + " are not accepted by the browser's Intl.DateTimeFormat");
+ }
+ setOrRemove(ATTR_TIME_ZONE, timeZone == null ? null : timeZone.getId());
+ return this;
+ }
+
+ /**
+ * Sets the {@code time-zone-name} attribute, controlling whether and how the time-zone name is
+ * appended in absolute-date output. Only relevant when {@link #setFormat format} is
+ * {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setTimeZoneName(TimeZoneName value) {
+ setOrRemove(ATTR_TIME_ZONE_NAME, value == null ? null : value.attributeValue());
+ return this;
+ }
+
+ /**
+ * Sets the {@code year} attribute. Accepts {@link DateTimePartStyle#NUMERIC} or
+ * {@link DateTimePartStyle#TWO_DIGIT}. Only relevant when {@link #setFormat format} is
+ * {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setYear(DateTimePartStyle style) {
+ return setDatePart(ATTR_YEAR, style, NUMERIC_STYLES);
+ }
+
+ /**
+ * Sets the {@code month} attribute. Accepts {@link DateTimePartStyle#NUMERIC},
+ * {@link DateTimePartStyle#TWO_DIGIT}, {@link DateTimePartStyle#NARROW},
+ * {@link DateTimePartStyle#SHORT}, or {@link DateTimePartStyle#LONG}. Only relevant when
+ * {@link #setFormat format} is {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setMonth(DateTimePartStyle style) {
+ return setDatePart(ATTR_MONTH, style, ALL_STYLES);
+ }
+
+ /**
+ * Sets the {@code day} attribute. Accepts {@link DateTimePartStyle#NUMERIC} or
+ * {@link DateTimePartStyle#TWO_DIGIT}. Only relevant when {@link #setFormat format} is
+ * {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setDay(DateTimePartStyle style) {
+ return setDatePart(ATTR_DAY, style, NUMERIC_STYLES);
+ }
+
+ /**
+ * Sets the {@code weekday} attribute. Accepts {@link DateTimePartStyle#NARROW},
+ * {@link DateTimePartStyle#SHORT}, or {@link DateTimePartStyle#LONG}. Only relevant when
+ * {@link #setFormat format} is {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setWeekday(DateTimePartStyle style) {
+ return setDatePart(ATTR_WEEKDAY, style, TEXT_STYLES);
+ }
+
+ /**
+ * Sets the {@code hour} attribute. Accepts {@link DateTimePartStyle#NUMERIC} or
+ * {@link DateTimePartStyle#TWO_DIGIT}. Only relevant when {@link #setFormat format} is
+ * {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setHour(DateTimePartStyle style) {
+ return setDatePart(ATTR_HOUR, style, NUMERIC_STYLES);
+ }
+
+ /**
+ * Sets the {@code minute} attribute. Accepts {@link DateTimePartStyle#NUMERIC} or
+ * {@link DateTimePartStyle#TWO_DIGIT}. Only relevant when {@link #setFormat format} is
+ * {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setMinute(DateTimePartStyle style) {
+ return setDatePart(ATTR_MINUTE, style, NUMERIC_STYLES);
+ }
+
+ /**
+ * Sets the {@code second} attribute. Accepts {@link DateTimePartStyle#NUMERIC} or
+ * {@link DateTimePartStyle#TWO_DIGIT}. Only relevant when {@link #setFormat format} is
+ * {@link Format#DATETIME}. {@code null} removes the attribute.
+ */
+ public RelativeTime setSecond(DateTimePartStyle style) {
+ return setDatePart(ATTR_SECOND, style, NUMERIC_STYLES);
+ }
+
+ /**
+ * Writes a date-part attribute after checking the style is in the part's allowed subset. The
+ * upstream element silently ignores or coerces out-of-subset values, so we fail fast instead.
+ */
+ private RelativeTime setDatePart(String attribute, DateTimePartStyle style,
+ Set allowed) {
+ if (style != null && !allowed.contains(style)) {
+ throw new IllegalArgumentException(
+ attribute + " does not accept " + style + "; allowed values: " + allowed);
+ }
+ setOrRemove(attribute, style == null ? null : style.attributeValue());
+ return this;
+ }
+
+ private void setOrRemove(String attribute, String value) {
+ if (value == null) {
+ getElement().removeAttribute(attribute);
+ } else {
+ getElement().setAttribute(attribute, value);
+ }
+ }
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/relativetime/Tense.java b/src/main/java/com/flowingcode/vaadin/addons/relativetime/Tense.java
new file mode 100644
index 0000000..48908a7
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/relativetime/Tense.java
@@ -0,0 +1,35 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.flowingcode.vaadin.addons.relativetime;
+
+/** Values for the {@code tense} attribute on {@code }. */
+public enum Tense {
+ /** Past or future is decided from the target instant. */
+ AUTO,
+ /** Force past phrasing ("3 days ago"). */
+ PAST,
+ /** Force future phrasing ("in 3 days"). */
+ FUTURE;
+
+ String attributeValue() {
+ return AttributeValues.ofName(this);
+ }
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/relativetime/TimeZoneName.java b/src/main/java/com/flowingcode/vaadin/addons/relativetime/TimeZoneName.java
new file mode 100644
index 0000000..8735619
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/relativetime/TimeZoneName.java
@@ -0,0 +1,50 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.flowingcode.vaadin.addons.relativetime;
+
+/**
+ * Values for the {@code time-zone-name} attribute on {@code }. Forwarded to
+ * {@code Intl.DateTimeFormat} when {@link Format#DATETIME} is in effect.
+ */
+public enum TimeZoneName {
+ /** Localised name ("Pacific Daylight Time"). */
+ LONG("long"),
+ /** Localised abbreviation ("PDT"). */
+ SHORT("short"),
+ /** Localised offset ("GMT-8"). */
+ SHORT_OFFSET("shortOffset"),
+ /** Localised offset ("GMT-08:00"). */
+ LONG_OFFSET("longOffset"),
+ /** Generic non-location ("PT"). */
+ SHORT_GENERIC("shortGeneric"),
+ /** Generic non-location ("Pacific Time"). */
+ LONG_GENERIC("longGeneric");
+
+ private final String attributeValue;
+
+ TimeZoneName(String attributeValue) {
+ this.attributeValue = attributeValue;
+ }
+
+ String attributeValue() {
+ return attributeValue;
+ }
+}
diff --git a/src/main/resources/META-INF/VAADIN/package.properties b/src/main/resources/META-INF/VAADIN/package.properties
index c66616f..4ccb7cd 100644
--- a/src/main/resources/META-INF/VAADIN/package.properties
+++ b/src/main/resources/META-INF/VAADIN/package.properties
@@ -1 +1,20 @@
+###
+# #%L
+# Relative Time Add-On
+# %%
+# Copyright (C) 2026 Flowing Code
+# %%
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# #L%
+###
vaadin.allowed-packages=com.flowingcode
diff --git a/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java
new file mode 100644
index 0000000..2c1ecd6
--- /dev/null
+++ b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java
@@ -0,0 +1,35 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.vaadin.addons;
+
+import com.flowingcode.vaadin.addons.demo.DynamicTheme;
+import com.vaadin.flow.component.page.AppShellConfigurator;
+import com.vaadin.flow.server.AppShellSettings;
+
+public class AppShellConfiguratorImpl implements AppShellConfigurator {
+
+ @Override
+ public void configurePage(AppShellSettings settings) {
+ if (DynamicTheme.isFeatureSupported()) {
+ DynamicTheme.LUMO.initialize(settings);
+ }
+ }
+
+}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java
index 8996b87..fbec06c 100644
--- a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java
+++ b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java
@@ -1,15 +1,15 @@
/*-
* #%L
- * Template Add-on
+ * Relative Time Add-On
* %%
- * Copyright (C) 2025 Flowing Code
+ * Copyright (C) 2026 Flowing Code
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
- *
+ *
* http://www.apache.org/licenses/LICENSE-2.0
- *
+ *
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
diff --git a/src/test/java/com/flowingcode/vaadin/addons/relativetime/AbstractRelativeTimeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/relativetime/AbstractRelativeTimeDemo.java
new file mode 100644
index 0000000..edcaf1d
--- /dev/null
+++ b/src/test/java/com/flowingcode/vaadin/addons/relativetime/AbstractRelativeTimeDemo.java
@@ -0,0 +1,62 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.vaadin.addons.relativetime;
+
+import com.flowingcode.vaadin.addons.demo.SourceCodeViewer;
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+
+/**
+ * Shared scaffolding for the RelativeTime demo views. Both {@link RelativeTimeDemo} and
+ * {@link UseCasesDemo} extend this so they don't duplicate the separator helper and the
+ * "add a section + wire its fragment highlight" boilerplate.
+ *
+ * Uses a styled {@link Div} (not {@code
}) for the separator because the upstream Vaadin
+ * {@code
} ships with Lumo-specific CSS that doesn't render under Aura or the bare base
+ * theme. A {@code } sidesteps every {@code
}-targeted rule.
+ */
+@SuppressWarnings("serial")
+abstract class AbstractRelativeTimeDemo extends Div {
+
+ /**
+ * Returns a horizontal-rule-equivalent {@link Div} (class {@code relative-time-demo-separator})
+ * that renders the same in Lumo, Aura, and the bare base theme.
+ */
+ protected static Component separator() {
+ Div div = new Div();
+ div.addClassName("relative-time-demo-separator");
+ return div;
+ }
+
+ /**
+ * Adds {@code sectionContent} to {@code parent}, preceded by a {@link #separator()} when this
+ * isn't the first section, and wires hover-to-highlight on the source fragment named
+ * {@code fragmentId}.
+ */
+ protected static void addHighlightedSection(VerticalLayout parent, String fragmentId,
+ Component sectionContent) {
+ if (parent.getComponentCount() > 1) {
+ parent.add(separator());
+ }
+ SourceCodeViewer.highlightOnHover(sectionContent, fragmentId);
+ parent.add(sectionContent);
+ }
+}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/DemoView.java b/src/test/java/com/flowingcode/vaadin/addons/relativetime/DemoView.java
similarity index 85%
rename from src/test/java/com/flowingcode/vaadin/addons/template/DemoView.java
rename to src/test/java/com/flowingcode/vaadin/addons/relativetime/DemoView.java
index 0561979..b8266d9 100644
--- a/src/test/java/com/flowingcode/vaadin/addons/template/DemoView.java
+++ b/src/test/java/com/flowingcode/vaadin/addons/relativetime/DemoView.java
@@ -1,15 +1,15 @@
/*-
* #%L
- * Template Add-on
+ * Relative Time Add-On
* %%
- * Copyright (C) 2025 Flowing Code
+ * Copyright (C) 2026 Flowing Code
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
- *
+ *
* http://www.apache.org/licenses/LICENSE-2.0
- *
+ *
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -18,7 +18,7 @@
* #L%
*/
-package com.flowingcode.vaadin.addons.template;
+package com.flowingcode.vaadin.addons.relativetime;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.BeforeEnterEvent;
@@ -31,6 +31,6 @@ public class DemoView extends VerticalLayout implements BeforeEnterObserver {
@Override
public void beforeEnter(BeforeEnterEvent event) {
- event.forwardTo(TemplateDemoView.class);
+ event.forwardTo(RelativeTimeDemoView.class);
}
}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/relativetime/RelativeTimeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/relativetime/RelativeTimeDemo.java
new file mode 100644
index 0000000..e3b7edf
--- /dev/null
+++ b/src/test/java/com/flowingcode/vaadin/addons/relativetime/RelativeTimeDemo.java
@@ -0,0 +1,315 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.vaadin.addons.relativetime;
+
+import com.flowingcode.vaadin.addons.demo.DemoSource;
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.html.H4;
+import com.vaadin.flow.component.html.H5;
+import com.vaadin.flow.component.html.Span;
+import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Locale;
+
+/**
+ * Feature-by-feature showcase of {@link RelativeTime}. Opens with the most commonly-needed
+ * features (threshold-based switching, format-style variations, live-update format comparison,
+ * localisation, prefix), then expands into a Past/Future date matrix that mirrors the upstream
+ *
{@code
+ * relative-time-element} examples page: two fixed reference dates ({@code 1970-01-01} and the
+ * {@code 2038-01-19} Y2K38 moment) are rendered through each of the three formats
+ * ({@link Format#DATETIME}, {@link Format#RELATIVE}, {@link Format#DURATION}) with the same
+ * attribute variations the upstream demo uses. Hover any section to highlight its source fragment.
+ *
+ *
For realistic patterns lifted into Vaadin views (Grid, chat bubble, event card, date-picker
+ * preview, stopwatch, session-expiry banner), see {@link UseCasesDemo}.
+ */
+@DemoSource
+@PageTitle("Basic Demo")
+@SuppressWarnings("serial")
+@Route(value = "basic", layout = RelativeTimeDemoView.class)
+public class RelativeTimeDemo extends AbstractRelativeTimeDemo {
+
+ private static final Instant PAST = Instant.parse("1970-01-01T00:00:00Z");
+ private static final Instant FUTURE = Instant.parse("2038-01-19T03:14:08Z");
+
+ public RelativeTimeDemo() {
+ VerticalLayout layout = new VerticalLayout();
+ layout.setPadding(false);
+ layout.setSpacing(true);
+
+ layout.add(
+ new Span(
+ "Common features on top; Past/Future format-attribute matrices below."
+ + " Hover any section to highlight its source."));
+
+ addSection(layout, "threshold", "Threshold", buildThresholdSection());
+ addSection(layout, "format-style", "Format style", buildFormatStyleSection());
+ addSection(layout, "live-update", "Live update", buildLiveUpdateSection());
+ addSection(layout, "localised", "Localised", buildLocalisedSection());
+ addSection(layout, "prefix", "Prefix", buildPrefixSection());
+ addSection(layout, "past-date", "Past date: 1970-01-01", buildPastDateSection());
+ addSection(layout, "future-date", "Future date: 2038-01-19 (Y2K38)",
+ buildFutureDateSection());
+
+ add(layout);
+ }
+
+ private static void addSection(VerticalLayout parent, String fragmentId, String title,
+ Component content) {
+ VerticalLayout section = new VerticalLayout(new H4(title), content);
+ section.setPadding(false);
+ section.setSpacing(false);
+ addHighlightedSection(parent, fragmentId, section);
+ }
+
+ // --- Threshold -------------------------------------------------------
+
+ // begin-block threshold
+ private static Component buildThresholdSection() {
+ Instant now = Instant.now();
+ VerticalLayout section = new VerticalLayout();
+ section.setPadding(false);
+ section.setSpacing(false);
+ section.add(
+ new Span(
+ "Beyond the threshold, the element flips from a relative phrase to an absolute"
+ + " date."));
+ section.add(row("10 days out, P30D threshold (relative)",
+ new RelativeTime(now.plus(10, ChronoUnit.DAYS)).setThreshold(Duration.ofDays(30))));
+ section.add(row("60 days out, P30D threshold (absolute)",
+ new RelativeTime(now.plus(60, ChronoUnit.DAYS)).setThreshold(Duration.ofDays(30))));
+ return section;
+ }
+ // end-block
+
+ // --- Format style ---------------------------------------------------
+
+ // begin-block format-style
+ private static Component buildFormatStyleSection() {
+ Instant aWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS);
+ VerticalLayout section = new VerticalLayout();
+ section.setPadding(false);
+ section.setSpacing(false);
+ section.add(
+ new Span(
+ "FormatStyle controls how compact the relative phrase is. Same instant rendered"
+ + " three ways."));
+ section.add(row("LONG / SHORT / NARROW (a week ago)",
+ new RelativeTime(aWeekAgo).setFormatStyle(FormatStyle.LONG),
+ new RelativeTime(aWeekAgo).setFormatStyle(FormatStyle.SHORT),
+ new RelativeTime(aWeekAgo).setFormatStyle(FormatStyle.NARROW)));
+ return section;
+ }
+ // end-block
+
+ // --- Live update ----------------------------------------------------
+
+ // begin-block live-update
+ private static Component buildLiveUpdateSection() {
+ Instant now = Instant.now();
+
+ RelativeTime auto = new RelativeTime(now);
+ RelativeTime duration = new RelativeTime(now).setFormat(Format.DURATION);
+ RelativeTime micro = new RelativeTime(now).setFormat(Format.MICRO);
+
+ Button restart = new Button("Restart", e -> {
+ Instant fresh = Instant.now();
+ auto.setDateTime(fresh);
+ duration.setDateTime(fresh);
+ micro.setDateTime(fresh);
+ });
+
+ VerticalLayout section = new VerticalLayout();
+ section.setPadding(false);
+ section.setSpacing(false);
+ section.add(
+ new Span(
+ "All three rows below are pinned at the same instant and update by"
+ + " themselves. Format.AUTO (the default) sits at \"now\" for ~55"
+ + " seconds before jumping to \"1 minute ago\" because of an upstream"
+ + " signed-int threshold; DURATION and MICRO tick from zero with no"
+ + " plateau. Use the Restart button to re-pin."));
+ section.add(row("Format.AUTO (default)", auto));
+ section.add(row("Format.DURATION", duration));
+ section.add(row("Format.MICRO", micro));
+ section.add(restart);
+ return section;
+ }
+ // end-block
+
+ // --- Localised -------------------------------------------------------
+
+ // begin-block localised
+ private static Component buildLocalisedSection() {
+ Instant recent = Instant.now().minus(4, ChronoUnit.HOURS);
+ VerticalLayout section = new VerticalLayout();
+ section.setPadding(false);
+ section.setSpacing(false);
+ section.add(
+ new Span(
+ "The lang attribute drives Intl.RelativeTimeFormat / Intl.DateTimeFormat. setLocale"
+ + " on the Java side writes it via locale.toLanguageTag()."));
+ section.add(row("English (default browser locale)",
+ new RelativeTime(recent)));
+ section.add(row("Spanish",
+ new RelativeTime(recent).setLocale(Locale.forLanguageTag("es"))));
+ section.add(row("Finnish",
+ new RelativeTime(recent).setLocale(Locale.forLanguageTag("fi"))));
+ section.add(row("Finnish with Format.DATETIME",
+ new RelativeTime(recent)
+ .setFormat(Format.DATETIME)
+ .setLocale(Locale.forLanguageTag("fi"))));
+ section.add(row("French with Format.DURATION",
+ new RelativeTime(recent)
+ .setFormat(Format.DURATION)
+ .setLocale(Locale.forLanguageTag("fr"))));
+ return section;
+ }
+ // end-block
+
+ // --- Prefix ---------------------------------------------------------
+
+ // begin-block prefix
+ private static Component buildPrefixSection() {
+ Instant farFuture = Instant.now().plus(Duration.ofDays(60));
+ VerticalLayout section = new VerticalLayout();
+ section.setPadding(false);
+ section.setSpacing(false);
+ section.add(
+ new Span(
+ "When the threshold is crossed, the element renders an absolute date prefixed"
+ + " by the word from the prefix attribute (default \"on\"). These rows"
+ + " pin a date 60 days out, past the default P30D threshold, so the"
+ + " prefix is visible."));
+ section.add(row("Default prefix (\"on\")",
+ new RelativeTime(farFuture)));
+ section.add(row("Custom prefix (setPrefix(\"Due\"))",
+ new RelativeTime(farFuture).setPrefix("Due")));
+ section.add(row("No prefix (setPrefix(\"\"))",
+ new RelativeTime(farFuture).setPrefix("")));
+ return section;
+ }
+ // end-block
+
+ // --- Past Date -------------------------------------------------------
+
+ // begin-block past-date
+ private static Component buildPastDateSection() {
+ VerticalLayout section = new VerticalLayout();
+ section.setPadding(false);
+ section.setSpacing(false);
+
+ section.add(new H5("Format.DATETIME"));
+ section.add(row("Default",
+ new RelativeTime(PAST).setFormat(Format.DATETIME)));
+ section.add(row("With time",
+ new RelativeTime(PAST).setFormat(Format.DATETIME)
+ .setHour(DateTimePartStyle.NUMERIC)
+ .setMinute(DateTimePartStyle.TWO_DIGIT)
+ .setSecond(DateTimePartStyle.TWO_DIGIT)));
+ section.add(row("Customised (all parts)",
+ new RelativeTime(PAST).setFormat(Format.DATETIME)
+ .setWeekday(DateTimePartStyle.NARROW)
+ .setYear(DateTimePartStyle.TWO_DIGIT)
+ .setMonth(DateTimePartStyle.NARROW)
+ .setDay(DateTimePartStyle.NUMERIC)
+ .setHour(DateTimePartStyle.NUMERIC)
+ .setMinute(DateTimePartStyle.TWO_DIGIT)
+ .setSecond(DateTimePartStyle.TWO_DIGIT)));
+
+ section.add(new H5("Format.RELATIVE (default)"));
+ section.add(row("Default",
+ new RelativeTime(PAST)));
+ section.add(row("Fixed to future tense",
+ new RelativeTime(PAST).setTense(Tense.FUTURE)));
+
+ section.add(new H5("Format.DURATION"));
+ section.add(row("Default",
+ new RelativeTime(PAST).setFormat(Format.DURATION)));
+ section.add(row("Month precision",
+ new RelativeTime(PAST).setFormat(Format.DURATION).setPrecision(Precision.MONTH)));
+
+ return section;
+ }
+ // end-block
+
+ // --- Future Date -----------------------------------------------------
+
+ // begin-block future-date
+ private static Component buildFutureDateSection() {
+ VerticalLayout section = new VerticalLayout();
+ section.setPadding(false);
+ section.setSpacing(false);
+
+ section.add(new H5("Format.DATETIME"));
+ section.add(row("Default",
+ new RelativeTime(FUTURE).setFormat(Format.DATETIME)));
+ section.add(row("With time",
+ new RelativeTime(FUTURE).setFormat(Format.DATETIME)
+ .setHour(DateTimePartStyle.NUMERIC)
+ .setMinute(DateTimePartStyle.TWO_DIGIT)
+ .setSecond(DateTimePartStyle.TWO_DIGIT)));
+ section.add(row("Customised (all parts)",
+ new RelativeTime(FUTURE).setFormat(Format.DATETIME)
+ .setWeekday(DateTimePartStyle.NARROW)
+ .setYear(DateTimePartStyle.TWO_DIGIT)
+ .setMonth(DateTimePartStyle.NARROW)
+ .setDay(DateTimePartStyle.NUMERIC)
+ .setHour(DateTimePartStyle.NUMERIC)
+ .setMinute(DateTimePartStyle.TWO_DIGIT)
+ .setSecond(DateTimePartStyle.TWO_DIGIT)));
+
+ section.add(new H5("Format.RELATIVE (default)"));
+ section.add(row("Default",
+ new RelativeTime(FUTURE)));
+ section.add(row("Fixed to past tense",
+ new RelativeTime(FUTURE).setTense(Tense.PAST)));
+
+ section.add(new H5("Format.DURATION"));
+ section.add(row("Default",
+ new RelativeTime(FUTURE).setFormat(Format.DURATION)));
+ section.add(row("Month precision",
+ new RelativeTime(FUTURE).setFormat(Format.DURATION).setPrecision(Precision.MONTH)));
+
+ return section;
+ }
+ // end-block
+
+ // --- shared helper ---------------------------------------------------
+
+ private static HorizontalLayout row(String label, Component... values) {
+ HorizontalLayout row = new HorizontalLayout();
+ row.setSpacing(true);
+ row.setPadding(false);
+ row.addClassName("relative-time-demo-row");
+ Span labelSpan = new Span(label + ":");
+ labelSpan.addClassName("relative-time-demo-row-label");
+ row.add(labelSpan);
+ row.add(values);
+ return row;
+ }
+}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/relativetime/RelativeTimeDemoView.java
similarity index 65%
rename from src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemoView.java
rename to src/test/java/com/flowingcode/vaadin/addons/relativetime/RelativeTimeDemoView.java
index a17133f..24f4a8f 100644
--- a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemoView.java
+++ b/src/test/java/com/flowingcode/vaadin/addons/relativetime/RelativeTimeDemoView.java
@@ -1,15 +1,15 @@
/*-
* #%L
- * Template Add-on
+ * Relative Time Add-On
* %%
- * Copyright (C) 2025 Flowing Code
+ * Copyright (C) 2026 Flowing Code
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
- *
+ *
* http://www.apache.org/licenses/LICENSE-2.0
- *
+ *
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -17,22 +17,25 @@
* limitations under the License.
* #L%
*/
-package com.flowingcode.vaadin.addons.template;
+package com.flowingcode.vaadin.addons.relativetime;
import com.flowingcode.vaadin.addons.DemoLayout;
import com.flowingcode.vaadin.addons.GithubLink;
import com.flowingcode.vaadin.addons.demo.TabbedDemo;
+import com.vaadin.flow.component.dependency.CssImport;
import com.vaadin.flow.router.ParentLayout;
import com.vaadin.flow.router.Route;
@SuppressWarnings("serial")
@ParentLayout(DemoLayout.class)
-@Route("template")
-@GithubLink("https://github.com/FlowingCode/AddonStarter24")
-public class TemplateDemoView extends TabbedDemo {
+@Route("relative-time")
+@CssImport("./styles/relative-time-add-on-demo-styles.css")
+@GithubLink("https://github.com/FlowingCode/RelativeTime")
+public class RelativeTimeDemoView extends TabbedDemo {
- public TemplateDemoView() {
- addDemo(TemplateDemo.class);
+ public RelativeTimeDemoView() {
+ addDemo(RelativeTimeDemo.class);
+ addDemo(UseCasesDemo.class);
setSizeFull();
}
}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/relativetime/UseCasesDemo.java b/src/test/java/com/flowingcode/vaadin/addons/relativetime/UseCasesDemo.java
new file mode 100644
index 0000000..6f93137
--- /dev/null
+++ b/src/test/java/com/flowingcode/vaadin/addons/relativetime/UseCasesDemo.java
@@ -0,0 +1,318 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.vaadin.addons.relativetime;
+
+import com.flowingcode.vaadin.addons.demo.DemoSource;
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.combobox.ComboBox;
+import com.vaadin.flow.component.datetimepicker.DateTimePicker;
+import com.vaadin.flow.component.grid.Grid;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.Span;
+import com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment;
+import com.vaadin.flow.component.orderedlayout.FlexComponent.JustifyContentMode;
+import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Realistic UI patterns showing how {@link RelativeTime} fits into common Vaadin views:
+ * audit-log grids, chat timestamps, event cards, a date-picker preview, a live stopwatch, and
+ * a session-expiry banner. Each section is structured so it can be lifted into a real app with
+ * minimal changes. Hover over any example to highlight the corresponding source fragment.
+ */
+@DemoSource
+@PageTitle("Use Cases")
+@SuppressWarnings("serial")
+@Route(value = "use-cases", layout = RelativeTimeDemoView.class)
+public class UseCasesDemo extends AbstractRelativeTimeDemo {
+
+ public UseCasesDemo() {
+ VerticalLayout layout = new VerticalLayout();
+ layout.setPadding(false);
+ layout.setSpacing(true);
+
+ layout.add(
+ new Span(
+ "Realistic UI patterns where RelativeTime fits naturally. Each timestamp re-renders"
+ + " in the browser as time passes. Each example uses the format best suited to its"
+ + " pattern: AUTO for human-friendly feeds, DURATION for stopwatches,"
+ + " future-tense with a threshold for countdowns, and MICRO for tight spaces."
+ + " Hover over any example to highlight the corresponding source fragment on"
+ + " the right."));
+
+ addUseCase(layout, "audit-log-grid",
+ "Audit log Grid", buildAuditLogGrid(),
+ "Vaadin Grid with a RelativeTime in a component column. Drops in alongside regular"
+ + " columns and scrolls smoothly. Each row's timestamp is a tiny custom element"
+ + " that handles its own updates, so the Grid stays cheap to render even with"
+ + " thousands of rows. Pattern: audit logs, change history, activity tables in"
+ + " admin UIs.");
+
+ addUseCase(layout, "chat-messages",
+ "Chat messages", buildChatMessages(),
+ "Bubble-style messages with the timestamp tucked inside each bubble, formatted as"
+ + " Format.MICRO (e.g. \"3m\", \"45s\"). Pattern: any chat, DM, or comment thread"
+ + " where you want a glance-able timestamp embedded in the message without"
+ + " consuming a whole column.");
+
+ addUseCase(layout, "event-card",
+ "Upcoming event card", buildEventCard(),
+ "Future-tense relative time inside a card. The element's auto-tense gives \"in 2 hours\""
+ + " for a future instant. Pattern: calendar widgets, meeting cards, reservation"
+ + " summaries.");
+
+ addUseCase(layout, "date-time-picker",
+ "Date picker preview", buildDateTimePickerSection(),
+ "DateTimePicker driving a RelativeTime preview through a value-change listener; a"
+ + " locale combo drives setLocale on the same preview. A 30-day threshold flips the"
+ + " preview to an absolute date for far-away picks. Pattern: any form field with a"
+ + " date input where you want an \"in X days\" preview next to the input.");
+
+ addUseCase(layout, "stopwatch",
+ "Live stopwatch", buildStopwatch(),
+ "A running counter using Format.DURATION (ticks every second from 0s with no \"now\""
+ + " plateau). The Start button pins the datetime to now; Stop clears it. Pattern:"
+ + " timers for in-progress work, build/deploy status, \"uptime since\" indicators.");
+
+ addUseCase(layout, "session-expiry",
+ "Session expiry warning", buildSessionWarning(),
+ "Banner with a future-tense countdown. The \"Stay logged in\" button re-pins the"
+ + " datetime, restarting the countdown. Pattern: any time-limited UI such as"
+ + " session expiry, OTP or code expiry, auction or sale countdowns.");
+
+ add(layout);
+ }
+
+ private static void addUseCase(VerticalLayout parent, String fragmentId, String title,
+ Component content, String explanation) {
+ Component block = useCase(title, content, explanation);
+ addHighlightedSection(parent, fragmentId, block);
+ }
+
+ // --- Audit log Grid ----------------------------------------------------
+
+ private record AuditLog(String user, String action, Instant when) {}
+
+ // begin-block audit-log-grid
+ private static Component buildAuditLogGrid() {
+ Instant now = Instant.now();
+ List rows =
+ List.of(
+ new AuditLog("alice", "Created project \"Atlas\"", now.minus(Duration.ofMinutes(2))),
+ new AuditLog("bob", "Updated permissions on /repos", now.minus(Duration.ofMinutes(8))),
+ new AuditLog("carol", "Deleted user 'temp123'", now.minus(Duration.ofMinutes(35))),
+ new AuditLog("dave", "Rotated API key", now.minus(Duration.ofHours(2))),
+ new AuditLog("alice", "Approved PR #142", now.minus(Duration.ofHours(5))),
+ new AuditLog("eve", "Logged in from new IP", now.minus(Duration.ofDays(1))),
+ new AuditLog("bob", "Changed billing email", now.minus(Duration.ofDays(3))),
+ new AuditLog("carol", "Exported user database", now.minus(Duration.ofDays(7))));
+
+ Grid grid = new Grid<>(AuditLog.class, false);
+ grid.setItems(rows);
+ grid.addColumn(AuditLog::user).setHeader("User").setAutoWidth(true);
+ grid.addColumn(AuditLog::action).setHeader("Action").setFlexGrow(1);
+ grid.addComponentColumn(log -> new RelativeTime(log.when()))
+ .setHeader("When")
+ .setAutoWidth(true);
+ grid.setAllRowsVisible(true);
+ return grid;
+ }
+ // end-block
+
+ // --- Chat messages -----------------------------------------------------
+
+ // begin-block chat-messages
+ private static Component buildChatMessages() {
+ Instant now = Instant.now();
+ VerticalLayout chat =
+ new VerticalLayout(
+ chatBubble("Hi! Got a minute?", now.minus(Duration.ofMinutes(3)), true),
+ chatBubble("Sure, what's up?", now.minus(Duration.ofMinutes(2)), false),
+ chatBubble("Want to grab lunch?", now.minus(Duration.ofSeconds(45)), true));
+ chat.setPadding(false);
+ chat.setSpacing(true);
+ chat.addClassName("relative-time-demo-chat");
+ return chat;
+ }
+
+ private static HorizontalLayout chatBubble(String text, Instant when, boolean sent) {
+ Span body = new Span(text);
+
+ RelativeTime stamp = new RelativeTime(when).setFormat(Format.MICRO);
+ stamp.addClassName("relative-time-demo-chat-bubble-stamp");
+
+ VerticalLayout bubble = new VerticalLayout(body, stamp);
+ bubble.setPadding(false);
+ bubble.setSpacing(false);
+ bubble.addClassName("relative-time-demo-chat-bubble");
+ bubble.addClassName(sent ? "relative-time-demo-chat-bubble-sent" : "relative-time-demo-chat-bubble-received");
+
+ HorizontalLayout row = new HorizontalLayout(bubble);
+ row.setWidthFull();
+ row.setJustifyContentMode(sent ? JustifyContentMode.END : JustifyContentMode.START);
+ return row;
+ }
+ // end-block
+
+ // --- Date picker preview ----------------------------------------------
+
+ // begin-block date-time-picker
+ private static Component buildDateTimePickerSection() {
+ DateTimePicker picker = new DateTimePicker("Pick a deadline");
+
+ ComboBox localeCombo = new ComboBox<>("Locale");
+ localeCombo.setItems(
+ List.of(
+ Locale.ENGLISH,
+ Locale.forLanguageTag("es"),
+ Locale.forLanguageTag("it"),
+ Locale.forLanguageTag("fr")));
+ localeCombo.setItemLabelGenerator(loc -> loc.getDisplayName(Locale.ENGLISH));
+
+ RelativeTime preview =
+ new RelativeTime()
+ .setTense(Tense.AUTO)
+ .setFormatStyle(FormatStyle.LONG)
+ .setThreshold(Duration.ofDays(30));
+
+ picker.addValueChangeListener(e -> preview.setDateTime(e.getValue()));
+ localeCombo.addValueChangeListener(e -> preview.setLocale(e.getValue()));
+
+ // Initial state: 5 days from now, English. Within the 30-day threshold,
+ // so the preview starts as a relative phrase.
+ picker.setValue(LocalDateTime.now().plusDays(5));
+ localeCombo.setValue(Locale.ENGLISH);
+
+ HorizontalLayout controls = new HorizontalLayout(picker, localeCombo);
+ controls.setAlignItems(Alignment.END);
+
+ Div previewRow = new Div(new Span("Deadline: "), preview);
+
+ VerticalLayout section = new VerticalLayout(controls, previewRow);
+ section.setPadding(false);
+ section.setSpacing(true);
+ return section;
+ }
+ // end-block
+
+ // --- Upcoming event card ----------------------------------------------
+
+ // begin-block event-card
+ private static Component buildEventCard() {
+ Instant inTwoHours = Instant.now().plus(Duration.ofHours(2));
+
+ Span title = new Span("Daily Standup");
+ title.addClassName("relative-time-demo-event-card-title");
+
+ Div timeLine = new Div(new Span("starts "), new RelativeTime(inTwoHours));
+
+ Span location = new Span("Conference room A");
+ location.addClassName("relative-time-demo-event-card-location");
+
+ VerticalLayout card = new VerticalLayout(title, timeLine, location);
+ card.setPadding(true);
+ card.setSpacing(false);
+ card.addClassName("relative-time-demo-event-card");
+ return card;
+ }
+ // end-block
+
+ // --- Live stopwatch ----------------------------------------------------
+
+ // begin-block stopwatch
+ private static Component buildStopwatch() {
+ RelativeTime elapsed = new RelativeTime().setFormat(Format.DURATION);
+ Span placeholder = new Span("...");
+ placeholder.addClassName("relative-time-demo-stopwatch-placeholder");
+
+ Div display = new Div(new Span("Elapsed: "), placeholder, elapsed);
+
+ Button startStop = new Button("Start");
+ startStop.addClickListener(
+ e -> {
+ if (elapsed.getDateTime() != null) {
+ elapsed.setDateTime((Instant) null);
+ placeholder.setVisible(true);
+ startStop.setText("Start");
+ } else {
+ elapsed.setDateTime(Instant.now());
+ placeholder.setVisible(false);
+ startStop.setText("Stop");
+ }
+ });
+
+ VerticalLayout layout = new VerticalLayout(display, startStop);
+ layout.setPadding(false);
+ layout.setSpacing(true);
+ return layout;
+ }
+ // end-block
+
+ // --- Session expiry warning -------------------------------------------
+
+ // begin-block session-expiry
+ private static Component buildSessionWarning() {
+ RelativeTime expiry = new RelativeTime(Instant.now().plus(Duration.ofMinutes(14)));
+
+ Span icon = new Span("⚠️");
+ icon.addClassName("relative-time-demo-session-banner-icon");
+
+ Div textRow = new Div(icon, new Span("Your session expires "), expiry, new Span("."));
+
+ Button stayLoggedIn = new Button("Stay logged in");
+ stayLoggedIn.addClickListener(
+ e -> expiry.setDateTime(Instant.now().plus(Duration.ofMinutes(14))));
+
+ HorizontalLayout banner = new HorizontalLayout(textRow, stayLoggedIn);
+ banner.setWidthFull();
+ banner.setJustifyContentMode(JustifyContentMode.BETWEEN);
+ banner.setAlignItems(Alignment.CENTER);
+ banner.addClassName("relative-time-demo-session-banner");
+ return banner;
+ }
+ // end-block
+
+ // --- shared helper -----------------------------------------------------
+
+ private static Component useCase(String title, Component content, String explanation) {
+ VerticalLayout block = new VerticalLayout();
+ block.setPadding(false);
+ block.setSpacing(false);
+
+ Span titleSpan = new Span(title);
+ titleSpan.addClassName("relative-time-demo-use-case-title");
+
+ content.getElement().getClassList().add("relative-time-demo-use-case-content");
+
+ Span explanationSpan = new Span(explanation);
+ explanationSpan.addClassName("relative-time-demo-use-case-explanation");
+
+ block.add(titleSpan, content, explanationSpan);
+ return block;
+ }
+}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/it/AbstractViewTest.java b/src/test/java/com/flowingcode/vaadin/addons/relativetime/it/AbstractViewTest.java
similarity index 96%
rename from src/test/java/com/flowingcode/vaadin/addons/template/it/AbstractViewTest.java
rename to src/test/java/com/flowingcode/vaadin/addons/relativetime/it/AbstractViewTest.java
index a2579ad..43b5916 100644
--- a/src/test/java/com/flowingcode/vaadin/addons/template/it/AbstractViewTest.java
+++ b/src/test/java/com/flowingcode/vaadin/addons/relativetime/it/AbstractViewTest.java
@@ -1,15 +1,15 @@
/*-
* #%L
- * Template Add-on
+ * Relative Time Add-On
* %%
- * Copyright (C) 2025 Flowing Code
+ * Copyright (C) 2026 Flowing Code
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
- *
+ *
* http://www.apache.org/licenses/LICENSE-2.0
- *
+ *
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -18,7 +18,7 @@
* #L%
*/
-package com.flowingcode.vaadin.addons.template.it;
+package com.flowingcode.vaadin.addons.relativetime.it;
import com.vaadin.testbench.ScreenshotOnFailureRule;
import com.vaadin.testbench.TestBench;
diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/it/ViewIT.java b/src/test/java/com/flowingcode/vaadin/addons/relativetime/it/ViewIT.java
similarity index 69%
rename from src/test/java/com/flowingcode/vaadin/addons/template/it/ViewIT.java
rename to src/test/java/com/flowingcode/vaadin/addons/relativetime/it/ViewIT.java
index 628dc14..91dd982 100644
--- a/src/test/java/com/flowingcode/vaadin/addons/template/it/ViewIT.java
+++ b/src/test/java/com/flowingcode/vaadin/addons/relativetime/it/ViewIT.java
@@ -1,15 +1,15 @@
/*-
* #%L
- * Template Add-on
+ * Relative Time Add-On
* %%
- * Copyright (C) 2025 Flowing Code
+ * Copyright (C) 2026 Flowing Code
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
- *
+ *
* http://www.apache.org/licenses/LICENSE-2.0
- *
+ *
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -18,11 +18,11 @@
* #L%
*/
-package com.flowingcode.vaadin.addons.template.it;
+package com.flowingcode.vaadin.addons.relativetime.it;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
-import static org.junit.Assert.assertThat;
+import static org.hamcrest.MatcherAssert.assertThat;
import com.vaadin.testbench.TestBenchElement;
import org.hamcrest.Description;
@@ -42,7 +42,13 @@ public void describeTo(Description description) {
@Override
protected boolean matchesSafely(TestBenchElement item, Description mismatchDescription) {
- String script = "let s=arguments[0].shadowRoot; return !!(s&&s.childElementCount)";
+ // An element is considered upgraded if it has a shadow root AND the shadow root has
+ // either an element child OR non-empty text content. relative-time writes its rendered
+ // phrase via `shadowRoot.textContent =`, which creates a text node (not an element
+ // child), so checking only childElementCount fails for this kind of component.
+ String script =
+ "let s=arguments[0].shadowRoot;"
+ + " return !!(s && (s.childElementCount || s.textContent.trim()))";
if (!item.getTagName().contains("-")) {
return true;
}
@@ -58,7 +64,7 @@ protected boolean matchesSafely(TestBenchElement item, Description mismatchDescr
@Test
public void componentWorks() {
- TestBenchElement element = $("paper-input").first();
+ TestBenchElement element = $("relative-time").first();
assertThat(element, hasBeenUpgradedToCustomElement);
}
}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/relativetime/test/RelativeTimeTest.java b/src/test/java/com/flowingcode/vaadin/addons/relativetime/test/RelativeTimeTest.java
new file mode 100644
index 0000000..90c6fe9
--- /dev/null
+++ b/src/test/java/com/flowingcode/vaadin/addons/relativetime/test/RelativeTimeTest.java
@@ -0,0 +1,346 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.vaadin.addons.relativetime.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.flowingcode.vaadin.addons.relativetime.DateTimePartStyle;
+import com.flowingcode.vaadin.addons.relativetime.Format;
+import com.flowingcode.vaadin.addons.relativetime.FormatStyle;
+import com.flowingcode.vaadin.addons.relativetime.Precision;
+import com.flowingcode.vaadin.addons.relativetime.RelativeTime;
+import com.flowingcode.vaadin.addons.relativetime.Tense;
+import com.flowingcode.vaadin.addons.relativetime.TimeZoneName;
+import com.vaadin.flow.dom.Element;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Locale;
+import org.junit.Test;
+
+public class RelativeTimeTest {
+
+ private static final Instant FIXED = Instant.parse("2026-01-15T12:34:56Z");
+
+ // --- datetime overloads -------------------------------------------------
+
+ @Test
+ public void setDateTime_instant_writesAttributeAndUpdatesGetter() {
+ RelativeTime rt = new RelativeTime();
+ rt.setDateTime(FIXED);
+ assertEquals(FIXED.toString(), attr(rt, "datetime"));
+ assertEquals(FIXED, rt.getDateTime());
+ }
+
+ @Test
+ public void setDateTime_null_clearsAttributeAndGetter() {
+ RelativeTime rt = new RelativeTime(FIXED);
+ rt.setDateTime((Instant) null);
+ assertNull(attr(rt, "datetime"));
+ assertNull(rt.getDateTime());
+ }
+
+ @Test
+ public void clear_removesAttributeAndGetter() {
+ RelativeTime rt = new RelativeTime(FIXED);
+ assertEquals(rt, rt.clear());
+ assertNull(attr(rt, "datetime"));
+ assertNull(rt.getDateTime());
+ }
+
+ @Test
+ public void setDateTime_offsetDateTime_normalisesToInstant() {
+ OffsetDateTime odt = OffsetDateTime.of(2026, 1, 15, 14, 34, 56, 0, ZoneOffset.ofHours(2));
+ RelativeTime rt = new RelativeTime();
+ rt.setDateTime(odt);
+ assertEquals(odt.toInstant(), rt.getDateTime());
+ assertEquals(odt.toInstant().toString(), attr(rt, "datetime"));
+ }
+
+ @Test
+ public void setDateTime_zonedDateTime_normalisesToInstant() {
+ ZonedDateTime zdt = ZonedDateTime.of(2026, 1, 15, 13, 34, 56, 0, ZoneId.of("Europe/Madrid"));
+ RelativeTime rt = new RelativeTime();
+ rt.setDateTime(zdt);
+ assertEquals(zdt.toInstant(), rt.getDateTime());
+ }
+
+ @Test
+ public void setDateTime_localDateTime_usesSystemDefaultZone() {
+ LocalDateTime ldt = LocalDateTime.of(2026, 1, 15, 12, 34, 56);
+ Instant expected = ldt.atZone(ZoneId.systemDefault()).toInstant();
+ RelativeTime rt = new RelativeTime();
+ rt.setDateTime(ldt);
+ assertEquals(expected, rt.getDateTime());
+ }
+
+ @Test
+ public void setDateTime_localDate_usesStartOfDayInSystemDefaultZone() {
+ LocalDate date = LocalDate.of(2026, 1, 15);
+ Instant expected = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
+ RelativeTime rt = new RelativeTime();
+ rt.setDateTime(date);
+ assertEquals(expected, rt.getDateTime());
+ }
+
+ // --- enum setters -------------------------------------------------------
+
+ @Test
+ public void setTense_writesLowercaseName() {
+ RelativeTime rt = new RelativeTime();
+ rt.setTense(Tense.FUTURE);
+ assertEquals("future", attr(rt, "tense"));
+ rt.setTense(null);
+ assertNull(attr(rt, "tense"));
+ }
+
+ @Test
+ public void setFormat_writesLowercaseName() {
+ RelativeTime rt = new RelativeTime();
+ rt.setFormat(Format.DURATION);
+ assertEquals("duration", attr(rt, "format"));
+ rt.setFormat(null);
+ assertNull(attr(rt, "format"));
+ }
+
+ @Test
+ public void setPrecision_writesLowercaseName() {
+ RelativeTime rt = new RelativeTime();
+ rt.setPrecision(Precision.DAY);
+ assertEquals("day", attr(rt, "precision"));
+ rt.setPrecision(null);
+ assertNull(attr(rt, "precision"));
+ }
+
+ @Test
+ public void setFormatStyle_writesKebabAttribute() {
+ RelativeTime rt = new RelativeTime();
+ rt.setFormatStyle(FormatStyle.NARROW);
+ assertEquals("narrow", attr(rt, "format-style"));
+ rt.setFormatStyle(null);
+ assertNull(attr(rt, "format-style"));
+ }
+
+ // --- duration, prefix, no-title, locale --------------------------------
+
+ @Test
+ public void setThreshold_writesIso8601Duration() {
+ RelativeTime rt = new RelativeTime();
+ rt.setThreshold(Duration.ofDays(30));
+ assertEquals("PT720H", attr(rt, "threshold"));
+ rt.setThreshold(null);
+ assertNull(attr(rt, "threshold"));
+ }
+
+ @Test
+ public void setThreshold_acceptsZero() {
+ RelativeTime rt = new RelativeTime();
+ rt.setThreshold(Duration.ZERO);
+ assertEquals("PT0S", attr(rt, "threshold"));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setThreshold_rejectsNegative() {
+ new RelativeTime().setThreshold(Duration.ofSeconds(-30));
+ }
+
+ @Test
+ public void setPrefix_emptyStringSetsEmpty_nullClears() {
+ RelativeTime rt = new RelativeTime();
+ rt.setPrefix("");
+ assertEquals("", attr(rt, "prefix"));
+ rt.setPrefix(null);
+ assertNull(attr(rt, "prefix"));
+ }
+
+ @Test
+ public void setNoTitle_trueSets_falseRemoves() {
+ RelativeTime rt = new RelativeTime();
+ rt.setNoTitle(true);
+ assertTrue(rt.getElement().hasAttribute("no-title"));
+ rt.setNoTitle(false);
+ assertFalse(rt.getElement().hasAttribute("no-title"));
+ }
+
+ @Test
+ public void setLocale_writesLanguageTagAsLang() {
+ RelativeTime rt = new RelativeTime();
+ rt.setLocale(Locale.forLanguageTag("es-ES"));
+ assertEquals("es-ES", attr(rt, "lang"));
+ rt.setLocale(null);
+ assertNull(attr(rt, "lang"));
+ }
+
+ // --- time-zone, time-zone-name, date parts -----------------------------
+
+ @Test
+ public void setTimeZone_writesZoneIdAsTimeZone() {
+ RelativeTime rt = new RelativeTime();
+ rt.setTimeZone(ZoneId.of("America/New_York"));
+ assertEquals("America/New_York", attr(rt, "time-zone"));
+ rt.setTimeZone(null);
+ assertNull(attr(rt, "time-zone"));
+ }
+
+ @Test
+ public void setTimeZone_acceptsUtc() {
+ RelativeTime rt = new RelativeTime();
+ rt.setTimeZone(ZoneId.of("UTC"));
+ assertEquals("UTC", attr(rt, "time-zone"));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setTimeZone_rejectsOffsetZone() {
+ new RelativeTime().setTimeZone(ZoneOffset.ofHours(2));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setTimeZone_rejectsZuluOffset() {
+ new RelativeTime().setTimeZone(ZoneOffset.UTC);
+ }
+
+ @Test
+ public void setTimeZoneName_writesCamelCaseValue() {
+ RelativeTime rt = new RelativeTime();
+ rt.setTimeZoneName(TimeZoneName.SHORT_OFFSET);
+ assertEquals("shortOffset", attr(rt, "time-zone-name"));
+ rt.setTimeZoneName(TimeZoneName.LONG);
+ assertEquals("long", attr(rt, "time-zone-name"));
+ rt.setTimeZoneName(null);
+ assertNull(attr(rt, "time-zone-name"));
+ }
+
+ @Test
+ public void datePartSetters_writeAttribute_andTwoDigitSerialisesWithHyphen() {
+ RelativeTime rt = new RelativeTime();
+
+ rt.setYear(DateTimePartStyle.NUMERIC);
+ assertEquals("numeric", attr(rt, "year"));
+
+ rt.setMonth(DateTimePartStyle.LONG);
+ assertEquals("long", attr(rt, "month"));
+
+ rt.setDay(DateTimePartStyle.TWO_DIGIT);
+ assertEquals("2-digit", attr(rt, "day"));
+
+ rt.setWeekday(DateTimePartStyle.NARROW);
+ assertEquals("narrow", attr(rt, "weekday"));
+
+ rt.setHour(DateTimePartStyle.TWO_DIGIT);
+ assertEquals("2-digit", attr(rt, "hour"));
+
+ rt.setMinute(DateTimePartStyle.NUMERIC);
+ assertEquals("numeric", attr(rt, "minute"));
+
+ rt.setSecond(DateTimePartStyle.TWO_DIGIT);
+ assertEquals("2-digit", attr(rt, "second"));
+ }
+
+ @Test
+ public void datePartSetters_nullRemovesAttribute() {
+ RelativeTime rt =
+ new RelativeTime()
+ .setYear(DateTimePartStyle.NUMERIC)
+ .setMonth(DateTimePartStyle.LONG)
+ .setDay(DateTimePartStyle.TWO_DIGIT)
+ .setWeekday(DateTimePartStyle.SHORT)
+ .setHour(DateTimePartStyle.NUMERIC)
+ .setMinute(DateTimePartStyle.NUMERIC)
+ .setSecond(DateTimePartStyle.NUMERIC);
+
+ rt.setYear(null);
+ rt.setMonth(null);
+ rt.setDay(null);
+ rt.setWeekday(null);
+ rt.setHour(null);
+ rt.setMinute(null);
+ rt.setSecond(null);
+
+ assertNull(attr(rt, "year"));
+ assertNull(attr(rt, "month"));
+ assertNull(attr(rt, "day"));
+ assertNull(attr(rt, "weekday"));
+ assertNull(attr(rt, "hour"));
+ assertNull(attr(rt, "minute"));
+ assertNull(attr(rt, "second"));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setWeekday_rejectsNumericStyle() {
+ new RelativeTime().setWeekday(DateTimePartStyle.NUMERIC);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setYear_rejectsTextStyle() {
+ new RelativeTime().setYear(DateTimePartStyle.LONG);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setDay_rejectsTextStyle() {
+ new RelativeTime().setDay(DateTimePartStyle.NARROW);
+ }
+
+ @Test
+ public void setMonth_acceptsEveryStyle() {
+ RelativeTime rt = new RelativeTime();
+ for (DateTimePartStyle style : DateTimePartStyle.values()) {
+ rt.setMonth(style);
+ assertTrue("month should accept " + style, rt.getElement().hasAttribute("month"));
+ }
+ }
+
+ // --- fluent chain returns this -----------------------------------------
+
+ @Test
+ public void settersReturnSameInstance() {
+ RelativeTime rt = new RelativeTime();
+ assertEquals(rt, rt.setDateTime(FIXED));
+ assertEquals(rt, rt.setTense(Tense.AUTO));
+ assertEquals(rt, rt.setFormat(Format.RELATIVE));
+ assertEquals(rt, rt.setPrecision(Precision.HOUR));
+ assertEquals(rt, rt.setFormatStyle(FormatStyle.LONG));
+ assertEquals(rt, rt.setThreshold(Duration.ofDays(1)));
+ assertEquals(rt, rt.setPrefix("on"));
+ assertEquals(rt, rt.setNoTitle(true));
+ assertEquals(rt, rt.setLocale(Locale.ENGLISH));
+ assertEquals(rt, rt.setTimeZone(ZoneId.of("UTC")));
+ assertEquals(rt, rt.setTimeZoneName(TimeZoneName.SHORT));
+ assertEquals(rt, rt.setYear(DateTimePartStyle.NUMERIC));
+ assertEquals(rt, rt.setMonth(DateTimePartStyle.LONG));
+ assertEquals(rt, rt.setDay(DateTimePartStyle.TWO_DIGIT));
+ assertEquals(rt, rt.setWeekday(DateTimePartStyle.SHORT));
+ assertEquals(rt, rt.setHour(DateTimePartStyle.NUMERIC));
+ assertEquals(rt, rt.setMinute(DateTimePartStyle.NUMERIC));
+ assertEquals(rt, rt.setSecond(DateTimePartStyle.NUMERIC));
+ }
+
+ private static String attr(RelativeTime rt, String name) {
+ Element el = rt.getElement();
+ return el.hasAttribute(name) ? el.getAttribute(name) : null;
+ }
+}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/relativetime/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/relativetime/test/SerializationTest.java
new file mode 100644
index 0000000..cdce46f
--- /dev/null
+++ b/src/test/java/com/flowingcode/vaadin/addons/relativetime/test/SerializationTest.java
@@ -0,0 +1,90 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.vaadin.addons.relativetime.test;
+
+import com.flowingcode.vaadin.addons.relativetime.DateTimePartStyle;
+import com.flowingcode.vaadin.addons.relativetime.Format;
+import com.flowingcode.vaadin.addons.relativetime.FormatStyle;
+import com.flowingcode.vaadin.addons.relativetime.Precision;
+import com.flowingcode.vaadin.addons.relativetime.RelativeTime;
+import com.flowingcode.vaadin.addons.relativetime.Tense;
+import com.flowingcode.vaadin.addons.relativetime.TimeZoneName;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.Locale;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class SerializationTest {
+
+ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(obj);
+ }
+ try (ObjectInputStream in =
+ new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
+ obj.getClass().cast(in.readObject());
+ }
+ }
+
+ @Test
+ public void testSerialization() throws ClassNotFoundException, IOException {
+ try {
+ testSerializationOf(new RelativeTime());
+ } catch (Exception e) {
+ Assert.fail("Problem while testing serialization: " + e.getMessage());
+ }
+ }
+
+ @Test
+ public void testSerializationOfConfiguredInstance() throws ClassNotFoundException, IOException {
+ try {
+ RelativeTime configured =
+ new RelativeTime(Instant.parse("2026-01-01T00:00:00Z"))
+ .setTense(Tense.FUTURE)
+ .setFormat(Format.RELATIVE)
+ .setPrecision(Precision.DAY)
+ .setFormatStyle(FormatStyle.LONG)
+ .setThreshold(Duration.ofDays(30))
+ .setPrefix("")
+ .setNoTitle(true)
+ .setLocale(Locale.forLanguageTag("es-ES"))
+ .setTimeZone(ZoneId.of("America/New_York"))
+ .setTimeZoneName(TimeZoneName.SHORT_OFFSET)
+ .setYear(DateTimePartStyle.NUMERIC)
+ .setMonth(DateTimePartStyle.LONG)
+ .setDay(DateTimePartStyle.TWO_DIGIT)
+ .setWeekday(DateTimePartStyle.SHORT)
+ .setHour(DateTimePartStyle.TWO_DIGIT)
+ .setMinute(DateTimePartStyle.TWO_DIGIT)
+ .setSecond(DateTimePartStyle.TWO_DIGIT);
+ testSerializationOf(configured);
+ } catch (Exception e) {
+ Assert.fail("Problem while testing serialization: " + e.getMessage());
+ }
+ }
+}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/template/test/SerializationTest.java
deleted file mode 100644
index 1f8ceed..0000000
--- a/src/test/java/com/flowingcode/vaadin/addons/template/test/SerializationTest.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*-
- * #%L
- * Template Add-on
- * %%
- * Copyright (C) 2025 Flowing Code
- * %%
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * #L%
- */
-package com.flowingcode.vaadin.addons.template.test;
-
-import com.flowingcode.vaadin.addons.template.TemplateAddon;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class SerializationTest {
-
- private void testSerializationOf(Object obj) throws IOException, ClassNotFoundException {
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
- oos.writeObject(obj);
- }
- try (ObjectInputStream in =
- new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
- obj.getClass().cast(in.readObject());
- }
- }
-
- @Test
- public void testSerialization() throws ClassNotFoundException, IOException {
- try {
- testSerializationOf(new TemplateAddon());
- } catch (Exception e) {
- Assert.fail("Problem while testing serialization: " + e.getMessage());
- }
- }
-}
diff --git a/src/test/resources/META-INF/frontend/styles/relative-time-add-on-demo-styles.css b/src/test/resources/META-INF/frontend/styles/relative-time-add-on-demo-styles.css
new file mode 100644
index 0000000..e22db2b
--- /dev/null
+++ b/src/test/resources/META-INF/frontend/styles/relative-time-add-on-demo-styles.css
@@ -0,0 +1,134 @@
+/*-
+ * #%L
+ * Relative Time Add-On
+ * %%
+ * Copyright (C) 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+/* Demo styles.
+ Every property uses a Lumo -> Vaadin base -> literal fallback chain so the
+ demos look correct under Lumo, Aura, and the bare Vaadin base theme. */
+
+/* Row helper (used in RelativeTimeDemo basic-demo rows) */
+.relative-time-demo-row {
+ padding: var(--lumo-space-xs, var(--vaadin-padding-xs, 0.25rem)) 0;
+}
+
+.relative-time-demo-row-label {
+ min-width: 22em;
+ color: var(--lumo-secondary-text-color,
+ var(--vaadin-text-color-secondary, #6b7280));
+}
+
+/* Shared separator (works in Lumo, Aura, base) */
+/* Rendered as a (not
) so theme-specific hr styling can't hide it.
+ align-self/flex-shrink are needed because a VerticalLayout is a flex column;
+ without them a 1px row gets squashed to zero or rendered at content-only
+ width. */
+.relative-time-demo-separator {
+ display: block;
+ align-self: stretch;
+ flex: 0 0 1px;
+ height: 1px;
+ background-color: var(--lumo-contrast-20pct,
+ var(--vaadin-border-color-secondary, rgba(0, 0, 0, 0.2)));
+ margin: var(--lumo-space-s, var(--vaadin-padding-s, 0.5rem)) 0;
+}
+
+/* Use-case block helper */
+.relative-time-demo-use-case-title {
+ font-weight: bold;
+ font-size: 1.05em;
+}
+
+.relative-time-demo-use-case-content {
+ margin-top: var(--lumo-space-s, var(--vaadin-padding-s, 0.5rem));
+}
+
+.relative-time-demo-use-case-explanation {
+ font-size: 0.9em;
+ color: var(--lumo-secondary-text-color,
+ var(--vaadin-text-color-secondary, #6b7280));
+ margin-top: var(--lumo-space-s, var(--vaadin-padding-s, 0.5rem));
+}
+
+/* Chat messages */
+.relative-time-demo-chat {
+ max-width: 32em;
+}
+
+.relative-time-demo-chat-bubble {
+ padding: var(--lumo-space-s, var(--vaadin-padding-s, 0.5rem))
+ var(--lumo-space-m, var(--vaadin-padding-m, 1rem));
+ border-radius: var(--lumo-border-radius-l, var(--vaadin-radius-l, 0.75rem));
+ max-width: 75%;
+}
+
+.relative-time-demo-chat-bubble-sent {
+ background: var(--lumo-primary-color, #1676f3);
+ color: var(--lumo-primary-contrast-color, #fff);
+}
+
+.relative-time-demo-chat-bubble-received {
+ background: var(--lumo-contrast-10pct,
+ var(--vaadin-background-container, rgba(0, 0, 0, 0.06)));
+ color: var(--lumo-body-text-color, var(--vaadin-text-color, inherit));
+}
+
+.relative-time-demo-chat-bubble-stamp {
+ font-size: 0.75em;
+ display: block;
+ text-align: right;
+ margin-top: var(--lumo-space-xs, var(--vaadin-padding-xs, 0.25rem));
+ opacity: 0.75;
+}
+
+/* Event card */
+.relative-time-demo-event-card {
+ border: 1px solid var(--lumo-contrast-20pct,
+ var(--vaadin-border-color-secondary, rgba(0, 0, 0, 0.12)));
+ border-radius: var(--lumo-border-radius-m, var(--vaadin-radius-m, 0.25rem));
+ max-width: 20em;
+}
+
+.relative-time-demo-event-card-title {
+ font-weight: bold;
+ font-size: 1.1em;
+}
+
+.relative-time-demo-event-card-location {
+ color: var(--lumo-secondary-text-color,
+ var(--vaadin-text-color-secondary, #6b7280));
+}
+
+/* Stopwatch */
+.relative-time-demo-stopwatch-placeholder {
+ color: var(--lumo-secondary-text-color,
+ var(--vaadin-text-color-secondary, #6b7280));
+}
+
+/* Session expiry banner */
+.relative-time-demo-session-banner {
+ background: var(--lumo-error-color-10pct, rgba(220, 38, 38, 0.08));
+ border: 1px solid var(--lumo-error-color-50pct, rgba(220, 38, 38, 0.4));
+ border-radius: var(--lumo-border-radius-m, var(--vaadin-radius-m, 0.25rem));
+ padding: var(--lumo-space-s, var(--vaadin-padding-s, 0.5rem));
+ max-width: 32em;
+}
+
+.relative-time-demo-session-banner-icon {
+ margin-right: var(--lumo-space-s, var(--vaadin-padding-s, 0.5rem));
+}
diff --git a/src/test/resources/META-INF/frontend/styles/shared-styles.css b/src/test/resources/META-INF/frontend/styles/shared-styles.css
deleted file mode 100644
index 6680e2d..0000000
--- a/src/test/resources/META-INF/frontend/styles/shared-styles.css
+++ /dev/null
@@ -1 +0,0 @@
-/*Demo styles*/
\ No newline at end of file