@olddevs/ui 4.3.0 → 4.4.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 (68) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/editor/RichTextEditor.d.ts.map +1 -1
  3. package/dist/editor/RichTextEditor.js +38 -4
  4. package/dist/editor/RichTextEditor.js.map +1 -1
  5. package/dist/editor/core/handle.d.ts +1 -5
  6. package/dist/editor/core/handle.d.ts.map +1 -1
  7. package/dist/editor/core/handle.js +1 -13
  8. package/dist/editor/core/handle.js.map +1 -1
  9. package/dist/editor/core/handleWatchers.d.ts +6 -0
  10. package/dist/editor/core/handleWatchers.d.ts.map +1 -0
  11. package/dist/editor/core/handleWatchers.js +14 -0
  12. package/dist/editor/core/handleWatchers.js.map +1 -0
  13. package/dist/editor/core/tools.d.ts +7 -2
  14. package/dist/editor/core/tools.d.ts.map +1 -1
  15. package/dist/editor/core/tools.js +2 -1
  16. package/dist/editor/core/tools.js.map +1 -1
  17. package/dist/editor/features/toc/RichTextToc.d.ts +63 -0
  18. package/dist/editor/features/toc/RichTextToc.d.ts.map +1 -0
  19. package/dist/editor/features/toc/RichTextToc.js +372 -0
  20. package/dist/editor/features/toc/RichTextToc.js.map +1 -0
  21. package/dist/editor/features/toc/headings.d.ts +31 -0
  22. package/dist/editor/features/toc/headings.d.ts.map +1 -0
  23. package/dist/editor/features/toc/headings.js +25 -0
  24. package/dist/editor/features/toc/headings.js.map +1 -0
  25. package/dist/editor/features/toc/stickyOffset.d.ts +3 -0
  26. package/dist/editor/features/toc/stickyOffset.d.ts.map +1 -0
  27. package/dist/editor/features/toc/stickyOffset.js +14 -0
  28. package/dist/editor/features/toc/stickyOffset.js.map +1 -0
  29. package/dist/editor/index.d.ts +2 -0
  30. package/dist/editor/index.d.ts.map +1 -1
  31. package/dist/editor/index.js +1 -0
  32. package/dist/editor/index.js.map +1 -1
  33. package/dist/i18n/defaults.d.ts.map +1 -1
  34. package/dist/i18n/defaults.js +1 -0
  35. package/dist/i18n/defaults.js.map +1 -1
  36. package/dist/i18n/messages.d.ts +5 -0
  37. package/dist/i18n/messages.d.ts.map +1 -1
  38. package/dist/locales/ru.d.ts.map +1 -1
  39. package/dist/locales/ru.js +1 -0
  40. package/dist/locales/ru.js.map +1 -1
  41. package/docs/api/messages.en.md +743 -0
  42. package/docs/api/messages.md +1 -0
  43. package/docs/api/utilities.en.md +233 -0
  44. package/docs/components/content/rich-text-toc.en.md +130 -0
  45. package/docs/components/content/rich-text-toc.md +129 -0
  46. package/docs/components/index.en.md +2 -1
  47. package/docs/components/index.md +1 -0
  48. package/docs/components/inputs/rich-text-editor.en.md +52 -34
  49. package/docs/components/inputs/rich-text-editor.md +20 -2
  50. package/docs/conventions.en.md +318 -0
  51. package/docs/getting-started.en.md +4 -4
  52. package/docs/getting-started.md +3 -3
  53. package/docs/glossary.en.md +2 -1
  54. package/docs/glossary.md +1 -0
  55. package/docs/index.en.md +53 -0
  56. package/llms.txt +1 -0
  57. package/package.json +1 -1
  58. package/src/editor/RichTextEditor.tsx +107 -3
  59. package/src/editor/core/handle.ts +2 -23
  60. package/src/editor/core/handleWatchers.ts +27 -0
  61. package/src/editor/core/tools.ts +9 -2
  62. package/src/editor/features/toc/RichTextToc.tsx +712 -0
  63. package/src/editor/features/toc/headings.ts +70 -0
  64. package/src/editor/features/toc/stickyOffset.ts +21 -0
  65. package/src/editor/index.ts +2 -0
  66. package/src/i18n/defaults.ts +1 -0
  67. package/src/i18n/messages.ts +5 -0
  68. package/src/locales/ru.ts +1 -0
@@ -403,6 +403,7 @@
403
403
  | `blockMoveDown` | строка | Move down | Вниз |
404
404
  | `blockDuplicate` | строка | Duplicate | Дублировать |
405
405
  | `blockDelete` | строка | Delete | Удалить |
406
+ | `toc` | строка | Table of contents | Оглавление |
406
407
 
407
408
  ## `errorState`
408
409
 
@@ -0,0 +1,233 @@
1
+ ---
2
+ category: api
3
+ status: stable
4
+ source: src/index.ts
5
+ sourceHash: 255c163bdc7a
6
+ ---
7
+
8
+ # Shared API: utilities, tokens, constants and types
9
+
10
+ ## Summary for AI
11
+
12
+ `@olddevs/ui` exports several programmatic APIs besides components. They are meant for integration with the
13
+ components and the shared visual vocabulary. Do not import the internal `src/lib/*` files directly. All public
14
+ component exports and their prop types are documented on the pages of the components themselves. The
15
+ `@olddevs/ui/icons` export is documented in [icons.md](../icons.md).
16
+
17
+ ## When to use
18
+
19
+ - `cn`: to merge Tailwind classes, especially when `text-caption` and a text color from the kit's scale appear
20
+ together.
21
+ - `useScrollLock`: for your own controlled overlay composition, when it owns the open state and must lock the
22
+ `body` scroll.
23
+ - `useMediaQuery`: when the window width decides what is rendered or how it behaves (what to mount, how many
24
+ months to show, whether to collapse `SplitPanel`), not only the look. To show or hide a block by width, use
25
+ a Tailwind class. Details are in [SplitPanel](../components/app-shell/split-panel.md).
26
+ - `Size`, `Tone`, `Variant`, `TONE_TOKENS` and `variantClasses`: when you create a new design-system primitive
27
+ with the same semantic axes.
28
+ - Period helpers: for a relative period in the URL and its conversion to dates. Time helpers: for `HH:MM` input
29
+ in date and time controls.
30
+ - The theme registry: for a theme switcher and for applying the saved theme early. Take the axis lists from
31
+ there. Do not rewrite them as constants in your own code.
32
+
33
+ ## When not to use
34
+
35
+ - Do not create a second `body` scroll-lock counter in the app. Unrelated counters break nested overlays. Use
36
+ the one `useScrollLock` from the package.
37
+ - Do not build Tailwind classes dynamically, for example `text-${tone}`. Use literal tokens or
38
+ `variantClasses`. Class names built at runtime do not make it into the CSS build.
39
+ - Do not pass an invalid string from the URL straight to `resolvePeriod`. First narrow it with
40
+ `isRelativePeriod`.
41
+ - Do not use `maskTime` as the final validation. The mask works during input, and normalization works after it.
42
+
43
+ ## Shared variants
44
+
45
+ The source of the shared exports is `src/index.ts`. Their implementations are in `src/lib/cn.ts`,
46
+ `src/hooks/useScrollLock.ts`, `src/hooks/useMediaQuery.ts`, `src/lib/variants.ts`, `src/lib/period.ts` and
47
+ `src/lib/time.ts`.
48
+
49
+ | Export | Purpose |
50
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | `cn(...inputs)` | `clsx` plus a configured Tailwind merge. The last conflicting class wins, and `text-caption` does not conflict with `text-fg-*`. |
52
+ | `useScrollLock(isLocked)` | Locks the scroll of `body`, compensates for the scrollbar width and counts nested locks at the module level. Releases the lock on unmount. |
53
+ | `useMediaQuery(query, serverValue?)` | Returns whether the media query matches now, and re-renders the component when it changes. Before hydration, and without `matchMedia`, it returns `serverValue` (`false`). |
54
+ | `Size` | The shared scale `sm \| md \| lg`. A specific component may support a subset of it. |
55
+ | `Tone` | The semantic color `neutral \| accent \| success \| warning \| danger`, not a shade name. |
56
+ | `Variant` | The visual treatment `solid \| soft \| outline \| ghost`. |
57
+ | `Numeric` | The digit style: `tabular` gives digits of equal width. An axis of the `Text` and `Heading` components, not a separate utility. |
58
+ | `ToneAtoms` | The type of a set of ready Tailwind classes for one `Tone`. |
59
+ | `TONE_TOKENS` | The map `Tone → ToneAtoms`. Use it instead of copying token strings. |
60
+ | `variantClasses(variant, tone)` | Ready classes for a variant and tone pair, for your own primitive. |
61
+ | `INVALID_BORDER_CLASS` | The ready class for the border of an invalid field. |
62
+
63
+ ## Periods
64
+
65
+ | Export | Purpose and limits |
66
+ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
67
+ | `RELATIVE_PERIODS`, `RelativePeriod` | The allowed relative values: `7d`, `30d`, `90d`, `180d`, `365d`, `thisMonth`, `lastMonth`, `thisYear`. |
68
+ | `isRelativePeriod(value)` | A runtime type guard for an external string, including one from the URL. |
69
+ | `resolvePeriod(period, timeZone?, now?)` | Returns the inclusive ISO bounds of a `DateRange`. Uses the local time zone by default. `now` is needed mainly for deterministic tests. |
70
+ | `DateRange` | A pair `{ start, end }` in ISO `YYYY-MM-DD`. Both bounds are inclusive. |
71
+ | `periodLength(range)` | The number of days, inclusive, computed in UTC. A range that was reversed by hand gives at least one day. An unparseable bound gives `0`, which means "nothing to count". |
72
+ | `formatPeriodLength(range, messages?)` | The label for the period length, from the message catalog: `14 дней` or `14 days`. Without `messages`, the English fallback is used. With an unparseable bound, it returns an empty string, not `0 дней`. |
73
+ | `PeriodPreset`, `periodPresets(messages?)` | A type and a function that builds a short set of standard presets for the UI, with labels from the message catalog. Without `messages`, the labels are in English. |
74
+ | `DEFAULT_PERIOD_PRESETS` | `@deprecated` since 2.0.0: an immutable set of presets with English labels. It does not know the locale. Use `periodPresets(messages)`. It is removed in the next major version. |
75
+
76
+ ```ts
77
+ const rawPeriod = searchParams.get("period");
78
+ const period = rawPeriod && isRelativePeriod(rawPeriod) ? rawPeriod : "30d";
79
+ const range = resolvePeriod(period, "Europe/Moscow");
80
+ ```
81
+
82
+ ## Time
83
+
84
+ | Export | Purpose |
85
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
+ | `maskTime(value)` | Keeps up to four digits and adds `:` after the first two: `2130 → 21:30`. Apply it on every input. |
87
+ | `normalizeTime(value, fallback?)` | Takes partial input and returns an `HH:MM` value within valid bounds. Empty input returns `fallback`, which defaults to `00:00`. Apply it when the input is complete. |
88
+ | `formatDate(isoDate, locale?)` | Formats an ISO day from `DatePicker` for display. Documented together with [DatePicker](../components/date-time/date-picker.md). |
89
+ | `upcomingSlots(step, now)` | Builds the nearest time slots. Documented together with [TimePicker](../components/date-time/time-picker.md). |
90
+
91
+ ## Input masks
92
+
93
+ These are pure functions of the mask engine. They are exported apart from the component, because you may need
94
+ to prepare a value before you send it, or check whether it is filled in, without a field on the screen.
95
+
96
+ | Export | Purpose |
97
+ | ----------------------- | ------------------------------------------------------------------------------------ |
98
+ | `formatMask(raw, mask)` | Lays out a raw value over the mask. The tail of the mask is not shown in advance. |
99
+ | `unmask(display, mask)` | Gets the raw value by position. A literal digit from the mask does not leak into it. |
100
+ | `maskCapacity(mask)` | How many value characters the mask holds: `+7 (999) 999-99-99` → 10. |
101
+
102
+ Mask tokens: `9` is a digit, `a` is a letter, `*` is a digit or a letter. Anything else is a literal. There is
103
+ no escaping. Details are in [MaskedInput](../components/inputs/masked-input.md).
104
+
105
+ ## Themes
106
+
107
+ The registry of theme axes and the contract for applying them. The kit has **no** theme switcher component.
108
+ `Segmented` already gives the right shape, and a button depends on the storage and the markup of a specific
109
+ app. The reasoning is in ADR-0020. The ready code for two variants of a switcher is in
110
+ [the recipe in theming.md](../theming.md#переключатель-темы).
111
+
112
+ | Export | Purpose |
113
+ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
114
+ | `UI_THEMES`, `UI_ACCENTS`, `UI_BRANDS` | The three theme axes as lists. A test checks them against `styles.css`, so they cannot drift apart silently. |
115
+ | `normalizeTheme`, `normalizeAccent`, `normalizeBrand` | Turn any value into a valid one. They accept `unknown`. A typical source is storage. |
116
+ | `applyTheme(root, prefs)` | Sets all three axes on the element. If an axis is not passed, it returns to its default instead of keeping the previous value. |
117
+ | `themeInitScript({ storageKey })` | A **string** of JS for applying the theme early, before the first render. |
118
+ | `DEFAULT_THEME_PREFS` | The state in which no overlay is set: `dark` / `blue` / `base`. |
119
+
120
+ `base` **removes** `data-brand` instead of writing `data-brand="base"`. There is no CSS block with that name,
121
+ and a leftover attribute would look like a working overlay.
122
+
123
+ ### Applying the theme early
124
+
125
+ Without early theme application, the user sees a flash of the wrong theme between the first render and
126
+ hydration. The kit returns a **string** and does not run code. The consumer's framework knows where to insert
127
+ it, not the design system.
128
+
129
+ ```jsx
130
+ import Script from "next/script";
131
+ import { themeInitScript } from "@olddevs/ui";
132
+
133
+ <Script
134
+ id="theme-init"
135
+ strategy="beforeInteractive"
136
+ dangerouslySetInnerHTML={{ __html: themeInitScript({ storageKey: "ui-prefs" }) }}
137
+ />;
138
+ ```
139
+
140
+ The storage format is a JSON object `{ theme, accent, brand }` under this key. The consumer writes it
141
+ (`applyTheme` does not save anything), and the script reads it. Any field can be missing or hold a wrong value.
142
+ Normalization still runs, and broken JSON does not break the page.
143
+
144
+ ## Table state
145
+
146
+ `useTableState` describes the shape of the `DataTable` state and the links between its parts. The hook **stores
147
+ nothing outside React** and subscribes to nothing. The kit provides the shape, and the consumer owns it
148
+ (ADR-0023).
149
+
150
+ | Export | Purpose |
151
+ | --------------- | -------------------------------------------------------------------------------- |
152
+ | `useTableState` | `{ state, set, reset }`. One update function instead of ten handlers. See below. |
153
+ | `TableState` | Sorting, the page and its size, selection, expansion, filters, columns, density. |
154
+ | `TableFilter` | An applied filter: `id`, `field`, `value`, and optional `label` and `to`. |
155
+
156
+ ### Invariants the hook exists to keep
157
+
158
+ - changing **filters, sorting or the page size resets the page** to the first one. Otherwise the user looks at
159
+ page four of a result set of ten rows, and reads the empty space as "nothing found";
160
+ - **selection is not reset.** The reverse looks tidier, but it breaks a bulk action exactly when that action is
161
+ needed most;
162
+ - density and column visibility do **not** touch the page. A filter narrows the result set, not the look;
163
+ - an explicit `page` in the same call cancels the reset. This is how the state is restored from the address,
164
+ in full and in one go.
165
+
166
+ The update goes through one function because the invariants must hold no matter what changed. Split into
167
+ separate handlers, they would drift apart.
168
+
169
+ ### Moving to the URL
170
+
171
+ | Export | Purpose |
172
+ | ---------------------- | -------------------------------------------------------------------------- |
173
+ | `tableStateToParams` | State to `URLSearchParams`. Defaults are not written. |
174
+ | `tableStateFromParams` | `URLSearchParams` to part of the state. Junk input does not break parsing. |
175
+ | `DEFAULT_TABLE_STATE` | Defaults, except density: the provider sets it. |
176
+
177
+ The functions are **pure and have no `"use client"`**. URL parsing is needed on the server too, because in
178
+ Next.js the state is restored from `searchParams` before rendering. Applying them to the URL is the app's job.
179
+ Routers differ in how they write parameters (`replace` or `push`, array encoding, `basePath`). A kit that tried
180
+ to guess them would get it wrong silently.
181
+
182
+ ```tsx
183
+ const { state, set } = useTableState(tableStateFromParams(searchParams));
184
+
185
+ // after a change, write it back the way your router supports
186
+ router.replace(`?${tableStateToParams(state)}`);
187
+ ```
188
+
189
+ **Selection and expansion are not kept in the URL**, although the original spec included selection in the table
190
+ state. There are two reasons. The list of keys has no length limit. And a link with a selection, opened the next
191
+ day, marks records that no longer exist. Selection is session state, not URL state.
192
+
193
+ The restored state is passed to the hook as a whole, in one `set` call. The invariant "narrowing the result set
194
+ resets the page" is cancelled by an explicit page in the same call. Otherwise a link would always open the first
195
+ page.
196
+
197
+ ### A filter token has its own `id`
198
+
199
+ A filter has its own `id`, not a pair of "field + value" and not a label. Two filters on one field give tokens
200
+ that look the same, and you cannot tell which of the two to remove. A label, however, is localized and truncated.
201
+
202
+ The model has no operators on purpose. Without `to`, the token means equality. With `to`, it means the range
203
+ `value…to`, where either bound may be an empty string ("from 50", "up to 100"). The presence of the `to` key
204
+ carries the difference, so check it with `!== undefined` (ADR-0063). `contains` and `in` can be added when they
205
+ are needed. In the URL, `to` is the fifth part of the token. Links with three and four parts are read as before.
206
+ For how to build a range in the UI, see [FilterBar](../components/table/filter-bar.md).
207
+
208
+ ## Constants, classes and public types: where they are documented
209
+
210
+ These are public exports, but not standalone components. The page of the family they belong to describes them,
211
+ so that AI does not treat a class as a general-purpose API:
212
+
213
+ | Exports | Documentation |
214
+ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
215
+ | `MENU_PANEL_CLASS`, `MENU_ITEM_CLASS`, `MENU_ITEM_ICON_CLASS`, `MENU_ITEM_SHORTCUT_CLASS`, `MENU_SECTION_LABEL_CLASS`, `MENU_SEPARATOR_CLASS` | [Menu](../components/overlays/menu.md) |
216
+ | `NAV_BUTTON_CLASS`, `WEEKDAY_CELL_CLASS` | [Calendar](../components/date-time/calendar.md) |
217
+ | `pageRange` | [Pagination](../components/navigation/pagination.md) |
218
+ | `maskTime`, `normalizeTime` | [DateTimePicker](../components/date-time/date-time-picker.md) |
219
+ | `formatDate` | [DatePicker](../components/date-time/date-picker.md) |
220
+ | `upcomingSlots` | [TimePicker](../components/date-time/time-picker.md) |
221
+
222
+ Each `*Props` type and each component-specific type exported with a component (for example `TableProps`,
223
+ `CommandItem`, `SelectItem`, `KeyboardSelection` or `DrawerSide`) stays part of that component's public API. It
224
+ is described on the page of its family. The [component catalog](../components/index.md) links the public exports
225
+ to the family pages. `src/index.ts` is the final source for all exports.
226
+
227
+ ## Related sources and checks
228
+
229
+ - Public surface: `src/index.ts`.
230
+ - Implementations: `src/lib/cn.ts`, `src/hooks/useScrollLock.ts`, `src/hooks/useMediaQuery.ts`,
231
+ `src/lib/variants.ts`, `src/lib/period.ts`, `src/lib/time.ts`, `src/lib/themes.ts`, `src/lib/table-state.ts`,
232
+ `src/hooks/useTableState.ts`.
233
+ - `maskTime` and `normalizeTime` are also used in `src/components/date-time/DateTimePicker.tsx`.
@@ -0,0 +1,130 @@
1
+ ---
2
+ component: RichTextToc
3
+ exports: [RichTextToc, RichTextTocProps]
4
+ category: content
5
+ status: stable
6
+ source: src/editor/features/toc/RichTextToc.tsx
7
+ story: src/editor/features/toc/RichTextToc.stories.tsx
8
+ sourceHash: 5eed0e4804e0
9
+ ---
10
+
11
+ # RichTextToc
12
+
13
+ ## Summary for AI
14
+
15
+ `RichTextToc` is the Table of contents of a Document, placed next to the View (`RichTextView`) or an Editor in `readOnly`. Collapsed, it is a strip of strokes, one per heading, with the stroke of the current section highlighted. Hovering the strip expands a list of headings; clicking an item scrolls to the heading and moves focus to it. The component is client-side and opt-in: the screen puts it in its own column and passes `target`, a ref to the View or to the Editor's Handle. Headings are read from the target's DOM; the Document has no anchors or `id` attributes, and the page address does not change. It lives in the `@olddevs/ui/editor` entry point. Decision: ADR-0066.
16
+
17
+ ```tsx
18
+ import { RichTextToc, RichTextView } from "@olddevs/ui/editor";
19
+ ```
20
+
21
+ ## When to use
22
+
23
+ - A long Document in the View: an article, a policy, a description, where the reader needs to jump to a section without scrolling through the whole page.
24
+ - An Editor in `readOnly` shows the same long Document and needs the same navigation.
25
+
26
+ ## When not to use
27
+
28
+ - A short Document: with fewer than two headings the component renders nothing, and on three or four paragraphs a Table of contents only takes up space.
29
+ - Navigation between application pages or sections of a site: use `SideNav` or `Tabs`, not the Table of contents of a Document.
30
+ - A link to a section that can be shared: the Table of contents goes to a heading without an anchor (`#slug`), and the page address does not change. A link to a section would need its own ADR about `id` in the Schema.
31
+ - A Table of contents as part of the Document itself, a Block inside the text: the Schema has no such Block.
32
+ - The Table of contents belongs inside the Editor field and the screen has no column for it: use the Editor's `toc` Tool, the same strip in the left gutter of the frame, sticky under the Toolbar and in a field with `maxHeight` (the `RichTextEditor` page, "Table of contents" section).
33
+
34
+ ## Minimal example
35
+
36
+ ```tsx
37
+ "use client";
38
+ import { RichTextToc, RichTextView } from "@olddevs/ui/editor";
39
+ import { useRef } from "react";
40
+
41
+ export function Article({ html }: { html: string }) {
42
+ const view = useRef<HTMLDivElement>(null);
43
+ return (
44
+ <div className="flex items-start gap-6">
45
+ {/* The screen sets the column and the stickiness, not the component. */}
46
+ <div className="sticky top-[calc(var(--topbar-h,0px)+16px)] w-10 shrink-0">
47
+ <RichTextToc target={view} />
48
+ </div>
49
+ <RichTextView ref={view} html={html} className="min-w-0 flex-1" />
50
+ </div>
51
+ );
52
+ }
53
+ ```
54
+
55
+ `RichTextView` stays server-safe here: only the screen's wrapper is client-side, because it needs the `ref`.
56
+
57
+ ## Public API
58
+
59
+ | Prop | Meaning |
60
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
61
+ | `target` | Required. A ref to a `RichTextView` or to the Handle of a `RichTextEditor` whose headings the Table of contents shows. Headings are read after mounting, so there is no strip before hydration. |
62
+ | `offsetVar` | The name of the CSS variable with the height of the screen's sticky header, `--topbar-h` by default (`TopBar` publishes it). A jump places the heading below the header, and the highlight counts a section from the header's bottom edge. |
63
+ | `listWidth` | The width of the list: a CSS length or pixels, `240` by default. It is fixed rather than sized to the longest heading; a heading that does not fit is truncated with an ellipsis. |
64
+ | `className` | Classes for the root. The remaining `div` props go to the root. |
65
+
66
+ ### Which headings are included
67
+
68
+ - `h1`–`h4` at the top level of the Document and inside a Wrapper.
69
+ - Headings in the columns of a Layout are not included: two headings at the same height in neighboring columns confuse the highlight of the current section. Headings inside custom Blocks and their Islands are not included either: their content is part of the Block, not of the Document's structure.
70
+ - With fewer than two headings the component renders nothing, and the screen's column stays empty.
71
+
72
+ The indent of an item and the length of its stroke depend on the level **relative to the largest heading in the Document**: if the Document starts with `h2`, then `h2` has no indent and the longest stroke. The deeper the level, the shorter the stroke.
73
+
74
+ ### A live Document
75
+
76
+ The list follows the target: a new `html` of the View, a new value of the Editor or an edit rebuilds it through a `MutationObserver` with a short delay. When a second heading appears, the strip appears; when one goes, it disappears. The Editor's Handle provides the Document only once the engine is ready; until then the Table of contents waits and picks it up on its own.
77
+
78
+ ### A long Document
79
+
80
+ Every heading gets its own stroke. The strip is never taller than the window: when there is not enough room for the strokes, the step between them shrinks. The list has a limited height and scrolls, and the item of the current section stays within its visible part.
81
+
82
+ ### The strip and the list
83
+
84
+ - The strip is a button labeled "Table of contents" with `aria-expanded`. The stroke of the current section is in the accent color, thicker and slightly longer than the others.
85
+ - The list opens when you hover the strip, after a short delay: a cursor passing by accidentally does not open it. A press, a tap, Enter and Space open it immediately.
86
+ - The list sits to the right of the strip, over the text. Clicking an item goes to the heading, and the list **stays open**. It closes when the cursor leaves the strip and the list, on Escape and on a press outside it.
87
+ - The current section is highlighted both in the strip and in the list (`aria-current="location"`).
88
+
89
+ ### Jumping to a heading
90
+
91
+ Clicking an item scrolls to the heading (without animation under `prefers-reduced-motion`) and moves focus to it: the next Tab continues from the heading, and a screen reader reads it. While it has focus, the heading gets `tabindex="-1"`; when focus leaves, the attribute is removed, so the Document keeps no trace. The page address does not change, no `id` attributes appear, and the Schema and `sanitizeRichText` stay as they were.
92
+
93
+ The heading you jump to is briefly highlighted with an accent-colored background that fades out in about a second and a half: the eye finds where the page has scrolled to, even if the heading did not reach the top edge (the end of the Document). The highlight is a Web Animations animation; it writes neither a class nor a `style` into the Document's markup.
94
+
95
+ The nearest scrollable ancestor of the target scrolls, or the window if there is none.
96
+
97
+ **An Editor being edited.** If the Editor is switched from `readOnly` to editing, the Table of contents keeps working: the list is rebuilt on edits, and a jump scrolls to the heading and highlights it. Focus does not move to the heading, though, and the caret stays where the author left it: there is no way to focus a heading inside a `contenteditable` without disturbing the caret. This mode is not covered by the contract.
98
+
99
+ ### Sticky header
100
+
101
+ The component takes the header's height by the contract of ADR-0016: from the CSS variable whose name `offsetVar` carries. If the variable exists, the heading lands exactly below the header, and a section becomes the current one when its heading reaches the header's bottom edge. If it does not, the offset is the heading's `scroll-margin-top`, which the screen can set itself. There is no numeric offset prop: the header's height depends on the breakpoint, and a constant would leave a gap on a narrow screen.
102
+
103
+ ### Narrow screens
104
+
105
+ If there is no room to the left of the text, the Table of contents hides itself: a strip that overlaps the Document's text or goes past the left edge of the window becomes invisible (`visibility: hidden`) together with its list. It keeps its space in the column, so the screen layout does not jump. When room appears, the strip comes back. This works both for a column next to the text and for the page margin to the left of a centered column (the `Margin` story).
106
+
107
+ ## Accessibility and keyboard
108
+
109
+ | Key | Where | Action |
110
+ | ------------ | ----- | -------------------------------------------------------- |
111
+ | Enter, Space | strip | Open the list, focus moves to the current section's item |
112
+ | ↓ ↑ | list | Next and previous item |
113
+ | Home, End | list | First and last item |
114
+ | Enter | item | Go to the heading, focus moves to the heading |
115
+ | Escape | list | Close the list, focus moves to the strip |
116
+
117
+ The list is a `nav` labeled "Table of contents", and its items are buttons in a `ul` list. The list is an overlay in the Escape stack: nested in another overlay, it closes first. Hover and focus do not take focus away from where it is: the list complements the strip rather than taking over control. Labels come from the kit's message catalog (`editor.toc`): "Оглавление" in `ru`, "Table of contents" in the fallback.
118
+
119
+ ## Anti-patterns
120
+
121
+ - Pointing `target` at a wrapper that holds the View together with neighboring blocks of the page: other headings end up in the Table of contents. Put the ref on the View itself.
122
+ - Hiding the Table of contents on a narrow screen with `display: none` on the column while also relying on its self-hiding: self-hiding is for when the column stays but has no room. A column that should not exist on a phone is removed by the screen itself.
123
+ - Adding `id` to headings for the sake of the Table of contents: it does not need them, and the Schema will strip them.
124
+
125
+ ## Related sources and Storybook
126
+
127
+ - Source: `src/editor/features/toc/RichTextToc.tsx`; heading collection — `headings.ts` next to it
128
+ - Stories: `src/editor/features/toc/RichTextToc.stories.tsx`
129
+ - Tests: `tests/rich-text-toc.test.tsx`; in the browser — `tests-visual/interactions.spec.ts`, the `RichTextToc` block
130
+ - Decision: ADR-0066 "The Table of contents of a Document as a separate `RichTextToc` component"
@@ -0,0 +1,129 @@
1
+ ---
2
+ component: RichTextToc
3
+ exports: [RichTextToc, RichTextTocProps]
4
+ category: content
5
+ status: stable
6
+ source: src/editor/features/toc/RichTextToc.tsx
7
+ story: src/editor/features/toc/RichTextToc.stories.tsx
8
+ ---
9
+
10
+ # RichTextToc
11
+
12
+ ## Кратко для AI
13
+
14
+ `RichTextToc` — Оглавление Документа рядом с Просмотром (`RichTextView`) или Редактором в `readOnly`. Свёрнуто — полоса штрихов, по одному на заголовок, штрих текущего раздела подсвечен. Наведение на полосу раскрывает список заголовков, клик по пункту прокручивает к заголовку и ставит на него фокус. Компонент клиентский и подключается только явно: экран ставит его в свою колонку и передаёт `target` — ref на Просмотр или Хэндл Редактора. Заголовки читаются из DOM цели, якорей и `id` в Документе нет, адрес страницы не меняется. Живёт во входе `@olddevs/ui/editor`. Решение — ADR-0066.
15
+
16
+ ```tsx
17
+ import { RichTextToc, RichTextView } from "@olddevs/ui/editor";
18
+ ```
19
+
20
+ ## Когда использовать
21
+
22
+ - Длинный Документ в Просмотре: статья, регламент, описание, где читателю нужно перейти к разделу, не листая страницу целиком.
23
+ - Редактор в `readOnly` показывает тот же длинный Документ, и навигация нужна там же.
24
+
25
+ ## Когда не использовать
26
+
27
+ - Короткий Документ: если в нём меньше двух заголовков, компонент ничего не рисует, а на трёх-четырёх абзацах Оглавление только занимает место.
28
+ - Навигация по страницам приложения или разделам сайта — это `SideNav` или `Tabs`, а не Оглавление Документа.
29
+ - Ссылка на раздел, которой можно поделиться: Оглавление ведёт к заголовку без якоря (`#slug`), адрес страницы не меняется. Ссылка на раздел потребует отдельного ADR об `id` в Схеме.
30
+ - Оглавление как часть самого Документа, Блоком внутри текста: такого Блока в Схеме нет.
31
+ - Оглавление нужно внутри поля Редактора, а своей колонки у экрана нет — это Инструмент `toc` Редактора: та же полоса в левом поле рамки, липкая под Панелью и в поле с `maxHeight` (страница `RichTextEditor`, раздел «Оглавление»).
32
+
33
+ ## Минимальный пример
34
+
35
+ ```tsx
36
+ "use client";
37
+ import { RichTextToc, RichTextView } from "@olddevs/ui/editor";
38
+ import { useRef } from "react";
39
+
40
+ export function Article({ html }: { html: string }) {
41
+ const view = useRef<HTMLDivElement>(null);
42
+ return (
43
+ <div className="flex items-start gap-6">
44
+ {/* Колонку и липкость задаёт экран, а не компонент. */}
45
+ <div className="sticky top-[calc(var(--topbar-h,0px)+16px)] w-10 shrink-0">
46
+ <RichTextToc target={view} />
47
+ </div>
48
+ <RichTextView ref={view} html={html} className="min-w-0 flex-1" />
49
+ </div>
50
+ );
51
+ }
52
+ ```
53
+
54
+ `RichTextView` при этом остаётся серверно-безопасным: клиентская здесь только обёртка экрана, которой нужен `ref`.
55
+
56
+ ## Public API
57
+
58
+ | Prop | Значение |
59
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
60
+ | `target` | Обязательный. Ref на `RichTextView` или на Хэндл `RichTextEditor`, чьи заголовки показывает Оглавление. Заголовки читаются после монтирования: до гидратации полосы нет. |
61
+ | `offsetVar` | Имя CSS-переменной с высотой липкой шапки экрана, по умолчанию `--topbar-h` (её публикует `TopBar`). Переход ставит заголовок под шапку, подсветка считает раздел от её нижнего края. |
62
+ | `listWidth` | Ширина списка: длина CSS или пиксели, по умолчанию `240`. Постоянная, а не по самому длинному заголовку; заголовок, что не помещается, обрезается многоточием. |
63
+ | `className` | Классы корня. Остальные свойства `div` уходят на корень. |
64
+
65
+ ### Какие заголовки попадают
66
+
67
+ - `h1`–`h4` на верхнем уровне Документа и внутри Обёртки.
68
+ - Не попадают заголовки в колонках Раскладки: два заголовка на одной высоте в соседних колонках путают подсветку текущего раздела. Не попадают и заголовки внутри своих Блоков и их Островов: их содержимое — часть Блока, а не структура Документа.
69
+ - Меньше двух заголовков — компонент ничего не рисует, колонка экрана остаётся пустой.
70
+
71
+ Отступ пункта и длина штриха считаются по уровню **относительно самого крупного заголовка в Документе**: если Документ начинается с `h2`, у `h2` нет отступа и самый длинный штрих. Чем глубже уровень, тем короче штрих.
72
+
73
+ ### Живой Документ
74
+
75
+ Список следит за целью: новый `html` Просмотра, новое значение Редактора или правка пересобирают его через `MutationObserver` с короткой задержкой. Пришёл второй заголовок — полоса появляется, ушёл — пропадает. Хэндл Редактора даёт Документ, только когда движок готов: до этого Оглавление ждёт и подхватывает его само.
76
+
77
+ ### Длинный Документ
78
+
79
+ На каждый заголовок свой штрих. Полоса не выше окна: когда штрихам не хватает места, шаг между ними сжимается. Список ограничен по высоте и прокручивается, пункт текущего раздела держится в его видимой части.
80
+
81
+ ### Полоса и список
82
+
83
+ - Полоса — кнопка с подписью «Оглавление» и `aria-expanded`. Штрих текущего раздела — цветом акцента, толще и чуть длиннее остальных.
84
+ - Список открывается наведением на полосу с короткой задержкой: случайный пролёт курсора его не открывает. Нажатие, касание, Enter и Space открывают его сразу.
85
+ - Список стоит справа от полосы поверх текста. Клик по пункту ведёт к заголовку, а список **остаётся открытым**: закрывается он, когда курсор ушёл с полосы и списка, по Escape и по нажатию вне него.
86
+ - Текущий раздел подсвечен и в полосе, и в списке (`aria-current="location"`).
87
+
88
+ ### Переход
89
+
90
+ Клик по пункту прокручивает к заголовку (при `prefers-reduced-motion` — без анимации) и ставит на него фокус: следующий Tab идёт от заголовка, скринридер читает его. На время фокуса заголовок получает `tabindex="-1"`, с уходом фокуса атрибут снимается — в Документе следа не остаётся. Адрес страницы не меняется, `id` не появляются, Схема и `sanitizeRichText` прежние.
91
+
92
+ Заголовок, к которому перешли, ненадолго подсвечивается фоном цвета акцента, и фон гаснет примерно за полторы секунды: глаз находит, куда приехала страница, даже если заголовок не встал к верхнему краю (конец Документа). Подсветка — анимация Web Animations, в разметку Документа она не пишет ни класса, ни `style`.
93
+
94
+ Прокручивается ближайший прокручиваемый предок цели, а если его нет — окно.
95
+
96
+ **Редактор в правке.** Если Редактор перевели из `readOnly` в правку, Оглавление продолжает работать: список пересобирается на правках, переход прокручивает к заголовку и подсвечивает его. Фокус при этом на заголовок не переходит, а каретка остаётся там, где её оставил автор: поставить фокус на заголовок внутри `contenteditable`, не тронув каретку, нельзя. Контрактом этот режим не обещан.
97
+
98
+ ### Липкая шапка
99
+
100
+ Высоту шапки компонент берёт по контракту ADR-0016: из CSS-переменной, имя которой передаёт `offsetVar`. Переменная есть — заголовок встаёт ровно под шапку, и текущим раздел становится, когда его заголовок дошёл до её нижнего края. Переменной нет — отступом служит `scroll-margin-top` заголовка, который экран может задать сам. Числового пропа отступа нет: высота шапки зависит от брейкпоинта, и константа оставила бы просвет на узком экране.
101
+
102
+ ### Узкий экран
103
+
104
+ Если слева от текста нет места, Оглавление прячется само: полоса, которая заходит на текст Документа или уходит за левый край окна, становится невидимой (`visibility: hidden`) вместе со своим списком. Место в колонке она сохраняет, раскладка экрана не прыгает. Когда место появляется, полоса возвращается. Так работает и колонка рядом с текстом, и поле страницы слева от центрированной колонки (история `Margin`).
105
+
106
+ ## Доступность и клавиатура
107
+
108
+ | Клавиша | Где | Действие |
109
+ | ------------ | ------ | ------------------------------------------------- |
110
+ | Enter, Space | полоса | Открыть список, фокус — на пункт текущего раздела |
111
+ | ↓ ↑ | список | Следующий и предыдущий пункт |
112
+ | Home, End | список | Первый и последний пункт |
113
+ | Enter | пункт | Перейти к заголовку, фокус — на заголовок |
114
+ | Escape | список | Закрыть список, фокус — на полосу |
115
+
116
+ Список — `nav` с подписью «Оглавление», пункты — кнопки в списке `ul`. Список — Оверлей в Стеке Escape: вложенный в другой оверлей, он закрывается первым. Наведение и фокус не уводят фокус с места: список дополняет полосу, а не забирает управление. Подписи берутся из каталога сообщений кита (`editor.toc`): «Оглавление» в `ru`, «Table of contents» в фолбэке.
117
+
118
+ ## Антипаттерны
119
+
120
+ - Передавать `target` на обёртку вокруг Просмотра вместе с соседними блоками страницы: в Оглавление попадут чужие заголовки. Ref — на сам Просмотр.
121
+ - Прятать Оглавление на узком экране через `display: none` у колонки и одновременно рассчитывать на его самоскрытие: самоскрытие нужно, когда колонка остаётся, а места в ней нет. Колонку, которой нет на телефоне, экран убирает сам.
122
+ - Ставить `id` заголовкам ради Оглавления: оно в них не нуждается, а Схема их снимет.
123
+
124
+ ## Связанные исходники и Storybook
125
+
126
+ - Исходник: `src/editor/features/toc/RichTextToc.tsx`, сбор заголовков — `headings.ts` рядом
127
+ - Stories: `src/editor/features/toc/RichTextToc.stories.tsx`
128
+ - Тесты: `tests/rich-text-toc.test.tsx`; в браузере — `tests-visual/interactions.spec.ts`, блок `RichTextToc`
129
+ - Решение: ADR-0066 «Оглавление Документа отдельным компонентом `RichTextToc`»
@@ -1,5 +1,5 @@
1
1
  ---
2
- sourceHash: 8b34aab7b3be
2
+ sourceHash: 1efca315c26c
3
3
  ---
4
4
 
5
5
  # Component catalog
@@ -141,6 +141,7 @@ Components built from primitives: the page frame, its navigation and header.
141
141
  - [SortableList](content/sortable-list.md): `SortableList` — a short list the user reorders by dragging: menu sections, scenario steps, priorities; lists sharing a `group` can exchange rows, which is how a kanban board is built
142
142
  - [EditableList](content/editable-list.md): `EditableList` — a list whose rows can be edited in place and reordered by dragging, and which can be added to at the bottom: a checklist, tasks; if you only need the order, `SortableList` is enough
143
143
  - [RichTextView](content/rich-text-view.md): `RichTextView` — displays ready-made rich text from the Editor without the Editor itself; a separate entry point `@olddevs/ui/editor`, suitable for a server component
144
+ - [RichTextToc](content/rich-text-toc.md): `RichTextToc` — the table of contents of a long Document next to the View or a `readOnly` Editor: a strip of strokes at the edge of the text, a list of headings on hover, a click goes to the heading; the `@olddevs/ui/editor` entry point
144
145
  - [VirtualGrid](content/virtual-grid.md): `VirtualGrid` — a grid of hundreds or thousands of previews that renders only the visible cells and lets you select them; for a dozen cards `Grid` is enough
145
146
  - [Avatar](content/avatar.md): `Avatar`, `AvatarGroup` — a round image of a person: a photo or, if there is none, initials; `AvatarGroup` shows several participants with a "+N" overflow count
146
147
  - [Data display](content/data-display.md): `Badge`, `Stat`, `DescriptionList`, `Table` — four ways to show data: a counter or a label, a key metric with an explanation, "property and value" pairs, a table of records of the same kind
@@ -137,6 +137,7 @@
137
137
  - [SortableList](content/sortable-list.md): `SortableList` — короткий список, который пользователь переставляет перетаскиванием: разделы меню, шаги сценария, приоритеты; списки одной `group` обмениваются строками: так собирается канбан-доска
138
138
  - [EditableList](content/editable-list.md): `EditableList` — список, строки которого можно править на месте и переставлять перетаскиванием, а сам список — пополнять снизу: чек-лист, задачи; если нужен только порядок, хватит `SortableList`
139
139
  - [RichTextView](content/rich-text-view.md): `RichTextView` — показ готового форматированного текста из Редактора без самого Редактора; отдельный вход `@olddevs/ui/editor`, годится серверному компоненту
140
+ - [RichTextToc](content/rich-text-toc.md): `RichTextToc` — Оглавление длинного Документа рядом с Просмотром или Редактором в `readOnly`: полоса штрихов у края текста, по наведению — список заголовков, клик ведёт к заголовку; вход `@olddevs/ui/editor`
140
141
  - [VirtualGrid](content/virtual-grid.md): `VirtualGrid` — сетка из сотен и тысяч превью, которая отрисовывает только видимые ячейки и позволяет их выбирать; для десятка карточек хватит `Grid`
141
142
  - [Avatar](content/avatar.md): `Avatar`, `AvatarGroup` — круглое изображение человека: фото или, если его нет, инициалы; `AvatarGroup` показывает нескольких участников со счётчиком остатка «+N»
142
143
  - [Data display](content/data-display.md): `Badge`, `Stat`, `DescriptionList`, `Table` — четыре способа показать данные: счётчик или метка, ключевой показатель с пояснением, пары «свойство и значение», таблица однотипных записей