@aircall/blocks 0.13.0 → 0.15.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.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Anchor, Badge, Button, CounterBadge, DropdownMenu, DropdownMenuAddon, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, Field, FieldDescription, FieldError, FieldLabel, FieldLabelAside, FieldLabelInfo, FieldLabelRow, HoverCard, HoverCardContent, HoverCardTrigger, InputGroup, InputGroupButton, InputGroupTextarea, Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle, Popover, PopoverContent, PopoverTrigger, Separator, Sidebar, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarProvider, Spinner, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TabsList, ToggleGroup, ToggleGroupItem, Tooltip, TooltipContent, TooltipTrigger, cn, registerI18nNamespace, useDsTranslation, useSidebar } from "@aircall/ds";
1
+ import { Anchor, Badge, Button, CounterBadge, DropdownMenu, DropdownMenuAddon, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, Field, FieldContent, FieldDescription, FieldError, FieldLabel, FieldLabelAside, FieldLabelInfo, FieldLabelRow, HoverCard, HoverCardContent, HoverCardTrigger, InputGroup, InputGroupButton, InputGroupTextarea, Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle, Popover, PopoverContent, PopoverTrigger, Separator, Sidebar, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarProvider, Spinner, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TabsList, ToggleGroup, ToggleGroupItem, Tooltip, TooltipContent, TooltipTrigger, cn, registerI18nNamespace, useDsLocale, useDsTranslation, useSidebar } from "@aircall/ds";
2
2
  import * as React from "react";
3
3
  import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -1734,17 +1734,27 @@ const DashboardStandalonePage = React.forwardRef((componentProps, forwardRef) =>
1734
1734
  return /* @__PURE__ */ jsx("div", {
1735
1735
  ref: forwardRef,
1736
1736
  "data-slot": "dashboard-standalone-page",
1737
- className: cn("flex h-screen flex-col bg-neutral-200 dark:bg-neutral-950", className),
1737
+ className: cn("flex h-screen gap-2 overflow-hidden bg-page-background p-2", className),
1738
1738
  ...props
1739
1739
  });
1740
1740
  });
1741
1741
  DashboardStandalonePage.displayName = "DashboardStandalonePage";
1742
+ const DashboardStandalonePageContent = React.forwardRef((componentProps, forwardRef) => {
1743
+ const { className, ...props } = componentProps;
1744
+ return /* @__PURE__ */ jsx("div", {
1745
+ ref: forwardRef,
1746
+ "data-slot": "dashboard-standalone-page-content",
1747
+ className: cn("flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-page-surface border border-border", className),
1748
+ ...props
1749
+ });
1750
+ });
1751
+ DashboardStandalonePageContent.displayName = "DashboardStandalonePageContent";
1742
1752
  const DashboardStandalonePageHeader = React.forwardRef((componentProps, forwardRef) => {
1743
1753
  const { className, ...props } = componentProps;
1744
1754
  return /* @__PURE__ */ jsx("header", {
1745
1755
  ref: forwardRef,
1746
1756
  "data-slot": "dashboard-standalone-page-header",
1747
- className: cn("grid h-16 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-4 border-b border-border bg-background px-4 md:px-6", className),
1757
+ className: cn("relative flex h-16 shrink-0 flex-col items-center justify-center gap-0.5 border-b border-border bg-background px-16 text-center", className),
1748
1758
  ...props
1749
1759
  });
1750
1760
  });
@@ -1754,18 +1764,28 @@ const DashboardStandalonePageTitle = React.forwardRef((componentProps, forwardRe
1754
1764
  return /* @__PURE__ */ jsx("h1", {
1755
1765
  ref: forwardRef,
1756
1766
  "data-slot": "dashboard-standalone-page-title",
1757
- className: cn("col-start-2 row-start-1 truncate text-center text-base font-bold text-foreground", className),
1767
+ className: cn("max-w-xl truncate text-center text-xl leading-tight font-bold text-foreground", className),
1758
1768
  ...props
1759
1769
  });
1760
1770
  });
1761
1771
  DashboardStandalonePageTitle.displayName = "DashboardStandalonePageTitle";
1772
+ const DashboardStandalonePageDescription = React.forwardRef((componentProps, forwardRef) => {
1773
+ const { className, ...props } = componentProps;
1774
+ return /* @__PURE__ */ jsx("p", {
1775
+ ref: forwardRef,
1776
+ "data-slot": "dashboard-standalone-page-description",
1777
+ className: cn("max-w-xl truncate text-center text-sm leading-tight font-medium text-muted-foreground", className),
1778
+ ...props
1779
+ });
1780
+ });
1781
+ DashboardStandalonePageDescription.displayName = "DashboardStandalonePageDescription";
1762
1782
  const DashboardStandalonePageActions = React.forwardRef((componentProps, forwardRef) => {
1763
1783
  const { side = "end", className, ...props } = componentProps;
1764
1784
  return /* @__PURE__ */ jsx("div", {
1765
1785
  ref: forwardRef,
1766
1786
  "data-slot": "dashboard-standalone-page-actions",
1767
1787
  "data-side": side,
1768
- className: cn("row-start-1 flex min-w-0 items-center gap-2", side === "start" ? "col-start-1 justify-start" : "col-start-3 justify-end", className),
1788
+ className: cn("absolute inset-y-0 flex items-center gap-2", side === "start" ? "left-4" : "right-4", className),
1769
1789
  ...props
1770
1790
  });
1771
1791
  });
@@ -1780,16 +1800,16 @@ const DashboardStandalonePageAction = React.forwardRef((componentProps, forwardR
1780
1800
  });
1781
1801
  });
1782
1802
  DashboardStandalonePageAction.displayName = "DashboardStandalonePageAction";
1783
- const DashboardStandalonePageContent = React.forwardRef((componentProps, forwardRef) => {
1803
+ const DashboardStandalonePageMain = React.forwardRef((componentProps, forwardRef) => {
1784
1804
  const { className, ...props } = componentProps;
1785
1805
  return /* @__PURE__ */ jsx("main", {
1786
1806
  ref: forwardRef,
1787
- "data-slot": "dashboard-standalone-page-content",
1807
+ "data-slot": "dashboard-standalone-page-main",
1788
1808
  className: cn("min-h-0 flex-1 overflow-auto p-4 md:p-6", className),
1789
1809
  ...props
1790
1810
  });
1791
1811
  });
1792
- DashboardStandalonePageContent.displayName = "DashboardStandalonePageContent";
1812
+ DashboardStandalonePageMain.displayName = "DashboardStandalonePageMain";
1793
1813
 
1794
1814
  //#endregion
1795
1815
  //#region src/components/dashboard-page.tsx
@@ -2042,6 +2062,123 @@ const RoleBadge = React.forwardRef((componentProps, forwardRef) => {
2042
2062
  });
2043
2063
  RoleBadge.displayName = "RoleBadge";
2044
2064
 
2065
+ //#endregion
2066
+ //#region src/components/score-badge.tsx
2067
+ const SCORE_TONE_BADGE = {
2068
+ success: {
2069
+ color: "green",
2070
+ tone: "medium-light"
2071
+ },
2072
+ "success-alt": {
2073
+ color: "green",
2074
+ tone: "light"
2075
+ },
2076
+ warning: {
2077
+ color: "yellow",
2078
+ tone: "light"
2079
+ },
2080
+ destructive: {
2081
+ color: "red",
2082
+ tone: "light"
2083
+ },
2084
+ "destructive-strong": {
2085
+ color: "red",
2086
+ tone: "medium-light"
2087
+ },
2088
+ muted: {
2089
+ color: "charcoal",
2090
+ tone: "medium-light"
2091
+ }
2092
+ };
2093
+ /**
2094
+ * The default color bands, from the Figma ScoreBadge spec (0–100 scale):
2095
+ * `≥85` excellent, `70–84` good, `50–69` fair, `20–49` poor, `<20` very poor.
2096
+ */
2097
+ const PERCENTAGE_THRESHOLDS = [
2098
+ {
2099
+ from: 85,
2100
+ tone: "success"
2101
+ },
2102
+ {
2103
+ from: 70,
2104
+ tone: "success-alt"
2105
+ },
2106
+ {
2107
+ from: 50,
2108
+ tone: "warning"
2109
+ },
2110
+ {
2111
+ from: 20,
2112
+ tone: "destructive"
2113
+ },
2114
+ {
2115
+ from: 0,
2116
+ tone: "destructive-strong"
2117
+ }
2118
+ ];
2119
+ /** Highest band whose `from` is ≤ value wins; a value below every band → muted. */
2120
+ function resolveTone(value, thresholds) {
2121
+ return [...thresholds].sort((a, b) => b.from - a.from).find((t) => value >= t.from)?.tone ?? "muted";
2122
+ }
2123
+ /**
2124
+ * Format a numeric score for display in the given locale (via `Intl.NumberFormat`),
2125
+ * and report the value rounded to the *same* precision.
2126
+ * - `number`: the value as-is — at most one decimal, trailing zeros dropped
2127
+ * (`en`: `90.66666 → "90.7"`, `4 → "4"`; `fr`: `90,7`).
2128
+ * - `percent`: a 0–100 score shown with a `%` suffix (`90.66666 → "90.7%"`; `fr`: `90,7 %`).
2129
+ *
2130
+ * `formatOptions` are merged over the per-kind defaults — e.g.
2131
+ * `{ maximumFractionDigits: 0 }` to drop decimals, `{ minimumFractionDigits: 2 }` for more.
2132
+ *
2133
+ * `rounded` is the value rounded to the displayed precision; the pill color is
2134
+ * resolved from it (not the raw value) so the color always matches the shown
2135
+ * number — e.g. `3.6` displayed as `"4"` is colored as a 4, not a 3.6.
2136
+ */
2137
+ function formatScore(value, kind, locale, formatOptions) {
2138
+ const options = kind === "percent" ? {
2139
+ style: "percent",
2140
+ maximumFractionDigits: 1,
2141
+ ...formatOptions
2142
+ } : {
2143
+ maximumFractionDigits: 1,
2144
+ ...formatOptions
2145
+ };
2146
+ const formatter = new Intl.NumberFormat(locale, options);
2147
+ const digits = formatter.resolvedOptions().maximumFractionDigits ?? 0;
2148
+ return {
2149
+ label: formatter.format(kind === "percent" ? value / 100 : value),
2150
+ rounded: Number(value.toFixed(digits))
2151
+ };
2152
+ }
2153
+ /**
2154
+ * A score pill. Composes DS `Badge`: derives its color from `value` against
2155
+ * `thresholds` and renders the value, locale-formatted per `kind`.
2156
+ *
2157
+ * @example
2158
+ * ```tsx
2159
+ * <ScoreBadge value={90.66666} /> // "90.7"
2160
+ * <ScoreBadge value={90.66666} kind="percent" /> // "90.7%"
2161
+ * <ScoreBadge value={4} thresholds={CSAT} formatOptions={{ maximumFractionDigits: 0 }} /> // "4"
2162
+ * ```
2163
+ */
2164
+ const ScoreBadge = React.forwardRef((componentProps, forwardRef) => {
2165
+ const { value, kind = "number", thresholds = PERCENTAGE_THRESHOLDS, formatOptions, className, ...props } = componentProps;
2166
+ const locale = useDsLocale();
2167
+ const score = value != null && Number.isFinite(value) ? value : null;
2168
+ const formatted = score === null ? null : formatScore(score, kind, locale, formatOptions);
2169
+ const { color, tone: badgeTone } = SCORE_TONE_BADGE[formatted === null ? "muted" : resolveTone(formatted.rounded, thresholds)];
2170
+ return /* @__PURE__ */ jsx(Badge, {
2171
+ ref: forwardRef,
2172
+ "data-slot": "score-badge",
2173
+ color,
2174
+ tone: badgeTone,
2175
+ className: cn("tabular-nums", className),
2176
+ ...props,
2177
+ children: formatted === null ? "--" : formatted.label
2178
+ });
2179
+ });
2180
+ ScoreBadge.displayName = "ScoreBadge";
2181
+
2045
2182
  //#endregion
2046
2183
  //#region src/components/chatbot-panel-trigger.tsx
2047
2184
  /**
@@ -2433,7 +2570,7 @@ function toFieldErrors(errors) {
2433
2570
 
2434
2571
  //#endregion
2435
2572
  //#region src/components/form-field.tsx
2436
- function FormFieldShell({ label, description, necessityIndicator, id, children }) {
2573
+ function FormFieldShell({ label, description, necessityIndicator, id, children, orientation = "vertical", controlPosition = "end", controlVariant = "fill" }) {
2437
2574
  const errors = useFieldContext().state.meta.errors;
2438
2575
  const labelContent = typeof label === "string" ? label : label?.content;
2439
2576
  const labelInfo = typeof label === "object" ? label.info : void 0;
@@ -2455,16 +2592,44 @@ function FormFieldShell({ label, description, necessityIndicator, id, children }
2455
2592
  variant: descriptionVariant,
2456
2593
  children: descriptionContent
2457
2594
  });
2458
- return /* @__PURE__ */ jsxs(Field, {
2459
- "data-invalid": errors.length > 0 ? true : void 0,
2595
+ const invalid = errors.length > 0 ? true : void 0;
2596
+ const instructionalDesc = descriptionVariant === "instructional" ? descriptionNode : null;
2597
+ const contextualDesc = descriptionVariant === "contextual" ? descriptionNode : null;
2598
+ const errorNode = /* @__PURE__ */ jsx(FieldError, { errors: toFieldErrors(errors) });
2599
+ if (orientation === "vertical") return /* @__PURE__ */ jsxs(Field, {
2600
+ "data-invalid": invalid,
2460
2601
  children: [
2461
2602
  labelRow,
2462
- descriptionVariant === "instructional" && descriptionNode,
2603
+ instructionalDesc,
2463
2604
  children,
2464
- descriptionVariant === "contextual" && descriptionNode,
2465
- /* @__PURE__ */ jsx(FieldError, { errors: toFieldErrors(errors) })
2605
+ contextualDesc,
2606
+ errorNode
2466
2607
  ]
2467
2608
  });
2609
+ if (controlVariant === "inline") {
2610
+ const textSide = descriptionContent !== void 0 || errors.length > 0 ? /* @__PURE__ */ jsxs(FieldContent, { children: [
2611
+ labelRow,
2612
+ instructionalDesc,
2613
+ contextualDesc,
2614
+ errorNode
2615
+ ] }) : labelRow;
2616
+ return /* @__PURE__ */ jsxs(Field, {
2617
+ orientation,
2618
+ "data-invalid": invalid,
2619
+ children: [controlPosition === "start" ? children : textSide, controlPosition === "start" ? textSide : children]
2620
+ });
2621
+ }
2622
+ const labelCol = /* @__PURE__ */ jsxs(FieldContent, { children: [labelRow, instructionalDesc] });
2623
+ const controlCol = /* @__PURE__ */ jsxs(FieldContent, { children: [
2624
+ children,
2625
+ contextualDesc,
2626
+ errorNode
2627
+ ] });
2628
+ return /* @__PURE__ */ jsxs(Field, {
2629
+ orientation,
2630
+ "data-invalid": invalid,
2631
+ children: [controlPosition === "start" ? controlCol : labelCol, controlPosition === "start" ? labelCol : controlCol]
2632
+ });
2468
2633
  }
2469
2634
  /**
2470
2635
  * Shared runtime binder for the per-primitive wrappers (FormSelectField, FormInputField, …).
@@ -2472,7 +2637,7 @@ function FormFieldShell({ label, description, necessityIndicator, id, children }
2472
2637
  * the AppField + shared shell. `TData`/`TControl` carry the precise field + bundle shapes through.
2473
2638
  */
2474
2639
  function FormFieldBase(props) {
2475
- const { form, name, validators, listeners, defaultValue, asyncDebounceMs, asyncAlways, label, description, necessityIndicator, buildControl, children } = props;
2640
+ const { form, name, validators, listeners, defaultValue, asyncDebounceMs, asyncAlways, label, description, necessityIndicator, orientation, controlPosition, controlVariant, buildControl, children } = props;
2476
2641
  const id = `${React.useId()}-${name}`;
2477
2642
  const AppField = form.AppField;
2478
2643
  return /* @__PURE__ */ jsx(AppField, {
@@ -2486,6 +2651,9 @@ function FormFieldBase(props) {
2486
2651
  label,
2487
2652
  description,
2488
2653
  necessityIndicator,
2654
+ orientation,
2655
+ controlPosition,
2656
+ controlVariant,
2489
2657
  id,
2490
2658
  children: children(field, buildControl(field, id))
2491
2659
  })
@@ -2791,14 +2959,21 @@ function switchControl(field, id) {
2791
2959
  * with a `switchProps` bundle. `name` is restricted to the form's **boolean** fields — a
2792
2960
  * string/number `name` is a type error.
2793
2961
  *
2962
+ * In a horizontal/responsive layout the switch is a bare, natural-width control beside its label
2963
+ * (`controlPosition` picks the side — defaults to `'start'`, i.e. switch on the left).
2964
+ *
2794
2965
  * @example
2795
2966
  * <FormSwitchField form={form} name="enabled" label="Enabled" validators={{ onChange }}>
2796
2967
  * {(field, { switchProps }) => <Switch {...switchProps} />}
2797
2968
  * </FormSwitchField>
2798
2969
  */
2799
2970
  function FormSwitchField(props) {
2971
+ const { orientation = "horizontal", controlPosition = "start", ...rest } = props;
2800
2972
  return /* @__PURE__ */ jsx(FormFieldBase, {
2801
- ...props,
2973
+ ...rest,
2974
+ orientation,
2975
+ controlPosition,
2976
+ controlVariant: "inline",
2802
2977
  buildControl: switchControl
2803
2978
  });
2804
2979
  }
@@ -3222,4 +3397,4 @@ function DashboardSidebarNav({ groups, renderLink }) {
3222
3397
  }
3223
3398
 
3224
3399
  //#endregion
3225
- export { BetaBadge, CardSaveBar, ChatbotExpandSuggestion, ChatbotInput, ChatbotPage, ChatbotPageBody, ChatbotPageContent, ChatbotPageConversation, ChatbotPageFooter, ChatbotPageHeading, ChatbotPageInputBar, ChatbotPanel, ChatbotPanelHeader, ChatbotPanelSuggestion, ChatbotPanelTrigger, ChatbotResponseBlock, ChatbotResponseLoading, ChatbotSidebar, ChatbotUserMessage, ComingSoonDescription, ComingSoonEmptyState, ComingSoonMedia, ComingSoonTitle, CommonForm, CopyButton, CopyButtonIcon, CopyButtonLabel, DashboardPage, DashboardPageBanner, DashboardPageContent, DashboardPageHeader, DashboardPageHeaderAction, DashboardPageHeaderActions, DashboardPageHeaderDescription, DashboardPageHeaderNav, DashboardPageHeaderNavBack, DashboardPageHeaderPrefix, DashboardPageHeaderSubtitle, DashboardPageHeaderTitle, DashboardPageHeaderTitleGroup, DashboardPageMain, DashboardPageTabs, DashboardSidebar, DashboardSidebarContent, DashboardSidebarFooter, DashboardSidebarGroup, DashboardSidebarGroupLabel, DashboardSidebarHeader, DashboardSidebarMenu, DashboardSidebarMenuBadge, DashboardSidebarMenuButton, DashboardSidebarMenuItem, DashboardSidebarNav, DashboardSidebarProvider, DashboardSidebarSubmenu, DashboardSidebarSubmenuItem, DashboardSidebarSubmenuSeparator, DashboardSidebarTrigger, DashboardStandalonePage, DashboardStandalonePageAction, DashboardStandalonePageActions, DashboardStandalonePageContent, DashboardStandalonePageHeader, DashboardStandalonePageTitle, DeprecatedBadge, EmptyState, EmptyStateActions, EmptyStateButton, EmptyStateDescription, EmptyStateExternalLink, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle, FormArrayField, FormComboboxField, FormFieldBase, FormFieldShell, FormInputField, FormMultiComboboxField, FormNumericField, FormOTPField, FormRadioGroupField, FormSelectField, FormSliderField, FormSwitchField, FormTextareaField, FormToggleGroupField, NewBadge, NoDataDescription, NoDataEmptyState, NoDataMedia, NoDataTitle, NoSupportDescription, NoSupportEmptyState, NoSupportMedia, NoSupportTitle, NotFoundDescription, NotFoundEmptyState, NotFoundMedia, NotFoundTitle, ProfessionalBadge, RestrictedAccessDescription, RestrictedAccessEmptyState, RestrictedAccessMedia, RestrictedAccessTitle, RoleBadge, SaveBar, SubmitButton, TrialBadge, UnknownErrorDescription, UnknownErrorEmptyState, UnknownErrorMedia, UnknownErrorTitle, fieldContext, formContext, toFieldErrors, useCopyToClipboard, useSidebar as useDashboardSidebar, useFieldContext, useForm, useFormContext, withFieldGroup, withForm };
3400
+ export { BetaBadge, CardSaveBar, ChatbotExpandSuggestion, ChatbotInput, ChatbotPage, ChatbotPageBody, ChatbotPageContent, ChatbotPageConversation, ChatbotPageFooter, ChatbotPageHeading, ChatbotPageInputBar, ChatbotPanel, ChatbotPanelHeader, ChatbotPanelSuggestion, ChatbotPanelTrigger, ChatbotResponseBlock, ChatbotResponseLoading, ChatbotSidebar, ChatbotUserMessage, ComingSoonDescription, ComingSoonEmptyState, ComingSoonMedia, ComingSoonTitle, CommonForm, CopyButton, CopyButtonIcon, CopyButtonLabel, DashboardPage, DashboardPageBanner, DashboardPageContent, DashboardPageHeader, DashboardPageHeaderAction, DashboardPageHeaderActions, DashboardPageHeaderDescription, DashboardPageHeaderNav, DashboardPageHeaderNavBack, DashboardPageHeaderPrefix, DashboardPageHeaderSubtitle, DashboardPageHeaderTitle, DashboardPageHeaderTitleGroup, DashboardPageMain, DashboardPageTabs, DashboardSidebar, DashboardSidebarContent, DashboardSidebarFooter, DashboardSidebarGroup, DashboardSidebarGroupLabel, DashboardSidebarHeader, DashboardSidebarMenu, DashboardSidebarMenuBadge, DashboardSidebarMenuButton, DashboardSidebarMenuItem, DashboardSidebarNav, DashboardSidebarProvider, DashboardSidebarSubmenu, DashboardSidebarSubmenuItem, DashboardSidebarSubmenuSeparator, DashboardSidebarTrigger, DashboardStandalonePage, DashboardStandalonePageAction, DashboardStandalonePageActions, DashboardStandalonePageContent, DashboardStandalonePageDescription, DashboardStandalonePageHeader, DashboardStandalonePageMain, DashboardStandalonePageTitle, DeprecatedBadge, EmptyState, EmptyStateActions, EmptyStateButton, EmptyStateDescription, EmptyStateExternalLink, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle, FormArrayField, FormComboboxField, FormFieldBase, FormFieldShell, FormInputField, FormMultiComboboxField, FormNumericField, FormOTPField, FormRadioGroupField, FormSelectField, FormSliderField, FormSwitchField, FormTextareaField, FormToggleGroupField, NewBadge, NoDataDescription, NoDataEmptyState, NoDataMedia, NoDataTitle, NoSupportDescription, NoSupportEmptyState, NoSupportMedia, NoSupportTitle, NotFoundDescription, NotFoundEmptyState, NotFoundMedia, NotFoundTitle, PERCENTAGE_THRESHOLDS, ProfessionalBadge, RestrictedAccessDescription, RestrictedAccessEmptyState, RestrictedAccessMedia, RestrictedAccessTitle, RoleBadge, SaveBar, ScoreBadge, SubmitButton, TrialBadge, UnknownErrorDescription, UnknownErrorEmptyState, UnknownErrorMedia, UnknownErrorTitle, fieldContext, formContext, toFieldErrors, useCopyToClipboard, useSidebar as useDashboardSidebar, useFieldContext, useForm, useFormContext, withFieldGroup, withForm };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aircall/blocks",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "sideEffects": [
@@ -81,7 +81,7 @@
81
81
  "vite": "7.3.1",
82
82
  "vitest": "4.0.17",
83
83
  "zod": "4.4.3",
84
- "@aircall/ds": "0.19.0",
84
+ "@aircall/ds": "0.21.0",
85
85
  "@aircall/hooks": "0.5.1"
86
86
  },
87
87
  "keywords": [
@@ -80,7 +80,7 @@ utils/data home or must stay in the consuming app.
80
80
 
81
81
  **UI with no blocks/ds equivalent yet**: some `@dashboard/library` UI components have
82
82
  no target in `@aircall/blocks` or `@aircall/ds` yet (e.g. `PieChart` and other charts,
83
- `AudioPlayer`, `TileLegend`). If a UI component is not in the routing table below and
83
+ `TileLegend`). If a UI component is not in the routing table below and
84
84
  not listed above, leave it imported from `@dashboard/library` for now — do not force a
85
85
  migration or invent a target.
86
86
 
@@ -100,6 +100,7 @@ migration or invent a target.
100
100
  | `List` + `ListItem` | `ItemGroup`, `Item`, `ItemMedia`, `ItemContent`, `ItemActions` (@aircall/ds) | load @aircall/blocks#aircall-blocks/migrate-dashboard/list | available |
101
101
  | `Tile` + `TileHeader` + `TileValue` | `Card` (+ parts) as the tile container (@aircall/ds) | load @aircall/blocks#aircall-blocks/migrate-dashboard/tile | available |
102
102
  | `GridLayout` + `GridItem` + `Gap` | native `<div>` + Tailwind grid/flex/gap utilities (NO component import needed — these are layout primitives) (@aircall/ds) | load @aircall/blocks#aircall-blocks/migrate-dashboard/layout | available |
103
+ | `AudioPlayer` | `Audio`, `AudioPlayerRow`, `AudioPlayerButton`, `AudioPlayerBar`, `AudioPlayerTime`, `AudioPlayerSpeed`, `AudioPlayerSkip`, `AudioPlayerManager`, `useAudioPlayer` (@aircall/ds) | load @aircall/ds#aircall-ds/migrate-audio-player | available |
103
104
  | `InfoPopup` + `InfoPopupTrigger` + `InfoPopupContent` | `HoverCard`, `HoverCardTrigger`, `HoverCardContent` (@aircall/ds) | load @aircall/blocks#aircall-blocks/migrate-dashboard/info-popup | available |
104
105
  | `CopyToClipboardButton` + `CopyToClipboardText` | `CopyButton`, `CopyButtonIcon`, `CopyButtonLabel` (@aircall/blocks) | load @aircall/blocks#aircall-blocks/migrate-dashboard/copy-button | available |
105
106
  | `ToggleRow` | `Field` (orientation="horizontal") + `Switch` + `FieldLabel` + optional `FieldDescription` (@aircall/ds) | load @aircall/blocks#aircall-blocks/migrate-dashboard/toggle-row | available |
@@ -29,32 +29,51 @@ Campaign Creation (`CampaignWizardPage`) and Add Contacts (`AddContactsPage`) ar
29
29
 
30
30
  ## 2. DashboardStandalonePage (full-page flows)
31
31
 
32
+ Four-part nesting: the outer `DashboardStandalonePage` is the `page-background` shell
33
+ (8px inset, flex row); `DashboardStandalonePageContent` is the rounded content **panel**
34
+ that holds the header + scroll area; the header carries a centered title (+ optional
35
+ description) with edge-pinned actions; `DashboardStandalonePageMain` is the scroll area.
36
+
32
37
  ```tsx
33
38
  import {
34
39
  DashboardStandalonePage,
40
+ DashboardStandalonePageContent,
35
41
  DashboardStandalonePageHeader,
36
42
  DashboardStandalonePageTitle,
43
+ DashboardStandalonePageDescription,
37
44
  DashboardStandalonePageActions,
38
45
  DashboardStandalonePageAction,
39
- DashboardStandalonePageContent
46
+ DashboardStandalonePageMain
40
47
  } from '@aircall/blocks';
41
48
 
42
49
  <DashboardStandalonePage>
43
- <DashboardStandalonePageHeader>
44
- <DashboardStandalonePageTitle>Create campaign</DashboardStandalonePageTitle>
45
- <DashboardStandalonePageActions>
46
- <DashboardStandalonePageAction onClick={onCancel}>Cancel</DashboardStandalonePageAction>
47
- </DashboardStandalonePageActions>
48
- </DashboardStandalonePageHeader>
49
50
  <DashboardStandalonePageContent>
50
- {/* a centered, width-capped Card — see below */}
51
+ <DashboardStandalonePageHeader>
52
+ <DashboardStandalonePageTitle>Create campaign</DashboardStandalonePageTitle>
53
+ {/* optional subtitle under the title */}
54
+ <DashboardStandalonePageDescription>Set up your outbound campaign</DashboardStandalonePageDescription>
55
+ {/* actions pin to an edge; side="start" pins left, default "end" pins right */}
56
+ <DashboardStandalonePageActions>
57
+ <DashboardStandalonePageAction onClick={onCancel}>Cancel</DashboardStandalonePageAction>
58
+ </DashboardStandalonePageActions>
59
+ </DashboardStandalonePageHeader>
60
+ <DashboardStandalonePageMain>
61
+ {/* a centered, width-capped Card — see below */}
62
+ </DashboardStandalonePageMain>
51
63
  </DashboardStandalonePageContent>
52
64
  </DashboardStandalonePage>
53
65
  ```
54
66
 
55
- ### What goes inside `DashboardStandalonePageContent` a centered, width-capped Card
67
+ - **Header** is a fixed 64px bar; the title (+ description) is optically centered and
68
+ actions are pinned to the left/right edges via `side` — declare them in any order.
69
+ - **Split layouts:** put a second panel (e.g. a `ChatbotPanel`) as a sibling of
70
+ `DashboardStandalonePageContent`; they share the shell's 8px gap. Hide a secondary
71
+ side panel below the `md` breakpoint (`className="hidden md:flex"`) so the main flow
72
+ gets the full width on narrow screens.
73
+
74
+ ### What goes inside `DashboardStandalonePageMain` — a centered, width-capped Card
56
75
 
57
- `DashboardStandalonePageContent` is a full-bleed scroll area; it does **not** constrain
76
+ `DashboardStandalonePageMain` is a full-bleed scroll area; it does **not** constrain
58
77
  width. The actual content is a centered container (`mx-auto` + a `max-w-*` cap) wrapping
59
78
  one or more `Card`s — the consistent pattern across the `DashboardStandalonePage` stories.
60
79
  Don't let content run full-width, and don't reach for `Paper`/raw page wrappers.
@@ -62,7 +81,7 @@ Don't let content run full-width, and don't reach for `Paper`/raw page wrappers.
62
81
  ```tsx
63
82
  import { Card, CardHeader, CardContent, CardFooter } from '@aircall/ds';
64
83
 
65
- <DashboardStandalonePageContent>
84
+ <DashboardStandalonePageMain>
66
85
  {/* single-column form / wizard */}
67
86
  <div className="mx-auto max-w-2xl pt-4">
68
87
  <Card>
@@ -71,13 +90,19 @@ import { Card, CardHeader, CardContent, CardFooter } from '@aircall/ds';
71
90
  <CardFooter className="justify-end gap-2">{/* Back / Next / Submit */}</CardFooter>
72
91
  </Card>
73
92
  </div>
74
- </DashboardStandalonePageContent>
93
+ </DashboardStandalonePageMain>
75
94
  ```
76
95
 
77
96
  Width cap by layout:
78
97
  - **Single-column form / wizard** → `mx-auto max-w-2xl pt-4` around one `Card` (CampaignWizard pattern).
79
98
  - **Wide / two-column** (form + summary aside) → `mx-auto flex max-w-7xl flex-col gap-6 pt-8 lg:flex-row`, a `Card` per column.
80
99
 
100
+ > **API note (renamed):** `DashboardStandalonePageContent` is now the rounded **panel**
101
+ > wrapper (header + main), and the scroll area is `DashboardStandalonePageMain` — matching
102
+ > the `DashboardPage` family (`Page → Content → Main`). Earlier code where
103
+ > `DashboardStandalonePageContent` was the scroll area must move that content into
104
+ > `DashboardStandalonePageMain` and wrap header+main in `DashboardStandalonePageContent`.
105
+
81
106
  ## 3. DashboardPage (standard in-app page)
82
107
 
83
108
  ```tsx
@@ -154,8 +179,10 @@ Wrong:
154
179
  Correct:
155
180
  ```tsx
156
181
  <DashboardStandalonePage>
157
- <DashboardStandalonePageHeader>...</DashboardStandalonePageHeader>
158
- <DashboardStandalonePageContent>{/* wizard */}</DashboardStandalonePageContent>
182
+ <DashboardStandalonePageContent>
183
+ <DashboardStandalonePageHeader>...</DashboardStandalonePageHeader>
184
+ <DashboardStandalonePageMain>{/* wizard */}</DashboardStandalonePageMain>
185
+ </DashboardStandalonePageContent>
159
186
  </DashboardStandalonePage>
160
187
  ```
161
188
 
@@ -155,6 +155,31 @@ Why one form: `useFormWizard().data` was a single accumulator across pages — a
155
155
  submits the complete object. Per-step `useForm`s would fragment that state and lose
156
156
  cross-step validation.
157
157
 
158
+ ## Validation UX — validate on click, don't disable
159
+
160
+ The `goNext` above is the **validate-on-click** pattern, and it's the one to reach for:
161
+ the Next button stays enabled, and clicking it validates the step and reveals what's
162
+ wrong. This is usually the product requirement ("never disable the button — tell me on
163
+ click"). It works because a **`'submit'`-cause validation also runs the field's
164
+ `onChange` validator** — so `form.validateField(name, 'submit')` populates errors even
165
+ for fields the user never touched, and each `Form*Field` renders them through its own
166
+ `FieldError`. (Verify against `form-core`'s `defaultValidationLogic`: the `submit` case
167
+ runs `[onChange, onBlur, onSubmit, …]`.)
168
+
169
+ - **Cross-field rules** ("at least one of A or B") can't be a single-field validator.
170
+ Put them on the parent `useForm`'s `validators`, or `safeParse` a step schema inside
171
+ `goNext`, and render the message where it belongs (e.g. a section-level `FieldError`).
172
+ - **i18n:** function validators can `return t(key)` directly; if you use a schema
173
+ library, build the schema in a factory that receives `t` (module-scope schemas can't
174
+ call `t`).
175
+ - If the design genuinely wants the button **disabled** until valid, subscribe
176
+ reactively instead — but this is the exception, not the default:
177
+ ```tsx
178
+ <form.Subscribe selector={s => isStepValid(s.values)}>
179
+ {ok => <Button disabled={!ok} onClick={() => setStep(next.id)}>Next</Button>}
180
+ </form.Subscribe>
181
+ ```
182
+
158
183
  ## Prop mapping
159
184
 
160
185
  | `FormWizardProps` / `useFormWizard` | After |
@@ -192,6 +217,22 @@ one field.
192
217
  **`necessityIndicator="required" | "optional"`** — mark only the exceptions (the few optional fields
193
218
  in a mostly-required form, or vice versa); never mix both markers in one form.
194
219
 
220
+ **Layout — `orientation` / `controlPosition`** — every wrapper defaults to a **vertical** field
221
+ (label above the control). Two shared props flip to a side-by-side layout, reusing the DS `Field`
222
+ orientation:
223
+ - `orientation="horizontal"` puts the label/description beside the control. `orientation="responsive"`
224
+ is vertical on narrow screens and horizontal once wide — it needs a `FieldGroup` ancestor (which
225
+ provides the `@container/field-group` the responsive variant reads).
226
+ - `controlPosition="start" | "end"` picks the control's side. Defaults per control:
227
+ `FormSwitchField` → `'start'` (switch left of its label), text/select/etc. → `'end'`.
228
+ - `FormSwitchField` **defaults to the inline (horizontal) layout** — a switch stacked under its label
229
+ is rarely wanted. A settings toggle: `<FormSwitchField label description>` (switch on the left);
230
+ pin the switch to the right with `controlPosition="end"`. Force the old stacked look with
231
+ `orientation="vertical"`.
232
+ - A responsive text field (label + description on the left, input + error on the right on desktop,
233
+ stacked on mobile): `<FormInputField orientation="responsive" label description … />` inside a
234
+ `FieldGroup`.
235
+
195
236
  ## Fields reference
196
237
 
197
238
  `@aircall/blocks` ships typed wrappers for all common DS controls; each takes `form`,
@@ -204,9 +245,45 @@ spread the control bundle onto the matching DS primitive.
204
245
  | text/email/password | `FormInputField` | radio group | `FormRadioGroupField` |
205
246
  | select | `FormSelectField` | OTP | `FormOTPField` |
206
247
  | combobox (single) | `FormComboboxField` | switch | `FormSwitchField` |
207
- | combobox (multi) | `FormMultiComboboxField` | number | `FormNumberField` |
248
+ | combobox (multi) | `FormMultiComboboxField` | number | `FormNumericField` |
208
249
  | textarea | `FormTextareaField` | slider | `FormSliderField` |
209
250
 
251
+ ## Multi-step gotchas
252
+
253
+ The `Stepper` **unmounts inactive `StepperContent`** (there's no `forceMount`). That's
254
+ usually fine — but two consequences bite:
255
+
256
+ - Field **values** persist in the form store across unmount, and so does field **meta**
257
+ (`isDirty` / `isTouched`) — a field's `mount()` cleanup is a no-op. So you can read
258
+ `form.state.fieldMeta['step1.field'].isDirty` while on step 3, and edits made on an
259
+ earlier step are still there when you submit. Add `forceMount` only if a step must
260
+ stay in the DOM.
261
+ - **Re-basing a saved draft: do NOT use `form.reset(values)`.** `reset` clears
262
+ `isTouched`, and when an unmounted step later remounts its field re-seeds to the
263
+ (empty) default — silently wiping the user's value. To mark the current values as
264
+ "synced" (e.g. after an autosave / draft save) while keeping them editable, clear
265
+ `isDirty` per field instead:
266
+ ```tsx
267
+ for (const path of Object.keys(form.state.fieldMeta))
268
+ form.setFieldMeta(path, m => ({ ...m, isDirty: false }));
269
+ ```
270
+ Now `isDirty` marks exactly what changed since the last sync — ideal for sending a
271
+ minimal update patch on the next save.
272
+
273
+ ## Field groups for large steps (optional)
274
+
275
+ For steps with many fields you can extract each into a `withFieldGroup` bound to a
276
+ subtree of the form, instead of listing everything in one flat `useForm`. Two things
277
+ to know:
278
+
279
+ - `fields` accepts a **subtree key** (`fields="general"`) **or** a **`FieldsMap`**
280
+ (`fields={{ groupField: 'form.path' }}`) — the map lets a group bind onto a flat
281
+ model without restructuring it.
282
+ - A field group has **no group-level `validators`**. Put cross-field rules on a
283
+ field-level validator that reads its sibling via `fieldApi.form.getFieldValue(...)`,
284
+ or on the parent `useForm`. Use `group.Subscribe` for reactive reads inside the group
285
+ (its `state.values` is the group's subset).
286
+
210
287
  ## Common Mistakes
211
288
 
212
289
  ### 1. Fabricating a `Stepper` instance API (`useStepper({steps})`, `nextStep`, `stepper=` prop)
@@ -288,3 +365,38 @@ Correct:
288
365
  `SubmitButton`/`CardSaveBar` are registered form components — they read `canSubmit`/`isSubmitting` from form context and must be rendered via `form.AppForm`, not handed a `form` prop. They must also sit inside the `<form>` so their `type="submit"` triggers `onSubmit`.
289
366
 
290
367
  Source: `packages/blocks/src/form/use-form.ts`
368
+
369
+ ### 5. Re-basing a multi-step draft with `form.reset`
370
+
371
+ Wrong:
372
+ ```tsx
373
+ await saveDraft(form.state.values);
374
+ form.reset(form.state.values); // clears isTouched → remounted steps re-seed to empty
375
+ ```
376
+
377
+ Correct:
378
+ ```tsx
379
+ await saveDraft(form.state.values);
380
+ for (const path of Object.keys(form.state.fieldMeta))
381
+ form.setFieldMeta(path, m => ({ ...m, isDirty: false })); // keeps values + isTouched
382
+ ```
383
+
384
+ `form.reset` is right for a single-screen form, but inside a `Stepper` (which unmounts inactive steps) it makes fields re-seed to their defaults on the next remount, wiping user input. Clearing `isDirty` re-bases the "changed since last sync" baseline without touching values.
385
+
386
+ Source: `packages/blocks/src/form/use-form.ts`
387
+
388
+ ## Testing (jsdom)
389
+
390
+ - **Drive navigation one step at a time.** `Next` handlers are async (validation runs
391
+ before advancing), so fire the click, **wait for the next step to render**, then
392
+ continue. Two back-to-back clicks race the async nav and both land on the same step.
393
+ ```tsx
394
+ fireEvent.click(getByTestId('next'));
395
+ await screen.findByText('Role'); // step 2 is on screen
396
+ fireEvent.click(getByTestId('next'));
397
+ ```
398
+ - **Assert errors without filling every field.** Because a `'submit'`-cause validation
399
+ runs the `onChange` validators (see "Validation UX"), clicking Next on an empty step
400
+ surfaces each field's `FieldError` — assert on those directly.
401
+ - **Field meta survives step changes**, so a test can edit a field on step 1, navigate
402
+ to a later step, submit, and still see the step-1 value in the payload.