@tensor_1/react 2.31.0 → 2.33.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 (117) hide show
  1. package/README.md +367 -0
  2. package/dist/SchedulingCalendar-C4XVbqZr.d.ts +339 -0
  3. package/dist/attachment-labels.d.ts +16 -0
  4. package/dist/attachment-labels.js +9 -0
  5. package/dist/calendar-model.d.ts +141 -1
  6. package/dist/calendar-model.js +200 -11
  7. package/dist/chart-preset.cjs +127 -7
  8. package/dist/chart-preset.d.cts +2 -2
  9. package/dist/chart-preset.d.ts +1 -0
  10. package/dist/chart-preset.js +6 -1
  11. package/dist/chart-theme-Cl0IrkKQ.d.cts +112 -0
  12. package/dist/chart-theme.d.ts +51 -0
  13. package/dist/chart-theme.js +99 -0
  14. package/dist/chart.cjs +127 -7
  15. package/dist/chart.d.cts +2 -2
  16. package/dist/chart.d.ts +1 -0
  17. package/dist/chart.js +6 -2
  18. package/dist/components/Alert.d.ts +17 -1
  19. package/dist/components/Alert.js +9 -4
  20. package/dist/components/AlertDismiss.d.ts +14 -0
  21. package/dist/components/AlertDismiss.js +11 -0
  22. package/dist/components/Attachment.d.ts +15 -12
  23. package/dist/components/Attachment.js +17 -17
  24. package/dist/components/AttachmentParts.d.ts +24 -0
  25. package/dist/components/AttachmentParts.js +20 -0
  26. package/dist/components/Chart.d.ts +1 -0
  27. package/dist/components/Chart.js +6 -1
  28. package/dist/components/ChartCore.d.ts +6 -1
  29. package/dist/components/ChartCore.js +23 -5
  30. package/dist/components/Chip.d.ts +17 -2
  31. package/dist/components/Chip.js +35 -11
  32. package/dist/components/ChipRemove.d.ts +17 -0
  33. package/dist/components/ChipRemove.js +13 -0
  34. package/dist/components/CollapsibleParts.d.ts +29 -0
  35. package/dist/components/CollapsibleParts.js +20 -0
  36. package/dist/components/CollapsibleSection.d.ts +30 -3
  37. package/dist/components/CollapsibleSection.js +7 -11
  38. package/dist/components/CollapsibleSectionState.d.ts +1 -1
  39. package/dist/components/CollapsibleSectionState.js +54 -27
  40. package/dist/components/DashboardGrid.d.ts +60 -0
  41. package/dist/components/DashboardGrid.js +40 -0
  42. package/dist/components/DateRangePicker.d.ts +7 -1
  43. package/dist/components/DateRangePicker.js +5 -1
  44. package/dist/components/DescriptionList.d.ts +12 -1
  45. package/dist/components/DescriptionList.js +5 -5
  46. package/dist/components/Dialogs.d.ts +24 -3
  47. package/dist/components/Dialogs.js +74 -16
  48. package/dist/components/FileUpload.d.ts +10 -2
  49. package/dist/components/FileUpload.js +10 -4
  50. package/dist/components/FilterEditor.d.ts +4 -0
  51. package/dist/components/FilterEditor.js +13 -2
  52. package/dist/components/Heartbeat.d.ts +31 -0
  53. package/dist/components/Heartbeat.js +28 -0
  54. package/dist/components/Menu.d.ts +53 -5
  55. package/dist/components/Menu.js +104 -4
  56. package/dist/components/Meter.d.ts +7 -3
  57. package/dist/components/Meter.js +2 -1
  58. package/dist/components/Popover.d.ts +21 -2
  59. package/dist/components/Popover.js +94 -17
  60. package/dist/components/Prose.d.ts +6 -2
  61. package/dist/components/Prose.js +1 -1
  62. package/dist/components/RuleBuilder.d.ts +52 -4
  63. package/dist/components/RuleBuilder.js +109 -24
  64. package/dist/components/SchedulingCalendar.d.ts +4 -110
  65. package/dist/components/SchedulingCalendar.js +299 -63
  66. package/dist/components/SchedulingTimeGrid.d.ts +4 -0
  67. package/dist/components/SchedulingTimeGrid.js +521 -0
  68. package/dist/components/SchedulingTimeline.d.ts +2 -1
  69. package/dist/components/Segmented.d.ts +17 -3
  70. package/dist/components/Segmented.js +59 -5
  71. package/dist/components/Select.d.ts +66 -2
  72. package/dist/components/Select.js +800 -10
  73. package/dist/components/Spinner.d.ts +2 -1
  74. package/dist/components/Spinner.js +1 -1
  75. package/dist/components/StatusPill.d.ts +8 -2
  76. package/dist/components/StatusPill.js +2 -2
  77. package/dist/components/Steps.d.ts +28 -4
  78. package/dist/components/Steps.js +46 -16
  79. package/dist/components/TableControls.js +4 -3
  80. package/dist/components/Tabs.d.ts +7 -2
  81. package/dist/components/Tabs.js +14 -1
  82. package/dist/components/TextDiff.d.ts +49 -0
  83. package/dist/components/TextDiff.js +124 -0
  84. package/dist/{emoji-image-6mPZ15Zy.d.cts → emoji-image-DjCKpdwE.d.cts} +1 -1
  85. package/dist/emoji.cjs +169 -27
  86. package/dist/emoji.d.cts +3 -3
  87. package/dist/file-model.d.ts +22 -3
  88. package/dist/file-model.js +6 -4
  89. package/dist/filter-model.d.ts +26 -2
  90. package/dist/filter-model.js +13 -1
  91. package/dist/hooks/useAnchoredPosition.d.ts +24 -1
  92. package/dist/hooks/useAnchoredPosition.js +58 -8
  93. package/dist/hooks/useVirtualRows.d.ts +17 -2
  94. package/dist/hooks/useVirtualRows.js +6 -5
  95. package/dist/icons/Icon.d.ts +14 -14
  96. package/dist/index.cjs +4888 -1714
  97. package/dist/index.d.cts +1871 -658
  98. package/dist/index.d.ts +16 -9
  99. package/dist/index.js +67 -4
  100. package/dist/labels-context.d.ts +29 -1
  101. package/dist/{overlay-position-Cjv5E7mn.d.cts → overlay-position-2Afke51l.d.cts} +20 -0
  102. package/dist/overlay-position.d.ts +20 -0
  103. package/dist/overlay-position.js +17 -2
  104. package/dist/rich-text.cjs +169 -27
  105. package/dist/rich-text.d.cts +3 -3
  106. package/dist/rule-builder-model.d.ts +34 -2
  107. package/dist/rule-builder-model.js +48 -12
  108. package/dist/scheduling-calendar-model.d.ts +169 -0
  109. package/dist/scheduling-calendar-model.js +176 -0
  110. package/dist/select-model.d.ts +80 -0
  111. package/dist/select-model.js +109 -0
  112. package/dist/table-engine.d.ts +7 -0
  113. package/dist/table-engine.js +2 -1
  114. package/dist/text-diff-model.d.ts +71 -0
  115. package/dist/text-diff-model.js +261 -0
  116. package/package.json +1 -1
  117. package/dist/ChartCore-DzWe10bN.d.cts +0 -59
package/README.md CHANGED
@@ -354,6 +354,69 @@ 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
+ - **Week and Day as a time grid** (`timeGrid`, HAR-1509): day columns on an hour axis.
399
+
400
+ ```tsx
401
+ <SchedulingCalendar view="week" timeGrid timeZone="Europe/Berlin" anchorDate="2026-10-07" showHeader={false}
402
+ entries={entries} overlays={windows} days={dayCounts} now={serverNow}
403
+ canMove={userMayMove} step={15}
404
+ onProposeMove={(id, { start, end, adjusted }) => reschedule(id, start, end)} // the server decides; the calendar never moves an item
405
+ onSelectEntry={openItem} onShowMore={openDay} />
406
+ ```
407
+
408
+ - A column is as tall as its day (23, 24 or 25 hours); an item sits at its real time. `view="day"` is one column.
409
+ - An entry that crosses midnight by up to `topLaneCrossMidnightMinutes` (default 360) is one item in two joined
410
+ parts; all-day (`allDay`), longer and later-running entries go to the lane above the hours (`placesInTopLane`).
411
+ - Moves are proposals: drag, or Shift and an arrow key, then Enter (Escape drops it). An entry whose props do not
412
+ change stays where it was, so a refused move snaps back by construction. `movable: false` pins an entry.
413
+ - Overlapping items share at most `maxLanes` lanes, fewer in a narrow column; the column "+N" is the consumer's
414
+ `days[date].overflowCount`. `renderEntry(entry, { availableLines })` says how many text lines fit.
415
+ - **Colour.** Every calendar colour is named once at the top of `scheduling-calendar.css` (`--calendar-*`).
416
+ - **Deprecated, still working in 2.x:** `entry.state` / `SchedulingEntryState`, `overlay.kind` /
417
+ `SchedulingOverlayKind` and the `previous` / `next` labels. An entry with `state` is no longer tinted: the two
418
+ problem states show a marker with the word for that state.
419
+
357
420
  ### SchedulingTimeline
358
421
 
359
422
  Lanes of bars on a time axis (HAR-1364; TENSOR's change day/week timeline, rollout Gantt rows and licence-renewal
@@ -378,6 +441,44 @@ markers). It pairs with `SchedulingCalendar`: same entry states, the same explic
378
441
  - **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
442
  - Pure helpers are exported for tests and server code: `timelineTicks`, `placeSpan`, `layoutLane`, `shiftSpan`, `snapToStep`, `pixelsToMs`, `defaultTimelineStep`.
380
443
 
444
+ ### Calendar model (zoned days, hour slots, lanes)
445
+
446
+ Pure functions for calendar geometry in one explicit IANA display zone (HAR-1504). They use only
447
+ `Intl.DateTimeFormat`, never the process time zone, and never a fixed day length: DST days have 23 or 25
448
+ hours. Every interval is half-open, `[start, end)`.
449
+
450
+ ```ts
451
+ zonedDayBounds('2026-10-25', 'Europe/Berlin'); // { start: 2026-10-24T22:00Z, end: 2026-10-25T23:00Z } (25 h)
452
+ zonedHourSlots('2026-10-25', 'Europe/Berlin'); // 25 slots; '02' twice, offsetLabel '+02:00' then '+01:00'
453
+ zonedHourSlots('2026-03-29', 'Europe/Berlin'); // 23 slots; no '02'
454
+ addZonedDays('2026-10-24T20:00Z', 1, 'Europe/Berlin'); // { instant: 2026-10-25T21:00Z, adjusted: null }
455
+ addZonedDays('2026-03-28T01:30Z', 1, 'Europe/Berlin'); // 03:30 local, adjusted: 'gap_forward'
456
+ zonedDaySpan('2026-10-10T22:00Z', '2026-10-11T22:00Z', 'Europe/Berlin'); // { start: '2026-10-11', end: '2026-10-12' }
457
+ enumerateDateKeys({ start: '2026-10-11', end: '2026-10-12' }); // { dates: ['2026-10-11'], truncated: false }
458
+ ```
459
+
460
+ - **Days:** `zonedDayBounds(dateKey, tz)` is the day's first instant and the next day's first instant, so the end of
461
+ one day is the start of the next. A day whose midnight is skipped starts at its first existing instant (01:00).
462
+ - **Hour slots:** `zonedHourSlots(dateKey, tz)` gives one `{ instant, label, offsetLabel }` per real hour start.
463
+ `label` is the local hour (`'00'`–`'23'`); `offsetLabel` (`'+02:00'`) is set on both occurrences of a repeated
464
+ hour and is `null` otherwise.
465
+ - **"+1 day":** `addZonedDays(instant, n, tz)` keeps the wall-clock time. A repeated wall time keeps the source's
466
+ offset; a skipped one moves forward once and returns `adjusted: 'gap_forward'`, so the caller can announce it.
467
+ - **Day membership:** `zonedDaySpan(start, end, tz)` returns the days an entry touches as `[start, end)` date keys;
468
+ an entry ending at local 00:00 does not touch the next day. It throws a `RangeError` when `end` is not after
469
+ `start` and never swaps. `enumerateDateKeys(span, { limit })` lists them (default limit 370, `Infinity` for none)
470
+ and returns `truncated: true` instead of stopping silently.
471
+ - **Lanes:** `packLanes(intervals, maxLanes, { order })` assigns first-fit lanes; overlapping intervals never share a
472
+ lane, touching ones may. Intervals that do not fit get `lane: null`, and each overlap cluster reports its
473
+ `laneCount` and `overflow`. `order: 'start'` (default) is greedy by start, then end, then id; `order: 'given'`
474
+ packs in input order, so a consumer's ranking decides who gets a lane.
475
+ - **Top-N:** `rankOverflow(items, compare, n)` is a stable sort and cut: `{ visible, hidden }`. `hidden` counts only
476
+ what it was given; show the consumer's own total when it has one.
477
+ - **Formatters:** `cachedDateTimeFormat(locale, options)` returns one shared `Intl.DateTimeFormat` per locale and
478
+ options shape. The model formats through it, so a 2,100-entry month builds at most a handful of formatters.
479
+ - **Deprecated:** `zonedDateSpan` (end-inclusive, swaps inverted ends) and `enumerateDateSpan` (stops silently at
480
+ 370) are unchanged in 2.x. Use `zonedDaySpan` and `enumerateDateKeys`.
481
+
381
482
  ### FilterEditor (typed column filters)
382
483
 
383
484
  `FilterEditor` is the content of one column-filter popover with a typed value (HAR-1365; TENSOR's
@@ -403,6 +504,26 @@ const field: FilterField = { id: 'state', label: t('state'), kind: 'enum', optio
403
504
 
404
505
  `isFilterEmpty`, `emptyFilterValue`, the operator lists and all words (`labels`) are exported, and the value is plain
405
506
  data for your own query string. Stale search results are ignored.
507
+
508
+ **Presets (enum, HAR-1505).** `field.presets` names selections the product defines, e.g. a default scope. They
509
+ render as toggle `Chip`s (`aria-pressed`) in a labelled group above the search box and options. Activating one
510
+ replaces the selection with its `values`; the preset whose values equal the selection (as a set) is pressed, so
511
+ ticking another option un-presses it. `summarizeFilter` then returns the preset's label (the `preset` summary
512
+ label, default `'{label}'`; use `'{field}: {label}'` to keep the column name), and `matchFilterPreset(field, value)`
513
+ returns the matching preset for your own use. The group's name is `labels.presets` (default "Presets").
514
+
515
+ ```tsx
516
+ const state: FilterField = {
517
+ id: 'state', label: t('state'), kind: 'enum', options: states,
518
+ presets: [
519
+ { id: 'open', label: t('openWork'), values: ['new', 'open', 'pending'] },
520
+ { id: 'all', label: t('all'), values: states.map((s) => s.value) },
521
+ ],
522
+ };
523
+ ```
524
+
525
+ Every `FilterEditor` word can also come from `<UixLabelsProvider labels={{ filterEditor: { … } }}>`; an
526
+ explicit `labels` prop still wins.
406
527
  ### EntityPicker
407
528
 
408
529
  A form field that holds one record found by an async search (HAR-1366; TENSOR's `entity-picker.tsx` and its
@@ -457,3 +578,249 @@ A full-screen media viewer (HAR-1367; TENSOR's capture viewer, MOTUS's programme
457
578
  - **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.
458
579
  - **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.
459
580
  - Pure helpers: `formatFileSize`, `fileMatchesAccept`, `partitionFiles`, `fileKind`.
581
+
582
+ ### TextDiff
583
+
584
+ A two-way diff of two texts (HAR-1368; TENSOR's knowledge-article version diff). `DiffViewer` stays the three-way configuration diff.
585
+
586
+ ```tsx
587
+ <TextDiff before={v3.body} after={v4.body} beforeLabel={t('version', { n: 3 })} afterLabel={t('version', { n: 4 })}
588
+ label={t('bodyChanges')} context={3} />
589
+ <TextDiff before={v3.title} after={v4.title} granularity="word" label={t('titleChanges')} />
590
+ ```
591
+
592
+ - **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.
593
+ - **Words** (`granularity="word"`): prose compared as one flow. Changed words across a space join into one change.
594
+ - **Views:** `split` (the default) puts the versions side by side and stacks below about 36rem of container width. `unified` interleaves them.
595
+ - **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.
596
+ - **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.
597
+ - Pure model for tests and servers: `diffText`, `diffTokens`, `textDiffRows`, `tokenizeText`.
598
+
599
+ ### DashboardGrid
600
+
601
+ 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.
602
+
603
+ ```tsx
604
+ <DashboardGrid columns={{ base: 1, sm: 2, lg: 3 }} gap="md">
605
+ <DashboardGrid.Item><Stat label="Open incidents" value={42} /></DashboardGrid.Item>
606
+ <DashboardGrid.Item span={2}><Card title="Queue by team">…</Card></DashboardGrid.Item>
607
+ <DashboardGrid.Item span="full">
608
+ <Chart title="Resolved per hour" option={option} height="clamp(160px, 40cqi, 360px)" />
609
+ </DashboardGrid.Item>
610
+ </DashboardGrid>
611
+ ```
612
+
613
+ - **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.
614
+ - **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).
615
+ - **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).
616
+ - **`gap`:** `sm` (--uix-space-3), `md` (default, --uix-space-4), `lg` (--uix-space-6).
617
+ - 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)`.
618
+ - 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.
619
+
620
+ ### Select draws its own list
621
+
622
+ `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:
623
+
624
+ ```tsx
625
+ <Select id="status" name="status" value={status} onChange={(e) => setStatus(e.target.value)}>
626
+ <option value="" disabled hidden>Choose a status</option>
627
+ <optgroup label="Active">{active.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</optgroup>
628
+ </Select>
629
+ <Select options={teams} value={team} onValueChange={(v) => setTeam(v as string)} searchable="auto" placeholder={t('team')} />
630
+ <Select multiple name="labels" options={labels} defaultValue={['network']} />
631
+ <Select loadOptions={(q, signal) => api.owners(q, { signal })} searchable options={[owner]} value={owner.value} />
632
+ ```
633
+
634
+ - **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.
635
+ - **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.
636
+ - Plain Select for up to about 12 options, `searchable` for longer lists, `EntityPicker` or `SearchSuggest` for records.
637
+
638
+ ### Gaps from the TENSOR migration (2.32.0)
639
+
640
+ Found on 2026-10-07/08 while TENSOR moved its hand-built UI onto 2.31.0. Everything is optional
641
+ unless a line says **Behaviour change**.
642
+
643
+ #### Anchored overlays stay inside the viewport
644
+
645
+ A `Popover` / `useAnchoredPosition` panel that fits neither above nor below its anchor used to
646
+ leave the viewport (a 384 px panel under a trigger in the middle of a 640 px phone screen got a
647
+ negative `top`). It now slides over the anchor and stays inside the 8 px padding, after a scroll
648
+ and after a resize as well. Once the anchor itself has left the viewport the panel follows it out.
649
+
650
+ ```tsx
651
+ <Popover anchor={triggerRef} capHeight>…</Popover> // taller than the screen: capped, scrolls inside
652
+ useAnchoredPosition(anchorRef, panelRef, {
653
+ open,
654
+ capHeight: true, // max-height = viewport − 2 × padding (a smaller own max-height still applies)
655
+ onAnchorHidden: () => setOpen(false), // what `Popover closeWhenAnchorHidden` is built on
656
+ shiftMainAxis: false, // opt out: the 2.31.0 placement
657
+ });
658
+ computePosition(anchor, size, viewport).mainAxisRange // [min, max] the top is kept in, or null
659
+ ```
660
+
661
+ With CSS anchor positioning the browser moves a box with its anchor *after* resolving `top`, so
662
+ no value can hold it at the viewport limit while the page scrolls. While a panel is held at the
663
+ limit, or within 64 px of it, the hook writes fixed coordinates instead; they cannot drift.
664
+
665
+ #### Menu: radio and checkbox items, attributes, Escape on the trigger
666
+
667
+ ```tsx
668
+ <Menu trigger={<Button>Preset</Button>}>
669
+ <MenuItem id="manage" data-action-id="preset.manage" onSelect={manage}>Manage presets</MenuItem>
670
+ <MenuSeparator />
671
+ <MenuRadioGroup label="Dashboard preset" value={preset} onValueChange={setPreset}>
672
+ <MenuItemRadio value="mine">My queue</MenuItemRadio>
673
+ <MenuItemRadio value="team">Team</MenuItemRadio>
674
+ </MenuRadioGroup>
675
+ <MenuItemCheckbox checked={zebra} onCheckedChange={setZebra}>Alternating rows</MenuItemCheckbox>
676
+ </Menu>
677
+ ```
678
+
679
+ - `MenuItemRadio` / `MenuItemCheckbox` render `menuitemradio` / `menuitemcheckbox` with
680
+ `aria-checked` and a check column. The menu opens on the checked radio item.
681
+ - A click or Enter follows `closeOnSelect` (radio: `true`, checkbox: `false`); Space changes the
682
+ state and leaves the menu open.
683
+ - Every item kind forwards `id` and `data-*` to its own element.
684
+ - Escape (and Tab) on the trigger closes a menu that has no item to focus (every item disabled).
685
+ - The menu is capped to the viewport and scrolls.
686
+
687
+ #### CollapsibleSection and Steps
688
+
689
+ ```tsx
690
+ <CollapsibleSection title="Details" defaultOpen persistKey={`incident:${id}:details`} persistStorage="local">…</CollapsibleSection>
691
+ <CollapsibleSection title="SLA" compact headingLevel={3} lazy openRequest={jumpToSla}>…</CollapsibleSection>
692
+ ```
693
+
694
+ - `defaultOpen` is the starting state; with `persistKey`, what the person chose wins over it. A
695
+ passed `open` is the same starting state and no longer overrides the remembered state at
696
+ mount; it still applies when the parent changes it.
697
+ - `persistStorage`: `'session'` (default) or `'local'`.
698
+ - `openRequest` already set at mount is honoured (a deep link), and every request moves focus to
699
+ the summary. `undefined` and `0` mean "no request yet".
700
+ - `compact`: a quiet row inside a card. `headingLevel`: the title is a real heading and the body a
701
+ `role="region"` named by it.
702
+
703
+ ```tsx
704
+ <Steps progress={false} headingLevel={2} label="New change">
705
+ <Step title="What is changing" description="One sentence is enough."><Field …/></Step>
706
+ <Step title="When"><Field …/></Step>
707
+ </Steps>
708
+ ```
709
+
710
+ `progress={false}` is a numbered list of sections that are all on screen: no state is drawn or
711
+ announced, and each step's children are its content, in a group named by the step title. Without
712
+ it `Steps` is the progress indicator it was.
713
+
714
+ Also: `DescriptionItem termProps` / `descriptionProps` (attributes for the `<dt>` / `<dd>`), and
715
+ `closeLabel` is optional on `PromptDialog` and `ConfirmDialog`.
716
+
717
+ #### Tabs: a name for the tablist
718
+
719
+ ```tsx
720
+ <Tabs aria-label="Inbox filters" value={tab} onChange={setTab}>…</Tabs>
721
+ <Tabs aria-labelledby="news-heading" overflow="scroll" …>…</Tabs>
722
+ ```
723
+
724
+ `Tabs` passes HTML attributes to its `role="tablist"` element, in both overflow modes.
725
+
726
+ #### Segmented: one tab stop, arrow keys, a radiogroup mode
727
+
728
+ ```tsx
729
+ <Segmented aria-label="Row density" value={density} onChange={setDensity}>…</Segmented>
730
+ <Segmented selection="radio" aria-labelledby="theme-label" value={theme} onChange={setTheme}>…</Segmented>
731
+ ```
732
+
733
+ - **Behaviour change:** a `Segmented` is one tab stop (the selected option, or the first enabled
734
+ one). Arrow Right/Down and Left/Up move and select, wrapping; Home / End jump; Left and Right
735
+ swap in a right-to-left group. Before, every option was a tab stop and no key was handled.
736
+ - `selection="radio"` renders `radiogroup` / `radio` / `aria-checked` instead of `group` /
737
+ `aria-pressed`, for a setting with exactly one value.
738
+
739
+ #### useVirtualRows
740
+
741
+ ```tsx
742
+ const v = useVirtualRows(rows, { rowHeight: 44, estimatedViewportHeight: 600, enabled: rows.length > 0 });
743
+ ```
744
+
745
+ - `estimatedViewportHeight`: the height to assume until the scroller is measured (and whenever it
746
+ measures 0), so a 5,000-row table mounts with one window, not 5,000 rows.
747
+ - When the row count shrinks under a large `scrollTop`, the window is clamped to the new count in
748
+ the same render: the spacer is never taller than the list.
749
+ - `enabled: false` returns every row and no spacers. Rows are windowed when
750
+ `rows.length > threshold` (default 100).
751
+
752
+ #### RuleBuilder for a flat AND-list
753
+
754
+ ```tsx
755
+ <RuleBuilder value={rule} onChange={setRule} fields={fields} operators={operators}
756
+ conditionsOnly combinator="and" allowGroups={false} checks={{ emptyGroup: false }} labels={t.ruleBuilder} />
757
+ <RuleBuilder … readOnly /> // the builder, every control disabled
758
+ <RuleBuilder … readOnly="summary" /> // one sentence
759
+ ```
760
+
761
+ - The built-in validation messages are labels (`issueEmptyGroup`, `issueNoActions`,
762
+ `issueMissingField`, …) and `checks` turns individual checks off.
763
+ - `conditionsOnly`: no "Then" block; `actions` is then optional.
764
+ - `combinator` fixes every group to one value (shown as text, never emits the other);
765
+ `hideCombinator` removes the control. `allowGroups={false}` hides "Add group".
766
+ - **Behaviour change:** `readOnly` renders the builder with its controls disabled, not a sentence.
767
+ The sentence is `readOnly="summary"`, and its words are labels.
768
+ - `UixLabelsProvider labels={{ ruleBuilder }}` translates every builder below it.
769
+
770
+ #### Chip
771
+
772
+ ```tsx
773
+ <Chip ref={chipRef} onClick={openEditor} onRemove={clear}
774
+ bodyProps={{ 'aria-haspopup': 'dialog', 'aria-expanded': open, 'aria-controls': editorId }}>Status: Open</Chip>
775
+ <Chip href="/incidents" current renderLink={link}>Incidents</Chip>
776
+ ```
777
+
778
+ - The × is named from `UixLabelsProvider` `chip.remove` (`'{label} entfernen'`); `removeLabel`
779
+ still wins.
780
+ - The ref is the body (the button, link or span that carries the chip's action), also on a
781
+ removable chip; `bodyProps` puts attributes there.
782
+ - `current` on a link chip: `aria-current="page"` and the filled look.
783
+
784
+ #### FileUpload and Attachment
785
+
786
+ - A long unbroken file name no longer widens `FileUpload` past its container.
787
+ - Both read `UixLabelsProvider` (`fileUpload`, `attachment`); a `labels` prop wins per word.
788
+ - `sizeBase={1024}` counts in 1024s, `formatSize` takes the product's formatter, and
789
+ `formatFileSize(bytes, locale, { base: 1024, units })` does the same outside a component.
790
+
791
+ #### Alert, Note, StatusPill, PromptDialog, Popover
792
+
793
+ ```tsx
794
+ <Alert ref={errorRef} tone="info" title="…" actions={<Button size="xs" variant="ghost">Details</Button>} onDismiss={hide}>…</Alert>
795
+ <StatusPill size="lg" tone="success" dot>Operational</StatusPill>
796
+ <PromptDialog … multiline destructive error={serverError} />
797
+ <Popover anchor={userRef} openOnHover>…</Popover>
798
+ ```
799
+
800
+ - `Alert`: `onDismiss` + `dismissLabel` (`UixLabelsProvider` `alert.dismiss`), a trailing `actions`
801
+ slot, a forwarded ref, and a text column that wraps a long title at 320 px.
802
+ - `Note tone={undefined}` and `tone="neutral"` are the default look.
803
+ - `StatusPill size`: `'sm' | 'md' | 'lg'`.
804
+ - `PromptDialog`: `multiline` (a textarea; Ctrl/⌘+Enter submits), `destructive` (a danger submit,
805
+ `role="alertdialog"`), an `error` slot, `role`. The field takes focus when the dialog opens.
806
+ - `Popover openOnHover`: opens on pointer or focus on its anchor, stays while either is on the
807
+ anchor or the popover, closes after both left and on Escape.
808
+
809
+ #### Spinner, Meter, Heartbeat
810
+
811
+ ```tsx
812
+ <Spinner size="sm" label="Uploading" />
813
+ <Meter tone="neutral" value={42} label="Option A, 42% of votes" />
814
+ <LiveIndicator state="live">Live</LiveIndicator> <Heartbeat state="danger" label="Connection lost" />
815
+ ```
816
+
817
+ - `Spinner size="sm"` is 16 px.
818
+ - `Meter tone="neutral"` (grey) and `tone="accent"` (brand) are fills for a proportion that is not
819
+ a status; both clear 3:1 against the track in light and dark, and no tone word is spoken.
820
+ - `Heartbeat` is the pulsing dot (`live`, `idle`, `warning`, `danger`), decorative unless it has a
821
+ `label`; `LiveIndicator` adds the state in text. The ring stops under `prefers-reduced-motion`.
822
+
823
+ #### ViewMenu
824
+
825
+ The density options no longer break a label inside a word at phone width ("Großzügig"); options
826
+ that do not fit one row wrap as whole options.