forty-cdk 0.3.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.
- package/date-picker/README.md +53 -0
- package/date-range-field/README.md +173 -0
- package/fesm2022/forty-cdk-combobox.mjs +39 -1
- package/fesm2022/forty-cdk-combobox.mjs.map +1 -1
- package/fesm2022/forty-cdk-core.mjs +1111 -338
- package/fesm2022/forty-cdk-core.mjs.map +1 -1
- package/fesm2022/forty-cdk-date-field.mjs +43 -345
- package/fesm2022/forty-cdk-date-field.mjs.map +1 -1
- package/fesm2022/forty-cdk-date-picker.mjs +589 -266
- package/fesm2022/forty-cdk-date-picker.mjs.map +1 -1
- package/fesm2022/forty-cdk-date-range-field.mjs +682 -0
- package/fesm2022/forty-cdk-date-range-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-drag-drop.mjs +49 -41
- package/fesm2022/forty-cdk-drag-drop.mjs.map +1 -1
- package/fesm2022/forty-cdk-hover-card.mjs +34 -4
- package/fesm2022/forty-cdk-hover-card.mjs.map +1 -1
- package/fesm2022/forty-cdk-listbox.mjs +15 -26
- package/fesm2022/forty-cdk-listbox.mjs.map +1 -1
- package/fesm2022/forty-cdk-table.mjs +10 -2
- package/fesm2022/forty-cdk-table.mjs.map +1 -1
- package/fesm2022/forty-cdk-time-field.mjs +45 -202
- package/fesm2022/forty-cdk-time-field.mjs.map +1 -1
- package/fesm2022/forty-cdk-time-range-field.mjs +671 -0
- package/fesm2022/forty-cdk-time-range-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-tooltip.mjs +40 -8
- package/fesm2022/forty-cdk-tooltip.mjs.map +1 -1
- package/fesm2022/forty-cdk-tree.mjs +26 -34
- package/fesm2022/forty-cdk-tree.mjs.map +1 -1
- package/hover-card/README.md +1 -0
- package/listbox/README.md +24 -0
- package/package.json +9 -1
- package/time-field/README.md +1 -1
- package/time-range-field/README.md +174 -0
- package/tooltip/README.md +1 -0
- package/tree/README.md +27 -0
- package/types/forty-cdk-checkbox.d.ts +1 -1
- package/types/forty-cdk-combobox.d.ts +26 -1
- package/types/forty-cdk-core.d.ts +504 -149
- package/types/forty-cdk-date-field.d.ts +12 -16
- package/types/forty-cdk-date-picker.d.ts +374 -155
- package/types/forty-cdk-date-range-field.d.ts +390 -0
- package/types/forty-cdk-hover-card.d.ts +8 -2
- package/types/forty-cdk-listbox.d.ts +20 -0
- package/types/forty-cdk-table.d.ts +8 -1
- package/types/forty-cdk-time-field.d.ts +16 -18
- package/types/forty-cdk-time-range-field.d.ts +389 -0
- package/types/forty-cdk-tooltip.d.ts +5 -3
- package/types/forty-cdk-tree.d.ts +28 -0
package/date-picker/README.md
CHANGED
|
@@ -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,
|