forty-cdk 0.4.0 → 0.5.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 (34) hide show
  1. package/date-picker/README.md +53 -0
  2. package/date-range-field/README.md +173 -0
  3. package/fesm2022/forty-cdk-combobox.mjs +39 -1
  4. package/fesm2022/forty-cdk-combobox.mjs.map +1 -1
  5. package/fesm2022/forty-cdk-core.mjs +1077 -337
  6. package/fesm2022/forty-cdk-core.mjs.map +1 -1
  7. package/fesm2022/forty-cdk-date-field.mjs +43 -345
  8. package/fesm2022/forty-cdk-date-field.mjs.map +1 -1
  9. package/fesm2022/forty-cdk-date-picker.mjs +589 -266
  10. package/fesm2022/forty-cdk-date-picker.mjs.map +1 -1
  11. package/fesm2022/forty-cdk-date-range-field.mjs +682 -0
  12. package/fesm2022/forty-cdk-date-range-field.mjs.map +1 -0
  13. package/fesm2022/forty-cdk-hover-card.mjs +34 -4
  14. package/fesm2022/forty-cdk-hover-card.mjs.map +1 -1
  15. package/fesm2022/forty-cdk-time-field.mjs +45 -202
  16. package/fesm2022/forty-cdk-time-field.mjs.map +1 -1
  17. package/fesm2022/forty-cdk-time-range-field.mjs +671 -0
  18. package/fesm2022/forty-cdk-time-range-field.mjs.map +1 -0
  19. package/fesm2022/forty-cdk-tooltip.mjs +40 -8
  20. package/fesm2022/forty-cdk-tooltip.mjs.map +1 -1
  21. package/hover-card/README.md +1 -0
  22. package/package.json +9 -1
  23. package/time-field/README.md +1 -1
  24. package/time-range-field/README.md +174 -0
  25. package/tooltip/README.md +1 -0
  26. package/types/forty-cdk-combobox.d.ts +25 -0
  27. package/types/forty-cdk-core.d.ts +478 -149
  28. package/types/forty-cdk-date-field.d.ts +12 -16
  29. package/types/forty-cdk-date-picker.d.ts +374 -155
  30. package/types/forty-cdk-date-range-field.d.ts +390 -0
  31. package/types/forty-cdk-hover-card.d.ts +8 -2
  32. package/types/forty-cdk-time-field.d.ts +16 -18
  33. package/types/forty-cdk-time-range-field.d.ts +389 -0
  34. package/types/forty-cdk-tooltip.d.ts +5 -3
@@ -286,6 +286,59 @@ readonly dateRange = signal<CalendarDateRange<CalendarDate> | null>(null);
286
286
  | `range` | `model<CalendarDateRange<D> \| null>` | Two-way bindable committed range. `(rangeChange)` fires only on commit / clear. Default `null`. |
287
287
  | `rangeSeparator` | `input<string>` | String placed between start and end in the formatted display. Default `' – '`. |
288
288
 
289
+ ## Range as a Signal Forms value — `ForDateRangePicker`
290
+
291
+ `ForDatePicker[selectionMode="range"]` exposes the range through a plain two-way `[(range)]` model with **no** form contract — so a range inside a form has to be hand-wired. `ForDateRangePicker` (selector `[forDateRangePicker]`) is the form-capable sibling: it is the root **and** the form value, implementing `FormValueControl<CalendarDateRange<D> | null>`, so the committed range auto-wires with `[formField]` exactly like any other control.
292
+
293
+ It reuses the same pieces — `[forDatePickerTrigger]`, `[forDatePickerContent]`, `[forDatePickerValue]`, `[forDatePickerAnchor]` — through a shared base, and provides `FOR_DATE_PICKER_CONTEXT` so they resolve under it. Project a `[forCalendar]` in `selectionMode="range"` and bind its range to the picker's `value`; the two-click anchor → commit flow keeps `value` `null` until both endpoints are chosen (the form never sees a half-entered range), and `start <= end` is an invariant. Range is day-granular in v1 (no time composition).
294
+
295
+ ```ts
296
+ import { type CalendarDateRange } from 'forty-cdk/calendar';
297
+ import { ForDateRangePicker } from 'forty-cdk/date-picker';
298
+ import { form } from '@angular/forms/signals';
299
+
300
+ interface Booking {
301
+ stay: CalendarDateRange<CalendarDate> | null;
302
+ }
303
+ readonly model = signal<Booking>({ stay: null });
304
+ readonly booking = form(this.model, (p) => required(p.stay));
305
+ ```
306
+
307
+ ```html
308
+ <div
309
+ forDateRangePicker
310
+ [formField]="booking.stay"
311
+ [(open)]="open"
312
+ [ariaLabel]="'Choose date range'"
313
+ #picker="forDateRangePicker"
314
+ >
315
+ <button forDatePickerTrigger>
316
+ <span forDatePickerValue [placeholder]="'Pick a range'"></span>
317
+ </button>
318
+
319
+ @if (open()) {
320
+ <div forDatePickerContent>
321
+ <div
322
+ forCalendar
323
+ selectionMode="range"
324
+ [(range)]="picker.value"
325
+ [min]="picker.minDate()"
326
+ [max]="picker.maxDate()"
327
+ >
328
+ <!-- …header + grid… -->
329
+ </div>
330
+ </div>
331
+ }
332
+ </div>
333
+ ```
334
+
335
+ - **Form value.** The committed `CalendarDateRange<D> | null` is the `value` model. `null` is the empty state — pair it with `required(p.stay)` so `invalid()` flips when the form demands a range and none is committed. `touched` fires on commit and on close, exactly like the single-date picker.
336
+ - **Validity.** `start <= end` is guaranteed by construction and is never an error. Forward `minDate` / `maxDate` to the calendar's `[min]` / `[max]`, and `minRangeLength` / `maxRangeLength` to the calendar's `[minRangeLength]` / `[maxRangeLength]` (a too-short / too-long range is rejected as a no-op by the calendar's two-click flow).
337
+ - **Native submission.** When `name` is set, two hidden inputs `<name>-start` / `<name>-end` mirror the committed endpoints as ISO `YYYY-MM-DD` for native `<form>` posts.
338
+ - **Bounds naming.** `minDate` / `maxDate` (not `min` / `max`) for the same reason as `ForDatePicker` — and additionally because `FormUiControl.min` / `max` are typed `NonNullable<TValue>` (the range object itself), which is meaningless as a bound.
339
+
340
+ Defaults are configured with `provideForDateRangePickerDefaults` (`sideOffset` / `collisionPadding`), and both wrapper patterns work via the exported `FOR_DATE_RANGE_PICKER_HOST_DIRECTIVE_INPUTS` / `FOR_DATE_RANGE_PICKER_HOST_DIRECTIVE_OUTPUTS` tuples — see [Wrapping form primitives](../../../../../docs/wrapping-form-primitives.md).
341
+
289
342
  ## Styling
290
343
 
291
344
  forty-cdk ships no styles. Add your own class to each piece — the `for*` selectors are the behavior API, not a styling contract (see [Styling forty-cdk](../../../../../docs/styling.md)). Key your CSS off the reflected `data-*` attributes below.
@@ -0,0 +1,173 @@
1
+ # DateRangeField
2
+
3
+ Headless, segmented, spin-editable date **range** input — the keyboard-first, form-capable counterpart to [DateRangePicker](../date-picker/README.md). There is **no single WAI-ARIA APG pattern** for a range field; it is a composition of two labelled `role="group"` endpoints (start / end), each holding a row of [Spinbuttons](https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/) — the same machinery as [DateField](../date-field/README.md) — nested inside one outer `role="group"`. Segment **order** and separators follow the runtime locale (`MM/DD/YYYY` vs `DD.MM.YYYY` vs `YYYY/MM/DD`).
4
+
5
+ `ForDateRangeField` implements `FormValueControl<CalendarDateRange<D> | null>` from `@angular/forms/signals` — the **same** contract as `ForDateRangePicker` — so the committed range auto-wires with `[formField]` and auto-associates inside a `[forField]` (label / description / error) with no extra markup. The value stays `null` until **both** endpoints are fully entered and ordered (`start <= end`); a half-entered or out-of-order range never reaches the form.
6
+
7
+ ## Date adapter — pick one (required)
8
+
9
+ All date math goes through the same pluggable `DateAdapter<D>` as `ForCalendar`, so the library hard-depends on **no** date library. Provide exactly one adapter in your application (or component) providers:
10
+
11
+ | Provider | Date type `D` | Dependency |
12
+ | --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
13
+ | `provideInternationalizedDateAdapter()` | `CalendarDate` (`@internationalized/date`) | **Recommended.** From `forty-cdk/internationalized-date`; needs `@internationalized/date` (optional peer) |
14
+ | `provideNativeDateAdapter()` | `Date` | None (zero-dependency fallback) |
15
+
16
+ ## Pieces
17
+
18
+ | Class | Selector | Role |
19
+ | -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
20
+ | `ForDateRangeField` | `[forDateRangeField]` | Root (`role="group"`). Owns both endpoint engines, composes the `CalendarDateRange`, the `FormValueControl`. |
21
+ | `ForDateRangeFieldStart` | `[forDateRangeFieldStart]` | Start endpoint group (`role="group"`). Its own tab stop; exposes `segments()`. |
22
+ | `ForDateRangeFieldEnd` | `[forDateRangeFieldEnd]` | End endpoint group (`role="group"`). Its own tab stop; exposes `segments()`. |
23
+ | `ForDateRangeFieldSegment` | `[forDateRangeFieldSegment]` | One editable part (`role="spinbutton"`). Roving tab stop, ARIA value reflection, keyboard editing. |
24
+ | `ForDateRangeFieldLiteral` | `[forDateRangeFieldLiteral]` | A decorative separator (`/`, `.`, `-`, `:`). `aria-hidden`, out of the tab order. |
25
+
26
+ ## Inputs / models — `ForDateRangeField`
27
+
28
+ | API | Type | Description |
29
+ | ------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
30
+ | `value` | `model<CalendarDateRange<D> \| null>` | Two-way bindable committed range, or `null` while incomplete or out of order. The `FormValueControl` backing. Default `null`. |
31
+ | `minDate` | `input<D \| null>` | Minimum date (inclusive) for both endpoints. A composed endpoint below it is clamped up. Named `minDate` — see note below. |
32
+ | `maxDate` | `input<D \| null>` | Maximum date (inclusive) for both endpoints. A composed endpoint above it is clamped down. Default `null`. |
33
+ | `granularity` | `input<'day' \| 'hour' \| 'minute' \| 'second'>` | Date-time precision shared by both endpoints. `'day'` (default) is date-only; coarser-than-day off appends time segments. |
34
+ | `hourCycle` | `input<12 \| 24 \| null>` | 12/24-hour cycle for the time segments. Default `null` → locale. 12-hour adds the AM/PM segment. |
35
+ | `locale` | `input<string \| null>` | BCP 47 locale driving segment order, separators, and month name. Default `null` → runtime locale. |
36
+ | `placeholder` | `input<Partial<Record<DateTimeSegmentType, string>>>` | Per-segment placeholder while empty, applied to both endpoints. Default `{}`. |
37
+ | `ariaLabel` | `input<string \| null>` | Accessible name for the whole range field group. Emits no `aria-label` while `null`. Default `null`. |
38
+ | `dir` | `input<'ltr' \| 'rtl' \| null>` | Writing direction. Default `null` resolves the ambient direction; mirrors ArrowLeft / ArrowRight segment navigation. |
39
+
40
+ The endpoint groups (`[forDateRangeFieldStart]` / `[forDateRangeFieldEnd]`) each accept an `ariaLabel` input for their own group label, falling back to the scope defaults (`'Start date'` / `'End date'`).
41
+
42
+ Plus the shared `FormUiControl` members from `@angular/forms/signals`: `disabled`, `readonly`, `required`, `invalid`, `name`, `errors`, `touched` (bound automatically by `[formField]`).
43
+
44
+ > **Why `minDate` / `maxDate`, not `min` / `max`?** `FormUiControl.min` / `max` are reserved members typed `number | undefined` for numeric validators, and additionally typed `NonNullable<TValue>` (the range object itself), which is meaningless as a bound — so the date bounds use distinct names.
45
+
46
+ ## Usage
47
+
48
+ ```ts
49
+ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
50
+ import { CalendarDate } from '@internationalized/date';
51
+ import { CalendarDateRange } from 'forty-cdk/calendar';
52
+ import {
53
+ ForDateRangeField,
54
+ ForDateRangeFieldEnd,
55
+ ForDateRangeFieldLiteral,
56
+ ForDateRangeFieldSegment,
57
+ ForDateRangeFieldStart,
58
+ } from 'forty-cdk/date-range-field';
59
+
60
+ @Component({
61
+ selector: 'app-stay',
62
+ changeDetection: ChangeDetectionStrategy.OnPush,
63
+ imports: [
64
+ ForDateRangeField,
65
+ ForDateRangeFieldStart,
66
+ ForDateRangeFieldEnd,
67
+ ForDateRangeFieldSegment,
68
+ ForDateRangeFieldLiteral,
69
+ ],
70
+ template: `
71
+ <div forDateRangeField class="range-field" [(value)]="stay" [ariaLabel]="'Stay'">
72
+ <div forDateRangeFieldStart class="range-endpoint" #start="forDateRangeFieldStart">
73
+ @for (seg of start.segments(); track seg.id) {
74
+ @if (seg.isLiteral) {
75
+ <span forDateRangeFieldLiteral>{{ seg.text }}</span>
76
+ } @else {
77
+ <span forDateRangeFieldSegment class="range-segment" [segment]="seg.type!">{{
78
+ seg.text
79
+ }}</span>
80
+ }
81
+ }
82
+ </div>
83
+ <span aria-hidden="true">–</span>
84
+ <div forDateRangeFieldEnd class="range-endpoint" #end="forDateRangeFieldEnd">
85
+ @for (seg of end.segments(); track seg.id) {
86
+ @if (seg.isLiteral) {
87
+ <span forDateRangeFieldLiteral>{{ seg.text }}</span>
88
+ } @else {
89
+ <span forDateRangeFieldSegment class="range-segment" [segment]="seg.type!">{{
90
+ seg.text
91
+ }}</span>
92
+ }
93
+ }
94
+ </div>
95
+ </div>
96
+ `,
97
+ })
98
+ export class StayField {
99
+ readonly stay = signal<CalendarDateRange<CalendarDate> | null>(null);
100
+ }
101
+ ```
102
+
103
+ ## Ordering
104
+
105
+ The two endpoints are typed independently, so order is not guaranteed by construction the way the picker's two-click flow guarantees it. The field preserves the `CalendarDateRange` `end >= start` invariant by **never emitting an out-of-order range**: when both endpoints are complete but `start > end`, the typed segments are kept (not silently rewritten), `value` stays `null`, and the root reflects `aria-invalid="true"` + `data-range-error` so the disorder is perceivable and stylable. Editing either endpoint back into order emits the range.
106
+
107
+ ## Keyboard (per segment)
108
+
109
+ Each endpoint is its own tab stop, so `Tab` moves from the start group to the end group to the next control; arrows move between segments **within** an endpoint. Horizontal arrows mirror under `dir="rtl"`.
110
+
111
+ | Key | Behavior |
112
+ | -------------------------- | ------------------------------------------------------------------------ |
113
+ | **0–9** | Type the value; auto-advances to the next segment when full. |
114
+ | **ArrowUp / ArrowDown** | Step the value. Day and month wrap; year clamps. Empty seeds from today. |
115
+ | **ArrowLeft / ArrowRight** | Move to the previous / next segment in the same endpoint (no wrap). |
116
+ | **Home / End** | Jump to the segment minimum / maximum. |
117
+ | **Backspace / Delete** | Clear the segment (the range becomes `null` until refilled). |
118
+
119
+ The day clamps to the current month's length (e.g. 31 → 28 in February), and each composed endpoint is clamped into `[minDate, maxDate]`.
120
+
121
+ ## Date-time range (`granularity > 'day'`)
122
+
123
+ Set `granularity` to `'hour'`, `'minute'`, or `'second'` to append time segments to **each** endpoint — hour / minute / second and, in 12-hour mode, an AM·PM `dayPeriod`. This needs a **time-capable** adapter — `provideNativeDateAdapter()` (`Date`) or `provideInternationalizedDateTimeAdapter()` (`CalendarDateTime`); the day-only `provideInternationalizedDateAdapter()` (`CalendarDate`) throws.
124
+
125
+ ## Scope defaults
126
+
127
+ ```ts
128
+ import { provideForDateRangeFieldDefaults } from 'forty-cdk/date-range-field';
129
+
130
+ // app config or a component's providers — localize the endpoint group labels,
131
+ // segment labels, and the empty-segment announcement for every nested field.
132
+ providers: [
133
+ provideForDateRangeFieldDefaults({
134
+ emptySegmentText: 'Vacío',
135
+ startLabel: 'Desde',
136
+ endLabel: 'Hasta',
137
+ segmentLabels: { day: 'día', month: 'mes', year: 'año', dayPeriod: 'AM/PM' },
138
+ }),
139
+ ];
140
+ ```
141
+
142
+ `startLabel` / `endLabel` supply each endpoint group's default `aria-label`; `segmentLabels` supplies each segment's default `aria-label`, keyed by part type. Unset keys keep the library default, so overriding a single key never wipes the rest. An endpoint's own `[ariaLabel]`, or a segment's own `[ariaLabel]`, still wins over the scope default.
143
+
144
+ ## Styling
145
+
146
+ forty-cdk ships no styles. Add your own class to each piece — the for\* selectors are the behavior API, not a styling contract (see [Styling forty-cdk](../../../../../docs/styling.md)). Key your CSS off the reflected data-\* attributes below.
147
+
148
+ ### Data attributes
149
+
150
+ | Piece | Attribute | Values |
151
+ | ---------------------------- | ------------------ | ----------------- |
152
+ | `[forDateRangeField]` | `data-disabled` | present \| absent |
153
+ | `[forDateRangeField]` | `data-readonly` | present \| absent |
154
+ | `[forDateRangeField]` | `data-empty` | present \| absent |
155
+ | `[forDateRangeField]` | `data-range-error` | present \| absent |
156
+ | `[forDateRangeFieldSegment]` | `data-highlighted` | present \| absent |
157
+ | `[forDateRangeFieldSegment]` | `data-placeholder` | present \| absent |
158
+ | `[forDateRangeFieldSegment]` | `data-disabled` | present \| absent |
159
+ | `[forDateRangeFieldSegment]` | `data-readonly` | present \| absent |
160
+
161
+ `data-empty` marks the whole field while the range is `null` (either endpoint unfilled, or the two out of order); `data-range-error` marks the specific case of two complete but out-of-order endpoints; `data-placeholder` marks each individual segment that is still empty. `data-highlighted` is the current roving-tabindex segment — the only focus hook the consumer gets, shared with the other roving primitives.
162
+
163
+ ## Accessibility notes
164
+
165
+ - **`role="group"`** on the root carries the field's accessible name (`ariaLabel`, or point native `aria-labelledby` at a visible label); each endpoint is its own labelled `role="group"`.
166
+ - **`role="spinbutton"`** per segment, with `aria-valuemin` / `aria-valuemax` / `aria-valuenow` reflected; the month segment also exposes a localized `aria-valuetext` ("March").
167
+ - **Roving tabindex per endpoint**: exactly one segment per endpoint is tabbable, so `Tab` steps start group → end group; arrows move between segments within an endpoint.
168
+ - **`aria-invalid="true"`** is reflected on the root when the form marks it invalid **or** when two complete endpoints are out of order.
169
+ - **Literals are `aria-hidden`** and never focusable — assistive tech reads only the spinbutton segments.
170
+
171
+ ## Wrapping in a design system
172
+
173
+ Both supported wrapper patterns — `hostDirectives` with the exported `FOR_DATE_RANGE_FIELD_HOST_DIRECTIVE_INPUTS` / `FOR_DATE_RANGE_FIELD_HOST_DIRECTIVE_OUTPUTS` name tuples, and subclassing — are documented in [Wrapping form primitives](../../../../../docs/wrapping-form-primitives.md).
@@ -155,7 +155,13 @@ function findSelectedEnabled(items, values, equals) {
155
155
  * The seed itself comes from the linkedSignal; this is its imperative tail.
156
156
  * The activedescendant is read `untracked` so the scroll never re-triggers
157
157
  * the effect, and pointer-move hover doesn't reach here (it changes none of
158
- * the tracked reads), so hovering never scrolls.
158
+ * the tracked reads), so hovering never scrolls. This handles a re-seed while
159
+ * the listbox is already open (e.g. the consumer's filter dropped the active
160
+ * option). The **initial open** scroll runs here too but is wiped a tick
161
+ * later when `[forComboboxContent]` portals to `document.body` (which resets
162
+ * `scrollTop`); `ForCombobox.scrollActiveOptionIntoView` re-applies it from
163
+ * the positioner's first-resolved-position hook, after the portal move and
164
+ * after the surface is sized (#1066).
159
165
  *
160
166
  * Internal — not re-exported from `combobox/index.ts` or `public-api.ts`.
161
167
  */
@@ -1023,6 +1029,37 @@ class ForCombobox extends FormUiControlBase {
1023
1029
  this.#pointerSuppression.suppress();
1024
1030
  host.scrollIntoView?.({ block: 'nearest' });
1025
1031
  }
1032
+ /**
1033
+ * Scroll the current activedescendant option into view. Driven from
1034
+ * `[forComboboxContent]`'s positioner first-resolved-position hook
1035
+ * (`onFirstPosition`) — the only moment both prerequisites hold: the content
1036
+ * has been portaled to `document.body` (which resets the scroll container's
1037
+ * `scrollTop` to 0, wiping the seed scroll the auto-highlight bridge applied
1038
+ * during change detection) and `@floating-ui/dom`'s `size` middleware has
1039
+ * constrained the surface to its `max-height` (so it is actually scrollable).
1040
+ *
1041
+ * Re-applies the scroll unconditionally — the bridge already recorded this id
1042
+ * as positioned, but the portal move invalidated the real scroll position, so
1043
+ * the usual "already positioned" guard must not short-circuit here. Fires once
1044
+ * per open (the positioner hook is one-shot per run), so a later hover never
1045
+ * scrolls. No-op while virtualizing: the navigator owns the virtualized scroll
1046
+ * and the indexed seed is intentionally passive.
1047
+ */
1048
+ scrollActiveOptionIntoView() {
1049
+ if (this.totalCount() !== undefined) {
1050
+ return;
1051
+ }
1052
+ const id = this.#activeId();
1053
+ if (id === null) {
1054
+ return;
1055
+ }
1056
+ const active = this.#items.items().find((o) => o.id() === id);
1057
+ if (!active) {
1058
+ return;
1059
+ }
1060
+ active.host.scrollIntoView?.({ block: 'nearest' });
1061
+ this.#lastPositionedId = id;
1062
+ }
1026
1063
  #cachedOptionsMemo = computed(() => {
1027
1064
  if (this.totalCount() === undefined) {
1028
1065
  return this.#labelCache.entries();
@@ -1652,6 +1689,7 @@ class ForComboboxContent {
1652
1689
  sticky: ctx.sticky,
1653
1690
  hideWhenDetached: ctx.hideWhenDetached,
1654
1691
  clipUntilPositioned: ctx.clipUntilPositioned,
1692
+ onFirstPosition: () => ctx.scrollActiveOptionIntoView(),
1655
1693
  },
1656
1694
  dismiss: {
1657
1695
  dismissible: ctx.dismissible,