prime-ui-kit 0.11.1 → 0.13.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 (171) hide show
  1. package/README.md +4 -1
  2. package/dist/chunks/{chunk-WDTSHOTD.js → chunk-E6SQBSDF.js} +140 -30
  3. package/dist/chunks/{chunk-CUH6DJ57.js → chunk-Z3WKKAS6.js} +6572 -5024
  4. package/dist/chunks/{chunk-GKX3QAUC.js → chunk-ZSJMNA46.js} +14 -5
  5. package/dist/color-picker.js +2 -2
  6. package/dist/components/chat/Chat.d.ts +11 -12
  7. package/dist/components/chat/chatContext.d.ts +2 -0
  8. package/dist/components/chat-list/ChatList.d.ts +191 -0
  9. package/dist/components/chat-list/formatChatTime.d.ts +6 -0
  10. package/dist/components/command-menu/CommandMenu.d.ts +10 -5
  11. package/dist/components/composer/Composer.d.ts +3 -10
  12. package/dist/components/data-table/DataTable.d.ts +16 -2
  13. package/dist/components/data-table/Row.d.ts +23 -7
  14. package/dist/components/data-table/StateRows.d.ts +5 -1
  15. package/dist/components/data-table/reorder.d.ts +3 -1
  16. package/dist/components/data-table/rows.d.ts +20 -4
  17. package/dist/components/file-upload/FileUpload.d.ts +7 -0
  18. package/dist/components/gantt/Gantt.d.ts +19 -1
  19. package/dist/components/gantt/GanttBar.d.ts +4 -1
  20. package/dist/components/gantt/GanttRow.d.ts +8 -1
  21. package/dist/components/index.d.ts +11 -7
  22. package/dist/components/kanban/Kanban.d.ts +17 -2
  23. package/dist/components/list/List.d.ts +120 -0
  24. package/dist/components/message/Message.d.ts +13 -0
  25. package/dist/components/message/MessageParts.d.ts +2 -0
  26. package/dist/components/modal/DialogParts.d.ts +5 -1
  27. package/dist/components/select/Select.d.ts +11 -5
  28. package/dist/components/select/SelectItem.d.ts +19 -0
  29. package/dist/components/select/selectContext.d.ts +4 -0
  30. package/dist/components/sparkline/Sparkline.d.ts +14 -3
  31. package/dist/components/textarea/Textarea.d.ts +11 -3
  32. package/dist/hooks/useUploadQueue.d.ts +5 -1
  33. package/dist/icons/registry.d.ts +2 -0
  34. package/dist/index.css +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +10 -2
  37. package/dist/internal/VisuallyHidden.d.ts +6 -1
  38. package/dist/internal/attachmentTile.d.ts +5 -1
  39. package/dist/internal/richText.d.ts +7 -2
  40. package/dist/internal/textFormatting.d.ts +52 -0
  41. package/dist/internal/textFormattingToolbar.d.ts +26 -0
  42. package/dist/skill/api-contract.md +1 -1
  43. package/dist/skill/cheatsheet.md +13 -1
  44. package/dist/skill/choosing.md +9 -4
  45. package/dist/skill/components.md +7 -5
  46. package/dist/skill/composition.md +9 -1
  47. package/dist/skill/layouts.md +4 -1
  48. package/dist/skill/patterns/app-shell.module.css +18 -0
  49. package/dist/skill/patterns/app-shell.tsx +301 -0
  50. package/dist/skill/patterns/chat.module.css +0 -25
  51. package/dist/skill/patterns/chat.tsx +72 -29
  52. package/dist/skill/patterns/kanban-board.module.css +28 -0
  53. package/dist/skill/patterns/kanban-board.tsx +612 -0
  54. package/dist/skill/patterns/media-library.module.css +66 -0
  55. package/dist/skill/patterns/media-library.tsx +638 -0
  56. package/dist/skill/patterns/schedule.module.css +22 -0
  57. package/dist/skill/patterns/schedule.tsx +722 -0
  58. package/dist/skill/reference/components/chat/COMPONENT.md +13 -4
  59. package/dist/skill/reference/components/chat/examples/states.tsx +100 -0
  60. package/dist/skill/reference/components/chat-list/COMPONENT.md +256 -0
  61. package/dist/skill/reference/components/chat-list/examples/controlled.tsx +103 -0
  62. package/dist/skill/reference/components/chat-list/examples/examples.module.css +66 -0
  63. package/dist/skill/reference/components/chat-list/examples/live.tsx +133 -0
  64. package/dist/skill/reference/components/chat-list/examples/long-list.tsx +87 -0
  65. package/dist/skill/reference/components/chat-list/examples/narrow.tsx +88 -0
  66. package/dist/skill/reference/components/chat-list/examples/overview.tsx +194 -0
  67. package/dist/skill/reference/components/chat-list/examples/search.tsx +71 -0
  68. package/dist/skill/reference/components/chat-list/examples/sizes.tsx +61 -0
  69. package/dist/skill/reference/components/chat-list/examples/states.tsx +107 -0
  70. package/dist/skill/reference/components/chat-list/examples/structure.tsx +135 -0
  71. package/dist/skill/reference/components/command-menu/COMPONENT.md +15 -11
  72. package/dist/skill/reference/components/command-menu/examples/server-search.tsx +11 -9
  73. package/dist/skill/reference/components/composer/COMPONENT.md +8 -7
  74. package/dist/skill/reference/components/composer/examples/formatting.tsx +1 -1
  75. package/dist/skill/reference/components/confirm-dialog/COMPONENT.md +1 -0
  76. package/dist/skill/reference/components/data-table/COMPONENT.md +21 -6
  77. package/dist/skill/reference/components/data-table/examples/groups.tsx +2 -1
  78. package/dist/skill/reference/components/data-table/examples/trend.tsx +85 -0
  79. package/dist/skill/reference/components/drawer/COMPONENT.md +7 -6
  80. package/dist/skill/reference/components/drawer/examples/client-card.tsx +50 -0
  81. package/dist/skill/reference/components/fieldset/COMPONENT.md +1 -0
  82. package/dist/skill/reference/components/file-upload/COMPONENT.md +12 -8
  83. package/dist/skill/reference/components/file-upload/examples/overview.tsx +14 -3
  84. package/dist/skill/reference/components/gantt/COMPONENT.md +15 -5
  85. package/dist/skill/reference/components/gantt/examples/row-link.tsx +96 -0
  86. package/dist/skill/reference/components/kanban/COMPONENT.md +12 -4
  87. package/dist/skill/reference/components/kanban/examples/item-link.tsx +92 -0
  88. package/dist/skill/reference/components/list/COMPONENT.md +195 -0
  89. package/dist/skill/reference/components/list/examples/as-link.tsx +37 -0
  90. package/dist/skill/reference/components/list/examples/examples.module.css +18 -0
  91. package/dist/skill/reference/components/list/examples/overflow.tsx +46 -0
  92. package/dist/skill/reference/components/list/examples/overview.tsx +63 -0
  93. package/dist/skill/reference/components/list/examples/reorder.tsx +48 -0
  94. package/dist/skill/reference/components/list/examples/sizes.tsx +35 -0
  95. package/dist/skill/reference/components/list/examples/states.tsx +27 -0
  96. package/dist/skill/reference/components/list/examples/with-icon.tsx +60 -0
  97. package/dist/skill/reference/components/message/COMPONENT.md +18 -2
  98. package/dist/skill/reference/components/message/examples/bot-actions.tsx +51 -0
  99. package/dist/skill/reference/components/modal/COMPONENT.md +6 -4
  100. package/dist/skill/reference/components/page-content/COMPONENT.md +2 -2
  101. package/dist/skill/reference/components/page-content/examples/examples.module.css +5 -12
  102. package/dist/skill/reference/components/page-content/examples/list-detail.tsx +13 -18
  103. package/dist/skill/reference/components/select/COMPONENT.md +38 -7
  104. package/dist/skill/reference/components/select/examples/create-action.tsx +107 -0
  105. package/dist/skill/reference/components/select/examples/saving.tsx +47 -0
  106. package/dist/skill/reference/components/sheet/COMPONENT.md +4 -4
  107. package/dist/skill/reference/components/sparkline/COMPONENT.md +16 -2
  108. package/dist/skill/reference/components/sparkline/examples/compact.tsx +41 -0
  109. package/dist/skill/reference/components/sparkline/examples/examples.module.css +12 -0
  110. package/dist/skill/reference/components/textarea/COMPONENT.md +22 -2
  111. package/dist/skill/reference/components/textarea/examples/formatting.tsx +20 -0
  112. package/dist/skill/reference/components/visually-hidden/COMPONENT.md +6 -3
  113. package/dist/skill/reference/components/visually-hidden/examples/overview.tsx +2 -2
  114. package/package.json +2 -2
  115. package/src/components/chat/COMPONENT.md +13 -4
  116. package/src/components/chat/examples/states.tsx +100 -0
  117. package/src/components/chat-list/COMPONENT.md +256 -0
  118. package/src/components/chat-list/examples/controlled.tsx +103 -0
  119. package/src/components/chat-list/examples/examples.module.css +66 -0
  120. package/src/components/chat-list/examples/live.tsx +133 -0
  121. package/src/components/chat-list/examples/long-list.tsx +87 -0
  122. package/src/components/chat-list/examples/narrow.tsx +88 -0
  123. package/src/components/chat-list/examples/overview.tsx +194 -0
  124. package/src/components/chat-list/examples/search.tsx +71 -0
  125. package/src/components/chat-list/examples/sizes.tsx +61 -0
  126. package/src/components/chat-list/examples/states.tsx +107 -0
  127. package/src/components/chat-list/examples/structure.tsx +135 -0
  128. package/src/components/command-menu/COMPONENT.md +15 -11
  129. package/src/components/command-menu/examples/server-search.tsx +11 -9
  130. package/src/components/composer/COMPONENT.md +8 -7
  131. package/src/components/composer/examples/formatting.tsx +1 -1
  132. package/src/components/confirm-dialog/COMPONENT.md +1 -0
  133. package/src/components/data-table/COMPONENT.md +21 -6
  134. package/src/components/data-table/examples/groups.tsx +2 -1
  135. package/src/components/data-table/examples/trend.tsx +85 -0
  136. package/src/components/drawer/COMPONENT.md +7 -6
  137. package/src/components/drawer/examples/client-card.tsx +50 -0
  138. package/src/components/fieldset/COMPONENT.md +1 -0
  139. package/src/components/file-upload/COMPONENT.md +12 -8
  140. package/src/components/file-upload/examples/overview.tsx +14 -3
  141. package/src/components/gantt/COMPONENT.md +15 -5
  142. package/src/components/gantt/examples/row-link.tsx +96 -0
  143. package/src/components/kanban/COMPONENT.md +12 -4
  144. package/src/components/kanban/examples/item-link.tsx +92 -0
  145. package/src/components/list/COMPONENT.md +195 -0
  146. package/src/components/list/examples/as-link.tsx +37 -0
  147. package/src/components/list/examples/examples.module.css +18 -0
  148. package/src/components/list/examples/overflow.tsx +46 -0
  149. package/src/components/list/examples/overview.tsx +63 -0
  150. package/src/components/list/examples/reorder.tsx +48 -0
  151. package/src/components/list/examples/sizes.tsx +35 -0
  152. package/src/components/list/examples/states.tsx +27 -0
  153. package/src/components/list/examples/with-icon.tsx +60 -0
  154. package/src/components/message/COMPONENT.md +18 -2
  155. package/src/components/message/examples/bot-actions.tsx +51 -0
  156. package/src/components/modal/COMPONENT.md +6 -4
  157. package/src/components/page-content/COMPONENT.md +2 -2
  158. package/src/components/page-content/examples/examples.module.css +5 -12
  159. package/src/components/page-content/examples/list-detail.tsx +13 -18
  160. package/src/components/select/COMPONENT.md +38 -7
  161. package/src/components/select/examples/create-action.tsx +107 -0
  162. package/src/components/select/examples/saving.tsx +47 -0
  163. package/src/components/sheet/COMPONENT.md +4 -4
  164. package/src/components/sparkline/COMPONENT.md +16 -2
  165. package/src/components/sparkline/examples/compact.tsx +41 -0
  166. package/src/components/sparkline/examples/examples.module.css +12 -0
  167. package/src/components/textarea/COMPONENT.md +22 -2
  168. package/src/components/textarea/examples/formatting.tsx +20 -0
  169. package/src/components/visually-hidden/COMPONENT.md +6 -3
  170. package/src/components/visually-hidden/examples/overview.tsx +2 -2
  171. package/dist/components/composer/format.d.ts +0 -39
@@ -0,0 +1,85 @@
1
+ /** Clients with revenue by month as a compact Sparkline in a cell of each row, next to the half-year total — `cell`, `width`. */
2
+ import { DataTable, type DataTableColumn, Sparkline, type SparklinePoint } from "prime-ui-kit";
3
+
4
+ type Client = { id: string; name: string; manager: string; revenue: number[] };
5
+ type Row = Client & { trend: SparklinePoint[]; total: number };
6
+
7
+ const MONTHS = ["май", "июн", "июл", "авг", "сен", "окт"];
8
+
9
+ const CLIENTS: Client[] = [
10
+ {
11
+ id: "C-118",
12
+ name: "ООО «Северный ветер»",
13
+ manager: "Ольга Смирнова",
14
+ revenue: [184_000, 196_500, 172_300, 210_800, 238_000, 251_400],
15
+ },
16
+ {
17
+ id: "C-102",
18
+ name: "АО «Технопарк»",
19
+ manager: "Игорь Волков",
20
+ revenue: [612_000, 580_400, 545_900, 498_200, 470_000, 455_600],
21
+ },
22
+ {
23
+ id: "C-131",
24
+ name: "ООО «Гранит»",
25
+ manager: "Мария Орлова",
26
+ revenue: [96_000, 88_400, 121_700, 134_000, 129_300, 152_800],
27
+ },
28
+ {
29
+ id: "C-147",
30
+ name: "ИП Козлов",
31
+ manager: "Глеб Котов",
32
+ revenue: [27_900, 31_200, 26_800, 35_400, 33_100, 41_000],
33
+ },
34
+ ];
35
+
36
+ const RUB = new Intl.NumberFormat("ru-RU", {
37
+ style: "currency",
38
+ currency: "RUB",
39
+ maximumFractionDigits: 0,
40
+ });
41
+
42
+ // The series are built once: a new array per render would cross-fade every plot again.
43
+ const ROWS: Row[] = CLIENTS.map((client) => ({
44
+ ...client,
45
+ trend: client.revenue.map((value, i) => ({ label: MONTHS[i] ?? "", value })),
46
+ total: client.revenue.reduce((sum, value) => sum + value, 0),
47
+ }));
48
+
49
+ const COLUMNS: DataTableColumn<Row>[] = [
50
+ { id: "name", header: "Клиент", accessor: "name", sortable: true },
51
+ { id: "manager", header: "Менеджер", accessor: "manager" },
52
+ {
53
+ id: "trend",
54
+ header: "По месяцам",
55
+ width: "10rem",
56
+ cell: (row) => (
57
+ <Sparkline
58
+ layout="compact"
59
+ data={row.trend}
60
+ label={`Выручка по месяцам, ${row.name}`}
61
+ formatValue={(value) => RUB.format(value)}
62
+ />
63
+ ),
64
+ },
65
+ {
66
+ id: "total",
67
+ header: "За полгода",
68
+ accessor: "total",
69
+ sortable: true,
70
+ numeric: true,
71
+ cell: (row) => RUB.format(row.total),
72
+ },
73
+ ];
74
+
75
+ export default function DataTableTrendExample() {
76
+ return (
77
+ <DataTable
78
+ columns={COLUMNS}
79
+ rows={ROWS}
80
+ getRowKey={(row) => row.id}
81
+ getRowLabel={(row) => row.name}
82
+ paging="none"
83
+ />
84
+ );
85
+ }
@@ -30,7 +30,7 @@ Drawer.Root state and dismiss policy (no DOM)
30
30
  └─ Drawer.Content portal + scrim + role="dialog" panel at the side
31
31
  ├─ Drawer.Guard no DOM: holds the drawer (busy / dirty) from inside
32
32
  ├─ Drawer.Header grid: [Icon] [Title + Description] [Actions] [close Button]
33
- │ ├─ Drawer.Icon
33
+ │ ├─ Drawer.Icon tone tile around an Icon, or an Avatar.Root / Thumbnail.Root with no tile
34
34
  │ ├─ Drawer.Title <h2>
35
35
  │ ├─ Drawer.Description <p>
36
36
  │ └─ Drawer.Actions row of tier-s controls before the close button
@@ -82,12 +82,12 @@ No DOM, no ref. State and dismiss policy.
82
82
  `ref` → `HTMLDivElement`. A row of controls at the end of the header, before the close button: a status Badge, a ⋯ Dropdown, an icon button. Controls inside take tier `s`. + native `<div>` props.
83
83
 
84
84
  ### Drawer.Icon
85
- `ref` → `HTMLSpanElement`. An `aria-hidden` 40 px tile with a tone fill.
85
+ `ref` → `HTMLSpanElement`. An `aria-hidden` 40 px tile with a tone fill; around an `Avatar.Root` or a `Thumbnail.Root` (the record's picture) no tile — `data-media`, the avatar sized to 40 px.
86
86
 
87
87
  | Prop | Type | Default | Description |
88
88
  |---|---|---|---|
89
- | `tone` | `"neutral" \| "accent" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Soft fill and icon color. |
90
- | `children` | `ReactNode` | — (required) | Icon glyph (sized to the m icon). |
89
+ | `tone` | `"neutral" \| "accent" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Soft fill and icon color; not used around a picture. |
90
+ | `children` | `ReactNode` | — (required) | An icon glyph (sized to the m icon), or the record's picture: an `Avatar.Root` (sized to the tile) or a `Thumbnail.Root`. |
91
91
  | `…rest` | `Omit<HTMLAttributes<HTMLSpanElement>, "children">` | — | `className` and the other attributes of the tile. |
92
92
 
93
93
  ### Drawer.Title · Drawer.Description
@@ -146,7 +146,7 @@ Below 640px of viewport every size is full width and loses its corners.
146
146
  | `fill` | equal-width buttons in one row | one full-width action («Понятно») | |
147
147
 
148
148
  ### tone (Icon)
149
- `neutral` (default) · `accent` · `success` · `warning` · `danger` · `info` — soft tile fill and icon color, as in Modal.
149
+ `neutral` (default) · `accent` · `success` · `warning` · `danger` · `info` — soft tile fill and icon color, as in Modal. Around the record's picture — an `Avatar.Root` or a `Thumbnail.Root` (found by element type) — there is no tile and no tone: `data-media`, the avatar sized to 40 px, so a person's or an object's card shows its picture in the header.
150
150
 
151
151
  ## States
152
152
  | State | Driven by | DOM |
@@ -206,6 +206,7 @@ Motion: the scrim fades, the panel slides from its side over `slow` and leaves o
206
206
  | [sizes.tsx](examples/sizes.tsx) | Every panel width, 360 to 800 px; below 640 px of viewport the panel takes the full width — `size`. |
207
207
  | [placement.tsx](examples/placement.tsx) | The panel slides from the right for details and from the left for filters, rounded only on the edge facing the page — `side`. |
208
208
  | [header-actions.tsx](examples/header-actions.tsx) | A record card: its status and a ⋯ menu sit in the header before the close button, one tier smaller — `Drawer.Actions`. |
209
+ | [client-card.tsx](examples/client-card.tsx) | A client card from the list: the contact's avatar heads the panel in place of an icon tile, then the details — `Drawer.Icon`, `Avatar.Root`. |
209
210
  | [guard.tsx](examples/guard.tsx) | A form that holds the drawer from inside: after an edit Escape, the scrim and Cancel ask before discarding, the footer says so, and saving locks every way out — `Drawer.Guard`, `Drawer.Meta`. |
210
211
  | [long-content.tsx](examples/long-content.tsx) | A long body scrolls on its own while the header and the footer stay in place — `Drawer.Body`. |
211
212
  | [dismiss.tsx](examples/dismiss.tsx) | An import closes only from its buttons, and not at all while it runs — `closeOnOutsideClick`, `closeOnEscape`. |
@@ -226,4 +227,4 @@ Motion: the scrim fades, the panel slides from its side over `slow` and leaves o
226
227
 
227
228
  ## Related
228
229
  - **Built from:** [Button](../button/COMPONENT.md) (close button), [ScrollContainer](../scroll-container/COMPONENT.md) (`Drawer.Body`), [ConfirmDialog](../confirm-dialog/COMPONENT.md) (the `Drawer.Guard` discard confirm), `Icon` (the close glyph)
229
- - **See also:** [Modal](../modal/COMPONENT.md) (the same header, body, footer and `Guard` in the centre), [Sheet](../sheet/COMPONENT.md) (the same parts raised from the bottom edge for a short task), [ConfirmDialog](../confirm-dialog/COMPONENT.md) (a destructive confirm), [Sidebar](../../layout/sidebar/COMPONENT.md), [Popover](../popover/COMPONENT.md)
230
+ - **See also:** [Modal](../modal/COMPONENT.md) (the same header, body, footer and `Guard` in the centre), [Sheet](../sheet/COMPONENT.md) (the same parts raised from the bottom edge for a short task), [ConfirmDialog](../confirm-dialog/COMPONENT.md) (a destructive confirm), [Sidebar](../../layout/sidebar/COMPONENT.md), [Popover](../popover/COMPONENT.md), [Avatar](../avatar/COMPONENT.md) (a person's picture in `Drawer.Icon`)
@@ -0,0 +1,50 @@
1
+ /** A client card from the list: the contact's avatar heads the panel in place of an icon tile, then the details — `Drawer.Icon`, `Avatar.Root`. */
2
+ import { Avatar, Button, DescriptionList, Drawer } from "prime-ui-kit";
3
+
4
+ const CLIENT = {
5
+ name: "Анна Соколова",
6
+ company: "ООО «Северный ветер»",
7
+ role: "Финансовый директор",
8
+ phone: "+7 495 120-44-18",
9
+ email: "a.sokolova@sev-veter.ru",
10
+ manager: "Игорь Лебедев",
11
+ };
12
+
13
+ export default function DrawerClientCardExample() {
14
+ return (
15
+ <Drawer.Root>
16
+ <Drawer.Trigger>
17
+ <Button.Root variant="soft" tone="neutral">
18
+ Открыть клиента
19
+ </Button.Root>
20
+ </Drawer.Trigger>
21
+ <Drawer.Content>
22
+ <Drawer.Header>
23
+ <Drawer.Icon>
24
+ <Avatar.Root color="blue">
25
+ <Avatar.Fallback>АС</Avatar.Fallback>
26
+ </Avatar.Root>
27
+ </Drawer.Icon>
28
+ <Drawer.Title>{CLIENT.name}</Drawer.Title>
29
+ <Drawer.Description>{CLIENT.company}</Drawer.Description>
30
+ </Drawer.Header>
31
+ <Drawer.Body>
32
+ <DescriptionList.Root>
33
+ <DescriptionList.Item label="Должность">{CLIENT.role}</DescriptionList.Item>
34
+ <DescriptionList.Item label="Телефон">{CLIENT.phone}</DescriptionList.Item>
35
+ <DescriptionList.Item label="Почта">{CLIENT.email}</DescriptionList.Item>
36
+ <DescriptionList.Item label="Менеджер">{CLIENT.manager}</DescriptionList.Item>
37
+ </DescriptionList.Root>
38
+ </Drawer.Body>
39
+ <Drawer.Footer>
40
+ <Drawer.Close>
41
+ <Button.Root variant="outline" tone="neutral">
42
+ Закрыть
43
+ </Button.Root>
44
+ </Drawer.Close>
45
+ <Button.Root>Написать</Button.Root>
46
+ </Drawer.Footer>
47
+ </Drawer.Content>
48
+ </Drawer.Root>
49
+ );
50
+ }
@@ -14,6 +14,7 @@
14
14
  - One choice among options with a heading → [Radio](../radio/COMPONENT.md) `Radio.Group label`, a [SegmentedControl](../segmented-control/COMPONENT.md) or [ButtonGroup](../button-group/COMPONENT.md) `label`.
15
15
  - A bounded block with a surface (a settings panel) → [Card](../card/COMPONENT.md) `Card.Root`; a Fieldset inside it groups the fields.
16
16
  - A page section with a title and actions → [PageContent](../page-content/COMPONENT.md) sections.
17
+ - A group of settings with a title and a trailing control (a «Сбросить» button, a master switch) → `Card.Root variant="panel"` with `Card.Header` (`Card.Title` + the control) and the Fieldset in `Card.Body`, named by the title (`aria-labelledby`), as the settings page pattern names its Fieldset by the section heading.
17
18
  - A single field's label → the field's own `label`.
18
19
 
19
20
  ## Import
@@ -33,9 +33,11 @@
33
33
 
34
34
  ## Import
35
35
  ```tsx
36
- import { FileUpload, type FileUploadFile, useUploadQueue } from "prime-ui-kit";
36
+ import { FileUpload, type FileUploadFile, formatFileSize, useUploadQueue } from "prime-ui-kit";
37
37
  ```
38
38
 
39
+ `formatFileSize(bytes)` writes a size the way the rows do («820 КБ», «1,2 МБ»): use it wherever a file size appears beside the kit's.
40
+
39
41
  ## Anatomy
40
42
  ```
41
43
  FileUpload.Root field frame: label → trigger → files → hint | error
@@ -66,9 +68,9 @@ FileUpload.Item a file row the set does not draw (own actions, a
66
68
  ```
67
69
  The built-in zone body (icon, `labels.title`, `labels.description` and a decorative soft Button) renders when there are no children; the built-in button content is the paperclip and `labels.browse`. The set draws its rows and tiles itself from `value` and `queue`; the `Item` parts are for a row it does not draw (a download button, a report being prepared).
68
70
 
69
- **One set of files.** The files are application data: `value` (`{ id, name, size?, type?, src?, alt? }`, controlled, or `defaultValue`) in its order, then every upload of `queue` that is not in it, in the queue's order. A file of `value` whose `id` is an upload's id shows that upload's state. `type` is the MIME type (without it the kind — picture, video, other — is read from the name's extension); `src` is a picture or a video; `alt` is spoken in place of the name and never drawn. Every change — a reorder by drag or Alt + ← / →, a removal, files added to the queue — goes through `onValueChange` with the whole new list, uploads included; the queue follows (a removed upload leaves it and stops, a moved one keeps its order through `queue.move`). Without `value` and `defaultValue` the set is the queue alone: an uncontrolled list of uploads that reorders through `queue.move`. Return a promise from `onValueChange` to save on a server: the touched files wait busy, a moved file or new files stand in their new places meanwhile, a removed file stays until the answer. Resolve and the change stands (a controlled owner applies `value` before resolving); reject and the set goes back and `labels.changeFailed` is announced.
71
+ **One set of files.** The files are application data: `value` (`{ id, name, size?, type?, src?, alt?, href? }`, controlled, or `defaultValue`) in its order, then every upload of `queue` that is not in it, in the queue's order. A file of `value` whose `id` is an upload's id shows that upload's state. `type` is the MIME type (without it the kind — picture, video, other — is read from the name's extension); `src` is a picture or a video; `alt` is spoken in place of the name and never drawn. `href` is the file's address: the row's name (a neutral `LinkButton`) or the whole tile under its chips becomes a link that opens the file in a new tab — the server's `Content-Disposition` decides open or download. Every change — a reorder by drag or Alt + ← / →, a removal, files added to the queue — goes through `onValueChange` with the whole new list, uploads included; the queue follows (a removed upload leaves it and stops, a moved one keeps its order through `queue.move`). Without `value` and `defaultValue` the set is the queue alone: an uncontrolled list of uploads that reorders through `queue.move`. Return a promise from `onValueChange` to save on a server: the touched files wait busy, a moved file or new files stand in their new places meanwhile, a removed file stays until the answer. Resolve and the change stands (a controlled owner applies `value` before resolving); reject and the set goes back and `labels.changeFailed` is announced.
70
72
 
71
- **The queue.** `useUploadQueue({ upload, concurrency })` owns the uploads: `upload(file, { signal, onProgress })` is yours and returns a Promise of the server's answer; `concurrency` files (3 by default) go at once and the rest wait as `queued`. It returns `{ items, add, retry, remove, clear, move }`. An item is `{ id, file, status, progress, error?, result?, preview? }`: `status` is `queued | loading | done | failed` — the kit's transfer states (`TransferState` without `remote`), so an item maps onto a message part as `transfer={item.status}`; `progress` is 0…1; `error` the rejection's message; `result` what `upload` resolved with; `preview` a local object URL of an image or video file (by MIME type, else by the name's extension). `add(files)` returns the new items; `remove(id)` aborts an upload in flight and revokes its preview; `clear()` takes every file out; `move(id, beforeId)` puts a file in front of another (`null` = last) — the order FileUpload tiles and the Composer tray reorder. `queue` takes `useUploadQueue(…)` or any object of that shape (`items`, `retry`, `remove`, optional `add` and `move`). Hand it to the root and picked or dropped files go into it (only with `add`; `onFilesChange` is not called then) and enter the set at once as their local previews, their `id` the queue item's id:
73
+ **The queue.** `useUploadQueue({ upload, concurrency })` owns the uploads: `upload(file, { id, signal, onProgress })` is yours and returns a Promise of the server's answer; run after-upload work (attach the file to a record, save the set) inside `upload` after the request resolves — `context.id` is the item, and a rejection of that work fails it; `concurrency` files (3 by default) go at once and the rest wait as `queued`. It returns `{ items, add, retry, remove, clear, move }`. An item is `{ id, file, status, progress, error?, result?, preview? }`: `status` is `queued | loading | done | failed` — the kit's transfer states (`TransferState` without `remote`), so an item maps onto a message part as `transfer={item.status}`; `progress` is 0…1; `error` the rejection's message; `result` what `upload` resolved with; `preview` a local object URL of an image or video file (by MIME type, else by the name's extension). `add(files)` returns the new items; `remove(id)` aborts an upload in flight and revokes its preview; `clear()` takes every file out; `move(id, beforeId)` puts a file in front of another (`null` = last) — the order FileUpload tiles and the Composer tray reorder. `queue` takes `useUploadQueue(…)` or any object of that shape (`items`, `retry`, `remove`, optional `add` and `move`). Hand it to the root and picked or dropped files go into it (only with `add`; `onFilesChange` is not called then) and enter the set at once as their local previews, their `id` the queue item's id:
72
74
 
73
75
  ```tsx
74
76
  const queue = useUploadQueue({ upload: sendFile, concurrency: 2 });
@@ -106,7 +108,7 @@ A rejection of `upload` fails the file with its message as the reason; retry que
106
108
  | `trigger` | `"dropzone" \| "button" \| "tile"` | `"dropzone"` | `dropzone`: the drop area. `button`: a compact picker button, no zone and no drop (a composer paperclip). `tile`: an add tile as the last cell of the files. |
107
109
  | `variant` | `"dashed" \| "solid"` | `"dashed"` | The look of the drop zone: `dashed` shows the drop line; `solid` keeps only the fill (cards, modals). |
108
110
  | `size` | `"xs" \| "s" \| "m" \| "l" \| "xl"` | `host tier, else "m"` | Tier of the zone padding, icon, title, button, label, hint and the rows. Without it the tier of its host (a form, a panel), else `m`. Tiles keep their own size. |
109
- | `value` | `readonly FileUploadFile[]` | — | Files in their order (controlled): `{ id, name, size?, type?, src?, alt? }` — on the server, or uploads of `queue` by their ids. `type` is the MIME type; `src` a picture or a video; `alt` is spoken in place of the name. |
111
+ | `value` | `readonly FileUploadFile[]` | — | Files in their order (controlled): `{ id, name, size?, type?, src?, alt?, href? }` — on the server or uploads of `queue` by id. `src`: picture or video; `alt` spoken for the name; `href` links name or tile to the file. |
110
112
  | `defaultValue` | `readonly FileUploadFile[]` | `[]` | Initial files (uncontrolled). |
111
113
  | `onValueChange` | `(value: readonly FileUploadFile[]) => void \| Promise<void>` | — | The set changed (a reorder, a removal, files added to `queue`), with the whole new list. Return the save's promise: the touched files wait busy, a rejection puts the set back and is announced. |
112
114
  | `queue` | `Pick<UploadQueue, "items" \| "retry" \| "remove"> & Partial<Pick<UploadQueue, "add" \| "move">>` | — | The uploads: `useUploadQueue(…)` or an object of that shape. Each file on its way shows its upload; removing a file aborts it. With `add`, picked and dropped files go into the queue. |
@@ -153,7 +155,7 @@ A rejection of `upload` fails the file with its message as the reason; retry que
153
155
 
154
156
  | Prop | Type | Default | Description |
155
157
  |---|---|---|---|
156
- | `value` | `readonly FileUploadFile[]` | — | Files in their order (controlled): `{ id, name, size?, type?, src?, alt? }` — on the server, or uploads of `queue` by their ids. `type` is the MIME type; `src` a picture or a video; `alt` is spoken in place of the name. |
158
+ | `value` | `readonly FileUploadFile[]` | — | Files in their order (controlled): `{ id, name, size?, type?, src?, alt?, href? }` — on the server or uploads of `queue` by id. `src`: picture or video; `alt` spoken for the name; `href` links name or tile to the file. |
157
159
  | `defaultValue` | `readonly FileUploadFile[]` | `[]` | Initial files (uncontrolled). |
158
160
  | `onValueChange` | `(value: readonly FileUploadFile[]) => void \| Promise<void>` | — | The set changed (a reorder, a removal, files added to `queue`), with the whole new list. Return the save's promise: the touched files wait busy, a rejection puts the set back and is announced. |
159
161
  | `queue` | `Pick<UploadQueue, "items" \| "retry" \| "remove"> & Partial<Pick<UploadQueue, "add" \| "move">>` | — | The uploads: `useUploadQueue(…)` or an object of that shape. Each file on its way shows its upload; removing a file aborts it. With `add`, picked and dropped files go into the queue. |
@@ -208,7 +210,7 @@ A rejection of `upload` fails the file with its message as the reason; retry que
208
210
 
209
211
  | Prop | Type | Default | Description |
210
212
  |---|---|---|---|
211
- | `upload` | `(file: File, context: { signal: AbortSignal; onProgress: (progress: number) => void }) => Promise<TResult>` | — (required) | Sends one file. Report the share sent with `onProgress` (0…1), stop on `signal`; resolve with the server's answer (kept as `result`), reject to fail the item with the rejection's message as the reason. |
213
+ | `upload` | `(file: File, context: { id: string; signal: AbortSignal; onProgress: (progress: number) => void }) => Promise<TResult>` | — (required) | Sends one file: progress through `onProgress` (0…1), stop on `signal`, resolve with the answer (`result`), reject to fail. `id` is the item: after-upload work runs here once the request resolves. |
212
214
  | `concurrency` | `number` | `3` | How many files upload at once; the rest wait as `queued`. |
213
215
 
214
216
  ## Variants
@@ -330,6 +332,7 @@ Also `data-size`, `data-variant` on the zone, `data-size` on Item, `data-status`
330
332
  - Icon-only buttons in `ItemActions` need an `aria-label` with the file name; the set names its own («Убрать …», «Повторить загрузку: …»).
331
333
  - A row in flight has its progress bar named by the file; a polite live region speaks a file that finished, the reason of one that failed, a removed file, the new cover after a reorder («Обложка: name», `labels.coverChanged`) and a refused change (`labels.changeFailed`); a file whose change is saved has `aria-busy`.
332
334
  - Tiles are `<li>` of a Dnd.Sortable `<ul>`, named «Фото 2: склад.jpg · Загрузка 45%» («Видео …», «Файл …»; the file's `alt` in place of its name), with `aria-roledescription` and `aria-keyshortcuts`; the position and format on a tile are hidden (the name says them); the disk is a circle button («Отменить загрузку: …» described by the state, «В очереди» / «Загрузка 45%», `aria-busy`, its ring `aria-hidden`; on a failure «Повторить загрузку: …» described by the reason); without a remove handler the disk is no button but a `progressbar` (`aria-valuenow`) while the file is queued or uploading; Dnd's live region announces moves.
335
+ - A file with `href`: in a row its name is a link (`<a target="_blank">`, named by the name); a tile holds a link over its middle named «Открыть name» (`labels.open`), a Tab stop after the tile itself.
333
336
  - Touch: a tile drag starts after a hold; the buttons on a tile are always shown, never hover-only.
334
337
 
335
338
  ### Labels
@@ -355,6 +358,7 @@ Also `data-size`, `data-variant` on the zone, `data-size` on Item, `data-status`
355
358
  | `cancel` | `"Отменить загрузку: {name}"` | Name of the disk that cancels a tile's upload in flight. |
356
359
  | `retry` | `"Повторить загрузку: {name}"` | Name of the retry button of a failed row (the retry disk of a failed tile). |
357
360
  | `remove` | `"Убрать {name}"` | Name of the remove button of a file (it also stops an upload). |
361
+ | `open` | `"Открыть {name}"` | Name of a tile's link to the file (`href`); a row's link is named by the file name. |
358
362
  | `removed` | `"{name}: убран"` | Live region: a file was removed. |
359
363
  | `coverChanged` | `"Обложка: {name}"` | Live region: a reorder put another file first, the new cover (`cover`). |
360
364
  | `changeFailed` | `"Не удалось сохранить изменение, файлы на прежних местах"` | Live region: the `onValueChange` promise of a change rejected and the set is as it was. |
@@ -367,7 +371,7 @@ Also `data-size`, `data-variant` on the zone, `data-size` on Item, `data-status`
367
371
  ## Examples
368
372
  | Example | Shows |
369
373
  |---|---|
370
- | [overview.tsx](examples/overview.tsx) | Documents of a deal: a drop zone, the files already attached as rows, and new files uploading through a queue with progress, a reason and a retry — `label`, `hint`, `multiple`, `defaultValue`, `queue`. |
374
+ | [overview.tsx](examples/overview.tsx) | Documents of a deal: a drop zone, the files already attached as rows whose names open them, and new files uploading through a queue with progress, a reason and a retry — `label`, `hint`, `multiple`, `defaultValue`, `href`, `queue`. |
371
375
  | [variants.tsx](examples/variants.tsx) | How files are added: the dashed drop zone, the zone with only its fill for cards and modals, the compact picker button and the add tile after the files — `variant`, `trigger`. |
372
376
  | [sizes.tsx](examples/sizes.tsx) | Every tier of the zone and of its file rows: padding, icon, button, text and the format badge follow it — `size`. |
373
377
  | [states.tsx](examples/states.tsx) | A default zone, a disabled one whose files are read-only and an invalid one without a message — `disabled`, `invalid`. |
@@ -403,5 +407,5 @@ Also `data-size`, `data-variant` on the zone, `data-size` on Item, `data-status`
403
407
  - Styling a tile from an ancestor (`.gallery li`) → the carried clone is portalled to `<body>` and loses it.
404
408
 
405
409
  ## Related
406
- - **Built from:** [Label](../label/COMPONENT.md) (`label`), [Hint](../hint/COMPONENT.md) (`hint`, `error`, `Description`), [Button](../button/COMPONENT.md) (browse button, `trigger="button"`, row and tile actions, the tile's upload disk `shape="circle"` `variant="shade"`), [Badge](../badge/COMPONENT.md) (`FormatBadge`), [Thumbnail](../thumbnail/COMPONENT.md) (`ItemPreview`, video tiles), [ProgressBar](../progress-bar/COMPONENT.md) (`ItemProgress`), [Dnd](../dnd/COMPONENT.md) and [ScrollContainer](../scroll-container/COMPONENT.md) (tiles), the shared attachment tile (`src/internal/attachmentTile`) and transfer disk (`src/internal/transferDisk`), `Icon`; the files of `queue` come from `useUploadQueue`
410
+ - **Built from:** [Label](../label/COMPONENT.md) (`label`), [Hint](../hint/COMPONENT.md) (`hint`, `error`, `Description`), [LinkButton](../link-button/COMPONENT.md) (a row's name with `href`), [Button](../button/COMPONENT.md) (browse button, `trigger="button"`, row and tile actions, the tile's upload disk `shape="circle"` `variant="shade"`), [Badge](../badge/COMPONENT.md) (`FormatBadge`), [Thumbnail](../thumbnail/COMPONENT.md) (`ItemPreview`, video tiles), [ProgressBar](../progress-bar/COMPONENT.md) (`ItemProgress`), [Dnd](../dnd/COMPONENT.md) and [ScrollContainer](../scroll-container/COMPONENT.md) (tiles), the shared attachment tile (`src/internal/attachmentTile`) and transfer disk (`src/internal/transferDisk`), `Icon`; the files of `queue` come from `useUploadQueue`
407
411
  - **See also:** [Composer](../composer/COMPONENT.md) (its attachment tray is `FileUpload.Queue layout="strip"`), [LinkButton](../link-button/COMPONENT.md) (an inline browse link in a custom body), [Avatar](../avatar/COMPONENT.md), [Thumbnail](../thumbnail/COMPONENT.md), [EmptyPage](../empty-page/COMPONENT.md)
@@ -1,4 +1,4 @@
1
- /** Documents of a deal: a drop zone, the files already attached as rows, and new files uploading through a queue with progress, a reason and a retry — `label`, `hint`, `multiple`, `defaultValue`, `queue`. */
1
+ /** Documents of a deal: a drop zone, the files already attached as rows whose names open them, and new files uploading through a queue with progress, a reason and a retry — `label`, `hint`, `multiple`, `defaultValue`, `href`, `queue`. */
2
2
  import { FileUpload, type FileUploadFile, type UploadContext, useUploadQueue } from "prime-ui-kit";
3
3
 
4
4
  import styles from "./examples.module.css";
@@ -6,8 +6,19 @@ import styles from "./examples.module.css";
6
6
  const MAX_SIZE = 10 * 1024 * 1024;
7
7
 
8
8
  const ATTACHED: readonly FileUploadFile[] = [
9
- { id: "deal-contract", name: "Договор № 48-П.pdf", size: 1_840_000, type: "application/pdf" },
10
- { id: "deal-estimate", name: "Смета.xlsx", size: 240_000 },
9
+ {
10
+ id: "deal-contract",
11
+ name: "Договор № 48-П.pdf",
12
+ size: 1_840_000,
13
+ type: "application/pdf",
14
+ href: "https://files.example.com/deals/48-P/contract.pdf",
15
+ },
16
+ {
17
+ id: "deal-estimate",
18
+ name: "Смета.xlsx",
19
+ size: 240_000,
20
+ href: "https://files.example.com/deals/48-P/estimate.xlsx",
21
+ },
11
22
  ];
12
23
 
13
24
  /** A stand-in for the server: sends a file in a few steps and answers with its address. */
@@ -15,6 +15,7 @@
15
15
  - Records compared by many attributes, sorted and selected → use [DataTable](../data-table/COMPONENT.md).
16
16
  - Work items moved between statuses → use [Kanban](../kanban/COMPONENT.md).
17
17
  - Picking a date or a range → use [Datepicker](../datepicker/COMPONENT.md).
18
+ - Slots within a day (hours, meeting-room bookings, appointments) → not covered: the chart's unit is a calendar day, dates snap to local midnight and the finest zoom is a day.
18
19
 
19
20
  ## Import
20
21
  ```tsx
@@ -29,8 +30,8 @@ Gantt surface (layer of the ladder), size container
29
30
  └─ role="grid"
30
31
  ├─ head row (sticky) corner (sticky) · axis: top tier (months / years) over bottom tier (days / weeks / months), today chip
31
32
  └─ rows backdrop: weekends, column lines, today line
32
- ├─ rowheader (sticky) renderRowAvatar · renderRow (or getRowLabel)
33
- └─ gridcell bars in lanes: <button> + Tooltip, grips at both ends, diamond for a milestone
33
+ ├─ rowheader (sticky) renderRowAvatar · renderRow (or getRowLabel); inside an <a> with getRowHref
34
+ └─ gridcell bars in lanes: <button> + Tooltip (title, dates, renderBarTooltip), grips at both ends, diamond for a milestone
34
35
  ```
35
36
 
36
37
  ## API
@@ -48,6 +49,9 @@ Gantt surface (layer of the ladder), size container
48
49
  | `getRowBars` | `(row: Row) => Bar[]` | — (required) | Bars of a row (`GanttBar`); bars that overlap in time stack into lanes and grow the row. |
49
50
  | `renderRow` | `(row: Row) => ReactNode` | — | First-column content instead of the label (a name with a role or a code). |
50
51
  | `renderRowAvatar` | `(row: Row) => ReactNode` | — | A picture before the row content (an `Avatar`). Below 36rem of chart width the column keeps only it; the name stays for assistive tech. |
52
+ | `getRowHref` | `(row: Row) => string` | — | The row header is a link: a real `<a>` fills the first column and is the row's stop in the grid, so a click, ⌘/Ctrl-click, a middle click and «open in new tab» work on it; Space follows it like Enter. |
53
+ | `onRowClick` | `(row: Row, event: GanttRowClickEvent) => void` | — | Opens a row in place (a profile in a Drawer): a click on its header, Enter or Space; never from a control inside it. With `getRowHref` it gets plain clicks: `event.preventDefault()` routes in the app. |
54
+ | `renderBarTooltip` | `(bar: Bar, row: Row) => ReactNode` | — | Extra lines of a bar's tooltip (a client, hours, an owner), under its title and the dates line — those always stay, the dates and the duration are what the bar means on the axis. |
51
55
  | `scale` | `"day" \| "week" \| "month"` | — | Zoom, controlled: a column per day (months over days), per week (months over weeks) or per month (years over months). |
52
56
  | `defaultScale` | `"day" \| "week" \| "month"` | `"week"` | Initial zoom, uncontrolled. |
53
57
  | `onScaleChange` | `(scale: GanttScale) => void` | — | The zoom switch changed. The day in the middle of the view stays in the middle. |
@@ -85,7 +89,7 @@ One bar of `getRowBars`: data, not a part. Dates are calendar days with an exclu
85
89
  ### scale
86
90
  | Value | Looks like | Use when | Default |
87
91
  |---|---|---|---|
88
- | `day` | a column per day (40 px at `m`), months over day numbers, weekends shaded, today's number in accent | shifts, bookings, a sprint | |
92
+ | `day` | a column per day (40 px at `m`), months over day numbers, weekends shaded, today's number in accent | daily shifts, bookings by the day, a sprint | |
89
93
  | `week` | a day is 16 px at `m`, months over week ranges («6–12»), weekends shaded | a quarter of projects, team occupancy | yes |
90
94
  | `month` | a day is 4 px, years over month names, no weekend wash | a yearly roadmap | |
91
95
 
@@ -133,6 +137,7 @@ A `tone` wins over `color`. Say the status in the label too, never only by color
133
137
  | pending | `onBarChange` returned a Promise | `data-pending`, `aria-busy`, a Spinner; reject → the bar glides back |
134
138
  | loading | `loading` without rows | Skeleton rows with one or two bars each in a real bar's height, radius and lane, under the column and today lines; `aria-busy`, status `labels.loading`; data cross-fades in |
135
139
  | empty | `rows` empty | a compact `EmptyPage` (`empty` or `labels.empty`), cross-faded in |
140
+ | row opens | `onRowClick` / `getRowHref` | `data-clickable` on the row header: a pointer cursor and a hover wash under its content; with `getRowHref` the content is an `<a>` whose hit area fills the header |
136
141
  | scrolled | the axis hides content at an edge | `data-overflow-start` / `data-overflow-end`: an edge shadow from the pinned column / at the end |
137
142
 
138
143
  ## Layout & spacing
@@ -143,6 +148,8 @@ A `tone` wins over `color`. Say the status in the label too, never only by color
143
148
  - Virtualization: rows render only in view (plus an overscan) at offsets computed from their lanes and corrected by measuring, so the total height and the scrollbar stay exact; header cells, period lines and bars render only inside the visible time window plus a screen on each side; weekends and day / week column lines are CSS patterns, not elements. A focused, dragged, pending or new bar stays rendered wherever the chart scrolls.
144
149
  - Editing precision: a move, a resize and a drawing snap to whole days at every zoom (at the month zoom a day is 4 px, so the snap holds a day until the pointer clearly leaves it); hold Alt to snap to the zoom's week or month. A bar is at least one day long: a resize past the opposite edge stops there and never flips. On touch a long press picks a bar up, then it drags like a pointer.
145
150
  - Creating: on empty space the cursor is a crosshair and, on pointer devices, the cell under it is washed with a «+». Drag across a row to draw a range in days, double-click (or long-press on touch) for one unit of the zoom; a drag after the long press draws days. While drawing, a ghost with a dates and duration tooltip follows the pointer. Without `onBarCreate` (or with `readOnly`) nothing can be created and empty space shows no create cursor. Near the edges of the axis a drag, a resize and a drawing scroll the chart.
151
+ - Tooltip: the bar's title, then its dates · duration · progress; `renderBarTooltip` adds lines under them (a client, planned hours) and never replaces them — the dates are what the bar means on the axis, and the same tooltip shows the new dates while dragging.
152
+ - A legend of bar colours goes in `toolbar`: a row of `Badge.Root color` + `Badge.Dot` with the category name, one per hue the bars use; the toolbar wraps it. Say the category in the bar label or the tooltip too, never only by colour.
146
153
  - The default axis covers every bar and today with a unit of room on both sides; pass `range` for a fixed period.
147
154
  - The chart opens with today a third into the view; «Сегодня» and the arrow keys scroll only the chart's scroller, never the page.
148
155
  - A row grows by a lane for every overlapping bar: lanes are packed greedily by start date.
@@ -157,7 +164,7 @@ A `tone` wins over `color`. Say the status in the label too, never only by color
157
164
  | `←` · `→` | Along the row: its header, then its bars in time order. A row off screen is rendered and scrolled into view. |
158
165
  | `↑` · `↓` | From a bar, the bar nearest in time in the row above / below (rows without bars are skipped); from a row header, the next row's header, empty rows too. |
159
166
  | `Home` · `End` | First / last bar of the row; with Ctrl, of the chart. |
160
- | `Enter` · `Space` | Selects the bar (`onSelect`). |
167
+ | `Enter` · `Space` | On a bar: selects it (`onSelect`). On a row header: opens the row (`onRowClick`) or follows its link (`getRowHref`). |
161
168
  | `Shift + ←` · `Shift + →` | Moves the bar by one day, at every zoom. |
162
169
  | `Shift + Alt + ←` · `Shift + Alt + →` | Moves the bar by the zoom's unit (a week, a month). |
163
170
  | `Ctrl + Shift + ←` · `Ctrl + Shift + →` | Shortens / lengthens the bar by one day (⌘ on macOS); it stops at one day. |
@@ -172,6 +179,7 @@ A `tone` wins over `color`. Say the status in the label too, never only by color
172
179
  - While `onBarChange` runs the bar and the grid are `aria-busy`. Loading without rows hides the skeletons and announces `labels.loading`.
173
180
  - The axis tiers, weekends and the today line are `aria-hidden`: the dates live in the bar names.
174
181
  - A row header is a focusable cell; with `onBarCreate` it is described by `labels.createHint` and has `aria-keyshortcuts="N"`. A new bar is announced, a refused one with `labels.createFailed`; the ghost is `aria-hidden`.
182
+ - With `getRowHref` the header's stop in the grid is a real `<a>` named by the row's content inside the `rowheader`: a click, ⌘/Ctrl-click, a middle click and «open in new tab» work, Space follows it like Enter, and the arrows rove from it as from any cell. With `onRowClick` alone the header itself opens the row by a click, Enter or Space; a click on a control inside it never does.
175
183
  - Rows are virtualized: only the rows in view are in the DOM, while `aria-rowcount` on the grid and `aria-rowindex` on each row keep the full count.
176
184
 
177
185
  ### Labels
@@ -206,6 +214,7 @@ A `tone` wins over `color`. Say the status in the label too, never only by color
206
214
  | [sizes.tsx](examples/sizes.tsx) | Every tier: one-lane rows from 28 to 52 px, bars, header and toolbar controls of the tier — `size`. |
207
215
  | [occupancy.tsx](examples/occupancy.tsx) | Team occupancy by day: people with avatars, projects that overlap into lanes, locked vacations, a saved move held busy and a conflicting one gliding back, new work drawn on a free day — `renderRowAvatar`, `renderRow`, `onBarChange`, `onBarCreate`, `onSelect`, `readOnly`. |
208
216
  | [large.tsx](examples/large.tsx) | Two thousand people over two years stay fast: only the rows and days in view are rendered, keyboard focus reaches any row, and new bars are drawn on empty space — `rows`, `range`, `scrollHeight`, `onBarCreate`. |
217
+ | [row-link.tsx](examples/row-link.tsx) | A team by client: each row header is a link to the person's profile that a plain click opens in place, each tooltip adds the client and the planned hours under the dates, and a legend of client hues stands in the toolbar — `getRowHref`, `onRowClick`, `renderBarTooltip`. |
209
218
  | [states.tsx](examples/states.tsx) | Skeleton rows in the chart's geometry while it loads, an empty period and a plan you may only look at — `loading`, `empty`, `readOnly`. |
210
219
  | [controlled.tsx](examples/controlled.tsx) | The zoom owned by the page: presets in the toolbar and the built-in switch change one state — `scale`, `onScaleChange`. |
211
220
  | [narrow.tsx](examples/narrow.tsx) | At 360 px the first column keeps only the avatars, the toolbar moves to its own row and the axis scrolls under the pinned column — `renderRowAvatar`. |
@@ -215,7 +224,8 @@ A `tone` wins over `color`. Say the status in the label too, never only by color
215
224
  - An inclusive `end` (`{ start: 3 July, end: 3 July }` for a one-day task) → that is a milestone; `end` is the day after the last one.
216
225
  - Status only by bar color → put it into the label too («Отпуск», «Просрочено»).
217
226
  - Dates with times expecting hour precision → the chart works in calendar days.
218
- - A `<div onClick>` inside `renderRow` to open a bar's record → use `onSelect`; the row header takes focus but not a press.
227
+ - A `<div onClick>` inside `renderRow` to open a bar's or a row's record → use `onSelect` for a bar and `onRowClick` / `getRowHref` for a row: they work from the keyboard too.
228
+ - Replacing the dates in the tooltip with your own text → `renderBarTooltip` adds lines under them; the dates stay.
219
229
  - Wrapping the chart in a Card → it is a surface itself; place it directly in the page or a Card body only as the card's one content.
220
230
  - Expecting the page to scroll to today → «Сегодня» scrolls only the chart.
221
231
 
@@ -0,0 +1,96 @@
1
+ /** A team by client: each row header is a link to the person's profile that a plain click opens in place, each tooltip adds the client and the planned hours under the dates, and a legend of client hues stands in the toolbar — `getRowHref`, `onRowClick`, `renderBarTooltip`. */
2
+ import { addDays, startOfToday, startOfWeek } from "date-fns";
3
+ import { Badge, Gantt, type GanttBar, type PaletteColor, Typography } from "prime-ui-kit";
4
+ import * as React from "react";
5
+
6
+ type ClientId = "granit" | "tekhnostil" | "kvartal";
7
+
8
+ type Work = GanttBar & { client: ClientId; hours: number };
9
+
10
+ type Person = { id: string; name: string; bars: Work[] };
11
+
12
+ const CLIENTS: Record<ClientId, { name: string; color: PaletteColor }> = {
13
+ granit: { name: "ООО «Гранит»", color: "teal" },
14
+ tekhnostil: { name: "АО «Техностиль»", color: "blue" },
15
+ kvartal: { name: "ООО «Зелёный квартал»", color: "pink" },
16
+ };
17
+
18
+ const WEEK = startOfWeek(startOfToday(), { weekStartsOn: 1 });
19
+
20
+ const work = (
21
+ id: string,
22
+ label: string,
23
+ client: ClientId,
24
+ from: number,
25
+ to: number,
26
+ hours: number,
27
+ ): Work => ({
28
+ id,
29
+ label,
30
+ client,
31
+ hours,
32
+ color: CLIENTS[client].color,
33
+ start: addDays(WEEK, from),
34
+ end: addDays(WEEK, to),
35
+ });
36
+
37
+ const TEAM: Person[] = [
38
+ {
39
+ id: "kotova",
40
+ name: "Анна Котова",
41
+ bars: [
42
+ work("k-1", "Обследование процессов", "granit", -2, 4, 32),
43
+ work("k-2", "ТЗ на интеграцию", "tekhnostil", 5, 12, 40),
44
+ ],
45
+ },
46
+ {
47
+ id: "gordeev",
48
+ name: "Илья Гордеев",
49
+ bars: [
50
+ work("g-1", "Интеграция с 1С", "tekhnostil", 0, 9, 56),
51
+ work("g-2", "Обмен с банком", "kvartal", 10, 16, 36),
52
+ ],
53
+ },
54
+ {
55
+ id: "belova",
56
+ name: "Ольга Белова",
57
+ bars: [
58
+ work("b-1", "Настройка ролей", "granit", 1, 6, 24),
59
+ work("b-2", "Обучение пользователей", "kvartal", 8, 13, 20),
60
+ ],
61
+ },
62
+ ];
63
+
64
+ export default function GanttRowLinkExample() {
65
+ const [opened, setOpened] = React.useState<string | null>(null);
66
+
67
+ return (
68
+ <Gantt<Person, Work>
69
+ aria-label="Работы команды по клиентам"
70
+ rows={TEAM}
71
+ getRowKey={(person) => person.id}
72
+ getRowLabel={(person) => person.name}
73
+ getRowBars={(person) => person.bars}
74
+ getRowHref={(person) => `/team/${person.id}`}
75
+ onRowClick={(person, event) => {
76
+ // A client-side router goes here (navigate(href)); the page itself never reloads.
77
+ event.preventDefault();
78
+ setOpened(person.name);
79
+ }}
80
+ renderBarTooltip={(bar) => `${CLIENTS[bar.client].name} · ${bar.hours} ч по плану`}
81
+ toolbar={
82
+ <>
83
+ {(Object.keys(CLIENTS) as ClientId[]).map((client) => (
84
+ <Badge.Root key={client} color={CLIENTS[client].color}>
85
+ <Badge.Dot />
86
+ {CLIENTS[client].name}
87
+ </Badge.Root>
88
+ ))}
89
+ <Typography as="span" variant="body-s" tone="muted" aria-live="polite">
90
+ {opened ? `Открыт профиль: ${opened}` : null}
91
+ </Typography>
92
+ </>
93
+ }
94
+ />
95
+ );
96
+ }
@@ -37,7 +37,7 @@ Kanban.Root board: horizontal strip of columns, its own
37
37
  │ ├─ description muted line under the title (`column.description`)
38
38
  │ └─ meta the stage's figures (`renderColumnMeta(column)`)
39
39
  └─ <ul> (Dnd.Sortable) own vertical scroll; Skeleton cards while loading; EmptyPage (Dnd `empty`) when empty
40
- └─ Kanban.Item <li> card from renderItem(item)
40
+ └─ Kanban.Item <li> card from renderItem(item); with getItemHref an <a> covers it
41
41
  ├─ Kanban.ItemHeader optional top row over a faint hairline: key · priority (or a full-width Thumbnail cover)
42
42
  ├─ Kanban.ItemTitle title, up to three lines
43
43
  ├─ Kanban.ItemDescription muted meta line: id, due date
@@ -68,12 +68,14 @@ Generic `Kanban.Root<T>`; `ref` → `HTMLDivElement`. The board: a strip of colu
68
68
  | `defaultColumnOrder` | `readonly string[]` | `the order of `columns`` | Initial column ids in their order (uncontrolled). |
69
69
  | `onColumnOrderChange` | `(order: readonly string[], move: KanbanColumnMove) => void \| Promise<void>` | — | A column moved: the new order, then `{ id, index }` to save. Return the save's promise: the column waits busy in its new place; a rejection glides it back. |
70
70
  | `renderItem` | `(item: T) => ReactNode` | — (required) | Renders one card; return a `Kanban.Item`. |
71
+ | `getItemHref` | `(item: T) => string` | — | The card is a link: a real `<a>` named by `getLabel` covers it and is its tab stop — a click, ⌘/Ctrl-click, a middle click, a new tab. A press on it still drags the card; a drag never follows it. |
72
+ | `onItemClick` | `(item: T, event: KanbanItemClickEvent) => void` | — | Opens a card in place (a Drawer): a click, Enter or Space; never from a control inside it or after a drag. With `getItemHref` it gets plain clicks: `event.preventDefault()` routes in the app. |
71
73
  | `renderColumnActions` | `(column: KanbanColumn) => ReactNode` | — | Trailing actions of a column header (add a card, a menu); kit controls inside take the `s` tier. |
72
74
  | `renderColumnMeta` | `(column: KanbanColumn) => ReactNode` | — | The meta line under a column's title and description: the stage's figures («12 сделок · 4,8 млн ₽»). |
73
75
  | `canDrop` | `(id: string, columnId: string) => boolean \| string` | — | A workflow rule: refuses card `id` in column `columnId` — `false`, or a string that says why (spoken and shown under the carried card). |
74
76
  | `variant` | `"well" \| "plain"` | `"well"` | `well`: every column is a filled well with its cards inside. `plain`: no wells — a filled header plate and wider cards carry the column. |
75
77
  | `loading` | `boolean` | `false` | Columns show skeleton cards in the card geometry; the cards cross-fade in when it turns off. |
76
- | `disabled` | `boolean` | `false` | Read-only board: no drag, no keyboard moves, no column grips; clicks on cards still work. |
78
+ | `disabled` | `boolean` | `false` | Read-only board: no drag, no keyboard moves, no column grips; `onItemClick` and `getItemHref` still open cards. |
77
79
  | `labels` | `Partial<KanbanLabels>` | — | Built-in strings, see Labels. |
78
80
  | `…rest` | `Omit<HTMLAttributes<HTMLDivElement>, "children" \| "defaultValue">` | — | `className` (the board height), `aria-label` and the other div attributes. |
79
81
 
@@ -83,7 +85,7 @@ Generic `Kanban.Root<T>`; `ref` → `HTMLDivElement`. The board: a strip of colu
83
85
  | Prop | Type | Default | Description |
84
86
  |---|---|---|---|
85
87
  | `disabled` | `boolean` | `false` | This card cannot be dragged or moved. |
86
- | `…rest` | `Omit<HTMLAttributes<HTMLElement>, "id">` | — | `children` (the card parts), `className`, `onClick` and the other attributes. |
88
+ | `…rest` | `Omit<HTMLAttributes<HTMLElement>, "id">` | — | `children` (the card parts), `className` and the other attributes; `onClick` / `onKeyDown` run before `onItemClick` opens the card — `preventDefault()` keeps it closed. |
87
89
 
88
90
  ### Kanban.ItemHeader
89
91
  `ref` → `HTMLDivElement`. Optional top row of a card: the key or project at the start, the last child (a priority `Badge`, an assignee) at the end.
@@ -154,6 +156,7 @@ Generic `Kanban.Root<T>`; `ref` → `HTMLDivElement`. The board: a strip of colu
154
156
  | loading | `loading` | `aria-busy` and `data-loading` on the root; `Skeleton` cards; `Crossfade` swaps them for the cards |
155
157
  | disabled | `disabled` / `Kanban.Item disabled` | `data-disabled` on the root / card; no `aria-roledescription`, no shortcuts, no column grips |
156
158
  | saving | `onValueChange` / `onColumnOrderChange` returned a pending promise | the card (column) stays in its new place with `aria-busy` and `data-pending`; a card's content quiets to 0.6 under a centred `Spinner`, a column's heading quiets and a spinner stands in for its grip; it does not move again until the answer |
159
+ | opens | `onItemClick` / `getItemHref` | `data-clickable` (in place) or `data-link` (a link over the card, its focus ring drawn on the card); a card that opens lifts on hover on a read-only board too |
157
160
  | refused save | the promise rejected | the card glides back to its old column and place (every card it pushed aside glides too), a column glides back in the strip; `labels.moveFailed` / `labels.columnMoveFailed` is announced |
158
161
 
159
162
  Motion: the hover lift (shadow, the card rises above its neighbours) and the empty slot fade (`fast`, `standard`); a card dropped under the mouse lands straight into its hover lift (`data-dnd-hover`); the swap from skeletons is `Crossfade`; drag motion is Dnd's (lift, gap, glide, landing); a saving card or column quiets (`fast`, both ways) and its spinner enters (`enterMotion`); a refused save glides back (`base`), never jumps. Under `prefers-reduced-motion` all of it is off.
@@ -178,6 +181,7 @@ A card's own `onKeyDown` runs before the board's: `preventDefault()` in it keeps
178
181
  | Key | Action |
179
182
  |---|---|
180
183
  | `Tab` | Moves through the cards, column by column. |
184
+ | `Enter` · `Space` | Opens the focused card (`onItemClick`) or follows its link (`getItemHref`). |
181
185
  | `Alt + ↑` · `Alt + ↓` | Moves the card one place inside its column. |
182
186
  | `Alt + ←` · `Alt + →` | On a card: moves it to the neighbouring column at the same row (or its end). On a column grip: moves the column one place. Focus follows. The direction follows the writing direction (`dir="rtl"`: ← goes forward). |
183
187
  | `Escape` | Cancels a drag in flight. |
@@ -187,6 +191,7 @@ A card's own `onKeyDown` runs before the board's: `preventDefault()` in it keeps
187
191
  - The column grip is a button named «Переместить колонку «…»» with `aria-keyshortcuts`; a keyboard column move is announced with its position («Колонка «…»: позиция 2 из 4», `labels.column`). A locked column has a lock named «Закреплено» instead.
188
192
  - A live region (`role="status"`) announces grabbed, dropped, «position N of M», a move to another column by its title and a refusal with its reason (`labels`, Dnd's labels).
189
193
  - The count is spoken as text («Карточек: 3 из 5»), not only by colour; a full column also reads `count / limit`.
194
+ - With `getItemHref` a real `<a>` named by `getLabel` covers the card and is its tab stop: a click, ⌘/Ctrl-click, a middle click and «open in new tab» work anywhere on the card, Space follows it like Enter, Alt + arrows move the card from it and a press on it drags the card; the release of a drag never follows it. Controls inside the card stand above the link. With `onItemClick` alone the card opens by a click, Enter or Space; a control inside the card never opens it, and a card that opens stays a tab stop on a `disabled` board.
190
195
  - Touch: a drag starts after a hold; a finger that moves first scrolls the board.
191
196
  - While loading the root has `aria-busy`; skeletons are hidden from assistive tech.
192
197
  - While a move is saved the card or column has `aria-busy` (its spinner is hidden from assistive tech); a refused save is announced (`labels.moveFailed`, `labels.columnMoveFailed`).
@@ -216,6 +221,7 @@ A card's own `onKeyDown` runs before the board's: `preventDefault()` in it keeps
216
221
  | [limits.tsx](examples/limits.tsx) | A support queue with a work-in-progress limit and a workflow rule: a full column and a blocked ticket are refused before the release with the reason under the card, from the keyboard too — `limit`, `canDrop`. |
217
222
  | [columns.tsx](examples/columns.tsx) | A sales pipeline whose stages move by the grip in their header while the first and the last stay locked; each stage carries its color on the count, a rule and its figures under the title, and is edited in a popover — `reorderableColumns`, `onColumnOrderChange`, `locked`, `color`, `description`, `renderColumnMeta`, `renderColumnActions`. |
218
223
  | [saving.tsx](examples/saving.tsx) | Every move is saved on the server: the card waits busy in its new place, and an invoice the server refuses to close glides back to its column — `onValueChange`. |
224
+ | [item-link.tsx](examples/item-link.tsx) | Tickets that open: each card is a link to its page, so ⌘-click or a middle click opens a new tab, a plain click, Enter or Space opens it in place, and a drag only moves it — `getItemHref`, `onItemClick`. |
219
225
  | [states.tsx](examples/states.tsx) | Loading draws skeleton cards in the card geometry and cross-fades the cards in; an empty column keeps its drop area; a read-only board drags nothing — `loading`, `disabled`. |
220
226
  | [controlled.tsx](examples/controlled.tsx) | The placement lives in the parent: every move arrives with its source, target and position to save, and a button resets the board — `value`, `onValueChange`. |
221
227
  | [narrow.tsx](examples/narrow.tsx) | On a phone-width screen a column takes 85% of the board and snaps into place, so the next one peeks; the strip scrolls inside the board, never the page. |
@@ -225,7 +231,9 @@ A card's own `onKeyDown` runs before the board's: `preventDefault()` in it keeps
225
231
  - Using the array index as the card id → ids must survive a move.
226
232
  - Keeping the status only on the item and no `value` → the board draws nothing; build `defaultValue` (or `value`) from your data: `{ [status]: ids }`.
227
233
  - No height on the root inside a fixed region → the page scrolls instead of the column.
228
- - A hover-only action on a card → touch has no hover; put the action in the card or open the card on click.
234
+ - A hover-only action on a card → touch has no hover; put the action in the card or open the card on click (`onItemClick`).
235
+ - `onClick` / `onKeyDown` on `Kanban.Item` to open a card → `onItemClick` (click, Enter, Space, never after a drag) or `getItemHref` for a page of its own.
236
+ - `getItemHref` without `getLabel` → the link is named by the card id; pass a human name.
229
237
  - Styling a card from an ancestor (`.board .card`) → the flying clone is portalled to `<body>` and loses it; put the class on `Kanban.Item`.
230
238
  - Keeping the column order in `columns` and reordering that array → pass `columnOrder` / `onColumnOrderChange`; `columns` stays the data.
231
239
  - Putting a system stage back first after every move → `locked: true` on that column.