@dashforge/tw 0.9.0-beta → 0.9.1-beta

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/index.esm.js +629 -67
  3. package/dist/src/components/Calendar/Calendar.d.ts +14 -0
  4. package/dist/src/components/Calendar/Calendar.d.ts.map +1 -0
  5. package/dist/src/components/Calendar/calendar.types.d.ts +72 -0
  6. package/dist/src/components/Calendar/calendar.types.d.ts.map +1 -0
  7. package/dist/src/components/Calendar/calendar.variants.d.ts +99 -0
  8. package/dist/src/components/Calendar/calendar.variants.d.ts.map +1 -0
  9. package/dist/src/components/DatePicker/DatePicker.d.ts +14 -0
  10. package/dist/src/components/DatePicker/DatePicker.d.ts.map +1 -0
  11. package/dist/src/components/DatePicker/datePicker.types.d.ts +88 -0
  12. package/dist/src/components/DatePicker/datePicker.types.d.ts.map +1 -0
  13. package/dist/src/components/DatePicker/datePicker.variants.d.ts +97 -0
  14. package/dist/src/components/DatePicker/datePicker.variants.d.ts.map +1 -0
  15. package/dist/src/index.d.ts +6 -0
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/package.json +2 -1
  18. package/src/components/Calendar/Calendar.test.tsx +75 -0
  19. package/src/components/Calendar/Calendar.tsx +264 -0
  20. package/src/components/Calendar/calendar.types.ts +65 -0
  21. package/src/components/Calendar/calendar.variants.ts +64 -0
  22. package/src/components/DatePicker/DatePicker.test.tsx +76 -0
  23. package/src/components/DatePicker/DatePicker.tsx +305 -0
  24. package/src/components/DatePicker/datePicker.types.ts +77 -0
  25. package/src/components/DatePicker/datePicker.variants.ts +50 -0
  26. package/src/index.ts +21 -1
  27. package/tsconfig.lib.json +7 -4
@@ -0,0 +1,264 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { KeyboardEvent } from 'react';
3
+ import {
4
+ formatLongDate,
5
+ resolveCalendarKey,
6
+ useCalendar,
7
+ } from '@dashforge/calendar-core';
8
+ import type { ISODate } from '@dashforge/calendar-core';
9
+ import { cn } from '../../utils/cn.js';
10
+ import { calendarDayVariants, calendarVariants } from './calendar.variants.js';
11
+ import type { CalendarProps } from './calendar.types.js';
12
+
13
+ // Inline 16×16 stroke chevrons — no icon dependency (tw convention).
14
+ function ChevronLeftIcon() {
15
+ return (
16
+ <svg
17
+ width="1em"
18
+ height="1em"
19
+ viewBox="0 0 16 16"
20
+ fill="none"
21
+ aria-hidden="true"
22
+ >
23
+ <path
24
+ d="M10 4 6 8l4 4"
25
+ stroke="currentColor"
26
+ strokeWidth="1.5"
27
+ strokeLinecap="round"
28
+ strokeLinejoin="round"
29
+ />
30
+ </svg>
31
+ );
32
+ }
33
+
34
+ function ChevronRightIcon() {
35
+ return (
36
+ <svg
37
+ width="1em"
38
+ height="1em"
39
+ viewBox="0 0 16 16"
40
+ fill="none"
41
+ aria-hidden="true"
42
+ >
43
+ <path
44
+ d="m6 4 4 4-4 4"
45
+ stroke="currentColor"
46
+ strokeWidth="1.5"
47
+ strokeLinecap="round"
48
+ strokeLinejoin="round"
49
+ />
50
+ </svg>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * `<Calendar>` — a standalone, inline month-grid date picker.
56
+ *
57
+ * The Tailwind presentation layer over the headless `useCalendar` engine
58
+ * from `@dashforge/calendar-core` (the same engine the MUI `@dashforge/ui`
59
+ * calendar consumes — only the skin differs). Implements the WCAG grid
60
+ * pattern with roving tab-index focus.
61
+ *
62
+ * It is a primitive: no form-bridge / RBAC integration — use `<DatePicker>`
63
+ * for a form-bound date field.
64
+ */
65
+ export function Calendar(props: CalendarProps) {
66
+ const {
67
+ value,
68
+ defaultValue,
69
+ onChange,
70
+ month,
71
+ year,
72
+ defaultMonth,
73
+ defaultYear,
74
+ onMonthChange,
75
+ minDate,
76
+ maxDate,
77
+ disabledDates,
78
+ isDateDisabled,
79
+ weekStartDay,
80
+ locale,
81
+ today,
82
+ disabled = false,
83
+ autoFocus = false,
84
+ sx,
85
+ slotProps,
86
+ testId,
87
+ 'aria-label': ariaLabel = 'Calendar',
88
+ } = props;
89
+
90
+ const isValueControlled = value !== undefined;
91
+ const [internalValue, setInternalValue] = useState<ISODate | null>(
92
+ defaultValue ?? null,
93
+ );
94
+ const selectedValue: ISODate | null = isValueControlled
95
+ ? value
96
+ : internalValue;
97
+
98
+ const handleSelect = useCallback(
99
+ (iso: ISODate) => {
100
+ if (!isValueControlled) {
101
+ setInternalValue(iso);
102
+ }
103
+ onChange?.(iso);
104
+ },
105
+ [isValueControlled, onChange],
106
+ );
107
+
108
+ const calendar = useCalendar({
109
+ selected: selectedValue,
110
+ month,
111
+ year,
112
+ defaultMonth,
113
+ defaultYear,
114
+ onMonthChange,
115
+ minDate,
116
+ maxDate,
117
+ disabledDates,
118
+ isDateDisabled,
119
+ weekStartDay,
120
+ locale,
121
+ today,
122
+ onSelect: handleSelect,
123
+ });
124
+
125
+ // Roving focus: the active day cell holds DOM focus; it is moved only
126
+ // after a keyboard interaction (or on mount when `autoFocus`). `focus`
127
+ // uses `preventScroll` so opening the calendar inside a popup never
128
+ // scroll-jumps the page before the popup is positioned.
129
+ const focusedCellRef = useRef<HTMLButtonElement | null>(null);
130
+ const shouldFocusRef = useRef(autoFocus);
131
+
132
+ useEffect(() => {
133
+ if (shouldFocusRef.current && focusedCellRef.current) {
134
+ focusedCellRef.current.focus({ preventScroll: true });
135
+ shouldFocusRef.current = false;
136
+ }
137
+ }, [calendar.focusedDate]);
138
+
139
+ const handleKeyDown = useCallback(
140
+ (event: KeyboardEvent<HTMLDivElement>) => {
141
+ if (disabled) {
142
+ return;
143
+ }
144
+ const action = resolveCalendarKey(event.key);
145
+ if (action === null) {
146
+ return;
147
+ }
148
+ event.preventDefault();
149
+ shouldFocusRef.current = true;
150
+ switch (action) {
151
+ case 'select':
152
+ calendar.selectDate(calendar.focusedDate);
153
+ break;
154
+ case 'monthForward':
155
+ calendar.goToNextMonth();
156
+ break;
157
+ case 'monthBackward':
158
+ calendar.goToPreviousMonth();
159
+ break;
160
+ default:
161
+ calendar.navigate(action);
162
+ }
163
+ },
164
+ [disabled, calendar],
165
+ );
166
+
167
+ const resolvedLocale = locale ?? 'en-US';
168
+ const v = calendarVariants({ disabled });
169
+
170
+ return (
171
+ <div
172
+ data-testid={testId}
173
+ className={cn(v.root(), sx, slotProps?.root?.className)}
174
+ >
175
+ {/* Header: month navigation + label */}
176
+ <div className={cn(v.header(), slotProps?.header?.className)}>
177
+ <button
178
+ type="button"
179
+ className={v.navButton()}
180
+ disabled={disabled}
181
+ aria-label="Previous month"
182
+ onClick={() => {
183
+ calendar.goToPreviousMonth();
184
+ }}
185
+ >
186
+ <ChevronLeftIcon />
187
+ </button>
188
+ <span aria-live="polite" className={v.monthLabel()}>
189
+ {calendar.monthLabel}
190
+ </span>
191
+ <button
192
+ type="button"
193
+ className={v.navButton()}
194
+ disabled={disabled}
195
+ aria-label="Next month"
196
+ onClick={() => {
197
+ calendar.goToNextMonth();
198
+ }}
199
+ >
200
+ <ChevronRightIcon />
201
+ </button>
202
+ </div>
203
+
204
+ {/* Grid */}
205
+ <div
206
+ role="grid"
207
+ aria-label={ariaLabel}
208
+ onKeyDown={handleKeyDown}
209
+ className={cn(v.grid(), slotProps?.grid?.className)}
210
+ >
211
+ <div role="row" className={v.weekdayRow()}>
212
+ {calendar.weekdayNames.map((dayName, index) => (
213
+ <span
214
+ key={`weekday-${String(index)}`}
215
+ role="columnheader"
216
+ className={v.weekday()}
217
+ >
218
+ {dayName}
219
+ </span>
220
+ ))}
221
+ </div>
222
+
223
+ {calendar.weeks.map((week) => (
224
+ <div key={week.key} role="row" className={v.weekRow()}>
225
+ {week.days.map((day) => {
226
+ const isActiveCell = day.iso === calendar.focusedDate;
227
+ return (
228
+ <span key={day.iso} role="gridcell" className="flex">
229
+ <button
230
+ type="button"
231
+ ref={isActiveCell ? focusedCellRef : null}
232
+ tabIndex={isActiveCell ? 0 : -1}
233
+ disabled={disabled}
234
+ aria-label={formatLongDate(
235
+ { year: day.year, month: day.month, day: day.day },
236
+ resolvedLocale,
237
+ )}
238
+ aria-current={day.isToday ? 'date' : undefined}
239
+ aria-pressed={day.isSelected}
240
+ aria-disabled={day.isDisabled}
241
+ onClick={() => {
242
+ calendar.selectDate(day.iso);
243
+ }}
244
+ className={cn(
245
+ calendarDayVariants({
246
+ siblingMonth: day.isSiblingMonth,
247
+ today: day.isToday,
248
+ selected: day.isSelected,
249
+ disabled: day.isDisabled,
250
+ }),
251
+ slotProps?.day?.className,
252
+ )}
253
+ >
254
+ {day.day}
255
+ </button>
256
+ </span>
257
+ );
258
+ })}
259
+ </div>
260
+ ))}
261
+ </div>
262
+ </div>
263
+ );
264
+ }
@@ -0,0 +1,65 @@
1
+ import type { ISODate, WeekDay } from '@dashforge/calendar-core';
2
+
3
+ /** Per-slot `className` overrides for `<Calendar>`. */
4
+ export interface CalendarSlotProps {
5
+ root?: { className?: string };
6
+ header?: { className?: string };
7
+ grid?: { className?: string };
8
+ /** Applied to every day-cell button. */
9
+ day?: { className?: string };
10
+ }
11
+
12
+ /**
13
+ * Props for the `<Calendar>` primitive — a standalone, inline month grid.
14
+ *
15
+ * `Calendar` is NOT a form field (no bridge / RBAC integration). It is the
16
+ * low-level building block used inside `<DatePicker>`. For a form-bound date
17
+ * input use `<DatePicker>`.
18
+ *
19
+ * Dates are ISO `YYYY-MM-DD` strings throughout. The headless date engine is
20
+ * `@dashforge/calendar-core` — shared with the MUI `@dashforge/ui` calendar.
21
+ */
22
+ export interface CalendarProps {
23
+ /** Selected date (controlled). */
24
+ value?: ISODate | null;
25
+ /** Initial selected date (uncontrolled). */
26
+ defaultValue?: ISODate | null;
27
+ /** Fired when a day is chosen. */
28
+ onChange?: (value: ISODate) => void;
29
+ /** Controlled displayed month (1-indexed). Pair with `year`. */
30
+ month?: number;
31
+ /** Controlled displayed year. Pair with `month`. */
32
+ year?: number;
33
+ /** Initial displayed month (1-indexed, uncontrolled). */
34
+ defaultMonth?: number;
35
+ /** Initial displayed year (uncontrolled). */
36
+ defaultYear?: number;
37
+ /** Fired when the displayed month changes. */
38
+ onMonthChange?: (month: number, year: number) => void;
39
+ /** Earliest selectable date (inclusive). */
40
+ minDate?: ISODate;
41
+ /** Latest selectable date (inclusive). */
42
+ maxDate?: ISODate;
43
+ /** Explicit list of disabled dates. */
44
+ disabledDates?: readonly ISODate[];
45
+ /** Predicate marking arbitrary dates disabled. */
46
+ isDateDisabled?: (date: ISODate) => boolean;
47
+ /** Weekday of the grid's first column (`0` = Sunday). Default `0`. */
48
+ weekStartDay?: WeekDay;
49
+ /** BCP-47 locale for month/weekday names. Default `"en-US"`. */
50
+ locale?: string;
51
+ /** Overrides "today" (ISO `YYYY-MM-DD`), mainly for testing. */
52
+ today?: ISODate;
53
+ /** Disables the whole calendar. */
54
+ disabled?: boolean;
55
+ /** Moves DOM focus to the active day cell on mount. */
56
+ autoFocus?: boolean;
57
+ /** Accessible label for the grid. Default `"Calendar"`. */
58
+ 'aria-label'?: string;
59
+ /** Root-level Tailwind class override. */
60
+ sx?: string;
61
+ /** Per-slot `className` overrides. */
62
+ slotProps?: CalendarSlotProps;
63
+ /** Test id applied to the root element. */
64
+ testId?: string;
65
+ }
@@ -0,0 +1,64 @@
1
+ import { tv } from 'tailwind-variants';
2
+
3
+ /**
4
+ * Structural slots for `<Calendar>`.
5
+ *
6
+ * Theme identity: every class is a bare neutral / brand token-backed
7
+ * Tailwind class. The `dashforgePreset()` CSS-variable swap auto-inverts
8
+ * the neutral palette, so there are NO `dark:` variants here (a `dark:` on
9
+ * a neutral class would double-invert — see `_shared/themeIdentity.test.ts`).
10
+ */
11
+ export const calendarVariants = tv({
12
+ slots: {
13
+ root: 'inline-flex w-fit flex-col gap-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 select-none',
14
+ header: 'flex items-center justify-between gap-1',
15
+ navButton:
16
+ 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[1rem] text-neutral-600 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:pointer-events-none disabled:opacity-40',
17
+ monthLabel: 'flex-1 text-center text-sm font-semibold text-neutral-900',
18
+ grid: 'flex flex-col gap-1 focus:outline-none',
19
+ weekdayRow: 'grid grid-cols-7 gap-1',
20
+ weekday:
21
+ 'flex h-8 items-center justify-center text-xs font-semibold text-neutral-500',
22
+ weekRow: 'grid grid-cols-7 gap-1',
23
+ },
24
+ variants: {
25
+ disabled: {
26
+ true: { root: 'opacity-60' },
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ disabled: false,
31
+ },
32
+ });
33
+
34
+ /**
35
+ * Per-day-cell recipe. Each of the 42 grid cells is rendered with its own
36
+ * state (`siblingMonth` / `today` / `selected` / `disabled`).
37
+ */
38
+ export const calendarDayVariants = tv({
39
+ base: 'flex h-9 w-9 items-center justify-center rounded-md text-sm text-neutral-900 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500',
40
+ variants: {
41
+ siblingMonth: {
42
+ true: 'text-neutral-400',
43
+ },
44
+ today: {
45
+ true: 'font-bold ring-1 ring-inset ring-primary-400',
46
+ },
47
+ selected: {
48
+ true: 'bg-primary-600 text-white hover:bg-primary-600',
49
+ },
50
+ disabled: {
51
+ true: 'cursor-default text-neutral-300 opacity-60 hover:bg-transparent',
52
+ },
53
+ },
54
+ compoundVariants: [
55
+ // A selected cell drops the "today" ring — the solid fill is enough.
56
+ { selected: true, today: true, class: 'ring-0' },
57
+ ],
58
+ defaultVariants: {
59
+ siblingMonth: false,
60
+ today: false,
61
+ selected: false,
62
+ disabled: false,
63
+ },
64
+ });
@@ -0,0 +1,76 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeAll } from 'vitest';
3
+ import { render, screen, fireEvent } from '@testing-library/react';
4
+
5
+ import { DatePicker } from './DatePicker.js';
6
+
7
+ beforeAll(() => {
8
+ // Radix Popover (Floating UI) needs ResizeObserver — jsdom lacks it.
9
+ if (!('ResizeObserver' in globalThis)) {
10
+ class ResizeObserverStub {
11
+ observe(): void {
12
+ /* no-op */
13
+ }
14
+ unobserve(): void {
15
+ /* no-op */
16
+ }
17
+ disconnect(): void {
18
+ /* no-op */
19
+ }
20
+ }
21
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver =
22
+ ResizeObserverStub;
23
+ }
24
+ });
25
+
26
+ describe('DatePicker', () => {
27
+ it('renders a labelled trigger with the placeholder', () => {
28
+ render(
29
+ <DatePicker name="birthday" label="Birthday" placeholder="Pick one" />,
30
+ );
31
+ expect(screen.queryByText('Birthday')).not.toBeNull();
32
+ expect(screen.getByRole('button').textContent).toContain('Pick one');
33
+ });
34
+
35
+ it('shows a localized controlled value', () => {
36
+ render(<DatePicker name="d" value="2026-05-20" />);
37
+ expect(screen.getByRole('button').textContent).toContain('May 20, 2026');
38
+ });
39
+
40
+ it('opens and toggles the calendar popover from the trigger', () => {
41
+ render(<DatePicker name="d" defaultValue="2026-05-20" />);
42
+ const trigger = screen.getByRole('button');
43
+ expect(screen.queryByRole('dialog')).toBeNull();
44
+ fireEvent.click(trigger);
45
+ expect(screen.queryByRole('dialog')).not.toBeNull();
46
+ fireEvent.click(trigger);
47
+ expect(screen.queryByRole('dialog')).toBeNull();
48
+ });
49
+
50
+ it('selects a date from the popover, updates the trigger and closes', () => {
51
+ const onChange = vi.fn();
52
+ render(
53
+ <DatePicker name="d" defaultValue="2026-05-20" onChange={onChange} />,
54
+ );
55
+ fireEvent.click(screen.getByRole('button'));
56
+ fireEvent.click(screen.getByRole('button', { name: /May 15, 2026/ }));
57
+ expect(onChange).toHaveBeenCalledWith('2026-05-15');
58
+ expect(screen.queryByRole('dialog')).toBeNull();
59
+ expect(screen.getByRole('button').textContent).toContain('May 15, 2026');
60
+ });
61
+
62
+ it('keeps a controlled value fixed but still reports the change', () => {
63
+ const onChange = vi.fn();
64
+ render(<DatePicker name="d" value="2026-05-20" onChange={onChange} />);
65
+ fireEvent.click(screen.getByRole('button'));
66
+ fireEvent.click(screen.getByRole('button', { name: /May 15, 2026/ }));
67
+ expect(onChange).toHaveBeenCalledWith('2026-05-15');
68
+ expect(screen.getByRole('button').textContent).toContain('May 20, 2026');
69
+ });
70
+
71
+ it('does not open the popover when disabled', () => {
72
+ render(<DatePicker name="d" disabled />);
73
+ fireEvent.click(screen.getByRole('button'));
74
+ expect(screen.queryByRole('dialog')).toBeNull();
75
+ });
76
+ });