@tensor_1/react 2.30.0 → 2.32.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 (122) hide show
  1. package/README.md +430 -0
  2. package/dist/attachment-labels.d.ts +16 -0
  3. package/dist/attachment-labels.js +9 -0
  4. package/dist/calendar-model.d.ts +141 -1
  5. package/dist/calendar-model.js +200 -11
  6. package/dist/chart-preset.cjs +127 -7
  7. package/dist/chart-preset.d.cts +2 -2
  8. package/dist/chart-preset.d.ts +1 -0
  9. package/dist/chart-preset.js +6 -1
  10. package/dist/chart-theme-Cl0IrkKQ.d.cts +112 -0
  11. package/dist/chart-theme.d.ts +51 -0
  12. package/dist/chart-theme.js +99 -0
  13. package/dist/chart.cjs +127 -7
  14. package/dist/chart.d.cts +2 -2
  15. package/dist/chart.d.ts +1 -0
  16. package/dist/chart.js +6 -2
  17. package/dist/components/Alert.d.ts +17 -1
  18. package/dist/components/Alert.js +9 -4
  19. package/dist/components/AlertDismiss.d.ts +14 -0
  20. package/dist/components/AlertDismiss.js +11 -0
  21. package/dist/components/Attachment.d.ts +58 -0
  22. package/dist/components/Attachment.js +69 -0
  23. package/dist/components/AttachmentParts.d.ts +24 -0
  24. package/dist/components/AttachmentParts.js +20 -0
  25. package/dist/components/Chart.d.ts +1 -0
  26. package/dist/components/Chart.js +6 -1
  27. package/dist/components/ChartCore.d.ts +6 -1
  28. package/dist/components/ChartCore.js +23 -5
  29. package/dist/components/Chip.d.ts +17 -2
  30. package/dist/components/Chip.js +35 -11
  31. package/dist/components/ChipRemove.d.ts +17 -0
  32. package/dist/components/ChipRemove.js +13 -0
  33. package/dist/components/CollapsibleParts.d.ts +29 -0
  34. package/dist/components/CollapsibleParts.js +20 -0
  35. package/dist/components/CollapsibleSection.d.ts +30 -3
  36. package/dist/components/CollapsibleSection.js +7 -11
  37. package/dist/components/CollapsibleSectionState.d.ts +1 -1
  38. package/dist/components/CollapsibleSectionState.js +54 -27
  39. package/dist/components/DashboardGrid.d.ts +60 -0
  40. package/dist/components/DashboardGrid.js +40 -0
  41. package/dist/components/DateRangePicker.d.ts +7 -1
  42. package/dist/components/DateRangePicker.js +5 -1
  43. package/dist/components/DescriptionList.d.ts +12 -1
  44. package/dist/components/DescriptionList.js +5 -5
  45. package/dist/components/Dialogs.d.ts +24 -3
  46. package/dist/components/Dialogs.js +74 -16
  47. package/dist/components/EntityPicker.d.ts +56 -0
  48. package/dist/components/EntityPicker.js +168 -0
  49. package/dist/components/FileIcon.d.ts +9 -0
  50. package/dist/components/FileIcon.js +17 -0
  51. package/dist/components/FileUpload.d.ts +95 -0
  52. package/dist/components/FileUpload.js +145 -0
  53. package/dist/components/FilterEditor.d.ts +55 -0
  54. package/dist/components/FilterEditor.js +186 -0
  55. package/dist/components/Heartbeat.d.ts +31 -0
  56. package/dist/components/Heartbeat.js +28 -0
  57. package/dist/components/Lightbox.d.ts +45 -0
  58. package/dist/components/Lightbox.js +102 -0
  59. package/dist/components/Menu.d.ts +53 -5
  60. package/dist/components/Menu.js +104 -4
  61. package/dist/components/Meter.d.ts +7 -3
  62. package/dist/components/Meter.js +2 -1
  63. package/dist/components/Popover.d.ts +21 -2
  64. package/dist/components/Popover.js +94 -17
  65. package/dist/components/Prose.d.ts +6 -2
  66. package/dist/components/Prose.js +1 -1
  67. package/dist/components/RuleBuilder.d.ts +52 -4
  68. package/dist/components/RuleBuilder.js +109 -24
  69. package/dist/components/SchedulingCalendar.d.ts +142 -11
  70. package/dist/components/SchedulingCalendar.js +234 -57
  71. package/dist/components/SchedulingTimeline.d.ts +1 -0
  72. package/dist/components/SearchSuggest.d.ts +6 -2
  73. package/dist/components/SearchSuggest.js +4 -0
  74. package/dist/components/Segmented.d.ts +17 -3
  75. package/dist/components/Segmented.js +59 -5
  76. package/dist/components/Select.d.ts +66 -2
  77. package/dist/components/Select.js +800 -10
  78. package/dist/components/Spinner.d.ts +2 -1
  79. package/dist/components/Spinner.js +1 -1
  80. package/dist/components/StatusPill.d.ts +8 -2
  81. package/dist/components/StatusPill.js +2 -2
  82. package/dist/components/Steps.d.ts +28 -4
  83. package/dist/components/Steps.js +46 -16
  84. package/dist/components/TableControls.js +4 -3
  85. package/dist/components/Tabs.d.ts +7 -2
  86. package/dist/components/Tabs.js +14 -1
  87. package/dist/components/TextDiff.d.ts +49 -0
  88. package/dist/components/TextDiff.js +124 -0
  89. package/dist/{emoji-image-6mPZ15Zy.d.cts → emoji-image-DjCKpdwE.d.cts} +1 -1
  90. package/dist/emoji.cjs +169 -27
  91. package/dist/emoji.d.cts +3 -3
  92. package/dist/file-model.d.ts +56 -0
  93. package/dist/file-model.js +52 -0
  94. package/dist/filter-model.d.ts +112 -0
  95. package/dist/filter-model.js +103 -0
  96. package/dist/hooks/useAnchoredPosition.d.ts +24 -1
  97. package/dist/hooks/useAnchoredPosition.js +58 -8
  98. package/dist/hooks/useVirtualRows.d.ts +17 -2
  99. package/dist/hooks/useVirtualRows.js +6 -5
  100. package/dist/icons/Icon.d.ts +14 -14
  101. package/dist/index.cjs +4938 -1631
  102. package/dist/index.d.cts +1622 -178
  103. package/dist/index.d.ts +20 -6
  104. package/dist/index.js +88 -4
  105. package/dist/labels-context.d.ts +28 -0
  106. package/dist/{overlay-position-Cjv5E7mn.d.cts → overlay-position-2Afke51l.d.cts} +20 -0
  107. package/dist/overlay-position.d.ts +20 -0
  108. package/dist/overlay-position.js +17 -2
  109. package/dist/rich-text.cjs +169 -27
  110. package/dist/rich-text.d.cts +3 -3
  111. package/dist/rule-builder-model.d.ts +34 -2
  112. package/dist/rule-builder-model.js +48 -12
  113. package/dist/scheduling-calendar-model.d.ts +66 -0
  114. package/dist/scheduling-calendar-model.js +82 -0
  115. package/dist/select-model.d.ts +80 -0
  116. package/dist/select-model.js +109 -0
  117. package/dist/table-engine.d.ts +7 -0
  118. package/dist/table-engine.js +2 -1
  119. package/dist/text-diff-model.d.ts +71 -0
  120. package/dist/text-diff-model.js +261 -0
  121. package/package.json +1 -1
  122. package/dist/ChartCore-DzWe10bN.d.cts +0 -59
package/README.md CHANGED
@@ -354,6 +354,52 @@ const icons: Record<EntityType, UixIcon> = { incident: SirenIcon, change: GitBra
354
354
  - **Accessibility:** decorative unless `label` is set. Never let an icon carry meaning alone; pair it with text.
355
355
  - **Bundle size:** each `<Name>Icon` is tree-shaken; one icon adds well under 3 KB minified. `<Icon name>` and `ICON_GLYPHS` load all of them.
356
356
  - **A glyph is missing?** Add its Lucide name to `scripts/icon-names.txt`, run `node scripts/generate-icons.mjs <lucide-react dir>` and release. Do not paste an SVG into a product.
357
+ ### SchedulingCalendar
358
+
359
+ A month, week or agenda of UTC ranges in one explicit IANA zone (HAR-1347, HAR-1506). The calendar draws; the
360
+ consumer owns every word, count and ranking.
361
+
362
+ ```tsx
363
+ import { SchedulingCalendar, layoutMonthSpans, schedulingGridDays } from '@tensor_1/react';
364
+ import { TriangleAlertIcon } from '@tensor_1/react/icons';
365
+
366
+ // The item: one hue dimension (band), one state channel (status), markers with text.
367
+ const entry = {
368
+ id: 'e1', title: 'Firewall rule update', start: '2026-10-06T08:00:00Z', end: '2026-10-06T09:00:00Z',
369
+ band: 'high', // 'none' | 'low' | 'medium' | 'high' — only 'high' is filled
370
+ status: 'tentative', // 'tentative' | 'committed' | 'live' | 'done' | 'dead' — line style, never a hue
371
+ markers: [{ id: 'm', label: 'Needs sign-off', emphasis: 'warning', icon: <TriangleAlertIcon /> }],
372
+ accessibleName: 'Firewall rule update, high, tentative, 6 October 10:00 to 11:00',
373
+ };
374
+ // A window: neutral, hatched, named, focusable. `global: false` never gets the global treatment.
375
+ const hold = { id: 'w1', label: 'Quarter close', kindLabel: 'Hold', scopeLabel: 'Payroll services', pattern: 'cross',
376
+ start: '2026-10-04T22:00:00Z', end: '2026-10-09T22:00:00Z' };
377
+
378
+ // Controlled month: the consumer ranks, cuts and counts; the calendar renders what it is given.
379
+ const grid = schedulingGridDays('2026-10-07', 'month', 1);
380
+ const spanLayout = layoutMonthSpans(spansInSalienceOrder, grid, { timeZone, weekStartsOn: 1 }); // lanes in the order given
381
+ <SchedulingCalendar timeZone="Europe/Berlin" anchorDate="2026-10-07" showHeader={false}
382
+ entries={multiDayEntries} overlays={[hold]} spanLayout={spanLayout}
383
+ dayEntries={{ '2026-10-07': topThree }} // already ranked and cut
384
+ days={{ '2026-10-07': { count: 50, overflowCount: 47, label: '50 items' } }} // "+47 more", never entries.length
385
+ maxEntriesPerDay={3} onShowMore={(date) => openDay(date)} // a controlled day never expands in place
386
+ onSelectEntry={openItem} onSelectOverlay={openWindow} onSelectDate={openDayView}
387
+ legend={legendOfWhatIsOnScreen} legendCaption="Times in Europe/Berlin"
388
+ notice={truncated && <p>Showing the first 2,000 items.</p>} emptyNote="Nothing is scheduled this month." />
389
+ ```
390
+
391
+ - **Drawn once.** An entry or window that covers several days is one bar per week row, in lanes above the chips
392
+ (`windowLaneCap`, `spanLaneCap`, default 2 each). Windows never take a chip slot. A row with more spans than lanes
393
+ shows "+N" and calls `onShowMore` with the first day that has a hidden span.
394
+ - **Day membership is end-exclusive in `timeZone`**: 22:00–00:00 is on its start day only.
395
+ - **Fixed geometry.** With `days` / `dayEntries` (or `maxEntriesPerDay` plus `onShowMore`) every cell keeps one
396
+ height: the head, the lane caps, the chip rows and the "+N" line. A chip reads `HH:MM title` (24 h, in the zone)
397
+ unless `renderEntry` replaces it.
398
+ - **Colour.** Every calendar colour is named once at the top of `scheduling-calendar.css` (`--calendar-*`).
399
+ - **Deprecated, still working in 2.x:** `entry.state` / `SchedulingEntryState`, `overlay.kind` /
400
+ `SchedulingOverlayKind` and the `previous` / `next` labels. An entry with `state` is no longer tinted: the two
401
+ problem states show a marker with the word for that state.
402
+
357
403
  ### SchedulingTimeline
358
404
 
359
405
  Lanes of bars on a time axis (HAR-1364; TENSOR's change day/week timeline, rollout Gantt rows and licence-renewal
@@ -377,3 +423,387 @@ markers). It pairs with `SchedulingCalendar`: same entry states, the same explic
377
423
  - **Keyboard:** one tab stop. ←/→ go to the previous or next bar in a lane, ↑/↓ to the nearest bar in the next lane, Home/End to the lane's ends, and Enter selects.
378
424
  - **Windows and markers:** `overlays` (`freeze`, `maintenance`, `blackout`; all lanes, or one with `laneId`), `markers` (a point in time) and `now` are drawn behind the bars and listed for screen readers.
379
425
  - Pure helpers are exported for tests and server code: `timelineTicks`, `placeSpan`, `layoutLane`, `shiftSpan`, `snapToStep`, `pixelsToMs`, `defaultTimelineStep`.
426
+
427
+ ### Calendar model (zoned days, hour slots, lanes)
428
+
429
+ Pure functions for calendar geometry in one explicit IANA display zone (HAR-1504). They use only
430
+ `Intl.DateTimeFormat`, never the process time zone, and never a fixed day length: DST days have 23 or 25
431
+ hours. Every interval is half-open, `[start, end)`.
432
+
433
+ ```ts
434
+ zonedDayBounds('2026-10-25', 'Europe/Berlin'); // { start: 2026-10-24T22:00Z, end: 2026-10-25T23:00Z } (25 h)
435
+ zonedHourSlots('2026-10-25', 'Europe/Berlin'); // 25 slots; '02' twice, offsetLabel '+02:00' then '+01:00'
436
+ zonedHourSlots('2026-03-29', 'Europe/Berlin'); // 23 slots; no '02'
437
+ addZonedDays('2026-10-24T20:00Z', 1, 'Europe/Berlin'); // { instant: 2026-10-25T21:00Z, adjusted: null }
438
+ addZonedDays('2026-03-28T01:30Z', 1, 'Europe/Berlin'); // 03:30 local, adjusted: 'gap_forward'
439
+ zonedDaySpan('2026-10-10T22:00Z', '2026-10-11T22:00Z', 'Europe/Berlin'); // { start: '2026-10-11', end: '2026-10-12' }
440
+ enumerateDateKeys({ start: '2026-10-11', end: '2026-10-12' }); // { dates: ['2026-10-11'], truncated: false }
441
+ ```
442
+
443
+ - **Days:** `zonedDayBounds(dateKey, tz)` is the day's first instant and the next day's first instant, so the end of
444
+ one day is the start of the next. A day whose midnight is skipped starts at its first existing instant (01:00).
445
+ - **Hour slots:** `zonedHourSlots(dateKey, tz)` gives one `{ instant, label, offsetLabel }` per real hour start.
446
+ `label` is the local hour (`'00'`–`'23'`); `offsetLabel` (`'+02:00'`) is set on both occurrences of a repeated
447
+ hour and is `null` otherwise.
448
+ - **"+1 day":** `addZonedDays(instant, n, tz)` keeps the wall-clock time. A repeated wall time keeps the source's
449
+ offset; a skipped one moves forward once and returns `adjusted: 'gap_forward'`, so the caller can announce it.
450
+ - **Day membership:** `zonedDaySpan(start, end, tz)` returns the days an entry touches as `[start, end)` date keys;
451
+ an entry ending at local 00:00 does not touch the next day. It throws a `RangeError` when `end` is not after
452
+ `start` and never swaps. `enumerateDateKeys(span, { limit })` lists them (default limit 370, `Infinity` for none)
453
+ and returns `truncated: true` instead of stopping silently.
454
+ - **Lanes:** `packLanes(intervals, maxLanes, { order })` assigns first-fit lanes; overlapping intervals never share a
455
+ lane, touching ones may. Intervals that do not fit get `lane: null`, and each overlap cluster reports its
456
+ `laneCount` and `overflow`. `order: 'start'` (default) is greedy by start, then end, then id; `order: 'given'`
457
+ packs in input order, so a consumer's ranking decides who gets a lane.
458
+ - **Top-N:** `rankOverflow(items, compare, n)` is a stable sort and cut: `{ visible, hidden }`. `hidden` counts only
459
+ what it was given; show the consumer's own total when it has one.
460
+ - **Formatters:** `cachedDateTimeFormat(locale, options)` returns one shared `Intl.DateTimeFormat` per locale and
461
+ options shape. The model formats through it, so a 2,100-entry month builds at most a handful of formatters.
462
+ - **Deprecated:** `zonedDateSpan` (end-inclusive, swaps inverted ends) and `enumerateDateSpan` (stops silently at
463
+ 370) are unchanged in 2.x. Use `zonedDaySpan` and `enumerateDateKeys`.
464
+
465
+ ### FilterEditor (typed column filters)
466
+
467
+ `FilterEditor` is the content of one column-filter popover with a typed value (HAR-1365; TENSOR's
468
+ `table-filter-editor.tsx`, MOTUS's "+ filter"). `summarizeFilter` turns the applied value into the chip text.
469
+ `FilterPopover` stays for the single-string case.
470
+
471
+ ```tsx
472
+ const field: FilterField = { id: 'state', label: t('state'), kind: 'enum', options: states };
473
+ <Popover anchor={chipRef} popover="manual">
474
+ <FilterEditor field={field} value={draft} onValueChange={setDraft} onApply={() => apply(field.id, draft)} onClear={() => clear(field.id)} />
475
+ </Popover>
476
+ <Chip onClick={edit} onRemove={remove}>{summarizeFilter(field, applied, { formatDate: formatDisplayDate })}</Chip>
477
+ ```
478
+
479
+ | `kind` | Value | Editor |
480
+ |---|---|---|
481
+ | `enum` | `{ values: string[] }` | checkboxes; a diacritic-insensitive search above more than `searchThreshold` (8) options; Select all / Clear selection |
482
+ | `text` | `{ operator, text }` | condition (`contains`, `not-contains`, `is`, `is-not`, `starts-with`) + text; Enter applies |
483
+ | `number` | `{ operator, value, to? }` | condition (`eq`…`gte`, `between`) + value(s), optional `unit` |
484
+ | `date-range` | `{ from?, to? }` | two date fields that bound each other |
485
+ | `boolean` | `{ value?: boolean }` | Any / Yes / No (`trueLabel`, `falseLabel`) |
486
+ | `reference` | `{ values: { value, label }[] }` | `onSearch(query)` → records, debounced (`searchDelay`), with loading, empty and error (retry) states; chosen records as removable chips |
487
+
488
+ `isFilterEmpty`, `emptyFilterValue`, the operator lists and all words (`labels`) are exported, and the value is plain
489
+ data for your own query string. Stale search results are ignored.
490
+
491
+ **Presets (enum, HAR-1505).** `field.presets` names selections the product defines, e.g. a default scope. They
492
+ render as toggle `Chip`s (`aria-pressed`) in a labelled group above the search box and options. Activating one
493
+ replaces the selection with its `values`; the preset whose values equal the selection (as a set) is pressed, so
494
+ ticking another option un-presses it. `summarizeFilter` then returns the preset's label (the `preset` summary
495
+ label, default `'{label}'`; use `'{field}: {label}'` to keep the column name), and `matchFilterPreset(field, value)`
496
+ returns the matching preset for your own use. The group's name is `labels.presets` (default "Presets").
497
+
498
+ ```tsx
499
+ const state: FilterField = {
500
+ id: 'state', label: t('state'), kind: 'enum', options: states,
501
+ presets: [
502
+ { id: 'open', label: t('openWork'), values: ['new', 'open', 'pending'] },
503
+ { id: 'all', label: t('all'), values: states.map((s) => s.value) },
504
+ ],
505
+ };
506
+ ```
507
+
508
+ Every `FilterEditor` word can also come from `<UixLabelsProvider labels={{ filterEditor: { … } }}>`; an
509
+ explicit `labels` prop still wins.
510
+ ### EntityPicker
511
+
512
+ A form field that holds one record found by an async search (HAR-1366; TENSOR's `entity-picker.tsx` and its
513
+ person, incident and CI pickers; MOTUS's author filter). Built on `SearchSuggest`.
514
+
515
+ ```tsx
516
+ <Field label={t('assignee')} htmlFor="assignee" hint={t('assigneeHint')}>
517
+ <EntityPicker id="assignee" name="assignee_id" label={t('assignee')} value={assignee} onValueChange={setAssignee}
518
+ onSearch={(q) => api.people.search(q)} labels={{ none: t('choosePerson') }} />
519
+ </Field>
520
+ ```
521
+
522
+ - **Chosen:** the record shows in the field (title plus its last breadcrumb, or `renderValue`), with a × named "Clear {title}". Choosing the field again opens a fresh search; Escape with an empty query returns to the record.
523
+ - **Searching:** debounced (`delay`, 250 ms) from `minQueryLength` (0) characters, with loading, empty and error states. **Retry is a list row**, so arrow keys reach it. Stale results are ignored. Focus returns to the field after a choice.
524
+ - **Forms:** `name` posts the chosen id in a hidden input; `id` and the `Field` hint and error wire to the active control. `SearchSuggest` gains `inputId` and `inputDescribedBy` for the same purpose.
525
+
526
+ ### Lightbox
527
+
528
+ A full-screen media viewer (HAR-1367; TENSOR's capture viewer, MOTUS's programme gallery and photo zoom).
529
+
530
+ ```tsx
531
+ <Lightbox items={photos.map((p) => ({ src: p.url, alt: p.alt, caption: p.caption, width: p.w, height: p.h }))}
532
+ index={index} onIndexChange={setIndex} open={open} onClose={() => setOpen(false)} />
533
+ ```
534
+
535
+ - **Moving:** previous/next buttons, ←/→, Home/End and a swipe on touch (`loop` wraps). An announced "{index} of {total}" counter and the caption sit under the media.
536
+ - **Size:** "Show actual size" switches between fitted and natural size; the stage scrolls when zoomed.
537
+ - **Video:** `kind: 'video'` renders a native player, which keeps its own arrow keys.
538
+ - **Dialog:** native `<dialog>`. Focus starts on Close, Escape and a backdrop click close it, and focus returns to the opener. Every word is in `labels`.
539
+ - **Image size:** pass `width` and `height` so an SVG or a slow image keeps its box.
540
+
541
+ ### FileUpload and Attachment
542
+
543
+ `FileUpload` is a drop zone, file picker and upload list (HAR-984; TENSOR's attachment panel, MOTUS's photo intake). `Attachment` is a row for a file that is already stored (HAR-985).
544
+
545
+ ```tsx
546
+ <FileUpload items={uploads} accept=".pdf,image/*" maxSize={20_000_000} maxFiles={10}
547
+ hint="PDF or photos, up to 20 MB"
548
+ onFilesAdded={(accepted) => accepted.forEach(startUpload)}
549
+ onRetry={retryUpload} onRemove={removeUpload} />
550
+
551
+ <AttachmentList label={`Attachments (${files.length})`}>
552
+ {files.map((f) => (
553
+ <Attachment key={f.id} name={f.name} size={f.size} type={f.type} href={f.url} download
554
+ meta={`${f.addedBy} · ${f.addedAt}`} onRemove={() => confirmRemove(f)} />
555
+ ))}
556
+ </AttachmentList>
557
+ ```
558
+
559
+ - **The product uploads.** `onFilesAdded(accepted, rejected)` gets the files that passed `accept`, `maxSize` and `maxFiles` (counting `items`). Report progress, success and failure back through `items` (`status`, `progress`, `error`). Rejected files are listed in an alert, one sentence each.
560
+ - **Keyboard:** "Choose files" is a real button, described by the `hint`. Dropping is an extra, not the only way in. `capture` opens the camera on phones.
561
+ - **List:** each file shows its size and status. Uploads show a progress bar (indeterminate without `progress`), and failures show the reason and a Retry button. `previewUrl` shows a thumbnail, and `onAltChange` asks for alt text on images.
562
+ - **Attachment:** the name is the link (`href`, `download`, `renderLink`). It has a quieter `meta` line, a `state` slot (e.g. a "Scanning" `StatusPill`), `actions`, `onRemove`, and `status` `loading` | `error` | `forbidden` (no link, and the reason shown). It's server-renderable.
563
+ - Pure helpers: `formatFileSize`, `fileMatchesAccept`, `partitionFiles`, `fileKind`.
564
+
565
+ ### TextDiff
566
+
567
+ A two-way diff of two texts (HAR-1368; TENSOR's knowledge-article version diff). `DiffViewer` stays the three-way configuration diff.
568
+
569
+ ```tsx
570
+ <TextDiff before={v3.body} after={v4.body} beforeLabel={t('version', { n: 3 })} afterLabel={t('version', { n: 4 })}
571
+ label={t('bodyChanges')} context={3} />
572
+ <TextDiff before={v3.title} after={v4.title} granularity="word" label={t('titleChanges')} />
573
+ ```
574
+
575
+ - **Lines** (`granularity="line"`, the default): a Myers diff. Removed and added lines are paired with their closest counterpart, so a renumbered list still lines up, and only the changed words are marked inside a pair.
576
+ - **Words** (`granularity="word"`): prose compared as one flow. Changed words across a space join into one change.
577
+ - **Views:** `split` (the default) puts the versions side by side and stacks below about 36rem of container width. `unified` interleaves them.
578
+ - **Not colour alone:** changes are `<del>` / `<ins>`, with a −/+ sign and a spoken "Removed:" / "Added:" (`labels`). Struck and underlined words mark changes inside a line.
579
+ - **Long texts:** `context` keeps that many unchanged lines around each change. Longer runs fold behind "Show n unchanged lines", and focus moves to the first revealed line. Over `maxTokens` (20 000) a note replaces the diff. Past `maxEdits` (2 000) the changed middle shows as one replacement instead of stalling.
580
+ - Pure model for tests and servers: `diffText`, `diffTokens`, `textDiffRows`, `tokenizeText`.
581
+
582
+ ### DashboardGrid
583
+
584
+ The layout grid for dashboard widgets (HAR-1555; TENSOR's role dashboards). Pure layout: drag-and-drop, persistence and widget chrome stay in the product. Server-renderable.
585
+
586
+ ```tsx
587
+ <DashboardGrid columns={{ base: 1, sm: 2, lg: 3 }} gap="md">
588
+ <DashboardGrid.Item><Stat label="Open incidents" value={42} /></DashboardGrid.Item>
589
+ <DashboardGrid.Item span={2}><Card title="Queue by team">…</Card></DashboardGrid.Item>
590
+ <DashboardGrid.Item span="full">
591
+ <Chart title="Resolved per hour" option={option} height="clamp(160px, 40cqi, 360px)" />
592
+ </DashboardGrid.Item>
593
+ </DashboardGrid>
594
+ ```
595
+
596
+ - **Columns follow the grid's own width**, not the viewport (a container query), so the same dashboard has fewer columns beside an open sidebar. Breakpoints: `sm` 36rem, `md` 48rem, `lg` 60rem, `xl` 80rem of grid width, sized for widgets of about 280–320 px (2 columns from 36rem, 3 from 60rem, 4 from 80rem). A breakpoint you leave out keeps the count of the next smaller one; a missing `base` is 1. Without `columns`: 1, 2 from `sm`, 3 from `lg`. Counts are 1–4.
597
+ - **Spans:** `span={1}` (default), `span={2}` (clamps to the columns there are, so it is the whole row at one column), `span="full"` (`grid-column: 1 / -1`, the whole row at every width).
598
+ - **Size-aware content:** each item is an inline-size container named `uix-dashboard-grid-item`. Give a Chart a height in `cqi` (a share of the item's width), or pass your own height per widget size; the Chart already redraws at the item's width. Use `@container uix-dashboard-grid-item (min-width: …)` for a card's own split layout. Widgets in one row share a height (the item's last child grows).
599
+ - **`gap`:** `sm` (--uix-space-3), `md` (default, --uix-space-4), `lg` (--uix-space-6).
600
+ - Refs reach the grid and item `<div>`s, and other props pass through. For an element you render yourself (a drag-and-drop library's sortable node, a server template), use `dashboardGridClassName(columns, gap)` and `dashboardGridItemClassName(span)`.
601
+ - Plain HTML: `.uix-dashboard-grid` with `--cols-N` / `--{sm,md,lg,xl}-cols-N` and `--gap-sm|lg`, items `.uix-dashboard-grid__item` with `--span-2` / `--full`. Every direct child of the grid is a cell.
602
+
603
+ ### Select draws its own list
604
+
605
+ `Select` (HAR-1572) no longer opens the browser's dropdown. It is the WAI-ARIA select-only combobox: a `<button role="combobox">` trigger and a UIx-drawn listbox in the top layer (a bottom sheet on phones). The props did not change, so existing call sites keep working:
606
+
607
+ ```tsx
608
+ <Select id="status" name="status" value={status} onChange={(e) => setStatus(e.target.value)}>
609
+ <option value="" disabled hidden>Choose a status</option>
610
+ <optgroup label="Active">{active.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</optgroup>
611
+ </Select>
612
+ <Select options={teams} value={team} onValueChange={(v) => setTeam(v as string)} searchable="auto" placeholder={t('team')} />
613
+ <Select multiple name="labels" options={labels} defaultValue={['network']} />
614
+ <Select loadOptions={(q, signal) => api.owners(q, { signal })} searchable options={[owner]} value={owner.value} />
615
+ ```
616
+
617
+ - **Forms:** a visually hidden `<select data-uix-select-proxy>` holds `name`, `required`, `form`, `multiple` and the value. `ref` points at it, so `FormData`, `form.reset()`, autofill, react-hook-form `register` and `Controller` work as before. `onChange(e)` gets `e.target.value`, and a failed `required` focuses the trigger.
618
+ - **Keyboard:** ↑ ↓ Home End PageUp PageDown and typing move (repeat one letter to cycle). Enter, Space, Tab and Alt+↑ choose. Escape closes and keeps the value. With `multiple`, Space toggles and Delete clears.
619
+ - Plain Select for up to about 12 options, `searchable` for longer lists, `EntityPicker` or `SearchSuggest` for records.
620
+
621
+ ### Gaps from the TENSOR migration (2.32.0)
622
+
623
+ Found on 2026-10-07/08 while TENSOR moved its hand-built UI onto 2.31.0. Everything is optional
624
+ unless a line says **Behaviour change**.
625
+
626
+ #### Anchored overlays stay inside the viewport
627
+
628
+ A `Popover` / `useAnchoredPosition` panel that fits neither above nor below its anchor used to
629
+ leave the viewport (a 384 px panel under a trigger in the middle of a 640 px phone screen got a
630
+ negative `top`). It now slides over the anchor and stays inside the 8 px padding, after a scroll
631
+ and after a resize as well. Once the anchor itself has left the viewport the panel follows it out.
632
+
633
+ ```tsx
634
+ <Popover anchor={triggerRef} capHeight>…</Popover> // taller than the screen: capped, scrolls inside
635
+ useAnchoredPosition(anchorRef, panelRef, {
636
+ open,
637
+ capHeight: true, // max-height = viewport − 2 × padding (a smaller own max-height still applies)
638
+ onAnchorHidden: () => setOpen(false), // what `Popover closeWhenAnchorHidden` is built on
639
+ shiftMainAxis: false, // opt out: the 2.31.0 placement
640
+ });
641
+ computePosition(anchor, size, viewport).mainAxisRange // [min, max] the top is kept in, or null
642
+ ```
643
+
644
+ With CSS anchor positioning the browser moves a box with its anchor *after* resolving `top`, so
645
+ no value can hold it at the viewport limit while the page scrolls. While a panel is held at the
646
+ limit, or within 64 px of it, the hook writes fixed coordinates instead; they cannot drift.
647
+
648
+ #### Menu: radio and checkbox items, attributes, Escape on the trigger
649
+
650
+ ```tsx
651
+ <Menu trigger={<Button>Preset</Button>}>
652
+ <MenuItem id="manage" data-action-id="preset.manage" onSelect={manage}>Manage presets</MenuItem>
653
+ <MenuSeparator />
654
+ <MenuRadioGroup label="Dashboard preset" value={preset} onValueChange={setPreset}>
655
+ <MenuItemRadio value="mine">My queue</MenuItemRadio>
656
+ <MenuItemRadio value="team">Team</MenuItemRadio>
657
+ </MenuRadioGroup>
658
+ <MenuItemCheckbox checked={zebra} onCheckedChange={setZebra}>Alternating rows</MenuItemCheckbox>
659
+ </Menu>
660
+ ```
661
+
662
+ - `MenuItemRadio` / `MenuItemCheckbox` render `menuitemradio` / `menuitemcheckbox` with
663
+ `aria-checked` and a check column. The menu opens on the checked radio item.
664
+ - A click or Enter follows `closeOnSelect` (radio: `true`, checkbox: `false`); Space changes the
665
+ state and leaves the menu open.
666
+ - Every item kind forwards `id` and `data-*` to its own element.
667
+ - Escape (and Tab) on the trigger closes a menu that has no item to focus (every item disabled).
668
+ - The menu is capped to the viewport and scrolls.
669
+
670
+ #### CollapsibleSection and Steps
671
+
672
+ ```tsx
673
+ <CollapsibleSection title="Details" defaultOpen persistKey={`incident:${id}:details`} persistStorage="local">…</CollapsibleSection>
674
+ <CollapsibleSection title="SLA" compact headingLevel={3} lazy openRequest={jumpToSla}>…</CollapsibleSection>
675
+ ```
676
+
677
+ - `defaultOpen` is the starting state; with `persistKey`, what the person chose wins over it. A
678
+ passed `open` is the same starting state and no longer overrides the remembered state at
679
+ mount; it still applies when the parent changes it.
680
+ - `persistStorage`: `'session'` (default) or `'local'`.
681
+ - `openRequest` already set at mount is honoured (a deep link), and every request moves focus to
682
+ the summary. `undefined` and `0` mean "no request yet".
683
+ - `compact`: a quiet row inside a card. `headingLevel`: the title is a real heading and the body a
684
+ `role="region"` named by it.
685
+
686
+ ```tsx
687
+ <Steps progress={false} headingLevel={2} label="New change">
688
+ <Step title="What is changing" description="One sentence is enough."><Field …/></Step>
689
+ <Step title="When"><Field …/></Step>
690
+ </Steps>
691
+ ```
692
+
693
+ `progress={false}` is a numbered list of sections that are all on screen: no state is drawn or
694
+ announced, and each step's children are its content, in a group named by the step title. Without
695
+ it `Steps` is the progress indicator it was.
696
+
697
+ Also: `DescriptionItem termProps` / `descriptionProps` (attributes for the `<dt>` / `<dd>`), and
698
+ `closeLabel` is optional on `PromptDialog` and `ConfirmDialog`.
699
+
700
+ #### Tabs: a name for the tablist
701
+
702
+ ```tsx
703
+ <Tabs aria-label="Inbox filters" value={tab} onChange={setTab}>…</Tabs>
704
+ <Tabs aria-labelledby="news-heading" overflow="scroll" …>…</Tabs>
705
+ ```
706
+
707
+ `Tabs` passes HTML attributes to its `role="tablist"` element, in both overflow modes.
708
+
709
+ #### Segmented: one tab stop, arrow keys, a radiogroup mode
710
+
711
+ ```tsx
712
+ <Segmented aria-label="Row density" value={density} onChange={setDensity}>…</Segmented>
713
+ <Segmented selection="radio" aria-labelledby="theme-label" value={theme} onChange={setTheme}>…</Segmented>
714
+ ```
715
+
716
+ - **Behaviour change:** a `Segmented` is one tab stop (the selected option, or the first enabled
717
+ one). Arrow Right/Down and Left/Up move and select, wrapping; Home / End jump; Left and Right
718
+ swap in a right-to-left group. Before, every option was a tab stop and no key was handled.
719
+ - `selection="radio"` renders `radiogroup` / `radio` / `aria-checked` instead of `group` /
720
+ `aria-pressed`, for a setting with exactly one value.
721
+
722
+ #### useVirtualRows
723
+
724
+ ```tsx
725
+ const v = useVirtualRows(rows, { rowHeight: 44, estimatedViewportHeight: 600, enabled: rows.length > 0 });
726
+ ```
727
+
728
+ - `estimatedViewportHeight`: the height to assume until the scroller is measured (and whenever it
729
+ measures 0), so a 5,000-row table mounts with one window, not 5,000 rows.
730
+ - When the row count shrinks under a large `scrollTop`, the window is clamped to the new count in
731
+ the same render: the spacer is never taller than the list.
732
+ - `enabled: false` returns every row and no spacers. Rows are windowed when
733
+ `rows.length > threshold` (default 100).
734
+
735
+ #### RuleBuilder for a flat AND-list
736
+
737
+ ```tsx
738
+ <RuleBuilder value={rule} onChange={setRule} fields={fields} operators={operators}
739
+ conditionsOnly combinator="and" allowGroups={false} checks={{ emptyGroup: false }} labels={t.ruleBuilder} />
740
+ <RuleBuilder … readOnly /> // the builder, every control disabled
741
+ <RuleBuilder … readOnly="summary" /> // one sentence
742
+ ```
743
+
744
+ - The built-in validation messages are labels (`issueEmptyGroup`, `issueNoActions`,
745
+ `issueMissingField`, …) and `checks` turns individual checks off.
746
+ - `conditionsOnly`: no "Then" block; `actions` is then optional.
747
+ - `combinator` fixes every group to one value (shown as text, never emits the other);
748
+ `hideCombinator` removes the control. `allowGroups={false}` hides "Add group".
749
+ - **Behaviour change:** `readOnly` renders the builder with its controls disabled, not a sentence.
750
+ The sentence is `readOnly="summary"`, and its words are labels.
751
+ - `UixLabelsProvider labels={{ ruleBuilder }}` translates every builder below it.
752
+
753
+ #### Chip
754
+
755
+ ```tsx
756
+ <Chip ref={chipRef} onClick={openEditor} onRemove={clear}
757
+ bodyProps={{ 'aria-haspopup': 'dialog', 'aria-expanded': open, 'aria-controls': editorId }}>Status: Open</Chip>
758
+ <Chip href="/incidents" current renderLink={link}>Incidents</Chip>
759
+ ```
760
+
761
+ - The × is named from `UixLabelsProvider` `chip.remove` (`'{label} entfernen'`); `removeLabel`
762
+ still wins.
763
+ - The ref is the body (the button, link or span that carries the chip's action), also on a
764
+ removable chip; `bodyProps` puts attributes there.
765
+ - `current` on a link chip: `aria-current="page"` and the filled look.
766
+
767
+ #### FileUpload and Attachment
768
+
769
+ - A long unbroken file name no longer widens `FileUpload` past its container.
770
+ - Both read `UixLabelsProvider` (`fileUpload`, `attachment`); a `labels` prop wins per word.
771
+ - `sizeBase={1024}` counts in 1024s, `formatSize` takes the product's formatter, and
772
+ `formatFileSize(bytes, locale, { base: 1024, units })` does the same outside a component.
773
+
774
+ #### Alert, Note, StatusPill, PromptDialog, Popover
775
+
776
+ ```tsx
777
+ <Alert ref={errorRef} tone="info" title="…" actions={<Button size="xs" variant="ghost">Details</Button>} onDismiss={hide}>…</Alert>
778
+ <StatusPill size="lg" tone="success" dot>Operational</StatusPill>
779
+ <PromptDialog … multiline destructive error={serverError} />
780
+ <Popover anchor={userRef} openOnHover>…</Popover>
781
+ ```
782
+
783
+ - `Alert`: `onDismiss` + `dismissLabel` (`UixLabelsProvider` `alert.dismiss`), a trailing `actions`
784
+ slot, a forwarded ref, and a text column that wraps a long title at 320 px.
785
+ - `Note tone={undefined}` and `tone="neutral"` are the default look.
786
+ - `StatusPill size`: `'sm' | 'md' | 'lg'`.
787
+ - `PromptDialog`: `multiline` (a textarea; Ctrl/⌘+Enter submits), `destructive` (a danger submit,
788
+ `role="alertdialog"`), an `error` slot, `role`. The field takes focus when the dialog opens.
789
+ - `Popover openOnHover`: opens on pointer or focus on its anchor, stays while either is on the
790
+ anchor or the popover, closes after both left and on Escape.
791
+
792
+ #### Spinner, Meter, Heartbeat
793
+
794
+ ```tsx
795
+ <Spinner size="sm" label="Uploading" />
796
+ <Meter tone="neutral" value={42} label="Option A, 42% of votes" />
797
+ <LiveIndicator state="live">Live</LiveIndicator> <Heartbeat state="danger" label="Connection lost" />
798
+ ```
799
+
800
+ - `Spinner size="sm"` is 16 px.
801
+ - `Meter tone="neutral"` (grey) and `tone="accent"` (brand) are fills for a proportion that is not
802
+ a status; both clear 3:1 against the track in light and dark, and no tone word is spoken.
803
+ - `Heartbeat` is the pulsing dot (`live`, `idle`, `warning`, `danger`), decorative unless it has a
804
+ `label`; `LiveIndicator` adds the state in text. The ring stops under `prefers-reduced-motion`.
805
+
806
+ #### ViewMenu
807
+
808
+ The density options no longer break a label inside a word at phone width ("Großzügig"); options
809
+ that do not fit one row wrap as whole options.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The words of an `Attachment` row. In a module of its own, with no "use client": the
3
+ * server-renderable `Attachment` and its client parts both read it, and a server component can
4
+ * still import the defaults as plain values.
5
+ */
6
+ interface AttachmentLabels {
7
+ /** Remove button. `{name}`. */
8
+ remove: string;
9
+ /** Download link name when `download` is set. `{name}`. */
10
+ download: string;
11
+ loading: string;
12
+ forbidden: string;
13
+ }
14
+ declare const DEFAULT_ATTACHMENT_LABELS: AttachmentLabels;
15
+
16
+ export { type AttachmentLabels, DEFAULT_ATTACHMENT_LABELS };
@@ -0,0 +1,9 @@
1
+ const DEFAULT_ATTACHMENT_LABELS = {
2
+ remove: "Remove {name}",
3
+ download: "Download {name}",
4
+ loading: "Loading\u2026",
5
+ forbidden: "You do not have access to this file."
6
+ };
7
+ export {
8
+ DEFAULT_ATTACHMENT_LABELS
9
+ };
@@ -19,9 +19,149 @@ declare function startOfMonth(value: string): string;
19
19
  /** A weekday in `Date.getUTCDay()` numbering: 0 = Sunday … 6 = Saturday. */
20
20
  type CalendarWeekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
21
21
  declare function buildMonthGrid(month: string, weekStartsOn?: CalendarWeekday): CalendarDay[];
22
+ /**
23
+ * A shared `Intl.DateTimeFormat` for `locale` and `options`. Formatters are expensive to
24
+ * build, so one instance is kept per locale and options shape (key order does not matter);
25
+ * the cache holds up to 64 shapes and drops the oldest past that.
26
+ */
27
+ declare function cachedDateTimeFormat(locale: string | readonly string[] | undefined, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat;
28
+ /** The calendar date (`YYYY-MM-DD`) of an instant in `timeZone`. */
22
29
  declare function zonedDateKey(instant: string | Date, timeZone: string): string;
30
+ /**
31
+ * The 24-hour wall-clock time (`HH:MM`) of an instant in `timeZone`. It reads the same
32
+ * cached formatter as the zone math, so it adds no `Intl.DateTimeFormat` instance.
33
+ */
34
+ declare function zonedTimeOfDay(instant: string | number | Date, timeZone: string): string;
35
+ interface ZonedDayBounds {
36
+ /** The first instant of the day in the zone (local 00:00, or the first existing time after a gap). */
37
+ start: Date;
38
+ /** The first instant of the next day: `end` of D equals `start` of D + 1. */
39
+ end: Date;
40
+ }
41
+ /**
42
+ * The half-open interval `[start, end)` of a calendar day in `timeZone`. DST days are 23 or
43
+ * 25 hours long; a day whose midnight does not exist starts at its first existing instant.
44
+ */
45
+ declare function zonedDayBounds(dateKey: string, timeZone: string): ZonedDayBounds;
46
+ interface ZonedHourSlot {
47
+ /** The real instant the local hour starts. */
48
+ instant: Date;
49
+ /** The local hour, two digits (`'00'` … `'23'`). */
50
+ label: string;
51
+ /**
52
+ * The UTC offset (`'+02:00'`) when this local hour occurs twice in the day (clocks went
53
+ * back), set on both occurrences; otherwise `null`.
54
+ */
55
+ offsetLabel: string | null;
56
+ }
57
+ /**
58
+ * One entry per real hour start in the day `[start, end)` in `timeZone`: 24 on most days,
59
+ * 23 when clocks go forward (no slot for the skipped hour) and 25 when they go back (the
60
+ * repeated hour twice, each with an `offsetLabel`).
61
+ */
62
+ declare function zonedHourSlots(dateKey: string, timeZone: string): ZonedHourSlot[];
63
+ interface ZonedDayShift {
64
+ instant: Date;
65
+ /** `'gap_forward'` when the target wall-clock time did not exist and moved forward past the gap. */
66
+ adjusted: 'gap_forward' | null;
67
+ }
68
+ /**
69
+ * Moves an instant by `days` calendar days in `timeZone`, keeping its wall-clock time
70
+ * ("+1 day" is the same local time on the next day, whether that day has 23, 24 or 25
71
+ * hours). A wall time that occurs twice keeps the source's offset when it can (else the
72
+ * earlier one). A wall time that does not exist moves forward by the gap, once, and the
73
+ * result says so (`adjusted: 'gap_forward'`).
74
+ */
75
+ declare function addZonedDays(instant: string | Date, days: number, timeZone: string): ZonedDayShift;
76
+ /**
77
+ * Calendar days `[start, end)` as date keys: `end` is the first day NOT covered.
78
+ * Produced by `zonedDaySpan`, read by `enumerateDateKeys`.
79
+ */
80
+ interface ZonedDaySpan {
81
+ start: string;
82
+ end: string;
83
+ }
84
+ /**
85
+ * The calendar days an instant interval `[start, end)` touches in `timeZone`, end-exclusive:
86
+ * an entry ending exactly at local 00:00 occupies only the day before. Throws a `RangeError`
87
+ * when `end` is not after `start`; it never swaps the ends.
88
+ */
89
+ declare function zonedDaySpan(start: string | Date, end: string | Date, timeZone: string): ZonedDaySpan;
90
+ /**
91
+ * The date keys of a `ZonedDaySpan` (`[start, end)`), at most `limit` of them (default
92
+ * 370; `Infinity` for no limit). `truncated` is true when the span has more days than were
93
+ * returned, so a long span is never cut silently. Throws a `RangeError` on an inverted span.
94
+ */
95
+ declare function enumerateDateKeys(span: ZonedDaySpan, options?: {
96
+ limit?: number;
97
+ }): {
98
+ dates: string[];
99
+ truncated: boolean;
100
+ };
101
+ /**
102
+ * Inclusive date keys of an instant interval, with inverted ends swapped.
103
+ * @deprecated Use `zonedDaySpan`, which is end-exclusive (an entry ending at local 00:00
104
+ * does not paint the next day) and throws on an inverted interval instead of swapping.
105
+ * The behaviour here is unchanged in 2.x; changing it follows the contract-change process (E13).
106
+ */
23
107
  declare function zonedDateSpan(start: string, end: string, timeZone: string): ZonedDateSpan;
108
+ /**
109
+ * Date keys of an inclusive span, stopping silently after `limit` (370).
110
+ * @deprecated Use `enumerateDateKeys`, which reads an end-exclusive `ZonedDaySpan` and
111
+ * reports `truncated` instead of stopping silently. The behaviour here is unchanged in 2.x (E13).
112
+ */
24
113
  declare function enumerateDateSpan(span: ZonedDateSpan, limit?: number): string[];
114
+ interface LaneInterval {
115
+ id: string;
116
+ /** Half-open `[start, end)`: ISO string, epoch ms or Date. */
117
+ start: string | number | Date;
118
+ end: string | number | Date;
119
+ }
120
+ interface LanePlacement {
121
+ /** 0-based lane, or `null` when the interval did not fit in `maxLanes`. */
122
+ lane: number | null;
123
+ /** Index into `PackedLanes.clusters`. */
124
+ cluster: number;
125
+ }
126
+ interface LaneCluster {
127
+ /** Ids of one run of transitively overlapping intervals, by start, then end, then id. */
128
+ ids: string[];
129
+ /** Lanes the cluster uses (≤ `maxLanes`); a renderer divides the width by this. */
130
+ laneCount: number;
131
+ /** Intervals of the cluster that got no lane. */
132
+ overflow: number;
133
+ }
134
+ interface PackedLanes {
135
+ lanes: Record<string, LanePlacement>;
136
+ clusters: LaneCluster[];
137
+ /** Intervals that got no lane, over all clusters. */
138
+ overflow: number;
139
+ }
140
+ interface PackLanesOptions {
141
+ /**
142
+ * `'start'` (default): greedy first-fit by start, then end, then id, so the result does
143
+ * not depend on input order. `'given'`: first-fit in input order, so the consumer's
144
+ * ranking decides which intervals get a lane when there are not enough.
145
+ */
146
+ order?: 'start' | 'given';
147
+ }
148
+ /**
149
+ * Assigns half-open intervals to at most `maxLanes` lanes, first-fit. Two intervals that
150
+ * overlap never share a lane; two that only touch (`a.end === b.start`) may. Intervals
151
+ * that do not fit get `lane: null` and count towards their cluster's `overflow`. A
152
+ * zero-length interval occupies its start instant. Throws on `end < start` (it never
153
+ * swaps) and on a duplicate id.
154
+ */
155
+ declare function packLanes(intervals: readonly LaneInterval[], maxLanes: number, options?: PackLanesOptions): PackedLanes;
156
+ /**
157
+ * Sorts a copy of `items` with `compare` (stable) and cuts it after `n`. `hidden` is the
158
+ * number of items not shown, so it is a true total only when `items` is complete; a
159
+ * consumer that knows a larger total shows that instead.
160
+ */
161
+ declare function rankOverflow<T>(items: readonly T[], compare: (a: T, b: T) => number, n: number): {
162
+ visible: T[];
163
+ hidden: number;
164
+ };
25
165
  declare function selectRangeDate(current: DateRangeValue, date: string): DateRangeValue;
26
166
  declare function isDateInRange(date: string, value: DateRangeValue): boolean;
27
167
  declare function isDateUnavailable(date: string, options: {
@@ -30,4 +170,4 @@ declare function isDateUnavailable(date: string, options: {
30
170
  isDisabled?: (date: string) => boolean;
31
171
  }): boolean;
32
172
 
33
- export { type CalendarDay, type CalendarWeekday, type DateRangeValue, type ZonedDateSpan, addCalendarDays, addCalendarMonths, buildMonthGrid, enumerateDateSpan, isDateInRange, isDateUnavailable, selectRangeDate, startOfMonth, toDateKey, zonedDateKey, zonedDateSpan };
173
+ export { type CalendarDay, type CalendarWeekday, type DateRangeValue, type LaneCluster, type LaneInterval, type LanePlacement, type PackLanesOptions, type PackedLanes, type ZonedDateSpan, type ZonedDayBounds, type ZonedDayShift, type ZonedDaySpan, type ZonedHourSlot, addCalendarDays, addCalendarMonths, addZonedDays, buildMonthGrid, cachedDateTimeFormat, enumerateDateKeys, enumerateDateSpan, isDateInRange, isDateUnavailable, packLanes, rankOverflow, selectRangeDate, startOfMonth, toDateKey, zonedDateKey, zonedDateSpan, zonedDayBounds, zonedDaySpan, zonedHourSlots, zonedTimeOfDay };