@tensor_1/react 2.35.0 → 2.37.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 (39) hide show
  1. package/README.md +154 -15
  2. package/dist/components/Avatar.d.ts +26 -4
  3. package/dist/components/Avatar.js +26 -3
  4. package/dist/components/CommandPalette.d.ts +6 -1
  5. package/dist/components/CommandPalette.js +9 -3
  6. package/dist/components/DateFieldParts.d.ts +61 -0
  7. package/dist/components/DateFieldParts.js +195 -0
  8. package/dist/components/DatePicker.d.ts +129 -0
  9. package/dist/components/DatePicker.js +340 -0
  10. package/dist/components/DateRangePicker.d.ts +34 -3
  11. package/dist/components/DateRangePicker.js +120 -17
  12. package/dist/components/EntityPicker.d.ts +29 -4
  13. package/dist/components/EntityPicker.js +20 -8
  14. package/dist/components/InlineEdit.d.ts +74 -0
  15. package/dist/components/InlineEdit.js +176 -0
  16. package/dist/components/Radio.d.ts +23 -2
  17. package/dist/components/Radio.js +18 -3
  18. package/dist/components/SchedulingAgenda.js +3 -1
  19. package/dist/components/SchedulingTimeGrid.js +6 -4
  20. package/dist/components/SchedulingTimeline.d.ts +130 -15
  21. package/dist/components/SchedulingTimeline.js +768 -141
  22. package/dist/components/SearchSuggest.d.ts +12 -1
  23. package/dist/components/SearchSuggest.js +7 -2
  24. package/dist/components/Tree.d.ts +30 -2
  25. package/dist/components/Tree.js +135 -10
  26. package/dist/date-field-model.d.ts +40 -0
  27. package/dist/date-field-model.js +79 -0
  28. package/dist/hooks/usePrinting.d.ts +15 -0
  29. package/dist/hooks/usePrinting.js +29 -0
  30. package/dist/index.cjs +3890 -2070
  31. package/dist/index.d.cts +808 -262
  32. package/dist/index.d.ts +9 -6
  33. package/dist/index.js +40 -4
  34. package/dist/labels-context.d.ts +14 -1
  35. package/dist/scheduling-timeline-model.d.ts +61 -11
  36. package/dist/scheduling-timeline-model.js +85 -26
  37. package/dist/scheduling-timeline-rows.d.ts +40 -0
  38. package/dist/scheduling-timeline-rows.js +42 -0
  39. package/package.json +5 -3
package/README.md CHANGED
@@ -460,6 +460,18 @@ const spanLayout = layoutMonthSpans(spansInSalienceOrder, grid, { timeZone, week
460
460
  - **`<List roving>`**: the items are one tab stop (ArrowUp/ArrowDown, Home, End; Enter or Space activates the
461
461
  focused item's `onClick`). A control inside an item keeps its own tab stop and keys. Items may be wrapped or
462
462
  rendered later by a child component.
463
+ - **Print (HAR-1541).** The stylesheet has `@media print` rules; add no print CSS of your own. On paper the same
464
+ markup has no scroller, fits the page width, and keeps a week row or an agenda row on one page. The colours are
465
+ the `--uix-print-*` tokens (light, whatever theme the screen has). Nothing depends on a background: the `high`
466
+ band is a heavy edge on a paper surface (on paper `--calendar-band-high-fill` is the paper's and the edge reads
467
+ the new `--calendar-band-high-edge`; no screen rule reads it), each window pattern has an edge style of its own (`solid`, `diagonal` dashed, `cross`
468
+ double, `dotted` dotted), and the hour lines are drawn as lines. Month chips wrap to show the whole title, and
469
+ a time-grid item is at least as tall as its time and grows to hold its words (one too short for a line on a
470
+ screen prints them too). The header's buttons, the "now" line and a pending move are not printed; the period
471
+ title, the zone label and the legend are. A long agenda mounts every row while the page prints (`beforeprint`,
472
+ or print media) and is a window of rows again afterwards. One limit: `window.print()` called from inside a
473
+ React effect, on mount or later, runs before those rows are there. Such a page calls it from an event handler
474
+ or a timer, or passes `virtualizeAbove={Infinity}`.
463
475
  - **Colour.** Every calendar colour is named once at the top of `scheduling-calendar.css` (`--calendar-*`).
464
476
  - **Deprecated, still working in 2.x:** `entry.state` / `SchedulingEntryState`, `overlay.kind` /
465
477
  `SchedulingOverlayKind` and the `previous` / `next` labels. An entry with `state` is no longer tinted: the two
@@ -467,27 +479,44 @@ const spanLayout = layoutMonthSpans(spansInSalienceOrder, grid, { timeZone, week
467
479
 
468
480
  ### SchedulingTimeline
469
481
 
470
- Lanes of bars on a time axis (HAR-1364; TENSOR's change day/week timeline, rollout Gantt rows and licence-renewal
471
- markers). It pairs with `SchedulingCalendar`: same entry states, the same explicit time zone.
482
+ Lanes of bars on a time axis (HAR-1364, HAR-1521): schedules by service, rollout rows, renewal markers. It pairs
483
+ with `SchedulingCalendar`: the same item API (`band`, `status`, `markers`, `accessibleName`), the same explicit
484
+ time zone, the same proposal model for moves.
472
485
 
473
486
  ```tsx
474
487
  <SchedulingTimeline
475
- lanes={[{ id: 'net', label: 'Network', meta: '3 changes' }]}
476
- items={changes.map((c) => ({ id: c.id, laneId: c.team, title: c.title, start: c.start, end: c.end, state: c.state }))}
477
- range={{ start: weekStart, end: weekEnd }} scale="day" timeZone={tz} now={nowIso}
478
- overlays={[{ id: 'q4', kind: 'freeze', label: 'Q4 freeze', start, end }]}
479
- markers={[{ id: 'r1', label: 'Renewal: Fortinet', at, laneId: 'net' }]}
480
- onSelectItem={(item) => openChange(item.id)}
481
- onMoveItem={(id, { start, end }) => reschedule(id, start, end)}
488
+ lanes={services.map((s) => ({ id: s.id, label: s.name }))}
489
+ groups={[{ id: 'pay', label: 'Payments', laneIds: ['checkout', 'ledger'], collapsed, summary: { count: 7 } }]}
490
+ onToggleGroup={(id) => toggle(id)}
491
+ items={rows.map((r) => ({ id: r.id, laneId: r.serviceId, title: r.title, start: r.start, end: r.end, band: r.band, status: r.status, markers: r.markers }))}
492
+ range={{ start: weekStart, end: weekEnd }} scale="day" subTicks={[6, 12, 18]} timeZone={tz} now={nowIso}
493
+ overlays={[{ id: 'q4', kindLabel: 'Hold', label: 'Quarter close', pattern: 'diagonal', laneIds: ['ledger'], start, end }]}
494
+ flagOverlaps={false}
495
+ onSelectItem={(item) => open(item.id)}
496
+ onProposeMove={(id, { start, end }) => askToMove(id, start, end)}
497
+ notice={truncated && 'Showing 500 of 1,240 items.'}
498
+ columnNotes={{ '2026-10-08': '12 more not shown' }}
482
499
  />
483
500
  ```
484
501
 
485
- - **Axis:** `scale` is `hour`, `day` (default), `week` or `month`. Ticks fall on wall-clock boundaries in `timeZone` (DST and half-hour zones included). `tickWidth` sets the width of one unit; the axis scrolls inside the component and the lane column stays put.
486
- - **Bars:** overlapping bars in a lane stack and are hatched (`data-conflict`), so an overlap reads without colour. A bar cut off by the range shows a dashed edge. `renderItem` replaces the bar text. The full title is in the name and the `title` tooltip.
487
- - **Moving:** with `onMoveItem`, bars move by drag or Shift+←/→ and their end changes with Alt+Shift+←/→. Moves snap to `step` (15 min on an hour axis, 1 h on a day axis, 1 day otherwise), and each move is announced. `movable: false` pins a bar. The component never moves a bar itself; it reports the new start and end and renders what you pass back.
488
- - **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.
489
- - **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.
490
- - Pure helpers are exported for tests and server code: `timelineTicks`, `placeSpan`, `layoutLane`, `shiftSpan`, `snapToStep`, `pixelsToMs`, `defaultTimelineStep`.
502
+ - **Axis:** `scale` is `hour`, `day` (default), `week` or `month`. Ticks fall on wall-clock boundaries in `timeZone` (DST and half-hour zones included). `tickWidth` sets the width of one unit; the axis scrolls inside the component and the lane column stays put. On a `day` axis `subTicks` (local hours) draws minor ticks inside each day; on an `hour` axis an hour that occurs twice (the clocks went back) shows its UTC offset.
503
+ - **Groups:** `groups` puts lanes into collapsible blocks, in the order given; a lane no group names is drawn after them. A collapsed group is one row with `summary.count` and `summary.markers`, which the consumer supplies: the timeline derives no count, and the `groupSummary` label words it (`Items: {count}` by default). That row stands for the lanes it hides: when a window covers one of them (or every lane), the row gets a strip under its head, one sub-row tall, and the window is drawn there, where the group label cannot lie over it. With `onToggleGroup` the consumer owns `collapsed`; without it the group opens and closes itself.
504
+ - **Bars:** bars that share time in a lane stack in sub-rows, packed by `packLanes`. A bar is drawn at least 24 px wide (`--timeline-bar-min`) and is packed as that wide, so two bars never cover each other and each is a 24 × 24 px target with nothing over it. The price is height: where bars are closer together than 24 px of axis, a lane takes more sub-rows than it has bars running at once. On a month axis at the default `tickWidth` 24 px is about six days, so a lane of daily bars is six sub-rows tall; a wider `tickWidth` brings it back down. The packing follows the width of the track (it is measured), and when it changes the first row in view is kept where it was. Only `band="high"` is filled; `status` is a line style; `markers` are shapes or icons with text. `renderItem` replaces the bar text, which is cut at a word: a first word that does not fit is not shown, so no part of a word or of a time is. The full title is in the name and the `title` tooltip.
505
+ - **Stacking flag:** by default a bar that shares time with another bar of its lane is hatched (`data-conflict`) and its name says so (`labels.conflict`). With `flagOverlaps={false}` the stacking stays and the hatch and the words go: the consumer says what clashes, with a reason, through `markers`.
506
+ - **Windows and markers:** an overlay is one neutral element with a `pattern` and its `kindLabel`, name and `scopeLabel` as text, however many rows there are. `laneIds` limits it to the rows of those lanes (unset: every row). With `onSelectOverlay` each window is a named button; without, windows are listed for screen readers. `markers` (a point in time, optionally with `laneIds`) and `now` are one line each. The now-line is neutral.
507
+ - **Moving:** three models, chosen by the callback.
508
+ - `onProposeMove(id, { start, end, adjusted })`: drag a bar, or press Shift+←/→ to build a pending move, Enter to send it and Escape to drop it. One call per finished gesture. The timeline never moves the bar: an item whose props do not change is back where it was, so a refused move snaps back. Return a promise to keep the outline until it settles. On a week or month axis a step is a calendar day (the wall-clock time is kept across a clock change).
509
+ - `onMoveItem(id, { start, end })`: the 2.x model, one call for every key press and every drop.
510
+ - `onResizeItem(id, { start, end })`: Alt+Shift+←/→ moves the end of a bar. Without it there is no resize and no hint offers one.
511
+ Moves snap to `step` (15 min on an hour axis, 1 h on a day axis, 1 day otherwise). `movable: false` pins a bar.
512
+ - **Keyboard:** the bars are one tab stop. ←/→ go to the previous or next bar in a lane, ↑/↓ to the nearest bar in the next lane (over group heads), Home/End to the ends of the lane, and Enter selects, or confirms a pending move, never both (a click or Space does nothing while a move is pending). A move that was sent is announced (`moveSent`).
513
+ - **Long timelines:** above `virtualizeAbove` rows (default 150) only the rows near the viewport are mounted, and an arrow key that goes to a bar that is not mounted mounts it, scrolls to it and focuses it. The rows counted are the ones passed, `lanes` plus `groups`: the answer does not move with the width, with what is collapsed or with how the bars pack. What is mounted is what the viewport shows plus overscan (under 150 row elements up to a viewport of about 7,000 px). The row of the focused control is never unmounted. `maxHeight` makes the lanes scroll inside the timeline under the axis; without it the page scrolls.
514
+ - **Notice and counts:** `notice` is shown above the axis; `columnNotes` puts a note on the column of a day (`YYYY-MM-DD` in `timeZone`).
515
+ - **Row heights:** with `groups`, `laneIds` or a virtual window every row has a fixed height (a lane is its sub-rows, a group head one line) and a lane label that does not fit is cut with an ellipsis. A timeline that uses none of them keeps rows that grow with their label.
516
+ - **Colour:** every colour is named once at the top of `scheduling-timeline.css` (`--timeline-*`). Forced colours are handled in `forced-colors.css`, as for the calendar.
517
+ - **Print (HAR-1541).** The stylesheet has `@media print` rules; add no print CSS of your own. On paper the timeline has no scroller and nothing sticks: the axis is fitted to the page width and the bars follow (every position is a share of the axis), and a lane stays on one page. The colours are the `--uix-print-*` tokens (light, whatever theme the screen has). Nothing depends on a background: the `high` band is a heavy edge on a paper surface (on paper `--timeline-band-high-fill` is the paper's and the edge reads the new `--timeline-band-high-edge`; no screen rule reads it), each window pattern has an edge style of its own, and the grid lines are drawn as lines. A note on the column of a day is not cut at its column (notes on neighbouring days can meet on a narrow page). The "now" line and a pending move are not printed. A timeline over `virtualizeAbove` rows mounts every row and bar while the page prints and windows again afterwards; a page that calls `window.print()` from inside a React effect calls it from an event handler or a timer, or passes `virtualizeAbove={Infinity}`. Limits: a bar keeps its share of the axis, so on a narrow page a short bar shows its time and not its title (the title is still the bar's name); and the bars are packed into sub-rows for the width the timeline had on the screen, so print from a window about as wide as the page when bars are short and close together.
518
+ - **Deprecated, still working in 2.x:** `item.state` / `SchedulingEntryState`, `overlay.kind` / `SchedulingTimelineOverlayKind` and the `states` / `overlays` labels. A bar with `state` is no longer tinted: the two problem states show a marker with the word for that state, and the kinds are told apart by pattern.
519
+ - Pure helpers are exported for tests and server code: `timelineTicks`, `timelineSubTicks`, `timelineRepeatedHourOffset`, `placeSpan`, `layoutLane`, `shiftSpan`, `snapToStep`, `pixelsToMs`, `defaultTimelineStep`, `timelineStepDelta`.
491
520
 
492
521
  ### Calendar model (zoned days, hour slots, lanes)
493
522
 
@@ -872,3 +901,113 @@ const v = useVirtualRows(rows, { rowHeight: 44, estimatedViewportHeight: 600, en
872
901
 
873
902
  The density options no longer break a label inside a word at phone width ("Großzügig"); options
874
903
  that do not fit one row wrap as whole options.
904
+
905
+ ### Date fields, InlineEdit and other older gaps (2.36.0)
906
+
907
+ Older kit issues that TENSOR's triage still found blocking on 2026-10-08. Everything is additive.
908
+
909
+ #### DatePicker, DateTimePicker, DateRangePicker `mode="field"`
910
+
911
+ ```tsx
912
+ <Field label={t('dueDate')} hint={t('workingDaysOnly')}>
913
+ <DatePicker value={due} onValueChange={setDue} name="due" locale={locale} weekStartsOn={1}
914
+ min="2026-11-02" max="2026-12-31" isUnavailable={isWeekend} />
915
+ </Field>
916
+
917
+ <Field label={t('starts')}>
918
+ <DateTimePicker value={starts} onValueChange={setStarts} timeZone="Europe/Vienna" minuteStep={15} />
919
+ </Field>
920
+
921
+ <DateRangePicker mode="field" value={period} onChange={setPeriod} label={t('period')} locale={locale} />
922
+ ```
923
+
924
+ - **`DatePicker`** is a text input that shows the date and reads a typed one, with a button that
925
+ opens one month in a popover. The value is ISO `YYYY-MM-DD` or `null`; `name` submits it in a
926
+ hidden input whatever the field shows.
927
+ - **Typing:** read on Enter and when focus leaves. ISO is always accepted; otherwise three numbers
928
+ in the locale's order (`3.4.2026` is 3 April in German, `3/4/2026` is 4 March in en-US). Text
929
+ that is not a date, or a date outside `min` / `max` / `isUnavailable`, marks the field invalid,
930
+ says why, and leaves `value` alone (`onInputProblem` tells a form). Clearing the field sets
931
+ `null`.
932
+ - **Format:** the locale's numeric date by default. `formatDate` changes what the field writes;
933
+ pass `parseDate` with it when your format is not three numbers, and a `placeholder` so the
934
+ message can name the shape.
935
+ - **Calendar keys:** arrows by day and week, Home / End to the ends of the week, PageUp / PageDown
936
+ by month, with Shift by year. Focus never leaves `min` / `max`; a day refused by `isUnavailable`
937
+ can take focus and is read out, but cannot be chosen. ArrowDown in the field opens the
938
+ calendar; Escape closes it, returns focus to the field and does not close a drawer around it.
939
+ - **`DateTimePicker`** adds a time input. The value is `YYYY-MM-DDTHH:mm`, the shape
940
+ `datetime-local` holds, so it replaces that input without changing what a form submits. The
941
+ zone the wall clock is in is shown beside the time and is its description: `timeZone` shows
942
+ that zone's short name on the chosen day (it follows daylight saving), `timeZoneLabel` replaces
943
+ the text, and without either the viewer's own zone is shown. The value carries no zone.
944
+ - **`DateRangePicker mode="field"`** is a trigger that shows the range and opens the months in a
945
+ popover; it closes when the range has both ends. `DateRangePicker` also takes `weekStartsOn`.
946
+ - **Words:** `UixLabelsProvider` `datePicker`, `dateTimePicker` and the three new
947
+ `dateRangePicker` keys (`placeholder`, `fieldStart`, `fieldRange`).
948
+ - **Helpers:** `parseDateInput`, `formatDateKey`, `datePattern`, `dateOrder`, `isValidDateKey`,
949
+ `splitDateTime`, `joinDateTime`, `timeZoneName`.
950
+
951
+ #### InlineEdit
952
+
953
+ ```tsx
954
+ <DescriptionItem term={t('title')}>
955
+ <InlineEdit label={t('title')} value={record.title} onSave={(next) => api.rename(record.id, next)}
956
+ validate={(next) => (next.trim() ? undefined : t('titleRequired'))} />
957
+ </DescriptionItem>
958
+ ```
959
+
960
+ The value is a button named by the value and "Edit {label}". Activating it swaps in an editor with
961
+ Save and Cancel: Enter saves (Ctrl/⌘+Enter in `multiline`), Escape cancels without reaching a
962
+ drawer around it, and nothing is saved on blur. A promise from `onSave` shows the busy state; a
963
+ rejection keeps the draft and shows the reason. Afterwards focus is back on the value.
964
+ `renderView` and `renderEditor` replace either half (a `Select`, a `DatePicker`); `disabled`
965
+ renders plain text. Words: `labels` or `UixLabelsProvider` `inlineEdit`.
966
+
967
+ #### RadioCard
968
+
969
+ ```tsx
970
+ <RadioGroup variant="card" label={t('rollout')}>
971
+ <RadioCard name="rollout" value="team" title={t('oneTeam')} description={t('oneTeamHint')}
972
+ checked={rollout === 'team'} onChange={() => setRollout('team')} />
973
+ …
974
+ </RadioGroup>
975
+ ```
976
+
977
+ A native radio inside a card: one tab stop for the group, arrow keys choose, the whole card is
978
+ the target. The choice is a border and a check mark, not colour alone. `media` puts a preview
979
+ above the text. The grid wraps to one column on a phone.
980
+
981
+ #### Tree loads children on expand
982
+
983
+ ```tsx
984
+ <Tree nodes={roots} loadChildren={(node) => api.org.children(node.id)} /> // roots: { id, label, hasChildren: true }
985
+ ```
986
+
987
+ A node with `hasChildren` and no `children` is expandable. The first time it is expanded
988
+ `loadChildren` runs: the node is `aria-busy` and shows a loading row; a rejection shows an error
989
+ row whose Enter, Space or click retries. Focus follows the row that replaces the one it was on,
990
+ and lands on the node once its children are there. The tree keeps the loaded children; pass them
991
+ back in `nodes` when you own the data. Words: `labels` or `UixLabelsProvider` `tree`.
992
+
993
+ #### Avatar presence
994
+
995
+ ```tsx
996
+ <Avatar presence="busy" src={user.photo} alt={user.name} /> <PresenceDot presence="away" />
997
+ ```
998
+
999
+ `presence` is `online` (a filled dot), `busy` (a dot with a bar), `away` and `offline` (two
1000
+ rings): a shape as well as a colour, and the state in visually hidden text (`presenceLabels`
1001
+ translates it). `status` is still `presence="online"`. `PresenceDot` is the dot alone, named by
1002
+ its state (`label={null}` makes it decorative). Fixed: the dot was clipped to a quarter by the avatar's round
1003
+ mask; an avatar with a dot no longer clips it.
1004
+
1005
+ #### EntityPicker and CommandPalette words
1006
+
1007
+ - `EntityPicker onSearch` may resolve `{ options, hasMore: true }` when its list was cut off: the
1008
+ list ends with a row that says more match (not an option; arrow keys skip it) and the polite
1009
+ status says so too. Below `minQueryLength` the open list shows "Type at least {count}
1010
+ characters." Both, and every other word, come from `labels` or `UixLabelsProvider`
1011
+ `entityPicker`. `SearchSuggest` has the two slots these use: `idle` and `note`.
1012
+ - `CommandPalette` announces its result count through `UixLabelsProvider` `commandPalette`
1013
+ (`resultsOne`, `resultsMany`, `{count}`) or a `resultsLabel(count)` prop; English is unchanged.
@@ -1,19 +1,41 @@
1
1
  import * as react from 'react';
2
2
  import { HTMLAttributes, ReactNode } from 'react';
3
3
 
4
+ /** Presence of a person (HAR-1374). Each state has its own shape as well as its own colour. */
5
+ type AvatarPresence = 'online' | 'busy' | 'away' | 'offline';
6
+ /** The words read for each presence state. English; pass translations as `presenceLabels`. */
7
+ declare const DEFAULT_PRESENCE_LABELS: Record<AvatarPresence, string>;
4
8
  interface AvatarProps extends HTMLAttributes<HTMLSpanElement> {
5
- /** TENSOR RX-125 (UIX-04): translatable; English default. */
9
+ /** TENSOR RX-125 (UIX-04): translatable; English default. Still honoured for `online`. */
6
10
  onlineLabel?: string;
7
11
  size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
8
12
  src?: string;
9
13
  alt?: string;
10
14
  /** Initials / fallback shown when there is no `src`. */
11
15
  children?: ReactNode;
12
- /** Online status dot. */
16
+ /** Online status dot. The same as `presence="online"`; kept for existing callers. */
13
17
  status?: boolean;
18
+ /**
19
+ * A presence dot: `online` (filled green), `busy` (red with a bar), `away` (an amber ring),
20
+ * `offline` (a grey ring). The state is also in visually hidden text, so it is never colour
21
+ * alone.
22
+ */
23
+ presence?: AvatarPresence;
24
+ /** Translations of the presence words. */
25
+ presenceLabels?: Partial<Record<AvatarPresence, string>>;
14
26
  }
15
27
  /** Avatar over `.uix-avatar`. Renders an image when `src` is set, else children (initials). */
16
- declare function Avatar({ size, src, alt, children, status, onlineLabel, className, ...props }: AvatarProps): react.JSX.Element;
28
+ declare function Avatar({ size, src, alt, children, status, presence, presenceLabels, onlineLabel, className, ...props }: AvatarProps): react.JSX.Element;
29
+ interface PresenceDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
30
+ presence: AvatarPresence;
31
+ /**
32
+ * The state in words for assistive technology. Default: the English word. Pass `null` when
33
+ * the state is already written next to the dot, to make the dot decorative.
34
+ */
35
+ label?: string | null;
36
+ }
37
+ /** The presence dot on its own, for a row or a menu that shows no avatar (HAR-1374). */
38
+ declare function PresenceDot({ presence, label, className, ...props }: PresenceDotProps): react.JSX.Element;
17
39
  interface AvatarGroupProps extends HTMLAttributes<HTMLSpanElement> {
18
40
  children?: ReactNode;
19
41
  }
@@ -29,4 +51,4 @@ interface UserChipProps extends HTMLAttributes<HTMLSpanElement> {
29
51
  /** Avatar + name (+ optional sub-line) over `.uix-user-chip`. */
30
52
  declare function UserChip({ avatar, name, sub, className, children, ...props }: UserChipProps): react.JSX.Element;
31
53
 
32
- export { Avatar, AvatarGroup, type AvatarGroupProps, type AvatarProps, UserChip, type UserChipProps };
54
+ export { Avatar, AvatarGroup, type AvatarGroupProps, type AvatarPresence, type AvatarProps, DEFAULT_PRESENCE_LABELS, PresenceDot, type PresenceDotProps, UserChip, type UserChipProps };
@@ -1,12 +1,33 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../cx.js";
3
- function Avatar({ size = "md", src, alt, children, status, onlineLabel = "online", className, ...props }) {
3
+ const DEFAULT_PRESENCE_LABELS = {
4
+ online: "online",
5
+ busy: "busy",
6
+ away: "away",
7
+ offline: "offline"
8
+ };
9
+ function Avatar({ size = "md", src, alt, children, status, presence, presenceLabels, onlineLabel, className, ...props }) {
10
+ const state = presence ?? (status ? "online" : void 0);
11
+ const word = state && (presenceLabels?.[state] ?? (state === "online" ? onlineLabel : void 0) ?? DEFAULT_PRESENCE_LABELS[state]);
4
12
  return /* @__PURE__ */ jsxs("span", { className: cx("uix-avatar", size !== "md" && `uix-avatar--${size}`, className), ...props, children: [
5
13
  src ? /* @__PURE__ */ jsx("img", { src, alt: alt ?? "" }) : children,
6
- status && /* @__PURE__ */ jsx("span", { className: "uix-avatar__status", "aria-hidden": "true" }),
7
- status && /* @__PURE__ */ jsx("span", { className: "uix-visually-hidden", children: onlineLabel })
14
+ state && /* @__PURE__ */ jsx("span", { className: "uix-avatar__status", "data-presence": state === "online" ? void 0 : state, "aria-hidden": "true" }),
15
+ state && /* @__PURE__ */ jsx("span", { className: "uix-visually-hidden", children: word })
8
16
  ] });
9
17
  }
18
+ function PresenceDot({ presence, label, className, ...props }) {
19
+ const decorative = label === null;
20
+ return /* @__PURE__ */ jsx(
21
+ "span",
22
+ {
23
+ className: cx("uix-presence", `uix-presence--${presence}`, className),
24
+ role: decorative ? void 0 : "img",
25
+ "aria-label": decorative ? void 0 : label ?? DEFAULT_PRESENCE_LABELS[presence],
26
+ "aria-hidden": decorative ? true : void 0,
27
+ ...props
28
+ }
29
+ );
30
+ }
10
31
  function AvatarGroup({ children, className, ...props }) {
11
32
  return /* @__PURE__ */ jsx("span", { className: cx("uix-avatar-group", className), ...props, children });
12
33
  }
@@ -23,5 +44,7 @@ function UserChip({ avatar, name, sub, className, children, ...props }) {
23
44
  export {
24
45
  Avatar,
25
46
  AvatarGroup,
47
+ DEFAULT_PRESENCE_LABELS,
48
+ PresenceDot,
26
49
  UserChip
27
50
  };
@@ -4,6 +4,11 @@ import { ReactNode, HTMLAttributes, InputHTMLAttributes } from 'react';
4
4
  interface CommandPaletteProps extends HTMLAttributes<HTMLDivElement> {
5
5
  /** TENSOR RX-125 (UIX-04): translatable; English default. */
6
6
  inputLabel?: string;
7
+ /**
8
+ * The polite result count for this palette (HAR-1647). Default: `UixLabelsProvider`
9
+ * `commandPalette.resultsOne` / `resultsMany` (`{count}`), then "1 result" / "3 results".
10
+ */
11
+ resultsLabel?: (count: number) => string;
7
12
  /** Props for the search input (value/onChange/placeholder wired by the consumer). */
8
13
  inputProps?: InputHTMLAttributes<HTMLInputElement>;
9
14
  children?: ReactNode;
@@ -14,7 +19,7 @@ interface CommandPaletteProps extends HTMLAttributes<HTMLDivElement> {
14
19
  * a combobox over the listbox of `CommandItem`s; spreads override every default, so
15
20
  * consumers already passing their own roles/labels keep working.
16
21
  */
17
- declare function CommandPalette({ inputProps, children, inputLabel: inputLabelProp, className, ...props }: CommandPaletteProps): react.JSX.Element;
22
+ declare function CommandPalette({ inputProps, children, inputLabel: inputLabelProp, resultsLabel, className, ...props }: CommandPaletteProps): react.JSX.Element;
18
23
  interface CommandGroupProps {
19
24
  label?: ReactNode;
20
25
  children?: ReactNode;
@@ -1,12 +1,18 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState } from "react";
3
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
4
4
  import { cx } from "../cx.js";
5
+ import { fillLabel } from "../fill-label.js";
5
6
  import { useUixLabels } from "../labels-context.js";
6
7
  const CmdkContext = createContext(null);
7
- function CommandPalette({ inputProps, children, inputLabel: inputLabelProp, className, ...props }) {
8
+ function CommandPalette({ inputProps, children, inputLabel: inputLabelProp, resultsLabel, className, ...props }) {
8
9
  const uixLabels = useUixLabels();
9
10
  const inputLabel = inputLabelProp ?? uixLabels.commandPalette?.input ?? "Command palette";
11
+ const resultsOne = uixLabels.commandPalette?.resultsOne ?? "{count} result";
12
+ const resultsMany = uixLabels.commandPalette?.resultsMany ?? "{count} results";
13
+ const resultsText = (count2) => resultsLabel?.(count2) ?? fillLabel(count2 === 1 ? resultsOne : resultsMany, { count: count2 });
14
+ const resultsTextRef = useRef(resultsText);
15
+ resultsTextRef.current = resultsText;
10
16
  const listId = useId();
11
17
  const [activeId, setActiveId] = useState(null);
12
18
  const [count, setCount] = useState(0);
@@ -19,7 +25,7 @@ function CommandPalette({ inputProps, children, inputLabel: inputLabelProp, clas
19
25
  return () => setCount((c) => c - 1);
20
26
  }, []);
21
27
  useEffect(() => {
22
- const t = window.setTimeout(() => setAnnounced(`${count} result${count === 1 ? "" : "s"}`), 150);
28
+ const t = window.setTimeout(() => setAnnounced(resultsTextRef.current(count)), 150);
23
29
  return () => clearTimeout(t);
24
30
  }, [count]);
25
31
  const ctx = useMemo(() => ({ listId, setActive, registerItem }), [listId, setActive, registerItem]);
@@ -0,0 +1,61 @@
1
+ import * as react from 'react';
2
+ import { MouseEvent } from 'react';
3
+ import { CalendarWeekday } from '../calendar-model.js';
4
+
5
+ /** The viewer's local calendar date as `YYYY-MM-DD` (not UTC: "today" is a local idea). */
6
+ declare const localDateKey: (now?: Date) => string;
7
+ declare const monthLabel: (date: string, locale?: string) => string;
8
+ declare const fullDateLabel: (date: string, locale?: string) => string;
9
+ /**
10
+ * Short weekday names in `locale`, starting on `weekStartsOn`. 2024-01-07 is a Sunday;
11
+ * formatting in UTC keeps each reference day on its own date.
12
+ */
13
+ declare const weekdayNames: (locale: string | undefined, weekStartsOn?: CalendarWeekday) => string[];
14
+ /**
15
+ * Open state and trigger wiring for a field whose calendar lives in a kit `Popover`
16
+ * (`popover="auto"`, so a press outside or Escape closes it). `restoreFocus` is called when it
17
+ * closes while focus is inside it or nowhere: the field takes focus back.
18
+ */
19
+ declare function useFieldPopover(popoverId: string, restoreFocus: () => void, onOpenChange?: (open: boolean) => void): {
20
+ open: boolean;
21
+ show: () => void;
22
+ hide: (restore?: boolean) => void;
23
+ /** Spread on the button that opens the calendar. */
24
+ triggerProps: {
25
+ 'aria-haspopup': "dialog";
26
+ 'aria-expanded': boolean;
27
+ 'aria-controls': string;
28
+ onPointerDown: () => void;
29
+ onPointerLeave: () => void;
30
+ onPointerCancel: () => void;
31
+ onClick: (event: MouseEvent<HTMLElement>) => void;
32
+ };
33
+ };
34
+ interface MonthCalendarProps {
35
+ /** The selected day, or `null`. */
36
+ value: string | null;
37
+ onSelect: (date: string) => void;
38
+ /** The day that has focus when the calendar opens. */
39
+ initialDate: string;
40
+ min?: string;
41
+ max?: string;
42
+ isUnavailable?: (date: string) => boolean;
43
+ locale?: string;
44
+ weekStartsOn?: CalendarWeekday;
45
+ today: string | null;
46
+ labels: {
47
+ previousMonth: string;
48
+ nextMonth: string;
49
+ };
50
+ /** Move focus to the active day when the calendar mounts. */
51
+ autoFocus?: boolean;
52
+ }
53
+ /**
54
+ * One month of day buttons with the full calendar key path: arrows by day and week, Home / End
55
+ * to the ends of the week, PageUp / PageDown by month, with Shift by year. Days outside
56
+ * `min` / `max` are never reached; unavailable days inside them can be reached and read out,
57
+ * but not chosen. Shares the day-cell look of the range picker.
58
+ */
59
+ declare function MonthCalendar({ value, onSelect, initialDate, min, max, isUnavailable, locale, weekStartsOn, today, labels, autoFocus, }: MonthCalendarProps): react.JSX.Element;
60
+
61
+ export { MonthCalendar, type MonthCalendarProps, fullDateLabel, localDateKey, monthLabel, useFieldPopover, weekdayNames };
@@ -0,0 +1,195 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { addCalendarDays, addCalendarMonths, buildMonthGrid, isDateUnavailable, startOfMonth } from "../calendar-model.js";
5
+ import { ChevronLeftIcon, ChevronRightIcon } from "../icons/components.js";
6
+ const localDateKey = (now = /* @__PURE__ */ new Date()) => `${now.getFullYear().toString().padStart(4, "0")}-${(now.getMonth() + 1).toString().padStart(2, "0")}-${now.getDate().toString().padStart(2, "0")}`;
7
+ const utc = (date) => /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
8
+ const monthLabel = (date, locale) => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }).format(utc(date));
9
+ const fullDateLabel = (date, locale) => new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }).format(utc(date));
10
+ const weekdayNames = (locale, weekStartsOn = 1) => {
11
+ const fmt = new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" });
12
+ return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2024, 0, 7 + weekStartsOn + i))));
13
+ };
14
+ const isOpen = (el) => {
15
+ try {
16
+ return !!el?.matches(":popover-open");
17
+ } catch {
18
+ return false;
19
+ }
20
+ };
21
+ function useFieldPopover(popoverId, restoreFocus, onOpenChange) {
22
+ const [open, setOpen] = useState(false);
23
+ const pointerWasOpen = useRef(null);
24
+ const restoreRef = useRef(restoreFocus);
25
+ restoreRef.current = restoreFocus;
26
+ const onOpenChangeRef = useRef(onOpenChange);
27
+ onOpenChangeRef.current = onOpenChange;
28
+ const skipRestore = useRef(false);
29
+ const popoverEl = () => typeof document === "undefined" ? null : document.getElementById(popoverId);
30
+ useEffect(() => {
31
+ const el = popoverEl();
32
+ if (!el) return;
33
+ const onToggle = (event) => {
34
+ const { newState } = event;
35
+ const nowOpen = newState ? newState === "open" : isOpen(el);
36
+ setOpen(nowOpen);
37
+ onOpenChangeRef.current?.(nowOpen);
38
+ if (nowOpen) return;
39
+ const active = el.ownerDocument.activeElement;
40
+ if (skipRestore.current) skipRestore.current = false;
41
+ else if (el.contains(active) || active === el.ownerDocument.body) restoreRef.current();
42
+ };
43
+ el.addEventListener("toggle", onToggle);
44
+ return () => el.removeEventListener("toggle", onToggle);
45
+ }, [popoverId]);
46
+ const show = () => {
47
+ const el = popoverEl();
48
+ if (el && !isOpen(el)) {
49
+ try {
50
+ el.showPopover();
51
+ } catch {
52
+ }
53
+ }
54
+ };
55
+ const hide = (restore = true) => {
56
+ const el = popoverEl();
57
+ if (!el || !isOpen(el)) return;
58
+ skipRestore.current = !restore;
59
+ try {
60
+ el.hidePopover();
61
+ } catch {
62
+ skipRestore.current = false;
63
+ }
64
+ };
65
+ return {
66
+ open,
67
+ show,
68
+ hide,
69
+ /** Spread on the button that opens the calendar. */
70
+ triggerProps: {
71
+ "aria-haspopup": "dialog",
72
+ "aria-expanded": open,
73
+ "aria-controls": popoverId,
74
+ // A pointer press outside an open auto popover already closed it (light dismiss); the
75
+ // click that follows must not open it again.
76
+ onPointerDown: () => {
77
+ pointerWasOpen.current = isOpen(popoverEl());
78
+ },
79
+ // A press that never became a click (dragged off, cancelled) must not decide the next one.
80
+ onPointerLeave: () => {
81
+ pointerWasOpen.current = null;
82
+ },
83
+ onPointerCancel: () => {
84
+ pointerWasOpen.current = null;
85
+ },
86
+ onClick: (event) => {
87
+ if (event.defaultPrevented) return;
88
+ const was = pointerWasOpen.current;
89
+ pointerWasOpen.current = null;
90
+ if (was === true) {
91
+ hide();
92
+ return;
93
+ }
94
+ if (isOpen(popoverEl())) hide();
95
+ else show();
96
+ }
97
+ }
98
+ };
99
+ }
100
+ function MonthCalendar({
101
+ value,
102
+ onSelect,
103
+ initialDate,
104
+ min,
105
+ max,
106
+ isUnavailable,
107
+ locale,
108
+ weekStartsOn = 1,
109
+ today,
110
+ labels,
111
+ autoFocus = false
112
+ }) {
113
+ const clamp = (date) => min && date < min ? min : max && date > max ? max : date;
114
+ const [active, setActive] = useState(() => clamp(initialDate));
115
+ const gridRef = useRef(null);
116
+ const pendingFocus = useRef(autoFocus);
117
+ const month = startOfMonth(active);
118
+ const days = useMemo(() => buildMonthGrid(month, weekStartsOn).filter((day) => day.inMonth), [month, weekStartsOn]);
119
+ const blanks = (utc(month).getUTCDay() - weekStartsOn + 7) % 7;
120
+ const weekdays = useMemo(() => weekdayNames(locale, weekStartsOn), [locale, weekStartsOn]);
121
+ useEffect(() => {
122
+ if (!pendingFocus.current) return;
123
+ pendingFocus.current = false;
124
+ gridRef.current?.querySelector(`[data-date="${active}"]`)?.focus({ preventScroll: true });
125
+ }, [active]);
126
+ const focusDate = (date) => {
127
+ pendingFocus.current = true;
128
+ setActive(clamp(date));
129
+ };
130
+ const showMonth = (amount) => setActive((current) => clamp(addCalendarMonths(current, amount)));
131
+ const unavailable = (date) => isDateUnavailable(date, { min, max, isDisabled: isUnavailable });
132
+ const onDayKeyDown = (event, date) => {
133
+ const rtl = typeof getComputedStyle === "function" && getComputedStyle(event.currentTarget).direction === "rtl";
134
+ const offsets = { ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1, ArrowUp: -7, ArrowDown: 7 };
135
+ if (event.key in offsets) {
136
+ event.preventDefault();
137
+ focusDate(addCalendarDays(date, offsets[event.key]));
138
+ } else if (event.key === "Home" || event.key === "End") {
139
+ event.preventDefault();
140
+ const index = (utc(date).getUTCDay() - weekStartsOn + 7) % 7;
141
+ focusDate(addCalendarDays(date, event.key === "Home" ? -index : 6 - index));
142
+ } else if (event.key === "PageUp" || event.key === "PageDown") {
143
+ event.preventDefault();
144
+ focusDate(addCalendarMonths(date, (event.key === "PageUp" ? -1 : 1) * (event.shiftKey ? 12 : 1)));
145
+ }
146
+ };
147
+ const atMin = !!min && month <= startOfMonth(min);
148
+ const atMax = !!max && month >= startOfMonth(max);
149
+ return /* @__PURE__ */ jsxs("div", { className: "uix-date-picker__calendar", children: [
150
+ /* @__PURE__ */ jsxs("div", { className: "uix-date-picker__nav", children: [
151
+ /* @__PURE__ */ jsx("button", { type: "button", className: "uix-date-picker__nav-btn", "aria-label": labels.previousMonth, "aria-disabled": atMin || void 0, onClick: () => {
152
+ if (!atMin) showMonth(-1);
153
+ }, children: /* @__PURE__ */ jsx(ChevronLeftIcon, { size: "sm" }) }),
154
+ /* @__PURE__ */ jsx("span", { className: "uix-date-picker__month", "aria-live": "polite", children: monthLabel(month, locale) }),
155
+ /* @__PURE__ */ jsx("button", { type: "button", className: "uix-date-picker__nav-btn", "aria-label": labels.nextMonth, "aria-disabled": atMax || void 0, onClick: () => {
156
+ if (!atMax) showMonth(1);
157
+ }, children: /* @__PURE__ */ jsx(ChevronRightIcon, { size: "sm" }) })
158
+ ] }),
159
+ /* @__PURE__ */ jsx("div", { className: "uix-date-range-picker__weekdays", "aria-hidden": "true", children: weekdays.map((day, index) => /* @__PURE__ */ jsx("span", { children: day }, index)) }),
160
+ /* @__PURE__ */ jsxs("div", { ref: gridRef, className: "uix-date-range-picker__grid uix-date-picker__grid", role: "group", "aria-label": monthLabel(month, locale), children: [
161
+ Array.from({ length: blanks }, (_, index) => /* @__PURE__ */ jsx("span", { "aria-hidden": "true" }, `blank-${index}`)),
162
+ days.map((day) => {
163
+ const off = unavailable(day.date);
164
+ return /* @__PURE__ */ jsx(
165
+ "button",
166
+ {
167
+ type: "button",
168
+ "data-date": day.date,
169
+ "data-selected": day.date === value || void 0,
170
+ "aria-pressed": day.date === value,
171
+ "aria-label": fullDateLabel(day.date, locale),
172
+ "aria-current": day.date === today ? "date" : void 0,
173
+ "aria-disabled": off || void 0,
174
+ tabIndex: active === day.date ? 0 : -1,
175
+ onFocus: () => setActive(day.date),
176
+ onKeyDown: (event) => onDayKeyDown(event, day.date),
177
+ onClick: () => {
178
+ if (!off) onSelect(day.date);
179
+ },
180
+ children: day.day
181
+ },
182
+ day.date
183
+ );
184
+ })
185
+ ] })
186
+ ] });
187
+ }
188
+ export {
189
+ MonthCalendar,
190
+ fullDateLabel,
191
+ localDateKey,
192
+ monthLabel,
193
+ useFieldPopover,
194
+ weekdayNames
195
+ };