@purpurds/date-field 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,7 @@
1
+ module.exports = {
2
+ "moduleName": "date-field",
3
+ "exports": [
4
+ "DateFieldProps",
5
+ "DateField"
6
+ ]
7
+ };
@@ -0,0 +1 @@
1
+ ._purpur-text-field_3gl1k_1{position:relative;display:flex;flex-direction:column;gap:var(--purpur-spacing-50)}._purpur-text-field__label_3gl1k_7{display:flex;gap:var(--purpur-spacing-25);width:fit-content}._purpur-text-field__label-text_3gl1k_12{width:fit-content}._purpur-text-field__field-row_3gl1k_15{display:flex;width:100%}._purpur-text-field__frame_3gl1k_19{position:absolute;top:0;right:0;bottom:0;left:0;border-radius:var(--purpur-border-radius-sm);border:var(--purpur-border-width-xs) solid var(--purpur-color-border-interactive-subtle);pointer-events:none}._purpur-text-field__input-container_3gl1k_26{position:relative;display:flex;align-items:center;width:100%;border-radius:var(--purpur-border-radius-sm);background:var(--purpur-color-background-primary)}._purpur-text-field__input-container_3gl1k_26:has(:-internal-autofill-previewed),._purpur-text-field__input-container_3gl1k_26:has(:-internal-autofill-selected){background-color:var(--purpur-color-background-interactive-auto-fill)!important}._purpur-text-field__input-container--disabled_3gl1k_37{background:var(--purpur-color-background-interactive-disabled)}._purpur-text-field__input-container--readonly_3gl1k_40{background:var(--purpur-color-background-interactive-read-only)}._purpur-text-field__input-container--has-clear-button_3gl1k_43{padding-right:var(--purpur-spacing-25)}._purpur-text-field__input-container--end-adornment_3gl1k_46{padding-right:var(--purpur-spacing-150)}._purpur-text-field__input-container--end-adornment_3gl1k_46 ._purpur-text-field__input_3gl1k_26{padding-right:var(--purpur-spacing-100)}._purpur-text-field__input-container--start-adornment_3gl1k_52{padding-left:var(--purpur-spacing-150)}._purpur-text-field__input-container--start-adornment_3gl1k_52 ._purpur-text-field__input_3gl1k_26{padding-left:var(--purpur-spacing-100)}._purpur-text-field__input_3gl1k_26{border-radius:var(--purpur-border-radius-sm);padding:calc(var(--purpur-spacing-100) + var(--purpur-spacing-25)) var(--purpur-spacing-150);border:none;width:100%;box-sizing:border-box;font-family:var(--purpur-typography-family-default);font-size:var(--purpur-typography-scale-100);line-height:150%;outline:none;background:transparent;color:var(--purpur-color-text-default)}._purpur-text-field__input_3gl1k_26:hover~._purpur-text-field__frame_3gl1k_19{border-width:var(--purpur-border-width-sm);border-color:var(--purpur-color-border-interactive-subtle-hover)}._purpur-text-field__input_3gl1k_26:active:not(:disabled):not(:read-only)~._purpur-text-field__frame_3gl1k_19,._purpur-text-field__input_3gl1k_26:focus:not(:disabled):not(:read-only)~._purpur-text-field__frame_3gl1k_19{outline:var(--purpur-border-width-sm) solid var(--purpur-color-border-interactive-focus);outline-offset:calc(var(--purpur-spacing-10) * 2);border-width:var(--purpur-border-width-xs);border-color:var(--purpur-color-border-interactive-subtle-hover)}._purpur-text-field__input_3gl1k_26:disabled{color:var(--purpur-color-text-weak)}._purpur-text-field__input_3gl1k_26:disabled~._purpur-text-field__frame_3gl1k_19{border-width:var(--purpur-border-width-xs);border-color:var(--purpur-color-border-medium)}._purpur-text-field__input_3gl1k_26:read-only:not(:disabled){color:var(--purpur-color-text-default)}._purpur-text-field__input_3gl1k_26:read-only:not(:disabled)~._purpur-text-field__frame_3gl1k_19{border-width:var(--purpur-border-width-xs)}._purpur-text-field__input_3gl1k_26:read-only:not(:disabled):not(._purpur-text-field__input--valid_3gl1k_94):not(._purpur-text-field__input--error_3gl1k_94)~._purpur-text-field__frame_3gl1k_19{border-color:var(--purpur-color-border-medium)}._purpur-text-field__input_3gl1k_26:-internal-autofill-previewed,._purpur-text-field__input_3gl1k_26:-internal-autofill-selected{box-shadow:inset 0 0 0 1px transparent,inset 0 0 0 100px var(--purpur-color-background-interactive-auto-fill)}._purpur-text-field__input_3gl1k_26._purpur-text-field__input--valid_3gl1k_94~._purpur-text-field__frame_3gl1k_19{border-color:var(--purpur-color-border-status-success)}._purpur-text-field__input_3gl1k_26._purpur-text-field__input--error_3gl1k_94:not(:hover)~._purpur-text-field__frame_3gl1k_19{border-color:var(--purpur-color-border-status-error)}._purpur-text-field__adornment-container_3gl1k_106{display:flex;align-items:center;gap:var(--purpur-spacing-50)}._purpur-text-field__valid-icon_3gl1k_111{color:var(--purpur-color-text-status-success-medium)}._purpur-date-field__container_ci2f4_1{position:relative}._purpur-date-field__overlay_ci2f4_5{pointer-events:none;box-sizing:border-box;font-family:var(--purpur-typography-family-default);font-size:var(--purpur-typography-scale-100);font-weight:var(--purpur-typography-scale-400);color:var(--purpur-color-text-default);line-height:var(--purpur-typography-line-height-loose);white-space:pre}._purpur-date-field__start-adornment_ci2f4_16{color:var(--purpur-color-text-weak)}
@@ -0,0 +1,4 @@
1
+ export declare const parseToDate: (date: string) => Date;
2
+ export declare const parseToString: (date: Date | undefined) => string;
3
+ export declare const mergeRefs: <T>(...inputRefs: (React.Ref<T> | undefined)[]) => React.Ref<T> | React.RefCallback<T>;
4
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,WAAW,SAAU,MAAM,SAEvC,CAAC;AAEF,eAAO,MAAM,aAAa,SAAU,IAAI,GAAG,SAAS,WAEnD,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,CAAC,gBACX,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC,EAAE,KACzC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,WAAW,CAAC,CAAC,CAiBpC,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,76 @@
1
+ {
2
+ "name": "@purpurds/date-field",
3
+ "version": "0.0.1",
4
+ "license": "AGPL-3.0-only",
5
+ "main": "./dist/date-field.cjs.js",
6
+ "types": "./dist/date-field.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "require": "./dist/date-field.cjs.js",
10
+ "types": "./dist/date-field.d.ts",
11
+ "default": "./dist/date-field.es.js"
12
+ },
13
+ "./styles": "./dist/styles.css",
14
+ "./metadata": "./dist/metadata.js"
15
+ },
16
+ "source": "src/date-field.tsx",
17
+ "dependencies": {
18
+ "classnames": "~2.5.0",
19
+ "date-fns": "~4.1.0",
20
+ "@purpurds/tokens": "7.7.0",
21
+ "@purpurds/field-error-text": "7.7.0",
22
+ "@purpurds/text-field": "7.7.0",
23
+ "@purpurds/field-helper-text": "7.7.0",
24
+ "@purpurds/icon": "7.7.0",
25
+ "@purpurds/label": "7.7.0"
26
+ },
27
+ "devDependencies": {
28
+ "eslint": "9.24.0",
29
+ "@storybook/react-vite": "^9.0.18",
30
+ "@testing-library/dom": "~10.4.0",
31
+ "@testing-library/jest-dom": "~6.4.0",
32
+ "@testing-library/react": "~16.2.0",
33
+ "@testing-library/user-event": "~14.5.1",
34
+ "@types/node": "20.12.12",
35
+ "@types/react-dom": "^19.0.4",
36
+ "@types/react": "^19.0.10",
37
+ "jsdom": "~22.1.0",
38
+ "lint-staged": "15.5.0",
39
+ "prettier": "~2.8.8",
40
+ "react-dom": "^19.0.0",
41
+ "react": "^19.0.0",
42
+ "storybook": "^9.0.18",
43
+ "typescript": "^5.6.3",
44
+ "vite": "^6.2.1",
45
+ "vitest": "^3.1.2",
46
+ "@purpurds/component-rig": "1.0.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@types/react": "^18 || ^19",
50
+ "@types/react-dom": "^18 || ^19",
51
+ "react": "^18 || ^19",
52
+ "react-dom": "^18 || ^19"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "@types/react": {
56
+ "optional": true
57
+ },
58
+ "@types/react-dom": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "scripts": {
63
+ "build:dev": "vite",
64
+ "build:watch": "vite build --watch",
65
+ "build": "vite build",
66
+ "ci:build": "rushx build",
67
+ "coverage": "vitest run --coverage",
68
+ "lint:fix": "eslint . --fix",
69
+ "lint": "lint-staged --no-stash 2>&1",
70
+ "sbdev": "rush sbdev",
71
+ "test:unit": "vitest run --passWithNoTests",
72
+ "test:watch": "vitest --watch",
73
+ "test": "rushx test:unit",
74
+ "typecheck": "tsc -p ./tsconfig.json"
75
+ }
76
+ }
@@ -0,0 +1,18 @@
1
+ .purpur-date-field__container {
2
+ position: relative;
3
+ }
4
+
5
+ .purpur-date-field__overlay {
6
+ pointer-events: none;
7
+ box-sizing: border-box;
8
+ font-family: var(--purpur-typography-family-default);
9
+ font-size: var(--purpur-typography-scale-100);
10
+ font-weight: var(--purpur-typography-scale-400);
11
+ color: var(--purpur-color-text-default);
12
+ line-height: var(--purpur-typography-line-height-loose);
13
+ white-space: pre;
14
+ }
15
+
16
+ .purpur-date-field__start-adornment {
17
+ color: var(--purpur-color-text-weak);
18
+ }
@@ -0,0 +1,71 @@
1
+ import React from "react";
2
+ import { IconCalendar } from "@purpurds/icon/calendar";
3
+ import type { Meta, StoryObj } from "@storybook/react";
4
+
5
+ import "@purpurds/text-field/styles";
6
+ import "@purpurds/date-field/styles";
7
+ import "@purpurds/label/styles";
8
+ import "@purpurds/field-helper-text/styles";
9
+ import { DateField } from "./date-field";
10
+
11
+ const meta = {
12
+ title: "Forms and Inputs/DateField",
13
+ component: DateField,
14
+ parameters: {
15
+ design: [
16
+ {
17
+ name: "DateField",
18
+ type: "figma",
19
+ url: "https://www.figma.com/design/XEaIIFskrrxIBHMZDkIuIg/Purpur-DS---Component-library---guidelines?node-id=39042-5999&p=f&t=5D0qSGRGqXJgdAxm-0",
20
+ },
21
+ ],
22
+ },
23
+ args: {
24
+ label: "Date field label",
25
+ helperText: "Helper text",
26
+ valid: false,
27
+ errorText: undefined,
28
+ disabled: false,
29
+ readOnly: false,
30
+ required: false,
31
+ },
32
+ argTypes: {
33
+ startAdornment: { table: { disable: true } },
34
+ endAdornment: { table: { disable: true } },
35
+ },
36
+ decorators: [
37
+ (Story) => (
38
+ <div style={{ maxWidth: "18.5rem" }}>
39
+ <Story />
40
+ </div>
41
+ ),
42
+ ],
43
+ } satisfies Meta<typeof DateField>;
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof DateField>;
47
+
48
+ export const Showcase: Story = {
49
+ args: {
50
+ label: "Date",
51
+ helperText: "This is a date field",
52
+ },
53
+ render: (args) => {
54
+ return <DateField {...args} startAdornment={<IconCalendar size="sm" />} />;
55
+ },
56
+ };
57
+
58
+ export const Controlled: Story = {
59
+ args: {
60
+ label: "Today",
61
+ helperText: "This is a controlled date field",
62
+ },
63
+ render: (args) => {
64
+ // eslint-disable-next-line react-hooks/rules-of-hooks
65
+ const [value, setValue] = React.useState<Date | undefined>(new Date());
66
+ const selectHandler = (date: Date | undefined) => {
67
+ setValue(date);
68
+ };
69
+ return <DateField {...args} value={value} onChange={selectHandler} />;
70
+ },
71
+ };
@@ -0,0 +1,63 @@
1
+ import React from "react";
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { DateField } from "./date-field";
6
+
7
+ describe("DateField", () => {
8
+ afterEach(() => {
9
+ cleanup();
10
+ });
11
+
12
+ it("renders with placeholder overlay", () => {
13
+ render(<DateField label="Date" />);
14
+ expect(screen.getByLabelText("Date")).toBeTruthy();
15
+ expect(screen.getAllByText("Y").length).toBe(4);
16
+ expect(screen.getAllByText("M").length).toBe(2);
17
+ expect(screen.getAllByText("D").length).toBe(2);
18
+ });
19
+
20
+ it("renders with initial value", () => {
21
+ render(<DateField label="Date" value={new Date("2023-12-25")} />);
22
+ expect(screen.getByDisplayValue("2023-12-25")).toBeTruthy();
23
+ });
24
+
25
+ it("calls onChange with valid date", () => {
26
+ const handleChange = vi.fn();
27
+ render(<DateField label="Date" onChange={handleChange} />);
28
+ const input = screen.getByLabelText("Date");
29
+ fireEvent.change(input, { target: { value: "2023-12-31" } });
30
+ expect(handleChange).toHaveBeenCalled();
31
+ const calledDate = handleChange.mock.calls[0][0];
32
+ expect(calledDate.getFullYear()).toBe(2023);
33
+ expect(calledDate.getMonth()).toBe(11); // December is 11
34
+ expect(calledDate.getDate()).toBe(31);
35
+ });
36
+
37
+ it("does not call onChange for incomplete date", () => {
38
+ const handleChange = vi.fn();
39
+ render(<DateField label="Date" onChange={handleChange} />);
40
+ const input = screen.getByLabelText("Date");
41
+ fireEvent.change(input, { target: { value: "2023-12" } });
42
+ expect(handleChange).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it("formats input as user types", () => {
46
+ render(<DateField label="Date" />);
47
+ const input = screen.getByLabelText("Date") as HTMLInputElement;
48
+ fireEvent.change(input, { target: { value: "20231225" } });
49
+ expect(input.value).toBe("2023-12-25");
50
+ });
51
+
52
+ it("forwards ref to input", () => {
53
+ const ref = React.createRef<HTMLInputElement>();
54
+ render(<DateField label="Date" ref={ref} />);
55
+ expect(ref.current?.tagName).toBe("INPUT");
56
+ });
57
+
58
+ it("applies data-testid prop", () => {
59
+ render(<DateField label="Date" data-testid="date-field" />);
60
+ expect(screen.getByTestId("date-field-label")).toBeTruthy();
61
+ expect(screen.getByTestId("date-field-input")).toBeTruthy();
62
+ });
63
+ });
@@ -0,0 +1,138 @@
1
+ import React, {
2
+ type ForwardedRef,
3
+ forwardRef,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import { TextField, type TextFieldProps } from "@purpurds/text-field";
10
+ import c from "classnames/bind";
11
+ import { isValid } from "date-fns";
12
+
13
+ import "@purpurds/text-field/styles";
14
+ import styles from "./date-field.module.scss";
15
+ import { mergeRefs, parseToDate, parseToString } from "./utils";
16
+
17
+ const cx = c.bind(styles);
18
+
19
+ type DateFieldPropsTemp = Omit<
20
+ TextFieldProps,
21
+ "type" | "value" | "onChange" | "clearButtonAriaLabel" | "clearButtonLabel" | "onClear"
22
+ >;
23
+
24
+ export type DateFieldProps = DateFieldPropsTemp & {
25
+ ["data-testid"]?: string;
26
+ value?: Date | undefined;
27
+ onChange?: (date: Date | undefined) => void;
28
+ };
29
+
30
+ const rootClassName = "purpur-date-field";
31
+ const PLACEHOLDER = "YYYY-MM-DD";
32
+
33
+ export const DateField = forwardRef(
34
+ (
35
+ { value, label, onChange, startAdornment, ...props }: DateFieldProps,
36
+ ref: ForwardedRef<HTMLInputElement>
37
+ ) => {
38
+ const [inputValue, setInputValue] = useState<string>(parseToString(value));
39
+
40
+ // update inputValue when value prop changes
41
+ useEffect(() => {
42
+ setInputValue(value ? parseToString(value) : "");
43
+ }, [value]);
44
+
45
+ const inputRef = useRef<HTMLInputElement>(null);
46
+ const mergedRef = mergeRefs(ref, inputRef);
47
+ const [overlayStyle, setOverlayStyle] = useState<React.CSSProperties>({});
48
+
49
+ useLayoutEffect(() => {
50
+ if (inputRef.current) {
51
+ const labelElement = window.document.querySelector(
52
+ `label[for="${inputRef.current.id}"]`
53
+ ) as HTMLLabelElement | null;
54
+ const labelHeight = labelElement ? labelElement.offsetHeight + 4 : 0;
55
+
56
+ const style = window.getComputedStyle(inputRef.current);
57
+
58
+ setOverlayStyle({
59
+ position: "absolute",
60
+ top: inputRef.current.offsetTop + labelHeight,
61
+ left: inputRef.current.offsetLeft,
62
+ padding: style.padding,
63
+ width: inputRef.current.offsetWidth,
64
+ height: inputRef.current.offsetHeight,
65
+ });
66
+ }
67
+ }, [inputValue, label, inputRef]);
68
+
69
+ const selectedDateCallback = (date: string) => {
70
+ if (date.length === 0) {
71
+ onChange?.(undefined);
72
+ }
73
+
74
+ if (date.length !== PLACEHOLDER.length) {
75
+ return;
76
+ }
77
+
78
+ const parsedDate = parseToDate(date);
79
+ if (isValid(parsedDate)) {
80
+ onChange?.(parsedDate);
81
+ }
82
+ };
83
+
84
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
85
+ const raw = e.target.value.replace(/\D/g, "");
86
+ // Build value with dashes at correct positions
87
+ let formatted = "";
88
+ if (raw.length > 0) formatted += raw.slice(0, 4);
89
+ if (raw.length > 4) formatted += "-" + raw.slice(4, 6);
90
+ if (raw.length > 6) formatted += "-" + raw.slice(6, 8);
91
+ setInputValue(formatted);
92
+
93
+ selectedDateCallback(e.target.value);
94
+ };
95
+
96
+ const formatOverlayText = () => {
97
+ const chars = PLACEHOLDER.split("");
98
+ return chars.map((char, i) => {
99
+ const userChar = inputValue[i];
100
+ return (
101
+ <span
102
+ key={i}
103
+ style={{
104
+ color: userChar ? "transparent" : "var(--purpur-color-text-weak)",
105
+ }}
106
+ >
107
+ {userChar || char}
108
+ </span>
109
+ );
110
+ });
111
+ };
112
+
113
+ return (
114
+ <div className={cx(`${rootClassName}__container`)}>
115
+ <TextField
116
+ {...props}
117
+ startAdornment={
118
+ startAdornment ? (
119
+ <span className={cx(`${rootClassName}__start-adornment`)}>{startAdornment}</span>
120
+ ) : undefined
121
+ }
122
+ ref={mergedRef}
123
+ value={inputValue}
124
+ onChange={handleInputChange}
125
+ type="text"
126
+ maxLength={PLACEHOLDER.length}
127
+ inputMode="numeric"
128
+ label={label}
129
+ />
130
+ <div role="presentation" style={overlayStyle} className={cx(`${rootClassName}__overlay`)}>
131
+ {formatOverlayText()}
132
+ </div>
133
+ </div>
134
+ );
135
+ }
136
+ );
137
+
138
+ DateField.displayName = "DateField";
@@ -0,0 +1,4 @@
1
+ declare module "*.scss" {
2
+ const styles: { [className: string]: string };
3
+ export default styles;
4
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { format, parse } from "date-fns";
2
+
3
+ export const parseToDate = (date: string) => {
4
+ return parse(date, "yyyy-MM-dd", new Date());
5
+ };
6
+
7
+ export const parseToString = (date: Date | undefined) => {
8
+ return date ? format(date, "yyyy-MM-dd") : "";
9
+ };
10
+
11
+ export const mergeRefs = <T>(
12
+ ...inputRefs: (React.Ref<T> | undefined)[]
13
+ ): React.Ref<T> | React.RefCallback<T> => {
14
+ const filteredInputRefs = inputRefs.filter(Boolean);
15
+
16
+ if (filteredInputRefs.length <= 1) {
17
+ const firstRef = filteredInputRefs[0];
18
+ return firstRef || null;
19
+ }
20
+
21
+ return (ref: T | null) => {
22
+ for (const inputRef of filteredInputRefs) {
23
+ if (typeof inputRef === "function") {
24
+ inputRef(ref);
25
+ } else if (inputRef) {
26
+ (inputRef as React.RefObject<T | null>).current = ref;
27
+ }
28
+ }
29
+ };
30
+ };