prime-ui-kit 0.11.0 → 0.12.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 (57) hide show
  1. package/README.md +2 -1
  2. package/bin/prime-ui-kit.js +0 -0
  3. package/dist/chunks/{chunk-GKX3QAUC.js → chunk-OTOIOHVE.js} +1 -1
  4. package/dist/chunks/{chunk-WDTSHOTD.js → chunk-VOZ7J72X.js} +7 -2
  5. package/dist/chunks/{chunk-CUH6DJ57.js → chunk-YJO3NJIJ.js} +716 -548
  6. package/dist/color-picker.js +2 -2
  7. package/dist/components/index.d.ts +2 -0
  8. package/dist/components/list/List.d.ts +105 -0
  9. package/dist/index.css +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +6 -2
  12. package/dist/internal/VisuallyHidden.d.ts +6 -1
  13. package/dist/skill/api-contract.md +1 -1
  14. package/dist/skill/cheatsheet.md +1 -1
  15. package/dist/skill/choosing.md +4 -2
  16. package/dist/skill/components.md +2 -1
  17. package/dist/skill/composition.md +8 -0
  18. package/dist/skill/layouts.md +2 -1
  19. package/dist/skill/patterns/app-shell.module.css +18 -0
  20. package/dist/skill/patterns/app-shell.tsx +301 -0
  21. package/dist/skill/patterns/chat.module.css +0 -16
  22. package/dist/skill/patterns/chat.tsx +16 -19
  23. package/dist/skill/patterns/kanban-board.module.css +28 -0
  24. package/dist/skill/patterns/kanban-board.tsx +618 -0
  25. package/dist/skill/patterns/media-library.module.css +66 -0
  26. package/dist/skill/patterns/media-library.tsx +638 -0
  27. package/dist/skill/patterns/schedule.module.css +22 -0
  28. package/dist/skill/patterns/schedule.tsx +710 -0
  29. package/dist/skill/reference/components/file-upload/COMPONENT.md +3 -1
  30. package/dist/skill/reference/components/list/COMPONENT.md +180 -0
  31. package/dist/skill/reference/components/list/examples/as-link.tsx +37 -0
  32. package/dist/skill/reference/components/list/examples/examples.module.css +18 -0
  33. package/dist/skill/reference/components/list/examples/overflow.tsx +46 -0
  34. package/dist/skill/reference/components/list/examples/overview.tsx +63 -0
  35. package/dist/skill/reference/components/list/examples/sizes.tsx +35 -0
  36. package/dist/skill/reference/components/list/examples/states.tsx +27 -0
  37. package/dist/skill/reference/components/list/examples/with-icon.tsx +60 -0
  38. package/dist/skill/reference/components/page-content/COMPONENT.md +2 -2
  39. package/dist/skill/reference/components/page-content/examples/examples.module.css +5 -12
  40. package/dist/skill/reference/components/page-content/examples/list-detail.tsx +13 -18
  41. package/dist/skill/reference/components/visually-hidden/COMPONENT.md +6 -3
  42. package/dist/skill/reference/components/visually-hidden/examples/overview.tsx +2 -2
  43. package/package.json +3 -3
  44. package/src/components/file-upload/COMPONENT.md +3 -1
  45. package/src/components/list/COMPONENT.md +180 -0
  46. package/src/components/list/examples/as-link.tsx +37 -0
  47. package/src/components/list/examples/examples.module.css +18 -0
  48. package/src/components/list/examples/overflow.tsx +46 -0
  49. package/src/components/list/examples/overview.tsx +63 -0
  50. package/src/components/list/examples/sizes.tsx +35 -0
  51. package/src/components/list/examples/states.tsx +27 -0
  52. package/src/components/list/examples/with-icon.tsx +60 -0
  53. package/src/components/page-content/COMPONENT.md +2 -2
  54. package/src/components/page-content/examples/examples.module.css +5 -12
  55. package/src/components/page-content/examples/list-detail.tsx +13 -18
  56. package/src/components/visually-hidden/COMPONENT.md +6 -3
  57. package/src/components/visually-hidden/examples/overview.tsx +2 -2
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./components/index";
2
2
  export * from "./hooks/useTokenValue";
3
3
  export * from "./hooks/useUploadQueue";
4
4
  export * from "./icons/index";
5
+ export { formatFileSize } from "./internal/fileSize";
5
6
  export { hueOf } from "./internal/palette";
6
7
  export * from "./internal/states";
7
8
  export type { TransferDirection, TransferHandler, TransferState } from "./internal/transfer";
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  Kanban,
34
34
  Kbd,
35
35
  LinkButton,
36
+ List,
36
37
  LoginForm,
37
38
  MediaViewer,
38
39
  Message,
@@ -67,6 +68,7 @@ import {
67
68
  chartColors,
68
69
  datepickerPresets,
69
70
  formatDatepickerValue,
71
+ formatFileSize,
70
72
  formatMessageDate,
71
73
  formatMessageTime,
72
74
  getWaveform,
@@ -81,7 +83,7 @@ import {
81
83
  useSidebar,
82
84
  useTokenValue,
83
85
  useUploadQueue
84
- } from "./chunks/chunk-CUH6DJ57.js";
86
+ } from "./chunks/chunk-YJO3NJIJ.js";
85
87
  import {
86
88
  Avatar,
87
89
  Badge,
@@ -113,7 +115,7 @@ import {
113
115
  textTones,
114
116
  tones,
115
117
  variants
116
- } from "./chunks/chunk-WDTSHOTD.js";
118
+ } from "./chunks/chunk-VOZ7J72X.js";
117
119
  import {
118
120
  ControlSizeProvider,
119
121
  useOptionalControlSize
@@ -164,6 +166,7 @@ export {
164
166
  Kbd,
165
167
  Label,
166
168
  LinkButton,
169
+ List,
167
170
  LoginForm,
168
171
  MediaViewer,
169
172
  Message,
@@ -210,6 +213,7 @@ export {
210
213
  dataStates,
211
214
  datepickerPresets,
212
215
  formatDatepickerValue,
216
+ formatFileSize,
213
217
  formatMessageDate,
214
218
  formatMessageTime,
215
219
  getPasswordStrength,
@@ -1,5 +1,10 @@
1
1
  import type * as React from "react";
2
2
  export type VisuallyHiddenProps = React.HTMLAttributes<HTMLSpanElement> & {
3
+ /**
4
+ * An announcement region: `polite` — `role="status"`, read after what is being said; `assertive`
5
+ * — `role="alert"`, read at once. Both are atomic: the whole text is read on every change.
6
+ */
7
+ live?: "polite" | "assertive";
3
8
  ref?: React.Ref<HTMLSpanElement>;
4
9
  };
5
10
  /** The sr-only recipe as a class, for an element that must stay itself (a focusable file input). */
@@ -9,7 +14,7 @@ export declare const visuallyHiddenClass: string;
9
14
  * Public as `VisuallyHidden` (docs in `src/components/visually-hidden/`); inside the kit it replaces
10
15
  * the per-component `.srOnly` / `.visuallyHidden` CSS copies.
11
16
  */
12
- export declare function VisuallyHidden({ className, ...rest }: VisuallyHiddenProps): import("react/jsx-runtime").JSX.Element;
17
+ export declare function VisuallyHidden({ className, live, ...rest }: VisuallyHiddenProps): import("react/jsx-runtime").JSX.Element;
13
18
  export declare namespace VisuallyHidden {
14
19
  var displayName: string;
15
20
  }
@@ -235,7 +235,7 @@ it saves).
235
235
  | `Icon`, `createIcon` | kit icon set and domain glyphs — see Icons below |
236
236
  | `chartColors` | the chart palette as CSS values for a chart library: `series` 1…8 in order, `other`, `grid`, `axis`, `scale` — follows the theme |
237
237
  | `useTokenValue("--prime-space-7")` | a length token in px for geometry CSS cannot take (SVG coordinates, canvas, virtual rows); follows a theme switch |
238
- | `VisuallyHidden` | text for screen readers only (an icon-only part's name, a spoken count); `role="status"` on it for a polite announcement |
238
+ | `VisuallyHidden` | text for screen readers only (an icon-only part's name, a spoken count); `live="polite"` on it for an announcement (`"assertive"` only for an error that blocks) |
239
239
 
240
240
  ## Icons
241
241
 
@@ -163,7 +163,7 @@ Full rules: [responsive.md](responsive.md).
163
163
  - Breakpoints 640 · 768 · 1024 · 1280 only for the app frame; inside a page — intrinsic grids (`auto-fill, minmax`) and container queries.
164
164
  - A wide table scrolls inside itself; offer `DataTable.ColumnsMenu` in its `toolbar` — never drop columns by breakpoint, never hand-build the chooser.
165
165
  - A row that opens a record page is `getRowHref` (a real link: ⌘-click, new tab), a row that opens a drawer is `onRowClick` — never `navigate()` in `onRowClick`, never your own `event.target` checks for buttons in the row.
166
- - A list + detail screen is `PageContent.Split` in a `PageContent.Root fill` page; a full-height table is `scrollHeight="fill"` — never a height computed from `100vh`.
166
+ - A list + detail screen is `PageContent.Split` in a `PageContent.Root fill` page, its records a `List` (`current` on the open one) — never ghost Buttons with a soft current; a full-height table is `scrollHeight="fill"` — never a height computed from `100vh`.
167
167
  - App sections on phones: `BottomNav` in `AppShell.Footer` — not in Main, not `position: fixed`, no own media query: the shell hides the footer while the Sidebar rail is on screen; a short task from the bottom edge: `Sheet` — Select, Dropdown, Popover and Modal become sheets below 640 by themselves.
168
168
  - Your hover styles inside `@media (hover: hover)`; nothing hover-only. Do not wrap kit fields to raise their text for iOS — they keep 16px on touch already.
169
169
  - A long form saves through the panel action with a count («Сохранить 3 изменения») — not a sticky bar at the bottom of a phone.
@@ -13,7 +13,7 @@ confuse. Links: [components.md](components.md). Whole screens: [composition.md](
13
13
  | see data: status labels, people, objects, numbers and their trend, properties of a record, rows, a board, a schedule, events, code, disclosed sections | Data display — Badge, Avatar, Thumbnail, Kbd, Card, DescriptionList, DataTable, Kanban, Gantt, Timeline, CodeBlock, Accordion, Sparkline, ChartFrame |
14
14
  | hold a conversation: read and send chat messages of every kind | Messages — Chat, Message, Gallery, VoicePlayer, VideoNote, Composer |
15
15
  | learn what happened, how far along it is, or watch a region load and change state; say it to a screen reader | Status and loading — Banner, Notification, ProgressBar, ProgressCircle, Spinner, Skeleton, Crossfade, EmptyPage, VisuallyHidden |
16
- | move between places: app sections, panels of a screen, the path, pages, steps | Navigation — Tabs, Breadcrumb, Pagination, Stepper, Sidebar, BottomNav |
16
+ | move between places: app sections, panels of a screen, the records of a list, the path, pages, steps | Navigation — Tabs, Breadcrumb, Pagination, List, Stepper, Sidebar, BottomNav |
17
17
  | see something on top of the page, or a photo or video full screen | Overlays — Tooltip, Popover, Dropdown, Modal, ConfirmDialog, Drawer, Sheet, CommandMenu, MediaViewer |
18
18
  | get the app frame and the structure of a page | Page — AppShell, AppHeader, PageContent, PageToolbar, Divider, ScrollContainer |
19
19
  | drag things by hand | Interaction — Dnd (a status board: Kanban in Data display) |
@@ -125,7 +125,8 @@ filter above a single table goes into the DataTable `toolbar` instead. A page th
125
125
  height to the work (a board, a chat) may let `AppHeader` carry the page instead: `AppHeader.Title
126
126
  as="h1"` and the PageToolbar in `AppHeader.Tools` — then main has no `PageContent.Header`. A record
127
127
  opened from a table: its own page → DataTable `getRowHref`; in place → `onRowClick` + Drawer; a list
128
- worked through record by record (mail, chat, tickets) → `PageContent.Split`. Sections of a long rail → `Sidebar.Group
128
+ worked through record by record (mail, chat, tickets) → `PageContent.Split` with a `List` of the records
129
+ (`current` on the open one). Sections of a long rail → `Sidebar.Group
129
130
  collapsible`; a section with sub-pages → `Sidebar.Sub`; the signed-in user → `Sidebar.Account` in a
130
131
  `Dropdown.Trigger` at the bottom of `Sidebar.Footer`.
131
132
 
@@ -133,6 +134,7 @@ collapsible`; a section with sub-pages → `Sidebar.Sub`; the signed-in user →
133
134
 
134
135
  | Pair | Rule |
135
136
  |---|---|
137
+ | List / Sidebar / Tabs / Card list / DataTable | List: the records of a list + detail screen (dialogs, tickets, mail), one open at a time (`current`), each row a button or a link with a text line, a preview, meta and a count. Sidebar: the sections of the **app**. Tabs: panels of **one screen**. `Card.List` (`variant="list"`): static facts in a card, nothing opens. DataTable: many records compared by columns, sorted, filtered, selected in bulk. |
136
138
  | Tabs / SegmentedControl | Tabs switch **panels of content** (navigation inside a screen). SegmentedControl switches a **value or mode** of the same content (period, view mode, filter). |
137
139
  | Modal / Drawer / Sheet / Popover / Tooltip | Modal: blocking decision or a short form (≤ 4 fields) that needs full attention. Drawer: long form, filters, record details beside the page, while keeping page context. Sheet: a short task that ends by itself — a pick, an action, a short note — raised from the bottom edge. Popover: small non-modal panel anchored to a control (quick edit, explanation with a link). Tooltip: a few words naming or explaining a control, no interactive content. |
138
140
  | Select / NativeSelect | Select: the kit's floating list — icons, descriptions, groups, search, `multiple`, `clearable`. NativeSelect: a plain text list where the OS picker is better (phones) or the value must post with a form by `name`. |
@@ -103,13 +103,14 @@ Say what happens: system messages, progress, loading placeholders, state swaps,
103
103
 
104
104
  ## Navigation (`navigation`)
105
105
 
106
- Move between places: app navigation (side rail, bottom bar on phones), tabs, the path, pages, steps.
106
+ Move between places: app navigation (side rail, bottom bar on phones), tabs, the records of a list, the path, pages, steps.
107
107
 
108
108
  | Component | Purpose | Docs |
109
109
  |---|---|---|
110
110
  | Tabs | Tabs for navigating between content panels of one screen. | [COMPONENT.md](reference/components/tabs/COMPONENT.md) · [examples](reference/components/tabs/examples/) |
111
111
  | Breadcrumb | Breadcrumbs: the path to the current page. | [COMPONENT.md](reference/components/breadcrumb/COMPONENT.md) · [examples](reference/components/breadcrumb/examples/) |
112
112
  | Pagination | Page-by-page navigation: arrows, page numbers with ellipsis and a compact «3 / 12» view. | [COMPONENT.md](reference/components/pagination/COMPONENT.md) · [examples](reference/components/pagination/examples/) |
113
+ | List | A selectable list of rows — the list half of a list + detail screen: conversations, tickets, mail, records; the open one is `current`. | [COMPONENT.md](reference/components/list/COMPONENT.md) · [examples](reference/components/list/examples/) |
113
114
  | Stepper | Steps of a multi-step process with pending, active, completed and danger statuses. | [COMPONENT.md](reference/components/stepper/COMPONENT.md) · [examples](reference/components/stepper/examples/) |
114
115
  | Sidebar | App side navigation in three modes — expanded, compact, hidden — and an off-canvas panel on narrow screens. | [COMPONENT.md](reference/layout/sidebar/COMPONENT.md) · [examples](reference/layout/sidebar/examples/) |
115
116
  | BottomNav | Phone navigation: 2–6 sections at the bottom, flat or a floating glass capsule, icons with or without labels, an optional round action. | [COMPONENT.md](reference/layout/bottom-nav/COMPONENT.md) · [examples](reference/layout/bottom-nav/examples/) |
@@ -18,6 +18,10 @@ is, and is shown live with its code in the playground (category «Компози
18
18
  | Dashboard | [dashboard.tsx](patterns/dashboard.tsx) | period switch, KPI row that cross-fades to the new period, a Sparkline revenue trend, invoiced vs paid in a ChartFrame, panels with progress and a split, short table + link to the list |
19
19
  | Screen states | [screen-states.tsx](patterns/screen-states.tsx) | page error in a Banner with retry, region loading with a Skeleton, Crossfade between states, table error in place, first-run EmptyPage |
20
20
  | Messenger | [chat.tsx](patterns/chat.tsx) | PageContent.Split of dialogs and a `Chat`: `Chat.Header` with the back button, avatar, title, the status that turns into «печатает…» (`typing`) and the search button, `Chat.Search`, `Chat.Pinned`, `Chat.Messages` from `items` + a stable `renderMessage` (dates, runs, the unread band and service events from the data, history loaded above with `hasOlder` / `onLoadOlder`), every kind of Message (photos, file, contact, voice in a VoicePlayer with a transcript, round video), reactions, reply and edit from the menu through `Composer.Context value` (`replyTo` / `editOf`), the jump to the quoted message (`Message.Reply target`), optimistic sends whose messages rise from the composer while the client's answer slides in, «печатает…» both ways (`onActivityChange` → `Chat.Root typing`), unread counts from `onNewMessage` and `onRead`, selection with `Chat.SelectionBar`, delivery ticks, the Composer in `Chat.Footer` |
21
+ | Sales pipeline board | [kanban-board.tsx](patterns/kanban-board.tsx) | header with one primary action, PageToolbar with SmartFilter search and a board / list SegmentedControl, Crossfade between a Kanban and a DataTable of the same deals, column totals in `renderColumnMeta`, moves saved through an `onValueChange` promise that the server can refuse, movable stages with locked first and last, the deal in a Drawer with a DescriptionList and stage actions, skeleton first load |
22
+ | App frame for desktop and phone (root layout) | [app-shell.tsx](patterns/app-shell.tsx) | Sidebar rail on a wide screen, floating BottomNav in `AppShell.Footer` on a phone («Ещё» opens the Sidebar panel), AppHeader with the menu button, title and search to CommandMenu, counts on items, a home page that cross-fades between sections |
23
+ | Media library | [media-library.tsx](patterns/media-library.tsx) | PageToolbar with sections and counts, search (`SmartFilter` with `fields={[]}`), order and «Загрузить»; uploads through `useUploadQueue` in `FileUpload` tiles (`trigger="tile"`, `layout="grid"`) with progress, a failure and retry, a done file moving into the library; a grid of `Thumbnail` / `Thumbnail.Video` tiles that open `MediaViewer` out of the tile (`getOrigin`, `thumbnails`, `renderActions`) or, in selection mode, are picked (`checked`) for bulk download and a `ConfirmDialog` delete; a Skeleton first load and a filtered `EmptyPage` in a `Crossfade` |
24
+ | Resource schedule | [schedule.tsx](patterns/schedule.tsx) | full-height `Gantt` (`scrollHeight="fill"`) under a `PageToolbar` with search, role / client filters and team sections; people with avatars, client hues, lanes for overlaps, locked absences and milestones; moves saved through an async `onBarChange` or refused and glided back, new work drawn with `onBarCreate` and named in a Modal (cancel fades the ghost), `onSelect` details in a Drawer with a `DescriptionList` and a confirmed removal, `loading` and filtered `empty` |
21
25
 
22
26
  Pick the closest one, rename the domain, keep the skeleton. Change structure only when the task needs
23
27
  it, and then by the rules below.
@@ -176,6 +180,10 @@ Hierarchy comes from space and a handful of type roles, not from size jumps, col
176
180
 
177
181
  - A list page is `DataTable` with the filters in its `toolbar` — `SmartFilter.Toolbar` and
178
182
  `SmartFilter.Chips` inside `SmartFilter.Root` (or one SegmentedControl / Select for a single field).
183
+ - Where the filters go: tools of one table live in its `toolbar`; when the page itself carries
184
+ sections with counts, a view switch (board / list, grid / list) or filters that drive more than one
185
+ table — a board, a schedule, a media grid — the page gets `PageToolbar` and the table keeps only its
186
+ own tools (`DataTable.ColumnsMenu`).
179
187
  - Paging is built in: `paging="pages"` (default, Pagination and the «Показано 1–6 из 10» range in the
180
188
  footer), `paging="infinite"` for feeds, `paging="none"` for short lists on dashboards and details.
181
189
  - When the server holds more than you load (a CRM list, a register): `source="server"` — `rows` is the
@@ -191,7 +191,8 @@ from what people do, not from how the backend is split. The full guide with the
191
191
  `PageContent.Detail` in a filling page. Side by side from 48rem of the split's own width, one pane
192
192
  at a time below it with `PageContent.BackButton` first in the detail's header; `open` is «a record
193
193
  is chosen». Each pane gives its last child the free height: a Card with its own `ScrollContainer`
194
- (`anchor="end"` for a thread). Details that open over a table and close again are a Drawer instead.
194
+ (`anchor="end"` for a thread). The records in the list pane are a `List` (`current` on the open
195
+ one). Details that open over a table and close again are a Drawer instead.
195
196
 
196
197
  ## Which pattern to start from
197
198
 
@@ -0,0 +1,18 @@
1
+ /* App shell: the frame (Sidebar / BottomNav / AppHeader) lays itself out; this file only places the
2
+ home page's blocks. Blocks 40 apart come from PageContent.Body. */
3
+
4
+ /* KPI row: as many ~14rem tiles as fit, one column on a phone. */
5
+ .kpis {
6
+ display: grid;
7
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--prime-space-14) * 4)), 1fr));
8
+ gap: var(--prime-space-4);
9
+ }
10
+
11
+ /* A list row: the text at the start, the meta (time, sum) at the end. */
12
+ .row {
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: space-between;
16
+ gap: var(--prime-space-4);
17
+ min-width: 0;
18
+ }
@@ -0,0 +1,301 @@
1
+ /** The app frame for desktop and phone: the Sidebar rail on a wide screen, the floating BottomNav in the footer on a phone, the AppHeader with the menu button, the title and search that opens the CommandMenu, and a simple home page that swaps with the current section. */
2
+ import {
3
+ AppHeader,
4
+ AppShell,
5
+ BottomNav,
6
+ Card,
7
+ Checkbox,
8
+ CommandMenu,
9
+ Crossfade,
10
+ Icon,
11
+ type IconName,
12
+ PageContent,
13
+ Sidebar,
14
+ Typography,
15
+ } from "prime-ui-kit";
16
+ import * as React from "react";
17
+
18
+ import styles from "./app-shell.module.css";
19
+
20
+ type SectionId = "today" | "deals" | "clients" | "tasks" | "reports" | "settings";
21
+ type Section = { id: SectionId; label: string; icon: IconName };
22
+ type Row = { id: string; title: string; meta: string };
23
+
24
+ /** The main sections: the Sidebar's first group on a wide screen, the bar on a phone. */
25
+ const MAIN: Section[] = [
26
+ { id: "today", label: "Сегодня", icon: "nav.home" },
27
+ { id: "deals", label: "Сделки", icon: "object.funnel" },
28
+ { id: "clients", label: "Клиенты", icon: "object.users" },
29
+ { id: "tasks", label: "Задачи", icon: "object.tasks" },
30
+ ];
31
+
32
+ /** The rest of the map: in the Sidebar footer; on a phone «Ещё» opens the Sidebar panel. */
33
+ const MORE: Section[] = [
34
+ { id: "reports", label: "Отчёты", icon: "object.chart" },
35
+ { id: "settings", label: "Настройки", icon: "action.settings" },
36
+ ];
37
+
38
+ const SECTIONS = [...MAIN, ...MORE];
39
+
40
+ /** Open deals: an informational count, plain in the Sidebar. */
41
+ const OPEN_DEALS = 12;
42
+
43
+ const KPIS = [
44
+ { label: "Сделки в работе", value: "37", delta: "+4 за неделю", tone: "success" },
45
+ { label: "Выручка за октябрь", value: "4,8 млн ₽", delta: "+12% к сентябрю", tone: "success" },
46
+ { label: "Ждут ответа", value: "5 клиентов", delta: "2 дольше суток", tone: "danger" },
47
+ ] as const;
48
+
49
+ const TASKS: Row[] = [
50
+ { id: "t1", title: "Позвонить в ООО «Север» по договору поставки", meta: "10:30" },
51
+ { id: "t2", title: "Отправить КП для АО «Меридиан»", meta: "12:00" },
52
+ { id: "t3", title: "Согласовать скидку для ИП Котова", meta: "15:00" },
53
+ { id: "t4", title: "Подготовить отчёт по воронке за неделю", meta: "17:30" },
54
+ ];
55
+
56
+ /** What every section other than «Сегодня» lists. */
57
+ const PAGES: Record<Exclude<SectionId, "today">, { description: string; rows: Row[] }> = {
58
+ deals: {
59
+ description: "12 сделок в работе на 9,6 млн ₽.",
60
+ rows: [
61
+ { id: "d1", title: "ООО «Север» · поставка оборудования", meta: "2 400 000 ₽" },
62
+ { id: "d2", title: "АО «Меридиан» · годовой сервис", meta: "1 180 000 ₽" },
63
+ { id: "d3", title: "ООО «Вектор» · внедрение CRM", meta: "860 000 ₽" },
64
+ ],
65
+ },
66
+ clients: {
67
+ description: "248 клиентов, 5 ждут ответа.",
68
+ rows: [
69
+ { id: "c1", title: "ООО «Север»", meta: "4 сделки" },
70
+ { id: "c2", title: "АО «Меридиан»", meta: "2 сделки" },
71
+ { id: "c3", title: "ИП Котов", meta: "1 сделка" },
72
+ ],
73
+ },
74
+ tasks: {
75
+ description: "Все задачи команды по срокам.",
76
+ rows: TASKS.map((task) => ({ ...task, meta: `сегодня, ${task.meta}` })),
77
+ },
78
+ reports: {
79
+ description: "Сводки по продажам и работе менеджеров.",
80
+ rows: [
81
+ { id: "r1", title: "Воронка продаж", meta: "обновлён сегодня" },
82
+ { id: "r2", title: "План и факт по менеджерам", meta: "октябрь" },
83
+ ],
84
+ },
85
+ settings: {
86
+ description: "Компания, команда и интеграции.",
87
+ rows: [
88
+ { id: "s1", title: "Команда и роли", meta: "8 человек" },
89
+ { id: "s2", title: "Почта и телефония", meta: "подключены" },
90
+ ],
91
+ },
92
+ };
93
+
94
+ const isMore = (id: SectionId) => MORE.some((section) => section.id === id);
95
+
96
+ export default function AppShellPattern() {
97
+ const [current, setCurrent] = React.useState<SectionId>("today");
98
+ const [menuOpen, setMenuOpen] = React.useState(false);
99
+ const [searchOpen, setSearchOpen] = React.useState(false);
100
+ const [done, setDone] = React.useState<ReadonlySet<string>>(() => new Set(["t1"]));
101
+
102
+ const openTasks = TASKS.length - done.size;
103
+ const section = SECTIONS.find((item) => item.id === current) ?? SECTIONS[0];
104
+
105
+ // One handler for every way into a section: the Sidebar, the bar and the search.
106
+ const go = (id: SectionId) => {
107
+ setCurrent(id);
108
+ setMenuOpen(false);
109
+ setSearchOpen(false);
110
+ };
111
+
112
+ const toggleTask = (id: string, checked: boolean) =>
113
+ setDone((prev) => {
114
+ const next = new Set(prev);
115
+ if (checked) next.add(id);
116
+ else next.delete(id);
117
+ return next;
118
+ });
119
+
120
+ // ⌘K opens the same search as the header field.
121
+ React.useEffect(() => {
122
+ const onKey = (event: KeyboardEvent) => {
123
+ if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
124
+ event.preventDefault();
125
+ setSearchOpen((open) => !open);
126
+ }
127
+ };
128
+ document.addEventListener("keydown", onKey);
129
+ return () => document.removeEventListener("keydown", onKey);
130
+ }, []);
131
+
132
+ const countOf = (id: SectionId) => {
133
+ if (id === "deals") return <Sidebar.ItemCount>{OPEN_DEALS}</Sidebar.ItemCount>;
134
+ if (id === "tasks" && openTasks > 0) {
135
+ return <Sidebar.ItemCount color="red">{openTasks}</Sidebar.ItemCount>;
136
+ }
137
+ return null;
138
+ };
139
+
140
+ const sidebarItem = (item: Section) => (
141
+ <Sidebar.Item key={item.id} current={item.id === current} onClick={() => go(item.id)}>
142
+ <Sidebar.ItemIcon>
143
+ <Icon name={item.icon} />
144
+ </Sidebar.ItemIcon>
145
+ {item.label}
146
+ {countOf(item.id)}
147
+ </Sidebar.Item>
148
+ );
149
+
150
+ return (
151
+ <>
152
+ <AppShell.Template
153
+ fillViewport
154
+ scrollResetKey={current}
155
+ nav={
156
+ <Sidebar.Root open={menuOpen} onOpenChange={setMenuOpen}>
157
+ <Sidebar.Header>
158
+ <Sidebar.Brand description="Отдел продаж">
159
+ <Sidebar.BrandLogo>
160
+ <Icon name="object.funnel" />
161
+ </Sidebar.BrandLogo>
162
+ Acme CRM
163
+ </Sidebar.Brand>
164
+ <Sidebar.Toggle variant="header" />
165
+ </Sidebar.Header>
166
+ <Sidebar.Content>
167
+ <Sidebar.Group>{MAIN.map(sidebarItem)}</Sidebar.Group>
168
+ </Sidebar.Content>
169
+ <Sidebar.Footer>{MORE.map(sidebarItem)}</Sidebar.Footer>
170
+ </Sidebar.Root>
171
+ }
172
+ header={
173
+ <AppHeader.Root>
174
+ <AppHeader.Start>
175
+ <AppHeader.MenuButton aria-expanded={menuOpen} onClick={() => setMenuOpen(true)} />
176
+ <AppHeader.Title>{section.label}</AppHeader.Title>
177
+ </AppHeader.Start>
178
+ <AppHeader.Search onClick={() => setSearchOpen(true)}>
179
+ Поиск сделок и клиентов
180
+ </AppHeader.Search>
181
+ </AppHeader.Root>
182
+ }
183
+ footer={
184
+ <BottomNav.Root floating>
185
+ {MAIN.map((item) => (
186
+ <BottomNav.Item
187
+ key={item.id}
188
+ current={item.id === current}
189
+ onClick={() => go(item.id)}
190
+ >
191
+ <BottomNav.ItemIcon>
192
+ <Icon name={item.icon} />
193
+ </BottomNav.ItemIcon>
194
+ {item.id === "tasks" && openTasks > 0 ? (
195
+ <BottomNav.ItemCount>{openTasks}</BottomNav.ItemCount>
196
+ ) : null}
197
+ {item.label}
198
+ </BottomNav.Item>
199
+ ))}
200
+ {/* The rest of the map lives in the Sidebar: «Ещё» opens it as a panel. */}
201
+ <BottomNav.Item
202
+ current={isMore(current)}
203
+ aria-expanded={menuOpen}
204
+ onClick={() => setMenuOpen(true)}
205
+ >
206
+ <BottomNav.ItemIcon>
207
+ <Icon name="action.more" />
208
+ </BottomNav.ItemIcon>
209
+ Ещё
210
+ </BottomNav.Item>
211
+ </BottomNav.Root>
212
+ }
213
+ >
214
+ {/* Another section is another page: it cross-fades instead of flipping. */}
215
+ <Crossfade state={current}>
216
+ <PageContent.Section aria-labelledby="page-title">
217
+ <PageContent.Header>
218
+ <PageContent.Title id="page-title">{section.label}</PageContent.Title>
219
+ <PageContent.Description>
220
+ {current === "today"
221
+ ? `Среда, 14 октября · открытых задач: ${openTasks}`
222
+ : PAGES[current].description}
223
+ </PageContent.Description>
224
+ </PageContent.Header>
225
+ <PageContent.Body>
226
+ {current === "today" ? (
227
+ <>
228
+ <div className={styles.kpis}>
229
+ {KPIS.map((kpi) => (
230
+ <Card.Root key={kpi.label} variant="stat-trend">
231
+ <Card.Label>{kpi.label}</Card.Label>
232
+ <Card.Value>{kpi.value}</Card.Value>
233
+ <Card.Delta tone={kpi.tone}>{kpi.delta}</Card.Delta>
234
+ </Card.Root>
235
+ ))}
236
+ </div>
237
+ <Card.Root variant="list">
238
+ <Card.Header>
239
+ <Card.Title as="h2">Задачи на сегодня</Card.Title>
240
+ </Card.Header>
241
+ <Card.List>
242
+ {TASKS.map((task) => (
243
+ <Card.ListItem key={task.id}>
244
+ <span className={styles.row}>
245
+ <Checkbox.Root
246
+ checked={done.has(task.id)}
247
+ onCheckedChange={(checked) => toggleTask(task.id, checked)}
248
+ >
249
+ <Checkbox.Label>{task.title}</Checkbox.Label>
250
+ </Checkbox.Root>
251
+ <Typography as="span" variant="caption" tone="muted">
252
+ {task.meta}
253
+ </Typography>
254
+ </span>
255
+ </Card.ListItem>
256
+ ))}
257
+ </Card.List>
258
+ </Card.Root>
259
+ </>
260
+ ) : (
261
+ <Card.Root variant="list">
262
+ <Card.List>
263
+ {PAGES[current].rows.map((row) => (
264
+ <Card.ListItem key={row.id}>
265
+ <span className={styles.row}>
266
+ <Typography as="span" variant="body-m" truncate>
267
+ {row.title}
268
+ </Typography>
269
+ <Typography as="span" variant="caption" tone="muted">
270
+ {row.meta}
271
+ </Typography>
272
+ </span>
273
+ </Card.ListItem>
274
+ ))}
275
+ </Card.List>
276
+ </Card.Root>
277
+ )}
278
+ </PageContent.Body>
279
+ </PageContent.Section>
280
+ </Crossfade>
281
+ </AppShell.Template>
282
+
283
+ <CommandMenu.Root open={searchOpen} onOpenChange={setSearchOpen} aria-label="Поиск">
284
+ <CommandMenu.Input placeholder="Раздел, сделка или клиент" />
285
+ <CommandMenu.List>
286
+ <CommandMenu.Empty />
287
+ <CommandMenu.Group label="Разделы">
288
+ {SECTIONS.map((item) => (
289
+ <CommandMenu.Item key={item.id} value={item.label} onSelect={() => go(item.id)}>
290
+ <CommandMenu.ItemIcon>
291
+ <Icon name={item.icon} />
292
+ </CommandMenu.ItemIcon>
293
+ {item.label}
294
+ </CommandMenu.Item>
295
+ ))}
296
+ </CommandMenu.Group>
297
+ </CommandMenu.List>
298
+ </CommandMenu.Root>
299
+ </>
300
+ );
301
+ }
@@ -13,21 +13,5 @@
13
13
  }
14
14
 
15
15
  .dialogs {
16
- display: flex;
17
- flex-direction: column;
18
- gap: var(--prime-space-1);
19
- margin: 0;
20
16
  padding: var(--prime-space-2);
21
- list-style: none;
22
- }
23
-
24
- /* A dialog row: avatar and name at the start, the unread count at the end. */
25
- .dialog {
26
- justify-content: flex-start;
27
- }
28
-
29
- .dialogText {
30
- display: grid;
31
- flex: 1;
32
- min-width: 0;
33
17
  }
@@ -1,7 +1,6 @@
1
1
  /** A support messenger on `Chat`: dialogs beside the conversation (one pane at a time on a phone), history that loads above as you scroll up, dates and runs from data, every kind of message, search, pinned messages, replies and edits from the menu with a jump to the quoted message, read receipts and unread counts from what was seen, the typing signal both ways, and the Composer whose messages rise into the list while the client answers. */
2
2
  import {
3
3
  Avatar,
4
- Badge,
5
4
  Button,
6
5
  Card,
7
6
  Chat,
@@ -13,6 +12,7 @@ import {
13
12
  Dropdown,
14
13
  type GalleryItem,
15
14
  Icon,
15
+ List,
16
16
  Message,
17
17
  type MessageReaction,
18
18
  type MessageStatus,
@@ -729,28 +729,25 @@ export default function ChatPattern() {
729
729
  <PageContent.List aria-label="Диалоги">
730
730
  <Card.Root className={styles.paneCard}>
731
731
  <ScrollContainer className={styles.pane}>
732
- <ul className={styles.dialogs}>
732
+ <List.Root className={styles.dialogs}>
733
733
  {DIALOGS.map((item) => (
734
- <li key={item.id}>
735
- <Button.Root
736
- variant={item.id === dialogId ? "soft" : "ghost"}
737
- tone="neutral"
738
- fullWidth
739
- aria-current={item.id === dialogId ? "true" : undefined}
740
- className={styles.dialog}
741
- onClick={() => setDialogId(item.id)}
742
- >
743
- <Avatar.Root size="s" color="blue">
734
+ <List.Item
735
+ key={item.id}
736
+ current={item.id === dialogId}
737
+ onClick={() => setDialogId(item.id)}
738
+ >
739
+ <List.ItemIcon>
740
+ <Avatar.Root color="blue">
744
741
  <Avatar.Fallback>{item.initials}</Avatar.Fallback>
745
742
  </Avatar.Root>
746
- <span className={styles.dialogText}>{item.name}</span>
747
- {(unread[item.id] ?? 0) > 0 ? (
748
- <Badge.Root color="blue">{unread[item.id]}</Badge.Root>
749
- ) : null}
750
- </Button.Root>
751
- </li>
743
+ </List.ItemIcon>
744
+ <List.ItemText>{item.name}</List.ItemText>
745
+ {(unread[item.id] ?? 0) > 0 ? (
746
+ <List.ItemCount color="blue">{unread[item.id]}</List.ItemCount>
747
+ ) : null}
748
+ </List.Item>
752
749
  ))}
753
- </ul>
750
+ </List.Root>
754
751
  </ScrollContainer>
755
752
  </Card.Root>
756
753
  </PageContent.List>
@@ -0,0 +1,28 @@
1
+ /* Kanban board: the toolbar sits right above the view it filters; the page rhythm comes from
2
+ PageContent. */
3
+
4
+ .workspace {
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: var(--prime-space-4);
8
+ min-width: 0;
9
+ }
10
+
11
+ /* The board takes most of the screen; every column scrolls inside it, never the page. */
12
+ .board {
13
+ block-size: min(calc(var(--prime-space-24) * 7), 75dvh);
14
+ }
15
+
16
+ /* Company cell: the name over the deal id. */
17
+ .company {
18
+ display: grid;
19
+ white-space: nowrap;
20
+ }
21
+
22
+ /* Owner: avatar, then the name. */
23
+ .owner {
24
+ display: inline-flex;
25
+ align-items: center;
26
+ gap: var(--prime-space-2);
27
+ white-space: nowrap;
28
+ }