Skip to content

Commit

Permalink
feat(countdown): add components (#12099)
Browse files Browse the repository at this point in the history
### Related Ticket(s)

[ADCMS-6655](https://jsw.ibm.com/browse/ADCMS-6655)

### Description

Migrates the `caem-countdown` component from carbon-for-aem into carbon-for-ibmdotcom as `c4d-countdown` component.

### Changelog

**New**

- Add new `c4d-countdown` component
  • Loading branch information
Valentin-Sorin-Nicolae authored Nov 13, 2024
1 parent 417143c commit 27bf4a3
Show file tree
Hide file tree
Showing 7 changed files with 436 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Meta, Props, Story, Canvas, Description } from '@storybook/addon-docs';
import { cdnJs } from '../../../globals/internal/storybook-cdn';
import '../index.ts';

<Meta title="Countdown" />

# Countdown

The countdown component will present a countdown to a target date and time. The
target date can be passed along as a date time string that is
[accepted by the Date constructor in Javascript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format),
i.e. ISO 8601. It includes support for limited formatting options as well as a
custom separator.

<Canvas withToolbar>
<Story id="components-countdown--default" height="100px" />
</Canvas>

<Description markdown={`${cdnJs({ components: ['countdown'] })}`} />

## `<c4d-countdown>` attributes, properties, and events

<Props of="c4d-countdown" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* @license
*
* Copyright IBM Corp. 2024
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import { select, date, text } from '@storybook/addon-knobs';
import textNullable from '../../../../.storybook/knob-text-nullable';
import { html } from 'lit';
import '../index';
import ifNonEmpty from '@carbon/web-components/es/globals/directives/if-non-empty.js';

import readme from './README.stories.mdx';

const msInDay = 86400000;
const twoWeeksFromNowTimestamp =
Number(new Date().getTime().toString()) + 14 * msInDay;
const twoWeeksFromNowISO = new Date(twoWeeksFromNowTimestamp);

export default {
title: 'Components/Countdown',
parameters: {
...readme.parameters,
hasStoryPadding: true,
knobs: {
Countdown: () => ({
targetDate: date('Target Date', twoWeeksFromNowISO),
separator: text('Separator', ', '),
labelType: select(
'Label Type',
['long', 'short', 'narrow', 'none'],
'long'
),
}),
},
},
decorators: [
(story) => html`
<div class="cds--grid">
<div class="cds--row">
<div class="cds--col-lg-16">${story()}</div>
</div>
</div>
`,
],
};

const Template = (args) => {
const { targetDate, separator, labelType } = args?.Countdown ?? {};

return html`
<c4d-countdown
target="${ifNonEmpty(targetDate)}"
separator="${ifNonEmpty(separator)}"
label-type="${ifNonEmpty(labelType)}"></c4d-countdown>
`;
};

export const Default = (args) => Template(args);

export const WithTimestamp = (args) => Template(args);

WithTimestamp.story = {
parameters: {
knobs: {
Countdown: () => ({
targetDate: textNullable(
'Timestamp',
twoWeeksFromNowTimestamp.toString()
),
separator: text('Separator', ', '),
labelType: select(
'Label Type',
['long', 'short', 'narrow', 'none'],
'long'
),
}),
},
},
};

export const InPromoBanner = (args) => {
return html`
<c4d-promo-banner>
<c4d-image
alt="Image alt text"
slot="image"
width="300"
height="300"
default-src="https://fpoimg.com/300x300?&bg_color=5396ee&text_color=161616">
<c4d-image-item
media="(min-width:1584px)"
srcset="https://fpoimg.com/600x600?&bg_color=ee5396&text_color=161616"></c4d-image-item>
<c4d-image-item
media="(min-width:1312px)"
srcset="https://fpoimg.com/400x400?&bg_color=53ee96&text_color=161616"></c4d-image-item>
</c4d-image>
<h5>${Template(args)}</h5>
<p>Optional short body text</p>
<c4d-button-cta
cta-type="local"
kind="tertiary"
slot="cta"
href="https://example.com"
>Call To Action</c4d-button-cta
>
</c4d-promo-banner>
`;
};
247 changes: 247 additions & 0 deletions packages/web-components/src/components/countdown/countdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* @license
*
* Copyright IBM Corp. 2024
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import { LitElement, html } from 'lit';
import { property, state } from 'lit/decorators.js';
import { carbonElement as customElement } from '@carbon/web-components/es/globals/decorators/carbon-element.js';
import settings from '@carbon/ibmdotcom-utilities/es/utilities/settings/settings.js';
import LocaleAPI from '@carbon/ibmdotcom-services/es/services/Locale/Locale.js';
import MediaQueryMixin, {
MQBreakpoints,
MQDirs,
} from '../../component-mixins/media-query/media-query';

const { stablePrefix: c4dPrefix } = settings;

const ms_per = {
second: 1000,
minute: 1000 * 60,
hour: 1000 * 60 * 60,
day: 1000 * 60 * 60 * 24,
};

const units = Object.keys(ms_per);

const getFormatters = (locale: Locale, labelType: UnitDisplay) => {
const lc_cc = `${locale.lc}-${locale.cc}`;

// The Typescript compiler sees unitDisplay as type
// "short" | "long" | "narrow" | undefined. Convert 'none' to undefined to
// avoid passing an unknown value to the unitDisplay option.
if (labelType === 'none') {
labelType = undefined;
}

return Object.fromEntries(
units.map((unit) => [
`to_${unit}s`,
new Intl.NumberFormat(lc_cc, {
style: 'unit',
unit,
unitDisplay: labelType,
minimumIntegerDigits: unit === 'day' ? 1 : 2,
// Force latin numerals. This fits for all current locales
// supported on ibm.com with the added benefit of forcing preferred
// latin numerals on sa-ar. In the future, if necessary, we could
// expose this options object as a property which we would merge with
// our defaults here, allowing clients to override our options.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
numberingSystem: 'latn',
}),
])
) as FormattersList;
};

type TimeDiff = {
days: number;
hours: number;
minutes: number;
seconds: number;
};

type FormattersList = {
to_days: Intl.NumberFormat;
to_hours: Intl.NumberFormat;
to_minutes: Intl.NumberFormat;
to_seconds: Intl.NumberFormat;
};

type Locale = {
lc: string;
cc: string;
};

type UnitDisplay = 'short' | 'narrow' | 'long' | 'none' | undefined;

/**
* The Countdown component.
* @element c4d-countdown
*/
@customElement(`${c4dPrefix}-countdown`)
class C4DCountdown extends MediaQueryMixin(LitElement, {
[MQBreakpoints.MD]: MQDirs.MIN,
}) {
@state()
isMdOrLarger = this.carbonBreakpoints.md.matches;

@state()
timeDiff?: TimeDiff;

@state()
targetDateTime?: number;

@state()
locale: Locale = {
lc: 'en',
cc: 'us',
};

/**
* The target date, either in date time string format (ISO 8601), or UNIX timestamp.
*/
@property({ attribute: 'target' })
targetInput?: string;

/**
* Optional date parts separator.
*/
@property({ attribute: 'separator' })
separator?: string;

/**
* Optional date parts label type. One of 'short', 'narrow', 'long', or 'none'.
*/
@property({ attribute: 'label-type' })
labelType: UnitDisplay = 'long';

_clock?;

_formatters: FormattersList = getFormatters(this.locale, this.labelType);

calculateDiff() {
const { targetDateTime } = this;
const now = Date.now();
let diff = (targetDateTime ?? 0) - now;

if (diff < 0) {
clearInterval(this._clock);

this.timeDiff = {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
};
return;
}

const days = Math.floor(diff / ms_per.day);
diff = diff - days * ms_per.day;

const hours = Math.floor(diff / ms_per.hour);
diff = diff - hours * ms_per.hour;

const minutes = Math.floor(diff / ms_per.minute);
diff = diff - minutes * ms_per.minute;

const seconds = Math.floor(diff / ms_per.second);

this.timeDiff = { days, hours, minutes, seconds };
}

_padInt(int: number, padLength: number) {
return int.toString().padStart(padLength, '0');
}

formatOutput(): string {
const {
_formatters: formatters,
timeDiff,
separator,
labelType,
isMdOrLarger,
_padInt: padInt,
} = this;

if (timeDiff === undefined) {
return '';
}

const { days, hours, minutes, seconds } = timeDiff;

let values;

if (labelType === 'none') {
values = [days, hours, minutes, seconds].join(separator ?? '');
} else {
const { to_days, to_hours, to_minutes, to_seconds } = formatters;

values = isMdOrLarger
? [
to_days.format(days),
to_hours.format(hours),
to_minutes.format(minutes),
to_seconds.format(seconds),
].join(separator ?? '')
: `${to_days.format(days)} ${padInt(hours, 2)}:${padInt(
minutes,
2
)}:${padInt(seconds, 2)}`;
}

return values;
}

protected mediaQueryCallbackMD() {
this.isMdOrLarger = this.carbonBreakpoints.md.matches;
}

async firstUpdated() {
super.firstUpdated();
this.style.display = 'contents';
this.locale = await LocaleAPI.getLocale();
}

updated(changedProperties) {
if (changedProperties.has('targetInput')) {
let target = new Date(this.targetInput as string);
const epochTime = parseInt(this.targetInput as string);

// If date is invalid and epochTime is a valid number, assume a valid
// epochTime and create a new Date from it.
if (isNaN(target.valueOf()) && !isNaN(epochTime)) {
target = new Date(epochTime);
}

if (!isNaN(target.valueOf())) {
this.targetDateTime = target.getTime();
}
}

if (changedProperties.has('targetDateTime')) {
clearInterval(this._clock);
this._clock = setInterval(this.calculateDiff.bind(this), 1000);
}

if (changedProperties.has('locale') || changedProperties.has('labelType')) {
this._formatters = getFormatters(this.locale, this.labelType);
}
}

createRenderRoot() {
return this;
}

render() {
return html` ${this.formatOutput()} `;
}
}

export default C4DCountdown;
10 changes: 10 additions & 0 deletions packages/web-components/src/components/countdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @license
*
* Copyright IBM Corp. 2024
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import './countdown';
Loading

0 comments on commit 27bf4a3

Please sign in to comment.