@eocrm/design-system 0.6.22 → 0.6.24

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 (34) hide show
  1. package/AI-PRIMER.md +167 -288
  2. package/README.md +11 -1
  3. package/docs/accessible-names.md +22 -0
  4. package/docs/components/Chart.md +1 -0
  5. package/docs/components/CircularProgress.md +4 -0
  6. package/docs/components/CurrencySelect.md +1 -1
  7. package/docs/components/FunnelChart.md +96 -0
  8. package/docs/components/Kbd.md +1 -1
  9. package/docs/components/MoneyInput.md +1 -1
  10. package/docs/components/PhoneInput.md +1 -1
  11. package/docs/components/Progress.md +7 -0
  12. package/docs/components/Text.md +8 -0
  13. package/docs/components/Title.md +4 -0
  14. package/docs/components/index.json +657 -0
  15. package/docs/i18n.md +74 -0
  16. package/docs/typescript.md +9 -0
  17. package/package.json +2 -2
  18. package/src/_meta/manifest.ts +1 -0
  19. package/src/components/Chart/Chart.tsx +1 -22
  20. package/src/components/Chart/ChartTooltip.tsx +5 -3
  21. package/src/components/FunnelChart/FunnelChart.module.scss +265 -0
  22. package/src/components/FunnelChart/FunnelChart.tokens.scss +72 -0
  23. package/src/components/FunnelChart/FunnelChart.tsx +607 -0
  24. package/src/components/FunnelChart/funnelModel.ts +124 -0
  25. package/src/components/FunnelChart/index.ts +2 -0
  26. package/src/components/LiquidEditor/useLiquidAutocomplete.ts +1 -1
  27. package/src/components/RichText/RichTextAttachment.tsx +1 -1
  28. package/src/components/_internal/useSize.ts +23 -0
  29. package/src/components.manifest.json +18 -2
  30. package/src/i18n/I18nProvider.tsx +1 -1
  31. package/src/i18n/en.ts +10 -0
  32. package/src/i18n/messages.ts +18 -0
  33. package/src/i18n/ru.ts +11 -0
  34. package/src/index.ts +7 -0
@@ -51,3 +51,7 @@
51
51
  - ❌ Hand-rolled spinning `<svg>` per page. `<CircularProgress />` indeterminate is the same visual, accessible and reduced-motion-aware.
52
52
  - ❌ `<CircularProgress value={0}>` to render an empty circle. `value={0}` is determinate (0% done); the intent is usually indeterminate, so omit `value`.
53
53
  - ❌ `<CircularProgress size="sm" label>` expecting centered text in a 16px circle. The label is auto-suppressed at `sm` by design; use `md` or `lg`.
54
+
55
+ ## Progress hard rule
56
+
57
+ No hand-rolled spinning `<svg>`, no `value={0}` for "not started yet": see [Progress.md](Progress.md#progress-hard-rule).
@@ -1,4 +1,4 @@
1
- # `<CurrencySelect>`
1
+ # `<CurrencySelect>` — ISO 4217 currency picker over Select
2
2
 
3
3
  Currency picker over the DS `Select`: you pass the ISO 4217 `currencies` to offer (required, in display order); it renders `CODE — Name` rows with localized names (`Intl.DisplayNames`). Single mode is controlled on `value: string | null` / `onChange(code | null)`; `multiple` switches to `string[]` (with `triggerDisplay` `"chips"` / `"summary"`). `currencyDisplay` sets how the SELECTED currency shows in the trigger: `"code"` (default, `EUR`), `"symbol"` (`€`) or `"both"` (`EUR €`). Search turns on automatically above 7 currencies (`searchable` overrides) and matches code, name and symbol. Invalid codes are dropped with a dev warning. `size`/`invalid`/`disabled`/`clearable`/`name` etc. pass through to `Select`.
4
4
 
@@ -0,0 +1,96 @@
1
+ # `<FunnelChart>` — stage funnel for pipeline and campaign conversion
2
+
3
+ Ordered stages (e.g. Leads → Qualified → Proposal → Won) drawn as one connected silhouette in a one-hue ramp: the height of each stage shows how much of the funnel is left, a pill in each later stage shows the step conversion, and the first stage carries its value and label inside its band. Fills a `DashboardWidget variant="chart"` body or sits inline on a page, and ships its own tooltip, keyboard inspection, data table and empty state. The app owns data, formatting, loading and errors.
4
+
5
+ ```tsx
6
+ import { useState } from 'react';
7
+ import {
8
+ FunnelChart,
9
+ DashboardWidget,
10
+ ErrorState,
11
+ Stack,
12
+ type FunnelStage,
13
+ } from '@eocrm/design-system';
14
+
15
+ const stages = [
16
+ { key: 'leads', label: 'Leads', value: 1200 },
17
+ { key: 'qualified', label: 'Qualified', value: 640 },
18
+ { key: 'proposal', label: 'Proposal', value: 210 },
19
+ { key: 'won', label: 'Won', value: 64 },
20
+ ]; // keep the array and `formatValue` referentially stable (module level or useMemo / useCallback)
21
+
22
+ <DashboardWidget variant="chart" title="Sales pipeline" loading={isLoading}>
23
+ {error ? (
24
+ <ErrorState title="Could not load" headingLevel={4} />
25
+ ) : (
26
+ <FunnelChart label="Sales pipeline" stages={stages} formatValue={formatCount} />
27
+ )}
28
+ </DashboardWidget>;
29
+
30
+ // Inline on a page: a definite-height parent is filled; an auto-height one gets the default height
31
+ <div style={{ height: 280 }}>
32
+ <FunnelChart label="Campaign funnel" stages={stages} formatValue={formatCount} shape="sharp" />
33
+ </div>;
34
+
35
+ // Drill-through plus an external headline that follows the hovered / focused stage
36
+ const [active, setActive] = useState<FunnelStage | null>(null);
37
+
38
+ <Stack gap="sm">
39
+ <strong>{active ? `${active.label}: ${formatCount(active.value)}` : 'Hover a stage'}</strong>
40
+ <FunnelChart
41
+ label="Sales pipeline"
42
+ stages={stages}
43
+ formatValue={formatCount}
44
+ onStageClick={(stage) => navigate(`/deals?stage=${stage.key}`)}
45
+ onActiveStageChange={setActive}
46
+ footnote="Closed deals only"
47
+ />
48
+ </Stack>;
49
+ ```
50
+
51
+ <!-- props:start -->
52
+
53
+ ## Props
54
+
55
+ <!-- prettier-ignore -->
56
+ | Prop | Type | Required | Description |
57
+ |---|---|---|---|
58
+ | `label` | `string` | yes | Accessible name of the figure and caption of its data table, e.g. "Sales pipeline". Required. |
59
+ | `stages` | `FunnelStage[]` | yes | Stages, top of the funnel first. Heights are relative to the largest value. Colour is a one-hue ramp from `--chart-series-1` (first stage) toward the surface (paler toward the foreground in dark); a stage's `color` slot overrides it. |
60
+ | `formatValue` | `(n: number) => string` | yes | Formats a stage value for the footer, tooltip and data table (the app owns i18n and currency). Required. |
61
+ | `shape` | `'taper' \| 'sharp'` | no | Silhouette between stages, in both layouts (vertical slices follow the same geometry turned on its side). Default `'taper'`. - `'taper'` — each stage holds its height for most of its column, then a short straight diagonal leads into the next one's height. - `'sharp'` — full-column trapezoids from one stage's height to the next. |
62
+ | `onStageClick` | `((stage: FunnelStage, index: number) => void)` | no | Makes every stage a button that calls this with the consumer's original stage object and its index, e.g. to drill through to the deals in that stage. Omit for a read-only funnel. |
63
+ | `onActiveStageChange` | `((stage: FunnelStage \| null) => void)` | no | Called with the hovered or focused stage (the consumer's original object), or `null` when none is active, e.g. so a surrounding headline can follow along. |
64
+ | `emptyMessage` | `ReactNode` | no | EmptyState title when there are no stages or every value is 0. Default: translated "No data for this period". |
65
+ | `footnote` | `ReactNode` | no | Short note under the funnel, e.g. "Closed deals only". Takes its own row (wraps, never truncates); also describes the plot for screen readers. Shown in the empty state too. |
66
+ | …native | | | plus native HTML attributes |
67
+
68
+ <!-- props:end -->
69
+
70
+ - **Percent semantics.** The pill between two stages is the _step conversion_ (next value ÷ this value). The tooltip and data table add the _share of the first stage_. Both are computed by the component; pass raw counts.
71
+ - **Heights** are relative to the largest value; stages are drawn top of funnel first, in the order given.
72
+ - **Colour** is a one-hue ramp, because stages are ordered: the first stage is `--chart-series-1` at full strength, each later stage a lighter tint mixed toward `--funnel-chart-ramp-target` (the surface in light, 48% max; `--color-fg` in dark, 65% max, so later stages get paler and stay crisp on the dark surface; also `--funnel-chart-ramp-color`, `--funnel-chart-ramp-max-mix`). The lightest stage stays clearly visible against the surface in both themes. With 5+ stages adjacent tints get close; the labels, table and tooltip carry identity. To highlight a stage (e.g. Won in green), give it `color` (a palette slot 1–8); it then uses `--chart-series-N` at full strength. There is no stage limit.
73
+ - **Hero first stage** (horizontal): the first stage's value (large) and label sit inside its band, and its footer cell stays empty. When the band is too short or narrow for that text, or the first stage has a `color` slot, the first stage falls back to the normal footer, so nothing is hidden. The hero text is `--funnel-chart-hero-fg` (the surface colour) set as WCAG large text, since it clears 3:1 but not 4.5:1 on `--chart-series-1`; recheck it if you override `--funnel-chart-ramp-color`. Later stages show the conversion pill in the band and value + label underneath, with dashed rules between footer columns.
74
+ - **Shape** (both layouts): `'taper'` (default) holds each stage's size for most of its column, then a short straight diagonal leads into the next; `'sharp'` draws full-column trapezoids. The last stage has no neck.
75
+ - **Layout.** Horizontal while every stage gets at least 96px of width. Narrower, it switches to vertical: one funnel slice per row (the same `shape`, turned on its side, thin gaps between slices) beside a table-like grid with a header (Stage, Value, vs previous) and per row a colour swatch, the label (wrapping, never truncated), the value and the step pill (the first row shows "—"). The visible header is hidden from screen readers; the hidden data table is their source. As a last resort the rows scroll inside the plot. Unmeasured (first paint, SSR) it assumes horizontal.
76
+ - **Clickable stages.** `onStageClick(stage, index)` makes every stage cell a button (Tab, Enter / Space) and receives your original stage object. Omit it for a read-only funnel. Escape dismisses the active stage.
77
+ - **`onActiveStageChange(stage | null)`** fires with the hovered or focused stage (your original object) and `null` when none is active. Use it for a headline outside the chart; do not put that headline in the widget body next to the chart.
78
+ - **Tooltip** follows hover, focus and tap, and tracks scroll and resize. **Screen readers** get a visually hidden data table (caption = `label`) with the exact values, conversion and share.
79
+ - **Empty** (no stages, or every value 0) renders `EmptyState` with `emptyMessage` (default "No data for this period"). `footnote` stays visible there.
80
+ - **Footnote** is a short muted note in its own row under the funnel (wraps, never truncates) and is the plot's accessible description.
81
+ - **Sizing.** Fills its parent (`width/height: 100%`). A definite-height parent (a `DashboardWidget` chart body, a `height` style) is filled; in an auto-height parent the shapes take an intrinsic default height (`--funnel-chart-min-height`).
82
+
83
+ #### When NOT to use
84
+
85
+ - ❌ Values over time → `Chart`.
86
+ - ❌ A single conversion ratio → `StatGroup` / `DashboardWidget variant="kpi"`.
87
+ - ❌ Unordered categories, or stages that don't narrow → `Chart type="bar"`.
88
+ - ❌ Many stages (beyond about 8) → fold the tail into an "Other" stage in the app; the ramp steps become hard to tell apart.
89
+
90
+ #### Anti-patterns
91
+
92
+ - ❌ Passing an inline `stages` array or `formatValue` on every render of a busy parent; geometry is recomputed when their identity changes.
93
+ - ❌ Wrapping `FunnelChart` in a fixed-height or scrolling div inside the widget body. The cell is the size.
94
+ - ❌ Rendering a note next to the chart in the widget body (it steals height). Use `footnote`.
95
+ - ❌ Passing `loading` / error UI into `FunnelChart`. Use `DashboardWidget loading` and an `ErrorState` child.
96
+ - ❌ Pre-computing percentages into `value`. Pass counts; the component derives conversion and share.
@@ -1,4 +1,4 @@
1
- # `Kbd`
1
+ # `Kbd` — keyboard-shortcut chips
2
2
 
3
3
  Inline keyboard-shortcut display: one `<kbd>` chip per key, joined with a faint `+` separator. Use for shortcut hints in tooltips, command palettes, search inputs, and help/shortcut sheets.
4
4
 
@@ -1,4 +1,4 @@
1
- # `<MoneyInput>`
1
+ # `<MoneyInput>` — currency picker + formatted decimal-string amount
2
2
 
3
3
  Money field: currency picker (`CurrencySelect`) + formatted amount `Input`, controlled on one `value: MoneyValue` (`{ amount: string | null; currency: string }`), `onChange(value)`. `amount` is a **canonical decimal string** (`"1234.50"`, `"-3"`) or `null` — never a JS number. `currencies` (ISO 4217, required) lists what the picker offers; exactly one renders a static prefix instead. The amount groups as you type in the locale's separators, keeps the caret where you are editing, rejects keystrokes past the currency's precision (2 for EUR, 0 for JPY, 3 for KWD) — a digit typed inside a full fraction overwrites the next one. Backspace/Delete step over group separators, and over the decimal while a fraction follows it (select the decimal to remove it). Blur pads the fraction (`12` → `12.00`). Switching currency rounds the amount half-up to the new precision (and pads it, as blur would). An external `amount` that is not a canonical decimal string displays empty with a dev warning. In comma-decimal locales the numpad `.` key types the decimal; a paste or drop is parsed on its own, then spliced over the selection. It must be exactly one number, optionally with currency symbols, a label word before it (`Total`, `Итого:`) and a currency word after it (an ISO code or `руб.`/`р.` — never a magnitude like `K`, `млн`, `million`) and a sign (`-5`, `−5`, `+5`, `5-`, `(1,234.50)`). Grouping must be unambiguous — first group 1–3 digits, then exactly 3, one kind of group character (`,` `.` space, NBSP, apostrophe) — and with both `.` and `,` the last one is the decimal whatever the locale. Anything doubtful (`12,34` or `1.234` in en, `15.01.2024`, `$5K`, `5 10`, a line break, extra words, a second number, stray parentheses) is rejected with no change, as is a partial paste that adds a second decimal, a sign, or fraction digits past the precision. A whole-value paste truncates to the precision (`$1,234.5678` → `1,234.56`). A negative paste must replace the whole value and needs `allowNegative` (or an already-negative value). `allowNegative` permits a leading `-`; without it, adding a `-` (typed or pasted) is rejected rather than silently dropped, and an existing external negative keeps its sign while edited. `onChange` fires on every edit and currency switch — never for blur-without-edit or external value changes. `name` emits hidden `<name>` / `<name>_currency` inputs.
4
4
 
@@ -1,4 +1,4 @@
1
- # `<PhoneInput>`
1
+ # `<PhoneInput>` — E.164 phone field with country picker
2
2
 
3
3
  International phone field: searchable country picker (DS `Select`) + national-number `Input`, controlled on a single `value: string | null` (**E.164**), `onChange(e164 | null)`. `defaultCountry` (ISO alpha-2) seeds the picker when empty; `countries` restricts the list; `size`/`invalid`/`disabled` pass through. Country names are localized via `Intl.DisplayNames`; metadata/validation via `libphonenumber-js`. Validate with the exported `isValidPhone(e164)` and drive `invalid` (or wrap in `<Field error>`). `countryDisplay` sets how the SELECTED country shows in the trigger — `"code"` (default, `+1`), `"iso"` (`US +1`), `"name"` (`United States +1`), or `"flag"` (`🇺🇸 +1`; emoji flags don't render on Windows Chrome/Edge). Dropdown rows always show the full name + code (searchable); opening the picker selects the text for instant type-to-search; the country can't be cleared. The picker's accessible name is composed from the group's (`<Field label="Phone">` → "Phone Country"), so it never collides with a form's own Country field; `countryLabel` overrides it. Store the emitted E.164, not the formatted display.
4
4
 
@@ -54,3 +54,10 @@ Use for per-file upload bars, wizard step indicators, form-completion meters and
54
54
  - ❌ A hand-rolled `<div style={{ width: `${n}%`, … }}>` bar — use the primitive.
55
55
  - ❌ `<Progress role="…">` — `role` is locked to `progressbar`; TypeScript rejects the override.
56
56
  - ❌ A separate consumer-rendered label element — the `label` slot already handles spacing, font size and tabular-nums for stable digit widths.
57
+
58
+ ## Progress hard rule
59
+
60
+ - ❌ Hand-rolled `<div style={{ width: '${n}%', background: '#xxx' }}>` progress bars — use `<Progress value={n}>`.
61
+ - ❌ Hand-rolled spinning `<svg>` per page (every Saving-state, every loader). Use `<CircularProgress />` indeterminate.
62
+ - ❌ `<Progress tone="success" value={100}>` or `<Progress tone="success" />` to "celebrate" completion. Tones communicate STATE during progress (warning at 85%, danger at 95%), not success-on-done. A finished bar is just a finished bar — leave the default tone.
63
+ - ❌ `<CircularProgress value={0}>` to render an empty circle for "not started yet." `value={0}` is determinate (0% done). The intent is usually indeterminate — omit `value` entirely.
@@ -61,3 +61,11 @@ If you need a size or tone the primitive doesn't expose, that is a token-vocabul
61
61
  - `<Text as="h2">`: Text doesn't accept heading tags; use `<Title order={2}>`.
62
62
  - Wrapping a `<Title>` in `<Text>` for tone/weight tweaks: pass tone/weight to the `<Title>`. `size="inherit"` is for runs INSIDE a heading, not for wrapping it.
63
63
  - Nesting `<Text>` in another `<Text>` with the default `as="p"`: the inner `<p>` inside the outer `<p>` is invalid HTML and triggers React's DOM nesting warning. Use `<Text as="span" tone="...">` for an inline override inside a paragraph.
64
+
65
+ ## Typography hard rule
66
+
67
+ - ❌ `style={{ fontSize: 'var(--font-size-sm)' }}` / `style={{ color: 'var(--color-fg-muted)' }}` — use `<Text size="sm" tone="muted">`.
68
+ - ❌ Raw `<h1>` / `<h2>` / `<h3>` — use `<Title order={N}>`.
69
+ - ❌ `<Text style={{ color: '#someHex' }}>` — pick a tone from the whitelist.
70
+ - For semantic emphasis (the bold _is_ the meaning — e.g. a user's name in a notification, an error keyword), use `<strong>` or `<em>` inside `<Text>`. For visual-only weight changes (a medium-weight name in a list because hierarchy says so, not because the name is emphatic), use `<Text weight="medium">`. Pick the one that matches _why_ the text is heavy.
71
+ - If the size / tone / weight you need isn't on `<Title>` or `<Text>`, **that's a token-vocabulary conversation, not a component-skipping conversation.**
@@ -52,3 +52,7 @@
52
52
  - `<h2 className={styles.title}>`: use `<Title order={2}>`; consumer SCSS should never name a typography class.
53
53
  - `<Title order={1} size="xs">`: usually a sign the heading hierarchy is wrong. Bump the order up instead of shrinking a low-order heading.
54
54
  - Skipping heading levels (`order={1}` then `order={4}`) hurts screen-reader users; use sequential orders.
55
+
56
+ ## Typography hard rule
57
+
58
+ No raw `<h1>` / `<h2>` / `<h3>`, no inline `style` font sizes or colours, `<strong>` / `<em>` vs `weight`: see [Text.md](Text.md#typography-hard-rule).