vueless 1.5.1-beta.2 → 1.5.1-beta.4

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.
@@ -60,7 +60,14 @@ export default /*tw*/ {
60
60
  periodRow: "mb-1 flex min-w-64 gap-1",
61
61
  periodButton: "{UButton} h-[3.125rem] w-full",
62
62
  periodButtonActive: "{>periodButton} bg-grayscale-accented/15!",
63
- rangeSwitchWrapper: "flex items-center justify-between py-2",
63
+ rangeSwitchWrapper: {
64
+ base: "flex items-center justify-between py-2",
65
+ variants: {
66
+ periodSwitch: {
67
+ false: "pt-0",
68
+ },
69
+ },
70
+ },
64
71
  rangeSwitchButton: "{UButton}",
65
72
  rangeSwitchTitle: "font-medium text-medium",
66
73
  periodDateList: {
@@ -87,7 +94,14 @@ export default /*tw*/ {
87
94
  },
88
95
  },
89
96
  customRangeDescription: "",
90
- rangeInputWrapper: "flex mt-4 group/range-input-wrapper",
97
+ rangeInputWrapper: {
98
+ base: "flex mt-4 group/range-input-wrapper",
99
+ variants: {
100
+ periodSwitch: {
101
+ false: "mt-2",
102
+ },
103
+ },
104
+ },
91
105
  rangeInput: {
92
106
  base: "{UInput}",
93
107
  inputLabel: {
@@ -211,6 +225,7 @@ export default /*tw*/ {
211
225
  dateFormat: undefined,
212
226
  maxDate: undefined,
213
227
  minDate: undefined,
228
+ periods: ["week", "month", "quarter", "year", "ownRange"],
214
229
  /* icons */
215
230
  calendarIcon: "calendar_month-fill",
216
231
  nextIcon: "keyboard_arrow_right",
@@ -225,6 +225,54 @@ OpenDirection.parameters = {
225
225
  },
226
226
  };
227
227
 
228
+ export const Periods: StoryFn<DefaultUDatePickerRangeArgs> = (args) => ({
229
+ components: { UDatePickerRange, UCol, UText },
230
+ setup: () => ({ args }),
231
+ template: `
232
+ <UCol>
233
+ <UDatePickerRange
234
+ v-bind="args"
235
+ v-model="args.modelValue"
236
+ label="All periods (default)"
237
+ class="w-full max-w-96"
238
+ />
239
+ <UDatePickerRange
240
+ v-bind="args"
241
+ v-model="args.modelValue"
242
+ :periods="['month', 'quarter', 'year']"
243
+ label="Month, quarter, year"
244
+ class="w-full max-w-96"
245
+ />
246
+ <UDatePickerRange
247
+ v-bind="args"
248
+ v-model="args.modelValue"
249
+ :periods="['week', 'month']"
250
+ label="Week, month"
251
+ class="w-full max-w-96"
252
+ />
253
+ <UDatePickerRange
254
+ v-bind="args"
255
+ v-model="args.modelValue"
256
+ :periods="['ownRange']"
257
+ label="Own range only"
258
+ class="w-full max-w-96"
259
+ />
260
+ </UCol>
261
+
262
+ <UText color="neutral" class="mt-4">{{ args.modelValue }}</UText>
263
+ `,
264
+ });
265
+ Periods.parameters = {
266
+ docs: {
267
+ description: {
268
+ story: "Use the `periods` prop to limit which period types are available for selection.",
269
+ },
270
+ story: {
271
+ height: "800px",
272
+ },
273
+ },
274
+ };
275
+
228
276
  export const DateFormat = DefaultTemplate.bind({});
229
277
  DateFormat.args = { variant: "input", dateFormat: "Y-m-d" };
230
278
  DateFormat.parameters = {
@@ -259,6 +307,35 @@ MinMax.parameters = {
259
307
  },
260
308
  };
261
309
 
310
+ export const DayClasses = DefaultTemplate.bind({});
311
+ DayClasses.args = {
312
+ modelValue: { from: fromDate, to: toDate },
313
+ dayClasses: (day: Date) => {
314
+ return [5, 12, 19].includes(day.getDate()) ? "bg-success" : "";
315
+ },
316
+ };
317
+ DayClasses.parameters = {
318
+ docs: {
319
+ description: {
320
+ story:
321
+ // eslint-disable-next-line vue/max-len
322
+ "Use the `dayClasses` prop to apply extra CSS classes to individual day cells, e.g. to highlight dates by status.",
323
+ },
324
+ source: {
325
+ code: `
326
+ <UDatePickerRange
327
+ v-model="args.modelValue"
328
+ label="Select date range"
329
+ :day-classes="(day) => ([5, 12, 19].includes(day.getDate()) ? 'bg-success' : '')"
330
+ class="w-full max-w-96"
331
+ />
332
+
333
+ <UText color="neutral" class="mt-4">{{ args.modelValue }}</UText>
334
+ `,
335
+ },
336
+ },
337
+ };
338
+
262
339
  export const CustomRangeButton = DefaultTemplate.bind({});
263
340
  CustomRangeButton.args = {
264
341
  customRangeButton: {
@@ -1,11 +1,12 @@
1
1
  import { mount } from "@vue/test-utils";
2
- import { describe, it, expect } from "vitest";
2
+ import { describe, it, expect, vi } from "vitest";
3
3
 
4
4
  import UDatePickerRange from "../UDatePickerRange.vue";
5
5
  import UInput from "../../ui.form-input/UInput.vue";
6
6
  import ULabel from "../../ui.form-label/ULabel.vue";
7
7
  import UButton from "../../ui.button/UButton.vue";
8
8
  import UDatePickerRangePeriodMenu from "../UDatePickerRangePeriodMenu.vue";
9
+ import { Period } from "../constants";
9
10
 
10
11
  import type { RangeDate } from "../../ui.form-calendar/types";
11
12
 
@@ -298,6 +299,24 @@ describe("UDatePickerRange.vue", () => {
298
299
  expect(component.props("maxDate")).toBe(maxDate);
299
300
  });
300
301
 
302
+ it("Day Classes – passes dayClasses to UCalendar", async () => {
303
+ const dayClasses = (day: Date) => (day.getDate() === 15 ? "bg-mint-highlight" : "");
304
+
305
+ const component = mount(UDatePickerRange, {
306
+ props: {
307
+ variant: "input",
308
+ dayClasses,
309
+ modelValue: { from: null, to: null },
310
+ },
311
+ });
312
+
313
+ const input = component.findComponent(UInput).get("input");
314
+
315
+ await input.trigger("focus");
316
+
317
+ expect(component.findComponent({ name: "UCalendar" }).props("dayClasses")).toBe(dayClasses);
318
+ });
319
+
301
320
  it("Custom Range Button – applies custom range button configuration", async () => {
302
321
  const customRangeButton = {
303
322
  range: { from: new Date("2023-01-01"), to: new Date("2023-01-31") },
@@ -440,6 +459,189 @@ describe("UDatePickerRange.vue", () => {
440
459
  });
441
460
  });
442
461
 
462
+ describe("Periods", () => {
463
+ const dataTest = "date-picker-range";
464
+ const menuPrefix = `${dataTest}-period-menu`;
465
+ const periodButtonNames: string[] = Object.values(Period).filter(
466
+ (type) => type !== Period.OwnRange && type !== Period.Custom,
467
+ );
468
+ const fullWeek: RangeDate = {
469
+ from: new Date(2023, 11, 4),
470
+ to: new Date(2023, 11, 10, 23, 59, 59),
471
+ };
472
+
473
+ async function mountOpened(props: Record<string, unknown>) {
474
+ const component = mount(UDatePickerRange, {
475
+ props: {
476
+ variant: "input",
477
+ dataTest,
478
+ modelValue: { from: null, to: null },
479
+ ...props,
480
+ },
481
+ });
482
+
483
+ await component.findComponent(UInput).get("input").trigger("focus");
484
+
485
+ return component;
486
+ }
487
+
488
+ function findPeriodButton(component: ReturnType<typeof mount>, name: string) {
489
+ return component.find(`[data-test="${menuPrefix}-period-${name}"]`);
490
+ }
491
+
492
+ function findOwnRangeButton(component: ReturnType<typeof mount>) {
493
+ return component.find(`[data-test="${menuPrefix}-own-range"]`);
494
+ }
495
+
496
+ function getRenderedPeriodNames(component: ReturnType<typeof mount>) {
497
+ return component
498
+ .findAll(`[data-test^="${menuPrefix}-period-"]`)
499
+ .map((button) => button.attributes("data-test")!.replace(`${menuPrefix}-period-`, ""))
500
+ .filter((name) => periodButtonNames.includes(name));
501
+ }
502
+
503
+ function isActive(button: ReturnType<ReturnType<typeof mount>["find"]>) {
504
+ return button.attributes("vl-key") === "periodButtonActive";
505
+ }
506
+
507
+ it("Periods – renders all period buttons and own range button by default", async () => {
508
+ const component = await mountOpened({});
509
+
510
+ expect(getRenderedPeriodNames(component)).toEqual(periodButtonNames);
511
+ expect(findOwnRangeButton(component).exists()).toBe(true);
512
+ });
513
+
514
+ it("Periods – renders only allowed period buttons without own range button", async () => {
515
+ const component = await mountOpened({ periods: ["month", "year"] });
516
+
517
+ expect(getRenderedPeriodNames(component)).toEqual(["month", "year"]);
518
+ expect(findOwnRangeButton(component).exists()).toBe(false);
519
+ });
520
+
521
+ it.each(["ownRange", "month"])(
522
+ "Periods – hides period switch when %s is the only period",
523
+ async (period) => {
524
+ const component = await mountOpened({ periods: [period] });
525
+
526
+ expect(getRenderedPeriodNames(component)).toEqual([]);
527
+ expect(findOwnRangeButton(component).exists()).toBe(false);
528
+ },
529
+ );
530
+
531
+ it("Periods – reduces range inputs top margin when period switch is hidden", async () => {
532
+ const component = await mountOpened({ periods: ["ownRange"] });
533
+ const rangeInputWrapper = component.get("[vl-key='rangeInputWrapper']");
534
+
535
+ expect(rangeInputWrapper.classes()).toContain("mt-2");
536
+ expect(rangeInputWrapper.classes()).not.toContain("mt-4");
537
+ });
538
+
539
+ it("Periods – removes range switch top padding when period switch is hidden", async () => {
540
+ const component = await mountOpened({ periods: ["month"] });
541
+
542
+ expect(component.get("[vl-key='rangeSwitchWrapper']").classes()).toContain("pt-0");
543
+ });
544
+
545
+ it("Periods – keeps range inputs top margin when period switch is shown", async () => {
546
+ const component = await mountOpened({});
547
+
548
+ expect(component.get("[vl-key='rangeInputWrapper']").classes()).toContain("mt-4");
549
+ });
550
+
551
+ it("Periods – shows own range button when custom range is also available", async () => {
552
+ const component = await mountOpened({
553
+ periods: ["ownRange"],
554
+ customRangeButton: {
555
+ range: { from: new Date(2023, 5, 1), to: new Date(2023, 5, 15) },
556
+ label: "First half of June",
557
+ },
558
+ });
559
+
560
+ expect(findOwnRangeButton(component).exists()).toBe(true);
561
+ expect(component.find(`[data-test="${menuPrefix}-custom-range"]`).exists()).toBe(true);
562
+ });
563
+
564
+ it("Periods – renders period buttons in canonical order", async () => {
565
+ const component = await mountOpened({
566
+ periods: ["year", "ownRange", "week", "quarter"],
567
+ });
568
+
569
+ expect(getRenderedPeriodNames(component)).toEqual(["week", "quarter", "year"]);
570
+ expect(findOwnRangeButton(component).exists()).toBe(true);
571
+ });
572
+
573
+ it("Periods – falls back to own range when model matches a disallowed period", async () => {
574
+ const component = await mountOpened({
575
+ periods: ["month", "ownRange"],
576
+ modelValue: fullWeek,
577
+ });
578
+
579
+ expect(findPeriodButton(component, "week").exists()).toBe(false);
580
+ expect(isActive(findOwnRangeButton(component))).toBe(true);
581
+ expect(isActive(findPeriodButton(component, "month"))).toBe(false);
582
+ });
583
+
584
+ it("Periods – falls back to the first allowed period when own range is not allowed", async () => {
585
+ const component = await mountOpened({
586
+ periods: ["year", "month"],
587
+ modelValue: fullWeek,
588
+ });
589
+
590
+ expect(isActive(findPeriodButton(component, "month"))).toBe(true);
591
+ expect(isActive(findPeriodButton(component, "year"))).toBe(false);
592
+ });
593
+
594
+ it("Periods – treats an empty array as all periods and warns", async () => {
595
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
596
+
597
+ const component = await mountOpened({ periods: [] });
598
+
599
+ expect(getRenderedPeriodNames(component)).toEqual(periodButtonNames);
600
+ expect(findOwnRangeButton(component).exists()).toBe(true);
601
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No valid "periods"'));
602
+
603
+ warnSpy.mockRestore();
604
+ });
605
+
606
+ it.each([
607
+ ["next", new Date(2023, 6, 1), new Date(2023, 6, 31)],
608
+ ["prev", new Date(2023, 4, 1), new Date(2023, 4, 31)],
609
+ ])(
610
+ "Periods – shifts custom range by the resolved period (%s)",
611
+ async (direction, expectedFrom, expectedTo) => {
612
+ const component = mount(UDatePickerRange, {
613
+ props: {
614
+ variant: "button",
615
+ dataTest,
616
+ periods: ["month", "year"],
617
+ modelValue: { from: null, to: null },
618
+ customRangeButton: {
619
+ range: { from: new Date(2023, 5, 1), to: new Date(2023, 5, 15) },
620
+ label: "First half of June",
621
+ },
622
+ "onUpdate:modelValue": (value: RangeDate) => {
623
+ component.setProps({ modelValue: value });
624
+ },
625
+ },
626
+ });
627
+
628
+ await component.get("[vl-key='rangeButtonSelect']").trigger("click");
629
+ await findPeriodButton(component, "year").trigger("click");
630
+ await component.get(`[data-test="${menuPrefix}-custom-range"]`).trigger("click");
631
+
632
+ await component.get(`[data-test="${dataTest}-button-${direction}"]`).trigger("click");
633
+
634
+ const { from, to } = component.props("modelValue") as {
635
+ from: Date;
636
+ to: Date;
637
+ };
638
+
639
+ expect(from.toDateString()).toBe(expectedFrom.toDateString());
640
+ expect(to.toDateString()).toBe(expectedTo.toDateString());
641
+ },
642
+ );
643
+ });
644
+
443
645
  describe("Menu", () => {
444
646
  it("Menu – opens when input is focused", async () => {
445
647
  const component = mount(UDatePickerRange, {
@@ -1,5 +1,5 @@
1
1
  import defaultConfig from "./config";
2
- import { ShiftAction } from "./constants";
2
+ import { ShiftAction, Period } from "./constants";
3
3
 
4
4
  import type { Ref } from "vue";
5
5
  import type { UnknownObject, ComponentConfig, GetDataTest } from "../types";
@@ -9,6 +9,7 @@ export type Locale = typeof defaultConfig.i18n;
9
9
  export type Config = typeof defaultConfig;
10
10
  export type IsDatePeriodOutOfRange = (datePeriod: DatePeriodRange) => boolean;
11
11
  export type ShiftActions = `${ShiftAction}`;
12
+ export type PeriodType = `${Period}`;
12
13
 
13
14
  export interface SortedLocale extends Omit<Locale, "weekdays" | "months"> {
14
15
  months: {
@@ -53,6 +54,8 @@ export interface UDatePickerRangePeriodMenuProps {
53
54
  locale: SortedLocale;
54
55
  dateFormat: string | undefined;
55
56
  isPeriod: IsPeriod;
57
+ allowedPeriods: readonly Period[];
58
+ isPeriodSwitchShown: boolean;
56
59
  maxDate: string | Date | undefined;
57
60
  minDate: string | Date | undefined;
58
61
  customRangeButton: CustomRangeButton;
@@ -122,6 +125,11 @@ export interface Props<TModelValue> {
122
125
  */
123
126
  customRangeButton?: CustomRangeButton;
124
127
 
128
+ /**
129
+ * Period types available for selection.
130
+ */
131
+ periods?: PeriodType[];
132
+
125
133
  /**
126
134
  * Datepicker open direction on x-axis.
127
135
  * @extendOnly
@@ -179,6 +187,11 @@ export interface Props<TModelValue> {
179
187
  */
180
188
  rightIcon?: string;
181
189
 
190
+ /**
191
+ * Extra CSS classes to apply to each day cell, on top of the built-in state classes.
192
+ */
193
+ dayClasses?: (day: Date) => string;
194
+
182
195
  /**
183
196
  * Unique element id.
184
197
  */
@@ -13,6 +13,7 @@ export function cacheMergedConfigs({ vuelessSrcDir, basePath }?: {
13
13
  }): Promise<void>;
14
14
  export function buildTSFile(entryPath: string, configOutFile: string): Promise<void>;
15
15
  export function removeFolderIfEmpty(dirPath: string): Promise<void>;
16
+ export function getAppName(): string;
16
17
  export function detectTypeScript(): Promise<boolean>;
17
18
  export function autoImportUserConfigs(basePath?: string): Promise<void>;
18
19
  export function generateConfigIndexContent(imports?: string[], componentEntries?: string[]): Promise<string>;
@@ -2,7 +2,7 @@ import { build as rolldownBuild } from "rolldown";
2
2
  import path from "node:path";
3
3
  import { cwd } from "node:process";
4
4
  import { pathToFileURL } from "node:url";
5
- import { existsSync, statSync } from "node:fs";
5
+ import { existsSync, statSync, readFileSync } from "node:fs";
6
6
  import { mkdir, readdir, rmdir, readFile, writeFile } from "node:fs/promises";
7
7
 
8
8
  import { getMergedConfig, getVuelessConfig } from "./vuelessConfig.js";
@@ -226,6 +226,18 @@ export async function removeFolderIfEmpty(dirPath) {
226
226
  }
227
227
  }
228
228
 
229
+ /**
230
+ * Reads the app name from the project's package.json (used to prefix theme cookies).
231
+ * @returns {string} The package name, or an empty string if unavailable.
232
+ */
233
+ export function getAppName() {
234
+ try {
235
+ return JSON.parse(readFileSync(path.join(cwd(), "package.json"), "utf-8")).name || "";
236
+ } catch {
237
+ return "";
238
+ }
239
+ }
240
+
229
241
  /**
230
242
  * Detects if TypeScript is a dependency in the project's package.json
231
243
  * @returns {Promise<boolean>} True if TypeScript is found in dependencies or devDependencies
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ import { AUTO_MODE_KEY, COLOR_MODE_KEY } from "../../constants.js";
4
+ import { ColorMode } from "../../types";
5
+ import type { Config } from "../../types";
6
+
7
+ const mockVuelessConfig: Config = {};
8
+
9
+ vi.mock("../ui", () => ({ vuelessConfig: mockVuelessConfig }));
10
+
11
+ /* jsdom doesn't implement matchMedia, which theme.ts evaluates on import. */
12
+ const prefersDark = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
13
+
14
+ window.matchMedia = vi.fn(() => prefersDark) as unknown as typeof window.matchMedia;
15
+
16
+ const { getTheme, getThemeCookieName, resetTheme } = await import("../theme");
17
+
18
+ function setConfig(config: Config) {
19
+ Object.keys(mockVuelessConfig).forEach((key) => delete mockVuelessConfig[key as keyof Config]);
20
+ Object.assign(mockVuelessConfig, config);
21
+ }
22
+
23
+ function clearCookies() {
24
+ document.cookie.split("; ").forEach((cookie) => {
25
+ const name = cookie.split("=")[0];
26
+
27
+ if (name) document.cookie = `${name}=; max-age=-1; path=/`;
28
+ });
29
+ }
30
+
31
+ beforeEach(() => {
32
+ localStorage.clear();
33
+ clearCookies();
34
+ setConfig({});
35
+ });
36
+
37
+ describe("getThemeCookieName", () => {
38
+ it("prefixes a key with the app name from package.json", () => {
39
+ expect(getThemeCookieName(COLOR_MODE_KEY)).toBe(`vueless-${COLOR_MODE_KEY}`);
40
+ });
41
+
42
+ it("makes the prefix cookie-safe", () => {
43
+ setConfig({ cookiePrefix: "@acme/Back_Office!" });
44
+
45
+ expect(getThemeCookieName(COLOR_MODE_KEY)).toBe(`acme-back_office-${COLOR_MODE_KEY}`);
46
+ });
47
+
48
+ it("lets cookiePrefix override the app name", () => {
49
+ setConfig({ cookiePrefix: "app-a" });
50
+
51
+ expect(getThemeCookieName(COLOR_MODE_KEY)).toBe(`app-a-${COLOR_MODE_KEY}`);
52
+ });
53
+
54
+ it("returns the legacy name when cookiePrefix is an empty string", () => {
55
+ setConfig({ cookiePrefix: "" });
56
+
57
+ expect(getThemeCookieName(COLOR_MODE_KEY)).toBe(COLOR_MODE_KEY);
58
+ });
59
+ });
60
+
61
+ describe("theme value storage", () => {
62
+ it("writes the cookie prefixed and the localStorage key raw", () => {
63
+ setConfig({ cookiePrefix: "app-a" });
64
+
65
+ getTheme({ colorMode: ColorMode.Dark });
66
+
67
+ expect(localStorage.getItem(COLOR_MODE_KEY)).toBe(ColorMode.Dark);
68
+ expect(document.cookie).toContain(`app-a-${COLOR_MODE_KEY}=${ColorMode.Dark}`);
69
+ expect(document.cookie.split("; ")).not.toContain(`${COLOR_MODE_KEY}=${ColorMode.Dark}`);
70
+ });
71
+
72
+ it("deletes the prefixed cookies on resetTheme", () => {
73
+ setConfig({ cookiePrefix: "app-a" });
74
+
75
+ getTheme({ colorMode: ColorMode.Dark });
76
+ resetTheme();
77
+
78
+ expect(localStorage.getItem(COLOR_MODE_KEY)).toBeNull();
79
+ expect(document.cookie).not.toContain(`app-a-${COLOR_MODE_KEY}=${ColorMode.Dark}`);
80
+ });
81
+ });
82
+
83
+ describe("CSR color mode precedence", () => {
84
+ it("prefers the explicit mode over everything else", () => {
85
+ localStorage.setItem(COLOR_MODE_KEY, ColorMode.Light);
86
+ setConfig({ colorMode: ColorMode.Light });
87
+
88
+ expect(getTheme({ colorMode: ColorMode.Dark }).colorMode).toBe(ColorMode.Dark);
89
+ });
90
+
91
+ it("falls back to localStorage when no mode is passed", () => {
92
+ localStorage.setItem(COLOR_MODE_KEY, ColorMode.Dark);
93
+ setConfig({ colorMode: ColorMode.Light });
94
+
95
+ expect(getTheme().colorMode).toBe(ColorMode.Dark);
96
+ });
97
+
98
+ it("ignores cookies when localStorage is empty", () => {
99
+ document.cookie = `${COLOR_MODE_KEY}=${ColorMode.Dark}; path=/`;
100
+ document.cookie = `vueless-${COLOR_MODE_KEY}=${ColorMode.Dark}; path=/`;
101
+ setConfig({ colorMode: ColorMode.Light });
102
+
103
+ expect(getTheme().colorMode).toBe(ColorMode.Light);
104
+ });
105
+
106
+ it("falls back to vuelessConfig.colorMode", () => {
107
+ setConfig({ colorMode: ColorMode.Dark });
108
+
109
+ expect(getTheme().colorMode).toBe(ColorMode.Dark);
110
+ });
111
+
112
+ it("falls back to light when nothing is set", () => {
113
+ expect(getTheme().colorMode).toBe(ColorMode.Light);
114
+ });
115
+ });
116
+
117
+ describe("auto color mode", () => {
118
+ it("reports auto mode from the cached localStorage flag", () => {
119
+ localStorage.setItem(AUTO_MODE_KEY, "1");
120
+ localStorage.setItem(COLOR_MODE_KEY, ColorMode.Dark);
121
+
122
+ expect(getTheme().isColorModeAuto).toBe(true);
123
+ });
124
+ });