@purpurds/countdown 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"countdown.types.d.ts","sourceRoot":"","sources":["../src/countdown.types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,MAAM,MAAM,cAAc,GAAG;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,IAAI,EAAE,IAAI,GAAG,IAAI,CAAC;IAClB,OAAO,EAAE,SAAS,GAAG,WAAW,CAAC;IAEjC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB;;OAEG;IACH,SAAS,EAAE,IAAI,GAAG,MAAM,CAAC;IACzB;;OAEG;IACH,OAAO,EAAE,IAAI,GAAG,MAAM,CAAC;IAEvB;;OAEG;IACH,iBAAiB,CAAC,EAAE,CAClB,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,mBAAmB,GAAG,gBAAgB,CAAC,KAChE,SAAS,CAAC;IACf;;OAEG;IACH,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,mBAAmB,GAAG,gBAAgB,CAAC,KAChE,SAAS,CAAC;IAEf;;;OAGG;IACH,aAAa,EAAE,aAAa,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC"}
@@ -0,0 +1,12 @@
1
+ import { default as React } from 'react';
2
+ import { CounterLabels } from './countdown.types';
3
+
4
+ interface CounterProps {
5
+ end: number;
6
+ size: "md" | "lg";
7
+ tag: "days" | "hours" | "minutes" | "seconds";
8
+ counterLabels: CounterLabels;
9
+ }
10
+ export declare const Counter: ({ tag, size, end, counterLabels }: CounterProps) => React.JSX.Element;
11
+ export {};
12
+ //# sourceMappingURL=counter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"counter.d.ts","sourceRoot":"","sources":["../src/counter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAIxE,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAQvD,UAAU,YAAY;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,IAAI,GAAG,IAAI,CAAC;IAClB,GAAG,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;IAC9C,aAAa,EAAE,aAAa,CAAC;CAC9B;AAID,eAAO,MAAM,OAAO,GAAI,mCAAmC,YAAY,sBA6CtE,CAAC"}
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ "moduleName": "countdown",
3
+ "exports": [
4
+ "Countdown"
5
+ ]
6
+ };
@@ -0,0 +1 @@
1
+ ._purpur-countdown--size-md_1ljp4_1{--counter-height: calc(56 * var(--purpur-spacing-10));--counter-width: calc(56 * var(--purpur-spacing-10))}._purpur-countdown--size-lg_1ljp4_5{--counter-height: calc(72 * var(--purpur-spacing-10));--counter-width: calc(80 * var(--purpur-spacing-10))}._purpur-countdown_1ljp4_1{--text-color: var(--purpur-color-text-default)}._purpur-countdown--variant-primary_1ljp4_12{--bg-color: var(--purpur-color-background-primary)}._purpur-countdown--variant-secondary_1ljp4_15{--bg-color: var(--purpur-color-background-secondary)}._purpur-countdown--negative_1ljp4_18{--text-color: var(--purpur-color-text-default-negative);--bg-color: var(--purpur-color-background-interactive-read-only-negative)}._purpur-countdown--fullwidth_1ljp4_22{--counter-width: 1fr}._purpur-countdown_1ljp4_1{--counter-count: 3}._purpur-countdown--showdays_1ljp4_28{--counter-count: 4}._purpur-countdown_1ljp4_1{display:grid;gap:var(--purpur-spacing-150);color:var(--text-color)}._purpur-countdown_1ljp4_1>._purpur-countdown__counter-container_1ljp4_36{display:grid;grid:var(--counter-height)/repeat(var(--counter-count),var(--counter-width));gap:var(--purpur-spacing-200)}._purpur-countdown_1ljp4_1>._purpur-countdown__label_1ljp4_41{color:inherit}._purpur-countdown-counter--size-lg_vx98f_1{font-family:var(--purpur-typography-family-display);font-weight:var(--purpur-typography-weight-normal);font-size:clamp(var(--purpur-typography-scale-400),3cqw,var(--purpur-typography-scale-600));line-height:var(--purpur-typography-line-height-snug)}._purpur-countdown-counter--size-md_vx98f_7{font-family:var(--purpur-typography-family-display);font-weight:var(--purpur-typography-weight-normal);font-size:clamp(var(--purpur-typography-scale-150),3cqw,var(--purpur-typography-scale-200));line-height:var(--purpur-typography-line-height-snug)}._purpur-countdown-counter_vx98f_1{display:grid;justify-items:center;align-items:center;padding:var(--purpur-spacing-100);border-radius:var(--purpur-border-radius-md);background-color:var(--bg-color);color:var(--text-color);overflow:hidden}._purpur-countdown-counter_vx98f_1>._purpur-countdown-counter__number_vx98f_23{position:relative;width:100%;color:inherit;text-align:center}@media (prefers-reduced-motion: no-preference){._purpur-countdown-counter_vx98f_1>._purpur-countdown-counter__number_vx98f_23[data-tick]{text-align:center;animation:_tick-animation_vx98f_1 .15s ease-out forwards}._purpur-countdown-counter_vx98f_1>._purpur-countdown-counter__number_vx98f_23[data-tick]:after{content:attr(data-tick);position:absolute;bottom:100%;inset-inline:0;color:inherit;animation:_tick-animation-pseudo_vx98f_1 .25s forwards}}._purpur-countdown-counter_vx98f_1>._purpur-countdown-counter__label_vx98f_44{font-family:var(--purpur-typography-family-default);font-weight:var(--purpur-typography-weight-normal);font-size:var(--purpur-typography-scale-75);line-height:var(--purpur-typography-line-height-loose);color:inherit}@keyframes _tick-animation_vx98f_1{0%{transform:translateY(100%);color:transparent}to{transform:translateY(0);color:var(--text-color)}}@keyframes _tick-animation-pseudo_vx98f_1{0%{color:var(--text-color)}to{color:transparent}}
@@ -0,0 +1,14 @@
1
+ export declare function getDiffs(endtime: number): {
2
+ seconds: number;
3
+ minutes: number;
4
+ hours: number;
5
+ days: number;
6
+ };
7
+ export declare function getNext(time: number, tag: string): number;
8
+ export declare const inMs: {
9
+ day: number;
10
+ hour: number;
11
+ minute: number;
12
+ second: number;
13
+ };
14
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM;;;;;EA4BvC;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,UAOhD;AAED,eAAO,MAAM,IAAI;;;;;CAKhB,CAAC"}
@@ -0,0 +1,2 @@
1
+ import purpurCommon from "@purpurds/component-rig/eslint.config.mjs";
2
+ export default purpurCommon;
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@purpurds/countdown",
3
+ "version": "0.0.1",
4
+ "license": "AGPL-3.0-only",
5
+ "main": "./dist/countdown.cjs.js",
6
+ "types": "./dist/countdown.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "require": "./dist/countdown.cjs.js",
10
+ "types": "./dist/countdown.d.ts",
11
+ "default": "./dist/countdown.es.js"
12
+ },
13
+ "./styles": "./dist/styles.css",
14
+ "./metadata": "./dist/metadata.js"
15
+ },
16
+ "source": "src/countdown.tsx",
17
+ "dependencies": {
18
+ "classnames": "~2.5.0",
19
+ "@purpurds/heading": "8.7.0",
20
+ "@purpurds/paragraph": "8.7.0",
21
+ "@purpurds/tokens": "8.7.0",
22
+ "@purpurds/visually-hidden": "8.7.0"
23
+ },
24
+ "devDependencies": {
25
+ "@rushstack/eslint-patch": "~1.10.0",
26
+ "@storybook/react": "^9.0.18",
27
+ "@telia/react-rig": "*",
28
+ "@testing-library/dom": "~10.4.0",
29
+ "@testing-library/jest-dom": "~6.4.0",
30
+ "@testing-library/react": "~16.2.0",
31
+ "@types/node": "22.17",
32
+ "@types/react-dom": "^19.0.4",
33
+ "@types/react": "^19.0.10",
34
+ "eslint": "9.24.0",
35
+ "jsdom": "~22.1.0",
36
+ "lint-staged": "15.5.0",
37
+ "prettier": "~2.8.8",
38
+ "react": "^19.0.0",
39
+ "react-dom": "^19.0.0",
40
+ "storybook": "^9.0.18",
41
+ "typescript": "^5.6.3",
42
+ "vite": "^6.2.1",
43
+ "vitest": "^3.1.2",
44
+ "@purpurds/component-rig": "1.0.0"
45
+ },
46
+ "peerDependencies": {
47
+ "@types/react": "^18 || ^19",
48
+ "@types/react-dom": "^18 || ^19",
49
+ "react": "^18 || ^19",
50
+ "react-dom": "^18 || ^19"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@types/react": {
54
+ "optional": true
55
+ },
56
+ "@types/react-dom": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "scripts": {
61
+ "build:dev": "vite",
62
+ "build:watch": "vite build --watch",
63
+ "build": "vite build",
64
+ "ci:build": "rushx build",
65
+ "coverage": "vitest run --coverage",
66
+ "lint:fix": "eslint . --fix",
67
+ "lint": "lint-staged --no-stash 2>&1",
68
+ "sbdev": "rush sbdev",
69
+ "test:unit": "vitest run --passWithNoTests",
70
+ "test:watch": "vitest --watch",
71
+ "test": "rushx test:unit",
72
+ "typecheck": "tsc -p ./tsconfig.json"
73
+ }
74
+ }
@@ -0,0 +1,60 @@
1
+ $root: ".purpur-countdown";
2
+
3
+ @mixin size {
4
+ &--size-md {
5
+ --counter-height: calc(56 * var(--purpur-spacing-10));
6
+ --counter-width: calc(56 * var(--purpur-spacing-10));
7
+ }
8
+ &--size-lg {
9
+ --counter-height: calc(72 * var(--purpur-spacing-10));
10
+ --counter-width: calc(80 * var(--purpur-spacing-10));
11
+ }
12
+ }
13
+ @mixin variant {
14
+ --text-color: var(--purpur-color-text-default);
15
+
16
+ &--variant-primary {
17
+ --bg-color: var(--purpur-color-background-primary);
18
+ }
19
+ &--variant-secondary {
20
+ --bg-color: var(--purpur-color-background-secondary);
21
+ }
22
+
23
+ &--negative {
24
+ --text-color: var(--purpur-color-text-default-negative);
25
+ --bg-color: var(--purpur-color-background-interactive-read-only-negative);
26
+ }
27
+ }
28
+ @mixin fullwidth {
29
+ &--fullwidth {
30
+ --counter-width: 1fr;
31
+ }
32
+ }
33
+ @mixin showdays {
34
+ --counter-count: 3;
35
+
36
+ &--showdays {
37
+ --counter-count: 4;
38
+ }
39
+ }
40
+
41
+ #{$root} {
42
+ @include size();
43
+ @include variant();
44
+ @include fullwidth();
45
+ @include showdays();
46
+
47
+ display: grid;
48
+ gap: var(--purpur-spacing-150);
49
+ color: var(--text-color);
50
+
51
+ & > #{$root}__counter-container {
52
+ display: grid;
53
+ grid: var(--counter-height) / repeat(var(--counter-count), var(--counter-width));
54
+ gap: var(--purpur-spacing-200);
55
+ }
56
+
57
+ & > #{$root}__label {
58
+ color: inherit;
59
+ }
60
+ }
@@ -0,0 +1,96 @@
1
+ import React from "react";
2
+ import { Paragraph } from "@purpurds/paragraph";
3
+ import type { Meta, StoryObj } from "@storybook/react";
4
+
5
+ import { Countdown } from "./countdown";
6
+
7
+ const meta = {
8
+ title: "Feedback Indicators/Countdown",
9
+ component: Countdown,
10
+ parameters: {
11
+ design: [
12
+ {
13
+ name: "Countdown",
14
+ type: "figma",
15
+ url: "https://www.figma.com/design/I93mUmNJbRz0ev4kpenxZl/Countdown?node-id=184-11979&p=f&t=JFmTvsUyZ3jAKGH0-0",
16
+ },
17
+ ],
18
+ },
19
+ } satisfies Meta<typeof Countdown>;
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof Countdown>;
23
+
24
+ const bg = {
25
+ primary: (negative: boolean) =>
26
+ negative
27
+ ? "var(--purpur-color-background-primary-negative)"
28
+ : "var(--purpur-color-background-secondary)",
29
+ secondary: (negative: boolean) =>
30
+ negative
31
+ ? "var(--purpur-color-background-secondary-negative)"
32
+ : "var(--purpur-color-background-primary)",
33
+ } as const;
34
+
35
+ export const Showcase: Story = {
36
+ decorators: [
37
+ (Story, ctx) => {
38
+ return (
39
+ <div
40
+ style={{
41
+ padding: "var(--purpur-spacing-800)",
42
+ backgroundColor: bg[ctx.args.variant](!!ctx.args.negative),
43
+ }}
44
+ >
45
+ <Story
46
+ args={{
47
+ ...ctx.args,
48
+ counterLabels: {
49
+ days: "Days",
50
+ hours: "Hours",
51
+ minutes: ctx.args.size === "lg" ? "Minutes" : "Min",
52
+ seconds: ctx.args.size === "lg" ? "Seconds" : "Sec",
53
+ },
54
+ }}
55
+ />
56
+ </div>
57
+ );
58
+ },
59
+ ],
60
+ argTypes: {
61
+ starttime: { control: "date" },
62
+ endtime: { control: "date" },
63
+ },
64
+ args: {
65
+ variant: "primary",
66
+ negative: false,
67
+ fullWidth: true,
68
+ showDays: true,
69
+ size: "lg",
70
+ label: "Time left until Christmas",
71
+
72
+ starttime: new Date(),
73
+ get endtime() {
74
+ const now = new Date();
75
+ const christmas = new Date(`${now.getFullYear()}-12-24T00:00:00`);
76
+ return christmas > now ? christmas : new Date(`${now.getFullYear() + 1}-12-24T00:00:00`);
77
+ },
78
+
79
+ "aria-label": "Time remaining for special sale offer",
80
+ renderAfterEnd(props) {
81
+ return (
82
+ <Paragraph negative={props.negative}>
83
+ 🎉 The wait is over! Happy Holidays! 🎉 {props.variant}
84
+ </Paragraph>
85
+ );
86
+ },
87
+ renderBeforeStart(props) {
88
+ return (
89
+ <Paragraph negative={props.negative}>
90
+ 🎄 Countdown to Christmas has not started yet! 🎄 {props.variant}
91
+ </Paragraph>
92
+ );
93
+ },
94
+ "data-testid": "countdown",
95
+ },
96
+ };
@@ -0,0 +1,122 @@
1
+ import React from "react";
2
+ import * as matchers from "@testing-library/jest-dom/matchers";
3
+ import { cleanup, render, screen } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { Countdown } from "./countdown";
7
+ import { type CountdownProps } from "./countdown.types";
8
+
9
+ expect.extend(matchers);
10
+
11
+ const tags = ["days", "hours", "minutes", "seconds"] as const;
12
+
13
+ describe("Countdown", () => {
14
+ const defaultProps: CountdownProps = {
15
+ "data-testid": "countdown",
16
+ label: "Countdown Timer",
17
+ starttime: new Date(Date.now() - 1000), // 1 second ago
18
+ endtime: new Date(Date.now() + 10000), // 10 seconds from now
19
+ size: "lg",
20
+ variant: "primary",
21
+ fullWidth: true,
22
+ showDays: true,
23
+ negative: false,
24
+ "aria-label": "Countdown timer",
25
+ counterLabels: {
26
+ days: "days",
27
+ hours: "hours",
28
+ minutes: "minutes",
29
+ seconds: "seconds",
30
+ },
31
+ };
32
+
33
+ afterEach(() => {
34
+ cleanup();
35
+ });
36
+
37
+ // Default props testing
38
+
39
+ it("renders the countdown component with label", () => {
40
+ render(<Countdown {...defaultProps} />);
41
+
42
+ expect(screen.getByRole("timer")).toBeInTheDocument();
43
+ });
44
+
45
+ it("renders the correct aria-labels", () => {
46
+ render(<Countdown {...defaultProps} />);
47
+
48
+ expect(screen.getByTestId("live-region")).toHaveAttribute(
49
+ "aria-label",
50
+ defaultProps["aria-label"]
51
+ );
52
+ });
53
+
54
+ it("renders the counter labels correctly", () => {
55
+ render(<Countdown {...defaultProps} />);
56
+
57
+ for (const tag of tags) {
58
+ expect(screen.getByTestId(`counter-${tag}`)).toHaveTextContent(
59
+ defaultProps.counterLabels[tag]
60
+ );
61
+ }
62
+ });
63
+
64
+ it("announces the time remaining in the live region", () => {
65
+ render(<Countdown {...defaultProps} />);
66
+
67
+ expect(screen.getByTestId("live-region")).toHaveAttribute("aria-live", "polite");
68
+ expect(screen.getByTestId("live-region")).toHaveTextContent(/days|hours|minutes|seconds/);
69
+ });
70
+
71
+ it("applies the correct class names based on props", () => {
72
+ render(<Countdown {...defaultProps} />);
73
+
74
+ const section = screen.getByRole("timer");
75
+
76
+ expect(section).toHaveClass("purpur-countdown");
77
+ expect(section).toHaveClass("purpur-countdown--fullwidth");
78
+ expect(section).not.toHaveClass("purpur-countdown--negative");
79
+ expect(section).toHaveClass("purpur-countdown--size-lg");
80
+ expect(section).toHaveClass("purpur-countdown--variant-primary");
81
+ });
82
+
83
+ // Custom props testing
84
+
85
+ it("renders nothing if the countdown has not started and no renderBeforeStart is provided", () => {
86
+ const props = {
87
+ ...defaultProps,
88
+ starttime: new Date(Date.now() + 10000), // 10 seconds from now
89
+ };
90
+ const { container } = render(<Countdown {...props} />);
91
+ expect(container.firstChild).toBeNull();
92
+ });
93
+
94
+ it("renders the renderBeforeStart content if provided and countdown has not started", () => {
95
+ const props = {
96
+ ...defaultProps,
97
+ starttime: new Date(Date.now() + 10000), // 10 seconds from now
98
+ renderBeforeStart: () => <div>Countdown not started</div>,
99
+ };
100
+ render(<Countdown {...props} />);
101
+ expect(screen.getByText("Countdown not started")).toBeInTheDocument();
102
+ });
103
+
104
+ it("renders nothing if the countdown has ended and no renderAfterEnd is provided", () => {
105
+ const props = {
106
+ ...defaultProps,
107
+ endtime: new Date(Date.now() - 10000), // 10 seconds ago
108
+ };
109
+ const { container } = render(<Countdown {...props} />);
110
+ expect(container.firstChild).toBeNull();
111
+ });
112
+
113
+ it("renders the renderAfterEnd content if provided and countdown has ended", () => {
114
+ const props = {
115
+ ...defaultProps,
116
+ endtime: new Date(Date.now() - 10000), // 10 seconds ago
117
+ renderAfterEnd: () => <div>Countdown ended</div>,
118
+ };
119
+ render(<Countdown {...props} />);
120
+ expect(screen.getByText("Countdown ended")).toBeInTheDocument();
121
+ });
122
+ });
@@ -0,0 +1,124 @@
1
+ import React, { type ForwardedRef, forwardRef, useId } from "react";
2
+ import { Paragraph } from "@purpurds/paragraph";
3
+ import { VisuallyHidden } from "@purpurds/visually-hidden";
4
+ import c from "classnames/bind";
5
+
6
+ import styles from "./countdown.module.scss";
7
+ import { type CountdownProps } from "./countdown.types";
8
+ import { Counter } from "./counter";
9
+ import { getDiffs } from "./utils";
10
+
11
+ const cx = c.bind(styles);
12
+
13
+ const rootClassName = "purpur-countdown";
14
+
15
+ const now = new Date();
16
+
17
+ /**
18
+ * Countdown component to display a countdown timer.
19
+ * @param props - CountdownProps
20
+ * @returns A React component that displays a countdown timer.
21
+ */
22
+
23
+ export const Countdown = forwardRef(
24
+ (
25
+ { renderAfterEnd, renderBeforeStart, ...props }: CountdownProps,
26
+ ref: ForwardedRef<HTMLButtonElement>
27
+ ) => {
28
+ const id = useId();
29
+
30
+ const {
31
+ size = "lg",
32
+ fullWidth = false,
33
+ negative = false,
34
+ showDays = true,
35
+ variant = "primary",
36
+ className,
37
+ counterLabels,
38
+ } = props;
39
+
40
+ const start = new Date(props.starttime).getTime();
41
+ const end = new Date(props.endtime).getTime();
42
+
43
+ const hasNotStarted = start > now.getTime();
44
+ const hasEnded = end < now.getTime();
45
+
46
+ if (hasNotStarted) return renderBeforeStart ? renderBeforeStart(props) : null;
47
+ if (hasEnded) return renderAfterEnd ? renderAfterEnd(props) : null;
48
+
49
+ const getCounters = () => {
50
+ const counterProps = { size, end, counterLabels };
51
+
52
+ return (
53
+ <>
54
+ {showDays && <Counter tag="days" {...counterProps} />}
55
+ <Counter tag="hours" {...counterProps} />
56
+ <Counter tag="minutes" {...counterProps} />
57
+ <Counter tag="seconds" {...counterProps} />
58
+ </>
59
+ );
60
+ };
61
+
62
+ // Create a single announcement of the current time (only once)
63
+
64
+ const renderAnnouncer = () => {
65
+ const getTimeAnnouncement = () => {
66
+ const { days, hours, minutes, seconds } = getDiffs(end);
67
+
68
+ const parts = [];
69
+
70
+ if (showDays) parts.push(`${days} ${counterLabels.days}`);
71
+ parts.push(`${hours} ${counterLabels.hours}`);
72
+ parts.push(`${minutes} ${counterLabels.minutes}`);
73
+ parts.push(`${seconds} ${counterLabels.seconds}`);
74
+
75
+ return parts.join(", ");
76
+ };
77
+
78
+ return (
79
+ <VisuallyHidden
80
+ data-testid="live-region"
81
+ aria-live="polite"
82
+ aria-label={props["aria-label"]}
83
+ >
84
+ {getTimeAnnouncement()}
85
+ </VisuallyHidden>
86
+ );
87
+ };
88
+
89
+ const counters = getCounters();
90
+
91
+ const classes = cx([
92
+ rootClassName,
93
+ `${rootClassName}--size-${size}`,
94
+ `${rootClassName}--variant-${variant}`,
95
+ {
96
+ [`${rootClassName}--fullwidth`]: fullWidth,
97
+ [`${rootClassName}--negative`]: negative,
98
+ [`${rootClassName}--showdays`]: showDays,
99
+ },
100
+ className,
101
+ ]);
102
+
103
+ return (
104
+ <section
105
+ id={id}
106
+ className={classes}
107
+ ref={ref}
108
+ role="timer"
109
+ data-testid={props["data-testid"]}
110
+ >
111
+ {/* Visually hidden live region that announces time only once */}
112
+ {renderAnnouncer()}
113
+ {props.label && (
114
+ <Paragraph className={cx(`${rootClassName}__label`)} variant="paragraph-100-medium">
115
+ {props.label}
116
+ </Paragraph>
117
+ )}
118
+ <div className={cx(`${rootClassName}__counter-container`)}>{counters}</div>
119
+ </section>
120
+ );
121
+ }
122
+ );
123
+
124
+ Countdown.displayName = "Countdown";
@@ -0,0 +1,50 @@
1
+ import { type ReactNode } from "react";
2
+
3
+ export type CountdownProps = {
4
+ "data-testid"?: string;
5
+ "aria-label": string;
6
+ className?: string;
7
+ label?: string;
8
+
9
+ size: "md" | "lg";
10
+ variant: "primary" | "secondary";
11
+
12
+ fullWidth?: boolean;
13
+ showDays?: boolean;
14
+ negative?: boolean;
15
+
16
+ /**
17
+ * Start time of the countdown. Can be a Date object or a timestamp in milliseconds.
18
+ */
19
+ starttime: Date | number;
20
+ /**
21
+ * End time of the countdown. Can be a Date object or a timestamp in milliseconds.
22
+ */
23
+ endtime: Date | number;
24
+
25
+ /**
26
+ * Render a custom element before the countdown starts.
27
+ */
28
+ renderBeforeStart?: (
29
+ props: Omit<CountdownProps, "renderBeforeStart" | "renderAfterEnd">
30
+ ) => ReactNode;
31
+ /**
32
+ * Render a custom element after the countdown ends.
33
+ */
34
+ renderAfterEnd?: (
35
+ props: Omit<CountdownProps, "renderBeforeStart" | "renderAfterEnd">
36
+ ) => ReactNode;
37
+
38
+ /**
39
+ * Copy for the countdown labels.
40
+ * Used to customize the text displayed for each time unit.
41
+ */
42
+ counterLabels: CounterLabels;
43
+ };
44
+
45
+ export type CounterLabels = {
46
+ days: string;
47
+ hours: string;
48
+ minutes: string;
49
+ seconds: string;
50
+ };
@@ -0,0 +1,81 @@
1
+ @use "@purpurds/heading/src/heading.mixins.scss" as heading;
2
+ @use "@purpurds/paragraph/src/paragraph.mixins.scss" as paragraph;
3
+
4
+ $root: ".purpur-countdown-counter";
5
+
6
+ @mixin size {
7
+ &--size-lg {
8
+ @include heading.purpur-display-15();
9
+ }
10
+ &--size-md {
11
+ @include heading.purpur-display-5();
12
+ }
13
+ }
14
+
15
+ #{$root} {
16
+ @include size();
17
+
18
+ display: grid;
19
+ justify-items: center;
20
+ align-items: center;
21
+
22
+ padding: var(--purpur-spacing-100);
23
+ border-radius: var(--purpur-border-radius-md);
24
+ background-color: var(--bg-color);
25
+ color: var(--text-color);
26
+
27
+ overflow: hidden;
28
+
29
+ & > #{$root}__number {
30
+ position: relative;
31
+
32
+ width: 100%;
33
+
34
+ color: inherit;
35
+ text-align: center;
36
+
37
+ /* Respect reduced motion preferences */
38
+ @media (prefers-reduced-motion: no-preference) {
39
+ &[data-tick] {
40
+ text-align: center;
41
+ animation: tick-animation 0.15s ease-out forwards;
42
+
43
+ &::after {
44
+ content: attr(data-tick);
45
+ position: absolute;
46
+ bottom: 100%;
47
+ inset-inline: 0;
48
+
49
+ color: inherit;
50
+
51
+ animation: tick-animation-pseudo 0.25s forwards;
52
+ }
53
+ }
54
+ }
55
+ }
56
+ & > #{$root}__label {
57
+ @include paragraph.purpur-additional-100();
58
+
59
+ color: inherit;
60
+ }
61
+ }
62
+
63
+ @keyframes tick-animation {
64
+ 0% {
65
+ transform: translateY(100%);
66
+ color: transparent;
67
+ }
68
+ 100% {
69
+ transform: translateY(0%);
70
+ color: var(--text-color);
71
+ }
72
+ }
73
+
74
+ @keyframes tick-animation-pseudo {
75
+ 0% {
76
+ color: var(--text-color);
77
+ }
78
+ 100% {
79
+ color: transparent;
80
+ }
81
+ }