@sproutsocial/seeds-react-datepicker 1.0.51 → 1.1.0

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,501 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import React from "react";
4
+ import moment, { type Moment } from "moment";
5
+ import { render, screen } from "@sproutsocial/seeds-react-testing-library";
6
+ import {
7
+ SingleDatePicker,
8
+ StatefulSingleDatePicker,
9
+ } from "../SingleDatePicker";
10
+ import { StatefulDateRangePicker } from "../DateRangePicker";
11
+ import { theme } from "@sproutsocial/seeds-react-theme";
12
+
13
+ /**
14
+ * Routing and static-CSS tests for the Datepicker Tailwind hybrid.
15
+ *
16
+ * Two pieces were styled by this package and both move to static CSS:
17
+ *
18
+ * 1. the day cell, which becomes semantic `seeds-datepicker-day*` classes;
19
+ * 2. the react-dates global overrides, which become an UNLAYERED block in
20
+ * datepicker.css (see the file's own comment for why unlayered).
21
+ *
22
+ * The audit found no per-instance routing signal for either piece — the public
23
+ * prop types carry no styled-system props, and the react-dates controllers drop
24
+ * `className`, so a `styled()` extension has nothing to preserve. The only
25
+ * consumer-visible branch is `renderDayContents`, which replaces the day cell
26
+ * outright. So "routing" here is structural, and these tests pin it.
27
+ *
28
+ * jest mocks CSS imports and jsdom implements neither `@layer` precedence nor
29
+ * cross-stylesheet insertion order, so the static rules are asserted against
30
+ * the CSS source text. Cascade behavior needs a real browser.
31
+ */
32
+
33
+ const FIXED_NOW = new Date("2026-06-15T12:00:00Z");
34
+ const CSS_PATH = path.join(__dirname, "..", "datepicker.css");
35
+ // Splits the file into its unlayered half and its layered half. Matched with the
36
+ // brace so the prose in the file header does not split it in the wrong place.
37
+ const LAYER_OPEN = "@layer components {";
38
+
39
+ const noop = () => {};
40
+
41
+ const PACKAGE_JSON_PATH = path.join(__dirname, "..", "..", "package.json");
42
+
43
+ const readCss = () => fs.readFileSync(CSS_PATH, "utf-8");
44
+ const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, "");
45
+ const unlayeredHalf = () => stripComments(readCss().split(LAYER_OPEN)[0] ?? "");
46
+ const layeredHalf = () => stripComments(readCss().split(LAYER_OPEN)[1] ?? "");
47
+
48
+ /** Splits a flat CSS string into selector -> { property: value }. */
49
+ const parseRules = (css: string) => {
50
+ const rules = new Map<string, Map<string, string>>();
51
+ for (const block of css.split("}")) {
52
+ const brace = block.indexOf("{");
53
+ if (brace === -1) continue;
54
+ const declarations = new Map<string, string>();
55
+ for (const declaration of block.slice(brace + 1).split(";")) {
56
+ const colon = declaration.indexOf(":");
57
+ if (colon === -1) continue;
58
+ declarations.set(
59
+ declaration.slice(0, colon).trim(),
60
+ declaration.slice(colon + 1).trim()
61
+ );
62
+ }
63
+ for (const selector of block.slice(0, brace).split(",")) {
64
+ const trimmed = selector.trim();
65
+ if (trimmed) rules.set(trimmed, declarations);
66
+ }
67
+ }
68
+ return rules;
69
+ };
70
+
71
+ /** The light-mode `:root` custom properties shipped by seeds-react-theme. */
72
+ const themeCustomProperties = (() => {
73
+ const themeCss = fs.readFileSync(
74
+ require.resolve("@sproutsocial/seeds-react-theme/css/theme-all"),
75
+ "utf-8"
76
+ );
77
+ const open = themeCss.indexOf(":root {");
78
+ const body = themeCss.slice(
79
+ open + ":root {".length,
80
+ themeCss.indexOf("}", open)
81
+ );
82
+ const properties = new Map<string, string>();
83
+ for (const declaration of body.split(";")) {
84
+ const colon = declaration.indexOf(":");
85
+ if (colon === -1) continue;
86
+ const name = declaration.slice(0, colon).trim();
87
+ if (name.startsWith("--")) {
88
+ properties.set(name, declaration.slice(colon + 1).trim());
89
+ }
90
+ }
91
+ return properties;
92
+ })();
93
+
94
+ const resolveVars = (value: string) =>
95
+ value
96
+ .replace(/var\((--[a-z0-9-]+)\)/g, (_match, name) => {
97
+ const resolved = themeCustomProperties.get(name);
98
+ if (resolved === undefined) {
99
+ throw new Error(`${name} is not defined in theme-all.css`);
100
+ }
101
+ return resolved;
102
+ })
103
+ .toLowerCase();
104
+
105
+ const readInjectedStyles = () =>
106
+ Array.from(document.querySelectorAll("style"))
107
+ .map((tag) => tag.textContent ?? "")
108
+ .join("\n");
109
+
110
+ beforeEach(() => {
111
+ jest.useFakeTimers();
112
+ jest.setSystemTime(FIXED_NOW);
113
+ moment.locale("en");
114
+ });
115
+
116
+ describe("day cell routing", () => {
117
+ it("renders the static day cell by default", () => {
118
+ render(<SingleDatePicker date={moment()} onDateChange={noop} />);
119
+ const cell = screen.getByLabelText("Wednesday, June 17, 2026");
120
+ const day = cell.firstElementChild as HTMLElement;
121
+ expect(day).toHaveClass("seeds-datepicker-day");
122
+ expect(day.className).not.toMatch(/\bsc-/);
123
+ });
124
+
125
+ it("marks a selected day and both of its pill edges", () => {
126
+ render(
127
+ <SingleDatePicker date={moment("2026-06-17")} onDateChange={noop} />
128
+ );
129
+ const day = screen.getByLabelText("Selected. Wednesday, June 17, 2026")
130
+ .firstElementChild as HTMLElement;
131
+ expect(day).toHaveClass("seeds-datepicker-day-selected");
132
+ expect(day).toHaveClass("seeds-datepicker-day-pill-left");
133
+ expect(day).toHaveClass("seeds-datepicker-day-pill-right");
134
+ });
135
+
136
+ it("marks the interior of a selected span without pill edges", () => {
137
+ render(
138
+ <StatefulDateRangePicker
139
+ startDate={moment("2026-06-15")}
140
+ endDate={moment("2026-06-19")}
141
+ />
142
+ );
143
+ const day = screen.getByLabelText("Selected. Wednesday, June 17, 2026")
144
+ .firstElementChild as HTMLElement;
145
+ expect(day).toHaveClass("seeds-datepicker-day-selected");
146
+ expect(day).not.toHaveClass("seeds-datepicker-day-pill-left");
147
+ expect(day).not.toHaveClass("seeds-datepicker-day-pill-right");
148
+ });
149
+
150
+ it("marks an out-of-range day", () => {
151
+ render(
152
+ <StatefulSingleDatePicker
153
+ date={moment()}
154
+ isOutsideRange={(date: Moment) =>
155
+ date.isSame(moment("2026-06-17"), "day")
156
+ }
157
+ />
158
+ );
159
+ const day = screen.getByLabelText("Not available. Wednesday, June 17, 2026")
160
+ .firstElementChild as HTMLElement;
161
+ expect(day).toHaveClass("seeds-datepicker-day-out-of-range");
162
+ });
163
+
164
+ it("is bypassed entirely by a consumer renderDayContents", () => {
165
+ render(
166
+ <StatefulSingleDatePicker
167
+ date={moment()}
168
+ renderDayContents={(day: Moment) => <b>{`d${day.format("D")}`}</b>}
169
+ />
170
+ );
171
+ const cell = screen.getByLabelText("Wednesday, June 17, 2026");
172
+ expect(cell.querySelector(".seeds-datepicker-day")).toBeNull();
173
+ expect(cell.querySelector("b")).toHaveTextContent("d17");
174
+ });
175
+ });
176
+
177
+ describe("runtime CSS-in-JS", () => {
178
+ /**
179
+ * On the legacy implementation, mounting a picker injects two styled-components
180
+ * rules for the day cell (Box's base rule plus CalendarDay's extension). The
181
+ * static path must inject none.
182
+ *
183
+ * This does NOT speak to the react-dates global overrides: `createGlobalStyle`
184
+ * output is not observable from jsdom under jest-styled-components, so their
185
+ * removal is verified from the CSS source below and, for cascade behavior,
186
+ * only in a real browser.
187
+ */
188
+ it("injects no styled-components rules while a picker is mounted", () => {
189
+ render(
190
+ <SingleDatePicker date={moment("2026-06-17")} onDateChange={noop} />
191
+ );
192
+ expect(readInjectedStyles().trim()).toBe("");
193
+ });
194
+ });
195
+
196
+ describe("datepicker.css delivery", () => {
197
+ const css = readCss;
198
+
199
+ /**
200
+ * Two delivery mechanisms, each of which can break silently:
201
+ * racine/buildComponents.ts globs `src/*.css` NON-recursively (so the file
202
+ * must sit at the src root, not under a component folder), and the package
203
+ * build has to copy it into dist itself — tsup does not.
204
+ */
205
+ it("ships the stylesheet where both racine and the package build expect it", () => {
206
+ expect(fs.existsSync(CSS_PATH)).toBe(true);
207
+
208
+ const { scripts } = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, "utf-8"));
209
+ const copyStep = "cp src/datepicker.css dist/datepicker.css";
210
+ expect(scripts.build).toContain(copyStep);
211
+ expect(scripts["build:debug"]).toContain(copyStep);
212
+ });
213
+
214
+ it("puts the semantic day classes in the components layer", () => {
215
+ const layered = layeredHalf();
216
+ for (const selector of [
217
+ ".seeds-datepicker-day",
218
+ ".seeds-datepicker-day-selected",
219
+ ".seeds-datepicker-day-pill-left",
220
+ ".seeds-datepicker-day-pill-right",
221
+ ".seeds-datepicker-day-hovered",
222
+ ".seeds-datepicker-day-out-of-range",
223
+ ]) {
224
+ expect(layered).toContain(`${selector} {`);
225
+ }
226
+ });
227
+
228
+ /**
229
+ * Guards the `&`-flattening of the old createGlobalStyle block. A mis-expanded
230
+ * nested selector produces no error anywhere else in this suite, because jsdom
231
+ * never applies these rules.
232
+ */
233
+ it("keeps the react-dates overrides unlayered and fully expanded", () => {
234
+ const unlayered = unlayeredHalf();
235
+ for (const selector of [
236
+ ".DayPicker {",
237
+ ".DayPicker .DayPicker_transitionContainer {",
238
+ ".DayPicker .DayPicker_weekHeader {",
239
+ ".DayPicker .DayPicker_weekHeader_li {",
240
+ ".DayPicker.DayPicker__horizontal {",
241
+ ".DayPicker .CalendarDay {",
242
+ ".DayPicker .CalendarDay:hover",
243
+ ".DayPicker .CalendarDay__selected",
244
+ ".DayPicker .CalendarDay__selected_span",
245
+ ".DayPicker .CalendarDay__default",
246
+ ".DayPicker .CalendarMonth {",
247
+ ".DayPicker .CalendarMonth_caption {",
248
+ ".DayPicker .CalendarMonth_table {",
249
+ ".DayPicker .CalendarMonthGrid {",
250
+ ".DayPicker .DayPickerNavigation_button__horizontal {",
251
+ ]) {
252
+ expect(unlayered).toContain(selector);
253
+ }
254
+ // A stray `&` means a nested selector was copied rather than flattened.
255
+ expect(unlayered).not.toContain("&");
256
+ });
257
+
258
+ /**
259
+ * `width: 230px` on the week header assumed content-box and broke under
260
+ * Tailwind's preflight, wrapping Saturday onto a second line. It and the two
261
+ * rules that compensated for it were deleted so the header shrink-wraps to
262
+ * the month and tracks `daySize`. Reinstating any of them re-breaks it.
263
+ */
264
+ it("does not pin the week header width or its compensating rules", () => {
265
+ // Checked across the WHOLE stylesheet, not just the unlayered half, so a
266
+ // reinstatement cannot hide by being added in the wrong block. Comments are
267
+ // stripped because the rule above this one explains the deletion by name.
268
+ const rules = stripComments(readCss());
269
+ expect(rules).not.toContain("width: 230px");
270
+ expect(rules).not.toContain("DayPicker_weekHeader_ul");
271
+ expect(rules).not.toContain("DayPicker_weekHeaders__horizontal");
272
+ // The rest of the week-header rule stays.
273
+ expect(unlayeredHalf()).toContain(".DayPicker .DayPicker_weekHeader {");
274
+ expect(unlayeredHalf()).toContain("top: 26px");
275
+ });
276
+
277
+ it("preserves the react-dates layout magic numbers verbatim", () => {
278
+ const unlayered = unlayeredHalf();
279
+ for (const declaration of [
280
+ "height: 228px !important",
281
+ "top: 26px",
282
+ "line-height: 21.333px",
283
+ "padding: 0 15px",
284
+ "left: 22px",
285
+ "right: 22px",
286
+ "top: -4px",
287
+ "padding: 7px 8px",
288
+ ]) {
289
+ expect(unlayered).toContain(declaration);
290
+ }
291
+ });
292
+
293
+ /**
294
+ * The legacy styled day cell emitted NO declaration when a pill edge was
295
+ * absent (see the contract test of the same name). Writing a `0` fallback
296
+ * would collapse the selection's rounded ends into square ones.
297
+ */
298
+ it("never writes a zero fallback for an absent pill edge", () => {
299
+ const layered = layeredHalf();
300
+ expect(layered).not.toMatch(/margin-(left|right):\s*0\s*;/);
301
+ expect(layered).not.toMatch(/border-[a-z]+-[a-z]+-radius:\s*0\s*;/);
302
+ });
303
+
304
+ it("reads every themed value from a CSS custom property", () => {
305
+ // The legacy implementation read the runtime theme, so no palette value may
306
+ // be hardcoded. `#fff`-style literals would break dark mode.
307
+ expect(css()).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
308
+
309
+ // ...and every property referenced must actually exist in theme-all.css.
310
+ // The value assertions further down only cover the layered day-cell rules,
311
+ // so without this a typo in the unlayered overrides would ship silently.
312
+ const references = stripComments(css()).match(/var\(--[a-z0-9-]+\)/g) ?? [];
313
+ expect(references.length).toBeGreaterThanOrEqual(18);
314
+ for (const reference of references) {
315
+ expect(() => resolveVars(reference)).not.toThrow();
316
+ }
317
+ });
318
+ });
319
+
320
+ /**
321
+ * F3 — the react-dates overrides target THIRD-PARTY DOM.
322
+ *
323
+ * Every other test in this file checks that the selector TEXT is present. None
324
+ * of them would notice if react-dates renamed a class: the peer range is
325
+ * `^21.8.0`, these are react-dates' private BEM names, and a minor bump could
326
+ * silently orphan an override while the whole suite stays green. So render a
327
+ * real picker and require each selector to match something.
328
+ */
329
+ describe("react-dates override selectors match real DOM", () => {
330
+ const renderFullCalendar = () =>
331
+ render(
332
+ // A range picker with a selection exercises every selector the overrides
333
+ // use: two months, nav buttons, week headers, day cells, and the
334
+ // __selected / __selected_span states.
335
+ <StatefulDateRangePicker
336
+ startDate={moment("2026-06-15")}
337
+ endDate={moment("2026-06-19")}
338
+ />
339
+ );
340
+
341
+ const overrideSelectors = () => [...parseRules(unlayeredHalf()).keys()];
342
+
343
+ it("matches at least one node for every override selector", () => {
344
+ const { container } = renderFullCalendar();
345
+ const selectors = overrideSelectors();
346
+
347
+ // Guards the filter below against silently passing on an empty list.
348
+ expect(selectors.length).toBeGreaterThanOrEqual(20);
349
+
350
+ // :hover and :active can never match in a static render; assert the rest of
351
+ // the selector, which is what carries the class names at risk of renaming.
352
+ const unmatched = selectors.filter((selector) => {
353
+ const structural = selector.replace(/:(hover|active|focus)\b/g, "");
354
+ return container.querySelectorAll(structural).length === 0;
355
+ });
356
+
357
+ expect(unmatched).toEqual([]);
358
+ });
359
+ });
360
+
361
+ /**
362
+ * F4 — bind the declaration VALUES to the theme.
363
+ *
364
+ * dayModifiers.ts stops the two paths disagreeing about WHICH state a day is
365
+ * in; nothing yet stops datepicker.css disagreeing about what a state LOOKS
366
+ * like. Someone could swap --color-container-bg-selected for --color-text-body
367
+ * and every other test would still pass.
368
+ *
369
+ * Resolving the custom properties through theme-all.css and comparing against
370
+ * the same theme JS values the legacy styled cell read gives styled == CSS
371
+ * transitively, via the contract tests that already pin styled == theme JS.
372
+ * Deliberately does NOT diff against the styled component, so it outlives
373
+ * styles.ts in Phase B.
374
+ */
375
+ describe("day cell CSS values match the theme the legacy cell read", () => {
376
+ /**
377
+ * [selector, property, exact declaration text, value the legacy cell read]
378
+ *
379
+ * Both halves are load-bearing. The declaration text pins WHICH custom
380
+ * property is used, because two tokens can share a value in one theme and
381
+ * diverge in the other — --color-container-bg-selected and --color-text-body
382
+ * are both #364141 in light mode but differ in dark, so a value-only check
383
+ * would wave that swap through. The theme value then proves the token is
384
+ * semantically the one the styled cell read.
385
+ */
386
+ const expectations: [string, string, string, string][] = [
387
+ // Box's baseline, which the legacy day cell inherited from styled(Box).
388
+ [".seeds-datepicker-day", "box-sizing", "border-box", "border-box"],
389
+ [
390
+ ".seeds-datepicker-day",
391
+ "font-family",
392
+ "var(--font-family)",
393
+ theme.fontFamily,
394
+ ],
395
+ [
396
+ ".seeds-datepicker-day-selected",
397
+ "background-color",
398
+ "var(--color-container-bg-selected)",
399
+ theme.colors.container.background.selected,
400
+ ],
401
+ [
402
+ ".seeds-datepicker-day-selected",
403
+ "color",
404
+ "var(--color-text-inverse)",
405
+ theme.colors.text.inverse,
406
+ ],
407
+ [
408
+ ".seeds-datepicker-day-pill-left",
409
+ "margin-left",
410
+ "var(--space-200)",
411
+ theme.space[200],
412
+ ],
413
+ [
414
+ ".seeds-datepicker-day-pill-left",
415
+ "border-top-left-radius",
416
+ "var(--radius-pill)",
417
+ theme.radii.pill,
418
+ ],
419
+ [
420
+ ".seeds-datepicker-day-pill-left",
421
+ "border-bottom-left-radius",
422
+ "var(--radius-pill)",
423
+ theme.radii.pill,
424
+ ],
425
+ [
426
+ ".seeds-datepicker-day-pill-right",
427
+ "margin-right",
428
+ "var(--space-200)",
429
+ theme.space[200],
430
+ ],
431
+ [
432
+ ".seeds-datepicker-day-pill-right",
433
+ "border-top-right-radius",
434
+ "var(--radius-pill)",
435
+ theme.radii.pill,
436
+ ],
437
+ [
438
+ ".seeds-datepicker-day-pill-right",
439
+ "border-bottom-right-radius",
440
+ "var(--radius-pill)",
441
+ theme.radii.pill,
442
+ ],
443
+ [
444
+ ".seeds-datepicker-day-hovered",
445
+ "margin",
446
+ "0 var(--space-200)",
447
+ `0 ${theme.space[200]}`,
448
+ ],
449
+ [
450
+ ".seeds-datepicker-day-hovered",
451
+ "border-radius",
452
+ "var(--radius-pill)",
453
+ theme.radii.pill,
454
+ ],
455
+ [
456
+ ".seeds-datepicker-day-hovered",
457
+ "border",
458
+ "1px solid var(--color-container-border-selected)",
459
+ `1px solid ${theme.colors.container.border.selected}`,
460
+ ],
461
+ [
462
+ ".seeds-datepicker-day-out-of-range",
463
+ "color",
464
+ "var(--color-text-subtext)",
465
+ theme.colors.text.subtext,
466
+ ],
467
+ // From the `disabled` mixin in seeds-react-mixins, which has no tokens.
468
+ [".seeds-datepicker-day-out-of-range", "opacity", "0.4", "0.4"],
469
+ [".seeds-datepicker-day-out-of-range", "pointer-events", "none", "none"],
470
+ ];
471
+
472
+ it.each(expectations)(
473
+ "%s { %s }",
474
+ (selector, property, declaration, themeValue) => {
475
+ const declarations = parseRules(layeredHalf()).get(selector);
476
+ expect(declarations).toBeDefined();
477
+
478
+ const declared = declarations?.get(property);
479
+ // Exact token identity — catches a swap to a token that happens to share
480
+ // this theme's value.
481
+ expect(declared).toBe(declaration);
482
+ // And the token resolves to what the legacy styled cell actually read.
483
+ expect(resolveVars(declared as string)).toBe(themeValue.toLowerCase());
484
+ }
485
+ );
486
+
487
+ it("leaves no declaration in the layered half unchecked", () => {
488
+ const covered = new Set(
489
+ expectations.map(([selector, property]) => `${selector} ${property}`)
490
+ );
491
+ const declared: string[] = [];
492
+ for (const [selector, declarations] of parseRules(layeredHalf())) {
493
+ for (const property of declarations.keys()) {
494
+ declared.push(`${selector} ${property}`);
495
+ }
496
+ }
497
+
498
+ expect(declared.length).toBe(expectations.length);
499
+ expect(declared.filter((entry) => !covered.has(entry))).toEqual([]);
500
+ });
501
+ });
package/src/cn.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Minimal className combiner, mirroring the local copies in seeds-react-list,
3
+ * seeds-react-profile and seeds-react-tree —
4
+ * @sproutsocial/seeds-react-utilities does NOT export a `cn` helper.
5
+ *
6
+ * Falsy inputs are dropped; object inputs contribute each truthy key.
7
+ */
8
+ export function cn(
9
+ ...inputs: (string | undefined | null | false | Record<string, boolean>)[]
10
+ ): string {
11
+ const classes: string[] = [];
12
+
13
+ for (const input of inputs) {
14
+ if (!input) continue;
15
+
16
+ if (typeof input === "string") {
17
+ classes.push(input);
18
+ } else if (typeof input === "object") {
19
+ for (const [key, value] of Object.entries(input)) {
20
+ if (value) {
21
+ classes.push(key);
22
+ }
23
+ }
24
+ }
25
+ }
26
+
27
+ return classes.join(" ");
28
+ }
package/src/common.tsx CHANGED
@@ -3,7 +3,7 @@ import "react-dates/lib/css/_datepicker.css";
3
3
  import type { Moment } from "moment";
4
4
  import React from "react";
5
5
  import Icon, { type TypeIconName } from "@sproutsocial/seeds-react-icon";
6
- import { CalendarDay } from "./styles";
6
+ import CalendarDay from "./CalendarDayTailwind";
7
7
  import type { TypeCommonDatePickerProps } from "./types";
8
8
 
9
9
  type TypeCalendarNavButtonType = "left" | "right";