@ahrowe/ui 0.38.0 → 0.39.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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs-types/common/datePicker/components/datePickerMonth/datePickerMonth.types.d.ts +6 -6
  3. package/dist/cjs-types/common/datePicker/dateFunctions.d.ts +10 -19
  4. package/dist/cjs-types/common/datePicker/datePicker.types.d.ts +20 -14
  5. package/dist/cjs-types/common/datePicker/index.d.ts +1 -0
  6. package/dist/cjs-types/common/popover/popover.types.d.ts +5 -0
  7. package/dist/cjs-types/common/utils/day.d.ts +16 -0
  8. package/dist/cjs-types/index.d.ts +1 -1
  9. package/dist/esm/common/datePicker/components/datePickerMonth/datePickerMonth.mjs +1 -1
  10. package/dist/esm/common/datePicker/components/datePickerMonth/datePickerMonth.mjs.map +1 -1
  11. package/dist/esm/common/datePicker/dateFunctions.mjs +1 -1
  12. package/dist/esm/common/datePicker/dateFunctions.mjs.map +1 -1
  13. package/dist/esm/common/datePicker/datePicker.mjs +1 -1
  14. package/dist/esm/common/datePicker/datePicker.mjs.map +1 -1
  15. package/dist/esm/common/hooks/useFocusBoundary.mjs +1 -1
  16. package/dist/esm/common/hooks/useFocusBoundary.mjs.map +1 -1
  17. package/dist/esm/common/popover/popover.mjs +1 -1
  18. package/dist/esm/common/popover/popover.mjs.map +1 -1
  19. package/dist/esm/common/popover/popover.types.mjs.map +1 -1
  20. package/dist/esm/common/utils/day.mjs +2 -0
  21. package/dist/esm/common/utils/day.mjs.map +1 -0
  22. package/dist/esm/index.mjs +1 -1
  23. package/dist/index.cjs +3 -3
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/types/common/datePicker/components/datePickerMonth/datePickerMonth.types.d.ts +6 -6
  26. package/dist/types/common/datePicker/dateFunctions.d.ts +10 -19
  27. package/dist/types/common/datePicker/datePicker.types.d.ts +20 -14
  28. package/dist/types/common/datePicker/index.d.ts +1 -0
  29. package/dist/types/common/popover/popover.types.d.ts +5 -0
  30. package/dist/types/common/utils/day.d.ts +16 -0
  31. package/dist/types/index.d.ts +1 -1
  32. package/docs/CLAUDE.md +5 -1
  33. package/docs/DatePicker.md +72 -45
  34. package/docs/FormValidator.md +3 -3
  35. package/docs/Popover.md +15 -0
  36. package/package.json +2 -2
@@ -8,16 +8,16 @@ export interface DatePickerMonthProps {
8
8
  month?: number;
9
9
  /** Year to display. */
10
10
  year?: number;
11
- /** Called with the day that was picked. */
12
- onSelect?: (date: Date) => void;
11
+ /** Called with the day that was picked, `'YYYY-MM-DD'`. */
12
+ onSelect?: (day: string) => void;
13
13
  /** First day of the highlighted range, and the day that starts with the tab stop. */
14
- selectionStart?: Date;
14
+ selectionStart?: string | null;
15
15
  /** Last day of the highlighted range. */
16
- selectionEnd?: Date;
16
+ selectionEnd?: string | null;
17
17
  /** Days before this are not selectable. */
18
- minDate?: Date | null;
18
+ minDate?: string | null;
19
19
  /** Days after this are not selectable. */
20
- maxDate?: Date | null;
20
+ maxDate?: string | null;
21
21
  /** Called to shift the displayed month by ±1 when arrow navigation runs past its edge. */
22
22
  onNavigateMonth?: (delta: 1 | -1) => void;
23
23
  }
@@ -1,26 +1,17 @@
1
- export declare function getDaysInMonth(month: number, year: number): number;
2
- export declare function getDateFromDayMonthAndYear(day: number, month: number, year: number): Date;
1
+ import { DayParts } from '../utils/day.js';
3
2
  export declare function getMonthName(locale: string, year: number, month: number, day: number, short?: boolean): string;
4
3
  export declare function getMonthNames(locale?: string): string[];
5
4
  export declare function getWeekDays(locale: string, short?: boolean): string[];
5
+ /** 1 (Monday) to 7 (Sunday). */
6
6
  export declare function getStartWeekDayOfMonth(month: number, year: number): number;
7
- /**
8
- * Adds `delta` days to a day/month/year triple, rolling over into adjacent months/years as
9
- * needed. Pure integer arithmetic over `getDaysInMonth` rather than a `Date` object, so it
10
- * can't drift a day from local-vs-UTC ambiguity the way mutating a UTC-constructed `Date`
11
- * with its local `setDate` would.
12
- */
13
- export declare function addDays(day: number, month: number, year: number, delta: number): {
14
- day: number;
15
- month: number;
16
- year: number;
17
- };
18
- export declare function getDayMonthAndYearOfDate(date: Date): {
19
- day: number;
20
- month: number;
21
- year: number;
22
- };
23
- export declare function getDateString(date: Date): string;
7
+ /** `delta` days from a day, rolling over into the months and years around it. */
8
+ export declare function addDays(day: number, month: number, year: number, delta: number): DayParts;
9
+ /** Today in the user's time zone. */
10
+ export declare function getToday(): DayParts;
11
+ /** A `'YYYY-MM-DD'` day as the field shows it, `dd.mm.yyyy`, or `''` for anything else. */
12
+ export declare function getDateString(value: string | null | undefined): string;
13
+ /** What the field holds, `dd.mm.yyyy`, as `'YYYY-MM-DD'`; `null` while incomplete, or for a day that does not exist. */
14
+ export declare function parseDateString(text: string): string | null;
24
15
  export interface DateMaskSelection {
25
16
  start: number;
26
17
  end: number;
@@ -3,8 +3,10 @@ import { FormValidator } from '../../services/formValidation/index.js';
3
3
  import { HtmlProps, SlotClassNames, SlotStyles } from '../types/slots.types.js';
4
4
  export type DatePickerSlots = 'root' | 'input' | 'header' | 'monthDropdown' | 'yearDropdown' | 'calendar' | 'clearButton';
5
5
  /**
6
- * `onChange` and `onSelect` report a date rather than a DOM event, so the native
6
+ * `onChange` and `onSelect` report a day rather than a DOM event, so the native
7
7
  * handlers of those names are dropped instead of being widened to accept both.
8
+ *
9
+ * Every day is a `'YYYY-MM-DD'` string, never a `Date`: see docs/DatePicker.md.
8
10
  */
9
11
  export interface DatePickerProps extends Omit<HtmlProps<HTMLDivElement>, 'onChange' | 'onSelect'> {
10
12
  /** Class on the root element. */
@@ -12,33 +14,37 @@ export interface DatePickerProps extends Omit<HtmlProps<HTMLDivElement>, 'onChan
12
14
  style?: CSSProperties;
13
15
  classNames?: SlotClassNames<DatePickerSlots>;
14
16
  styles?: SlotStyles<DatePickerSlots>;
15
- /** Selected date, controlled. Ignored while a `formValidator` owns the value. */
16
- selected?: Date;
17
+ /** Selected day, `'YYYY-MM-DD'`, controlled. Ignored while a `formValidator` owns the value. */
18
+ selected?: string | null;
17
19
  /** Floating label on the input. */
18
20
  label?: string;
19
21
  /**
20
22
  * Called when a day is picked in the calendar, after `onChange`. Picking also
21
23
  * closes the picker.
22
24
  */
23
- onSelect?: (date: Date | null) => void;
25
+ onSelect?: (day: string | null) => void;
24
26
  /**
25
27
  * Called whenever the value changes, whether picked in the calendar or typed
26
- * into the input. `null` when the field was cleared or holds no valid date.
28
+ * into the input. `null` when the field was cleared or holds no valid day.
27
29
  */
28
- onChange?: (date: Date | null) => void;
29
- /** Hands the value to a validator, which then owns it. See docs/FormValidator.md. */
30
- formValidator?: FormValidator | null;
31
- /** Earliest selectable date. Also bounds the year dropdown. */
32
- minDate?: Date | null;
33
- /** Latest selectable date. Also bounds the year dropdown. */
34
- maxDate?: Date | null;
30
+ onChange?: (day: string | null) => void;
31
+ /**
32
+ * Hands the value to a validator, which then owns it. See docs/FormValidator.md. The three
33
+ * forms are what TypeScript infers for `new FormValidator<string | null>(null)`,
34
+ * `new FormValidator(null)` and `new FormValidator('')`; a `Date` validator fits none of them.
35
+ */
36
+ formValidator?: FormValidator<string | null> | FormValidator<string> | FormValidator<null> | null;
37
+ /** Earliest selectable day, `'YYYY-MM-DD'`. Also bounds the year dropdown. */
38
+ minDate?: string | null;
39
+ /** Latest selectable day, `'YYYY-MM-DD'`. Also bounds the year dropdown. */
40
+ maxDate?: string | null;
35
41
  /**
36
42
  * Start of a highlighted range. Defaults to the selected date, so two pickers
37
43
  * sharing a start and an end highlight the span between them.
38
44
  */
39
- startDate?: Date;
45
+ startDate?: string | null;
40
46
  /** End of the highlighted range. */
41
- endDate?: Date;
47
+ endDate?: string | null;
42
48
  /** Render the calendar in place instead of in a popover. */
43
49
  isInline?: boolean;
44
50
  /** Open the calendar in a modal, with the input read-only. */
@@ -1,2 +1,3 @@
1
1
  export { default } from './datePicker.js';
2
2
  export * from './datePicker.types.js';
3
+ export { dateToDay, dayToDate } from '../utils/day.js';
@@ -24,6 +24,11 @@ export type PopoverSlots = 'root' | 'trigger' | 'floating' | 'panel' | 'arrow' |
24
24
  export interface PopoverProps extends Omit<HtmlProps, 'content'> {
25
25
  /** The trigger element. */
26
26
  children?: ReactNode;
27
+ /**
28
+ * Place the panel against this element instead of `children`, for one Popover does not wrap.
29
+ * Open it with `isOpen`; `null` falls back to `children`.
30
+ */
31
+ anchor?: HTMLElement | null;
27
32
  /** The popover body. */
28
33
  content?: ReactNode;
29
34
  /** Preferred side (default `Bottom`). */
@@ -0,0 +1,16 @@
1
+ export interface DayParts {
2
+ year: number;
3
+ /** 1 to 12. */
4
+ month: number;
5
+ day: number;
6
+ }
7
+ export declare function daysInMonth({ year, month }: Pick<DayParts, 'year' | 'month'>): number;
8
+ /** The parts of a `'YYYY-MM-DD'` string, or `null` for anything else, 31 February included. */
9
+ export declare function parseDay(value: unknown): DayParts | null;
10
+ /** `value` itself when it is a `'YYYY-MM-DD'` day that exists, otherwise `null`. */
11
+ export declare function asDay(value: unknown): string | null;
12
+ export declare function formatDay({ year, month, day }: DayParts): string;
13
+ /** The day `date` falls on in the browser's time zone, as `'YYYY-MM-DD'`; `''` for an Invalid Date. */
14
+ export declare function dateToDay(date: Date): string;
15
+ /** The first moment of a `'YYYY-MM-DD'` day in the browser's time zone. An Invalid Date for anything else. */
16
+ export declare function dayToDate(day: string): Date;
@@ -158,4 +158,4 @@ export * from './common/hooks/index.js';
158
158
  export { FormValidator, FormValidatorGroup, Validators, ValidatableComponent, useFormValidator, useFormValidatorGroup, } from './services/formValidation/index.js';
159
159
  export type { ValidationError, ValidatorFunction, ErrorParser, FormValidatorGroupOptions, InferGroupValues, MergedValues, } from './services/formValidation/index.js';
160
160
  export { mapLocale, setLocale, getLocale } from './services/localization.js';
161
- export { isNumber, isDate, isISODate, isEmail, isBic, isURL, hasEightPasswordChars, hasOnePasswordLetter, hasOnePasswordDigit, isPassword, } from '@ahrowe/form-validation';
161
+ export { isNumber, isDate, isDay, isISODate, isEmail, isBic, isURL, hasEightPasswordChars, hasOnePasswordLetter, hasOnePasswordDigit, isPassword, } from '@ahrowe/form-validation';
package/docs/CLAUDE.md CHANGED
@@ -20,6 +20,10 @@ enums and slot names. Do not infer props from a component's name.
20
20
  RadioGroup, DatePicker, TimeInput, OtpInput, IconPicker) take `formValidator`, which then owns
21
21
  the value: `new FormValidator('', [Validators.required()])`. Whole forms use
22
22
  `useFormValidatorGroup`. See FormValidator.md and FormValidatorGroup.md.
23
+ - **Days and times are strings**, never a `Date`: DatePicker holds `'YYYY-MM-DD'`, TimeInput
24
+ `'HH:MM'`. `dateToDay` and `dayToDate` convert in the browser's time zone; never use
25
+ `toISOString().slice(0, 10)`, which gives the UTC day. DatePicker.md shows how to turn a day
26
+ into a moment in a given time zone.
23
27
  - **Styling.** `className` and `style` hit the root element; `classNames.<slot>` and `styles.<slot>`
24
28
  hit named inner elements. Slot names are on each doc's **Slots** line. Every native attribute of
25
29
  the root element passes through (`id`, `aria-*`, `data-*`, `on*`).
@@ -63,7 +67,7 @@ enums and slot names. Do not infer props from a component's name.
63
67
  | ConfigProvider.md | ConfigProvider | Set global **default props** for library components once, near the root of your app, instead of repeating the same prop on every instance |
64
68
  | ConfirmModal.md | ConfirmModal | Confirmation dialog before destructive or irreversible actions — delete, archive, send, overwrite |
65
69
  | CopyButton.md | CopyButton | Icon-only button that copies a value to the clipboard: an invoice number, an ID in a table row, an API key or share link inside an `Input` |
66
- | DatePicker.md | DatePicker | Date selection input — booking forms, birth date fields, date range pickers, deadline selectors |
70
+ | DatePicker.md | DatePicker, dateToDay, dayToDate | Date selection input — booking forms, birth date fields, date range pickers, deadline selectors |
67
71
  | Divider.md | Divider, DividerTextPosition, DividerOrientation | A rule that separates sections of content — horizontal by default, filling the width of its parent |
68
72
  | Drawer.md | Drawer | Inline horizontal panel that collapses and expands by animating its width — side panels, navigation rails, detail/filter panes that slide open beside the main content |
69
73
  | DropZone.md | DropZone, DropZoneReturnType, DropZoneStatus | Drag-and-drop file upload area, optionally click-to-open the native picker |
@@ -2,72 +2,58 @@
2
2
 
3
3
  **When to use:** Date selection input — booking forms, birth date fields, date range pickers, deadline selectors. Renders as a text input with a floating label by default; can also be inline or modal.
4
4
 
5
- **Keywords:** calendar picker
5
+ **Keywords:** calendar picker, timezone, ISO date, date string, day picker
6
6
 
7
7
  **Keyboard:** the day grid uses a roving tab stop (the selected day, or today, or the 1st — one Tab reaches it, not every day individually); arrow keys move by day (`←`/`→`) or by week (`↑`/`↓`), crossing into the adjacent month automatically when they run past the edge, and Enter/Space selects the focused day.
8
8
 
9
- **Import:** `import { DatePicker } from '@ahrowe/ui'`
9
+ **Import:** `import { DatePicker, dateToDay, dayToDate } from '@ahrowe/ui'`
10
10
  **Types:** `import type { DatePickerProps } from '@ahrowe/ui'`
11
11
 
12
12
  ```tsx
13
- import { DatePicker, FormValidator } from '@ahrowe/ui';
13
+ import { DatePicker, FormValidator, dateToDay } from '@ahrowe/ui';
14
14
 
15
- // Controlled input with floating label
16
- <DatePicker
17
- label="Date of birth"
18
- selected={date}
19
- onSelect={(date) => setDate(date)}
20
- />
15
+ // Controlled input with floating label. The value is a day: '2026-09-15'
16
+ const [day, setDay] = useState<string | null>(null);
17
+ <DatePicker label="Date of birth" selected={day} onSelect={setDay} />
21
18
 
22
19
  // With FormValidator
23
- const dateValidator = new FormValidator<Date | null>(null);
24
- <DatePicker label="Start date" formValidator={dateValidator} />
20
+ const dayValidator = new FormValidator<string | null>(null);
21
+ <DatePicker label="Start date" formValidator={dayValidator} />
25
22
 
26
23
  // Inline calendar (no input field)
27
- <DatePicker
28
- selected={date}
29
- onSelect={setDate}
30
- isInline
31
- noInput
32
- />
24
+ <DatePicker selected={day} onSelect={setDay} isInline noInput />
33
25
 
34
26
  // Inside a modal (calendar opens in a Modal overlay)
35
- <DatePicker
36
- label="Schedule date"
37
- selected={date}
38
- onSelect={setDate}
39
- isModal
40
- />
27
+ <DatePicker label="Schedule date" selected={day} onSelect={setDay} isModal />
41
28
 
42
- // With min/max constraints and a clear button
29
+ // From today to the end of the year, with a clear button
43
30
  <DatePicker
44
31
  label="Appointment"
45
- selected={date}
46
- onSelect={setDate}
47
- minDate={new Date()}
48
- maxDate={endOfYear}
32
+ selected={day}
33
+ onSelect={setDay}
34
+ minDate={dateToDay(new Date())}
35
+ maxDate="2026-12-31"
49
36
  showClearButton
50
37
  />
51
38
 
52
- // Date range (highlight start–end)
53
- <DatePicker
54
- label="From"
55
- selected={startDate}
56
- onSelect={setStartDate}
57
- startDate={startDate}
58
- endDate={endDate}
59
- />
39
+ // Range: two pickers sharing a highlighted start and end
40
+ <DatePicker label="From" selected={from} onSelect={setFrom} startDate={from} endDate={to} />
41
+ <DatePicker label="To" selected={to} onSelect={setTo} startDate={from} endDate={to} minDate={from} />
60
42
  ```
61
43
 
44
+ **A day, not a moment:** every day DatePicker takes or hands out is a `'YYYY-MM-DD'` string, the value an `<input type="date">` has. It has no time and no time zone, so it is the same day for everyone who sees it. The strings sort as the days do, so `from < to` compares two of them. `null`, `''` and anything that is not a real day, such as `'2026-02-30'`, mean no value; anything but the first two also logs a warning naming the prop, once.
45
+
46
+ `dateToDay(date)` and `dayToDate(day)` convert to and from a `Date` in the browser's time zone: `dateToDay(new Date())` is today, `dayToDate(day)` the first moment of that day. Do not use `date.toISOString().slice(0, 10)`: that is the day in UTC, which in Vienna shortly after midnight is still yesterday.
47
+
62
48
  **Key props:**
63
49
 
64
50
  | Prop | Type | Description |
65
51
  |------|------|-------------|
66
52
  | `label` | `string` | Floating label on the input |
67
- | `selected` | `Date` | Currently selected date |
68
- | `onSelect` | `(date: Date \| null) => void` | Called when a date is picked |
69
- | `onChange` | `(date: Date \| null) => void` | Alternative change callback |
70
- | `formValidator` | `FormValidator` | Connects to validation |
53
+ | `selected` | `string \| null` | Selected day, `'YYYY-MM-DD'` |
54
+ | `onSelect` | `(day: string \| null) => void` | Called when a day is picked in the calendar |
55
+ | `onChange` | `(day: string \| null) => void` | Called on every change, picked or typed |
56
+ | `formValidator` | `FormValidator<string \| null>` | Connects to validation, and then owns the value. `new FormValidator(null)` and `new FormValidator('')` fit too |
71
57
  | `isRequired` | `boolean` | |
72
58
  | `isValid` | `boolean` | Manual valid state |
73
59
  | `errorMessage` | `string` | Manual error message |
@@ -75,12 +61,53 @@ const dateValidator = new FormValidator<Date | null>(null);
75
61
  | `noHeader` | `boolean` | Hide the month/year navigation header |
76
62
  | `isModal` | `boolean` | Open calendar in a Modal overlay |
77
63
  | `isInline` | `boolean` | Render calendar always-visible inline |
78
- | `minDate` | `Date \| null` | Earliest selectable date |
79
- | `maxDate` | `Date \| null` | Latest selectable date |
64
+ | `minDate` | `string \| null` | Earliest selectable day |
65
+ | `maxDate` | `string \| null` | Latest selectable day |
80
66
  | `showClearButton` | `boolean` | Show a clear/reset button |
81
- | `startDate` | `Date` | Range highlight start |
82
- | `endDate` | `Date` | Range highlight end |
67
+ | `startDate` | `string \| null` | Range highlight start |
68
+ | `endDate` | `string \| null` | Range highlight end |
83
69
 
84
70
  **Slots:** `root` `input` `header` `monthDropdown` `yearDropdown` `calendar` `clearButton`
85
71
 
86
- The root also takes the full native attribute set (`id`, `data-*`, `aria-*`, `title`, event handlers). `onChange` and `onSelect` report a `Date`, so the native handlers of those names are not available.
72
+ The root also takes the full native attribute set (`id`, `data-*`, `aria-*`, `title`, event handlers). `onChange` and `onSelect` report a day, so the native handlers of those names are not available.
73
+
74
+ ## When a day stands for a moment
75
+
76
+ A poll that closes on a day, a booking that starts on one, a deadline: here the day has to become a moment, and only your app knows in which time zone and at which point of the day. Store the moment itself (a UTC timestamp), and show it in the time zone of whoever reads it.
77
+
78
+ In the user's own time zone the platform is enough:
79
+
80
+ ```ts
81
+ const start = dayToDate(day); // the first moment of the day, where the user is
82
+ ```
83
+
84
+ In a given zone, a company's, a household's, a venue's, use a library that knows time zones, such as Luxon. Name the zone (`'Europe/Vienna'`), not its offset (`+02:00`): the offset changes with daylight saving.
85
+
86
+ ```ts
87
+ import { DateTime } from 'luxon';
88
+
89
+ // Closes at the end of the day in Vienna, wherever the reader is.
90
+ const closesAt = DateTime.fromISO(day, { zone: 'Europe/Vienna' }).endOf('day').toJSDate();
91
+
92
+ // And back, for the picker: the day that moment falls on in Vienna.
93
+ const shown = DateTime.fromJSDate(closesAt, { zone: 'Europe/Vienna' }).toISODate();
94
+ ```
95
+
96
+ With a [TimeInput](TimeInput.md) next to it, which holds `'HH:MM'`, the two make a moment the same way: ``DateTime.fromISO(`${day}T${time}`, { zone })``, or, in the user's own zone, `dayToDate(day)` followed by `setHours(hours, minutes)`.
97
+
98
+ ## Migration from Date values
99
+
100
+ Before 0.39 every day was a `Date` at midnight UTC. Now it is a `'YYYY-MM-DD'` string, and TypeScript points at each place to change, because a `Date` no longer fits any of the props. Two things still get through: a `Date` in plain JavaScript or through a validator typed as `FormValidator<any>`, and a stored value read back as its JSON, `'2026-09-15T00:00:00.000Z'`, which is a string to TypeScript. DatePicker shows either as an empty field, logs a warning naming the prop, and leaves the value alone until the user actually edits the field.
101
+
102
+ | Before | After |
103
+ |--------|-------|
104
+ | `useState<Date \| null>(null)` | `useState<string \| null>(null)` |
105
+ | `new FormValidator<Date \| null>(null)` | `new FormValidator<string \| null>(null)` |
106
+ | `selected={new Date()}`, `minDate={new Date()}` | `selected={dateToDay(new Date())}` |
107
+ | `a.getTime() < b.getTime()` | `a < b` |
108
+ | `date.toLocaleDateString(locale)` | `dayToDate(day).toLocaleDateString(locale)` |
109
+ | `JSON.stringify(date)`, `date.toISOString()` | the string itself |
110
+
111
+ Data you already stored from DatePicker was midnight UTC of the picked day. `stored.toISOString().slice(0, 10)` turns exactly that back into the day, since the value was built in UTC; do this only for values DatePicker produced.
112
+
113
+ Where the value was really a moment (the poll and deadline cases above), decide the time zone once, in your own code, as shown in the section before this one, rather than relying on what DatePicker happened to hand out.
@@ -32,7 +32,7 @@ nameValidator.dirty // value has changed since creation
32
32
  > | Field | Default value |
33
33
  > |-------|---------------|
34
34
  > | string (text, email, …) | `''` |
35
- > | `Date` | `null` |
35
+ > | day (`DatePicker`, `'YYYY-MM-DD'`) | `null` |
36
36
  > | number | `undefined` (or `0`) |
37
37
  > | checkbox / boolean | `false` |
38
38
  > | single-select id | `null` |
@@ -69,7 +69,7 @@ function NameField() {
69
69
 
70
70
  ```tsx
71
71
  const ageValidator = new FormValidator<number | undefined>(undefined, [Validators.required()]);
72
- const dateValidator = new FormValidator<Date | null>(null);
72
+ const dayValidator = new FormValidator<string | null>(null); // DatePicker: '2026-09-15'
73
73
  const agreedValidator = new FormValidator<boolean>(false, [Validators.required()]);
74
74
  ```
75
75
 
@@ -108,7 +108,7 @@ Validators.multiEmail(errorMsg?) // comma/semicolon-separated list of
108
108
  Validators.mustBeNumber(errorMsg?) // parses as a number
109
109
  Validators.oneOf(allowedValues, errorMsg?) // value is in the allowed array
110
110
  Validators.typeOf(allowedTypes, errorMsg?) // typeof value matches (string or string[])
111
- Validators.date(errorMsg?) // valid date string (DD.MM.YYYY or ISO)
111
+ Validators.date(errorMsg?) // a real date: a 'YYYY-MM-DD' day (DatePicker), DD.MM.YYYY, a toISOString() timestamp, or a valid Date
112
112
  Validators.dateObject(errorMsg?) // an object with an `isValid` flag (e.g. a date wrapper)
113
113
  Validators.website(errorMsg?) // valid URL
114
114
  Validators.bic(errorMsg?) // valid BIC/SWIFT code
package/docs/Popover.md CHANGED
@@ -28,6 +28,18 @@ import { Popover, PopoverPlacement, PopoverAlign, PopoverTrigger } from '@ahrowe
28
28
  <Popover isOpen={open} onOpenChange={setOpen} content={<Form onDone={() => setOpen(false)} />}>
29
29
  <button>Edit</button>
30
30
  </Popover>
31
+
32
+ // One popover for many elements it does not wrap: a calendar's events, a table's cells
33
+ const [anchor, setAnchor] = useState<HTMLElement | null>(null);
34
+ {events.map((event) => (
35
+ <button key={event.id} onClick={(e) => setAnchor(e.currentTarget)}>{event.title}</button>
36
+ ))}
37
+ <Popover
38
+ anchor={anchor}
39
+ isOpen={anchor !== null}
40
+ onOpenChange={(open) => !open && setAnchor(null)}
41
+ content={<EventDetails />}
42
+ />
31
43
  ```
32
44
 
33
45
  **PopoverPlacement enum:** `PopoverPlacement.Top` | `Bottom` | `Left` | `Right` — the preferred side; auto-flips to the opposite side when there's no room.
@@ -44,6 +56,7 @@ import { Popover, PopoverPlacement, PopoverAlign, PopoverTrigger } from '@ahrowe
44
56
  |------|------|---------|-------------|
45
57
  | `content` | `ReactNode` | — | Panel body |
46
58
  | `children` | `ReactNode` | — | Trigger element |
59
+ | `anchor` | `HTMLElement \| null` | | Place the panel against this element instead of `children`. Open it with `isOpen`; `children` can be left out. A click on it is not an outside click, Escape returns the focus to it, and changing it while open moves the panel. `null` falls back to `children` |
47
60
  | `placement` | `PopoverPlacement` | `Bottom` | Preferred side |
48
61
  | `align` | `PopoverAlign` | `Center` | Cross-axis alignment |
49
62
  | `trigger` | `PopoverTrigger` | `Click` | What opens the popover |
@@ -80,3 +93,5 @@ Requires a `<div id="bodyEnd"></div>` at the app root — the panel is portaled
80
93
  **Scrolling:** where the browser supports CSS anchor positioning, including the trigger's transforms, the panel is anchored to the trigger in CSS, so it moves with a scrolling trigger in the same frame instead of catching up a frame later. Firefox resolves anchors without transforms, so it keeps the pixel positioning. While it is open the trigger carries an inline `anchor-name`, added to any inline one it already has; an `anchor-name` your stylesheet gives the trigger is overridden for that time.
81
94
 
82
95
  **Keyboard & focus (click trigger):** the panel is portaled to the end of the DOM, so Tab can't reach it naturally. When the panel has focusable content and the keyboard opened it (Enter or Space on the trigger, or ArrowDown/ArrowUp), focus moves to the first focusable element and the panel gets `role="dialog"`; a pointer click leaves the focus where it already is. `focusOnOpen` forces either. Tabbing past the last element closes the popover and moves focus to the element after the trigger; Shift+Tab past the first element, and Escape, close it and return focus to the trigger. This is non-modal: focus is not trapped, and hover/focus triggers don't steal focus. Escape closes only the innermost open layer, so a popover inside a `Modal` closes on the first press and leaves the modal open. Popovers with only static content don't manage focus or claim the dialog role.
96
+
97
+ **Anchored (`anchor`):** your code opens the panel, not a key event Popover sees, so the default `focusOnOpen` looks at the click that opened it: Enter or Space on the anchor or on something inside it (a click with `detail === 0`) moves the focus into the panel, a pointer click leaves it where it is, and an open with no click at all, such as suggestions appearing while the user types into an anchored field, never takes it. Escape, Tab and Shift+Tab out of the panel continue from the anchor as they would from a trigger: from the anchor itself when it takes focus, otherwise from the first focusable element inside it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahrowe/ui",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "A React UI component library with theming, CSS Modules, and TypeScript support",
5
5
  "keywords": [
6
6
  "agent",
@@ -88,7 +88,7 @@
88
88
  "prepare": "husky"
89
89
  },
90
90
  "dependencies": {
91
- "@ahrowe/form-validation": "^0.2.0",
91
+ "@ahrowe/form-validation": "^0.3.0",
92
92
  "@dnd-kit/core": "^6.3.1",
93
93
  "@dnd-kit/sortable": "^10.0.0",
94
94
  "@dnd-kit/utilities": "^3.2.2",