@aircall/blocks
Version:
Aircall Blocks — higher-level UI compositions built on @aircall/ds
11,053 lines • 466 kB
JavaScript
import { t as ChatbotLoadingBlock } from "./chatbot-loading-block-DCJoZhC2.js";
import { Alert, AlertAction, AlertDescription, AlertTitle, Anchor, Attachment, AttachmentContent, AttachmentMedia, AttachmentTitle, Badge, Button, ButtonGroup, Calendar, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle, Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, CounterBadge, CountryFlag, DataCombobox, DialogClose, DialogContent, DialogTitle, DropdownMenu, DropdownMenuAddon, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLabelAside, FieldLabelInfo, FieldLabelRow, HoverCard, HoverCardContent, HoverCardTrigger, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupTextarea, Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle, Popover, PopoverContent, PopoverTrigger, ScrollArea, Separator, Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, Sidebar, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarProvider, Skeleton, Slider, Spinner, Stepper, StepperContent, StepperCounter, StepperProgress, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TabsList, Toggle, ToggleGroup, ToggleGroupItem, Tooltip, TooltipContent, TooltipTrigger, buttonVariants, cn, registerI18nNamespace, useComboboxAnchor, useDsLocale, useDsTranslation, useSidebar } from "@aircall/ds";
import * as React from "react";
import { Suspense, createContext, lazy, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { AiAgents, AiAssist, AiAssistPro, AircallLoader, AircallLogo, AircallMark, ArrowDownRight, ArrowLeft, ArrowRight, ArrowUp, ArrowUpRight, BookUser, Check, ChevronDown, ChevronLeft, ChevronRight, Copy, Ellipsis, ExternalLink, FileText, Frown, Globe, History, ImageOff, Info, Languages, ListFilter, LockKeyhole, Meh, Menu, MessageSquare, Minus, PanelLeft, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, Plus, Search, Settings, Smile, SocialWhatsappOriginal, Sprout, Square, SquareArrowOutUpRight, Star, ThumbsDown, ThumbsDownFilled, ThumbsUp, ThumbsUpFilled, TriangleAlert, X, XIcon, Zap } from "@aircall/react-icons";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useCallbackRef, useComposedRefs, useControllableState, useIsMounted, usePrevious, useSafeLayoutEffect, useUnmount } from "@aircall/hooks";
import { cva } from "class-variance-authority";
import { createPortal } from "react-dom";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { COUNTRIES, PHONE_NUMBER_CHARACTER_REGEXP, formatAsYouType, getCountryByCode, guessCountryCode, isPartialPhoneNumber, stripHashCharacters } from "@aircall/numbers";
import { Popover as Popover$1 } from "@base-ui/react/popover";
import { addDays, endOfDay, endOfMonth, startOfDay, startOfMonth, startOfWeek, subDays, subMonths } from "date-fns";
import { rangeContainsModifiers } from "react-day-picker";
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
//#endregion
//#region src/i18n/register.ts
registerI18nNamespace("blocks", {
en: {
"emptyState.comingSoon.title": "Coming soon!",
"emptyState.comingSoon.description": "You'll be able to access this feature in the near future.",
"emptyState.noData.title": "No results matching",
"emptyState.noData.description": "Try resetting your search or removing a filter.",
"emptyState.restrictedAccess.title": "You don't have access to this page.",
"emptyState.restrictedAccess.description": "Contact an admin on your team to get access.",
"emptyState.unknownError.title": "We couldn't load this page",
"emptyState.unknownError.description": "Something went wrong while loading. Try again to load the data.",
"emptyState.notFound.title": "We couldn't find that page",
"emptyState.notFound.description": "It may have been moved, deleted, or the link may be incorrect.",
"emptyState.noSupport.title": "We can't support this page",
"emptyState.noSupport.description": "Try updating your browser to the latest version.",
"chatbotInput.placeholder": "Ask anything...",
"chatbotInput.messageLabel": "Message",
"chatbotInput.stopGenerating": "Stop generating",
"chatbotInput.sendMessage": "Send message",
"chatbotInput.conversationHistory": "Conversation history",
"chatbotInput.noConversations": "No past conversations",
"chatbotInput.recentChatHistory": "Recent chat history",
"productBadge.beta": "Beta",
"productBadge.new": "New",
"productBadge.deprecated": "Deprecated",
"productBadge.trial": "Trial",
"productBadge.professional": "Professional",
"licenseBadge.aiAssist": "AI Assist",
"licenseBadge.aiAssistPro": "AI Assist Pro",
"licenseBadge.whatsapp": "Whatsapp",
"licenseBadge.analyticsPlus": "Analytics+",
"licenseBadge.surveys": "Surveys",
"licenseAlert.dismiss": "Dismiss",
"licenseAlert.aiAgents": "AI Agents",
"roleBadge.agent": "Agent",
"roleBadge.supervisor": "Supervisor",
"roleBadge.admin": "Admin",
"roleBadge.owner": "Owner",
"sentiment.positive": "Positive",
"sentiment.negative": "Negative",
"sentiment.neutral": "Neutral",
"chatbotResponse.copy": "Copy",
"chatbotResponse.helpful": "Helpful",
"chatbotResponse.notHelpful": "Not helpful",
"chatbotResponse.sourceCount": "{{count}} Source",
"chatbotResponse.sourceCount_plural": "{{count}} Sources",
"chatbotResponse.openSource": "Open {{title}}",
"chatbotPanel.sendFeedback": "Send feedback",
"chatbotPanel.collapsePanel": "Collapse panel",
"chatbotPanel.expandPanel": "Expand panel",
"chatbotPanel.closePanel": "Close panel",
"chatbotPanel.minimize": "Minimize",
"chatbotPanel.goToFullPage": "Go to full page",
"chatbotSidebar.newChat": "New chat",
"chatbotSidebar.optionsFor": "Options for {{title}}",
"chatbotPage.footerAskAircallLink": "Ask Aircall",
"chatbotPage.footerBeforeLink": " uses AI and can make mistakes. Since it's in beta, using it means accepting Aircall's ",
"chatbotPage.betaTermsLink": "Beta terms",
"chatbotPage.footerAfterLink": ".",
"chatbotPage.scrollToBottom": "Scroll to bottom",
"chatbotPanel.title": "Ask Aircall",
"chatbotPanel.emptyTitle": "Need help with Aircall?",
"chatbotPanel.emptyDescription": "Find answers, explore features, and get help understanding Aircall",
"chatbotPanel.disclaimerBefore": "You are interacting with an AI assistant (Beta). By using this feature you accept Aircall's ",
"chatbotPanel.disclaimerLink": "Beta terms",
"chatbotPanel.disclaimerAfter": ". Responses may be inaccurate or incomplete. Please verify before relying on them.",
"chatbotPanel.resizeHandle": "Resize panel",
"select.noResults": "No results found.",
"chatbotResponse.renderingComponent": "Rendering component...",
"chatbotResponse.thinking": "Thinking...",
"chatbotResponse.mermaidErrorTitle": "Couldn't render diagram",
"chatbotResponse.mermaidErrorDescription": "Something went wrong while rendering this diagram.",
"dashboardFilters.allFilters": "All filters",
"dashboardFilters.apply": "Apply",
"dashboardFilters.clearAll": "Clear all",
"dashboardFilters.reset": "Reset",
"dashboardFilters.resultCount": "{{count}} result",
"dashboardFilters.searchPlaceholder": "Search",
"dashboardFilters.resultCount_plural": "{{count}} results",
"filterCountry.placeholder": "Country",
"filterCountry.countValue": "{{count}} country",
"filterCountry.countValue_plural": "{{count}} countries",
"filterLanguage.placeholder": "Language",
"filterLanguage.countValue": "{{count}} language",
"filterLanguage.countValue_plural": "{{count}} languages",
"languageSelect.placeholder": "Select a language",
"languageSelect.placeholderMultiple": "Select languages",
"dashboardFilters.selectPlaceholder": "Select",
"dashboardFilters.title": "Filters",
"dateRange.lastMonth": "Last month",
"dateRange.lastSixMonths": "Last 6 months",
"dateRange.lastWeek": "Last week",
"dateRange.thisMonth": "This month",
"dateRange.thisWeek": "This week",
"dateRange.today": "Today",
"dateRange.yesterday": "Yesterday",
"filter.clear": "Clear filter",
"filterRange.from": "From",
"filterRange.to": "To",
"daysPicker.mon": "M",
"daysPicker.tue": "T",
"daysPicker.wed": "W",
"daysPicker.thu": "T",
"daysPicker.fri": "F",
"daysPicker.sat": "S",
"daysPicker.sun": "S",
"daysPicker.mon.full": "Monday",
"daysPicker.tue.full": "Tuesday",
"daysPicker.wed.full": "Wednesday",
"daysPicker.thu.full": "Thursday",
"daysPicker.fri.full": "Friday",
"daysPicker.sat.full": "Saturday",
"daysPicker.sun.full": "Sunday",
"phoneInput.selectCountry": "Select country",
"phoneInput.searchCountry": "Search for a country",
"phoneInput.setAsDefault": "Set {{country}} as default",
"wizard.back": "Previous step",
"wizard.skip": "Skip",
"wizard.next": "Next step",
"bubble.moreMedia": "+{{count}}",
"bubble.moreMedia_plural": "+{{count}}",
"bubble.unavailableMedia": "Unavailable media",
"bubble.hint": "More info",
"bubble.download": "Download",
"bubble.mediaViewerTitle": "Media viewer",
"bubble.openMedia": "Open media",
"bubble.closeMediaViewer": "Close media viewer"
},
fr: {
"emptyState.comingSoon.title": "Bientôt disponible !",
"emptyState.comingSoon.description": "Vous pourrez accéder à cette fonctionnalité dans un futur proche.",
"emptyState.noData.title": "Aucun résultat correspondant",
"emptyState.noData.description": "Réinitialisez votre recherche ou supprimez un filtre.",
"emptyState.restrictedAccess.title": "Vous n'avez pas accès à cette page.",
"emptyState.restrictedAccess.description": "Contactez un administrateur de votre équipe pour obtenir l'accès.",
"emptyState.unknownError.title": "Impossible de charger cette page",
"emptyState.unknownError.description": "Un problème est survenu lors du chargement. Réessayez pour charger les données.",
"emptyState.notFound.title": "Impossible de trouver cette page",
"emptyState.notFound.description": "Elle a peut-être été déplacée, supprimée, ou le lien est incorrect.",
"emptyState.noSupport.title": "Nous ne pouvons pas prendre en charge cette page",
"emptyState.noSupport.description": "Essayez de mettre à jour votre navigateur vers la dernière version.",
"chatbotInput.placeholder": "Posez votre question...",
"chatbotInput.messageLabel": "Message",
"chatbotInput.stopGenerating": "Arrêter la génération",
"chatbotInput.sendMessage": "Envoyer un message",
"chatbotInput.conversationHistory": "Historique des conversations",
"chatbotInput.noConversations": "Aucune conversation passée",
"chatbotInput.recentChatHistory": "Historique récent des chats",
"productBadge.beta": "Bêta",
"productBadge.new": "Nouveau",
"productBadge.deprecated": "Obsolète",
"productBadge.trial": "Essai",
"productBadge.professional": "Professionnel",
"licenseBadge.aiAssist": "AI Assist",
"licenseBadge.aiAssistPro": "AI Assist Pro",
"licenseBadge.whatsapp": "Whatsapp",
"licenseBadge.analyticsPlus": "Analytics+",
"licenseBadge.surveys": "Sondages",
"licenseAlert.dismiss": "Ignorer",
"licenseAlert.aiAgents": "AI Agents",
"roleBadge.agent": "Agent",
"roleBadge.supervisor": "Superviseur",
"roleBadge.admin": "Admin",
"roleBadge.owner": "Propriétaire",
"sentiment.positive": "Positif",
"sentiment.negative": "Négatif",
"sentiment.neutral": "Neutre",
"chatbotResponse.copy": "Copier",
"chatbotResponse.helpful": "Utile",
"chatbotResponse.notHelpful": "Pas utile",
"chatbotResponse.sourceCount": "{{count}} Source",
"chatbotResponse.sourceCount_plural": "{{count}} Sources",
"chatbotResponse.openSource": "Ouvrir {{title}}",
"chatbotPanel.sendFeedback": "Envoyer un commentaire",
"chatbotPanel.collapsePanel": "Réduire le panneau",
"chatbotPanel.expandPanel": "Développer le panneau",
"chatbotPanel.closePanel": "Fermer le panneau",
"chatbotPanel.minimize": "Réduire",
"chatbotPanel.goToFullPage": "Aller à la page complète",
"chatbotSidebar.newChat": "Nouvelle conversation",
"chatbotSidebar.optionsFor": "Options pour {{title}}",
"chatbotPage.footerAskAircallLink": "Demander à Aircall",
"chatbotPage.footerBeforeLink": " utilise l'IA et peut faire des erreurs. Comme c'est en bêta, l'utiliser signifie accepter les ",
"chatbotPage.betaTermsLink": "conditions Bêta",
"chatbotPage.footerAfterLink": " d'Aircall.",
"chatbotPage.scrollToBottom": "Défiler vers le bas",
"chatbotPanel.title": "Demander à Aircall",
"chatbotPanel.emptyTitle": "Besoin d'aide avec Aircall ?",
"chatbotPanel.emptyDescription": "Trouvez des réponses, explorez les fonctionnalités et obtenez de l’aide pour comprendre Aircall",
"chatbotPanel.disclaimerBefore": "Vous interagissez avec un assistant IA (bêta). En utilisant cette fonctionnalité, vous acceptez les ",
"chatbotPanel.disclaimerLink": "conditions de la bêta",
"chatbotPanel.disclaimerAfter": " d’Aircall. Les réponses peuvent être inexactes ou incomplètes. Veuillez les vérifier avant de vous y fier.",
"chatbotPanel.resizeHandle": "Redimensionner le panneau",
"select.noResults": "Aucun résultat.",
"chatbotResponse.renderingComponent": "Rendu du composant...",
"chatbotResponse.thinking": "Réflexion en cours...",
"chatbotResponse.mermaidErrorTitle": "Impossible d'afficher le diagramme",
"chatbotResponse.mermaidErrorDescription": "Une erreur s'est produite lors de l'affichage de ce diagramme.",
"dashboardFilters.allFilters": "Tous les filtres",
"dashboardFilters.apply": "Appliquer",
"dashboardFilters.clearAll": "Tout effacer",
"dashboardFilters.reset": "Réinitialiser",
"dashboardFilters.resultCount": "{{count}} résultat",
"dashboardFilters.searchPlaceholder": "Rechercher",
"dashboardFilters.resultCount_plural": "{{count}} résultats",
"filterCountry.placeholder": "Pays",
"filterCountry.countValue": "{{count}} pays",
"filterCountry.countValue_plural": "{{count}} pays",
"filterLanguage.placeholder": "Langue",
"filterLanguage.countValue": "{{count}} langue",
"filterLanguage.countValue_plural": "{{count}} langues",
"languageSelect.placeholder": "Choisir une langue",
"languageSelect.placeholderMultiple": "Choisir des langues",
"dashboardFilters.selectPlaceholder": "Sélectionner",
"dashboardFilters.title": "Filtres",
"dateRange.lastMonth": "Le mois dernier",
"dateRange.lastSixMonths": "Les 6 derniers mois",
"dateRange.lastWeek": "La semaine derniere",
"dateRange.thisMonth": "Ce mois-ci",
"dateRange.thisWeek": "Cette semaine",
"dateRange.today": "Aujourd'hui",
"dateRange.yesterday": "Hier",
"filter.clear": "Effacer le filtre",
"filterRange.from": "De",
"filterRange.to": "À",
"daysPicker.mon": "L",
"daysPicker.tue": "M",
"daysPicker.wed": "M",
"daysPicker.thu": "J",
"daysPicker.fri": "V",
"daysPicker.sat": "S",
"daysPicker.sun": "D",
"daysPicker.mon.full": "Lundi",
"daysPicker.tue.full": "Mardi",
"daysPicker.wed.full": "Mercredi",
"daysPicker.thu.full": "Jeudi",
"daysPicker.fri.full": "Vendredi",
"daysPicker.sat.full": "Samedi",
"daysPicker.sun.full": "Dimanche",
"phoneInput.selectCountry": "Sélectionner un pays",
"phoneInput.searchCountry": "Rechercher un pays",
"phoneInput.setAsDefault": "Définir {{country}} par défaut",
"wizard.back": "Étape précédente",
"wizard.skip": "Ignorer",
"wizard.next": "Étape suivante",
"bubble.moreMedia": "+{{count}}",
"bubble.moreMedia_plural": "+{{count}}",
"bubble.unavailableMedia": "Média indisponible",
"bubble.hint": "Plus d'infos",
"bubble.download": "Télécharger",
"bubble.mediaViewerTitle": "Visionneuse de médias",
"bubble.openMedia": "Ouvrir le média",
"bubble.closeMediaViewer": "Fermer la visionneuse de médias"
},
es: {
"emptyState.comingSoon.title": "¡Próximamente!",
"emptyState.comingSoon.description": "Podrás acceder a esta función en un futuro próximo.",
"emptyState.noData.title": "No hay resultados que coincidan",
"emptyState.noData.description": "Intenta restablecer tu búsqueda o eliminar un filtro.",
"emptyState.restrictedAccess.title": "No tienes acceso a esta página.",
"emptyState.restrictedAccess.description": "Contacta a un administrador de tu equipo para obtener acceso.",
"emptyState.unknownError.title": "No pudimos cargar esta página",
"emptyState.unknownError.description": "Algo salió mal durante la carga. Inténtalo de nuevo para cargar los datos.",
"emptyState.notFound.title": "No pudimos encontrar esa página",
"emptyState.notFound.description": "Es posible que haya sido movida, eliminada, o el enlace sea incorrecto.",
"emptyState.noSupport.title": "No podemos mostrar esta página",
"emptyState.noSupport.description": "Intenta actualizar tu navegador a la última versión.",
"chatbotInput.placeholder": "Pregunta lo que quieras...",
"chatbotInput.messageLabel": "Mensaje",
"chatbotInput.stopGenerating": "Detener la generación",
"chatbotInput.sendMessage": "Enviar mensaje",
"chatbotInput.conversationHistory": "Historial de conversaciones",
"chatbotInput.noConversations": "No hay conversaciones pasadas",
"chatbotInput.recentChatHistory": "Historial de chats recientes",
"productBadge.beta": "Beta",
"productBadge.new": "Nuevo",
"productBadge.deprecated": "Obsoleto",
"productBadge.trial": "Prueba",
"productBadge.professional": "Profesional",
"licenseBadge.aiAssist": "AI Assist",
"licenseBadge.aiAssistPro": "AI Assist Pro",
"licenseBadge.whatsapp": "Whatsapp",
"licenseBadge.analyticsPlus": "Analytics+",
"licenseBadge.surveys": "Encuestas",
"licenseAlert.dismiss": "Descartar",
"licenseAlert.aiAgents": "AI Agents",
"roleBadge.agent": "Agente",
"roleBadge.supervisor": "Supervisor",
"roleBadge.admin": "Administrador",
"roleBadge.owner": "Propietario",
"sentiment.positive": "Positivo",
"sentiment.negative": "Negativo",
"sentiment.neutral": "Neutral",
"chatbotResponse.copy": "Copiar",
"chatbotResponse.helpful": "Útil",
"chatbotResponse.notHelpful": "No útil",
"chatbotResponse.sourceCount": "{{count}} Fuente",
"chatbotResponse.sourceCount_plural": "{{count}} Fuentes",
"chatbotResponse.openSource": "Abrir {{title}}",
"chatbotPanel.sendFeedback": "Enviar comentarios",
"chatbotPanel.collapsePanel": "Contraer panel",
"chatbotPanel.expandPanel": "Expandir panel",
"chatbotPanel.closePanel": "Cerrar panel",
"chatbotPanel.minimize": "Minimizar",
"chatbotPanel.goToFullPage": "Ir a la página completa",
"chatbotSidebar.newChat": "Nueva conversación",
"chatbotSidebar.optionsFor": "Opciones para {{title}}",
"chatbotPage.footerAskAircallLink": "Preguntar a Aircall",
"chatbotPage.footerBeforeLink": " usa IA y puede cometer errores. Como está en beta, usarlo implica aceptar los ",
"chatbotPage.betaTermsLink": "términos Beta",
"chatbotPage.footerAfterLink": " de Aircall.",
"chatbotPage.scrollToBottom": "Desplazarse hacia abajo",
"chatbotPanel.title": "Preguntar a Aircall",
"chatbotPanel.emptyTitle": "¿Necesitas ayuda con Aircall?",
"chatbotPanel.emptyDescription": "Encuentra respuestas, explora funciones y obtén ayuda para entender Aircall",
"chatbotPanel.disclaimerBefore": "Estás interactuando con un asistente de IA (Beta). Al usar esta función, aceptas los ",
"chatbotPanel.disclaimerLink": "términos de la Beta",
"chatbotPanel.disclaimerAfter": " de Aircall. Las respuestas pueden ser inexactas o incompletas. Verifícalas antes de confiar en ellas.",
"chatbotPanel.resizeHandle": "Redimensionar el panel",
"select.noResults": "No se encontraron resultados.",
"chatbotResponse.renderingComponent": "Renderizando componente...",
"chatbotResponse.thinking": "Pensando...",
"chatbotResponse.mermaidErrorTitle": "No se pudo renderizar el diagrama",
"chatbotResponse.mermaidErrorDescription": "Se produjo un error al renderizar este diagrama.",
"dashboardFilters.allFilters": "Todos los filtros",
"dashboardFilters.apply": "Aplicar",
"dashboardFilters.clearAll": "Borrar todo",
"dashboardFilters.reset": "Restablecer",
"dashboardFilters.resultCount": "{{count}} resultado",
"dashboardFilters.searchPlaceholder": "Buscar",
"dashboardFilters.resultCount_plural": "{{count}} resultados",
"filterCountry.placeholder": "País",
"filterCountry.countValue": "{{count}} país",
"filterCountry.countValue_plural": "{{count}} países",
"filterLanguage.placeholder": "Idioma",
"filterLanguage.countValue": "{{count}} idioma",
"filterLanguage.countValue_plural": "{{count}} idiomas",
"languageSelect.placeholder": "Elegir un idioma",
"languageSelect.placeholderMultiple": "Elegir idiomas",
"dashboardFilters.selectPlaceholder": "Seleccionar",
"dashboardFilters.title": "Filtros",
"dateRange.lastMonth": "Mes pasado",
"dateRange.lastSixMonths": "Ultimos 6 meses",
"dateRange.lastWeek": "Semana pasada",
"dateRange.thisMonth": "Este mes",
"dateRange.thisWeek": "Esta semana",
"dateRange.today": "Hoy",
"dateRange.yesterday": "Ayer",
"filter.clear": "Borrar filtro",
"filterRange.from": "Desde",
"filterRange.to": "Hasta",
"daysPicker.mon": "L",
"daysPicker.tue": "M",
"daysPicker.wed": "X",
"daysPicker.thu": "J",
"daysPicker.fri": "V",
"daysPicker.sat": "S",
"daysPicker.sun": "D",
"daysPicker.mon.full": "Lunes",
"daysPicker.tue.full": "Martes",
"daysPicker.wed.full": "Miércoles",
"daysPicker.thu.full": "Jueves",
"daysPicker.fri.full": "Viernes",
"daysPicker.sat.full": "Sábado",
"daysPicker.sun.full": "Domingo",
"phoneInput.selectCountry": "Seleccionar país",
"phoneInput.searchCountry": "Buscar un país",
"phoneInput.setAsDefault": "Establecer {{country}} como predeterminado",
"wizard.back": "Paso anterior",
"wizard.skip": "Omitir",
"wizard.next": "Paso siguiente",
"bubble.moreMedia": "+{{count}}",
"bubble.moreMedia_plural": "+{{count}}",
"bubble.unavailableMedia": "Contenido multimedia no disponible",
"bubble.hint": "Más información",
"bubble.download": "Descargar",
"bubble.mediaViewerTitle": "Visor de contenido multimedia",
"bubble.openMedia": "Abrir contenido multimedia",
"bubble.closeMediaViewer": "Cerrar el visor de contenido multimedia"
},
de: {
"emptyState.comingSoon.title": "Demnächst verfügbar!",
"emptyState.comingSoon.description": "Sie werden in naher Zukunft auf diese Funktion zugreifen können.",
"emptyState.noData.title": "Keine passenden Ergebnisse",
"emptyState.noData.description": "Versuchen Sie, Ihre Suche zurückzusetzen oder einen Filter zu entfernen.",
"emptyState.restrictedAccess.title": "Sie haben keinen Zugriff auf diese Seite.",
"emptyState.restrictedAccess.description": "Wenden Sie sich an einen Administrator in Ihrem Team, um Zugriff zu erhalten.",
"emptyState.unknownError.title": "Diese Seite konnte nicht geladen werden",
"emptyState.unknownError.description": "Beim Laden ist ein Problem aufgetreten. Versuchen Sie es erneut, um die Daten zu laden.",
"emptyState.notFound.title": "Diese Seite konnte nicht gefunden werden",
"emptyState.notFound.description": "Sie wurde möglicherweise verschoben, gelöscht, oder der Link ist falsch.",
"emptyState.noSupport.title": "Diese Seite wird nicht unterstützt",
"emptyState.noSupport.description": "Versuchen Sie, Ihren Browser auf die neueste Version zu aktualisieren.",
"chatbotInput.placeholder": "Stellen Sie eine Frage...",
"chatbotInput.messageLabel": "Nachricht",
"chatbotInput.stopGenerating": "Generierung stoppen",
"chatbotInput.sendMessage": "Nachricht senden",
"chatbotInput.conversationHistory": "Gesprächsverlauf",
"chatbotInput.noConversations": "Keine vergangenen Gespräche",
"chatbotInput.recentChatHistory": "Letzter Chatverlauf",
"productBadge.beta": "Beta",
"productBadge.new": "Neu",
"productBadge.deprecated": "Veraltet",
"productBadge.trial": "Testversion",
"productBadge.professional": "Professional",
"licenseBadge.aiAssist": "AI Assist",
"licenseBadge.aiAssistPro": "AI Assist Pro",
"licenseBadge.whatsapp": "Whatsapp",
"licenseBadge.analyticsPlus": "Analytics+",
"licenseBadge.surveys": "Erhebungen",
"licenseAlert.dismiss": "Schließen",
"licenseAlert.aiAgents": "AI Agents",
"roleBadge.agent": "Agent",
"roleBadge.supervisor": "Supervisor",
"roleBadge.admin": "Admin",
"roleBadge.owner": "Inhaber",
"sentiment.positive": "Positiv",
"sentiment.negative": "Negativ",
"sentiment.neutral": "Neutral",
"chatbotResponse.copy": "Kopieren",
"chatbotResponse.helpful": "Hilfreich",
"chatbotResponse.notHelpful": "Nicht hilfreich",
"chatbotResponse.sourceCount": "{{count}} Quelle",
"chatbotResponse.sourceCount_plural": "{{count}} Quellen",
"chatbotResponse.openSource": "{{title}} öffnen",
"chatbotPanel.sendFeedback": "Feedback senden",
"chatbotPanel.collapsePanel": "Panel einklappen",
"chatbotPanel.expandPanel": "Panel ausklappen",
"chatbotPanel.closePanel": "Panel schließen",
"chatbotPanel.minimize": "Minimieren",
"chatbotPanel.goToFullPage": "Zur vollständigen Seite",
"chatbotSidebar.newChat": "Neuer Chat",
"chatbotSidebar.optionsFor": "Optionen für {{title}}",
"chatbotPage.footerAskAircallLink": "Aircall fragen",
"chatbotPage.footerBeforeLink": " nutzt KI und kann Fehler machen. Da es sich in der Beta-Phase befindet, bedeutet die Nutzung, dass Sie die ",
"chatbotPage.betaTermsLink": "Beta-Bedingungen",
"chatbotPage.footerAfterLink": " von Aircall akzeptieren.",
"chatbotPage.scrollToBottom": "Nach unten scrollen",
"chatbotPanel.title": "Aircall fragen",
"chatbotPanel.emptyTitle": "Brauchen Sie Hilfe mit Aircall?",
"chatbotPanel.emptyDescription": "Finden Sie Antworten, entdecken Sie Funktionen und erhalten Sie Hilfe beim Verständnis von Aircall",
"chatbotPanel.disclaimerBefore": "Sie interagieren mit einem KI-Assistenten (Beta). Durch die Nutzung dieser Funktion akzeptieren Sie die ",
"chatbotPanel.disclaimerLink": "Beta-Bedingungen",
"chatbotPanel.disclaimerAfter": " von Aircall. Antworten können ungenau oder unvollständig sein. Bitte überprüfen Sie sie, bevor Sie sich darauf verlassen.",
"chatbotPanel.resizeHandle": "Panel-Größe ändern",
"select.noResults": "Keine Ergebnisse gefunden.",
"chatbotResponse.renderingComponent": "Komponente wird geladen...",
"chatbotResponse.thinking": "Wird bearbeitet...",
"chatbotResponse.mermaidErrorTitle": "Diagramm konnte nicht gerendert werden",
"chatbotResponse.mermaidErrorDescription": "Beim Rendern dieses Diagramms ist ein Fehler aufgetreten.",
"dashboardFilters.allFilters": "Alle Filter",
"dashboardFilters.apply": "Anwenden",
"dashboardFilters.clearAll": "Alle löschen",
"dashboardFilters.reset": "Zurücksetzen",
"dashboardFilters.resultCount": "{{count}} Ergebnis",
"dashboardFilters.searchPlaceholder": "Suchen",
"dashboardFilters.resultCount_plural": "{{count}} Ergebnisse",
"filterCountry.placeholder": "Land",
"filterCountry.countValue": "{{count}} Land",
"filterCountry.countValue_plural": "{{count}} Länder",
"filterLanguage.placeholder": "Sprache",
"filterLanguage.countValue": "{{count}} Sprache",
"filterLanguage.countValue_plural": "{{count}} Sprachen",
"languageSelect.placeholder": "Sprache auswählen",
"languageSelect.placeholderMultiple": "Sprachen auswählen",
"dashboardFilters.selectPlaceholder": "Auswählen",
"dashboardFilters.title": "Filter",
"dateRange.lastMonth": "Letzter Monat",
"dateRange.lastSixMonths": "Letzte 6 Monate",
"dateRange.lastWeek": "Letzte Woche",
"dateRange.thisMonth": "Dieser Monat",
"dateRange.thisWeek": "Diese Woche",
"dateRange.today": "Heute",
"dateRange.yesterday": "Gestern",
"filter.clear": "Filter löschen",
"filterRange.from": "Von",
"filterRange.to": "Bis",
"daysPicker.mon": "M",
"daysPicker.tue": "D",
"daysPicker.wed": "M",
"daysPicker.thu": "D",
"daysPicker.fri": "F",
"daysPicker.sat": "S",
"daysPicker.sun": "S",
"daysPicker.mon.full": "Montag",
"daysPicker.tue.full": "Dienstag",
"daysPicker.wed.full": "Mittwoch",
"daysPicker.thu.full": "Donnerstag",
"daysPicker.fri.full": "Freitag",
"daysPicker.sat.full": "Samstag",
"daysPicker.sun.full": "Sonntag",
"phoneInput.selectCountry": "Land auswählen",
"phoneInput.searchCountry": "Land suchen",
"phoneInput.setAsDefault": "{{country}} als Standard festlegen",
"wizard.back": "Vorheriger Schritt",
"wizard.skip": "Überspringen",
"wizard.next": "Nächster Schritt",
"bubble.moreMedia": "+{{count}}",
"bubble.moreMedia_plural": "+{{count}}",
"bubble.unavailableMedia": "Medien nicht verfügbar",
"bubble.hint": "Weitere Informationen",
"bubble.download": "Herunterladen",
"bubble.mediaViewerTitle": "Medienbetrachter",
"bubble.openMedia": "Medien öffnen",
"bubble.closeMediaViewer": "Medienbetrachter schließen"
},
it: {
"emptyState.comingSoon.title": "Prossimamente!",
"emptyState.comingSoon.description": "Potrai accedere a questa funzionalità nel prossimo futuro.",
"emptyState.noData.title": "Nessun risultato corrispondente",
"emptyState.noData.description": "Prova a reimpostare la ricerca o a rimuovere un filtro.",
"emptyState.restrictedAccess.title": "Non hai accesso a questa pagina.",
"emptyState.restrictedAccess.description": "Contatta un amministratore del tuo team per ottenere l'accesso.",
"emptyState.unknownError.title": "Impossibile caricare questa pagina",
"emptyState.unknownError.description": "Si è verificato un problema durante il caricamento. Riprova a caricare i dati.",
"emptyState.notFound.title": "Impossibile trovare quella pagina",
"emptyState.notFound.description": "Potrebbe essere stata spostata, eliminata, o il link potrebbe essere errato.",
"emptyState.noSupport.title": "Non possiamo supportare questa pagina",
"emptyState.noSupport.description": "Prova ad aggiornare il browser all'ultima versione.",
"chatbotInput.placeholder": "Chiedi qualsiasi cosa...",
"chatbotInput.messageLabel": "Messaggio",
"chatbotInput.stopGenerating": "Interrompi la generazione",
"chatbotInput.sendMessage": "Invia messaggio",
"chatbotInput.conversationHistory": "Cronologia conversazioni",
"chatbotInput.noConversations": "Nessuna conversazione passata",
"chatbotInput.recentChatHistory": "Cronologia recente delle chat",
"productBadge.beta": "Beta",
"productBadge.new": "Nuovo",
"productBadge.deprecated": "Obsoleto",
"productBadge.trial": "Prova",
"productBadge.professional": "Professional",
"licenseBadge.aiAssist": "AI Assist",
"licenseBadge.aiAssistPro": "AI Assist Pro",
"licenseBadge.whatsapp": "Whatsapp",
"licenseBadge.analyticsPlus": "Analytics+",
"licenseBadge.surveys": "Sondaggi",
"licenseAlert.dismiss": "Ignora",
"licenseAlert.aiAgents": "AI Agents",
"roleBadge.agent": "Agente",
"roleBadge.supervisor": "Supervisore",
"roleBadge.admin": "Admin",
"roleBadge.owner": "Proprietario",
"sentiment.positive": "Positivo",
"sentiment.negative": "Negativo",
"sentiment.neutral": "Neutro",
"chatbotResponse.copy": "Copia",
"chatbotResponse.helpful": "Utile",
"chatbotResponse.notHelpful": "Non utile",
"chatbotResponse.sourceCount": "{{count}} Fonte",
"chatbotResponse.sourceCount_plural": "{{count}} Fonti",
"chatbotResponse.openSource": "Apri {{title}}",
"chatbotPanel.sendFeedback": "Invia feedback",
"chatbotPanel.collapsePanel": "Comprimi pannello",
"chatbotPanel.expandPanel": "Espandi pannello",
"chatbotPanel.closePanel": "Chiudi pannello",
"chatbotPanel.minimize": "Riduci a icona",
"chatbotPanel.goToFullPage": "Vai alla pagina completa",
"chatbotSidebar.newChat": "Nuova chat",
"chatbotSidebar.optionsFor": "Opzioni per {{title}}",
"chatbotPage.footerAskAircallLink": "Chiedi ad Aircall",
"chatbotPage.footerBeforeLink": " usa l'IA e può commettere errori. Trattandosi di una beta, utilizzarlo significa accettare i ",
"chatbotPage.betaTermsLink": "termini Beta",
"chatbotPage.footerAfterLink": " di Aircall.",
"chatbotPage.scrollToBottom": "Scorri verso il basso",
"chatbotPanel.title": "Chiedi ad Aircall",
"chatbotPanel.emptyTitle": "Hai bisogno di aiuto con Aircall?",
"chatbotPanel.emptyDescription": "Trova risposte, esplora le funzionalità e ottieni aiuto per comprendere Aircall",
"chatbotPanel.disclaimerBefore": "Stai interagendo con un assistente IA (Beta). Utilizzando questa funzionalità accetti i ",
"chatbotPanel.disclaimerLink": "termini della Beta",
"chatbotPanel.disclaimerAfter": " di Aircall. Le risposte possono essere inesatte o incomplete. Verificale prima di farne affidamento.",
"chatbotPanel.resizeHandle": "Ridimensiona il pannello",
"select.noResults": "Nessun risultato trovato.",
"chatbotResponse.renderingComponent": "Rendering del componente...",
"chatbotResponse.thinking": "Elaborazione in corso...",
"chatbotResponse.mermaidErrorTitle": "Impossibile visualizzare il diagramma",
"chatbotResponse.mermaidErrorDescription": "Si è verificato un errore durante il rendering di questo diagramma.",
"dashboardFilters.allFilters": "Tutti i filtri",
"dashboardFilters.apply": "Applica",
"dashboardFilters.clearAll": "Cancella tutto",
"dashboardFilters.reset": "Reimposta",
"dashboardFilters.resultCount": "{{count}} risultato",
"dashboardFilters.searchPlaceholder": "Cerca",
"dashboardFilters.resultCount_plural": "{{count}} risultati",
"filterCountry.placeholder": "Paese",
"filterCountry.countValue": "{{count}} paese",
"filterCountry.countValue_plural": "{{count}} paesi",
"filterLanguage.placeholder": "Lingua",
"filterLanguage.countValue": "{{count}} lingua",
"filterLanguage.countValue_plural": "{{count}} lingue",
"languageSelect.placeholder": "Scegli una lingua",
"languageSelect.placeholderMultiple": "Scegli lingue",
"dashboardFilters.selectPlaceholder": "Seleziona",
"dashboardFilters.title": "Filtri",
"dateRange.lastMonth": "Mese scorso",
"dateRange.lastSixMonths": "Ultimi 6 mesi",
"dateRange.lastWeek": "Settimana scorsa",
"dateRange.thisMonth": "Questo mese",
"dateRange.thisWeek": "Questa settimana",
"dateRange.today": "Oggi",
"dateRange.yesterday": "Ieri",
"filter.clear": "Cancella filtro",
"filterRange.from": "Da",
"filterRange.to": "A",
"daysPicker.mon": "L",
"daysPicker.tue": "M",
"daysPicker.wed": "M",
"daysPicker.thu": "G",
"daysPicker.fri": "V",
"daysPicker.sat": "S",
"daysPicker.sun": "D",
"daysPicker.mon.full": "Lunedì",
"daysPicker.tue.full": "Martedì",
"daysPicker.wed.full": "Mercoledì",
"daysPicker.thu.full": "Giovedì",
"daysPicker.fri.full": "Venerdì",
"daysPicker.sat.full": "Sabato",
"daysPicker.sun.full": "Domenica",
"phoneInput.selectCountry": "Seleziona paese",
"phoneInput.searchCountry": "Cerca un paese",
"phoneInput.setAsDefault": "Imposta {{country}} come predefinito",
"wizard.back": "Passaggio precedente",
"wizard.skip": "Salta",
"wizard.next": "Passaggio successivo",
"bubble.moreMedia": "+{{count}}",
"bubble.moreMedia_plural": "+{{count}}",
"bubble.unavailableMedia": "Contenuto non disponibile",
"bubble.hint": "Altre informazioni",
"bubble.download": "Scarica",
"bubble.mediaViewerTitle": "Visualizzatore multimedia",
"bubble.openMedia": "Apri contenuto multimedia",
"bubble.closeMediaViewer": "Chiudi il visualizzatore multimedia"
}
});
//#endregion
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/typeof.js
function _typeof(o) {
"@babel/helpers - typeof";
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
return typeof o;
} : function(o) {
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
}, _typeof(o);
}
//#endregion
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/toPrimitive.js
function toPrimitive(t, r) {
if ("object" != _typeof(t) || !t) return t;
var e = t[Symbol.toPrimitive];
if (void 0 !== e) {
var i = e.call(t, r || "default");
if ("object" != _typeof(i)) return i;
throw new TypeError("@@toPrimitive must return a primitive value.");
}
return ("string" === r ? String : Number)(t);
}
//#endregion
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/toPropertyKey.js
function toPropertyKey(t) {
var i = toPrimitive(t, "string");
return "symbol" == _typeof(i) ? i : i + "";
}
//#endregion
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/defineProperty.js
function _defineProperty(e, r, t) {
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
value: t,
enumerable: !0,
configurable: !0,
writable: !0
}) : e[r] = t, e;
}
//#endregion
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/objectSpread2.js
function ownKeys(e, r) {
var t = Object.keys(e);
if (Object.getOwnPropertySymbols) {
var o = Object.getOwnPropertySymbols(e);
r && (o = o.filter(function(r) {
return Object.getOwnPropertyDescriptor(e, r).enumerable;
})), t.push.apply(t, o);
}
return t;
}
function _objectSpread2(e) {
for (var r = 1; r < arguments.length; r++) {
var t = null != arguments[r] ? arguments[r] : {};
r % 2 ? ownKeys(Object(t), !0).forEach(function(r) {
_defineProperty(e, r, t[r]);
}) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function(r) {
Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));
});
}
return e;
}
//#endregion
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/objectWithoutPropertiesLoose.js
function _objectWithoutPropertiesLoose(r, e) {
if (null == r) return {};
var t = {};
for (var n in r) if ({}.hasOwnProperty.call(r, n)) {
if (e.includes(n)) continue;
t[n] = r[n];
}
return t;
}
//#endregion
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/objectWithoutProperties.js
function _objectWithoutProperties(e, t) {
if (null == e) return {};
var o, r, i = _objectWithoutPropertiesLoose(e, t);
if (Object.getOwnPropertySymbols) {
var s = Object.getOwnPropertySymbols(e);
for (r = 0; r < s.length; r++) o = s[r], t.includes(o) || {}.propertyIsEnumerable.call(e, o) && (i[o] = e[o]);
}
return i;
}
//#endregion
//#region src/components/empty-state.tsx
const _excluded$37 = ["children"];
const _excluded2$15 = ["className"];
const _excluded3$12 = ["href", "children"];
const _excluded4$9 = ["children"];
const _excluded5$9 = ["children"];
/** Generic root. Anchor of the family + escape hatch for ad-hoc empty states. */
const EmptyState = React.forwardRef((componentProps, forwardRef) => {
return /* @__PURE__ */ jsx(Empty, _objectSpread2({ ref: forwardRef }, componentProps));
});
EmptyState.displayName = "EmptyState";
/** Groups the media, title and description with the design's spacing. */
const EmptyStateHeader = React.forwardRef((componentProps, forwardRef) => {
return /* @__PURE__ */ jsx(EmptyHeader, _objectSpread2({ ref: forwardRef }, componentProps));
});
EmptyStateHeader.displayName = "EmptyStateHeader";
/** Decorative media slot (icon/avatar). Hidden from assistive tech. */
const EmptyStateMedia = React.forwardRef((componentProps, forwardRef) => {
const { children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$37);
return /* @__PURE__ */ jsx(EmptyMedia, _objectSpread2(_objectSpread2({
ref: forwardRef,
variant: "icon",
"aria-hidden": true
}, props), {}, { children }));
});
EmptyStateMedia.displayName = "EmptyStateMedia";
/** Title line of an empty state. Wraps the DS `EmptyTitle`. */
const EmptyStateTitle = React.forwardRef((componentProps, forwardRef) => {
return /* @__PURE__ */ jsx(EmptyTitle, _objectSpread2({ ref: forwardRef }, componentProps));
});
EmptyStateTitle.displayName = "EmptyStateTitle";
/** Supporting description text of an empty state. Wraps the DS `EmptyDescription`. */
const EmptyStateDescription = React.forwardRef((componentProps, forwardRef) => {
return /* @__PURE__ */ jsx(EmptyDescription, _objectSpread2({ ref: forwardRef }, componentProps));
});
EmptyStateDescription.displayName = "EmptyStateDescription";
/**
* Row of action buttons below the header. Lays buttons out horizontally
* (wrapping when needed), matching the design. Place an `EmptyStateExternalLink`
* link as a sibling after it — not inside — to sit on its own line below.
*/
const EmptyStateActions = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$15);
return /* @__PURE__ */ jsx(EmptyContent, _objectSpread2({
ref: forwardRef,
className: cn("flex-row flex-wrap justify-center gap-3", className)
}, props));
});
EmptyStateActions.displayName = "EmptyStateActions";
/**
* Action button for an empty state — the only button allowed inside one. Wraps
* the DS `Button` with `size` locked to `default` for visual consistency across
* empty states; consumers still choose the `variant`.
*/
const EmptyStateButton = React.forwardRef((componentProps, forwardRef) => {
return /* @__PURE__ */ jsx(Button, _objectSpread2({
ref: forwardRef,
size: "default"
}, componentProps));
});
EmptyStateButton.displayName = "EmptyStateButton";
/**
* External "Learn more"-style link for an empty state. Renders a real `<a>` (via
* `EmptyStateButton` with `variant="link"`) that opens in a new tab. Pass
* `children` to override the default "Learn more" label, including any icon.
*
* @default children "Learn more"
*/
const EmptyStateExternalLink = React.forwardRef((componentProps, forwardRef) => {
const { href, children = "Learn more" } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$12);
return /* @__PURE__ */ jsx(EmptyStateButton, _objectSpread2(_objectSpread2({
ref: forwardRef,
variant: "link",
render: /* @__PURE__ */ jsx("a", {
href,
target: "_blank",
rel: "noopener noreferrer"
})
}, props), {}, { children }));
});
EmptyStateExternalLink.displayName = "EmptyStateExternalLink";
/**
* Builds one preset's per-variant parts (`Root`, `Media`, `Title`, `Description`)
* from its icon, default copy, and a11y role. Returns an object of `forwardRef`
* components that reuse the shared empty-state parts; renders no DOM itself.
*/
function createEmptyStateVariant(config) {
const Root = React.forwardRef((componentProps, forwardRef) => /* @__PURE__ */ jsx(EmptyState, _objectSpread2({
ref: forwardRef,
role: config.role
}, componentProps)));
Root.displayName = `${config.name}EmptyState`;
const Media = React.forwardRef((componentProps, forwardRef) => /* @__PURE__ */ jsx(EmptyStateMedia, _objectSpread2(_objectSpread2({ ref: forwardRef }, componentProps), {}, { children: config.icon })));
Media.displayName = `${config.name}Media`;
const Title = React.forwardRef((_ref, forwardRef) => {
let { children } = _ref, componentProps = _objectWithoutProperties(_ref, _excluded4$9);
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsx(EmptyStateTitle, _objectSpread2(_objectSpread2({ ref: forwardRef }, componentProps), {}, { children: children !== null && children !== void 0 ? children : t(config.titleKey) }));
});
Title.displayName = `${config.name}Title`;
const Description = React.forwardRef((_ref2, forwardRef) => {
let { children } = _ref2, componentProps = _objectWithoutProperties(_ref2, _excluded5$9);
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsx(EmptyStateDescription, _objectSpread2(_objectSpread2({ ref: forwardRef }, componentProps), {}, { children: children !== null && children !== void 0 ? children : config.descriptionKey ? t(config.descriptionKey) : void 0 }));
});
Description.displayName = `${config.name}Description`;
return {
Root,
Media,
Title,
Description
};
}
//#endregion
//#region src/components/coming-soon.tsx
const ComingSoon = createEmptyStateVariant({
name: "ComingSoon",
icon: /* @__PURE__ */ jsx(Sprout, {}),
titleKey: "emptyState.comingSoon.title",
descriptionKey: "emptyState.comingSoon.description"
});
/**
* Root of the "Coming soon" empty state — shown when a feature exists in the UI
* but is not yet available. Renders a `<div>` (the DS `Empty` container); compose
* it from its parts.
*
* @example
* ```tsx
* <ComingSoonEmptyState>
* <EmptyStateHeader>
* <ComingSoonMedia />
* <ComingSoonTitle />
* <ComingSoonDescription />
* </EmptyStateHeader>
* <EmptyStateActions>
* <EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
* <EmptyStateExternalLink href={docsUrl} />
* </EmptyStateActions>
* </ComingSoonEmptyState>
* ```
*/
const ComingSoonEmptyState = ComingSoon.Root;
/** Decorative media slot of the "Coming soon" empty state (a sprout icon). Renders a `<div>`, hidden from assistive tech. */
const ComingSoonMedia = ComingSoon.Media;
/** Title line of the "Coming soon" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const ComingSoonTitle = ComingSoon.Title;
/** Supporting description text of the "Coming soon" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const ComingSoonDescription = ComingSoon.Description;
//#endregion
//#region src/components/restricted-access.tsx
const RestrictedAccess = createEmptyStateVariant({
name: "RestrictedAccess",
icon: /* @__PURE__ */ jsx(LockKeyhole, {}),
titleKey: "emptyState.restrictedAccess.title",
descriptionKey: "emptyState.restrictedAccess.description"
});
/**
* Root of the "Restricted access" empty state — shown when the user lacks
* permission to view a page. Renders a `<div>` (the DS `Empty` container); pair
* it with an `EmptyStateExternalLink` for the "Learn more" action.
*
* @example
* ```tsx
* <RestrictedAccessEmptyState>
* <EmptyStateHeader>
* <RestrictedAccessMedia />
* <RestrictedAccessTitle />
* <RestrictedAccessDescription />
* </EmptyStateHeader>
* <EmptyStateActions>
* <EmptyStateExternalLink href={docsUrl} />
* </EmptyStateActions>
* </RestrictedAccessEmptyState>
* ```
*/
const RestrictedAccessEmptyState = RestrictedAccess.Root;
/** Decorative media slot of the "Restricted access" empty state (a locked padlock icon). Renders a `<div>`, hidden from assistive tech. */
const RestrictedAccessMedia = RestrictedAccess.Media;
/** Title line of the "Restricted access" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const RestrictedAccessTitle = RestrictedAccess.Title;
/** Supporting description text of the "Restricted access" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const RestrictedAccessDescription = RestrictedAccess.Description;
//#endregion
//#region src/components/not-found.tsx
const NotFound = createEmptyStateVariant({
name: "NotFound",
icon: /* @__PURE__ */ jsx(Search, {}),
titleKey: "emptyState.notFound.title",
descriptionKey: "emptyState.notFound.description"
});
/**
* Root of the "Not found" empty state — shown when a page or route cannot be
* found. Renders a `<div>` (the DS `Empty` container); pair it with an
* `EmptyStateButton` for the navigation action (e.g. "Go to inbox").
*
* @example
* ```tsx
* <NotFoundEmptyState>
* <EmptyStateHeader>
* <NotFoundMedia />
* <NotFoundTitle />
* <NotFoundDescription />
* </EmptyStateHeader>
* <EmptyStateActions>
* <EmptyStateButton onClick={goToInbox}>Go to inbox</EmptyStateButton>
* </EmptyStateActions>
* </NotFoundEmptyState>
* ```
*/
const NotFoundEmptyState = NotFound.Root;
/** Decorative media slot of the "Not found" empty state (a search icon). Renders a `<div>`, hidden from assistive tech. */
const NotFoundMedia = NotFound.Media;
/** Title line of the "Not found" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const NotFoundTitle = NotFound.Title;
/** Supporting description text of the "Not found" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const NotFoundDescription = NotFound.Description;
//#endregion
//#region src/components/no-data.tsx
const NoData = createEmptyStateVariant({
name: "NoData",
icon: /* @__PURE__ */ jsx(Search, {}),
titleKey: "emptyState.noData.title",
descriptionKey: "emptyState.noData.description",
role: "status"
});
/**
* Root of the "No data" empty state — shown when a search or filter returns no
* results. Renders a `<div>` (the DS `Empty` container) with `role="status"` so
* it announces politely after a user action; pair it with an `EmptyStateButton`
* for the "Reset" action.
*
* @example
* ```tsx
* <NoDataEmptyState>
* <EmptyStateHeader>
* <NoDataMedia />
* <NoDataTitle />
* <NoDataDescription />
* </EmptyStateHeader>
* <EmptyStateActions>
* <EmptyStateButton variant="outline" onClick={resetFilters}>Reset</EmptyStateButton>
* </EmptyStateActions>
* </NoDataEmptyState>
* ```
*/
const NoDataEmptyState = NoData.Root;
/** Decorative media slot of the "No data" empty state (a search icon). Renders a `<div>`, hidden from assistive tech. */
const NoDataMedia = NoData.Media;
/** Title line of the "No data" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const NoDataTitle = NoData.Title;
/** Supporting description text of the "No data" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const NoDataDescription = NoData.Description;
//#endregion
//#region src/components/no-support.tsx
const NoSupport = createEmptyStateVariant({
name: "NoSupport",
icon: /* @__PURE__ */ jsx(Settings, {}),
titleKey: "emptyState.noSupport.title",
descriptionKey: "emptyState.noSupport.description"
});
/**
* Root of the "No support" empty state — shown when the current browser can't
* render a page. Renders a `<div>` (the DS `Empty` container); pair it with an
* `EmptyStateExternalLink` for the "Learn more" action.
*
* @example
* ```tsx
* <NoSupportEmptyState>
* <EmptyStateHeader>
* <NoSupportMedia />
* <NoSupportTitle />
* <NoSupportDescription />
* </EmptyStateHeader>
* <EmptyStateActions>
* <EmptyStateExternalLink href={docsUrl} />
* </EmptyStateActions>
* </NoSupportEmptyState>
* ```
*/
const NoSupportEmptyState = NoSupport.Root;
/** Decorative media slot of the "No support" empty state (a settings icon). Renders a `<div>`, hidden from assistive tech. */
const NoSupportMedia = NoSupport.Media;
/** Title line of the "No support" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const NoSupportTitle = NoSupport.Title;
/** Supporting description text of the "No support" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const NoSupportDescription = NoSupport.Description;
//#endregion
//#region src/components/unknown-error.tsx
const UnknownError = createEmptyStateVariant({
name: "UnknownError",
icon: /* @__PURE__ */ jsx(TriangleAlert, {}),
titleKey: "emptyState.unknownError.title",
descriptionKey: "emptyState.unknownError.description",
role: "alert"
});
/**
* Root of the "Unknown error" empty state — shown when content fails to load
* unexpectedly. Renders a `<div>` (the DS `Empty` container) with `role="alert"`
* so it announces assertively; pair it with a primary `EmptyStateButton` for the
* "Try again" action.
*
* @example
* ```tsx
* <UnknownErrorEmptyState>
* <EmptyStateHeader>
* <UnknownErrorMedia />
* <UnknownErrorTitle />
* <UnknownErrorDescription />
* </EmptyStateHeader>
* <EmptyStateActions>
* <EmptyStateButton onClick={retry}>Try again</EmptyStateButton>
* </EmptyStateActions>
* </UnknownErrorEmptyState>
* ```
*/
const UnknownErrorEmptyState = UnknownError.Root;
/** Decorative media slot of the "Unknown error" empty state (a warning triangle icon). Renders a `<div>`, hidden from assistive tech. */
const UnknownErrorMedia = UnknownError.Media;
/** Title line of the "Unknown error" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const UnknownErrorTitle = UnknownError.Title;
/** Supporting description text of the "Unknown error" empty state. Renders a `<div>` with the design copy by default; pass children to override. */
const UnknownErrorDescription = UnknownError.Description;
//#endregion
//#region src/helpers/content.helpers.ts
/**
* Normalizes a fenced code block's rehype-react children into its raw text.
* Usually a single string, but the parser can split it into an array (e.g.
* mid-stream, before a fence closes) — only string entries carry source text,
* so element entries contribute nothing rather than stringifying to garbage.
*/
function extractFenceCode(children) {
if (Array.isArray(children)) return children.map(extractFenceCode).join("");
if (typeof children === "string") return children;
return "";
}
/**
* Whether a space-separated `className` string contains `token` as one of
* its own class names, not merely as a substring — `"language-mermaid-js"`
* must not match `"language-mermaid"`, even though it contains that text.
*/
function hasClassToken(className, token) {
return className.split(/\s+/).includes(token);
}
function isTableCellValue(v) {
return v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
}
function parseTableComponent(obj) {
const { title, columns, rows } = obj;
if (typeof title !== "string") return null;
if (!Array.isArray(columns) || !columns.every((c) => typeof c === "string") || columns.length < 1) return null;
if (!Array.isArray(rows) || rows.length < 1 || !rows.every((row) => Array.isArray(row) && row.every(isTableCellValue))) return null;
return {
type: "table",
title,
columns,
rows
};
}
function parseAudioComponent(obj) {
const { url, title } = obj;
if (typeof url !== "string") return null;
try {
new URL(url);
} catch (_unused) {
return null;
}
return _objectSpread2({
type: "audio",
url
}, typeof title === "string" ? { title } : {});
}
function parseTranscriptComponent(obj) {
const { title, utterances } = obj;
if (!Array.isArray(utterances) || utterances.length < 1) return null;
const parsed = [];
for (const u of utterances) {
if (typeof u !== "object" || u === null) return null;
const uObj = u;
if (typeof uObj.speaker !== "string" || typeof uObj.text !== "string") return null;
parsed.push(_objectSpread2({
speaker: uObj.speaker,
text: uObj.text
}, typeof uObj.startTime === "number" ? { startTime: uObj.startTime } : {}));
}
return _objectSpread2({
type: "transcript",
utterances: parsed
}, typeof title === "string" ? { title } : {});
}
function parseComponentData(obj) {
if (typeof obj !== "object" || obj === null) return null;
const record = obj;
if (record.type === "table") return parseTableComponent(record);
if (record.type === "audio") return parseAudioComponent(record);
if (record.type === "transcript") return parseTranscriptComponent(record);
return null;
}
function parseJsonLenient(raw) {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed);
} catch (_unused2) {
if (trimmed[0] === "{") {
let depth = 0;
let inString = false;
let escaped = false;
for (let i = 0; i < trimmed.length; i++) {
const ch = trimmed[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\" && inString) {
escaped = true;
continue;
}
if (ch === "\"") {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{" || ch === "[") depth++;
if (ch === "}" || ch === "]") depth--;
if (depth === 0) return JSON.parse(trimmed.slice(0, i + 1));
}
}
throw new Error("Invalid JSON");
}
}
/**
* Returns the [start, end) byte ranges of fenced code blocks in `text`.
* Used to skip `:::component` directives that appear inside code examples.
*/
function getCodeFenceRanges(text) {
const ranges = [];
for (const m of text.matchAll(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1[ \t]*$/gm)) ranges.push([m.index, m.index + m[0].length]);
const lastClosed = ranges.length ? ranges[ranges.length - 1][1] : 0;
const openMatch = text.slice(lastClosed).match(/^(`{3,}|~{3,})[^\n]*$/m);
if (openMatch && openMatch.index !== void 0) ranges.push([lastClosed + openMatch.index, text.length]);
return ranges;
}
function inAnyRange(pos, ranges) {
return ranges.some(([s, e]) => pos >= s && pos < e);
}
/**
* Parses AI-generated text and extracts `:::component <json> :::` directives.
* Returns an ordered list of text, component, and loading-component blocks.
*
* @param streaming - Pass `true` while the response is still streaming so that
* an unclosed directive emits a `loading-component` placeholder. When `false`
* (the default, for a completed response) an unclosed directive is treated as
* raw text so no content is silently dropped.
*/
function parseContentBlocks(text) {
const COMPONENT_REGEX = /:::component\s+([\s\S]*?[}\]])\s*:::/g;
const fenceRanges = getCodeFenceRanges(text);
const blocks = [];
let lastIndex = 0;
for (const match of text.matchAll(COMPONENT_REGEX)) {
var _match$index;
const matchStart = (_match$index = match.index) !== null && _match$index !== void 0 ? _match$index : 0;
if (inAnyRange(matchStart, fenceRanges)) continue;
if (matchStart > lastIndex) {
const content = text.slice(lastIndex, matchStart).trim();
if (content) blocks.push({
type: "text",
content
});
}
try {
const data = parseComponentData(parseJsonLenient(match[1]));
blocks.push(data ? {
type: "component",
data
} : {
type: "text",
content: match[0]
});
} catch (_unused3) {
blocks.push({
type: "text",
content: match[0]
});
}
lastIndex = matchStart + match[0].length;
}
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex);
let incompleteIdx = -1;
let searchFrom = 0;
while (incompleteIdx === -1) {
const idx = remaining.indexOf(":::component", searchFrom);
if (idx === -1) break;
if (!inAnyRange(lastIndex + idx, fenceRanges)) incompleteIdx = idx;
else searchFrom = idx + 1;
}
if (incompleteIdx !== -1) {
const before = remaining.slice(0, incompleteIdx).trim();
if (before) blocks.push({
type: "text",
content: before
});
blocks.push({ type: "loading-component" });
} else {
const content = remaining.trim();
if (content) blocks.push({
type: "text",
content
});
}
}
return blocks;
}
//#endregion
//#region src/components/chatbot-audio-block.tsx
/** Renders a :::component audio block inside a chatbot response. */
function ChatbotAudioBlock({ data }) {
return /* @__PURE__ */ jsxs("div", {
className: "flex flex-col gap-1",
children: [data.title && /* @__PURE__ */ jsx("p", {
className: "text-sm font-medium text-foreground",
children: data.title
}), /* @__PURE__ */ jsx("audio", {
controls: true,
src: data.url,
className: "w-full"
})]
});
}
//#endregion
//#region src/components/chatbot-table-block.tsx
/** Renders a :::component table block inside a chatbot response. */
function ChatbotTableBlock({ data }) {
const { columns, rows } = data;
return /* @__PURE__ */ jsxs(Table, { children: [/* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsx(TableRow, { children: columns.map((col) => /* @__PURE__ */ jsx(TableHead, { children: col }, col)) }) }), /* @__PURE__ */ jsx(TableBody, { children: rows.map((row, rowIndex) => /* @__PURE__ */ jsx(TableRow, { children: row.map((cell, cellIndex) => /* @__PURE__ */ jsx(TableCell, { children: cell != null ? String(cell) : "" }, cellIndex)) }, rowIndex)) })] });
}
//#endregion
//#region src/components/chatbot-transcript-block.tsx
const SPEAKER_COLORS = [
{
text: "text-blue-600",
bg: "bg-blue-600"
},
{
text: "text-amber-600",
bg: "bg-amber-600"
},
{
text: "text-purple-600",
bg: "bg-purple-600"
},
{
text: "text-rose-600",
bg: "bg-rose-600"
},
{
text: "text-teal-600",
bg: "bg-teal-600"
}
];
function formatTime(seconds) {
return `${Math.floor(seconds / 60)}:${Math.floor(seconds % 60).toString().padStart(2, "0")}`;
}
/** Renders a :::component transcript block inside a chatbot response. */
function ChatbotTranscriptBlock({ data }) {
const { title, utterances } = data;
const speakerColors = useMemo(() => {
const map = /* @__PURE__ */ new Map();
let idx = 0;
for (const u of utterances) if (!map.has(u.speaker)) {
map.set(u.speaker, SPEAKER_COLORS[idx % SPEAKER_COLORS.length]);
idx++;
}
return map;
}, [utterances]);
return /* @__PURE__ */ jsxs("div", {
className: "flex flex-col gap-1",
children: [title && /* @__PURE__ */ jsx("p", {
className: "text-sm font-medium text-foreground",
children: title
}), /* @__PURE__ */ jsx("div", {
className: "flex flex-col gap-3 my-2 mx-2",
children: utterances.map((utterance, index) => {
const colors = speakerColors.get(utterance.speaker);
const prevSpeaker = index > 0 ? utterances[index - 1].speaker : null;
const showName = utterance.speaker !== prevSpeaker;
return /* @__PURE__ */ jsxs("div", {
className: "flex gap-2 items-stretch",
children: [
/* @__PURE__ */ jsx("div", { className: cn("w-0.5 rounded-sm shrink-0", colors.bg) }),
/* @__PURE__ */ jsxs("div", {
className: "flex flex-col gap-0.5 flex-1 min-w-0",
children: [showName && /* @__PURE__ */ jsx("p", {
className: cn("text-xs font-semibold", colors.text),
children: utterance.speaker
}), /* @__PURE__ */ jsx("p", {
className: "text-sm text-foreground",
children: utterance.text
})]
}),
utterance.startTime != null && /* @__PURE__ */ jsx("p", {
className: "text-xs text-muted-foreground shrink-0",
children: formatTime(utterance.startTime)
})
]
}, index);
})
})]
});
}
//#endregion
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/asyncToGenerator.js
function asyncGeneratorStep(n, t, e, r, o, a, c) {
try {
var i = n[a](c), u = i.value;
} catch (n) {
e(n);
return;
}
i.done ? t(u) : Promise.resolve(u).then(r, o);
}
function _asyncToGenerator(n) {
return function() {
var t = this, e = arguments;
return new Promise(function(r, o) {
var a = n.apply(t, e);
function _next(n) {
asyncGeneratorStep(a, r, o, _next, _throw, "next", n);
}
function _throw(n) {
asyncGeneratorStep(a, r, o, _next, _throw, "throw", n);
}
_next(void 0);
});
};
}
//#endregion
//#region src/hooks/use-copy-to-clipboard.ts
function useCopyToClipboard(value, { timeout = 5e3, onCopied } = {}) {
const [copied, setCopied] = useState(false);
const timerRef = useRef(void 0);
const getIsMounted = useIsMounted();
const onCopiedRef = useCallbackRef(onCopied);
useUnmount(() => clearTimeout(timerRef.current));
useEffect(() => {
clearTimeout(timerRef.current);
setCopied(false);
}, [value]);
return {
copy: useCallback(_asyncToGenerator(function* () {
try {
yield navigator.clipboard.writeText(value);
if (!getIsMounted()) return;
setCopied(true);
onCopiedRef();
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (getIsMounted()) setCopied(false);
}, timeout);
} catch (_unused) {}
}), [
value,
timeout,
getIsMounted,
onCopiedRef
]),
copied
};
}
//#endregion
//#region src/components/copy-button.tsx
const _excluded$36 = [
"value",
"timeout",
"onCopied",
"variant",
"children"
];
const CopyButtonContext = createContext(null);
function useCopyButtonContext() {
const ctx = useContext(CopyButtonContext);
if (ctx === null) throw new Error("CopyButtonIcon and CopyButtonLabel must be rendered inside a CopyButton.");
return ctx;
}
/**
* Copy-to-clipboard button. Composes DS `Button`; copies `value` on click and
* exposes a `copied` state (auto-reset after `timeout`) to its `CopyButtonIcon`
* and `CopyButtonLabel` children via context. Renders a `<button>`.
*
* @example
* ```tsx
* <CopyButton value="npm i @aircall/ds">
* <CopyButtonIcon />
* <CopyButtonLabel />
* </CopyButton>
* ```
*/
const CopyButton = React.forwardRef((componentProps, forwardRef) => {
const { value, timeout, onCopied, variant = "outline", children } = componentProps, buttonProps = _objectWithoutProperties(componentProps, _excluded$36);
const { copy, copied } = useCopyToClipboard(value, {
timeout,
onCopied
});
return /* @__PURE__ */ jsx(CopyButtonContext.Provider, {
value: { copied },
children: /* @__PURE__ */ jsx(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
onClick: () => {
copy();
},
variant
}, buttonProps), {}, { children }))
});
});
CopyButton.displayName = "CopyButton";
/**
* Icon for `CopyButton` that swaps between a copy glyph and a check glyph based on
* the parent's copied state. Renders an `<svg>`. Must be used inside a `CopyButton`.
*/
const CopyButtonIcon = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps;
const { copied } = useCopyButtonContext();
return /* @__PURE__ */ jsx(copied ? Check : Copy, {
ref: forwardRef,
className
});
});
CopyButtonIcon.displayName = "CopyButtonIcon";
/**
* Text label for `CopyButton` that shows `label` normally and `copiedLabel` after a
* successful copy. Renders a `<span>`. Must be used inside a `CopyButton`.
*/
const CopyButtonLabel = React.forwardRef((componentProps, forwardRef) => {
const { label = "Copy", copiedLabel = "Copied", className } = componentProps;
const { copied } = useCopyButtonContext();
return /* @__PURE__ */ jsx("span", {
ref: forwardRef,
className: cn(className),
children: copied ? copiedLabel : label
});
});
CopyButtonLabel.displayName = "CopyButtonLabel";
//#endregion
//#region src/components/chatbot-response-block.tsx
const _excluded$35 = ["node"];
const _excluded2$14 = ["node"];
const _excluded3$11 = ["node"];
const _excluded4$8 = ["node"];
const _excluded5$8 = ["node"];
const _excluded6$6 = ["node"];
const _excluded7$5 = ["node"];
const _excluded8$5 = ["node"];
const _excluded9$4 = ["node"];
const _excluded10$4 = ["node"];
const _excluded11$3 = ["node"];
const _excluded12$3 = ["node", "style"];
const _excluded13$3 = ["node", "style"];
const _excluded14$3 = ["node"];
const ChatbotMermaidBlock = lazy(() => import("./chatbot-mermaid-block-C_4Ppp7d.js").then((module) => ({ default: module.ChatbotMermaidBlock })));
function SourcesBadge({ sources, onSourceClick }) {
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsxs(HoverCard, { children: [/* @__PURE__ */ jsx(HoverCardTrigger, {
render: /* @__PURE__ */ jsx(Button, {
variant: "secondary",
size: "sm"
}),
children: t("chatbotResponse.sourceCount", { count: sources.length })
}), /* @__PURE__ */ jsx(HoverCardContent, {
side: "top",
align: "start",
className: "w-80 p-1",
children: /* @__PURE__ */ jsx(ItemGroup, { children: sources.map((source) => /* @__PURE__ */ jsx("div", {
role: "listitem",
children: /* @__PURE__ */ jsxs(Item, {
size: "xs",
render: /* @__PURE__ */ jsx("a", {
href: source.url,
target: "_blank",
rel: "noreferrer noopener",
"aria-label": t("chatbotResponse.openSource", { title: source.title }),
onClick: () => onSourceClick === null || onSourceClick === void 0 ? void 0 : onSourceClick(source)
}),
children: [
/* @__PURE__ */ jsx(ItemMedia, {
variant: "icon",
children: /* @__PURE__ */ jsx(FileText, {})
}),
/* @__PURE__ */ jsx(ItemContent, { children: /* @__PURE__ */ jsx(ItemTitle, { children: source.title }) }),
/* @__PURE__ */ jsx(ItemActions, { children: /* @__PURE__ */ jsx(ExternalLink, { className: "size-4" }) })
]
})
}, source.url)) })
})] });
}
function createMarkdownComponents(streamingRef) {
return {
h1: (_ref) => {
let { node: _n } = _ref, props = _objectWithoutProperties(_ref, _excluded$35);
return /* @__PURE__ */ jsx("h1", _objectSpread2({ className: "mt-10 text-4xl font-semibold first:mt-0" }, props));
},
h2: (_ref2) => {
let { node: _n } = _ref2, props = _objectWithoutProperties(_ref2, _excluded2$14);
return /* @__PURE__ */ jsx("h2", _objectSpread2({ className: "mt-11 border-b border-border pb-2 text-3xl font-semibold leading-9 first:mt-0" }, props));
},
h3: (_ref3) => {
let { node: _n } = _ref3, props = _objectWithoutProperties(_ref3, _excluded3$11);
return /* @__PURE__ */ jsx("h3", _objectSpread2({ className: "mt-10 text-2xl font-semibold leading-8 first:mt-0" }, props));
},
p: (_ref4) => {
let { node: _n } = _ref4, props = _objectWithoutProperties(_ref4, _excluded4$8);
return /* @__PURE__ */ jsx("p", _objectSpread2({ className: "mt-4 text-base leading-6 text-foreground first:mt-0" }, props));
},
blockquote: (_ref5) => {
let { node: _n } = _ref5, props = _objectWithoutProperties(_ref5, _excluded5$8);
return /* @__PURE__ */ jsx("blockquote", _objectSpread2({ className: "mt-4 border-l-2 border-border pl-6 first:mt-0" }, props));
},
ul: (_ref6) => {
let { node: _n } = _ref6, props = _objectWithoutProperties(_ref6, _excluded6$6);
return /* @__PURE__ */ jsx("ul", _objectSpread2({ className: "mt-4 list-disc pl-6 text-base leading-6 text-foreground first:mt-0" }, props));
},
ol: (_ref7) => {
let { node: _n } = _ref7, props = _objectWithoutProperties(_ref7, _excluded7$5);
return /* @__PURE__ */ jsx("ol", _objectSpread2({ className: "mt-4 list-decimal pl-6 text-base leading-6 text-foreground first:mt-0" }, props));
},
li: (_ref8) => {
let { node: _n } = _ref8, props = _objectWithoutProperties(_ref8, _excluded8$5);
return /* @__PURE__ */ jsx("li", _objectSpread2({ className: "mb-2 last:mb-0" }, props));
},
pre: (_ref9) => {
var _children$props;
let { node: _n } = _ref9, props = _objectWithoutProperties(_ref9, _excluded9$4);
const { children } = props;
if (React.isValidElement(children) && typeof ((_children$props = children.props) === null || _children$props === void 0 ? void 0 : _children$props.className) === "string" && hasClassToken(children.props.className, "language-mermaid")) {
const code = extractFenceCode(children.props.children).replace(/\n$/, "");
return /* @__PURE__ */ jsx(Suspense, {
fallback: /* @__PURE__ */ jsx(ChatbotLoadingBlock, {}),
children: /* @__PURE__ */ jsx(ChatbotMermaidBlock, {
code,
streaming: streamingRef.current
})
});
}
return /* @__PURE__ */ jsx("pre", _objectSpread2({}, props));
},
a: (_ref10) => {
let { node: _n } = _ref10, props = _objectWithoutProperties(_ref10, _excluded10$4);
return /* @__PURE__ */ jsx("a", _objectSpread2({ className: "text-green-700 underline underline-offset-2 hover:text-green-700/80" }, props));
},
table: (_ref11) => {
let { node: _n } = _ref11, props = _objectWithoutProperties(_ref11, _excluded11$3);
return /* @__PURE__ */ jsx("div", {
className: "mt-4 overflow-x-auto rounded border border-border first:mt-0",
children: /* @__PURE__ */ jsx("table", _objectSpread2({ className: "w-full text-base leading-6" }, props))
});
},
th: (_ref12) => {
let { node: _n, style } = _ref12, props = _objectWithoutProperties(_ref12, _excluded12$3);
return /* @__PURE__ */ jsx("th", _objectSpread2({
className: "border-t border-border px-4 py-2 text-left font-bold text-foreground",
style
}, props));
},
td: (_ref13) => {
let { node: _n, style } = _ref13, props = _objectWithoutProperties(_ref13, _excluded13$3);
return /* @__PURE__ */ jsx("td", _objectSpread2({
className: "border-t border-border px-4 py-2 text-foreground",
style
}, props));
},
tr: (_ref14) => {
let { node: _n } = _ref14, props = _objectWithoutProperties(_ref14, _excluded14$3);
return /* @__PURE__ */ jsx("tr", _objectSpread2({ className: "even:bg-muted" }, props));
}
};
}
/**
* Renders a chatbot response from markdown, splitting it into markdown text and
* embedded component blocks (table, audio, transcript, loading), with a sources
* hover card and copy / thumbs-up / thumbs-down feedback controls. Renders a
* `<div>` (`data-slot="chatbot-response-block"`).
*
* @example
* ```tsx
* <ChatbotResponseBlock
* messageId="msg-1"
* markdown="**Hello** from the assistant"
* sources={[{ title: 'Help center', url: 'https://help.aircall.io' }]}
* onFeedback={value => console.log(value)}
* />
* ```
*/
function ChatbotResponseBlock({ messageId, markdown, streaming = false, sources, onFeedback, onSourceClick, onCopy, className }) {
const t = useDsTranslation("blocks");
const [feedback, setFeedback] = useState(null);
const onFeedbackRef = useCallbackRef(onFeedback);
const isMounted = useRef(false);
const feedbackRef = useRef(feedback);
feedbackRef.current = feedback;
const streamingRef = useRef(streaming);
streamingRef.current = streaming;
const blocks = useMemo(() => parseContentBlocks(markdown), [markdown]);
const markdownComponents = useMemo(() => createMarkdownComponents(streamingRef), []);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
return;
}
if (feedbackRef.current === null) return;
setFeedback(null);
onFeedbackRef(null);
}, [
messageId,
markdown,
onFeedbackRef
]);
function handleFeedback(value) {
const next = feedback === value ? null : value;
setFeedback(next);
onFeedback === null || onFeedback === void 0 || onFeedback(next);
}
return /* @__PURE__ */ jsxs("div", {
"data-slot": "chatbot-response-block",
className: cn("flex flex-col gap-2", className),
children: [blocks.map((block, index) => {
if (block.type === "text") return /* @__PURE__ */ jsx(ReactMarkdown, {
remarkPlugins: [remarkGfm],
components: markdownComponents,
children: block.content
}, index);
if (block.type === "component") {
if (block.data.type === "table") return /* @__PURE__ */ jsx(ChatbotTableBlock, { data: block.data }, index);
if (block.data.type === "audio") return /* @__PURE__ */ jsx(ChatbotAudioBlock, { data: block.data }, index);
if (block.data.type === "transcript") return /* @__PURE__ */ jsx(ChatbotTranscriptBlock, { data: block.data }, index);
}
if (block.type === "loading-component") return /* @__PURE__ */ jsx(ChatbotLoadingBlock, {}, index);
return null;
}), !streaming && /* @__PURE__ */ jsxs("div", {
className: "flex items-center gap-1",
children: [sources && sources.length > 0 && /* @__PURE__ */ jsx("div", {
className: "flex flex-1",
children: /* @__PURE__ */ jsx(SourcesBadge, {
sources,
onSourceClick
})
}), /* @__PURE__ */ jsxs("div", {
className: "ml-auto flex items-center gap-1",
children: [
/* @__PURE__ */ jsx(CopyButton, {
value: markdown,
variant: "ghost",
size: "icon-sm",
"aria-label": t("chatbotResponse.copy"),
onCopied: onCopy,
children: /* @__PURE__ */ jsx(CopyButtonIcon, {})
}),
/* @__PURE__ */ jsx(Button, {
type: "button",
variant: "ghost",
size: "icon-sm",
"aria-pressed": feedback === "up",
"aria-label": t("chatbotResponse.helpful"),
onClick: () => handleFeedback("up"),
className: cn(feedback === "up" && "text-primary"),
children: feedback === "up" ? /* @__PURE__ */ jsx(ThumbsUpFilled, {}) : /* @__PURE__ */ jsx(ThumbsUp, {})
}),
/* @__PURE__ */ jsx(Button, {
type: "button",
variant: "ghost",
size: "icon-sm",
"aria-pressed": feedback === "down",
"aria-label": t("chatbotResponse.notHelpful"),
onClick: () => handleFeedback("down"),
className: cn(feedback === "down" && "text-primary"),
children: feedback === "down" ? /* @__PURE__ */ jsx(ThumbsDownFilled, {}) : /* @__PURE__ */ jsx(ThumbsDown, {})
})
]
})]
})]
});
}
//#endregion
//#region src/components/chatbot-response-loading.tsx
/**
* Loading indicator rendered inside `ChatbotPageConversation` while the backend
* is still streaming a response. Shows the Aircall animated logo next to a
* customisable shimmer text label.
*
* @example
* ```tsx
* <ChatbotPageConversation>
* <ChatbotUserMessage>How do I set my phone line?</ChatbotUserMessage>
* {isLoading && <ChatbotResponseLoading text="Thinking..." />}
* </ChatbotPageConversation>
* ```
*/
function ChatbotResponseLoading({ text, className }) {
const t = useDsTranslation("blocks");
const label = text !== null && text !== void 0 ? text : t("chatbotResponse.thinking");
return /* @__PURE__ */ jsxs("div", {
"data-slot": "chatbot-response-loading",
className: cn("flex items-center gap-2", className),
children: [/* @__PURE__ */ jsx(AircallLoader, {}), /* @__PURE__ */ jsx("span", {
className: cn("text-sm bg-clip-text text-transparent", "motion-safe:animate-chatbot-shimmer"),
style: {
backgroundImage: "linear-gradient(90deg, var(--color-muted-foreground) 25%, var(--color-foreground) 50%, var(--color-muted-foreground) 75%)",
backgroundSize: "200% auto"
},
children: label
})]
});
}
//#endregion
//#region src/components/chatbot-page.tsx
const _excluded$34 = ["className"];
const _excluded2$13 = ["className"];
const _excluded3$10 = ["className"];
const _excluded4$7 = [
"className",
"betaTermsHref",
"learnMoreHref",
"disclaimer"
];
const _excluded5$7 = [
"className",
"children",
"loading",
"loadingText"
];
const _excluded6$5 = ["className"];
/**
* Root layout shell for the AI chatbot experience; provides the `SidebarProvider`
* context required by `ChatbotSidebar` and lays out the sidebar and content
* columns. Renders a `SidebarProvider` (a `<div>` wrapper) with the sidebar width
* preset and full-height overrides for panel/modal contexts.
*
* @example
* ```tsx
* <ChatbotPage>
* <ChatbotSidebar conversations={[...]} />
* <ChatbotPageContent>
* <ChatbotPageBody>
* <ChatbotPageHeading>Need help?</ChatbotPageHeading>
* <ChatbotInput />
* </ChatbotPageBody>
* <ChatbotPageFooter betaTermsHref="https://legal.aircall.io/..." />
* </ChatbotPageContent>
* </ChatbotPage>
* ```
*/
const ChatbotPage = React.forwardRef((componentProps, forwardRef) => {
const { children, className } = componentProps;
return /* @__PURE__ */ jsx(SidebarProvider, {
ref: forwardRef,
className: cn("!min-h-0 h-full", className),
style: { "--sidebar-width": "14rem" },
children
});
});
ChatbotPage.displayName = "ChatbotPage";
/**
* Content area that fills the right side of `ChatbotPage`. Use alongside
* `ChatbotSidebar` as a direct child of `ChatbotPage`.
*
* In the **idle state** place `ChatbotPageBody`, then `ChatbotPageFooter`.
* In the **active conversation state** place `ChatbotPageConversation`,
* `ChatbotPageInputBar`, then `ChatbotPageFooter`.
*
* @example
* ```tsx
* <ChatbotPage>
* <ChatbotSidebar conversations={[...]} />
* <ChatbotPageContent>
* <ChatbotPageBody>
* <ChatbotPageHeading>Need help with Aircall?</ChatbotPageHeading>
* <ChatbotInput />
* <ChatbotExpandSuggestion categories={[...]} suggestions={[...]} />
* </ChatbotPageBody>
* <ChatbotPageFooter betaTermsHref="https://legal.aircall.io/..." />
* </ChatbotPageContent>
* </ChatbotPage>
* ```
*/
const ChatbotPageContent = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$34);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "chatbot-page-content",
className: cn("flex flex-1 flex-col items-center bg-background px-4", className)
}, props));
});
ChatbotPageContent.displayName = "ChatbotPageContent";
/**
* Centered content area for the idle/empty state inside `ChatbotPageContent`.
* Constrains children to a max-width of 896px and vertically centers them.
* Place `ChatbotPageHeading`, `ChatbotInput`, and `ChatbotExpandSuggestion`
* as children.
*/
const ChatbotPageBody = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$13);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "chatbot-page-body",
className: cn("relative z-[2] flex w-full max-w-[896px] flex-1 flex-col items-center justify-center gap-6", className)
}, props));
});
ChatbotPageBody.displayName = "ChatbotPageBody";
/**
* Centered heading displayed above the input area in the idle state.
* Renders an `<h2>` element styled at 24px / medium weight.
*
* @example
* ```tsx
* <ChatbotPageHeading>Need help with Aircall?</ChatbotPageHeading>
* ```
*/
const ChatbotPageHeading = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$10);
return /* @__PURE__ */ jsx("h2", _objectSpread2({
ref: forwardRef,
"data-slot": "chatbot-page-heading",
className: cn("min-w-full text-center text-2xl font-medium text-foreground", className)
}, props));
});
ChatbotPageHeading.displayName = "ChatbotPageHeading";
/**
* Footer bar showing the AI beta disclaimer. By default it renders the i18n
* notice — "Ask Aircall uses AI and can make mistakes. Since it's in beta,
* using it means accepting Aircall's Beta terms." — where "Beta terms" links to
* `betaTermsHref` and "Ask Aircall" links to `learnMoreHref` when provided.
* Pass `disclaimer` to replace the whole notice with custom content.
*
* @example
* ```tsx
* <ChatbotPageFooter
* betaTermsHref="https://legal.aircall.io/#template-9jw4r0usl"
* learnMoreHref="https://support.aircall.io/en-gb/articles/38002629571869"
* />
* ```
*/
const ChatbotPageFooter = React.forwardRef((componentProps, forwardRef) => {
const { className, betaTermsHref, learnMoreHref, disclaimer } = componentProps, props = _objectWithoutProperties(componentProps, _excluded4$7);
const t = useDsTranslation("blocks");
const content = disclaimer !== void 0 ? disclaimer : /* @__PURE__ */ jsxs("p", {
className: "text-center text-xs text-muted-foreground",
children: [
learnMoreHref ? /* @__PURE__ */ jsx(Anchor, {
href: learnMoreHref,
target: "_blank",
rel: "noopener noreferrer",
children: t("chatbotPage.footerAskAircallLink")
}) : t("chatbotPage.footerAskAircallLink"),
t("chatbotPage.footerBeforeLink"),
betaTermsHref ? /* @__PURE__ */ jsx(Anchor, {
href: betaTermsHref,
target: "_blank",
rel: "noopener noreferrer",
children: t("chatbotPage.betaTermsLink")
}) : t("chatbotPage.betaTermsLink"),
t("chatbotPage.footerAfterLink")
]
});
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "chatbot-page-footer",
className: cn("relative z-[1] flex w-full shrink-0 flex-col items-center justify-center pb-4", className)
}, props), {}, { children: content }));
});
ChatbotPageFooter.displayName = "ChatbotPageFooter";
/**
* Scrollable conversation thread for the active chatbot state. Place
* `ChatbotUserMessage` and `ChatbotResponseBlock` as alternating children.
* Use alongside `ChatbotPageInputBar` and `ChatbotPageFooter` inside
* `ChatbotPageContent`.
*
* The component is `relative` so consumers can absolutely-position a
* scroll-to-bottom FAB inside it.
*
* @example
* ```tsx
* <ChatbotPageContent>
* <ChatbotPageConversation>
* <ChatbotUserMessage>How do I set my phone line?</ChatbotUserMessage>
* <ChatbotResponseBlock messageId="1" markdown="..." />
* </ChatbotPageConversation>
* <ChatbotPageInputBar>
* <ChatbotInput size="large" />
* </ChatbotPageInputBar>
* <ChatbotPageFooter betaTermsHref="https://legal.aircall.io/..." />
* </ChatbotPageContent>
* ```
*/
const ChatbotPageConversation = React.forwardRef((componentProps, forwardRef) => {
const { className, children, loading = false, loadingText } = componentProps, props = _objectWithoutProperties(componentProps, _excluded5$7);
const innerRef = React.useRef(null);
const [atBottom, setAtBottom] = React.useState(true);
const atBottomRef = React.useRef(true);
const prevChildCountRef = React.useRef(React.Children.count(children));
const t = useDsTranslation("blocks");
const handleScroll = React.useCallback(() => {
const el = innerRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8;
setAtBottom(isAtBottom);
atBottomRef.current = isAtBottom;
}, []);
React.useEffect(() => {
const el = innerRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
React.useEffect(() => {
const count = React.Children.count(children);
const isNewMessage = count > prevChildCountRef.current;
prevChildCountRef.current = count;
const el = innerRef.current;
if (!el) return;
if (isNewMessage) {
el.scrollTo({
top: el.scrollHeight,
behavior: "smooth"
});
atBottomRef.current = true;
setAtBottom(true);
} else if (atBottomRef.current) el.scrollTo({
top: el.scrollHeight,
behavior: "instant"
});
}, [children]);
React.useEffect(() => {
if (!loading) return;
const el = innerRef.current;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: "smooth"
});
atBottomRef.current = true;
setAtBottom(true);
}, [loading]);
const scrollToBottom = React.useCallback(() => {
var _innerRef$current;
(_innerRef$current = innerRef.current) === null || _innerRef$current === void 0 || _innerRef$current.scrollTo({
top: innerRef.current.scrollHeight,
behavior: "smooth"
});
}, []);
const mergedRef = React.useCallback((node) => {
innerRef.current = node;
if (typeof forwardRef === "function") forwardRef(node);
else if (forwardRef) forwardRef.current = node;
}, [forwardRef]);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
"data-slot": "chatbot-page-conversation",
className: cn("relative z-[2] flex flex-col overflow-hidden w-full max-w-[896px] flex-1", className)
}, props), {}, { children: [/* @__PURE__ */ jsxs("div", {
ref: mergedRef,
className: cn("flex flex-1 flex-col gap-6 overflow-y-auto px-6 py-4", "[scrollbar-width:thin] [scrollbar-color:transparent_transparent] hover:[scrollbar-color:rgba(0,0,0,0.15)_transparent]", "[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-transparent [&:hover::-webkit-scrollbar-thumb]:bg-black/20"),
children: [children, loading && /* @__PURE__ */ jsx(ChatbotResponseLoading, { text: loadingText })]
}), !atBottom && /* @__PURE__ */ jsx("div", {
className: "absolute bottom-4 right-6 z-10",
children: /* @__PURE__ */ jsx(Button, {
variant: "outline",
size: "icon-lg",
className: "shadow-lg",
"aria-label": t("chatbotPage.scrollToBottom"),
onClick: scrollToBottom,
children: /* @__PURE__ */ jsx(ChevronDown, {})
})
})] }));
});
ChatbotPageConversation.displayName = "ChatbotPageConversation";
/**
* Wrapper that anchors `ChatbotInput` to the bottom of `ChatbotPageContent`
* at the correct max-width. Place between `ChatbotPageConversation` and
* `ChatbotPageFooter`.
*
* @example
* ```tsx
* <ChatbotPageInputBar>
* <ChatbotInput size="large" />
* </ChatbotPageInputBar>
* ```
*/
const ChatbotPageInputBar = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded6$5);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "chatbot-page-input-bar",
className: cn("relative z-[1] flex w-full max-w-[896px] shrink-0 flex-col pb-4", className)
}, props));
});
ChatbotPageInputBar.displayName = "ChatbotPageInputBar";
//#endregion
//#region src/components/chatbot-panel-header.tsx
const _excluded$33 = [
"title",
"onMenu",
"menuContent",
"menuOpen",
"onMenuOpenChange",
"onFeedback",
"onExpand",
"open",
"onOpenChange",
"onClose",
"className"
];
/**
* Header bar for the chatbot panel. Each control renders only when its handler is
* provided. The leading ☰ opens a Popover (conversation history) when
* `menuContent` is set, otherwise fires `onMenu`; with no menu handler it falls
* back to a non-interactive panel indicator. Title is optional — pass an empty
* string to hide it. The expand and close/collapse buttons show a hover
* tooltip; requires a `TooltipProvider` in the tree.
*/
function ChatbotPanelHeader(_ref) {
let { title = "Ask Aircall", onMenu, menuContent, menuOpen, onMenuOpenChange, onFeedback, onExpand, open = false, onOpenChange, onClose, className } = _ref, props = _objectWithoutProperties(_ref, _excluded$33);
const t = useDsTranslation("blocks");
const menuButton = /* @__PURE__ */ jsx(Button, {
variant: "ghost",
size: "icon",
"aria-label": "Open menu",
onClick: onMenu,
children: /* @__PURE__ */ jsx(Menu, { className: "size-4" })
});
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
"data-slot": "chatbot-panel-header",
"data-state": open ? "open" : "closed",
className: cn("flex w-full items-center gap-2 border-b border-border bg-background p-2", className)
}, props), {}, { children: [
menuContent != null ? /* @__PURE__ */ jsxs(Popover, {
open: menuOpen,
onOpenChange: onMenuOpenChange,
children: [/* @__PURE__ */ jsx(PopoverTrigger, { render: menuButton }), /* @__PURE__ */ jsx(PopoverContent, {
align: "start",
alignOffset: 6,
sideOffset: 4,
className: "w-80 gap-0 p-0",
children: menuContent
})]
}) : onMenu != null ? menuButton : /* @__PURE__ */ jsx("span", {
"aria-hidden": "true",
className: "flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground",
children: /* @__PURE__ */ jsx(PanelLeft, { className: "size-4" })
}),
/* @__PURE__ */ jsx("span", {
className: "min-w-0 flex-1 truncate text-base font-bold leading-none text-foreground",
children: title
}),
onFeedback != null && /* @__PURE__ */ jsx(Button, {
variant: "ghost",
size: "icon",
"aria-label": t("chatbotPanel.sendFeedback"),
onClick: onFeedback,
children: /* @__PURE__ */ jsx(MessageSquare, { className: "size-4" })
}),
onExpand != null && /* @__PURE__ */ jsxs(Tooltip, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
render: /* @__PURE__ */ jsx(Button, {
variant: "ghost",
size: "icon",
"aria-label": "Expand to fullscreen",
onClick: onExpand
}),
children: /* @__PURE__ */ jsx(SquareArrowOutUpRight, { className: "size-4" })
}), /* @__PURE__ */ jsx(TooltipContent, {
side: "bottom",
className: "text-xs",
children: t("chatbotPanel.goToFullPage")
})] }),
onOpenChange != null && /* @__PURE__ */ jsx(Button, {
variant: "ghost",
size: "icon",
"aria-label": open ? t("chatbotPanel.collapsePanel") : t("chatbotPanel.expandPanel"),
onClick: () => onOpenChange(!open),
children: open ? /* @__PURE__ */ jsx(Minus, { className: "size-4" }) : /* @__PURE__ */ jsx(Plus, { className: "size-4" })
}),
onClose != null && /* @__PURE__ */ jsxs(Tooltip, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
render: /* @__PURE__ */ jsx(Button, {
variant: "ghost",
size: "icon",
"aria-label": t("chatbotPanel.closePanel"),
onClick: onClose
}),
children: /* @__PURE__ */ jsx(Minus, { className: "size-4" })
}), /* @__PURE__ */ jsx(TooltipContent, {
side: "bottom",
className: "text-xs",
children: t("chatbotPanel.minimize")
})] })
] }));
}
//#endregion
//#region src/components/chatbot-sidebar.tsx
const SIDEBAR_MIN_WIDTH = 140;
const SIDEBAR_MAX_WIDTH = 480;
/**
* Conversation-history sidebar: an optional "New Chat" entry plus a scrollable,
* resizable list of recent conversations, each with an optional trailing ⋯ menu.
* Renders the DS `Sidebar` (a `<div>`), or a plain `<div>` when `popup`. Must be
* rendered inside a `SidebarProvider`.
*
* @example
* ```tsx
* <SidebarProvider>
* <ChatbotSidebar conversations={items} onSelectConversation={open} />
* </SidebarProvider>
* ```
*/
const ChatbotSidebar = React.forwardRef((componentProps, forwardRef) => {
const { conversations, activeConversationId, onNewChat, onSelectConversation, renderConversationMenuContent, recentLabel = "Recent", showNewChat = true, popup = false, className } = componentProps;
const t = useDsTranslation("blocks");
const isNewChatActive = !activeConversationId || !conversations.some((c) => c.id === activeConversationId);
const sidebarInternalRef = React.useRef(null);
const mergedRef = React.useCallback((node) => {
sidebarInternalRef.current = node;
if (typeof forwardRef === "function") forwardRef(node);
else if (forwardRef) forwardRef.current = node;
}, [forwardRef]);
const handleResizeMouseDown = React.useCallback((e) => {
e.preventDefault();
const el = sidebarInternalRef.current;
if (!el) return;
const startX = e.clientX;
const startWidth = el.getBoundingClientRect().width;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMouseMove = (e) => {
const newWidth = Math.min(Math.max(startWidth + (e.clientX - startX), SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH);
el.style.setProperty("--sidebar-width", `${newWidth}px`);
};
const onMouseUp = () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}, []);
const scrollableContent = /* @__PURE__ */ jsxs("div", {
"data-slot": "sidebar-content",
className: cn("flex min-h-0 flex-1 flex-col gap-2 overflow-auto", "[scrollbar-width:thin] [scrollbar-color:transparent_transparent] hover:[scrollbar-color:rgba(0,0,0,0.15)_transparent]", "[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full", "[&::-webkit-scrollbar-thumb]:bg-transparent [&:hover::-webkit-scrollbar-thumb]:bg-black/20"),
children: [showNewChat && /* @__PURE__ */ jsx(SidebarGroup, { children: /* @__PURE__ */ jsx(SidebarMenu, { children: /* @__PURE__ */ jsx(SidebarMenuItem, { children: /* @__PURE__ */ jsxs(SidebarMenuButton, {
isActive: isNewChatActive,
onClick: onNewChat,
children: [/* @__PURE__ */ jsx(Plus, {}), /* @__PURE__ */ jsx("span", { children: t("chatbotSidebar.newChat") })]
}) }) }) }), conversations.length > 0 && /* @__PURE__ */ jsxs(SidebarGroup, { children: [/* @__PURE__ */ jsx(SidebarGroupLabel, { children: recentLabel }), /* @__PURE__ */ jsx(SidebarMenu, { children: conversations.map((conversation) => {
const isActive = conversation.id === activeConversationId;
return /* @__PURE__ */ jsx(SidebarMenuItem, { children: renderConversationMenuContent ? /* @__PURE__ */ jsxs(DropdownMenu, { children: [
/* @__PURE__ */ jsx(SidebarMenuButton, {
isActive,
onClick: () => onSelectConversation === null || onSelectConversation === void 0 ? void 0 : onSelectConversation(conversation.id),
children: /* @__PURE__ */ jsx("span", {
className: "min-w-0 truncate",
children: conversation.title
})
}),
/* @__PURE__ */ jsx(SidebarMenuAction, {
showOnHover: true,
render: /* @__PURE__ */ jsx(DropdownMenuTrigger, {}),
"aria-label": t("chatbotSidebar.optionsFor", { title: conversation.title }),
children: /* @__PURE__ */ jsx(Ellipsis, {})
}),
/* @__PURE__ */ jsx(DropdownMenuContent, {
side: "right",
children: renderConversationMenuContent(conversation)
})
] }) : /* @__PURE__ */ jsx(SidebarMenuButton, {
isActive,
onClick: () => onSelectConversation === null || onSelectConversation === void 0 ? void 0 : onSelectConversation(conversation.id),
children: /* @__PURE__ */ jsx("span", {
className: "min-w-0 truncate",
children: conversation.title
})
}) }, conversation.id);
}) })] })]
});
if (popup) return /* @__PURE__ */ jsx("div", {
ref: forwardRef,
className: cn("flex h-full w-full flex-col", className),
children: scrollableContent
});
return /* @__PURE__ */ jsxs(Sidebar, {
ref: mergedRef,
collapsible: "none",
className: cn("relative", className),
children: [scrollableContent, /* @__PURE__ */ jsx("div", {
role: "separator",
"aria-orientation": "vertical",
className: "group/resize absolute inset-y-0 right-0 z-10 w-1 cursor-col-resize",
onMouseDown: handleResizeMouseDown,
children: /* @__PURE__ */ jsx("div", { className: "absolute inset-y-0 right-0 w-px bg-border transition-colors group-hover/resize:bg-muted-foreground/40" })
})]
});
});
ChatbotSidebar.displayName = "ChatbotSidebar";
//#endregion
//#region src/components/chatbot-panel-suggestion.tsx
const _excluded$32 = ["className"];
/**
* Pill-shaped prompt suggestion shown in the chatbot panel; a preset styling of
* `Button` with a green border and surface background. Renders a `<button>`.
*
* @example
* ```tsx
* <ChatbotPanelSuggestion onClick={() => ask('How do I set my phone line?')}>
* How do I set my phone line?
* </ChatbotPanelSuggestion>
* ```
*/
function ChatbotPanelSuggestion(_ref) {
let { className } = _ref, props = _objectWithoutProperties(_ref, _excluded$32);
return /* @__PURE__ */ jsx(Button, _objectSpread2({
"data-slot": "chatbot-panel-suggestion",
variant: "ghost",
className: cn("h-auto max-w-[280px] text-left rounded-[18px] border border-primary px-3 py-1.5", "bg-background text-primary whitespace-normal", "hover:bg-[color-mix(in_srgb,#5BF1B2_20%,var(--background))] hover:text-primary", className)
}, props));
}
//#endregion
//#region src/components/chatbot-input.tsx
const _excluded$31 = [
"className",
"value",
"onValueChange",
"onSubmit",
"onStop",
"placeholder",
"loading",
"size",
"expanded",
"disabled",
"suggestions",
"onSuggestionClick",
"conversations",
"activeConversationId",
"onSelectConversation"
];
const textareaVariants = cva([
"min-h-8 py-1.5 pr-2 pl-0 text-sm",
"placeholder:text-neutral-500",
"disabled:bg-transparent dark:disabled:bg-transparent disabled:opacity-100",
"[scrollbar-color:rgba(0,0,0,0.1)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/10 [&::-webkit-scrollbar-track]:bg-transparent"
], {
variants: {
size: {
default: "max-h-20",
large: "max-h-32"
},
expanded: {
true: "min-h-20",
false: ""
}
},
defaultVariants: {
size: "default",
expanded: false
}
});
/**
* Chatbot message composer: an auto-growing `InputGroup` textarea with a send/stop
* button, plus optional suggestion pills and a conversation-history popover. Works
* controlled (`value` + `onValueChange`) or uncontrolled. Enter submits;
* Shift+Enter inserts a newline. Renders the DS `InputGroup` (a `<div>`) wrapping a
* `<textarea>`.
*
* @example
* ```tsx
* <ChatbotInput value={text} onValueChange={setText} onSubmit={send} />
* ```
*/
function ChatbotInput(_ref) {
let { className, value, onValueChange, onSubmit, onStop, placeholder, loading = false, size = "default", expanded = false, disabled, suggestions, onSuggestionClick, conversations, activeConversationId, onSelectConversation } = _ref, textareaProps = _objectWithoutProperties(_ref, _excluded$31);
const t = useDsTranslation("blocks");
const [currentValue = "", setCurrentValue] = useControllableState({
prop: value,
defaultProp: "",
onChange: onValueChange
});
const [historyOpen, setHistoryOpen] = React.useState(false);
const hasValue = currentValue.trim().length > 0;
const handleChange = (event) => {
setCurrentValue(event.target.value);
};
const submit = () => {
if (loading || disabled || !hasValue) return;
onSubmit === null || onSubmit === void 0 || onSubmit(currentValue);
setCurrentValue("");
};
const handleKeyDown = (event) => {
var _textareaProps$onKeyD;
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
(_textareaProps$onKeyD = textareaProps.onKeyDown) === null || _textareaProps$onKeyD === void 0 || _textareaProps$onKeyD.call(textareaProps, event);
};
const hasSuggestions = suggestions && suggestions.length > 0;
const hasBottomRow = hasSuggestions || conversations !== void 0;
const sendStopButton = loading ? /* @__PURE__ */ jsx(InputGroupButton, {
variant: "secondary",
size: "icon",
className: "rounded-full",
"aria-label": t("chatbotInput.stopGenerating"),
disabled,
onClick: onStop,
children: /* @__PURE__ */ jsx(Square, {})
}) : /* @__PURE__ */ jsx(InputGroupButton, {
variant: hasValue ? "default" : "ghost",
size: "icon",
className: "rounded-full",
"aria-label": t("chatbotInput.sendMessage"),
disabled: disabled || !hasValue,
onClick: submit,
children: /* @__PURE__ */ jsx(ArrowUp, {})
});
const historyButton = conversations !== void 0 ? /* @__PURE__ */ jsxs(Popover, {
open: historyOpen,
onOpenChange: setHistoryOpen,
children: [/* @__PURE__ */ jsx(PopoverTrigger, { render: /* @__PURE__ */ jsx(Button, {
variant: "ghost",
size: "icon",
disabled,
"aria-label": t("chatbotInput.conversationHistory"),
children: /* @__PURE__ */ jsx(History, {})
}) }), /* @__PURE__ */ jsx(PopoverContent, {
align: "end",
sideOffset: 4,
className: "w-80 gap-0 p-0",
children: conversations.length === 0 ? /* @__PURE__ */ jsx("p", {
className: "px-4 py-6 text-center text-sm text-muted-foreground",
children: t("chatbotInput.noConversations")
}) : /* @__PURE__ */ jsx(SidebarProvider, {
className: "!min-h-0",
style: { "--sidebar-width": "20rem" },
children: /* @__PURE__ */ jsx(ChatbotSidebar, {
popup: true,
showNewChat: false,
recentLabel: t("chatbotInput.recentChatHistory"),
className: "bg-sidebar [&_[data-slot=sidebar-content]]:gap-0 [&_[data-slot=sidebar-content]]:max-h-[280px]",
conversations,
activeConversationId,
onSelectConversation: (id) => {
onSelectConversation === null || onSelectConversation === void 0 || onSelectConversation(id);
setHistoryOpen(false);
}
})
})
})]
}) : null;
return /* @__PURE__ */ jsxs(InputGroup, {
"data-slot": "chatbot-input",
"data-loading": loading || void 0,
className: cn("h-auto gap-2 overflow-clip rounded-3xl bg-background py-2 pr-2 pl-6", hasBottomRow ? "w-fit flex-col items-stretch" : "items-end", "shadow-[0px_0px_8px_0px_var(--input)]", "hover:shadow-[0px_0px_8px_0px_var(--ring)]", "has-[[data-slot=input-group-control]:focus-visible]:border-green-400", "has-[[data-slot=input-group-control]:focus-visible]:ring-0", "has-[[data-slot=input-group-control]:focus-visible]:shadow-[0px_0px_8px_0px_var(--ring)]", className),
"aria-disabled": disabled || void 0,
children: [/* @__PURE__ */ jsx(InputGroupTextarea, _objectSpread2(_objectSpread2({
rows: 1,
placeholder: placeholder !== null && placeholder !== void 0 ? placeholder : t("chatbotInput.placeholder"),
"aria-label": t("chatbotInput.messageLabel")
}, textareaProps), {}, {
value: currentValue,
disabled,
onChange: handleChange,
onKeyDown: handleKeyDown,
className: cn(textareaVariants({
size,
expanded
}))
})), hasBottomRow ? /* @__PURE__ */ jsxs("div", {
className: "flex items-center justify-between gap-2",
children: [/* @__PURE__ */ jsx("div", {
className: "flex gap-2",
children: hasSuggestions && suggestions.map((s) => /* @__PURE__ */ jsx(ChatbotPanelSuggestion, {
className: "max-w-none shrink-0 whitespace-nowrap",
disabled,
onClick: () => onSuggestionClick === null || onSuggestionClick === void 0 ? void 0 : onSuggestionClick(s),
children: s.label
}, s.id))
}), /* @__PURE__ */ jsxs("div", {
className: "flex items-center gap-2",
children: [historyButton, sendStopButton]
})]
}) : /* @__PURE__ */ jsx("div", {
className: "flex shrink-0 items-center gap-2",
children: sendStopButton
})]
});
}
//#endregion
//#region src/components/chatbot-user-message.tsx
const _excluded$30 = ["className", "children"];
/**
* Right-aligned chat bubble for a user's message; clamps long content to six
* lines with a "Show more" / "Show less" toggle. Renders a `<div>`
* (`data-slot="chatbot-user-message"`).
*
* @example
* ```tsx
* <ChatbotUserMessage>How do I set up my phone number?</ChatbotUserMessage>
* ```
*/
function ChatbotUserMessage(_ref) {
let { className, children } = _ref, props = _objectWithoutProperties(_ref, _excluded$30);
const [expanded, setExpanded] = useState(false);
const [showToggle, setShowToggle] = useState(false);
const contentRef = useRef(null);
useLayoutEffect(() => {
if (expanded || !contentRef.current) return;
const el = contentRef.current;
setShowToggle(el.scrollHeight > el.clientHeight);
}, [children, expanded]);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
"data-slot": "chatbot-user-message",
className: cn("ml-auto", "w-fit max-w-[80%]", "rounded-[18px]", "bg-green-200 text-foreground dark:bg-green-900", "px-4 py-2", "text-sm", "break-words", className)
}, props), {}, { children: [/* @__PURE__ */ jsx("div", {
ref: contentRef,
className: cn(!expanded && "line-clamp-6"),
children
}), showToggle && /* @__PURE__ */ jsx("button", {
type: "button",
"aria-expanded": expanded,
onClick: () => setExpanded((prev) => !prev),
className: "mt-1 block w-full cursor-pointer text-right text-xs font-semibold text-foreground/70 hover:text-foreground",
children: expanded ? "Show less" : "Show more"
})] }));
}
//#endregion
//#region src/components/dashboard-standalone-page.tsx
const _excluded$29 = ["className"];
const _excluded2$12 = ["className"];
const _excluded3$9 = ["className"];
const _excluded4$6 = ["className"];
const _excluded5$6 = ["className"];
const _excluded6$4 = ["side", "className"];
const _excluded7$4 = ["variant"];
const _excluded8$4 = ["className"];
/**
* Full-screen takeover shell for focused, sidebar-less flows; renders a flex-row `<div>`
* holding one or more `DashboardStandalonePageContent` panels side by side.
*
* @example
* ```tsx
* <DashboardStandalonePage>
* <DashboardStandalonePageContent>
* <DashboardStandalonePageHeader>
* <DashboardStandalonePageTitle>Create user</DashboardStandalonePageTitle>
* <DashboardStandalonePageActions side="start">
* <DashboardStandalonePageAction>Cancel</DashboardStandalonePageAction>
* </DashboardStandalonePageActions>
* </DashboardStandalonePageHeader>
* <DashboardStandalonePageMain>…</DashboardStandalonePageMain>
* </DashboardStandalonePageContent>
* </DashboardStandalonePage>
* ```
*/
const DashboardStandalonePage = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$29);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-standalone-page",
className: cn("flex h-screen gap-2 overflow-hidden bg-page-background p-2", className)
}, props));
});
DashboardStandalonePage.displayName = "DashboardStandalonePage";
/**
* Rounded, bordered content panel that fills the shell; renders a `<div>`.
* Typically wraps a `DashboardStandalonePageHeader` above a `DashboardStandalonePageMain`.
*/
const DashboardStandalonePageContent = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$12);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-standalone-page-content",
className: cn("flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-page-surface border border-border", className)
}, props));
});
DashboardStandalonePageContent.displayName = "DashboardStandalonePageContent";
/**
* Fixed-height (64px) header bar with an optically centered title and absolutely
* positioned action slots; renders a `<header>`.
*/
const DashboardStandalonePageHeader = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$9);
return /* @__PURE__ */ jsx("header", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-standalone-page-header",
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)
}, props));
});
DashboardStandalonePageHeader.displayName = "DashboardStandalonePageHeader";
/**
* Centered, truncated page title in the header; renders an `<h1>`.
*/
const DashboardStandalonePageTitle = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded4$6);
return /* @__PURE__ */ jsx("h1", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-standalone-page-title",
className: cn("max-w-xl truncate text-center text-xl leading-tight font-bold text-foreground", className)
}, props));
});
DashboardStandalonePageTitle.displayName = "DashboardStandalonePageTitle";
/**
* Optional subtitle stacked beneath the title in the header; renders a `<p>`.
*/
const DashboardStandalonePageDescription = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded5$6);
return /* @__PURE__ */ jsx("p", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-standalone-page-description",
className: cn("max-w-xl truncate text-center text-sm leading-tight font-medium text-muted-foreground", className)
}, props));
});
DashboardStandalonePageDescription.displayName = "DashboardStandalonePageDescription";
/**
* Action slot absolutely pinned to the header's left or right edge; renders a `<div>`.
* Sitting outside the flow, it never shifts the centered title.
*/
const DashboardStandalonePageActions = React.forwardRef((componentProps, forwardRef) => {
const { side = "end", className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded6$4);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-standalone-page-actions",
"data-side": side,
className: cn("absolute inset-y-0 flex items-center gap-2", side === "start" ? "left-4" : "right-4", className)
}, props));
});
DashboardStandalonePageActions.displayName = "DashboardStandalonePageActions";
/**
* Header action button rendering a ds `Button` (ghost, `lg` by default); renders a `<button>`.
*/
const DashboardStandalonePageAction = React.forwardRef((componentProps, forwardRef) => {
const { variant = "ghost" } = componentProps, props = _objectWithoutProperties(componentProps, _excluded7$4);
return /* @__PURE__ */ jsx(Button, _objectSpread2({
ref: forwardRef,
size: "lg",
variant
}, props));
});
DashboardStandalonePageAction.displayName = "DashboardStandalonePageAction";
/**
* Scrollable, transparent content area below the header inside the panel; renders a `<main>`.
*/
const DashboardStandalonePageMain = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded8$4);
return /* @__PURE__ */ jsx("main", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-standalone-page-main",
className: cn("min-h-0 flex-1 overflow-auto p-4 md:p-6", className)
}, props));
});
DashboardStandalonePageMain.displayName = "DashboardStandalonePageMain";
//#endregion
//#region src/components/dashboard-page.tsx
const _excluded$28 = ["className"];
const _excluded2$11 = ["className"];
const _excluded3$8 = ["className"];
const _excluded4$5 = ["className"];
const _excluded5$5 = ["className"];
/**
* Full-screen outer shell for a dashboard view; renders a `<div>`.
* Stacks an optional `DashboardPageBanner` above a `DashboardSidebarProvider`
* that holds the sidebar and a `DashboardPageContent`.
*
* @example
* ```tsx
* <DashboardPage>
* <DashboardSidebarProvider>
* <DashboardSidebar>…</DashboardSidebar>
* <DashboardPageContent>…</DashboardPageContent>
* </DashboardSidebarProvider>
* </DashboardPage>
* ```
*/
const DashboardPage = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$28);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-page",
className: cn("flex flex-col h-screen bg-page-background p-2 gap-2", className)
}, props));
});
DashboardPage.displayName = "DashboardPage";
/**
* Full-width top row that sits flush above the sidebar/content row; renders a `<div>`.
* Use to host trial notices, announcements, or any ds `Banner`.
*/
const DashboardPageBanner = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$11);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-page-banner",
className: cn("-mt-2 -mx-2", className)
}, props));
});
DashboardPageBanner.displayName = "DashboardPageBanner";
/**
* White content card filling the right column beside the sidebar; renders a `<div>`.
*/
const DashboardPageContent = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$8);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-page-content",
className: cn("flex-1 min-w-0 h-full flex flex-col bg-background rounded-xl border border-border overflow-hidden", "[&:has([data-slot=dph-tabs])>[data-slot=dph]]:border-b-0", className)
}, props));
});
DashboardPageContent.displayName = "DashboardPageContent";
/**
* Scrollable content area inside `DashboardPageContent`; renders a `<main>`.
*/
const DashboardPageMain = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded4$5);
return /* @__PURE__ */ jsx("main", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-page-main",
className: cn("flex-1 min-h-0 overflow-auto py-4 px-6", className)
}, props));
});
DashboardPageMain.displayName = "DashboardPageMain";
/**
* Left-aligned tab bar placed as a sibling of `DashboardPageHeader`; renders a ds `TabsList`.
*/
const DashboardPageTabs = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded5$5);
return /* @__PURE__ */ jsx(TabsList, _objectSpread2(_objectSpread2({ ref: forwardRef }, props), {}, {
"data-slot": "dph-tabs",
className: cn("flex w-full justify-start px-6 border-b border-border rounded-none bg-transparent", "[&>[data-slot=tabs-trigger]]:flex-none", "[&>[data-slot=tabs-trigger]]:rounded-none", "[&>[data-slot=tabs-trigger]]:after:hidden", "[&>[data-slot=tabs-trigger]]:border-b-2", "[&>[data-slot=tabs-trigger]]:border-b-transparent", "[&>[data-slot=tabs-trigger]]:-mb-px", "[&>[data-slot=tabs-trigger][data-active]]:border-b-foreground", className)
}));
});
DashboardPageTabs.displayName = "DashboardPageTabs";
//#endregion
//#region src/components/dashboard-page-header.tsx
const _excluded$27 = ["className", "children"];
const _excluded2$10 = ["className", "children"];
const _excluded3$7 = ["children"];
const _excluded4$4 = ["className", "children"];
const _excluded5$4 = ["className", "children"];
const _excluded6$3 = [
"size",
"className",
"children"
];
const _excluded7$3 = ["className", "children"];
const _excluded8$3 = ["className", "children"];
const _excluded9$3 = ["className", "children"];
const _excluded10$3 = ["size"];
/**
* Header for a `DashboardPageContent`; renders a `<div>` that establishes a
* `@container` context with an inner CSS-grid its sub-components self-place into.
*
* @example
* ```tsx
* <DashboardPageHeader>
* <DashboardPageHeaderTitle>Team</DashboardPageHeaderTitle>
* <DashboardPageHeaderActions>
* <DashboardPageHeaderAction>Invite</DashboardPageHeaderAction>
* </DashboardPageHeaderActions>
* </DashboardPageHeader>
* ```
*/
const DashboardPageHeader = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$27);
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dph",
className: cn("@container bg-background border-b border-border w-full", className)
}, props), {}, { children: /* @__PURE__ */ jsx("div", {
"data-slot": "dph-grid",
className: "group/dph pl-4 pr-6 py-3",
children
}) }));
});
DashboardPageHeader.displayName = "DashboardPageHeader";
/**
* Back-navigation row for Level 2 headers; renders a `<div>` in the grid `nav` area.
*/
const DashboardPageHeaderNav = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$10);
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dph-nav",
className: cn("[grid-area:nav] flex items-center gap-1", className)
}, props), {}, { children }));
});
DashboardPageHeaderNav.displayName = "DashboardPageHeaderNav";
/**
* Back button rendering a ghost, small ds `Button` with a leading chevron icon.
* Accepts a render prop so it can become an `<a>` or router `<Link>`.
*/
const DashboardPageHeaderNavBack = React.forwardRef((componentProps, forwardRef) => {
const { children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$7);
return /* @__PURE__ */ jsxs(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
variant: "ghost",
size: "sm",
nativeButton: false
}, props), {}, { children: [/* @__PURE__ */ jsx(ChevronLeft, {}), children] }));
});
DashboardPageHeaderNavBack.displayName = "DashboardPageHeaderNavBack";
/**
* Icon/flag slot shown left of the title; renders a `<div>` in the grid `prefix` area.
*/
const DashboardPageHeaderPrefix = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded4$4);
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dph-prefix",
className: cn("[grid-area:prefix] flex shrink-0 px-2 pt-2 items-center justify-center self-start", className)
}, props), {}, { children }));
});
DashboardPageHeaderPrefix.displayName = "DashboardPageHeaderPrefix";
/**
* Wrapper that stacks the title and subtitle; renders a `<div>` in the grid `info` area.
*/
const DashboardPageHeaderTitleGroup = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded5$4);
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dph-title-group",
className: cn("[grid-area:info] flex min-w-0 flex-col self-center pl-2", "group-has-[[data-slot=dph-prefix]]/dph:pl-0", className)
}, props), {}, { children }));
});
DashboardPageHeaderTitleGroup.displayName = "DashboardPageHeaderTitleGroup";
/**
* Page title; renders an `<h1>`, self-placing in the grid `info` area or deferring
* layout to `DashboardPageHeaderTitleGroup` when nested inside one.
*/
const DashboardPageHeaderTitle = React.forwardRef((componentProps, forwardRef) => {
const { size = "lg", className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded6$3);
return /* @__PURE__ */ jsx("h1", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dph-title",
className: cn("[grid-area:info] self-center pl-2 group-has-[[data-slot=dph-prefix]]/dph:pl-0", "[[data-slot=dph-title-group]_&]:p-0", "[[data-slot=dph-title-group]_&]:self-auto", "truncate text-foreground", size === "lg" ? "text-3xl font-bold" : "text-xl font-bold", "leading-10", className)
}, props), {}, { children }));
});
DashboardPageHeaderTitle.displayName = "DashboardPageHeaderTitle";
/**
* Optional subtitle line/block placed beneath the title; renders a `<div>`.
*/
const DashboardPageHeaderSubtitle = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded7$3);
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dph-subtitle",
className: cn("flex flex-col items-start gap-0.5 max-w-xl", className)
}, props), {}, { children }));
});
DashboardPageHeaderSubtitle.displayName = "DashboardPageHeaderSubtitle";
/**
* Muted, clamped description text under the title; renders a `<div>`.
*/
const DashboardPageHeaderDescription = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded8$3);
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dph-description",
className: cn("text-sm text-muted-foreground line-clamp-3 -mt-1", className)
}, props), {}, { children }));
});
DashboardPageHeaderDescription.displayName = "DashboardPageHeaderDescription";
/**
* Flex container for header actions; renders a `<div>` in the grid `actions` area.
*/
const DashboardPageHeaderActions = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded9$3);
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dph-actions",
className: cn("[grid-area:actions] flex items-center gap-2 self-start pl-2", className)
}, props), {}, { children }));
});
DashboardPageHeaderActions.displayName = "DashboardPageHeaderActions";
/**
* Header action button rendering a ds `Button` locked to the actions area sizes;
* renders a `<button>` (defaults to a labeled `lg` CTA, or `icon-lg` for icon-only triggers).
*/
const DashboardPageHeaderAction = React.forwardRef((componentProps, forwardRef) => {
const { size = "lg" } = componentProps, props = _objectWithoutProperties(componentProps, _excluded10$3);
return /* @__PURE__ */ jsx(Button, _objectSpread2({
ref: forwardRef,
size
}, props));
});
DashboardPageHeaderAction.displayName = "DashboardPageHeaderAction";
//#endregion
//#region src/components/dashboard-filters.tsx
const _excluded$26 = ["children", "className"];
const _excluded2$9 = [
"label",
"children",
"className"
];
/**
* How many filters a `DashboardFiltersRow` shows inline before it starts holding the untouched ones
* back for the sheet. At or below this count everything is inline; above it, only the filters that
* have a value applied stay on the row.
*
* Not a prop. The number is a design decision about how wide a filter row may read, and a page that
* could raise it would be re-deciding the thing this component exists to settle.
*/
const DASHBOARD_FILTERS_INLINE_LIMIT = 3;
/**
* Attaches a value type to a filter id, the way `atom()` pins an atom's.
*
* **The presets do not need this.** `FilterCombobox`, `FilterRange`, `FilterLanguage` and
* `FilterCountry` each pin their own value type on their `id` prop, so for them a plain string and a
* branded id type-check identically - the COMPONENT pins the type, not the id. Use a plain string
* there; it reads better and says the same thing.
*
* Where it pays is a filter composed by hand, calling `useDashboardFilterStaging` with no explicit
* type argument. Then the id is the only thing that can type the staged value:
*
* ```tsx
* // Plain id: nothing types the staged value, so indexing it fails to compile.
* const { stagedValue } = useDashboardFilterStaging('teamSize');
*
* // Branded id: `stagedValue` is [number, number] | undefined.
* const teamSize = createDashboardFilterId<[number, number]>('teamSize');
* const { stagedValue } = useDashboardFilterStaging(teamSize);
* ```
*
* And where it does NOT help, so nobody reaches for it expecting more: the presets all pass an
* explicit type argument to that hook, so the id's brand is irrelevant to them.
*
* It also catches a mismatch: hand a `DashboardFilterId<A>` to something expecting a
* `DashboardFilterId<B>` and the two phantom types conflict, so it fails to compile. That protection
* only bites between two BRANDED ids - a plain string is assignable to either, by design.
*
* @example
* ```tsx
* const durationFilter = createDashboardFilterId<[number, number]>('duration');
* ```
*/
function createDashboardFilterId(id) {
return id;
}
/** Absent outside a `DashboardFilters`, which is what lets a filter stay usable standalone. */
const DashboardFilterRegistryContext = React.createContext(null);
const DashboardFiltersStateContext = React.createContext(null);
const DashboardFilterDraftContext = React.createContext(null);
/** Whether a re-report carries anything the row would render differently. */
function sameRegistration(a, b) {
return a.hasValue === b.hasValue && a.isDirty === b.isDirty;
}
/**
* Whether any registered filter is DIRTY - changed away from its default. That is the only thing
* clearing can undo, so a row where every filter still holds the page's own default value has nothing
* to offer.
*/
function hasDirtyFilter(registrations) {
return [...registrations.values()].some((entry) => entry.isDirty);
}
/**
* Reports a filter's state up to the enclosing `DashboardFilters` row, so the row can decide which
* filters to inline and whether Clear is worth offering - without ever storing a filter's value.
*
* No-ops when there is no enclosing `DashboardFilters`, so a filter stays usable on its own - which
* is the normal case: most filters do not live in a dashboard row.
*
* Already called for you by the `Filter` root, so a chip built from the filter family needs
* nothing. Call it directly only when building a filter that is not a `Filter`.
*
* The row learns only what it needs to decide VISIBILITY. It never learns how to change a filter:
* applying and clearing are the consumer's, through one callback each.
*
* Reports having a value and being dirty SEPARATELY, because the row asks two different questions of
* them: placement reads `hasValue`, Clear all reads `isDirty`. Collapsing the two into one flag is what
* once dropped a chip holding the page's default value off the row entirely.
*
* @param id - The filter's declared `id`, by convention the query-param name it owns.
* @param state - The filter's current state. Read field by field, so passing a fresh object literal
* every render is fine: only a change to `hasValue` or `isDirty` re-reports.
*
* @example
* ```tsx
* useDashboardFilterRegistration(id, { hasValue, isDirty });
* ```
*/
function useDashboardFilterRegistration(id, state) {
const registry = React.useContext(DashboardFilterRegistryContext);
const surface = useDashboardFilterSurface();
const { hasValue, isDirty } = state;
const registryForSurface = surface === "row" ? registry : null;
useSafeLayoutEffect(() => {
registryForSurface === null || registryForSurface === void 0 || registryForSurface.registerFilter(id, {
hasValue,
isDirty
});
}, [
registryForSurface,
id,
hasValue,
isDirty
]);
React.useEffect(() => {
if (!registryForSurface) return;
return () => registryForSurface.unregisterFilter(id);
}, [registryForSurface, id]);
}
/**
* State root for a dashboard filter row. Owns the filter registry, and holds no layout of its own
* beyond keeping its parts on one line - the row's rules and its clipping live in
* `DashboardFiltersRow`.
*
* The model is one DECLARATION per filter with TWO renderings. A filter is written once, inside the
* row; the container decides whether it appears as a chip on the row or as a field in the sheet, and
* the filter itself renders whichever surface it finds itself on. So a filter's state, its label and
* its option list are stated once and cannot drift between the two.
*
* The root holds no filter VALUES. Each filter reports whether it is applied, whether that value is
* its default, how to clear itself and how to apply a staged value, through
* {@link useDashboardFilterRegistration}, which the `Filter` root already calls. A consumer keeps
* its filter state exactly where it already keeps it.
*
* @example
* ```tsx
* <DashboardFilters onClearAll={() => setSearchParams({})}>
* <DashboardFiltersRow>
* <DashboardFiltersSheet resultCount={results.length} onApply={applyToSearchParams} />
* <FilterCountry id="country" selectedKeys={countries} onChange={setCountries} />
* <FilterLanguage id="language" selectedKeys={languages} onChange={setLanguages} />
* </DashboardFiltersRow>
* <DashboardFiltersClear />
* </DashboardFilters>
* ```
*/
const DashboardFilters = React.forwardRef((componentProps, forwardRef) => {
const { children, onClearAll, className } = componentProps;
const [registrations, setRegistrations] = React.useState(() => /* @__PURE__ */ new Map());
const registerFilter = React.useCallback((id, registration) => {
setRegistrations((current) => {
const existing = current.get(id);
if (existing && sameRegistration(existing, registration)) return current;
const next = new Map(current);
next.set(id, registration);
return next;
});
}, []);
const unregisterFilter = React.useCallback((id) => {
setRegistrations((current) => {
if (!current.has(id)) return current;
const next = new Map(current);
next.delete(id);
return next;
});
}, []);
const registry = React.useMemo(() => ({
registerFilter,
unregisterFilter
}), [registerFilter, unregisterFilter]);
const onClearAllRef = React.useRef(onClearAll);
onClearAllRef.current = onClearAll;
const clearAllFilters = React.useCallback(() => {
var _onClearAllRef$curren;
(_onClearAllRef$curren = onClearAllRef.current) === null || _onClearAllRef$curren === void 0 || _onClearAllRef$curren.call(onClearAllRef);
setStagedValues((current) => current.size === 0 ? current : /* @__PURE__ */ new Map());
}, []);
const state = React.useMemo(() => ({
registrations,
clearAllFilters: typeof onClearAll === "function" ? clearAllFilters : void 0
}), [
registrations,
clearAllFilters,
onClearAll
]);
const [stagedValues, setStagedValues] = React.useState(() => /* @__PURE__ */ new Map());
const stage = React.useCallback((id, value) => {
setStagedValues((current) => {
const next = new Map(current);
next.set(id, value);
return next;
});
}, []);
const resetDraft = React.useCallback(() => {
setStagedValues((current) => current.size === 0 ? current : /* @__PURE__ */ new Map());
}, []);
const draft = React.useMemo(() => ({
stagedValues,
stage,
resetDraft
}), [
stagedValues,
stage,
resetDraft
]);
return /* @__PURE__ */ jsx(DashboardFilterRegistryContext.Provider, {
value: registry,
children: /* @__PURE__ */ jsx(DashboardFilterDraftContext.Provider, {
value: draft,
children: /* @__PURE__ */ jsx(DashboardFiltersStateContext.Provider, {
value: state,
children: /* @__PURE__ */ jsx("div", {
ref: forwardRef,
"data-slot": "dashboard-filters",
className: cn("flex w-full min-w-min items-center gap-2", className),
children
})
})
})
});
});
DashboardFilters.displayName = "DashboardFilters";
const DashboardFiltersRowContext = React.createContext(null);
/**
* Reads which surface the enclosing container is asking a filter to render: a chip for the row, or a
* field form for the sheet.
*
* Defaults to `'row'` outside any `DashboardFilters`, because a filter on its own is a chip - that
* is the common case, and a filter must never depend on a row existing.
*
* @example
* ```tsx
* if (useDashboardFilterSurface() === 'sheet') {
* return <DashboardFilterField label="Country">...</DashboardFilterField>;
* }
* ```
*/
function useDashboardFilterSurface() {
var _React$useContext$sur, _React$useContext;
return (_React$useContext$sur = (_React$useContext = React.useContext(DashboardFiltersRowContext)) === null || _React$useContext === void 0 ? void 0 : _React$useContext.surface) !== null && _React$useContext$sur !== void 0 ? _React$useContext$sur : "row";
}
/**
* Whether this filter renders its chip on the row at all. `false` means the row is holding it back
* for the sheet: the filter stays MOUNTED and keeps its registration, and renders nothing.
*
* True outside a `DashboardFiltersRow`, so a standalone filter always draws itself.
*/
function useIsFilterInlined(id) {
const row = React.useContext(DashboardFiltersRowContext);
if (!row) return true;
return row.isFilterInlined(id);
}
/**
* The filter row itself: the progressive-disclosure rules, and the single clipping line the chips
* live on.
*
* The rules, both driven by what the filters report rather than by anything the page passes:
* at or below {@link DASHBOARD_FILTERS_INLINE_LIMIT} filters every one shows inline; above it only
* the filters with a value applied stay on the row, and the untouched ones are reachable through
* `DashboardFiltersSheet`. A filter held back is still mounted and still registered - it just draws
* nothing - which is what keeps it counted and clearable.
*
* "With a value applied" means ANY value, the page's default included: a chip the user has not touched
* still shows what the page is filtering by. A filter the user CHANGED also stays, even when the
* change was to empty it - otherwise the one filter they most need to undo is the one that vanishes.
* Whether a filter has been changed is a separate question from whether it holds a value, and only
* `DashboardFiltersClear` asks the first one.
*
* Layout is a single non-wrapping 40px line. The row takes the width it is given and CLIPS, scrolling
* horizontally, so it can never push the page's trailing actions off screen and a chip that does not
* fit stays reachable by scrolling. It cannot grow.
*
* NARROW-WIDTH GUARANTEE, and it is the row's own contract rather than the consumer's problem: however
* little room this is given, the "All filters" trigger and `DashboardFiltersClear` stay visible and
* reachable, and ONLY the chips scroll or clip.
*
* What actually holds it, established by mutating each piece and seeing which stories fail: the chips
* area's `min-w-24` floor combined with the row NOT carrying `min-w-0`, so the row keeps a content-based
* minimum that a greedy neighbour has to yield to; and the trigger being portalled out of the scrolling
* area, so it cannot scroll away. The `min-w-min` on this element and on the row are explicit
* statements of that same intent rather than separate mechanisms - removing either alone does not break
* the stories, removing the floor does.
*
* It did not hold before, and the failure was backwards: with `min-w-0` the row collapsed, so the
* escape hatch for narrow widths was the first thing clipped away. Measured in a real app beside a
* search input - 700px lost the first chip, 660px left a 27px viewport hiding 480px of content, 620px
* left a 0px viewport, and 560px lost the trigger and Clear entirely. Pinned now at all five widths by
* `NarrowWidthGuaranteeAtMeasuredViewports`.
*
* Publishes its children so `DashboardFiltersSheet` can render the SAME filter declarations with a
* different surface. That is what makes one declaration serve both renderings.
*/
const DashboardFiltersRow = React.forwardRef((componentProps, forwardRef) => {
const { children, className } = componentProps;
const state = React.useContext(DashboardFiltersStateContext);
const registrations = state === null || state === void 0 ? void 0 : state.registrations;
const isFilterInlined = React.useCallback((id) => {
if (!registrations) return true;
if (registrations.size <= 3) return true;
const entry = registrations.get(id);
return entry === void 0 ? true : entry.hasValue || entry.isDirty;
}, [registrations]);
const [pinnedSlot, setPinnedSlot] = React.useState(null);
const rowContext = React.useMemo(() => ({
surface: "row",
filters: children,
isFilterInlined,
pinnedSlot
}), [
children,
isFilterInlined,
pinnedSlot
]);
return /* @__PURE__ */ jsx(DashboardFiltersRowContext.Provider, {
value: rowContext,
children: /* @__PURE__ */ jsxs("div", {
ref: forwardRef,
className: cn("flex h-10 min-w-min flex-1 items-center gap-2", className),
"data-slot": "dashboard-filters-row",
children: [/* @__PURE__ */ jsx("div", {
ref: setPinnedSlot,
"data-slot": "dashboard-filters-row-pinned",
className: "flex shrink-0 items-center gap-2 empty:hidden"
}), /* @__PURE__ */ jsx(ScrollArea, {
className: "h-10 w-0 min-w-24 flex-1",
"data-slot": "dashboard-filters-scroll",
scrollFade: true,
children: /* @__PURE__ */ jsx("div", {
className: "flex w-max items-center gap-1 pr-0.5",
children
})
})]
})
});
});
DashboardFiltersRow.displayName = "DashboardFiltersRow";
/**
* Provides the sheet surface to a copy of the row's filters. Used by `DashboardFiltersSheet`.
*
* Carries `isFilterInlined` as always-true: the sheet lists EVERY filter, which is the whole reason
* it exists, so the row's inline rule must not reach inside it.
*/
function DashboardFilterSheetSurface({ children }) {
const value = React.useMemo(() => ({
surface: "sheet",
filters: children,
isFilterInlined: () => true,
pinnedSlot: null
}), [children]);
return /* @__PURE__ */ jsx(DashboardFiltersRowContext.Provider, {
value,
children
});
}
/**
* Clears every filter in the row, and renders ONLY when that would change something: some filter has
* to be applied and away from its default value, so this is never offered as a no-op.
*
* Belongs OUTSIDE `DashboardFiltersRow`, and that placement is the point: inside it, this would sit
* in the clipping scroll content and scroll out of reach at exactly the widths where a user has the
* most filters applied and most wants it.
*
* Clearing itself is the CONSUMER's, through the row's `onClearAll`, in one call. Blocks performs no
* writes: it decides whether the action is worth offering, never what it does. Renders nothing
* without that handler, because an action that cannot act should not be offered.
*
* About COMMITTED values - what the page is actually filtered by. Not to be confused with the sheet's
* Reset, which only discards a draft the user has not applied yet; the two carry different labels for
* that reason.
*/
const DashboardFiltersClear = React.forwardRef((componentProps, forwardRef) => {
const { children, className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$26);
const t = useDsTranslation("blocks");
const state = React.useContext(DashboardFiltersStateContext);
if (!(state === null || state === void 0 ? void 0 : state.clearAllFilters) || !hasDirtyFilter(state.registrations)) return null;
return /* @__PURE__ */ jsx(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-filters-clear",
onClick: state.clearAllFilters,
size: "lg",
variant: "ghost",
className: cn("shrink-0", className)
}, props), {}, { children: children !== null && children !== void 0 ? children : t("dashboardFilters.clearAll") }));
});
DashboardFiltersClear.displayName = "DashboardFiltersClear";
/**
* Reads and writes one filter's SHEET draft entry.
*
* A filter rendering its sheet field shows `stagedValue` when something is staged and its own
* committed prop value otherwise, and routes every edit to `stage` instead of to the consumer's
* `onChange`. That is the whole "nothing reaches the page until Apply" rule, expressed once.
*
* `hasStagedValue` matters separately from `stagedValue`: a filter can legitimately stage an EMPTY
* value (an emptied selection, a cleared range), and `undefined` alone cannot tell "staged nothing"
* apart from "not staged".
*
* @example
* ```tsx
* const { stagedValue, hasStagedValue, stage } = useDashboardFilterStaging<Set<string>>(id);
* const shown = hasStagedValue ? stagedValue : selectedKeys;
* ```
*/
function useDashboardFilterStaging(id) {
var _draft$stagedValues$h;
const draft = React.useContext(DashboardFilterDraftContext);
const stage = React.useCallback((value) => {
draft === null || draft === void 0 || draft.stage(id, value);
}, [draft, id]);
return {
stagedValue: draft === null || draft === void 0 ? void 0 : draft.stagedValues.get(id),
hasStagedValue: (_draft$stagedValues$h = draft === null || draft === void 0 ? void 0 : draft.stagedValues.has(id)) !== null && _draft$stagedValues$h !== void 0 ? _draft$stagedValues$h : false,
stage
};
}
/**
* One labelled field in the sheet: a filter's name above its control (Figma node 7959-8635, where
* every row of the sheet body is a "Label" over one control).
*
* The label is VISUAL. Each control names itself for assistive technology - `LanguageCombobox` from its
* placeholder, `DataCombobox` from `aria-label` - because these controls are `role="combobox"`,
* which is never named by surrounding text, so an `htmlFor` label alone would leave them unnamed.
*/
const DashboardFilterField = React.forwardRef((componentProps, forwardRef) => {
const { label, children, className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$9);
return /* @__PURE__ */ jsxs(Field, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-filter-field",
className
}, props), {}, { children: [label === void 0 ? null : /* @__PURE__ */ jsx(FieldLabel, { children: label }), children] }));
});
DashboardFilterField.displayName = "DashboardFilterField";
/**
* The "All filters" trigger (Figma node 7959-5922) and the sheet it opens (Figma node 7959-8635):
* every filter in the row as a labelled field, over a footer with the applied result count on the
* left and Reset then Apply on the right.
*
* Renders the row's OWN children on the sheet surface rather than taking a second list of filters, so
* a filter is declared once and cannot drift between its chip and its field. It renders `null` on the
* sheet surface, which is what stops it recursing into itself when the sheet re-renders those
* children.
*
* Nothing a user changes here reaches the page until Apply: edits are staged in the root's draft, and
* Apply pushes each staged value back through the filter that staged it. Reset discards the draft and
* leaves the sheet open, so it is about UNAPPLIED changes - unlike `DashboardFiltersClear`, which
* clears what the page is actually filtered by.
*
* Dismissing the sheet without applying discards the draft too, matching what Escape does inside a
* filter chip: a change the user walked away from was not a change.
*
* Belongs INSIDE `DashboardFiltersRow`, as its first child, so the trigger reads ahead of the chips.
*/
function DashboardFiltersSheet({ resultCount, onApply, onReset, children, className }) {
const t = useDsTranslation("blocks");
const surface = useDashboardFilterSurface();
const row = React.useContext(DashboardFiltersRowContext);
const draft = React.useContext(DashboardFilterDraftContext);
const [open, setOpen] = React.useState(false);
const panelRef = React.useRef(null);
if (surface === "sheet") return null;
const handleOpenChange = (nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) draft === null || draft === void 0 || draft.resetDraft();
};
const handleApply = () => {
var _draft$stagedValues;
onApply === null || onApply === void 0 || onApply(Object.fromEntries((_draft$stagedValues = draft === null || draft === void 0 ? void 0 : draft.stagedValues) !== null && _draft$stagedValues !== void 0 ? _draft$stagedValues : []));
draft === null || draft === void 0 || draft.resetDraft();
setOpen(false);
};
const handleReset = () => {
draft === null || draft === void 0 || draft.resetDraft();
onReset === null || onReset === void 0 || onReset();
};
const trigger = /* @__PURE__ */ jsxs(SheetTrigger, {
render: /* @__PURE__ */ jsx(Button, {
"data-slot": "dashboard-filters-sheet-trigger",
size: "lg",
variant: "outline",
className: cn("shrink-0", className)
}),
children: [children !== null && children !== void 0 ? children : t("dashboardFilters.allFilters"), /* @__PURE__ */ jsx(ListFilter, { "data-icon": "inline-end" })]
});
return /* @__PURE__ */ jsxs(Sheet, {
open,
onOpenChange: handleOpenChange,
children: [(row === null || row === void 0 ? void 0 : row.pinnedSlot) ? createPortal(trigger, row.pinnedSlot) : trigger, /* @__PURE__ */ jsxs(SheetContent, {
ref: panelRef,
initialFocus: panelRef,
"data-slot": "dashboard-filters-sheet",
className: "gap-0",
children: [
/* @__PURE__ */ jsx(SheetHeader, { children: /* @__PURE__ */ jsx(SheetTitle, { children: t("dashboardFilters.title") }) }),
/* @__PURE__ */ jsx("div", {
"data-slot": "dashboard-filters-sheet-body",
className: "flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto p-4",
children: /* @__PURE__ */ jsx(DashboardFilterSheetSurface, { children: row === null || row === void 0 ? void 0 : row.filters })
}),
/* @__PURE__ */ jsxs(SheetFooter, {
className: cn("flex-row items-center gap-2 border-t border-border", resultCount === void 0 ? "justify-end" : "justify-between"),
children: [resultCount === void 0 ? null : /* @__PURE__ */ jsx("span", {
"data-slot": "dashboard-filters-sheet-count",
className: "text-muted-foreground",
children: t("dashboardFilters.resultCount", { count: resultCount })
}), /* @__PURE__ */ jsxs("span", {
className: "flex items-center gap-2",
children: [/* @__PURE__ */ jsx(Button, {
"data-slot": "dashboard-filters-sheet-reset",
onClick: handleReset,
size: "lg",
variant: "ghost",
children: t("dashboardFilters.reset")
}), /* @__PURE__ */ jsx(Button, {
"data-slot": "dashboard-filters-sheet-apply",
onClick: handleApply,
size: "lg",
children: t("dashboardFilters.apply")
})]
})]
})
]
})]
});
}
//#endregion
//#region src/components/product-badges.tsx
const _excluded$25 = ["className"];
const _excluded2$8 = ["className"];
const _excluded3$6 = ["role", "className"];
const GRADIENT_BASE$1 = cn("border-transparent bg-transparent text-foreground", "bg-[image:linear-gradient(90deg,var(--badge-overlay),var(--badge-overlay)),linear-gradient(90deg,var(--badge-accent),var(--badge-accent))]");
const productBadgeVariants = cva(GRADIENT_BASE$1, { variants: { type: {
beta: "[--badge-accent:var(--color-blue-300)]",
new: "[--badge-accent:var(--color-green-300)]",
deprecated: "[--badge-accent:var(--color-yellow-300)]",
trial: "[--badge-accent:var(--color-blue-300)]"
} } });
const PRODUCT_BADGE_KEY = {
beta: "productBadge.beta",
new: "productBadge.new",
deprecated: "productBadge.deprecated",
trial: "productBadge.trial"
};
function createProductBadge(type) {
const labelKey = PRODUCT_BADGE_KEY[type];
const ProductBadge = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$25);
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsx(Badge, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": `badge-${type}`,
color: "charcoal",
tone: "dark",
className: cn(productBadgeVariants({ type }), className)
}, props), {}, { children: t(labelKey) }));
});
ProductBadge.displayName = `${type.charAt(0).toUpperCase()}${type.slice(1)}Badge`;
return ProductBadge;
}
/** Blue pill — feature in beta, not yet GA */
const BetaBadge = createProductBadge("beta");
/** Green pill — recently launched feature */
const NewBadge = createProductBadge("new");
/** Amber pill — feature being phased out */
const DeprecatedBadge = createProductBadge("deprecated");
/** Blue pill — user is in a trial period (same blue family as Beta) */
const TrialBadge = createProductBadge("trial");
/** Charcoal pill — Professional tier (no gradient; solid secondary Badge) */
const ProfessionalBadge = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$8);
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsx(Badge, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "badge-professional",
tone: "dark",
color: "charcoal"
}, props), {}, { children: t("productBadge.professional") }));
});
ProfessionalBadge.displayName = "ProfessionalBadge";
const roleBadgeVariants = cva(GRADIENT_BASE$1, { variants: { role: {
agent: "[--badge-accent:var(--color-yellow-400)]",
supervisor: "[--badge-accent:var(--color-blue-500)]",
admin: "[--badge-accent:var(--color-purple-500)]",
owner: "[--badge-accent:var(--color-green-400)]"
} } });
const ROLE_BADGE_KEY = {
agent: "roleBadge.agent",
supervisor: "roleBadge.supervisor",
admin: "roleBadge.admin",
owner: "roleBadge.owner"
};
const RoleBadge = React.forwardRef((componentProps, forwardRef) => {
const { role, className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$6);
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsx(Badge, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": `badge-${role}`,
color: "charcoal",
tone: "dark",
className: cn(roleBadgeVariants({ role }), className)
}, props), {}, { children: t(ROLE_BADGE_KEY[role]) }));
});
RoleBadge.displayName = "RoleBadge";
//#endregion
//#region src/components/license-badge.tsx
const _excluded$24 = ["license", "className"];
const GRADIENT_BASE = cn("border-transparent bg-transparent", "[--badge-overlay:rgba(255,255,255,0.9)] dark:[--badge-overlay:rgba(10,10,10,0.9)]", "bg-[image:linear-gradient(90deg,var(--badge-overlay),var(--badge-overlay)),linear-gradient(90deg,var(--badge-accent),var(--badge-accent))]");
const licenseBadgeVariants = cva(GRADIENT_BASE, { variants: { license: {
ai_assist: cn("[--badge-accent:var(--color-green-700)] text-green-700", "dark:[--badge-accent:var(--color-green-400)] dark:text-green-400"),
ai_assist_pro: "[--badge-accent:var(--color-add-on-ai-assist-pro)] text-add-on-ai-assist-pro",
whatsapp: "[--badge-accent:var(--color-add-on-whatsapp)] text-foreground",
analytics_plus: "[--badge-accent:var(--color-add-on-analytics-plus)] text-add-on-analytics-plus",
surveys: "[--badge-accent:var(--color-add-on-surveys)] text-add-on-surveys"
} } });
const LICENSE_BADGE_KEY = {
ai_assist: "licenseBadge.aiAssist",
ai_assist_pro: "licenseBadge.aiAssistPro",
whatsapp: "licenseBadge.whatsapp",
analytics_plus: "licenseBadge.analyticsPlus",
surveys: "licenseBadge.surveys"
};
const LICENSE_BADGE_ICON = {
ai_assist: AiAssist,
ai_assist_pro: AiAssistPro,
whatsapp: SocialWhatsappOriginal,
analytics_plus: Zap,
surveys: Pencil
};
const LicenseBadge = React.forwardRef((componentProps, forwardRef) => {
const { license, className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$24);
const t = useDsTranslation("blocks");
const Icon = LICENSE_BADGE_ICON[license];
return /* @__PURE__ */ jsxs(Badge, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": `badge-${license}`,
color: "charcoal",
tone: "dark",
className: cn(licenseBadgeVariants({ license }), className)
}, props), {}, { children: [/* @__PURE__ */ jsx(Icon, {}), t(LICENSE_BADGE_KEY[license])] }));
});
LicenseBadge.displayName = "LicenseBadge";
//#endregion
//#region src/components/license-alert.tsx
const _excluded$23 = [
"license",
"title",
"action",
"onDismiss",
"children",
"className"
];
const _excluded2$7 = ["className", "render"];
/**
* Per-licence colour, all of it hanging off one `--color-add-on-*` token — see
* `src/styles/theme.css`. One token per add-on is Figma's own structure, and it
* is why there is no `dark:` anything here: the tokens flip themselves, and the
* two that are brand colours (WhatsApp, AIVA) deliberately do not.
*
* Two custom properties travel down to the CTA so {@link LicenseAlertAction}
* does not need to know which licence it sits in, plus the film it sits under:
*
* - `--license-cta` — the accent the CTA's background film sits over
* - `--license-cta-fg` — the CTA's label
* - `--license-film` — the film itself, 0.95 opaque, flipping by theme
*/
const licenseAlertVariants = cva(cn("bg-linear-78 to-transparent", "[--license-film:rgba(255,255,255,0.95)] dark:[--license-film:rgba(10,10,10,0.95)]"), { variants: { license: {
ai_assist_pro: cn("border-add-on-ai-assist-pro from-add-on-ai-assist-pro/20", "[&>svg]:text-add-on-ai-assist-pro", "[--license-cta:var(--color-add-on-ai-assist-pro)]", "[--license-cta-fg:var(--color-add-on-ai-assist-pro)]"),
analytics_plus: cn("border-add-on-analytics-plus from-add-on-analytics-plus/20", "[&>svg]:text-add-on-analytics-plus", "[--license-cta:var(--color-add-on-analytics-plus)]", "[--license-cta-fg:var(--color-add-on-analytics-plus)]"),
whatsapp: cn("border-add-on-whatsapp from-add-on-whatsapp/20", "[--license-cta:#0b0d11] [--license-cta-fg:var(--color-foreground)]"),
surveys: cn("border-add-on-surveys from-add-on-surveys/20", "[&>svg]:text-add-on-surveys", "[--license-cta:var(--color-add-on-surveys)]", "[--license-cta-fg:var(--color-add-on-surveys)]"),
ai_agents: cn("border-add-on-aiva from-add-on-aiva/20", "[&>svg]:text-add-on-aiva", "[--license-cta:#0b0d11] [--license-cta-fg:var(--color-foreground)]")
} } });
const LICENSE_ALERT_KEY = {
ai_assist_pro: LICENSE_BADGE_KEY.ai_assist_pro,
analytics_plus: LICENSE_BADGE_KEY.analytics_plus,
whatsapp: LICENSE_BADGE_KEY.whatsapp,
surveys: LICENSE_BADGE_KEY.surveys,
ai_agents: "licenseAlert.aiAgents"
};
const LICENSE_ALERT_ICON = {
ai_assist_pro: LICENSE_BADGE_ICON.ai_assist_pro,
analytics_plus: LICENSE_BADGE_ICON.analytics_plus,
whatsapp: LICENSE_BADGE_ICON.whatsapp,
surveys: LICENSE_BADGE_ICON.surveys,
ai_agents: AiAgents
};
const LicenseAlert = React.forwardRef((componentProps, forwardRef) => {
const { license, title, action, onDismiss, children, className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$23);
const t = useDsTranslation("blocks");
const Icon = LICENSE_ALERT_ICON[license];
return /* @__PURE__ */ jsxs(Alert, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": `license-alert-${license}`,
className: cn(licenseAlertVariants({ license }), className)
}, props), {}, { children: [
/* @__PURE__ */ jsx(Icon, {}),
/* @__PURE__ */ jsx(AlertTitle, {
className: "text-base",
children: title !== null && title !== void 0 ? title : t(LICENSE_ALERT_KEY[license])
}),
/* @__PURE__ */ jsx(AlertDescription, {
className: "text-foreground",
children
}),
(action || onDismiss) && /* @__PURE__ */ jsxs(AlertAction, {
className: "items-center",
children: [action, onDismiss && /* @__PURE__ */ jsx(Button, {
variant: "ghost",
size: "icon",
"aria-label": t("licenseAlert.dismiss"),
onClick: onDismiss,
children: /* @__PURE__ */ jsx(X, {})
})]
})
] }));
});
LicenseAlert.displayName = "LicenseAlert";
/**
* The call to action inside a {@link LicenseAlert} — the licence's accent under
* a near-opaque film, which is what makes it read as a tint rather than a solid,
* in the shape of a `lg` DS button.
*
* Takes its colours from the custom properties its alert sets, so it needs no
* licence of its own, and falls back to the primary colour when used outside
* one.
*
* Wears `buttonVariants` rather than rendering a DS `Button`, so that `render`
* can turn it into a real link. The DS `Button` is a Base UI button primitive:
* handed an anchor it keeps `role="button"`, which announces "button" for
* something that navigates. Here an anchor stays an anchor. The trade is that
* only the styles come across, not the primitive's behaviour — `disabled` in
* particular is whatever the rendered element does with it, which for an anchor
* is nothing. No upsell CTA in the design is ever disabled.
*
* @example
* ```tsx
* <LicenseAlertAction onClick={openUpgradeDialog}>Discover AI Assist Pro</LicenseAlertAction>
* <LicenseAlertAction render={<a href="/upgrade" />}>Discover AI Assist Pro</LicenseAlertAction>
* ```
*/
const LicenseAlertAction = React.forwardRef((componentProps, forwardRef) => {
const { className, render } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$7);
const rendersNativeButton = !render || React.isValidElement(render) && render.type === "button";
return useRender({
defaultTagName: "button",
props: mergeProps(_objectSpread2(_objectSpread2({ ref: forwardRef }, rendersNativeButton && { type: "button" }), {}, { className: cn(buttonVariants({ size: "lg" }), "border-transparent bg-transparent bg-clip-border", "text-(--license-cta-fg,var(--color-primary))", "bg-[image:linear-gradient(90deg,var(--license-film,rgba(255,255,255,0.95)),var(--license-film,rgba(255,255,255,0.95))),linear-gradient(90deg,var(--license-cta,var(--color-primary)),var(--license-cta,var(--color-primary)))]", "hover:[--license-film:rgba(255,255,255,0.9)] dark:hover:[--license-film:rgba(10,10,10,0.9)]", className) }), props),
render,
state: { slot: "license-alert-action" }
});
});
LicenseAlertAction.displayName = "LicenseAlertAction";
//#endregion
//#region src/components/score-badge.tsx
const _excluded$22 = [
"value",
"kind",
"thresholds",
"formatOptions",
"className"
];
const SCORE_TONE_BADGE = {
success: {
color: "green",
tone: "medium-light"
},
"success-alt": {
color: "green",
tone: "light"
},
warning: {
color: "yellow",
tone: "light"
},
destructive: {
color: "red",
tone: "light"
},
"destructive-strong": {
color: "red",
tone: "medium-light"
},
muted: {
color: "charcoal",
tone: "medium-light"
}
};
/**
* The default color bands, from the Figma ScoreBadge spec (0–100 scale):
* `≥85` excellent, `70–84` good, `50–69` fair, `20–49` poor, `<20` very poor.
*/
const PERCENTAGE_THRESHOLDS = [
{
from: 85,
tone: "success"
},
{
from: 70,
tone: "success-alt"
},
{
from: 50,
tone: "warning"
},
{
from: 20,
tone: "destructive"
},
{
from: 0,
tone: "destructive-strong"
}
];
/**
* Highest band whose `from` is ≤ value wins; a value below every band → muted.
*
* @internal Exported for `score-badge.test.ts` only — not re-exported from the package index.
*/
function resolveTone(value, thresholds) {
var _band$tone;
const band = [...thresholds].sort((a, b) => b.from - a.from).find((t) => value >= t.from);
return (_band$tone = band === null || band === void 0 ? void 0 : band.tone) !== null && _band$tone !== void 0 ? _band$tone : "muted";
}
/**
* Format a numeric score for display in the given locale (via `Intl.NumberFormat`),
* and report the value rounded to the *same* precision.
* - `number`: the value as-is — at most one decimal, trailing zeros dropped
* (`en`: `90.66666 → "90.7"`, `4 → "4"`; `fr`: `90,7`).
* - `percent`: a 0–100 score shown with a `%` suffix (`90.66666 → "90.7%"`; `fr`: `90,7 %`).
*
* `formatOptions` are merged over the per-kind defaults — e.g.
* `{ maximumFractionDigits: 0 }` to drop decimals, `{ minimumFractionDigits: 2 }` for more.
*
* `rounded` is the value rounded to the displayed precision; the pill color is
* resolved from it (not the raw value) so the color always matches the shown
* number — e.g. `3.6` displayed as `"4"` is colored as a 4, not a 3.6.
*
* @internal Exported for `score-badge.test.ts` only — not re-exported from the package index.
*/
function formatScore(value, kind, locale, formatOptions) {
var _formatter$resolvedOp;
const options = kind === "percent" ? _objectSpread2({
style: "percent",
maximumFractionDigits: 1
}, formatOptions) : _objectSpread2({ maximumFractionDigits: 1 }, formatOptions);
const formatter = new Intl.NumberFormat(locale, options);
const digits = (_formatter$resolvedOp = formatter.resolvedOptions().maximumFractionDigits) !== null && _formatter$resolvedOp !== void 0 ? _formatter$resolvedOp : 0;
return {
label: formatter.format(kind === "percent" ? value / 100 : value),
rounded: Number(value.toFixed(digits))
};
}
/**
* A score pill. Composes DS `Badge`: derives its color from `value` against
* `thresholds` and renders the value, locale-formatted per `kind`.
*
* @example
* ```tsx
* <ScoreBadge value={90.66666} /> // "90.7"
* <ScoreBadge value={90.66666} kind="percent" /> // "90.7%"
* <ScoreBadge value={4} thresholds={CSAT} formatOptions={{ maximumFractionDigits: 0 }} /> // "4"
* ```
*/
const ScoreBadge = React.forwardRef((componentProps, forwardRef) => {
const { value, kind = "number", thresholds = PERCENTAGE_THRESHOLDS, formatOptions, className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$22);
const locale = useDsLocale();
const score = value != null && Number.isFinite(value) ? value : null;
const formatted = score === null ? null : formatScore(score, kind, locale, formatOptions);
const tone = formatted === null ? "muted" : resolveTone(formatted.rounded, thresholds);
const { color, tone: badgeTone } = SCORE_TONE_BADGE[tone];
return /* @__PURE__ */ jsx(Badge, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "score-badge",
color,
tone: badgeTone,
className: cn("tabular-nums", className)
}, props), {}, { children: formatted === null ? "--" : formatted.label }));
});
ScoreBadge.displayName = "ScoreBadge";
//#endregion
//#region src/helpers/format-kpi.ts
/** Default secondary unit for clock durations. */
function clockDurationUnit(scale) {
return scale === "hours" ? "hr/min" : "min/sec";
}
/**
* Locale decimal separator (e.g. `.` for `en-US`, `,` for `fr-FR`).
*/
function getDecimalSeparator(locale) {
var _part$value;
const part = new Intl.NumberFormat(locale).formatToParts(1.1).find((p) => p.type === "decimal");
return (_part$value = part === null || part === void 0 ? void 0 : part.value) !== null && _part$value !== void 0 ? _part$value : ".";
}
/**
* Split a locale-formatted number on its decimal separator so the integer group
* can use primary typography and the fraction (+ unit) secondary typography.
*/
function splitFormattedNumber(formatted, locale) {
const dec = getDecimalSeparator(locale);
const idx = formatted.indexOf(dec);
if (idx === -1) return {
primary: formatted,
secondary: ""
};
return {
primary: formatted.slice(0, idx),
secondary: formatted.slice(idx)
};
}
/**
* Split a duration string on the leading digit run (`"1h 2min 3s"` → `"1"` + `"h 2min 3s"`).
*/
function splitLeadingDigits(text) {
const match = /^(\d+)([\s\S]*)$/.exec(text);
if (!match) return null;
return {
primary: match[1],
secondary: match[2]
};
}
/**
* Format seconds into a readable duration (e.g. `1h 2min 3s`).
* Implemented without `date-fns`.
*
* @example
* formatSecondsToDuration(0) // => "0s"
* formatSecondsToDuration(60) // => "1min 0s"
* formatSecondsToDuration(3600) // => "1h 0min 0s"
*/
function formatSecondsToDuration(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
const days = Math.floor(s / 86400);
const hours = Math.floor(s % 86400 / 3600);
const minutes = Math.floor(s % 3600 / 60);
const seconds = s % 60;
if (days) return `${days}d ${hours}h ${minutes}min ${seconds}s`;
if (hours) return `${hours}h ${minutes}min ${seconds}s`;
if (minutes) return `${minutes}min ${seconds}s`;
return `${seconds}s`;
}
/**
* Format seconds as a zero-padded clock duration (`HH:MM` / `MM:SS`).
* The full clock string is primary typography; the unit is appended separately.
*
* @example
* formatSecondsToClock(75180, 'hours') // => { primary: "20:53", secondary: "" }
* formatSecondsToClock(304, 'minutes') // => { primary: "05:04", secondary: "" }
*/
function formatSecondsToClock(totalSeconds, scale) {
const s = Math.max(0, Math.floor(totalSeconds));
if (scale === "hours") {
const hours = Math.floor(s / 3600);
const minutes = Math.floor(s % 3600 / 60);
return {
primary: `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`,
secondary: ""
};
}
const minutes = Math.floor(s / 60);
const seconds = s % 60;
return {
primary: `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`,
secondary: ""
};
}
/**
* Format a KPI `value` for split typography display.
* Returns `null` for empty / non-finite values (caller renders {@link KPI_EMPTY}).
*
* Contract:
* - `string` is always opaque text (`kind` is ignored). Coerce at the call site
* if the API sent a numeric string and you need `number` / `percent` / `duration`.
* - `number` / `percent` / `duration` require a finite `number`.
*/
function formatKpiValue(value, kind, locale, { formatOptions, durationStyle = "long", durationScale = "minutes" } = {}) {
if (kind === "text" || typeof value === "string") {
const text = String(value);
if (text === "") return null;
return {
primary: text,
secondary: ""
};
}
if (!Number.isFinite(value)) return null;
if (kind === "duration") {
var _splitLeadingDigits;
if (durationStyle === "clock") return formatSecondsToClock(value, durationScale);
const formatted = formatSecondsToDuration(value);
return (_splitLeadingDigits = splitLeadingDigits(formatted)) !== null && _splitLeadingDigits !== void 0 ? _splitLeadingDigits : {
primary: formatted,
secondary: ""
};
}
const options = kind === "percent" ? _objectSpread2({
style: "percent",
maximumFractionDigits: 1
}, formatOptions) : _objectSpread2({
maximumFractionDigits: 1,
minimumFractionDigits: 0
}, formatOptions);
return splitFormattedNumber(new Intl.NumberFormat(locale, options).format(kind === "percent" ? value / 100 : value), locale);
}
//#endregion
//#region src/components/kpi.tsx
const _excluded$21 = [
"className",
"size",
"disabled",
"children"
];
const _excluded2$6 = [
"className",
"value",
"kind",
"formatOptions",
"durationStyle",
"durationScale",
"unit",
"trend",
"tone",
"active",
"disabled",
"children",
"onClick",
"render"
];
const _excluded3$5 = [
"className",
"trend",
"tone",
"children"
];
const _excluded4$3 = ["className"];
const _excluded5$3 = ["className"];
const TREND_ICON = {
up: ArrowUpRight,
down: ArrowDownRight,
neutral: Minus
};
const TONE_CLASS = {
positive: "text-success",
negative: "text-destructive",
neutral: "text-muted-foreground"
};
const KpiCardContext = React.createContext(null);
function useKpiCardDisabled() {
var _React$useContext$dis, _React$useContext;
return (_React$useContext$dis = (_React$useContext = React.useContext(KpiCardContext)) === null || _React$useContext === void 0 ? void 0 : _React$useContext.disabled) !== null && _React$useContext$dis !== void 0 ? _React$useContext$dis : false;
}
function resolveKind(value, kind) {
if (typeof value === "string") return "text";
if (kind) return kind;
return "number";
}
function TrendGlyph({ trend, tone, className }) {
const Icon = TREND_ICON[trend];
return /* @__PURE__ */ jsx(Icon, {
"aria-hidden": "true",
className: cn("shrink-0", TONE_CLASS[tone], className)
});
}
/**
* KPI tile container. Thin wrapper around DS `Card` (`size="sm"` by default)
* that provides a `disabled` context for nested {@link KpiValue}s.
* When `disabled`, applies `opacity-50` and `cursor-not-allowed`, and blocks
* activation of nested interactive values.
* Compose with DS `CardHeader` / `CardTitle` / `CardContent` / `CardAction`,
* {@link KpiValue}, and {@link KpiDescription}.
*
* @example
* ```tsx
* import { CardHeader, CardTitle, CardContent } from '@aircall/ds';
* import { KpiCard, KpiValue, KpiDescription } from '@aircall/blocks';
*
* <KpiCard>
* <CardHeader>
* <CardTitle>Answered</CardTitle>
* </CardHeader>
* <CardContent className="flex flex-col gap-1">
* <KpiValue value={123.1} kind="percent" trend="up" tone="positive" />
* <KpiDescription trend="up" tone="positive">+2.48% vs previous period</KpiDescription>
* </CardContent>
* </KpiCard>
* ```
*/
const KpiCard = React.forwardRef((componentProps, forwardRef) => {
const { className, size = "sm", disabled = false, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$21);
return /* @__PURE__ */ jsx(KpiCardContext.Provider, {
value: { disabled },
children: /* @__PURE__ */ jsx(Card, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "kpi-card",
"data-disabled": disabled || void 0,
"aria-disabled": disabled || void 0,
size,
className: cn("gap-2 data-[size=sm]:gap-2", disabled && "cursor-not-allowed opacity-50", className)
}, props), {}, { children }))
});
});
KpiCard.displayName = "KpiCard";
/**
* KPI metric value. Formats via `useDsLocale` and renders primary (and optional
* secondary) typography, with trend variants. Passing `onClick` makes the value
* interactive (button + dotted underline). Prefer {@link KpiCard}`s `disabled`
* for tile-level muting; a local `disabled` still works for standalone use.
* Compose inside DS `CardContent` with {@link KpiDescription}.
*
* Prop naming: `kind` + `formatOptions` for formatting; `trend` is direction
* (icon); `tone` is meaning (color) — "up" is not always good.
*
* @example
* ```tsx
* <KpiValue value={123.1} kind="percent" trend="up" tone="positive" />
* ```
*/
const KpiValue = React.forwardRef((componentProps, forwardRef) => {
const { className, value, kind: kindProp, formatOptions, durationStyle = "long", durationScale = "minutes", unit, trend, tone = "neutral", active = false, disabled: disabledProp = false, children, onClick, render } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$6);
const locale = useDsLocale();
const cardDisabled = useKpiCardDisabled();
const isDisabled = Boolean(disabledProp || cardDisabled);
const isInteractive = typeof onClick === "function";
const kind = resolveKind(value, kindProp);
let content;
const valueUnderlineClassName = cn(isInteractive && "border-b border-current", isInteractive && !active && "border-dotted", isInteractive && active && "border-solid");
const trendGlyph = trend ? /* @__PURE__ */ jsx(TrendGlyph, {
trend,
tone: disabledProp && !cardDisabled ? "neutral" : tone,
className: "size-6"
}) : null;
if (children != null) content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("span", {
className: cn("inline-flex items-baseline gap-0.5", valueUnderlineClassName),
children: [/* @__PURE__ */ jsx("span", {
className: "text-3xl font-bold",
children
}), unit != null && unit !== "" && /* @__PURE__ */ jsx("span", {
className: "pt-2 text-xl leading-none font-semibold",
children: unit
})]
}), trendGlyph] });
else {
const formatted = value == null || typeof value === "number" && !Number.isFinite(value) ? null : formatKpiValue(value, kind, locale, {
formatOptions,
durationStyle,
durationScale
});
const resolvedUnit = unit !== void 0 ? unit : kind === "duration" && durationStyle === "clock" ? clockDurationUnit(durationScale) : void 0;
const unitNode = resolvedUnit != null && resolvedUnit !== "" ? resolvedUnit : null;
if (!formatted) content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
className: cn("text-3xl font-bold", valueUnderlineClassName),
children: "--"
}), trendGlyph] });
else {
const hasSecondary = formatted.secondary !== "" || unitNode != null;
content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("span", {
className: cn("inline-flex items-baseline gap-0.5", valueUnderlineClassName),
children: [/* @__PURE__ */ jsx("span", {
className: "text-3xl font-bold",
children: formatted.primary
}), hasSecondary && /* @__PURE__ */ jsxs("span", {
className: "pt-2 text-xl leading-none font-semibold",
children: [
formatted.secondary,
formatted.secondary !== "" && unitNode != null ? " " : null,
unitNode
]
})]
}), trendGlyph] });
}
}
const rootClassName = cn("inline-flex w-fit items-center gap-0.5 font-sans", disabledProp && !cardDisabled ? "text-muted-foreground" : active && isInteractive && !isDisabled ? "text-primary" : "text-foreground", isInteractive && !isDisabled && cn("cursor-pointer border-0 bg-transparent p-0 transition-colors active:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", active ? "hover:text-primary/80" : "hover:text-muted-foreground"), isInteractive && isDisabled && "cursor-not-allowed border-0 bg-transparent p-0", className);
return useRender({
defaultTagName: isInteractive ? "button" : "div",
props: mergeProps(_objectSpread2({
ref: forwardRef,
className: rootClassName,
children: content
}, isInteractive ? {
type: "button",
onClick,
disabled: isDisabled,
"aria-pressed": active
} : isDisabled ? { "aria-disabled": true } : {}), props),
render,
state: {
slot: "kpi-value",
active,
disabled: isDisabled,
interactive: isInteractive,
trend,
tone
}
});
});
KpiValue.displayName = "KpiValue";
/**
* Supporting comparison / context line under a {@link KpiValue}. Compose as a
* sibling inside DS `CardContent` — not the metric title (`CardTitle` owns that).
*
* Text stays foreground; `trend` / `tone` only color the trailing icon so an
* upward change that is bad uses `trend="up" tone="negative"`.
*
* @example
* ```tsx
* <KpiDescription trend="up" tone="positive">+2.48% vs previous period</KpiDescription>
* <KpiDescription trend="up" tone="negative">+12% abandoned</KpiDescription>
* ```
*/
const KpiDescription = React.forwardRef(function KpiDescription(componentProps, forwardRef) {
const { className, trend, tone = "neutral", children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$5);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "kpi-description",
"data-trend": trend,
"data-tone": tone,
className: cn("inline-flex items-center gap-0.5 font-sans text-xs font-medium text-foreground", className)
}, props), {}, { children: [children, trend ? /* @__PURE__ */ jsx(TrendGlyph, {
trend,
tone,
className: "size-3"
}) : null] }));
});
KpiDescription.displayName = "KpiDescription";
/**
* Loading placeholder sized for a {@link KpiValue} row. Thin wrapper around
* DS `Skeleton` (`h-4 w-[45px]` with vertical margin to match the value row).
* Swap in place of {@link KpiValue} while data loads.
*
* @example
* ```tsx
* <CardContent>
* {loading ? <KpiValueSkeleton /> : <KpiValue value={123} />}
* </CardContent>
* ```
*/
const KpiValueSkeleton = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded4$3);
return /* @__PURE__ */ jsx(Skeleton, _objectSpread2({
ref: forwardRef,
"data-slot": "kpi-value-skeleton",
className: cn("my-[9px] h-4 w-[45px]", className)
}, props));
});
KpiValueSkeleton.displayName = "KpiValueSkeleton";
/**
* Loading placeholder sized for a {@link KpiDescription} row. Thin wrapper
* around DS `Skeleton` (`12×144`, matching `text-xs` comparison copy).
* Swap in place of {@link KpiDescription} while data loads.
*
* @example
* ```tsx
* <CardContent className="flex flex-col gap-1">
* {loading ? (
* <>
* <KpiValueSkeleton />
* <KpiDescriptionSkeleton />
* </>
* ) : (
* <>
* <KpiValue value={123} />
* <KpiDescription>+2.48% vs previous period</KpiDescription>
* </>
* )}
* </CardContent>
* ```
*/
const KpiDescriptionSkeleton = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded5$3);
return /* @__PURE__ */ jsx(Skeleton, _objectSpread2({
ref: forwardRef,
"data-slot": "kpi-description-skeleton",
className: cn("h-3 w-36", className)
}, props));
});
KpiDescriptionSkeleton.displayName = "KpiDescriptionSkeleton";
//#endregion
//#region src/components/sentiment-badge.tsx
const _excluded$20 = ["sentiment", "children"];
const SENTIMENT_BADGE = {
positive: {
color: "green",
tone: "medium-light",
Icon: Smile,
labelKey: "sentiment.positive"
},
negative: {
color: "red",
tone: "medium-light",
Icon: Frown,
labelKey: "sentiment.negative"
},
neutral: {
color: "charcoal",
tone: "medium-light",
Icon: Meh,
labelKey: "sentiment.neutral"
}
};
/**
* A sentiment pill for conversation mood (positive / negative / neutral).
* Composes DS `Badge`: the `sentiment` picks the color+tone and leading
* emoticon, and the label defaults to a localized word for that sentiment.
* Pass `children` to override the label (the icon and color stay). The icon is
* decorative (`aria-hidden`) — the text label is the accessible name.
*
* @example
* ```tsx
* <SentimentBadge sentiment="positive" /> // 🙂 Positive
* <SentimentBadge sentiment="negative">Angry</SentimentBadge> // 🙁 Angry
* ```
*/
const SentimentBadge = React.forwardRef((componentProps, forwardRef) => {
const { sentiment, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$20);
const t = useDsTranslation("blocks");
const { color, tone, Icon, labelKey } = SENTIMENT_BADGE[sentiment];
return /* @__PURE__ */ jsxs(Badge, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "sentiment-badge",
"data-sentiment": sentiment,
color,
tone
}, props), {}, { children: [/* @__PURE__ */ jsx(Icon, { "aria-hidden": "true" }), children !== null && children !== void 0 ? children : t(labelKey)] }));
});
SentimentBadge.displayName = "SentimentBadge";
//#endregion
//#region src/components/chatbot-panel-trigger.tsx
const _excluded$19 = [
"children",
"icon",
"className"
];
/**
* Floating trigger that shows / hides the chatbot panel.
*
* Rests as a 40px icon-only circle and expands into a pill that reveals its
* label on hover and keyboard focus. The four Figma states map to real CSS
* pseudo-states: default (rest), hover, focus (`:focus-visible` ring) and
* pressed (`:active`, dimmed to 60%).
*/
function ChatbotPanelTrigger(_ref) {
let { children = "Ask anything", icon = /* @__PURE__ */ jsx(AircallLogo, {}), className } = _ref, buttonProps = _objectWithoutProperties(_ref, _excluded$19);
return /* @__PURE__ */ jsxs(Button, _objectSpread2(_objectSpread2({
"data-slot": "chatbot-panel-trigger",
className: cn("h-10 min-w-10 gap-0 rounded-full px-[9px] text-base", "border-white/20 bg-clip-border", "bg-green-600 shadow-[0px_0px_6px_0px_var(--color-green-500)]", "hover:bg-[color-mix(in_srgb,var(--color-green-600)_90%,white)]", "hover:shadow-[0px_0px_6px_0px_var(--color-green-500),0px_10px_15px_-3px_rgba(0,0,0,0.1),0px_4px_6px_-4px_rgba(0,0,0,0.1)]", "focus-visible:shadow-none focus-visible:ring-[3px] focus-visible:ring-[color-mix(in_srgb,var(--color-green-400)_40%,transparent)]", "active:bg-green-600 active:shadow-[0px_0px_4px_0px_var(--color-green-400)]", className)
}, buttonProps), {}, { children: [/* @__PURE__ */ jsx("span", {
className: "grid size-5 shrink-0 place-items-center [&_svg]:size-5",
children: icon
}), /* @__PURE__ */ jsx("span", {
className: cn("max-w-0 overflow-hidden opacity-0 transition-[max-width,opacity] duration-200", "group-hover/button:max-w-[12rem] group-hover/button:opacity-100", "group-focus-visible/button:max-w-[12rem] group-focus-visible/button:opacity-100", "group-active/button:max-w-[12rem] group-active/button:opacity-100"),
children: /* @__PURE__ */ jsx("span", {
className: "block pl-1 leading-6 whitespace-nowrap",
children
})
})] }));
}
//#endregion
//#region src/helpers/timezones.helpers.ts
/** Turns an IANA id into a friendly name: `"Europe/Madrid"` → `"Europe - Madrid"`. */
function prettifyTimezoneName(value) {
return value.replace(/\//g, " - ").replace(/_/g, " ");
}
/**
* Legacy zone list ported verbatim from the dashboard-modules library.
*
* In `getDefaultTimezones` it is unioned with the browser's
* `Intl.supportedValuesOf('timeZone')` set **by `value`, browser first** — so any id
* the runtime already provides keeps the prettified IANA name, and only ids the
* runtime omits (e.g. `Etc/*` aliases, or modern spellings like `Asia/Kolkata` that
* ICU still canonicalizes as `Asia/Calcutta`) are added from this list with their
* friendly names. Kept in full for parity with the library and to guarantee coverage
* of stored values on runtimes with a leaner `Intl` timezone set.
*/
const LEGACY_TIMEZONES = [
{
value: "Etc/GMT+12",
name: "International Date Line West"
},
{
value: "Pacific/Pago_Pago",
name: "American Samoa"
},
{
value: "Pacific/Midway",
name: "Midway Island"
},
{
value: "Pacific/Honolulu",
name: "Hawaii"
},
{
value: "America/Juneau",
name: "Alaska"
},
{
value: "America/Los_Angeles",
name: "Pacific Time (US & Canada)"
},
{
value: "America/Tijuana",
name: "Tijuana"
},
{
value: "America/Phoenix",
name: "Arizona"
},
{
value: "America/Chihuahua",
name: "Chihuahua"
},
{
value: "America/Mazatlan",
name: "Mazatlan"
},
{
value: "America/Denver",
name: "Mountain Time (US & Canada)"
},
{
value: "America/Guatemala",
name: "Central America"
},
{
value: "America/Chicago",
name: "Central Time (US & Canada)"
},
{
value: "America/Mexico_City",
name: "Mexico City"
},
{
value: "America/Monterrey",
name: "Monterrey"
},
{
value: "America/Regina",
name: "Saskatchewan"
},
{
value: "America/Bogota",
name: "Bogota"
},
{
value: "America/New_York",
name: "Eastern Time (US & Canada)"
},
{
value: "America/Indiana/Indianapolis",
name: "Indiana (East)"
},
{
value: "America/Lima",
name: "Lima"
},
{
value: "America/Halifax",
name: "Atlantic Time (Canada)"
},
{
value: "America/Caracas",
name: "Caracas"
},
{
value: "America/Guyana",
name: "Georgetown"
},
{
value: "America/La_Paz",
name: "La Paz"
},
{
value: "America/Puerto_Rico",
name: "Puerto Rico"
},
{
value: "America/Santiago",
name: "Santiago"
},
{
value: "America/St_Johns",
name: "Newfoundland"
},
{
value: "America/Sao_Paulo",
name: "Brasilia"
},
{
value: "America/Argentina/Buenos_Aires",
name: "Buenos Aires"
},
{
value: "America/Godthab",
name: "Greenland"
},
{
value: "America/Montevideo",
name: "Montevideo"
},
{
value: "Atlantic/South_Georgia",
name: "Mid-Atlantic"
},
{
value: "Atlantic/Azores",
name: "Azores"
},
{
value: "Atlantic/Cape_Verde",
name: "Cape Verde Is."
},
{
value: "Africa/Casablanca",
name: "Casablanca"
},
{
value: "Europe/Dublin",
name: "Dublin"
},
{
value: "Europe/Lisbon",
name: "Lisbon"
},
{
value: "Europe/London",
name: "London"
},
{
value: "Africa/Monrovia",
name: "Monrovia"
},
{
value: "Etc/UTC",
name: "UTC"
},
{
value: "Europe/Amsterdam",
name: "Amsterdam"
},
{
value: "Europe/Belgrade",
name: "Belgrade"
},
{
value: "Europe/Berlin",
name: "Berlin"
},
{
value: "Europe/Bratislava",
name: "Bratislava"
},
{
value: "Europe/Brussels",
name: "Brussels"
},
{
value: "Europe/Budapest",
name: "Budapest"
},
{
value: "Europe/Copenhagen",
name: "Copenhagen"
},
{
value: "Europe/Ljubljana",
name: "Ljubljana"
},
{
value: "Europe/Madrid",
name: "Madrid"
},
{
value: "Europe/Paris",
name: "Paris"
},
{
value: "Europe/Prague",
name: "Prague"
},
{
value: "Europe/Rome",
name: "Rome"
},
{
value: "Europe/Sarajevo",
name: "Sarajevo"
},
{
value: "Europe/Skopje",
name: "Skopje"
},
{
value: "Europe/Stockholm",
name: "Stockholm"
},
{
value: "Europe/Vienna",
name: "Vienna"
},
{
value: "Europe/Warsaw",
name: "Warsaw"
},
{
value: "Africa/Algiers",
name: "West Central Africa"
},
{
value: "Europe/Zagreb",
name: "Zagreb"
},
{
value: "Europe/Zurich",
name: "Zurich"
},
{
value: "Europe/Athens",
name: "Athens"
},
{
value: "Europe/Bucharest",
name: "Bucharest"
},
{
value: "Africa/Cairo",
name: "Cairo"
},
{
value: "Africa/Harare",
name: "Harare"
},
{
value: "Europe/Helsinki",
name: "Helsinki"
},
{
value: "Asia/Jerusalem",
name: "Jerusalem"
},
{
value: "Europe/Kaliningrad",
name: "Kaliningrad"
},
{
value: "Europe/Kiev",
name: "Kyiv"
},
{
value: "Africa/Johannesburg",
name: "Pretoria"
},
{
value: "Europe/Riga",
name: "Riga"
},
{
value: "Europe/Sofia",
name: "Sofia"
},
{
value: "Europe/Tallinn",
name: "Tallinn"
},
{
value: "Europe/Vilnius",
name: "Vilnius"
},
{
value: "Asia/Baghdad",
name: "Baghdad"
},
{
value: "Europe/Istanbul",
name: "Istanbul"
},
{
value: "Asia/Kuwait",
name: "Kuwait"
},
{
value: "Europe/Minsk",
name: "Minsk"
},
{
value: "Europe/Moscow",
name: "Moscow"
},
{
value: "Africa/Nairobi",
name: "Nairobi"
},
{
value: "Asia/Riyadh",
name: "Riyadh"
},
{
value: "Asia/Tehran",
name: "Tehran"
},
{
value: "Asia/Baku",
name: "Baku"
},
{
value: "Asia/Muscat",
name: "Muscat"
},
{
value: "Europe/Samara",
name: "Samara"
},
{
value: "Asia/Tbilisi",
name: "Tbilisi"
},
{
value: "Europe/Volgograd",
name: "Volgograd"
},
{
value: "Asia/Yerevan",
name: "Yerevan"
},
{
value: "Asia/Kabul",
name: "Kabul"
},
{
value: "Asia/Yekaterinburg",
name: "Ekaterinburg"
},
{
value: "Asia/Karachi",
name: "Karachi"
},
{
value: "Asia/Tashkent",
name: "Tashkent"
},
{
value: "Asia/Kolkata",
name: "Kolkata"
},
{
value: "Asia/Colombo",
name: "Sri Jayawardenepura"
},
{
value: "Asia/Kathmandu",
name: "Kathmandu"
},
{
value: "Asia/Almaty",
name: "Almaty"
},
{
value: "Asia/Dhaka",
name: "Dhaka"
},
{
value: "Asia/Urumqi",
name: "Urumqi"
},
{
value: "Asia/Rangoon",
name: "Rangoon"
},
{
value: "Asia/Bangkok",
name: "Bangkok"
},
{
value: "Asia/Jakarta",
name: "Jakarta"
},
{
value: "Asia/Krasnoyarsk",
name: "Krasnoyarsk"
},
{
value: "Asia/Novosibirsk",
name: "Novosibirsk"
},
{
value: "Asia/Shanghai",
name: "Beijing"
},
{
value: "Asia/Chongqing",
name: "Chongqing"
},
{
value: "Asia/Hong_Kong",
name: "Hong Kong"
},
{
value: "Asia/Irkutsk",
name: "Irkutsk"
},
{
value: "Asia/Kuala_Lumpur",
name: "Kuala Lumpur"
},
{
value: "Australia/Perth",
name: "Perth"
},
{
value: "Asia/Singapore",
name: "Singapore"
},
{
value: "Asia/Taipei",
name: "Taipei"
},
{
value: "Asia/Ulaanbaatar",
name: "Ulaanbaatar"
},
{
value: "Asia/Seoul",
name: "Seoul"
},
{
value: "Asia/Tokyo",
name: "Tokyo"
},
{
value: "Asia/Yakutsk",
name: "Yakutsk"
},
{
value: "Australia/Adelaide",
name: "Adelaide"
},
{
value: "Australia/Darwin",
name: "Darwin"
},
{
value: "Australia/Brisbane",
name: "Brisbane"
},
{
value: "Pacific/Guam",
name: "Guam"
},
{
value: "Australia/Hobart",
name: "Hobart"
},
{
value: "Australia/Melbourne",
name: "Melbourne"
},
{
value: "Pacific/Port_Moresby",
name: "Port Moresby"
},
{
value: "Australia/Sydney",
name: "Sydney"
},
{
value: "Asia/Vladivostok",
name: "Vladivostok"
},
{
value: "Asia/Magadan",
name: "Magadan"
},
{
value: "Pacific/Noumea",
name: "New Caledonia"
},
{
value: "Pacific/Guadalcanal",
name: "Solomon Is."
},
{
value: "Asia/Srednekolymsk",
name: "Srednekolymsk"
},
{
value: "Pacific/Auckland",
name: "Auckland"
},
{
value: "Pacific/Fiji",
name: "Fiji"
},
{
value: "Asia/Kamchatka",
name: "Kamchatka"
},
{
value: "Pacific/Majuro",
name: "Marshall Is."
},
{
value: "Pacific/Chatham",
name: "Chatham Is."
},
{
value: "Pacific/Tongatapu",
name: "Nuku'alofa"
},
{
value: "Pacific/Apia",
name: "Samoa"
},
{
value: "Pacific/Fakaofo",
name: "Tokelau Is."
}
];
/** Browser-supported IANA timezones (prettified names), or `[]` if unavailable. */
function getBrowserTimezones() {
return (typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : []).map((value) => ({
value,
name: prettifyTimezoneName(value)
}));
}
/**
* Default zone list, matching the dashboard-modules library: the browser's IANA
* zones (prettified) unioned with {@link LEGACY_TIMEZONES} by `value` (browser wins
* on duplicates), so legacy-only aliases still appear with their friendly names.
*/
function getDefaultTimezones() {
const merged = [...getBrowserTimezones()];
const seen = new Set(merged.map((entry) => entry.value));
for (const legacy of LEGACY_TIMEZONES) if (!seen.has(legacy.value)) {
merged.push(legacy);
seen.add(legacy.value);
}
return merged;
}
/** Formats a signed minute offset as `UTC±HH:MM`. */
function formatUtcOffset(offsetMinutes) {
const sign = offsetMinutes < 0 ? "-" : "+";
const abs = Math.abs(offsetMinutes);
return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
}
/**
* Resolves a zone's current offset from UTC (in minutes) using native
* `Intl.DateTimeFormat` (`longOffset`, e.g. `GMT+05:30`).
*/
function resolveUtcOffset(timeZone) {
try {
var _parts$find$value, _parts$find;
const match = ((_parts$find$value = (_parts$find = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "longOffset"
}).formatToParts(/* @__PURE__ */ new Date()).find((part) => part.type === "timeZoneName")) === null || _parts$find === void 0 ? void 0 : _parts$find.value) !== null && _parts$find$value !== void 0 ? _parts$find$value : "GMT").match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
if (!match) return 0;
const [, sign, hours, minutes = "0"] = match;
const total = Number(hours) * 60 + Number(minutes);
return sign === "-" ? -total : total;
} catch (_unused) {
return 0;
}
}
/** Enriches each entry with its native-Intl UTC offset, then sorts by offset, then name. */
function buildTimezoneOptions(entries) {
return entries.map((entry) => {
const offsetMinutes = resolveUtcOffset(entry.value);
return _objectSpread2(_objectSpread2({}, entry), {}, {
offsetMinutes,
formattedOffset: formatUtcOffset(offsetMinutes)
});
}).sort((a, b) => a.offsetMinutes - b.offsetMinutes || a.name.localeCompare(b.name));
}
/** Returns the browser's best-guess IANA timezone, or `undefined` if unavailable. */
function guessTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (_unused2) {
return;
}
}
//#endregion
//#region src/components/timezone-combobox.tsx
const _excluded$18 = [
"value",
"onValueChange",
"guessTimezone",
"items",
"emptyMessage",
"disabled",
"data-test"
];
/**
* A searchable IANA timezone picker built on the DS `Combobox`, showing each zone's
* name and its `UTC±HH:MM` offset. Offsets are computed with the native
* `Intl.DateTimeFormat` — no timezone library dependency.
*
* The trigger shows the zone name; the offset appears in the dropdown and stays
* searchable. The selected value is the IANA zone id (e.g. `"Europe/Madrid"`).
*
* @example
* ```tsx
* const [tz, setTz] = React.useState<string | null>('Europe/Madrid');
* <TimezoneCombobox value={tz} onValueChange={setTz} />
* ```
*/
const TimezoneCombobox = React.forwardRef((componentProps, forwardRef) => {
const { value, onValueChange, guessTimezone: shouldGuess = false, items, emptyMessage, disabled, "data-test": dataTest = "timezone-select" } = componentProps, rest = _objectWithoutProperties(componentProps, _excluded$18);
const t = useDsTranslation("blocks");
const { ids, byId } = React.useMemo(() => {
const built = buildTimezoneOptions(items !== null && items !== void 0 ? items : getDefaultTimezones());
return {
ids: built.map((option) => option.value),
byId: new Map(built.map((option) => [option.value, option]))
};
}, [items]);
const guessedDefault = shouldGuess ? guessTimezone() : void 0;
const [selected, setSelected] = useControllableState({
prop: value,
defaultProp: guessedDefault && byId.has(guessedDefault) ? guessedDefault : null,
onChange: onValueChange
});
const itemToStringLabel = React.useCallback((id) => {
var _byId$get$name, _byId$get;
return (_byId$get$name = (_byId$get = byId.get(id)) === null || _byId$get === void 0 ? void 0 : _byId$get.name) !== null && _byId$get$name !== void 0 ? _byId$get$name : id;
}, [byId]);
const filter = React.useCallback((id, query) => {
const option = byId.get(id);
if (!option) return false;
return `${option.name} ${option.formattedOffset}`.toLowerCase().includes(query.trim().toLowerCase());
}, [byId]);
return /* @__PURE__ */ jsxs(Combobox, {
items: ids,
value: selected !== null && selected !== void 0 ? selected : null,
onValueChange: setSelected,
itemToStringLabel,
filter,
disabled,
children: [/* @__PURE__ */ jsx(ComboboxInput, _objectSpread2({
ref: forwardRef,
disabled,
"data-test": dataTest
}, rest)), /* @__PURE__ */ jsxs(ComboboxContent, {
"data-test": `${dataTest}-content`,
children: [/* @__PURE__ */ jsx(ComboboxEmpty, { children: emptyMessage !== null && emptyMessage !== void 0 ? emptyMessage : t("select.noResults") }), /* @__PURE__ */ jsx(ComboboxList, { children: (id) => {
const option = byId.get(id);
if (!option) return null;
return /* @__PURE__ */ jsxs(ComboboxItem, {
value: id,
className: "justify-between gap-4",
"data-test": `${dataTest}-item-${id}`,
children: [/* @__PURE__ */ jsx("span", { children: option.name }), /* @__PURE__ */ jsx("span", {
className: "text-muted-foreground text-sm",
children: option.formattedOffset
})]
}, id);
} })]
})]
});
});
TimezoneCombobox.displayName = "TimezoneCombobox";
//#endregion
//#region src/helpers/countries.helpers.ts
/**
* Resolves a country's name in a locale through `Intl.DisplayNames`.
*
* Deliberately NOT `COUNTRIES.localizedCountryName`: that field is a misnomer, every one of its
* entries is hardcoded English. It is used only as the fallback when a region code has no
* display name.
*/
function resolveCountryName(code, displayLocale, fallback) {
const resolved = safeRegionName(code, displayLocale);
if (resolved && resolved !== code) return resolved;
return fallback;
}
/** `Intl.DisplayNames` throws on a malformed locale or code, so failures degrade to no name. */
function safeRegionName(code, displayLocale) {
try {
return new Intl.DisplayNames([displayLocale], { type: "region" }).of(code);
} catch (_unused) {
return;
}
}
/**
* Matches a country against a query on THREE fields, so `France`, `FR` and `+33` all find
* France. Diacritics are folded, and a leading plus on the query is optional.
*/
function matchesCountryQuery(option, query) {
var _option$phonePrefix$r, _option$phonePrefix;
const needle = foldForSearch(query);
if (!needle) return true;
const needleDigits = needle.replace("+", "");
return foldForSearch(option.name).includes(needle) || option.code.toLowerCase().startsWith(needle) || !!needleDigits && ((_option$phonePrefix$r = (_option$phonePrefix = option.phonePrefix) === null || _option$phonePrefix === void 0 ? void 0 : _option$phonePrefix.replace("+", "")) !== null && _option$phonePrefix$r !== void 0 ? _option$phonePrefix$r : "").startsWith(needleDigits);
}
/** Lower-cases and strips diacritics so `Reunion` matches `Réunion`. */
function foldForSearch(value) {
return value.trim().toLowerCase().normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "");
}
/**
* Narrows a country list to a query with {@link matchesCountryQuery}. Shared with `FilterCountry`
* so both surfaces search identically.
*/
function filterCountriesByQuery(options, query) {
return options.filter((option) => matchesCountryQuery(option, query));
}
//#endregion
//#region src/helpers/phone.helpers.ts
/** Everything that is not a digit or a leading plus, i.e. the characters formatting adds. */
const NOT_DIGIT_OR_PLUS$1 = /[^+\d]/g;
/** A single digit or plus, tested per character while walking a formatted string. */
const DIGIT_OR_PLUS = /[+\d]/;
/**
* Where the caret belongs after as-you-type formatting rewrote the value under it.
*
* Formatting inserts and removes brackets and spaces, so the same logical position moves. Counting
* digits rather than characters is what survives that: find how many digits sat left of the caret,
* then walk the new string until the same number have gone by.
*
* @param cursorPosition Caret offset in `previousValue`, before the reformat.
* @param previousValue The value as it was rendered when the caret was measured.
* @param currentValue The freshly formatted value the caret has to land in.
*/
function getUpdatedCursorPosition(cursorPosition, previousValue, currentValue) {
const digitCountDelta = currentValue.replace(NOT_DIGIT_OR_PLUS$1, "").length - previousValue.replace(NOT_DIGIT_OR_PLUS$1, "").length;
const target = previousValue.slice(0, cursorPosition).replace(NOT_DIGIT_OR_PLUS$1, "").length + digitCountDelta;
let seen = 0;
let position = 0;
for (const char of currentValue) {
if (DIGIT_OR_PLUS.test(char)) seen++;
if (seen > target) break;
position++;
}
return position;
}
/**
* Swaps the dial prefix when the user picks a different country, so `+33 1 23` on France becomes
* `+1 1 23` on the US.
*
* Anything without the old prefix is returned untouched. Reformatting it here would run a
* formatter over values that must not be formatted, a name in permissive mode or an emergency
* number, and the caller derives the display from the new country anyway.
*/
function getPhoneNumberWithCountry(phoneNumber, previousCountry, nextCountry) {
const previousPrefix = getDialPrefix(previousCountry);
const nextPrefix = getDialPrefix(nextCountry);
if (previousPrefix && nextPrefix && phoneNumber.startsWith(previousPrefix)) return phoneNumber.replace(previousPrefix, nextPrefix);
return phoneNumber;
}
/**
* `getCountryByCode` THROWS on a code outside Aircall's list, and country detection reads its
* codes from libphonenumber, which knows territories the list does not. Throwing inside a
* keystroke handler would take the form down, so an unknown code degrades to no prefix.
*/
function getDialPrefix(country) {
try {
return getCountryByCode(country).phonePrefix;
} catch (_unused) {
return;
}
}
/**
* Digits typed since the previous value, capped at one, which separates typing from pasting.
*
* A paste lands several digits at once and should be trusted to carry its own country prefix; a
* keystroke should not flip the country on every digit.
*/
function isTyping(value, previousValue) {
const digits = value.replace(NOT_DIGIT_OR_PLUS$1, "").length;
if (!previousValue) return digits <= 1;
return digits - previousValue.replace(NOT_DIGIT_OR_PLUS$1, "").length <= 1;
}
//#endregion
//#region src/hooks/use-country-options.ts
/**
* The country option list, localized and collated for the viewer's DS locale.
* Sourced from `@aircall/numbers` so the codes and dial prefixes stay in one place.
*/
function useCountryOptions() {
const dsLocale = useDsLocale();
return React.useMemo(() => {
const collator = new Intl.Collator(dsLocale);
return COUNTRIES.map((country) => ({
code: country.countryCode,
name: resolveCountryName(country.countryCode, dsLocale, country.localizedCountryName),
phonePrefix: country.phonePrefix
})).sort((a, b) => collator.compare(a.name, b.name));
}, [dsLocale]);
}
//#endregion
//#region src/hooks/use-detected-country-code.ts
/**
* Follows what the user types and reports the country it implies, so pasting `+4915112345678`
* lands on Germany without touching the country control.
*
* Typing and pasting are told apart on purpose: a paste is trusted to carry its own dial prefix,
* while a keystroke must not flip the country on every digit. See {@link isTyping}.
*
* Returns the detected country, falling back to the selected one, and calls `onDetect` only on an
* actual change so a consumer can mirror it into its own state.
*
* @example
* ```tsx
* const country = useDetectedCountryCode(value, selectedCountry, defaultCountry, setSelectedCountry);
* ```
*/
function useDetectedCountryCode(inputValue, selectedCountry, defaultCountry, onDetect) {
const previousValue = usePrevious(inputValue);
const guessed = guessCountryCode(inputValue, selectedCountry, defaultCountry, isTyping(inputValue, previousValue));
React.useEffect(() => {
if (guessed !== selectedCountry) onDetect === null || onDetect === void 0 || onDetect(guessed);
}, [
guessed,
selectedCountry,
onDetect
]);
return guessed;
}
//#endregion
//#region src/components/phone-input.tsx
const _excluded$17 = [
"value",
"defaultValue",
"onValueChange",
"country",
"defaultCountry",
"onCountryChange",
"onDefaultCountryChange",
"mode",
"format",
"disabled",
"readOnly",
"placeholder",
"searchPlaceholder",
"emptyMessage",
"className",
"data-test"
];
/** Everything formatting adds, stripped when `format` is off. */
const NOT_DIGIT_OR_PLUS = /[^+\d]/g;
/**
* Phone number field: a country picker inside an input that formats the number as it is typed.
*
* `strict` (the default) rejects characters a number cannot contain. `permissive` accepts any text
* and reports through `onValueChange` whether it still parses, which lets a dialer fall back to
* searching contacts by name.
*
* The country is a separate controlled value, so a consumer keeps holding it where it already does
* and can validate against it. It follows what is typed: pasting a dial prefix moves the picker,
* and changing the picker rewrites the prefix.
*
* @example
* ```tsx
* const [value, setValue] = React.useState('');
* const [country, setCountry] = React.useState<CountryCode>(user.defaultCountry);
* <PhoneInput
* value={value}
* onValueChange={setValue}
* country={country}
* onCountryChange={setCountry}
* defaultCountry={user.defaultCountry}
* />
* ```
*
* @example Permissive, for a dialer that also searches by name
* ```tsx
* <PhoneInput
* mode="permissive"
* value={query}
* defaultCountry={user.defaultCountry}
* onValueChange={(next, meta) => { setQuery(next); setSearchingByName(!meta.isPhoneNumber); }}
* />
* ```
*/
const PhoneInput = React.forwardRef((componentProps, forwardedRef) => {
var _byCode$get;
const { value, defaultValue = "", onValueChange, country, defaultCountry, onCountryChange, onDefaultCountryChange, mode = "strict", format = true, disabled, readOnly, placeholder, searchPlaceholder, emptyMessage, className, "data-test": dataTest = "phone-input" } = componentProps, inputProps = _objectWithoutProperties(componentProps, _excluded$17);
const t = useDsTranslation("blocks");
const [countryState, setSelectedCountry] = useControllableState({
prop: country,
defaultProp: defaultCountry,
onChange: onCountryChange
});
const selectedCountry = countryState !== null && countryState !== void 0 ? countryState : defaultCountry;
const countryRef = React.useRef(selectedCountry);
countryRef.current = selectedCountry;
const commitCountry = React.useCallback((next) => {
countryRef.current = next;
setSelectedCountry(next);
}, [setSelectedCountry]);
const [numberState, setNumber] = useControllableState({
prop: value,
defaultProp: defaultValue,
onChange: (next) => onValueChange === null || onValueChange === void 0 ? void 0 : onValueChange(next !== null && next !== void 0 ? next : "", buildChangeMeta(next !== null && next !== void 0 ? next : "", countryRef.current))
});
const number = numberState !== null && numberState !== void 0 ? numberState : "";
const searchingByName = mode === "permissive" && number.trim().length > 0 && !isPartialPhoneNumber(number);
const [open, setOpen] = React.useState(false);
if (open && searchingByName) setOpen(false);
const [search, setSearch] = React.useState("");
const inputRef = React.useRef(null);
const groupRef = React.useRef(null);
const setInputRef = useMergedInputRef(inputRef, forwardedRef);
const locale = useDsLocale();
const options = useCountryOptions();
const sortedCountries = React.useMemo(() => {
const collator = new Intl.Collator(locale);
return [...options].sort((a, b) => collator.compare(a.name, b.name));
}, [options, locale]);
const countryList = React.useMemo(() => {
const defaultOption = sortedCountries.find((option) => option.code === defaultCountry);
if (!defaultOption) return sortedCountries;
return [defaultOption, ...sortedCountries.filter((option) => option.code !== defaultCountry)];
}, [sortedCountries, defaultCountry]);
const selectedOption = (_byCode$get = React.useMemo(() => new Map(options.map((option) => [option.code, option])), [options]).get(selectedCountry)) !== null && _byCode$get !== void 0 ? _byCode$get : null;
const itemToStringLabel = React.useCallback((option) => option.phonePrefix ? `${option.name} (${option.phonePrefix})` : option.name, []);
/**
* Known limitation: the star is a focusable control inside `role="option"`, which is invalid
* ARIA and unreachable by arrow keys. Needs a design fix.
*/
const renderCountryRow = React.useCallback((option) => {
const showsDefaultCountry = Boolean(onDefaultCountryChange);
const isDefault = option.code === defaultCountry;
const canSetDefault = showsDefaultCountry && !isDefault;
return /* @__PURE__ */ jsxs("span", {
className: "group/phone-country-row flex w-full min-w-0 items-center gap-2",
children: [/* @__PURE__ */ jsxs("span", {
className: "relative flex size-6 shrink-0 items-center justify-center",
children: [
/* @__PURE__ */ jsx(CountryFlag, {
countryIsoCode: option.code,
size: "xl",
className: cn(canSetDefault && "group-hover/phone-country-row:opacity-0")
}),
showsDefaultCountry && isDefault ? /* @__PURE__ */ jsx(Star, {
"aria-hidden": true,
"data-slot": "phone-input-default-marker",
strokeWidth: 4,
className: "absolute -right-0.5 -bottom-0.5 size-3 fill-warning text-popover [paint-order:stroke]"
}) : null,
canSetDefault ? /* @__PURE__ */ jsxs(Tooltip, { children: [/* @__PURE__ */ jsx(TooltipTrigger, { render: /* @__PURE__ */ jsx(Button, {
variant: "ghost",
size: "icon-sm",
"aria-label": t("phoneInput.setAsDefault", { country: option.name }),
"data-slot": "phone-input-set-default",
"data-test": `phone-input-set-default-${option.code}`,
className: "absolute inset-0 opacity-0 transition-none group-hover/phone-country-row:opacity-100 focus-visible:opacity-100 active:not-aria-[haspopup]:translate-y-0 active:not-aria-[haspopup]:opacity-100",
onClick: (event) => {
event.stopPropagation();
onDefaultCountryChange === null || onDefaultCountryChange === void 0 || onDefaultCountryChange(option.code);
},
children: /* @__PURE__ */ jsx(Star, { className: "size-5" })
}) }), /* @__PURE__ */ jsx(TooltipContent, { children: t("phoneInput.setAsDefault", { country: option.name }) })] }) : null
]
}), /* @__PURE__ */ jsxs("span", {
className: "flex min-w-0 flex-1 flex-col gap-0.5",
children: [/* @__PURE__ */ jsx("span", {
className: "truncate text-sm",
children: option.name
}), option.phonePrefix ? /* @__PURE__ */ jsx("span", {
className: "truncate text-xs text-muted-foreground",
children: option.phonePrefix
}) : null]
})]
});
}, [
defaultCountry,
onDefaultCountryChange,
t
]);
const displayed = React.useMemo(() => formatForDisplay(number, selectedCountry, mode, format), [
number,
selectedCountry,
mode,
format
]);
const pendingEdit = React.useRef(null);
React.useLayoutEffect(() => {
const edit = pendingEdit.current;
pendingEdit.current = null;
if (!edit || !inputRef.current || edit.text === displayed) return;
const caret = getUpdatedCursorPosition(edit.caret, edit.text, displayed);
inputRef.current.setSelectionRange(caret, caret);
}, [displayed]);
const numberRef = React.useRef(number);
numberRef.current = number;
useDetectedCountryCode(number, selectedCountry, defaultCountry, React.useCallback((detected) => {
commitCountry(detected);
onValueChange === null || onValueChange === void 0 || onValueChange(numberRef.current, buildChangeMeta(numberRef.current, detected));
}, [commitCountry, onValueChange]));
const handleChange = (event) => {
const typed = stripHashCharacters(event.target.value);
if (mode === "strict" && !PHONE_NUMBER_CHARACTER_REGEXP.test(typed)) return;
if (event.target.selectionStart !== null) pendingEdit.current = {
caret: event.target.selectionStart,
text: typed
};
setNumber(typed);
};
const handleKeyDown = (event) => {
var _inputProps$onKeyDown;
(_inputProps$onKeyDown = inputProps.onKeyDown) === null || _inputProps$onKeyDown === void 0 || _inputProps$onKeyDown.call(inputProps, event);
const target = event.currentTarget;
if (event.key !== "Backspace" || !format || !isFormattable(target.value, mode)) return;
const caret = target.selectionStart;
if (caret === null || caret !== target.selectionEnd || caret === 0) return;
const charBefore = target.value[caret - 1];
if (/[+\d]/.test(charBefore)) return;
const lastDigit = findLastDigitIndex(target.value, caret);
if (lastDigit === -1) return;
event.preventDefault();
const stripped = target.value.slice(0, lastDigit) + target.value.slice(caret);
pendingEdit.current = {
caret: lastDigit,
text: stripped
};
setNumber(stripped);
};
const handleCountrySelection = (option) => {
var _inputRef$current;
if (!option || option.code === selectedCountry) return;
const next = option.code;
commitCountry(next);
setNumber(getPhoneNumberWithCountry(displayed, selectedCountry, next));
(_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 || _inputRef$current.focus();
};
return /* @__PURE__ */ jsxs(Combobox, {
items: countryList,
value: selectedOption,
onValueChange: handleCountrySelection,
open,
onOpenChange: (nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setSearch("");
},
inputValue: search,
onInputValueChange: setSearch,
itemToStringLabel,
filter: matchesCountryQuery,
children: [/* @__PURE__ */ jsxs(InputGroup, {
ref: groupRef,
"data-slot": "phone-input",
className: cn("overflow-hidden", className),
"data-test": dataTest,
children: [/* @__PURE__ */ jsx(InputGroupAddon, {
align: "inline-start",
className: "h-full self-stretch p-0 has-[>button]:ml-0",
children: searchingByName ? /* @__PURE__ */ jsx("span", {
"aria-hidden": true,
"data-slot": "phone-input-name-search",
"data-test": `${dataTest}-name-search`,
className: "flex h-full w-10 items-center justify-center bg-muted",
children: /* @__PURE__ */ jsx(BookUser, { className: "size-4" })
}) : /* @__PURE__ */ jsx(ComboboxTrigger, { render: /* @__PURE__ */ jsx(InputGroupButton, {
disabled: disabled || readOnly,
"aria-label": t("phoneInput.selectCountry"),
"data-slot": "phone-input-country-trigger",
"data-test": `${dataTest}-country-trigger`,
className: "h-full w-10 rounded-none border-0 bg-muted px-0 hover:bg-input focus-visible:ring-0 focus-visible:inset-ring-3 focus-visible:inset-ring-ring/50 data-popup-open:bg-input",
children: /* @__PURE__ */ jsx(CountryFlag, {
countryIsoCode: selectedCountry,
size: "md"
})
}) })
}), /* @__PURE__ */ jsx(InputGroupInput, _objectSpread2(_objectSpread2({}, inputProps), {}, {
ref: setInputRef,
type: "tel",
inputMode: mode === "strict" ? "tel" : "text",
autoComplete: "tel",
value: displayed,
onChange: handleChange,
onKeyDown: handleKeyDown,
disabled,
readOnly,
placeholder,
"data-test": `${dataTest}-field`
}))]
}), /* @__PURE__ */ jsxs(ComboboxContent, {
anchor: groupRef,
align: "start",
className: "w-(--anchor-width) min-w-72 p-0",
"data-test": `${dataTest}-country-content`,
children: [
/* @__PURE__ */ jsx(ComboboxInput, {
showTrigger: false,
placeholder: searchPlaceholder !== null && searchPlaceholder !== void 0 ? searchPlaceholder : t("phoneInput.searchCountry"),
children: /* @__PURE__ */ jsx(InputGroupAddon, {
align: "inline-start",
children: /* @__PURE__ */ jsx(Search, {})
})
}),
/* @__PURE__ */ jsx(ComboboxEmpty, { children: emptyMessage !== null && emptyMessage !== void 0 ? emptyMessage : t("select.noResults") }),
/* @__PURE__ */ jsx(ComboboxList, { children: (option) => /* @__PURE__ */ jsx(ComboboxItem, {
value: option,
children: renderCountryRow(option)
}, option.code) })
]
})]
});
});
PhoneInput.displayName = "PhoneInput";
/**
* The same test the field uses to decide whether to show the country picker, so the two can never
* disagree. `isPossibleNumber` was wrong here: it rejects a half-typed number, which told a dialer
* to search contacts by name on every keystroke while the field still showed a flag.
*/
function buildChangeMeta(value, country) {
return {
isPhoneNumber: isPartialPhoneNumber(value),
country
};
}
/** Strict mode can only hold a number; permissive mode has to leave a name alone. */
function isFormattable(value, mode) {
return mode === "strict" || isPartialPhoneNumber(value);
}
/**
* With `format` off the separators are stripped rather than left alone, so turning it off for a
* value already in the field, as an emergency number does, actually undoes the formatting.
*/
function formatForDisplay(value, country, mode, format) {
if (!isFormattable(value, mode)) return value;
return format ? formatAsYouType(value, country) : value.replace(NOT_DIGIT_OR_PLUS, "");
}
function findLastDigitIndex(value, from) {
for (let index = from - 1; index >= 0; index--) if (/[+\d]/.test(value[index])) return index;
return -1;
}
/** The input node is needed internally for the caret AND has to reach the caller's ref. */
function useMergedInputRef(internalRef, forwardedRef) {
return React.useCallback((node) => {
internalRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}, [internalRef, forwardedRef]);
}
//#endregion
//#region src/components/chatbot-expand-suggestion.tsx
const _excluded$16 = [
"categories",
"selectedCategory",
"onCategoryChange",
"label",
"suggestions",
"onSuggestionClick",
"className"
];
/**
* Displays category filter pills and a list of clickable suggestion rows for the
* chatbot idle state. Renders a `<div>` wrapping an optional `ToggleGroup` of
* category pills and a list of `Item` rows separated by `Separator`.
*
* @example
* ```tsx
* <ChatbotExpandSuggestion
* categories={[{ value: 'billing', label: 'Billing' }]}
* suggestions={[{ id: '1', text: 'How do I reset my password?' }]}
* onSuggestionClick={suggestion => console.log(suggestion.text)}
* />
* ```
*/
const ChatbotExpandSuggestion = React.forwardRef((componentProps, forwardRef) => {
const { categories, selectedCategory: selectedCategoryProp, onCategoryChange, label = "Suggestions", suggestions, onSuggestionClick, className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$16);
const [selectedCategory, setSelectedCategory] = useControllableState({
prop: selectedCategoryProp,
onChange: onCategoryChange
});
const hasCategories = categories != null && categories.length > 0;
const hasSuggestions = suggestions != null && suggestions.length > 0;
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
className: cn("flex w-full flex-col items-center gap-6", className)
}, props), {}, { children: [hasCategories && /* @__PURE__ */ jsx(ToggleGroup, {
variant: "outline",
spacing: 2,
value: selectedCategory != null ? [selectedCategory] : [],
onValueChange: (vals) => setSelectedCategory(vals.at(-1)),
children: categories.map((cat) => /* @__PURE__ */ jsx(ToggleGroupItem, {
value: cat.value,
className: "data-pressed:bg-secondary data-pressed:text-secondary-foreground data-pressed:border-secondary",
children: cat.label
}, cat.value))
}), hasSuggestions && /* @__PURE__ */ jsxs("div", {
className: "flex w-full flex-col gap-2",
children: [/* @__PURE__ */ jsx("div", {
className: "px-3 py-2",
children: /* @__PURE__ */ jsx("p", {
className: "text-sm font-semibold text-foreground leading-none",
children: label
})
}), suggestions.map((suggestion, index) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx(Item, {
render: /* @__PURE__ */ jsx("button", { type: "button" }),
size: "sm",
className: "cursor-pointer",
onClick: () => onSuggestionClick === null || onSuggestionClick === void 0 ? void 0 : onSuggestionClick(suggestion),
children: /* @__PURE__ */ jsx(ItemContent, { children: /* @__PURE__ */ jsx(ItemTitle, { children: suggestion.text }) })
}), index < suggestions.length - 1 && /* @__PURE__ */ jsx(Separator, {})] }, suggestion.id))]
})] }));
});
ChatbotExpandSuggestion.displayName = "ChatbotExpandSuggestion";
//#endregion
//#region src/components/chatbot-panel.tsx
const _excluded$15 = [
"title",
"onMenu",
"conversations",
"activeConversationId",
"onNewChat",
"onSelectConversation",
"renderConversationMenuContent",
"onExpand",
"onClose",
"emptyTitle",
"emptyDescription",
"suggestions",
"onSuggestionSelect",
"inputValue",
"onInputValueChange",
"onSubmit",
"onStop",
"loading",
"loadingText",
"placeholder",
"autoFocus",
"disclaimer",
"resizable",
"defaultWidth",
"minWidth",
"maxWidth",
"onWidthChange",
"className",
"style",
"children"
];
/** Resize bounds (px), from Figma: min 320 / max 768 / default 400. */
const DEFAULT_WIDTH = 400;
const MIN_WIDTH = 320;
const MAX_WIDTH = 768;
/** Width delta applied per Arrow key press on the resize handle. */
const KEYBOARD_RESIZE_STEP = 16;
const DISCLAIMER_LINK_HREF = "https://legal.aircall.io/#template-9jw4r0usl";
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
/**
* Presentational chatbot surface — header, scrollable conversation area (or a
* built-in empty state), composer, and disclaimer — in a width-resizable shell.
* Owns no visibility state (the consumer controls show/hide); pass the conversation
* as `children`, and no children renders the empty state. Renders a `<div>`
* container.
*
* @example
* ```tsx
* <ChatbotPanel onClose={close} onSubmit={send}>
* <ChatbotUserMessage>Hi</ChatbotUserMessage>
* <ChatbotResponseBlock>Hello!</ChatbotResponseBlock>
* </ChatbotPanel>
* ```
*/
function ChatbotPanel(_ref) {
let { title, onMenu, conversations, activeConversationId, onNewChat, onSelectConversation, renderConversationMenuContent, onExpand, onClose, emptyTitle, emptyDescription, suggestions = [], onSuggestionSelect, inputValue, onInputValueChange, onSubmit, onStop, loading = false, loadingText, placeholder, autoFocus, disclaimer, resizable = true, defaultWidth = DEFAULT_WIDTH, minWidth = MIN_WIDTH, maxWidth = MAX_WIDTH, onWidthChange, className, style, children } = _ref, props = _objectWithoutProperties(_ref, _excluded$15);
const t = useDsTranslation("blocks");
const [menuOpen, setMenuOpen] = React.useState(false);
const [width, setWidth] = React.useState(() => clamp(defaultWidth, minWidth, maxWidth));
const applyWidth = React.useCallback((next) => {
const clamped = clamp(next, minWidth, maxWidth);
setWidth(clamped);
onWidthChange === null || onWidthChange === void 0 || onWidthChange(clamped);
}, [
minWidth,
maxWidth,
onWidthChange
]);
const dragRef = React.useRef(null);
React.useEffect(() => {
if (!resizable) return void 0;
const handlePointerMove = (event) => {
const drag = dragRef.current;
if (!drag) return;
applyWidth(drag.startWidth - (event.clientX - drag.startX));
};
const handlePointerUp = () => {
dragRef.current = null;
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
}, [resizable, applyWidth]);
const handleResizeKeyDown = (event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
applyWidth(width + KEYBOARD_RESIZE_STEP);
} else if (event.key === "ArrowRight") {
event.preventDefault();
applyWidth(width - KEYBOARD_RESIZE_STEP);
}
};
const hasConversation = React.Children.toArray(children).length > 0;
const bodyRef = React.useRef(null);
const [showScrollDown, setShowScrollDown] = React.useState(false);
const updateScrollState = React.useCallback(() => {
const el = bodyRef.current;
if (!el) return;
setShowScrollDown(el.scrollHeight - el.scrollTop - el.clientHeight > 8);
}, []);
React.useLayoutEffect(() => {
updateScrollState();
}, [
children,
width,
loading,
updateScrollState
]);
const scrollToBottom = () => {
var _bodyRef$current;
(_bodyRef$current = bodyRef.current) === null || _bodyRef$current === void 0 || _bodyRef$current.scrollTo({
top: bodyRef.current.scrollHeight,
behavior: "smooth"
});
};
const resolvedDisclaimer = disclaimer === void 0 ? /* @__PURE__ */ jsxs("p", {
className: "text-xs text-muted-foreground",
children: [
t("chatbotPanel.disclaimerBefore"),
/* @__PURE__ */ jsx("a", {
href: DISCLAIMER_LINK_HREF,
target: "_blank",
rel: "noreferrer",
className: "text-primary underline",
children: t("chatbotPanel.disclaimerLink")
}),
t("chatbotPanel.disclaimerAfter")
]
}) : disclaimer;
const menuContent = conversations != null ? /* @__PURE__ */ jsx(SidebarProvider, {
className: "!min-h-0 flex-1",
style: { "--sidebar-width": "20rem" },
children: /* @__PURE__ */ jsx(ChatbotSidebar, {
popup: true,
className: "[&_[data-slot=sidebar-content]]:gap-0 [&_[data-slot=sidebar-content]]:max-h-[280px]",
conversations,
activeConversationId,
onNewChat: onNewChat && (() => {
onNewChat();
setMenuOpen(false);
}),
onSelectConversation: onSelectConversation && ((id) => {
onSelectConversation(id);
setMenuOpen(false);
}),
renderConversationMenuContent
})
}) : void 0;
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
"data-slot": "chatbot-panel",
className: cn("relative flex h-full flex-col overflow-clip rounded-xl border border-border bg-background", className),
style: _objectSpread2({ width }, style)
}, props), {}, { children: [
resizable && /* @__PURE__ */ jsx("div", {
role: "separator",
"aria-orientation": "vertical",
"aria-label": t("chatbotPanel.resizeHandle"),
"aria-valuenow": width,
"aria-valuemin": minWidth,
"aria-valuemax": maxWidth,
tabIndex: 0,
onPointerDown: (event) => {
dragRef.current = {
startX: event.clientX,
startWidth: width
};
},
onKeyDown: handleResizeKeyDown,
className: cn("absolute inset-y-0 left-0 z-10 w-1.5 cursor-ew-resize touch-none select-none", "hover:bg-border/60 focus-visible:bg-border/60 focus-visible:outline-none")
}),
/* @__PURE__ */ jsx(ChatbotPanelHeader, {
title: title !== null && title !== void 0 ? title : t("chatbotPanel.title"),
onMenu,
menuContent,
menuOpen: conversations != null ? menuOpen : void 0,
onMenuOpenChange: setMenuOpen,
onExpand,
onClose
}),
/* @__PURE__ */ jsxs("div", {
className: "relative flex min-h-0 flex-1 flex-col",
children: [/* @__PURE__ */ jsx("div", {
ref: bodyRef,
"data-slot": "chatbot-panel-body",
onScroll: updateScrollState,
className: "flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-4",
children: hasConversation ? /* @__PURE__ */ jsxs("div", {
className: "flex flex-col gap-6",
children: [children, loading && /* @__PURE__ */ jsx(ChatbotResponseLoading, { text: loadingText })]
}) : /* @__PURE__ */ jsxs("div", {
"data-slot": "chatbot-panel-empty",
className: "flex flex-1 flex-col items-center",
children: [/* @__PURE__ */ jsxs("div", {
className: "flex flex-1 flex-col items-center justify-center gap-2 text-center",
children: [
/* @__PURE__ */ jsx("span", {
"aria-hidden": "true",
className: "grid size-12 place-items-center rounded-full border border-green-400 bg-[#00bd82] shadow-[0px_0px_8px_0px_var(--color-green-300)] [&_path]:fill-current [&_svg]:size-7 text-white dark:text-[#101820]",
children: /* @__PURE__ */ jsx(AircallMark, {})
}),
/* @__PURE__ */ jsx("h2", {
className: "text-sm font-medium text-foreground",
children: emptyTitle !== null && emptyTitle !== void 0 ? emptyTitle : t("chatbotPanel.emptyTitle")
}),
/* @__PURE__ */ jsx("p", {
className: "max-w-[280px] text-sm text-muted-foreground",
children: emptyDescription !== null && emptyDescription !== void 0 ? emptyDescription : t("chatbotPanel.emptyDescription")
})
]
}), suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
className: "flex w-full flex-col items-center gap-2 py-4",
children: suggestions.map((suggestion) => {
var _suggestion$id;
return /* @__PURE__ */ jsx(ChatbotPanelSuggestion, {
onClick: () => onSuggestionSelect === null || onSuggestionSelect === void 0 ? void 0 : onSuggestionSelect(suggestion),
children: suggestion.label
}, (_suggestion$id = suggestion.id) !== null && _suggestion$id !== void 0 ? _suggestion$id : suggestion.label);
})
})]
})
}), hasConversation && showScrollDown && /* @__PURE__ */ jsx(Button, {
variant: "outline",
size: "icon",
"aria-label": "Scroll to bottom",
onClick: scrollToBottom,
className: "absolute right-4 bottom-4 size-10 rounded-md shadow-lg",
children: /* @__PURE__ */ jsx(ChevronDown, { className: "size-6" })
})]
}),
/* @__PURE__ */ jsxs("div", {
"data-slot": "chatbot-panel-footer",
className: "flex flex-col gap-2.5 bg-gradient-to-b from-background/10 to-background px-4 pt-0 pb-4",
children: [/* @__PURE__ */ jsx(ChatbotInput, {
value: inputValue,
onValueChange: onInputValueChange,
onSubmit,
onStop,
loading,
placeholder,
autoFocus
}), resolvedDisclaimer]
})
] }));
}
//#endregion
//#region src/hooks/use-is-text-truncated.ts
/**
* Reports whether an element's text is currently truncated, i.e. whether its text is clipped
* by `text-overflow: ellipsis` because the content is wider than the box. Compares
* `scrollWidth` against `clientWidth` and re-measures on resize via a `ResizeObserver`, so it
* stays correct when the container or the text changes.
*
* Attach the returned ref to the element that carries the truncation classes (`truncate`, or
* `overflow-hidden text-ellipsis whitespace-nowrap`) and use `isTruncated` to decide whether
* to reveal the full text, typically in a tooltip.
*
* @example
* ```tsx
* const { ref, isTruncated } = useIsTextTruncated<HTMLSpanElement>();
* return (
* <span ref={ref} className="max-w-45 truncate">
* {label}
* </span>
* );
* ```
*/
function useIsTextTruncated() {
const ref = React.useRef(null);
const [isTruncated, setIsTruncated] = React.useState(false);
React.useEffect(() => {
const element = ref.current;
if (!element) return;
const measure = () => setIsTruncated(element.scrollWidth - element.clientWidth >= 1);
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
});
return {
ref,
isTruncated
};
}
//#endregion
//#region src/components/filter.tsx
const _excluded$14 = [
"className",
"children",
"icon",
"placeholder",
"labelId"
];
const _excluded2$5 = [
"className",
"side",
"align",
"sideOffset",
"alignOffset",
"anchor",
"children",
"icon",
"placeholder",
"triggerLabel",
"triggerClassName",
"open",
"defaultOpen",
"onOpenChange",
"aria-label",
"aria-labelledby"
];
const _excluded3$4 = [
"className",
"children",
"onClick"
];
const FilterContext = React.createContext(null);
/**
* Reads the state of the enclosing `Filter` (status, size, disabled, value).
* Throws when called outside a `Filter` root.
*
* @example
* ```tsx
* const { status } = useFilter();
* ```
*/
function useFilter() {
const context = React.useContext(FilterContext);
if (!context) throw new Error("useFilter must be used within a Filter");
return context;
}
/** Whether a value is applied at all. Nullish and the empty string all mean nothing is applied. */
function hasAppliedValue(value) {
return value !== null && value !== void 0 && value !== "";
}
/**
* Derives the filter status the way a form derives pristine from dirty. Reads `atDefaultValue` as
* THREE states, which is what makes an emptied filter answerable:
*
* - `undefined` - the filter has no default at all, so any value the user applies is a change and no
* value means untouched.
* - `true` - the applied value IS the default. Untouched.
* - `false` - the filter HAS a default and is away from it. Dirty even with nothing applied, because
* a user who emptied a filter the page defaulted has changed it and needs the way back.
*
* That last case is why this cannot collapse to "dirty needs a value": emptying a defaulted filter
* would then read untouched, hiding both clear affordances and - above a row's inline limit - the chip
* itself, leaving the page filtered differently from its default with nothing on screen saying so.
*/
function resolveFilterStatus(hasValue, atDefaultValue) {
if (atDefaultValue === void 0) return hasValue ? "dirty" : "pristine";
return atDefaultValue ? "pristine" : "dirty";
}
/**
* `cva` variant definitions for the trigger's label region - resolves the label color.
*
* Keys on whether a value is APPLIED rather than on `status`: a chip showing the page's default value
* reads as applied, so it takes the same foreground as one the user chose. Only an empty chip, showing
* its placeholder, is muted.
*
* `cap-center` (from `@aircall/ds`) is what makes the label read centred against the trigger's icon.
* Without it the label's line box is centred but its GLYPHS are not: Chrome rounds Fellix's ascent
* and descent to whole pixels, and at 14px that rounding leaves 1.8px more room below the baseline
* than above the cap, half of which the text takes as an upward shift. The utility owns this
* element's `padding-block-end` / `margin-block-end`, so do not add `pb-*` / `mb-*` here.
*/
const filterLabelVariants = cva("max-w-45 cap-center truncate text-sm font-medium", {
variants: { hasValue: {
true: "text-foreground",
false: "text-muted-foreground"
} },
defaultVariants: { hasValue: false }
});
/** Button size variant driving the trigger's dimensions, per filter size. */
const triggerSizeByFilterSize = {
lg: "lg",
sm: "sm"
};
/**
* Width of a filter chip's popup, whichever kind of popup it is: 250px to 320px.
*
* Shared by {@link FilterPanel} and by the `DataCombobox` popup a `FilterCombobox` opens, because a
* user reads those as the same control and a chip should not change size according to which of the
* two it happens to use.
*
* A floor and a ceiling rather than a fixed width. The floor is the load-bearing half: both popups
* size themselves from their ANCHOR, and the anchor is the chip - so a chip reading "Type" opened a
* 121px popup with the option list crushed inside it. The ceiling stops a chip with a long applied
* value from opening a popup wider than its content needs.
*
* Overridable per call site: `FilterCombobox` takes `contentClassName` and `FilterPanel` takes
* `className`, and both land after this, so a `min-w-*` there wins.
*/
const FILTER_POPUP_WIDTH = "min-w-62.5 max-w-80";
/** Button size variant driving the clear button's square dimensions, per filter size. */
const clearSizeByFilterSize = {
lg: "icon-lg",
sm: "icon-sm"
};
/**
* The chip SHELL: a `ButtonGroup` that joins a trigger and `FilterClear` into one bordered chip,
* plus the state every part reads. Derives `status` from `value` + `atDefaultValue` - status is
* never a prop - and reports the filter to an enclosing `DashboardFilters`.
*
* It owns no popup and no open state, deliberately. A chip's popup belongs to whatever renders its
* CONTENT, for one reason that admits no exception: a popup root has to enclose both the trigger and
* the popup, so only the thing that owns the popup can wrap the trigger. Three shapes follow from
* that, and all three compose here:
*
* - No popup at all - a chip that only displays a value and clears it. Trigger plus `FilterClear`.
* - A combobox popup - `FilterCombobox` hands a trigger to a DS `DataCombobox`, which brings its own
* popup, dismiss layer and open state. A Combobox root cannot be rendered inside someone else's
* popup, so this is the only arrangement that works.
* - An arbitrary panel - {@link FilterPanel}, which owns a `Popover` and renders the trigger for
* you. `FilterRange` is this.
*
* Anchoring stays correct in every case because the shell owns `anchorRef` (the whole chip, trigger
* and clear button together) and publishes it through {@link useFilter}.
*
* The status is exposed as `data-status` (and `data-size`) on the group, and through
* {@link useFilter}.
*
* @example
* ```tsx
* <Filter id="team-size" value="4 to 12" onClear={() => setRange(null)}>
* <FilterTrigger placeholder="Team size" />
* <FilterClear />
* </Filter>
* ```
*/
function Filter(componentProps) {
const { id, value, committedValue, onClear, atDefaultValue, size = "lg", disabled = false, className, children } = componentProps;
const anchorRef = React.useRef(null);
const triggerId = React.useId();
const hasValue = hasAppliedValue(value);
const hasCommittedValue = hasAppliedValue(committedValue === void 0 ? value : committedValue);
const status = resolveFilterStatus(hasCommittedValue, atDefaultValue);
const contextValue = React.useMemo(() => ({
value,
status,
hasValue,
size,
disabled,
onClear,
anchorRef,
triggerId
}), [
value,
status,
hasValue,
size,
disabled,
onClear,
triggerId
]);
const isInlined = useIsFilterInlined(id);
useDashboardFilterRegistration(id, {
hasValue: hasCommittedValue,
isDirty: status === "dirty"
});
if (!isInlined) return null;
return /* @__PURE__ */ jsx(FilterContext.Provider, {
value: contextValue,
children: /* @__PURE__ */ jsx(ButtonGroup, {
ref: anchorRef,
"data-slot": "filter",
"data-status": status,
"data-size": size,
className,
children
})
});
}
/**
* The trigger glyph's size, at BOTH chip sizes: 16px, beside the 14px label.
*
* Applied to the icon ELEMENT rather than through a `[&_svg:not([class*='size-'])]` descendant
* selector on a wrapper, and that is the whole point. `Button`'s `lg` variant carries its own
* `[&_svg:not([class*='size-'])]:size-5`, which matches any Lucide icon (they ship no `size-`
* class of their own) at exactly the same CSS specificity as a wrapper rule here would - the
* `:not([class*='size-'])` attribute selector counts, so both land on (0,2,1) and mere stylesheet
* ORDER decides which wins. That is not something to leave to chance, and not something to force
* with an `!important` either.
*
* Putting a real `size-` class on the icon makes BOTH `:not([class*='size-'])` selectors stop
* matching it, so neither Button's nor ours applies and the size is simply the one on the element.
*
* Figma note: the Filter Item icon reads 24x24 there, but that is Lucide's intrinsic artboard
* (`width: 24, height: 24, viewBox: '0 0 24 24'` in `lucide-react`'s default attributes), not a
* design decision. The component's own token set carries `height/h-4` (16) and no 20px value at
* all, so 16px is the Figma number and `size-5` never was.
*/
const FILTER_ICON_SIZE = "size-4";
/**
* Sizes the trigger's icon at {@link FILTER_ICON_SIZE} by adding the class to the icon element.
*
* A caller who already sized their icon keeps their size: `cn` resolves conflicting Tailwind
* sizes in favour of the LAST one, and theirs comes last. A non-element icon is returned
* untouched, since there is nothing to put a class on.
*/
function sizeFilterTriggerIcon(icon) {
if (!React.isValidElement(icon)) return icon;
return React.cloneElement(icon, { className: cn(FILTER_ICON_SIZE, icon.props.className) });
}
/**
* The inside of a filter chip's trigger: its optional leading icon and its label, where the label is
* the applied value or - with nothing applied - the filter's name, muted.
*
* Internal, like `FilterTriggerIcon` and `FilterTriggerLabel` either side of it: every trigger in the
* family is a {@link FilterTrigger}, so nothing outside this file composes the inside of one.
*
* Reads `hasValue` from the `Filter` context rather than taking it as a prop, so a caller cannot get
* the muted-placeholder state wrong.
*/
function FilterTriggerContent({ icon, placeholder, children, labelId }) {
var _ref;
const { value, hasValue } = useFilter();
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(FilterTriggerIcon, { icon }), /* @__PURE__ */ jsx(FilterTriggerLabel, {
hasValue,
id: labelId,
children: (_ref = children !== null && children !== void 0 ? children : value) !== null && _ref !== void 0 ? _ref : placeholder
})] });
}
function FilterTriggerIcon({ icon }) {
if (!icon) return null;
return /* @__PURE__ */ jsx("span", {
"data-slot": "filter-icon",
className: "flex shrink-0 items-center",
children: sizeFilterTriggerIcon(icon)
});
}
/**
* How long the pointer must rest on a truncated chip label before its tooltip opens, in ms.
*
* 200ms, chosen against the two failures either side of it. Chips sit in a ROW, so a pointer
* crossing the row passes over several of them on its way somewhere else: at 0ms every chip it
* grazes flashes a tooltip. Base UI's own per-trigger default is 600ms, which reads as broken on a
* label whose whole job is to reveal text the ellipsis is hiding - long enough that a deliberate
* hover feels like nothing happened. 200ms clears an incidental sweep and still answers a hover
* that meant it.
*
* A named constant rather than a literal at the call site: it is a timing decision with a reason,
* and a `delay={200}` in JSX carries neither the reason nor a place to change it once.
*/
const FILTER_LABEL_TOOLTIP_DELAY_MS = 200;
/**
* The trigger's label region, capped at `max-w-45` (180px per Figma) and truncated with an
* ellipsis. Reveals the full text in a `Tooltip` ONLY while it is actually truncated, so a
* label that fits never sprouts one.
*
* The hover delay is stated here rather than inherited, at {@link FILTER_LABEL_TOOLTIP_DELAY_MS}.
*
* No `TooltipProvider` is required, and that is exactly why the delay has to be explicit. A
* provider does two things - it shares one delay across sibling tooltips, and it supplies that
* delay - and a consumer app cannot be assumed to have wrapped anything, so neither happens here.
* Without a provider each chip falls back to Base UI's own per-trigger default, and the chip's
* timing would be whatever that constant happens to be in the installed version.
*
* The tooltip fires on hover of a `<span>` nested inside the trigger button, which is not
* focusable; keyboard and assistive-technology users are covered instead by the full label
* text remaining in the button's accessible name, truncated or not.
*
* KNOWN SMELL, not yet fixed: returning the bare span in one branch and a `Tooltip` wrapping that
* span in the other changes the component type at this position, so React REMOUNTS the label every
* time truncation flips. A resize mid-hover therefore tears down an open tooltip. Rendering the
* `Tooltip` unconditionally and varying only whether it has content would avoid the remount. It
* also already cost real debugging time: a test holding a label reference across the flip is left
* with a detached node that fails silently - see `queryLabel` in `Filter.stories.tsx`.
*/
function FilterTriggerLabel({ hasValue, children, id }) {
const { ref, isTruncated } = useIsTextTruncated();
const label = /* @__PURE__ */ jsx("span", _objectSpread2(_objectSpread2({ ref }, id === void 0 ? {} : { id }), {}, {
"data-slot": "filter-label",
className: filterLabelVariants({ hasValue }),
children
}));
if (!isTruncated) return label;
return /* @__PURE__ */ jsxs(Tooltip, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
delay: FILTER_LABEL_TOOLTIP_DELAY_MS,
render: label
}), /* @__PURE__ */ jsx(TooltipContent, {
"data-slot": "filter-label-tooltip",
children
})] });
}
/**
* The chip's main button, as a plain outline `Button` showing the filter name or the applied value.
* Renders a `<button>` and nothing around it.
*
* The ONE trigger in the family, and deliberately not a popup trigger itself. Use it directly for a
* chip with no popup, or hand it to whichever component owns the popup - {@link FilterPanel}'s
* `trigger`, or a `DataCombobox`'s. Each of those wraps it in its own trigger primitive and merges
* its props through, which works because this builds its content inside its own render where that
* merge cannot reach it.
*
* Renders `children ?? value ?? placeholder`. `children` overrides the label TEXT only, never
* its COLOR: the color comes from the root's `value`, so `children` with no root `value` still
* renders muted.
*
* The label is capped at 180px and truncates; when it is actually truncated the full text is
* revealed in a tooltip on hover. A label that fits never gets one.
*
* @example
* ```tsx
* <FilterTrigger placeholder="Duration" icon={<ClockIcon />} />
* ```
*/
const FilterTrigger = React.forwardRef((componentProps, forwardRef) => {
const { className, children, icon, placeholder, labelId } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$14);
const { size, disabled, triggerId } = useFilter();
return /* @__PURE__ */ jsx(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
id: triggerId,
"data-slot": "filter-trigger",
variant: "outline",
size: triggerSizeByFilterSize[size],
disabled,
className
}, props), {}, { children: /* @__PURE__ */ jsx(FilterTriggerContent, {
icon,
placeholder,
labelId,
children
}) }));
});
FilterTrigger.displayName = "FilterTrigger";
/**
* A chip whose popup is an arbitrary panel: owns the `Popover`, renders the chip's trigger, and
* anchors the popup to the WHOLE chip (the shell's `ButtonGroup`), not just the trigger.
*
* This is the part that replaced the old `FilterTrigger` + `FilterContent` pair, and the merge is the
* point rather than a convenience. A popup root has to enclose both the trigger and the popup, so
* whatever owns the popup must render the trigger - splitting them forced the SHELL to own a
* `Popover` for every chip, including the ones with no popup at all and the ones whose content brings
* its own (`FilterCombobox`). Keeping the pair together is what lets the shell own none.
*
* Reach for it when the panel is not a list of options: a range, a calendar, a bespoke form.
* For a list, use `FilterCombobox`, whose `DataCombobox` owns its popup and gives you selection,
* search, virtualisation and keyboard navigation.
*
* Named for the PANEL rather than for the popover it happens to use, because everywhere else in DS
* `Popover` is the root of a three-part set - `Popover` / `PopoverTrigger` / `PopoverContent` - and
* a `FilterPopover` reads like one piece of that set rather than all of it. This is the whole
* thing: root, trigger and popup in one component, which is not a convenience but the only shape
* Base UI allows here.
*
* Padding-neutral by design: the popup adds no insets of its own, so list content supplies
* its own padding and a range panel can add `p-4`. `children` is required and arbitrary.
*
* SCROLL-NEUTRAL by design, and this one is load-bearing: never give this popup its own
* `overflow-auto`. A list it hosts (down to `ComboboxList`) is already the scroll container, and a
* second scroll box around it takes the scrolling instead, leaving the inner one stuck at
* `scrollTop: 0`. That is a double scrollbar at best; with virtualised rows the virtualizer reads
* exactly that frozen `scrollTop` and silently renders only its first window. No error, just a list
* that will not scroll past its first screen.
*
* The popup is a dialog, so by default it names itself after the trigger via `aria-labelledby`.
* Pass `aria-label` or `aria-labelledby` to override that name: an explicit `aria-label`
* SUPPRESSES the trigger fallback, because `aria-labelledby` otherwise wins the accessible-name
* computation and the override would do nothing.
*
* @example
* ```tsx
* <FilterPanel placeholder="Duration" icon={<ClockIcon />} open={open} onOpenChange={setOpen}>
* <div className="p-4">...</div>
* </FilterPanel>
* ```
*/
function FilterPanel(componentProps) {
const { className, side = "bottom", align = "start", sideOffset = 4, alignOffset = 0, anchor, children, icon, placeholder, triggerLabel, triggerClassName, open, defaultOpen, onOpenChange, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$5);
const { anchorRef, triggerId, size, disabled } = useFilter();
const labelledBy = ariaLabelledBy !== null && ariaLabelledBy !== void 0 ? ariaLabelledBy : ariaLabel ? void 0 : triggerId;
return /* @__PURE__ */ jsxs(Popover, {
open,
defaultOpen,
onOpenChange,
children: [/* @__PURE__ */ jsx(PopoverTrigger, {
id: triggerId,
"data-slot": "filter-trigger",
disabled,
className: triggerClassName,
render: /* @__PURE__ */ jsx(Button, {
variant: "outline",
size: triggerSizeByFilterSize[size]
}),
children: /* @__PURE__ */ jsx(FilterTriggerContent, {
icon,
placeholder,
children: triggerLabel
})
}), /* @__PURE__ */ jsx(Popover$1.Portal, { children: /* @__PURE__ */ jsx(Popover$1.Positioner, {
side,
align,
sideOffset,
alignOffset,
anchor: anchor !== null && anchor !== void 0 ? anchor : anchorRef,
"data-slot": "filter-panel-positioner",
className: "isolate z-popup",
children: /* @__PURE__ */ jsx(Popover$1.Popup, _objectSpread2(_objectSpread2({
"data-slot": "filter-panel",
"aria-label": ariaLabel,
"aria-labelledby": labelledBy,
className: cn("max-h-(--available-height) origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", FILTER_POPUP_WIDTH, className)
}, props), {}, { children }))
}) })]
});
}
/**
* Returns the filter to its default value. Renders an outline icon `Button` with an X, joined to the
* trigger's right edge by the root's `ButtonGroup`.
*
* Renders nothing unless the status is `dirty` - so an untouched chip has nothing to clear, and
* neither does one showing the page's own default value, which is already where a clear would put it.
*
* It DOES render on a dirty chip with nothing applied, which looks odd for half a second and is
* deliberate: that is a filter the user emptied away from the page's default, so this button is the
* only way back to it from the chip itself.
*
* Absent from the DOM rather than hidden, so the `ButtonGroup` sibling selectors see a single child
* and the chip keeps its fully rounded shape while `pristine`.
*
* @example
* ```tsx
* <FilterClear />
* ```
*/
const FilterClear = React.forwardRef((componentProps, forwardRef) => {
const { className, children, onClick } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$4);
const { status, size, disabled, onClear } = useFilter();
const t = useDsTranslation("blocks");
if (status !== "dirty") return null;
const handleClick = (event) => {
onClick === null || onClick === void 0 || onClick(event);
onClear === null || onClear === void 0 || onClear();
};
return /* @__PURE__ */ jsx(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
type: "button",
"data-slot": "filter-clear",
variant: "outline",
size: clearSizeByFilterSize[size],
disabled,
"aria-label": t("filter.clear"),
onClick: handleClick,
className
}, props), {}, { children: children !== null && children !== void 0 ? children : /* @__PURE__ */ jsx(XIcon, { className: "size-4" }) }));
});
FilterClear.displayName = "FilterClear";
//#endregion
//#region src/hooks/use-draft-state.ts
/**
* Draft state whose latest value can be read synchronously, in the same event turn it was set.
*
* For the draft behind a popup that commits on dismissal. The problem it solves: a dismissal and
* the edit that preceded it can land in ONE turn - clicking outside a popup blurs the field it
* contains and dismisses the popup - so the close handler runs before React has re-rendered, and
* its render closure still holds the value from before the edit. Committing that closure value
* silently applies the pre-edit draft, or skips the commit because the "was it touched" flag had
* not flipped yet. `set` writes a ref alongside the state and `latest()` reads that ref, so the
* close handler sees the edit whether or not a render has happened in between.
*
* NOT the same as `useLatest` from `@aircall/hooks`, which mirrors its argument during RENDER:
* the render that has not happened yet is exactly the problem here, so it would hand back the
* same stale value. The write has to happen where the edit happens.
*
* @example
* ```tsx
* const draft = useDraftState(committed);
* // ...on edit
* draft.set(next);
* // ...on close, in a handler that may run before the edit has rendered
* onChange(draft.latest());
* ```
*/
function useDraftState(initial) {
const [value, setValue] = React.useState(initial);
const latestRef = React.useRef(value);
return {
value,
set: React.useCallback((next) => {
latestRef.current = next;
setValue(next);
}, []),
latest: React.useCallback(() => latestRef.current, [])
};
}
//#endregion
//#region src/components/filter-combobox.tsx
const _excluded$13 = [
"items",
"getItemValue",
"getItemLabel",
"renderItem",
"getGroupKey",
"getGroupLabel",
"disabledKeys",
"selectedItems",
"loadingState",
"skeletonCount",
"emptyState",
"searchPlaceholder",
"virtualizeRows",
"estimateRowHeight",
"searchable",
"onSearch",
"onFetchMore",
"hasMore",
"selectionMode",
"selectedKeys",
"defaultKeys",
"onChange",
"formatValue",
"placeholder",
"icon",
"size",
"disabled",
"contentClassName"
];
/**
* The selection draft, plus the reset that reads the committed selection back into it whenever
* the popup opens.
*
* On `useDraftState` rather than plain `useState` because the close path COMMITS the draft, and a
* close can share its turn with the edit before it - see that hook.
*/
function useDraftSelection(committed) {
const draft = useDraftState(committed);
const reset = () => draft.set(committed);
return {
draft,
reset
};
}
/**
* A filter chip whose popup is a searchable, selectable list: the `Filter` chip plus a DS
* `DataCombobox`. One component for both selection modes, and for both surfaces - on a
* `DashboardFiltersRow` it is a chip, inside a `DashboardFiltersSheet` a labelled field.
*
* Blocks owns the draft while DS `Filter` stays neutral about it. The chip label renders the
* DRAFT, so picking a row updates the chip immediately, but `onChange` fires only on COMMIT -
* when the popup closes. Escape discards the draft and restores the committed value, matching
* the interaction diagram in Figma node 8247-51724.
*
* @example
* ```tsx
* <FilterCombobox id="numbers"
* placeholder="Numbers"
* items={numbers}
* getItemValue={n => n.id}
* getItemLabel={n => n.label}
* selectionMode="multiple"
* selectedKeys={applied}
* onChange={setApplied}
* formatValue={keys => `${keys.size} numbers`}
* />
* ```
*/
function FilterCombobox(componentProps) {
const { items, getItemValue, getItemLabel, renderItem, getGroupKey, getGroupLabel, disabledKeys, selectedItems, loadingState, skeletonCount, emptyState, searchPlaceholder, virtualizeRows, estimateRowHeight, searchable, onSearch, onFetchMore, hasMore, selectionMode = "single", selectedKeys, defaultKeys, onChange, formatValue, placeholder, icon, size, disabled, contentClassName } = componentProps, filterProps = _objectWithoutProperties(componentProps, _excluded$13);
const t = useDsTranslation("blocks");
const { draft, reset } = useDraftSelection(selectedKeys);
const [open, setOpen] = React.useState(false);
const surface = useDashboardFilterSurface();
const { stagedValue, hasStagedValue, stage } = useDashboardFilterStaging(componentProps.id);
/**
* Everything about the LIST, shared verbatim by the chip's popup and the sheet's field, so the two
* surfaces cannot drift into offering different options for the same filter.
*/
const listProps = {
items,
getItemValue,
getItemLabel,
renderItem,
getGroupKey,
getGroupLabel,
disabledKeys,
selectedItems,
loadingState,
skeletonCount,
emptyState,
virtualizeRows,
estimateRowHeight,
onSearch,
onFetchMore,
hasMore,
selectionMode,
"aria-label": typeof placeholder === "string" ? placeholder : void 0
};
const commit = (keys) => {
if (!sameKeys(keys, selectedKeys)) onChange(keys);
};
/**
* The ONE way this popup closes. Every close path calls it - a dismissal through
* `handleOpenChange`, and the single-mode pick that closes the popup itself - so no path can
* close while skipping the bookkeeping a close owes.
*
* `commitDraft` is the difference between the two kinds of close: Escape discards the draft and
* leaves the committed value untouched, everything else applies it.
*
* No query reset here. `DataCombobox` reports `onSearch('')` on every close of its own popup, so
* a caller holding a server-side query hears the query end whether it dismissed from the chip or
* from a drawer field. This used to be restated here because DS only covered some closes; it now
* covers all of them, and saying it twice would fire two resets for one dismissal.
*/
const closePopup = (commitDraft) => {
setOpen(false);
if (!commitDraft) {
reset();
return;
}
commit(draft.latest());
};
const handleOpenChange = (nextOpen, eventDetails) => {
if (nextOpen) {
setOpen(true);
reset();
return;
}
closePopup(eventDetails.reason !== "escape-key");
};
const handleSelectionChange = (keys, details) => {
if (details.reason === "escape-key") return;
draft.set(keys);
};
if (surface === "sheet") return /* @__PURE__ */ jsx(DashboardFilterField, {
label: placeholder,
children: /* @__PURE__ */ jsx(DataCombobox, _objectSpread2(_objectSpread2({}, listProps), {}, {
placeholder: t("dashboardFilters.selectPlaceholder"),
searchable: searchable !== null && searchable !== void 0 ? searchable : "auto",
showClear: false,
selectedKeys: hasStagedValue ? stagedValue : selectedKeys,
onSelectionChange: (keys) => stage(keys)
}))
});
const shownKeys = open ? draft.value : selectedKeys;
return /* @__PURE__ */ jsxs(Filter, _objectSpread2(_objectSpread2({
value: formatValue(shownKeys),
committedValue: formatValue(selectedKeys),
atDefaultValue: defaultKeys === void 0 ? void 0 : sameKeys(selectedKeys, defaultKeys),
size,
disabled,
onClear: () => {
const restored = defaultKeys !== null && defaultKeys !== void 0 ? defaultKeys : /* @__PURE__ */ new Set();
draft.set(restored);
onSearch === null || onSearch === void 0 || onSearch("");
commit(restored);
}
}, filterProps), {}, { children: [/* @__PURE__ */ jsx(FilterComboboxChip, {
listProps,
searchPlaceholder: searchPlaceholder !== null && searchPlaceholder !== void 0 ? searchPlaceholder : t("dashboardFilters.searchPlaceholder"),
searchable,
contentClassName,
placeholder,
icon: typeof icon === "function" ? icon(shownKeys) : icon,
disabled,
open,
onOpenChange: handleOpenChange,
selectedKeys: draft.value,
onSelectionChange: handleSelectionChange
}), /* @__PURE__ */ jsx(FilterClear, {})] }));
}
/**
* The chip's trigger plus its `DataCombobox` popup, as one child of `Filter` rather than inline in
* `FilterCombobox`.
*
* A separate component ONLY so it can call `useFilter()`: the popup anchors to the whole chip - the
* root's `ButtonGroup`, trigger and clear button together - and that ref lives in the `Filter`
* context, which is not readable from the component that renders the root.
*
* Anchoring to the trigger alone would leave the popup short of the chip's right edge by the width
* of `FilterClear`, which is visible the moment a filter has a value.
*/
function FilterComboboxChip({ listProps, searchPlaceholder, searchable, contentClassName, placeholder, icon, disabled, open, onOpenChange, selectedKeys, onSelectionChange }) {
const { anchorRef } = useFilter();
const labelId = React.useId();
return /* @__PURE__ */ jsx(DataCombobox, _objectSpread2(_objectSpread2({}, listProps), {}, {
trigger: /* @__PURE__ */ jsx(FilterTrigger, {
placeholder,
icon,
labelId,
"aria-labelledby": labelId
}),
anchor: anchorRef,
placeholder: searchPlaceholder,
searchable,
disabled,
open,
onOpenChange,
selectedKeys,
onSelectionChange,
contentClassName: cn(FILTER_POPUP_WIDTH, contentClassName)
}));
}
/** Set equality by membership, so a re-created Set with the same keys is not a change. */
function sameKeys(a, b) {
if (a.size !== b.size) return false;
for (const key of a) if (!b.has(key)) return false;
return true;
}
//#endregion
//#region src/components/filter-range.tsx
const _excluded$12 = [
"min",
"max",
"step",
"value",
"onChange",
"formatValue",
"contentLabel",
"showInputs",
"fromLabel",
"toLabel",
"formatInput",
"parseInput",
"placeholder",
"icon",
"size",
"disabled",
"contentClassName",
"defaultValue"
];
/** Default chip and readout label: `from - to`, or the single value when both ends match. */
function formatRangeDefault([from, to]) {
if (from === to) return String(from);
return `${from} - ${to}`;
}
/**
* Whether a range covers its whole span, which is this filter's default: it reads as an applied value
* but excludes nothing. `null` is not applied at all, so it is not "at its default" either.
*/
function isFullSpan(value, min, max) {
return value !== null && value[0] <= min && value[1] >= max;
}
/** Range equality, so a re-created pair with the same bounds is not a change. */
function sameRange(a, b) {
if (a === null || b === null) return a === b;
return a[0] === b[0] && a[1] === b[1];
}
/** Clamps and orders a pair so `from` never exceeds `to` and both stay inside the bounds. */
function normalizeRange(pair, min, max) {
const from = Math.min(Math.max(pair[0], min), max);
const to = Math.min(Math.max(pair[1], min), max);
if (from > to) return [to, from];
return [from, to];
}
/**
* The chip's summary label. Nothing committed reads as no value at all, so the chip keeps its
* empty styling - a muted placeholder - even while the popup is open and the thumbs are being
* dragged. Once a range is committed the label tracks the DRAFT while open, and the COMMITTED range
* once closed - so a parent that clears the filter from the outside never leaves a stale label behind.
*/
function rangeChipLabel(open, committed, draft, formatValue) {
if (committed === null) return;
if (open) return formatValue(draft);
return formatValue(committed);
}
/**
* One bound's input, labelled, UNCONTROLLED, and never remounted.
*
* Two failure modes to avoid at once, and only this shape avoids both.
*
* It used to remount on every committed edit, through a `key` built from its text. That is what
* made an uncontrolled field track the draft, and it cost real reliability: any reference to the
* input - a caller's ref, the focus, a test's captured node - was detached the moment a commit
* landed, and typing into a detached input silently goes nowhere.
*
* Making it CONTROLLED fixed that and introduced the opposite problem: every keystroke round-trips
* through React state, so under load `{Enter}` can fire before the last character has been rendered
* back, and the handler commits a half-typed value. "17:30" arriving as "17:3" does not parse, so the
* entry is rejected and the draft never moves.
*
* So: uncontrolled, for typing that cannot lose characters and a `currentTarget.value` that is always
* what the user actually typed - plus an imperative sync for the one thing `defaultValue` cannot do,
* which is follow the draft after mount. The write goes through a ref in a LAYOUT effect, so a
* clamped entry ("99" becoming "5") is corrected in the same frame as the commit.
*/
function FilterRangeBoundInput({ bound, label, text, correctionToken, onCommit }) {
const id = React.useId();
const inputRef = React.useRef(null);
useSafeLayoutEffect(() => {
if (inputRef.current && inputRef.current.value !== text) inputRef.current.value = text;
}, [text, correctionToken]);
return /* @__PURE__ */ jsxs(Field, { children: [/* @__PURE__ */ jsx(FieldLabel, {
htmlFor: id,
children: label
}), /* @__PURE__ */ jsx(Input, {
ref: inputRef,
id,
"data-slot": bound === 0 ? "filter-range-input-from" : "filter-range-input-to",
defaultValue: text,
onBlur: (event) => onCommit(bound, event.target.value),
onKeyDown: (event) => {
if (event.key === "Enter") onCommit(bound, event.currentTarget.value);
}
})] });
}
/**
* The paired from/to `Field`s under the slider, shown when `showInputs` is set.
*
* The bound labels default to the `filterRange.from` / `filterRange.to` translations rather than to
* hard-coded English, so a consumer that passes neither still gets the user's locale. This is also
* why the defaults resolve HERE and not in `FilterRange`: nothing labels anything in the
* slider-only variant, so there is no empty label element to translate.
*/
function FilterRangeInputs({ draft, fromLabel, toLabel, formatInput, correctionTokens, onCommitInput }) {
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsxs(FieldGroup, {
"data-slot": "filter-range-inputs",
className: "grid grid-cols-2 gap-2",
children: [/* @__PURE__ */ jsx(FilterRangeBoundInput, {
bound: 0,
label: fromLabel !== null && fromLabel !== void 0 ? fromLabel : t("filterRange.from"),
text: formatInput(draft[0]),
correctionToken: correctionTokens[0],
onCommit: onCommitInput
}), /* @__PURE__ */ jsx(FilterRangeBoundInput, {
bound: 1,
label: toLabel !== null && toLabel !== void 0 ? toLabel : t("filterRange.to"),
text: formatInput(draft[1]),
correctionToken: correctionTokens[1],
onCommit: onCommitInput
})]
});
}
/**
* A filter chip whose popup is a two-thumb range: the `Filter` chip plus a padded popup holding
* a DS `Slider` and, optionally, a pair of `Field` inputs (Figma node 7959-10838).
*
* The DS `Slider` renders one thumb per value when `value` is an array, so the two-thumb range
* comes for free - there is no custom slider here.
*
* Covers all three Figma variants through one API: Duration is the slider alone, CSAT adds
* numeric inputs (`showInputs`), and Hours adds time inputs by supplying `formatInput` /
* `parseInput` that map a clock label to minutes since midnight. Open-ended ranges are just a
* bound sitting at `min` or `max`; there is no presets API (see the MR description).
*
* Like `FilterCombobox`, the chip label tracks the DRAFT while the popup is open and `onChange`
* fires on COMMIT (popup close). Escape discards.
*
* @example
* ```tsx
* <FilterRange id="duration"
* placeholder="Duration"
* min={0}
* max={60}
* value={[5, 15]}
* onChange={setDuration}
* formatValue={([from, to]) => `${from} - ${to} min`}
* />
* ```
*/
function FilterRange(componentProps) {
const { min = 0, max = 100, step = 1, value, onChange, formatValue = formatRangeDefault, contentLabel, showInputs = false, fromLabel, toLabel, formatInput = String, parseInput = Number, placeholder, icon, size, disabled, contentClassName, defaultValue } = componentProps, filterProps = _objectWithoutProperties(componentProps, _excluded$12);
const committed = value;
const draft = useDraftState(committed !== null && committed !== void 0 ? committed : [min, max]);
const [open, setOpen] = React.useState(false);
const touched = useDraftState(false);
const [correctionTokens, setCorrectionTokens] = React.useState([0, 0]);
const requestCorrection = (bound) => {
setCorrectionTokens((current) => bound === 0 ? [current[0] + 1, current[1]] : [current[0], current[1] + 1]);
};
const labelId = React.useId();
const surface = useDashboardFilterSurface();
const { stagedValue, hasStagedValue, stage } = useDashboardFilterStaging(componentProps.id);
const reset = () => {
draft.set(committed !== null && committed !== void 0 ? committed : [min, max]);
touched.set(false);
};
/** Moves the draft and marks the visit as deliberate. Every edit path goes through this. */
const editDraft = (next) => {
draft.set(normalizeRange(next, min, max));
touched.set(true);
};
const commit = (next, wasTouched) => {
if (!wasTouched) return;
if (!committed || committed[0] !== next[0] || committed[1] !== next[1]) onChange(next);
};
/**
* The ONE way this popup closes, so no path can close while skipping what a close owes -
* the same shape `FilterCombobox` uses. `commitDraft` separates the two kinds of close: Escape
* discards the draft, everything else applies it.
*/
const closePopup = (commitDraft) => {
setOpen(false);
if (!commitDraft) {
reset();
return;
}
commit(draft.latest(), touched.latest());
};
const handleOpenChange = (nextOpen, eventDetails) => {
if (nextOpen) {
setOpen(true);
reset();
return;
}
closePopup(eventDetails.reason !== "escape-key");
};
const handleCommitInput = (bound, raw) => {
const parsed = parseInput(raw);
if (parsed === null || Number.isNaN(parsed)) {
requestCorrection(bound);
return;
}
const current = draft.latest();
const next = bound === 0 ? [parsed, current[1]] : [current[0], parsed];
editDraft(next);
requestCorrection(bound);
};
if (surface === "sheet") {
const staged = hasStagedValue ? stagedValue !== null && stagedValue !== void 0 ? stagedValue : [min, max] : committed !== null && committed !== void 0 ? committed : [min, max];
return /* @__PURE__ */ jsx(DashboardFilterField, {
label: /* @__PURE__ */ jsxs("span", {
className: "flex w-full items-center justify-between gap-2",
children: [/* @__PURE__ */ jsx("span", { children: contentLabel !== null && contentLabel !== void 0 ? contentLabel : placeholder }), /* @__PURE__ */ jsx("span", {
"data-slot": "filter-range-summary",
className: "font-normal text-muted-foreground",
children: formatValue(staged)
})]
}),
children: /* @__PURE__ */ jsx(Slider, {
"data-slot": "filter-range-slider",
min,
max,
step,
value: staged,
onValueChange: (next) => stage(normalizeRange(next, min, max))
})
});
}
return /* @__PURE__ */ jsxs(Filter, _objectSpread2(_objectSpread2({
value: rangeChipLabel(open, committed, draft.value, formatValue),
committedValue: committed === null ? void 0 : formatValue(committed),
atDefaultValue: defaultValue === void 0 ? committed === null || isFullSpan(committed, min, max) : sameRange(committed, defaultValue),
size,
disabled,
onClear: () => {
const restored = defaultValue !== null && defaultValue !== void 0 ? defaultValue : null;
draft.set(restored !== null && restored !== void 0 ? restored : [min, max]);
touched.set(false);
onChange(restored);
}
}, filterProps), {}, { children: [/* @__PURE__ */ jsx(FilterPanel, {
placeholder,
icon,
open,
onOpenChange: handleOpenChange,
className: contentClassName,
children: /* @__PURE__ */ jsxs("div", {
"data-slot": "filter-range",
className: "flex w-64 flex-col gap-4 p-4",
children: [/* @__PURE__ */ jsxs(Field, {
"data-slot": "filter-range-field",
children: [/* @__PURE__ */ jsxs(FieldLabel, {
id: labelId,
className: "flex items-center justify-between",
children: [/* @__PURE__ */ jsx("span", { children: contentLabel !== null && contentLabel !== void 0 ? contentLabel : placeholder }), /* @__PURE__ */ jsx("span", {
"data-slot": "filter-range-readout",
className: "text-muted-foreground",
children: formatValue(draft.value)
})]
}), /* @__PURE__ */ jsx(Slider, {
"data-slot": "filter-range-slider",
"aria-labelledby": labelId,
min,
max,
step,
value: draft.value,
onValueChange: (next) => editDraft(next)
})]
}), showInputs ? /* @__PURE__ */ jsx(FilterRangeInputs, {
draft: draft.value,
fromLabel,
toLabel,
formatInput,
correctionTokens,
onCommitInput: handleCommitInput
}) : null]
})
}), /* @__PURE__ */ jsx(FilterClear, {})] }));
}
//#endregion
//#region src/components/date-range-shortcuts.ts
/**
* The date shortcuts a dashboard offers, in the order they read.
*
* Exactly the seven `conversation-center-ext` ships today
* (`src/utilities/date/predefined_options.constants.ts`), so a customer moving onto this component
* finds the same choices producing the same ranges.
*
* Note for anyone comparing with the extension: its `getPredefinedOptions()` defines an EIGHTH,
* `last_seven_days`, which is absent from the shipped `PREDEFINED_OPTION_KEYS` list and so has never
* been offered. It is deliberately not here either.
*/
const DATE_RANGE_SHORTCUTS = [
"today",
"yesterday",
"this_week",
"last_week",
"this_month",
"last_month",
"last_six_months"
];
/**
* The range a shortcut means, relative to a reference day.
*
* The boundary rules are copied from `conversation-center-ext`'s `getPredefinedOptions()` rather than
* re-derived, and it uses the same `date-fns` functions, so the two agree by construction rather than
* by my reading of them. Two of those rules are surprising enough to be worth stating, because they
* look like bugs and are not - they are what customers see today:
*
* - `this_week` and `last_week` start on MONDAY. `date-fns` `startOfWeek` defaults to Sunday, and the
* extension adds a day to it. Changing that would silently move every weekly filter by a day.
* - the ranges that end "now" end at the end of TODAY, not at the current instant, so a range stays
* stable for the rest of the day instead of shifting under the user.
* - `last_week` ends at MIDNIGHT on its final Sunday, not at the end of it, so it excludes almost all
* of that Sunday. That reads like an off-by-one and is preserved on purpose: it is the range
* customers are filtering by today, and quietly widening it would change their numbers.
*
* KNOWN DEFECT, reproduced deliberately. On a SUNDAY, `startOfWeek` returns that same Sunday, so the
* `+ 1 day` puts the start of `this_week` on TOMORROW while its end stays today - the range comes back
* INVERTED, with `from` after `to`. Customers hit this one day in seven today. It is reproduced rather
* than fixed because matching current behaviour was the requirement, and `weekStartsOn: 1` would fix it
* in one line but would also move every weekly range for everyone - a product decision. Pinned by a
* test named after it, so it is visible rather than waiting to be rediscovered.
*
* @param shortcut - Which shortcut to resolve.
* @param today - Reference day, defaulting to now. Injectable so this is testable without mocking
* the clock, which is also how the tests pin the boundaries against fixed dates.
*/
function dateRangeFromShortcut(shortcut, today = /* @__PURE__ */ new Date()) {
const reference = startOfDayOf(today);
const referenceEnd = endOfDayOf(today);
switch (shortcut) {
case "today": return {
from: reference,
to: referenceEnd,
shortcut
};
case "yesterday": {
const yesterday = subDays(reference, 1);
return {
from: yesterday,
to: endOfDayOf(yesterday),
shortcut
};
}
case "this_week": return {
from: addDays(startOfWeek(reference), 1),
to: referenceEnd,
shortcut
};
case "last_week": {
const startOfLastWeek = startOfWeek(subDays(reference, 7));
return {
from: addDays(startOfLastWeek, 1),
to: addDays(startOfLastWeek, 7),
shortcut
};
}
case "this_month": return {
from: startOfMonth(reference),
to: referenceEnd,
shortcut
};
case "last_month": {
const endOfLastMonth = subDays(startOfMonth(reference), 1);
return {
from: startOfMonth(endOfLastMonth),
to: endOfMonth(endOfLastMonth),
shortcut
};
}
case "last_six_months": return {
from: subMonths(reference, 6),
to: referenceEnd,
shortcut
};
default: throw new Error(`dateRangeFromShortcut: no range defined for ${String(shortcut)}`);
}
}
/** Midnight at the start of a given day, without assuming that day is today. */
function startOfDayOf(date) {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
return start;
}
/** The last millisecond of a given day, matching `date-fns` `endOfToday` for today. */
function endOfDayOf(date) {
const end = new Date(date);
end.setHours(23, 59, 59, 999);
return end;
}
/** Whether two ranges cover the same instants, so a reopened picker can mark its active shortcut. */
function isSameDateRange(one, two) {
if (!one || !two) return one === two;
return one.from.getTime() === two.from.getTime() && one.to.getTime() === two.to.getTime();
}
/** The `dateRange.*` translation key for each built-in shortcut. */
const SHORTCUT_TRANSLATION_KEYS = {
today: "dateRange.today",
yesterday: "dateRange.yesterday",
this_week: "dateRange.thisWeek",
last_week: "dateRange.lastWeek",
this_month: "dateRange.thisMonth",
last_month: "dateRange.lastMonth",
last_six_months: "dateRange.lastSixMonths"
};
/**
* One `DateRangeShortcutDefinition` per built-in shortcut, in the same order as
* `DATE_RANGE_SHORTCUTS`. The default for `DateRangeCalendar`'s `shortcuts` prop.
*/
const DEFAULT_DATE_RANGE_SHORTCUTS = DATE_RANGE_SHORTCUTS.map((key) => ({
key,
label: (t) => t(SHORTCUT_TRANSLATION_KEYS[key]),
getRange: (today) => {
const { from, to } = dateRangeFromShortcut(key, today);
return {
from,
to
};
}
}));
/**
* Which shortcut, if any, currently describes a range.
*
* Compares INSTANTS rather than trusting `value.shortcut`, so a range restored from a URL - where the
* shortcut was never persisted - still lights up its chip. Checked against the `shortcuts` list
* actually offered, so a custom definition can light up its own chip too. If two definitions in the
* list resolve to the same range, the first one in list order wins - avoid offering two shortcuts
* that can produce identical ranges if which one lights up matters.
*/
function matchDateRangeShortcut(value, shortcuts, today = /* @__PURE__ */ new Date()) {
var _shortcuts$find;
if (!value) return;
return (_shortcuts$find = shortcuts.find((shortcut) => isSameDateRange(value, shortcut.getRange(today)))) === null || _shortcuts$find === void 0 ? void 0 : _shortcuts$find.key;
}
//#endregion
//#region src/components/date-range-calendar.tsx
const _excluded$11 = [
"value",
"onChange",
"shortcuts",
"today",
"disabled",
"className"
];
/** Whether a shortcut range contains any date blocked by the calendar's disabled matcher. */
function isDateRangeDisabled(range, disabled) {
return Boolean(disabled && rangeContainsModifiers(range, disabled));
}
/**
* A date-range calendar with shortcut chips, in one bordered card (Figma node 8502-40329).
*
* ONE bordered container: the ds `Calendar` on top, then a visually distinct shaded footer strip
* inside that same container holding the shortcuts as outlined Toggle pills, wrapping onto two rows. Not a
* column of buttons beside the calendar, which is what the staging UI does and what this deliberately
* does not copy.
*
* Built against 8502-40329, NOT the earlier 7959-10949. That node had a timezone `Select` between the
* calendar and the chips, so it had a middle section this does not: there are two regions here, not
* three. 8502-40329 lives on a Figma branch and is not reachable through the API, so it was worked
* from an exported screenshot.
*
* Blocks-owned because it is a genuine composite: the calendar, the shortcut vocabulary and the rule
* that a picked range covers whole days all have to agree, and every consumer assembling that from ds
* primitives would have to get the same three things right. A `DataCombobox` or a `ToggleGroup` on its
* own belongs in the consumer instead.
*
* Exactly one shortcut can be active, and which one is derived from the RANGE rather than remembered:
* a range restored from query params, where no shortcut was ever persisted, still lights up its chip.
*
* No timezone control, by instruction. The older node drew one; it is out of scope and deliberately
* not stubbed.
*
* Offers all SEVEN default shortcuts unless `shortcuts` narrows, reorders, or extends them with
* consumer-defined ones. The reference screenshot draws six - it stops at `Last month` - but the
* seventh, `last_six_months`, is in the shipped vocabulary, so it is offered by default and simply
* wraps. Worth knowing if the design and this ever look one chip apart.
*
* @example
* ```tsx
* <DateRangeCalendar value={range} onChange={setRange} />
* ```
*/
function DateRangeCalendar(componentProps) {
var _value$from;
const { value, onChange, shortcuts = DEFAULT_DATE_RANGE_SHORTCUTS, today, disabled, className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$11);
const t = useDsTranslation("blocks");
const [inProgress, setInProgress] = React.useState();
const referenceToday = today !== null && today !== void 0 ? today : /* @__PURE__ */ new Date();
const activeShortcut = matchDateRangeShortcut(value, shortcuts, referenceToday);
const selected = inProgress !== null && inProgress !== void 0 ? inProgress : value ? {
from: value.from,
to: value.to
} : void 0;
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
"data-slot": "date-range-calendar",
className: cn("w-75 overflow-hidden rounded-lg border border-border bg-background", className)
}, props), {}, { children: [/* @__PURE__ */ jsx(Calendar, {
className: "w-full",
fixedWeeks: true,
classNames: {
root: "w-full rounded-none border-0 bg-transparent overflow-hidden",
today: "rounded-(--cell-radius) text-foreground",
day: "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius) [&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius) [&:has(>button[data-range-start=true][data-range-end=true])]:bg-background [&:has(>button[data-range-start=true][data-range-end=true])]:before:bg-transparent [&:has(>button[data-range-start=true][data-range-end=true])]:after:bg-transparent [&:has(>button[data-range-start=true]):has(+td>button[data-range-end=true])]:mr-px [&:has(>button[data-range-start=true]):has(+td>button[data-range-end=true])]:bg-transparent [&:has(>button[data-range-start=true]):has(+td>button[data-range-end=true])]:after:bg-transparent [&:has(>button[data-range-start=true]):has(+td>button[data-range-end=true])_button]:rounded-(--cell-radius)! [&:has(>button[data-range-start=true]):has(+td>button[data-range-end=true])+td]:ml-px [&:has(>button[data-range-start=true]):has(+td>button[data-range-end=true])+td]:bg-transparent [&:has(>button[data-range-start=true]):has(+td>button[data-range-end=true])+td]:after:bg-transparent [&:has(>button[data-range-start=true]):has(+td>button[data-range-end=true])+td_button]:rounded-(--cell-radius)!"
},
mode: "range",
selected,
disabled,
defaultMonth: (_value$from = value === null || value === void 0 ? void 0 : value.from) !== null && _value$from !== void 0 ? _value$from : referenceToday,
onSelect: (range) => {
setInProgress(range);
if ((range === null || range === void 0 ? void 0 : range.from) && (range === null || range === void 0 ? void 0 : range.to)) {
onChange({
from: startOfDay(range.from),
to: endOfDay(range.to)
});
setInProgress(void 0);
}
}
}), /* @__PURE__ */ jsx("div", {
"data-slot": "date-range-calendar-shortcuts",
className: "border-t border-border bg-muted/40 p-3",
children: /* @__PURE__ */ jsx("div", {
className: "flex w-full flex-wrap gap-2",
children: shortcuts.map((shortcut) => {
const shortcutRange = shortcut.getRange(referenceToday);
const shortcutDisabled = isDateRangeDisabled(shortcutRange, disabled);
const label = typeof shortcut.label === "function" ? shortcut.label(t) : shortcut.label;
return /* @__PURE__ */ jsx("div", {
"data-slot": "date-range-calendar-shortcut",
children: /* @__PURE__ */ jsx(Toggle, {
variant: "outline",
pressedColor: "lowContrast",
pressed: activeShortcut === shortcut.key,
disabled: shortcutDisabled,
className: "rounded-full! bg-background! hover:bg-muted! data-pressed:bg-muted! data-pressed:text-foreground! data-pressed:hover:bg-muted! data-pressed:hover:text-foreground!",
onPressedChange: (pressed) => {
if (!pressed) return;
setInProgress(void 0);
onChange(_objectSpread2(_objectSpread2({}, shortcutRange), {}, { shortcut: shortcut.key }));
},
children: label
})
}, shortcut.key);
})
})
})] }));
}
//#endregion
//#region src/helpers/language-semantics.ts
/**
* Deepgram language semantics that CLDR cannot infer from the vendor tag alone. In particular,
* `zh-HK` is Cantonese, while `zh-CN` is Mandarin/Simplified Chinese.
*/
const LANGUAGE_SEMANTIC_MAP = {
"en-US": {
group: "en",
displayCode: "en",
region: "US"
},
"es-ES": {
group: "es",
displayCode: "es",
region: "ES"
},
"es-419": {
group: "es",
displayCode: "es",
region: "419"
},
"es-MX": {
group: "es",
displayCode: "es",
region: "MX"
},
"ca-ES": {
group: "ca",
displayCode: "ca",
region: "ES"
},
"it-IT": {
group: "it",
displayCode: "it",
region: "IT"
},
"de-DE": {
group: "de",
displayCode: "de",
region: "DE"
},
"de-CH": {
group: "de",
displayCode: "de",
region: "CH"
},
"pt-BR": {
group: "pt",
displayCode: "pt",
region: "BR"
},
"pt-PT": {
group: "pt",
displayCode: "pt",
region: "PT"
},
"zh-CN": {
group: "zh",
displayCode: "zh",
script: "Hans"
},
"zh-TW": {
group: "zh",
displayCode: "zh",
script: "Hant"
},
"zh-Hans": {
group: "zh",
displayCode: "zh",
script: "Hans"
},
"zh-Hant": {
group: "zh",
displayCode: "zh",
script: "Hant"
},
"zh-HK": {
group: "yue",
displayCode: "yue",
region: "HK"
},
"nl-BE": {
group: "nl-BE",
displayCode: "nl-BE"
},
yue: {
group: "yue",
displayCode: "yue"
},
"yue-Hant-TW": {
group: "yue",
displayCode: "yue",
script: "Hant",
region: "TW"
}
};
/** Canonicalizes a BCP 47 tag while preserving malformed vendor values unchanged. */
function canonicalizeLanguageCode(code) {
try {
return new Intl.Locale(code).toString();
} catch (_unused) {
return code;
}
}
/** Returns a semantic language definition, with a locale-derived fallback for new vendor tags. */
function getLanguageSemanticDefinition(code) {
const canonicalCode = canonicalizeLanguageCode(code);
const mapped = LANGUAGE_SEMANTIC_MAP[canonicalCode];
if (mapped) return mapped;
try {
const locale = new Intl.Locale(canonicalCode);
return {
group: locale.language || canonicalCode,
displayCode: locale.language || canonicalCode,
region: locale.region,
script: locale.script
};
} catch (_unused2) {
var _canonicalCode$split$;
const displayCode = (_canonicalCode$split$ = canonicalCode.split("-")[0]) !== null && _canonicalCode$split$ !== void 0 ? _canonicalCode$split$ : canonicalCode;
return {
group: displayCode,
displayCode
};
}
}
/** Returns the variant identity used to count siblings inside one semantic language group. */
function getLanguageSemanticVariantKey(definition) {
var _definition$script, _definition$region;
return `${(_definition$script = definition.script) !== null && _definition$script !== void 0 ? _definition$script : ""}:${(_definition$region = definition.region) !== null && _definition$region !== void 0 ? _definition$region : ""}`;
}
//#endregion
//#region src/helpers/languages.helpers.ts
/**
* Normalizes a language label or query for case- and accent-insensitive comparison.
*
* `NFKD` splits an accented character into its base plus a combining mark, and stripping `\p{M}`
* removes those marks - so `Francais` matches `Français` and `Espanol` matches `Español`. Without
* it a user typing on a keyboard without the accent finds nothing, which is the common case for
* anyone searching a language list in a language that is not their own.
*/
function normalizeLanguageSearchText(value) {
return value.normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "").trim().toLowerCase();
}
/**
* Whether any of a language's search labels matches the query, accent- and case-insensitively.
*
* Takes a LIST of labels rather than one, because a language is findable under more than one
* name: with endonym labels on (the default) the visible label is `Deutsch`, and a user typing
* `german` still expects to find it. The caller decides which names count - typically the
* displayed label plus the English one.
*/
function matchesLanguageQuery(searchLabels, query) {
const normalizedQuery = normalizeLanguageSearchText(query);
if (normalizedQuery === "") return true;
return searchLabels.some((label) => normalizeLanguageSearchText(label).includes(normalizedQuery));
}
/**
* Filters a language list by query.
*
* Extracted so a filter chip, its drawer field and the standalone `LanguageCombobox` cannot search
* differently for the same language - the same reason `filterCountriesByQuery` exists. Before it,
* the chip used the DS label-only client filter while the drawer used `LanguageCombobox`'s
* normalizing one, so `francais` found Français in the drawer and nothing on the chip.
*
* `getSearchLabels` defaults to the option's own label. Pass it to widen the match, e.g. to
* include the English name alongside the endonym.
*/
function filterLanguagesByQuery(options, query, getSearchLabels = (option) => [option.label]) {
return options.filter((option) => matchesLanguageQuery(getSearchLabels(option), query));
}
//#endregion
//#region src/components/language-combobox.tsx
const _excluded$10 = [
"codes",
"translateItems",
"labelOverrides",
"extraOptions",
"placeholder",
"className",
"contentClassName",
"multiple",
"value",
"defaultValue",
"onValueChange",
"disabled",
"aria-invalid",
"aria-describedby",
"aria-labelledby",
"aria-label",
"data-test"
];
const _excluded2$4 = [
"codes",
"translateItems",
"labelOverrides",
"extraOptions",
"placeholder",
"className",
"contentClassName",
"multiple",
"value",
"defaultValue",
"onValueChange",
"disabled",
"aria-invalid",
"aria-describedby",
"aria-labelledby",
"aria-label",
"data-test"
];
/**
* The BCP 47 language codes offered by default. Aircall's product surfaces settle around this
* set; pass `codes` to narrow or extend it.
*/
const LANGUAGE_CODES = [
"ar",
"ca",
"cs",
"da",
"de",
"el",
"en",
"es",
"fi",
"fr",
"he",
"hi",
"hu",
"id",
"it",
"ja",
"ko",
"nb",
"nl",
"pl",
"pt",
"ro",
"ru",
"sk",
"sv",
"th",
"tr",
"uk",
"vi",
"zh"
];
/** Resolves a semantic language or script/region qualifier in the UI locale. */
function resolveLanguageQualifier(definition, displayLocale) {
try {
if (definition.script) return new Intl.DisplayNames([displayLocale], { type: "script" }).of(definition.script);
if (definition.region) return new Intl.DisplayNames([displayLocale], { type: "region" }).of(definition.region);
} catch (_unused) {
return;
}
}
/**
* Resolves one language option label. A lone semantic language uses its generic name, while a
* qualifier is shown only when multiple variants of that semantic language are offered.
*/
function resolveLanguageOptionLabel({ code, displayLocale, offeredCodes, labelOverrides }) {
var _labelOverrides$canon;
const canonicalCode = canonicalizeLanguageCode(code);
const override = (_labelOverrides$canon = labelOverrides === null || labelOverrides === void 0 ? void 0 : labelOverrides[canonicalCode]) !== null && _labelOverrides$canon !== void 0 ? _labelOverrides$canon : labelOverrides === null || labelOverrides === void 0 ? void 0 : labelOverrides[code];
if (override !== void 0) return override;
const definition = getLanguageSemanticDefinition(code);
const siblingVariants = new Set(offeredCodes.map((offeredCode) => getLanguageSemanticDefinition(offeredCode)).filter((sibling) => sibling.group === definition.group).map(getLanguageSemanticVariantKey));
const languageLabel = resolveLanguageLabel(definition.displayCode, displayLocale);
if (siblingVariants.size <= 1) return languageLabel;
const qualifier = resolveLanguageQualifier(definition, displayLocale);
return qualifier ? `${languageLabel} (${qualifier})` : languageLabel;
}
/** Returns visible, English, and endonym aliases for one semantic language option. */
function getLanguageSearchLabels(code, label, displayLocale) {
const definition = getLanguageSemanticDefinition(code);
const canonicalCode = canonicalizeLanguageCode(code);
const baseLabel = resolveLanguageLabel(definition.displayCode, displayLocale);
const vendorEnglishLabel = definition.displayCode === canonicalCode.split("-")[0] ? resolveLanguageLabel(canonicalCode, "en") : void 0;
return [...new Set([
label,
baseLabel,
resolveLanguageLabel(definition.displayCode, "en"),
resolveLanguageLabel(definition.displayCode, definition.displayCode),
vendorEnglishLabel
].filter((candidate) => candidate !== void 0))];
}
/**
* Upper-cases the first GRAPHEME of a label in a given locale.
*
* `Intl.DisplayNames` returns endonyms in each language's own casing convention, and several
* languages do not capitalise their own name: `es` gives "espanol" and `fr` gives "francais" while
* `de` gives "Deutsch" and `en` gives "English". Rendered in one list that reads as a bug, so the
* first letter is raised for all of them.
*
* Grapheme-safe and locale-aware, both deliberately: `slice(0, 1)` splits a surrogate pair or an
* emoji-style cluster down the middle, and `toUpperCase()` on a Turkish "i" gives "I" rather than
* the correct "Istanbul"-style dotted capital that `toLocaleUpperCase('tr')` produces.
*/
function capitalizeFirstGrapheme(label, locale) {
if (!label) return label;
const first = firstGrapheme(label);
return first.toLocaleUpperCase(locale) + label.slice(first.length);
}
/** The first grapheme cluster, via `Intl.Segmenter` where available. */
function firstGrapheme(label) {
var _segmenter$segment$Sy, _segmenter$segment$Sy2;
if (typeof Intl.Segmenter !== "function") {
var _;
return (_ = [...label][0]) !== null && _ !== void 0 ? _ : "";
}
return (_segmenter$segment$Sy = (_segmenter$segment$Sy2 = new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(label)[Symbol.iterator]().next().value) === null || _segmenter$segment$Sy2 === void 0 ? void 0 : _segmenter$segment$Sy2.segment) !== null && _segmenter$segment$Sy !== void 0 ? _segmenter$segment$Sy : "";
}
/**
* Resolves a language's display name through `Intl.DisplayNames`.
*
* One code path serves both label styles: the display locale is either the language's OWN code
* (an endonym, so `fr` reads "Francais") or the viewer's locale (so `fr` reads "French" for an
* English viewer).
*
* `Intl.DisplayNames` returns the input code unchanged when it knows no name for it, so an
* unrecognised code falls back to its upper-cased form rather than rendering a bare lowercase
* tag.
*
* The first letter is always raised, in the label's OWN locale - see
* {@link capitalizeFirstGrapheme} for why Spanish and French would otherwise render lowercase
* beside German and English.
*/
function resolveLanguageLabel(code, displayLocale) {
const resolved = safeDisplayName(code, displayLocale);
if (resolved && resolved !== code) return capitalizeFirstGrapheme(resolved, displayLocale);
return code.toUpperCase();
}
/** `Intl.DisplayNames` throws on a malformed locale or code, so failures degrade to no name. */
function safeDisplayName(code, displayLocale) {
try {
return new Intl.DisplayNames([displayLocale], { type: "language" }).of(code);
} catch (_unused2) {
return;
}
}
/** Builds semantic labels, search aliases, and sorting for the offered language codes. */
function useLanguageOptions(codes, translateItems, options = {}) {
const dsLocale = useDsLocale();
return React.useMemo(() => {
const collator = new Intl.Collator(dsLocale);
return [...new Set(codes)].map((code) => {
const displayLocale = translateItems ? code : dsLocale;
const label = resolveLanguageOptionLabel({
code,
displayLocale,
offeredCodes: codes,
labelOverrides: options.labelOverrides
});
return {
code,
label,
searchLabels: getLanguageSearchLabels(code, label, displayLocale)
};
}).sort((a, b) => collator.compare(a.label, b.label));
}, [
codes,
dsLocale,
options.labelOverrides,
translateItems
]);
}
/**
* Searchable language picker composed from the DS `Combobox` family. Labels come from
* `Intl.DisplayNames`, so no curated name table ships with it. Options are the language name
* alone: language surfaces carry no flag, since a language is not a country. Semantic aliases
* prevent vendor wording such as `Chinese (Hong Kong)` from leaking into the UI.
*
* By default each language is labelled in its OWN language (endonyms: English, Francais,
* Deutsch). Set `translateItems={false}` to label every option in the viewer's DS locale
* instead (English, French, German).
*
* `multiple` picks several languages at once, shown as removable chips in the input. The value
* widens to a list of codes with it, so the two forms are separate prop types.
*
* @example
* ```tsx
* <LanguageCombobox value={language} onValueChange={setLanguage} />
* <LanguageCombobox translateItems={false} value={language} onValueChange={setLanguage} />
* <LanguageCombobox multiple value={languages} onValueChange={setLanguages} />
* ```
*/
function LanguageCombobox(componentProps) {
return componentProps.multiple ? /* @__PURE__ */ jsx(MultipleLanguageCombobox, _objectSpread2({}, componentProps)) : /* @__PURE__ */ jsx(SingleLanguageCombobox, _objectSpread2({}, componentProps));
}
function useLanguageComboboxModel({ codes, translateItems, extraOptions, labelOverrides }) {
const options = useLanguageOptions(codes, translateItems, { labelOverrides });
const { itemValues, optionByCode, extraOptionByValue } = React.useMemo(() => ({
itemValues: [...extraOptions.map((option) => option.value), ...options.map((option) => option.code)],
optionByCode: new Map(options.map((option) => [option.code, option])),
extraOptionByValue: new Map(extraOptions.map((option) => [option.value, option]))
}), [extraOptions, options]);
return {
itemValues,
optionByCode,
extraOptionByValue,
itemToStringLabel: React.useCallback((code) => {
const option = optionByCode.get(code);
if (option) return option.label;
const extra = extraOptionByValue.get(code);
return typeof (extra === null || extra === void 0 ? void 0 : extra.label) === "string" ? extra.label : code;
}, [extraOptionByValue, optionByCode]),
filter: React.useCallback((code, query) => {
var _option$searchLabels;
const extra = extraOptionByValue.get(code);
if (extra) return matchesLanguageQuery([typeof extra.label === "string" ? extra.label : ""], query);
const option = optionByCode.get(code);
if (!option) return matchesLanguageQuery([], query);
return matchesLanguageQuery((_option$searchLabels = option.searchLabels) !== null && _option$searchLabels !== void 0 ? _option$searchLabels : [option.label], query);
}, [extraOptionByValue, optionByCode])
};
}
/**
* The popup rows, as a `ComboboxList` child function. A language renders its name, an extra
* option its own label.
*/
function languageItemRenderer(optionByCode, extraOptionByValue, dataTest) {
return (code) => {
const option = optionByCode.get(code);
if (option) return /* @__PURE__ */ jsx(ComboboxItem, {
value: code,
"data-test": `${dataTest}-item-${code}`,
children: option.label
}, code);
const extra = extraOptionByValue.get(code);
if (!extra) return null;
return /* @__PURE__ */ jsx(ComboboxItem, {
value: code,
"data-test": `${dataTest}-item-${code}`,
children: extra.label
}, code);
};
}
/** One language, in the input. The original single-value picker. */
function SingleLanguageCombobox(componentProps) {
const { codes = LANGUAGE_CODES, translateItems = true, labelOverrides, extraOptions = EMPTY_EXTRA_OPTIONS, placeholder: placeholderProp, className, contentClassName, multiple: _multiple, value, defaultValue, onValueChange, disabled, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedby, "aria-labelledby": ariaLabelledby, "aria-label": ariaLabel, "data-test": dataTest = "language-select" } = componentProps, comboboxProps = _objectWithoutProperties(componentProps, _excluded$10);
const t = useDsTranslation("blocks");
const placeholder = placeholderProp !== null && placeholderProp !== void 0 ? placeholderProp : t("languageSelect.placeholder");
const { itemValues, optionByCode, extraOptionByValue, itemToStringLabel, filter } = useLanguageComboboxModel({
codes,
translateItems,
extraOptions,
labelOverrides
});
const [selected, setSelected] = useControllableState({
prop: value,
defaultProp: defaultValue !== null && defaultValue !== void 0 ? defaultValue : null
});
const handleValueChange = React.useCallback((next, details) => {
setSelected(next);
onValueChange === null || onValueChange === void 0 || onValueChange(next, details);
}, [onValueChange, setSelected]);
const placeholderText = typeof placeholder === "string" ? placeholder : void 0;
const anchorRef = useComboboxAnchor();
return /* @__PURE__ */ jsxs(Combobox, _objectSpread2(_objectSpread2({}, comboboxProps), {}, {
items: itemValues,
value: selected !== null && selected !== void 0 ? selected : null,
onValueChange: handleValueChange,
itemToStringLabel,
filter,
disabled,
children: [/* @__PURE__ */ jsx(ComboboxInput, {
ref: anchorRef,
"data-test": dataTest,
className: cn("w-64 max-w-full", className),
placeholder: placeholderText,
disabled,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedby,
"aria-labelledby": ariaLabelledby,
"aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : placeholderText
}), /* @__PURE__ */ jsxs(ComboboxContent, {
anchor: anchorRef,
"data-test": `${dataTest}-content`,
className: contentClassName,
children: [/* @__PURE__ */ jsx(ComboboxEmpty, { children: t("select.noResults") }), /* @__PURE__ */ jsx(ComboboxList, {
"aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : placeholderText,
children: languageItemRenderer(optionByCode, extraOptionByValue, dataTest)
})]
})]
}));
}
/**
* Several languages, as removable chips in the input. Same rows and same search as the single
* form - only the selection and the way it is displayed differ.
*/
function MultipleLanguageCombobox(componentProps) {
const { codes = LANGUAGE_CODES, translateItems = true, labelOverrides, extraOptions = EMPTY_EXTRA_OPTIONS, placeholder: placeholderProp, className, contentClassName, multiple: _multiple, value, defaultValue, onValueChange, disabled, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedby, "aria-labelledby": ariaLabelledby, "aria-label": ariaLabel, "data-test": dataTest = "language-select" } = componentProps, comboboxProps = _objectWithoutProperties(componentProps, _excluded2$4);
const t = useDsTranslation("blocks");
const { itemValues, optionByCode, extraOptionByValue, itemToStringLabel, filter } = useLanguageComboboxModel({
codes,
translateItems,
extraOptions,
labelOverrides
});
const [selected, setSelected] = useControllableState({
prop: value,
defaultProp: defaultValue !== null && defaultValue !== void 0 ? defaultValue : EMPTY_CODES
});
const handleValueChange = React.useCallback((next, details) => {
setSelected(next);
onValueChange === null || onValueChange === void 0 || onValueChange(next, details);
}, [onValueChange, setSelected]);
const placeholder = placeholderProp !== null && placeholderProp !== void 0 ? placeholderProp : t("languageSelect.placeholderMultiple");
const placeholderText = typeof placeholder === "string" ? placeholder : void 0;
const anchorRef = useComboboxAnchor();
const rootValue = React.useMemo(() => [...selected !== null && selected !== void 0 ? selected : EMPTY_CODES], [selected]);
return /* @__PURE__ */ jsxs(Combobox, _objectSpread2(_objectSpread2({}, comboboxProps), {}, {
multiple: true,
items: itemValues,
value: rootValue,
onValueChange: handleValueChange,
itemToStringLabel,
filter,
disabled,
children: [/* @__PURE__ */ jsx(ComboboxChips, {
ref: anchorRef,
"data-test": `${dataTest}-chips`,
className: cn("w-64 max-w-full", className),
children: /* @__PURE__ */ jsx(ComboboxValue, { children: (codesSelected) => /* @__PURE__ */ jsxs(Fragment, { children: [codesSelected.map((code) => /* @__PURE__ */ jsx(ComboboxChip, {
"data-test": `${dataTest}-chip-${code}`,
children: itemToStringLabel(code)
}, code)), /* @__PURE__ */ jsx(ComboboxChipsInput, {
"data-test": dataTest,
disabled,
placeholder: codesSelected.length === 0 ? placeholderText : "",
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedby,
"aria-labelledby": ariaLabelledby,
"aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : placeholderText
})] }) })
}), /* @__PURE__ */ jsxs(ComboboxContent, {
anchor: anchorRef,
"data-test": `${dataTest}-content`,
className: contentClassName,
children: [/* @__PURE__ */ jsx(ComboboxEmpty, { children: t("select.noResults") }), /* @__PURE__ */ jsx(ComboboxList, {
"aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : placeholderText,
children: languageItemRenderer(optionByCode, extraOptionByValue, dataTest)
})]
})]
}));
}
/** Shared empty default, so an unset `extraOptions` keeps a stable identity across renders. */
const EMPTY_EXTRA_OPTIONS = [];
/** Shared empty default, so an unset multiple `value` keeps a stable identity across renders. */
const EMPTY_CODES = [];
//#endregion
//#region src/components/filter-language.tsx
const _excluded$9 = [
"codes",
"translateItems",
"labelOverrides",
"placeholder",
"icon",
"formatValue"
];
const getLanguageValue = (option) => option.code;
const getLanguageLabel = (option) => option.label;
/**
* Language filter chip: a thin preset over `FilterCombobox` carrying the language option list.
*
* Both surfaces come from ONE declaration. There is deliberately no `surface === 'sheet'` branch:
* the drawer field is the same `FilterCombobox` field as everything else, with the same rows and the
* same accent-insensitive search, because `items` and `onSearch` are forwarded to both surfaces.
*
* It used to render `LanguageCombobox` in the drawer for single selection. That produced the same
* field from a second code path, and the two drifted - the drawer inherited `LanguageCombobox`'s
* hardcoded English "Select a language" prompt, and the two surfaces searched differently, since
* only `LanguageCombobox` normalized accents. The normalizing filter now lives in
* `filterLanguagesByQuery`, so both surfaces share it and neither needs the override.
* Same labelling rules as `LanguageCombobox` - endonyms by default, viewer-locale names with
* `translateItems={false}` - and options are the language name alone, since language surfaces
* carry no flag.
*
* The trigger keeps the generic `Languages` glyph whatever is selected. Pass `icon={null}` for
* none, or a node to pin a different one.
*
* @example
* ```tsx
* <FilterLanguage id="language" selectedKeys={languages} onChange={setLanguages} selectionMode="multiple" />
* ```
*/
function FilterLanguage(componentProps) {
const { codes = LANGUAGE_CODES, translateItems = true, labelOverrides, placeholder, icon, formatValue } = componentProps, filterSelectProps = _objectWithoutProperties(componentProps, _excluded$9);
const t = useDsTranslation("blocks");
const resolvedPlaceholder = placeholder !== null && placeholder !== void 0 ? placeholder : t("filterLanguage.placeholder");
const options = useLanguageOptions(codes, translateItems, { labelOverrides });
const [query, setQuery] = React.useState("");
const visible = React.useMemo(() => filterLanguagesByQuery(options, query, (option) => {
var _option$searchLabels;
return (_option$searchLabels = option.searchLabels) !== null && _option$searchLabels !== void 0 ? _option$searchLabels : [option.label];
}), [options, query]);
return /* @__PURE__ */ jsx(FilterCombobox, _objectSpread2({
items: visible,
getItemValue: getLanguageValue,
getItemLabel: getLanguageLabel,
placeholder: resolvedPlaceholder,
icon: icon === void 0 ? /* @__PURE__ */ jsx(Languages, {}) : icon,
formatValue: formatValue !== null && formatValue !== void 0 ? formatValue : ((keys) => summarizeLanguages(keys, options, t)),
searchable: true,
onSearch: setQuery
}, filterSelectProps));
}
/** One selection reads as its label; several read as a count, so the chip never overflows. */
function summarizeLanguages(keys, options, t) {
if (keys.size === 0) return;
if (keys.size === 1) {
var _options$find$label, _options$find;
const [code] = [...keys];
return (_options$find$label = (_options$find = options.find((option) => option.code === code)) === null || _options$find === void 0 ? void 0 : _options$find.label) !== null && _options$find$label !== void 0 ? _options$find$label : code.toUpperCase();
}
return t("filterLanguage.countValue", { count: keys.size });
}
//#endregion
//#region src/components/country-combobox.tsx
const _excluded$8 = [
"selectionMode",
"virtualizeRows",
"dialPrefix"
];
const getCountryValue$1 = (option) => option.code;
const getCountryLabel$1 = (option) => option.name;
/**
* The label with the dial prefix appended, for `dialPrefix="label"`.
*
* A country with no dial code of its own (Antarctica) renders its name alone rather than a
* dangling `()`, which is why `phonePrefix` is checked rather than interpolated blindly.
*/
const getCountryLabelWithPrefix = (option) => option.phonePrefix ? `${option.name} (${option.phonePrefix})` : option.name;
/**
* A country row: its flag, localized name and, if it has one, its dial prefix. Shared by
* `CountryCombobox` and `FilterCountry` so a country renders identically across
* every surface. Internal - not part of the public API; use `renderCountryOption` as a
* `DataCombobox`/`ComboboxRows` `renderItem`, or render it directly.
*
* Deliberately a plain flex row, NOT the DS `Item` family: `Item`'s base class carries an
* always-on 1px border (`variant="default"` only zeroes its COLOR via `border-transparent`,
* never its width) and `ItemTitle` hardcodes `leading-snug` (19.25px at `text-sm`) instead of a
* flat 20px. Dropped into a combobox/select row - which already owns its own padding - that
* lands the row at a non-round 37.25px instead of the expected 36px unless both are overridden
* by hand. The Figma design for these rows doesn't use `Item` either, so a plain row matches it
* with nothing to fight.
*/
function CountryItem({ option }) {
return /* @__PURE__ */ jsxs("span", {
"data-slot": "country-item",
className: "flex w-full min-w-0 items-center gap-2",
children: [
/* @__PURE__ */ jsx(CountryFlag, {
countryIsoCode: option.code,
size: "md"
}),
/* @__PURE__ */ jsx("span", {
className: "min-w-0 truncate",
children: option.name
}),
option.phonePrefix ? /* @__PURE__ */ jsx("span", {
className: "ml-auto text-xs text-muted-foreground",
children: option.phonePrefix
}) : null
]
});
}
/**
* `renderItem` for a country list, so `CountryCombobox` and `FilterCountry` render the same row.
*/
const renderCountryOption = (option) => /* @__PURE__ */ jsx(CountryItem, { option });
/**
* Searchable country list built on the DS `DataCombobox`, with the DS `CountryFlag` per row.
* A combobox rather than a select because there are 244 countries.
*
* Search matches the localized name, the ISO2 code and the dial prefix.
*
* Rows are VIRTUALIZED: there are 244 countries and each row mounts a flag, which is enough to
* make opening the list and typing in it visibly lag. Pass `virtualizeRows={false}` to opt out,
* e.g. for a test that wants every row in the DOM.
*
* `virtualizeRows` is passed explicitly even though `DataCombobox`'s auto threshold would switch
* it on anyway at 244 rows. Kept deliberately, as documentation rather than as mechanism: this
* component has a KNOWN performance reason to virtualise, and that should not silently depend on
* where a shared threshold happens to sit. If the threshold is ever raised above 244, this list
* must not quietly go back to mounting every flag.
*
* No `estimateRowHeight` override: a country row is one line (flag, name, dial prefix), which is
* exactly the plain row `DataCombobox` already assumes. Only taller shapes - an avatar row, a
* two-line item - need to declare a height.
*
* @example
* ```tsx
* <CountryCombobox selectedKeys={countries} onSelectionChange={setCountries} />
* ```
*/
function CountryCombobox(componentProps) {
const { selectionMode = "single", virtualizeRows = true, dialPrefix = "row" } = componentProps, comboboxProps = _objectWithoutProperties(componentProps, _excluded$8);
const options = useCountryOptions();
const [query, setQuery] = React.useState("");
const visible = React.useMemo(() => filterCountriesByQuery(options, query), [options, query]);
return /* @__PURE__ */ jsx(DataCombobox, _objectSpread2({
items: visible,
getItemValue: getCountryValue$1,
getItemLabel: dialPrefix === "label" ? getCountryLabelWithPrefix : getCountryLabel$1,
renderItem: renderCountryOption,
selectionMode,
virtualizeRows,
onSearch: setQuery
}, comboboxProps));
}
//#endregion
//#region src/components/filter-country.tsx
const _excluded$7 = [
"placeholder",
"icon",
"formatValue",
"virtualizeRows"
];
const getCountryValue = (option) => option.code;
const getCountryLabel = (option) => option.name;
/**
* The trigger's icon for a country selection: the selected country's FLAG once one country is
* applied, and the generic `Globe` glyph otherwise. The same rule `FilterLanguage` follows.
*
* Exactly one selection, because that is the only case where a flag tells the truth. A
* multi-selection's label reads "3 countries", and a flag beside it would name one country the
* filter is not limited to - the first key, in an arbitrary order at that.
*
* Resolved through the option LIST rather than handed straight to `CountryFlag`, and that is not
* ceremony: `CountryFlag` renders the US flag for any code it does not recognise, so a key the list
* does not contain - a stale query param, a code from another vocabulary - would put a confidently
* wrong flag on the chip. Falling back to the glyph is the honest answer for a code we cannot name.
*
* 16px, matching `CountryItem` so one country reads the same in the chip and in the list. The
* size travels as a `className` rather than `CountryFlag`'s `size` prop because `FilterTrigger`
* clones the icon with its own 16px first and `CountryFlag` puts the incoming class last, so a
* `size` prop would lose the merge and render at 16px.
*/
function resolveCountryIcon(keys, options) {
if (keys.size !== 1) return /* @__PURE__ */ jsx(Globe, {});
const [code] = [...keys];
const selected = options.find((option) => option.code === code);
return selected ? /* @__PURE__ */ jsx(CountryFlag, {
countryIsoCode: selected.code,
size: "md"
}) : /* @__PURE__ */ jsx(Globe, {});
}
/**
* Country filter chip: a thin preset over `FilterCombobox` carrying the country list, the flag
* rows and the three-field search (localized name, ISO2 code, dial prefix), so `France`, `FR`
* and `+33` all find France.
*
* Both surfaces come from ONE declaration. There is deliberately no `surface === 'sheet'` branch:
* everything the drawer field needs - `renderItem` for the flag rows, `onSearch` for the
* three-field search, `virtualizeRows` for 244 rows - is forwarded by `FilterCombobox` to both its
* chip popup and its drawer field. An override rendering `CountryCombobox` here produced the same
* field from a second code path, which is how the two drifted apart on their placeholder.
*
* The trigger's icon FOLLOWS the selection: the selected country's flag once one country is applied,
* and a `Globe` glyph while none or several are. Pass `icon={null}` for none, or a node to pin one
* glyph regardless of the selection. See {@link resolveCountryIcon}.
*
* @example
* ```tsx
* <FilterCountry id="country" selectedKeys={countries} onChange={setCountries} selectionMode="multiple" />
* ```
*/
function FilterCountry(componentProps) {
const { placeholder, icon, formatValue, virtualizeRows = true } = componentProps, filterSelectProps = _objectWithoutProperties(componentProps, _excluded$7);
const t = useDsTranslation("blocks");
const resolvedPlaceholder = placeholder !== null && placeholder !== void 0 ? placeholder : t("filterCountry.placeholder");
const options = useCountryOptions();
const [query, setQuery] = React.useState("");
const visible = React.useMemo(() => filterCountriesByQuery(options, query), [options, query]);
return /* @__PURE__ */ jsx(FilterCombobox, _objectSpread2({
items: visible,
getItemValue: getCountryValue,
getItemLabel: getCountryLabel,
renderItem: renderCountryOption,
placeholder: resolvedPlaceholder,
icon: icon === void 0 ? (keys) => resolveCountryIcon(keys, options) : icon,
formatValue: formatValue !== null && formatValue !== void 0 ? formatValue : ((keys) => summarizeCountries(keys, options, t)),
searchable: true,
onSearch: setQuery,
virtualizeRows
}, filterSelectProps));
}
/**
* One selection reads as its name; several read as a count, so the chip never overflows.
*
* The count goes through `t` with `{{count}}`, so i18next picks the singular or plural form for
* the active locale. The singular is unreachable from here - one selection returns the country's
* NAME - but the key still ships both forms, because a consumer passing its own `formatValue` is
* not bound by that rule and a count key with no plural is a latent "1 countries".
*/
function summarizeCountries(keys, options, t) {
if (keys.size === 0) return;
if (keys.size === 1) {
var _options$find$name, _options$find;
const [code] = [...keys];
return (_options$find$name = (_options$find = options.find((option) => option.code === code)) === null || _options$find === void 0 ? void 0 : _options$find.name) !== null && _options$find$name !== void 0 ? _options$find$name : code;
}
return t("filterCountry.countValue", { count: keys.size });
}
//#endregion
//#region src/form/form-context.ts
/**
* TanStack Form contexts for `@aircall/blocks`.
*
* - `fieldContext` / `formContext` — pass to `createFormHook` when extending `useForm` with custom field or form components.
* - `useFieldContext<T>()` — read the bound field's value, meta, and handlers inside a custom field component.
* - `useFormContext()` — read form-level state (isDirty, isSubmitting, …) inside a custom form component.
*
* @example
* // Extending useForm with a custom SwitchField
* const { useAppForm } = createFormHook({ fieldContext, formContext, fieldComponents: { SwitchField }, formComponents: {} })
*/
const { fieldContext, formContext, useFieldContext, useFormContext } = createFormHookContexts();
//#endregion
//#region src/components/save-bar.tsx
function SaveBar({ isDirty, isSubmitting, canSubmit, onReset, submitLabel = "Save changes", savingLabel = "Saving…", resetLabel = "Discard", className }) {
return /* @__PURE__ */ jsx("div", {
"data-slot": "card-footer",
className: cn("grid transition-[grid-template-rows] duration-300 ease-in-out", isDirty ? "grid-rows-[1fr]" : "grid-rows-[0fr]", className),
children: /* @__PURE__ */ jsx("div", {
className: "overflow-hidden",
children: /* @__PURE__ */ jsxs("div", {
className: "flex items-center justify-end gap-2 border-t p-4",
children: [/* @__PURE__ */ jsx(Button, {
type: "button",
variant: "ghost",
onClick: onReset,
disabled: !isDirty || isSubmitting,
children: resetLabel
}), /* @__PURE__ */ jsx(Button, {
type: "submit",
"data-test": "save-button",
disabled: !isDirty || !canSubmit || isSubmitting,
children: isSubmitting ? savingLabel : submitLabel
})]
})
})
});
}
//#endregion
//#region src/components/card-save-bar.tsx
/**
* Save/discard bar for card-layout forms — the TanStack Form adapter around
* `SaveBar`. Reads `isDirty`, `isSubmitting`, and `canSubmit` from form context
* and renders `SaveBar` (which owns the animation, buttons, and the
* `data-slot="card-footer"` that flushes it to the card edge).
*
* Must be placed **inside** the `<form>` element and wrapped by `form.AppForm`
* (or any `formContext` provider).
*
* @example
* <form.AppForm>
* <form.CardSaveBar submitLabel="Save" resetLabel="Discard" />
* </form.AppForm>
*/
function CardSaveBar({ submitLabel, savingLabel, resetLabel }) {
const form = useFormContext();
return /* @__PURE__ */ jsx(form.Subscribe, {
selector: (s) => ({
isDirty: !s.isDefaultValue,
isSubmitting: s.isSubmitting,
canSubmit: s.canSubmit
}),
children: ({ isDirty, isSubmitting, canSubmit }) => /* @__PURE__ */ jsx(SaveBar, {
isDirty,
isSubmitting,
canSubmit,
onReset: () => form.reset(),
submitLabel,
savingLabel,
resetLabel
})
});
}
//#endregion
//#region src/components/submit-button.tsx
const _excluded$6 = [
"children",
"loadingLabel",
"className"
];
/**
* Context-aware submit button. Disables itself when the form cannot be submitted or is submitting.
* Shows `loadingLabel` (default `"Saving…"`) while submitting.
*
* Renders a DS `<Button type="submit">` (a `<button>` element).
* Must be a descendant of `form.AppForm` (or any `formContext` provider).
* Use for inline/flat layouts where `CardSaveBar` is not needed.
* Accepts all `Button` props except `type` and `disabled` (managed internally).
*
* @example
* <form.AppForm>
* <form.SubmitButton loadingLabel="Saving…">Save</form.SubmitButton>
* </form.AppForm>
*/
function SubmitButton(_ref) {
let { children = "Submit", loadingLabel, className } = _ref, buttonProps = _objectWithoutProperties(_ref, _excluded$6);
const form = useFormContext();
return /* @__PURE__ */ jsx(form.Subscribe, {
selector: (s) => ({
isSubmitting: s.isSubmitting,
canSubmit: s.canSubmit
}),
children: ({ isSubmitting, canSubmit }) => /* @__PURE__ */ jsx(Button, _objectSpread2(_objectSpread2({
type: "submit",
disabled: !canSubmit || isSubmitting,
className: cn(className)
}, buttonProps), {}, { children: isSubmitting ? loadingLabel !== null && loadingLabel !== void 0 ? loadingLabel : "Saving…" : children }))
});
}
//#endregion
//#region src/form/use-form.ts
/**
* Pre-configured TanStack Form hook for `@aircall/blocks`.
*
* Fields are built with the composable, typed `Form*Field` wrappers (e.g. `FormSelectField`),
* which take the `form` as a prop — there are no registered `fieldComponents`. Only the
* form-level UI (`SubmitButton`, `CardSaveBar`) is registered, so it's available as
* `form.SubmitButton` / `form.CardSaveBar` inside `form.AppForm`.
*
* `CommonForm` is the full form object — use it directly or extend it:
* - `CommonForm.useAppForm` — primary hook; types inferred from `defaultValues`
* - `CommonForm.withForm` — HOC equivalent for higher-order function patterns
*
* @example
* const form = useForm({ defaultValues: { name: '' }, onSubmit: async ({ value }) => save(value) })
*/
const CommonForm = createFormHook({
fieldContext,
formContext,
fieldComponents: {},
formComponents: {
SubmitButton,
CardSaveBar
}
});
const useForm = CommonForm.useAppForm;
const { withForm, withFieldGroup } = CommonForm;
//#endregion
//#region src/form/field-errors.ts
/**
* Normalize a TanStack Form field's `meta.errors` into the `{ message }[]` shape
* that `@aircall/ds`'s `FieldError` expects.
*
* Errors come from two validator sources with different shapes:
* - per-field validators return plain strings (e.g. `'Email is required'`)
* - standard-schema validators (e.g. Zod on `useForm`) return issue **objects** with a `message`
*
* Both must render their human-readable text — never `"[object Object]"`.
*/
function toFieldErrors(errors) {
return errors.map((error) => {
var _message;
return { message: typeof error === "string" ? error : (_message = error === null || error === void 0 ? void 0 : error.message) !== null && _message !== void 0 ? _message : String(error) };
});
}
//#endregion
//#region src/components/form-field.tsx
/**
* Internal layout shell shared by every `Form*Field` wrapper. Normalizes the
* `label` / `description` props, lays out the `Field → label row → description →
* control → error` skeleton per `orientation` / `controlVariant`, and applies the
* error-display policy plus `data-invalid`. Reads the bound field from context,
* so it must render inside `form.AppField`. Renders a DS `Field`.
*/
function FormFieldShell({ label, description, necessityIndicator, id, children, orientation = "vertical", controlPosition = "end", controlVariant = "fill" }) {
var _ref;
const errors = useFieldContext().state.meta.errors;
const labelContent = typeof label === "string" ? label : label === null || label === void 0 ? void 0 : label.content;
const labelInfo = typeof label === "object" ? label.info : void 0;
const labelAside = typeof label === "object" ? label.aside : void 0;
const hasAddons = labelInfo !== void 0 || labelAside !== void 0;
const descriptionContent = typeof description === "string" ? description : description === null || description === void 0 ? void 0 : description.content;
const descriptionVariant = (_ref = typeof description === "object" ? description.variant : void 0) !== null && _ref !== void 0 ? _ref : "instructional";
const labelNode = labelContent !== void 0 && /* @__PURE__ */ jsx(FieldLabel, {
necessityIndicator,
htmlFor: id,
children: labelContent
});
const labelRow = labelContent !== void 0 && (hasAddons ? /* @__PURE__ */ jsxs(FieldLabelRow, { children: [
labelNode,
labelInfo !== void 0 && /* @__PURE__ */ jsx(FieldLabelInfo, { children: labelInfo }),
labelAside !== void 0 && /* @__PURE__ */ jsx(FieldLabelAside, { children: labelAside })
] }) : labelNode);
const descriptionNode = descriptionContent !== void 0 && /* @__PURE__ */ jsx(FieldDescription, {
variant: descriptionVariant,
children: descriptionContent
});
const invalid = errors.length > 0 ? true : void 0;
const instructionalDesc = descriptionVariant === "instructional" ? descriptionNode : null;
const contextualDesc = descriptionVariant === "contextual" ? descriptionNode : null;
const errorNode = /* @__PURE__ */ jsx(FieldError, { errors: toFieldErrors(errors) });
if (orientation === "vertical") return /* @__PURE__ */ jsxs(Field, {
"data-invalid": invalid,
children: [
labelRow,
instructionalDesc,
children,
contextualDesc,
errorNode
]
});
if (controlVariant === "inline") {
const textSide = descriptionContent !== void 0 || errors.length > 0 ? /* @__PURE__ */ jsxs(FieldContent, { children: [
labelRow,
instructionalDesc,
contextualDesc,
errorNode
] }) : labelRow;
return /* @__PURE__ */ jsxs(Field, {
orientation,
"data-invalid": invalid,
children: [controlPosition === "start" ? children : textSide, controlPosition === "start" ? textSide : children]
});
}
const labelCol = /* @__PURE__ */ jsxs(FieldContent, { children: [labelRow, instructionalDesc] });
const controlCol = /* @__PURE__ */ jsxs(FieldContent, { children: [
children,
contextualDesc,
errorNode
] });
return /* @__PURE__ */ jsxs(Field, {
orientation,
"data-invalid": invalid,
children: [controlPosition === "start" ? controlCol : labelCol, controlPosition === "start" ? labelCol : controlCol]
});
}
/**
* Shared runtime binder for the per-primitive wrappers (FormSelectField, FormInputField, …).
* Field-name/value/validator typing is enforced at each wrapper's boundary; this just renders
* the AppField + shared shell. `TData`/`TControl` carry the precise field + bundle shapes through.
*/
function FormFieldBase(props) {
const { form, name, validators, listeners, defaultValue, asyncDebounceMs, asyncAlways, label, description, necessityIndicator, orientation, controlPosition, controlVariant, buildControl, children } = props;
const id = `${React.useId()}-${name}`;
const AppField = form.AppField;
return /* @__PURE__ */ jsx(AppField, {
name,
validators,
listeners,
defaultValue,
asyncDebounceMs,
asyncAlways,
children: (field) => /* @__PURE__ */ jsx(FormFieldShell, {
label,
description,
necessityIndicator,
orientation,
controlPosition,
controlVariant,
id,
children: children(field, buildControl(field, id))
})
});
}
//#endregion
//#region src/components/form-select-field.tsx
function selectControl(field, id) {
return {
selectProps: {
value: field.state.value || null,
onValueChange: (value) => field.handleChange(value !== null && value !== void 0 ? value : "")
},
selectTriggerProps: {
id,
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
}
};
}
/**
* Select field wrapper. Owns the binding + shared `Field/Label/Error` shell, and hands the
* consumer pre-wired `selectProps` / `selectTriggerProps` to spread onto `@aircall/ds` Select
* primitives — trigger, value, and content stay fully composable (Combobox-style triggers too).
*
* Typing is derived from the threaded `form`: `name` is restricted to the form's **string**
* fields, and `validators` infer the field value — identical to using `form.AppField` directly.
*
* @example
* <FormSelectField form={form} name="language" label="Language" validators={{ onChange }}>
* {(field, { selectProps, selectTriggerProps }) => (
* <Select {...selectProps}>
* <SelectTrigger {...selectTriggerProps}><SelectValue placeholder="Pick one" /></SelectTrigger>
* <SelectContent>{options}</SelectContent>
* </Select>
* )}
* </FormSelectField>
*/
function FormSelectField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: selectControl }));
}
//#endregion
//#region src/components/form-input-field.tsx
function inputControl(field, id) {
var _field$state$value;
return { inputProps: {
id,
value: (_field$state$value = field.state.value) !== null && _field$state$value !== void 0 ? _field$state$value : "",
onChange: (event) => field.handleChange(event.target.value),
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
} };
}
/**
* Text-input field wrapper — same threaded-`form` typing and shared shell as `FormSelectField`,
* with an `inputProps` bundle. Compose the input yourself (add adornments, counters, prefixes).
* `name` is restricted to the form's **string** fields.
*
* @example
* <FormInputField form={form} name="email" label="Email" validators={{ onChange }}>
* {(field, { inputProps }) => <Input {...inputProps} type="email" placeholder="you@co.com" />}
* </FormInputField>
*/
function FormInputField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: inputControl }));
}
//#endregion
//#region src/components/form-combobox-field.tsx
function comboboxControl(field, id) {
return {
comboboxProps: {
value: field.state.value || null,
onValueChange: (value) => field.handleChange(value !== null && value !== void 0 ? value : "")
},
comboboxInputProps: {
id,
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
}
};
}
/**
* Combobox (searchable single-select) field wrapper for **string-valued** fields. Same threaded-
* `form` typing and shared shell as the other wrappers; the render-prop hands you `comboboxProps`
* and `comboboxInputProps` to spread, leaving the input, trigger, grouping, and item rendering
* entirely up to you — the case that motivated the render-prop API.
*
* @example
* <FormComboboxField form={form} name="framework" label="Framework" validators={{ onChange }}>
* {(field, { comboboxProps, comboboxInputProps }) => (
* <Combobox items={FRAMEWORKS} {...comboboxProps}>
* <ComboboxInput {...comboboxInputProps} placeholder="Search…" />
* <ComboboxContent>
* <ComboboxEmpty>No match.</ComboboxEmpty>
* <ComboboxList>{item => <ComboboxItem key={item} value={item}>{item}</ComboboxItem>}</ComboboxList>
* </ComboboxContent>
* </Combobox>
* )}
* </FormComboboxField>
*/
function FormComboboxField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: comboboxControl }));
}
//#endregion
//#region src/components/form-multi-combobox-field.tsx
function multiComboboxControl(field, id) {
var _field$state$value;
return {
comboboxProps: {
value: (_field$state$value = field.state.value) !== null && _field$state$value !== void 0 ? _field$state$value : [],
onValueChange: (values) => field.handleChange(values)
},
comboboxInputProps: {
id,
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
}
};
}
/**
* Multi-select combobox field wrapper — same threaded-`form` typing and shared shell as
* `FormSelectField`, with `comboboxProps` / `comboboxInputProps` bundles. `name` is restricted
* to the form's **string[]** fields — a string/number `name` is a type error.
*
* @example
* <FormMultiComboboxField form={form} name="tags" label="Tags" validators={{ onChange }}>
* {(field, { comboboxProps, comboboxInputProps }) => (
* <Combobox multiple items={items} {...comboboxProps}>
* <ComboboxChips><ComboboxInput {...comboboxInputProps} /></ComboboxChips>
* </Combobox>
* )}
* </FormMultiComboboxField>
*/
function FormMultiComboboxField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: multiComboboxControl }));
}
//#endregion
//#region src/components/form-array-field.tsx
/**
* Array field wrapper — drives a `form.AppField` in `mode="array"` and owns the shared
* `Field/Label/Description/Error` shell, so an array-level validator (e.g. "add at least one")
* surfaces under the list. `name` is restricted to the form's **array** fields, and the child's
* `field` is typed to the element type, with TanStack's array mutators (`pushValue`, `removeValue`,
* `moveValue`, …) exposed for add/remove/reorder.
*
* It deliberately has **no control bundle**: an array is a container of rows, not a single input.
* Each row is composed from the existing single-control wrappers with an indexed `name`.
*
* @example
* <FormArrayField form={form} name="webhooks" label="Webhooks" description={{ content: 'Endpoints to notify', variant: 'contextual' }}>
* {field => (
* <>
* {field.state.value.map((_row, i) => (
* <div key={i} className="flex items-end gap-2">
* <FormInputField form={form} name={`webhooks[${i}].url`} label="URL">
* {(_f, { inputProps }) => <Input {...inputProps} placeholder="https://…" />}
* </FormInputField>
* <Button variant="ghost" onClick={() => field.removeValue(i)}>Remove</Button>
* </div>
* ))}
* <Button variant="outline" onClick={() => field.pushValue({ url: '' })}>Add webhook</Button>
* </>
* )}
* </FormArrayField>
*/
function FormArrayField(props) {
const { form, name, validators, listeners, defaultValue, asyncDebounceMs, asyncAlways, label, description, necessityIndicator, children } = props;
const id = `${React.useId()}-${name}`;
const AppField = form.AppField;
return /* @__PURE__ */ jsx(AppField, {
name,
mode: "array",
validators,
listeners,
defaultValue,
asyncDebounceMs,
asyncAlways,
children: (field) => /* @__PURE__ */ jsx(FormFieldShell, {
label,
description,
necessityIndicator,
id,
children: children(field)
})
});
}
//#endregion
//#region src/components/form-textarea-field.tsx
function textareaControl(field, id) {
var _field$state$value;
return { textareaProps: {
id,
value: (_field$state$value = field.state.value) !== null && _field$state$value !== void 0 ? _field$state$value : "",
onChange: (event) => field.handleChange(event.target.value),
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
} };
}
/**
* Multiline text-input field wrapper — same threaded-`form` typing and shared shell as
* `FormSelectField`, with a `textareaProps` bundle. Compose the textarea yourself (rows,
* counters, auto-grow). `name` is restricted to the form's **string** fields.
*
* @example
* <FormTextareaField form={form} name="bio" label="Bio" validators={{ onChange }}>
* {(field, { textareaProps }) => <Textarea {...textareaProps} rows={4} />}
* </FormTextareaField>
*/
function FormTextareaField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: textareaControl }));
}
//#endregion
//#region src/components/form-rich-textarea-field.tsx
function richTextareaControl(field) {
var _field$state$value;
return { richTextareaProps: {
defaultValue: (_field$state$value = field.state.value) !== null && _field$state$value !== void 0 ? _field$state$value : "",
onValueChange: (value) => field.handleChange(value.plainText)
} };
}
/**
* Rich-text field wrapper for the `@aircall/ds` `RichTextarea` — same threaded-`form` typing and
* shared shell (`FieldLabel` / `FieldError` / necessity / layout) as the other `Form*Field`
* wrappers, with a `richTextareaProps` bundle. The field stores the editor's **plain text**
* (`name` is restricted to the form's string fields); read `json`/`markdown` off the value object
* yourself if you need them. Prefer `validators.onChange` (there is no blur event; see
* `RichTextareaControl`).
*
* @example
* <FormRichTextareaField form={form} name="note" label="Note" validators={{ onChange }}>
* {(_field, { richTextareaProps }) => (
* <RichTextarea {...richTextareaProps} aria-label="Note" placeholder="Leave a note…" />
* )}
* </FormRichTextareaField>
*/
function FormRichTextareaField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: richTextareaControl }));
}
//#endregion
//#region src/components/form-radio-group-field.tsx
function radioGroupControl(field, _id) {
return { radioGroupProps: {
value: field.state.value || null,
onValueChange: (value) => field.handleChange(value !== null && value !== void 0 ? value : ""),
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
} };
}
/**
* Radio-group field wrapper — same threaded-`form` typing and shared shell as `FormSelectField`,
* with a `radioGroupProps` bundle. Compose the items yourself. `name` is restricted to the
* form's **string** fields.
*
* @example
* <FormRadioGroupField form={form} name="plan" label="Plan" validators={{ onChange }}>
* {(field, { radioGroupProps }) => (
* <RadioGroup {...radioGroupProps}>
* <RadioGroupItem value="a" />
* </RadioGroup>
* )}
* </FormRadioGroupField>
*/
function FormRadioGroupField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: radioGroupControl }));
}
//#endregion
//#region src/components/form-otp-field.tsx
function otpControl(field, id) {
var _field$state$value;
return { otpProps: {
id,
value: (_field$state$value = field.state.value) !== null && _field$state$value !== void 0 ? _field$state$value : "",
onChange: (value) => field.handleChange(value),
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
} };
}
/**
* One-time-code field wrapper — same threaded-`form` typing and shared shell as `FormSelectField`,
* with an `otpProps` bundle. Compose the slots yourself. `name` is restricted to the form's
* **string** fields.
*
* @example
* <FormOTPField form={form} name="code" label="Code" validators={{ onChange }}>
* {(field, { otpProps }) => <InputOTP maxLength={6} {...otpProps}>{slots}</InputOTP>}
* </FormOTPField>
*/
function FormOTPField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: otpControl }));
}
//#endregion
//#region src/components/form-phone-input-field.tsx
function phoneInputControl(field, id) {
var _field$state$value;
return { phoneInputProps: {
id,
value: (_field$state$value = field.state.value) !== null && _field$state$value !== void 0 ? _field$state$value : "",
onValueChange: (value) => field.handleChange(value),
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
} };
}
/**
* Phone-number field wrapper — same threaded-`form` typing and shared shell as `FormInputField`,
* with a `phoneInputProps` bundle. `name` is restricted to the form's **string** fields.
*
* A validator that has to know the country reads it from wherever the child holds it, which is
* the reason the country is not bound here: it keeps the wrapper the same shape as every other
* one instead of inventing a second binding only this field would have.
*
* @example
* <FormPhoneInputField form={form} name="phone" label="Phone" validators={{ onChange }}>
* {(field, { phoneInputProps }) => (
* <PhoneInput
* {...phoneInputProps}
* country={country}
* onCountryChange={setCountry}
* defaultCountry={user.defaultCountry}
* />
* )}
* </FormPhoneInputField>
*/
function FormPhoneInputField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: phoneInputControl }));
}
//#endregion
//#region src/components/form-switch-field.tsx
const _excluded$5 = ["orientation", "controlPosition"];
function switchControl(field, id) {
return { switchProps: {
id,
checked: field.state.value,
onCheckedChange: (checked) => field.handleChange(checked),
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
} };
}
/**
* Toggle field wrapper — same threaded-`form` typing and shared shell as `FormSelectField`,
* with a `switchProps` bundle. `name` is restricted to the form's **boolean** fields — a
* string/number `name` is a type error.
*
* In a horizontal/responsive layout the switch is a bare, natural-width control beside its label
* (`controlPosition` picks the side — defaults to `'start'`, i.e. switch on the left).
*
* @example
* <FormSwitchField form={form} name="enabled" label="Enabled" validators={{ onChange }}>
* {(field, { switchProps }) => <Switch {...switchProps} />}
* </FormSwitchField>
*/
function FormSwitchField(props) {
const { orientation = "horizontal", controlPosition = "start" } = props, rest = _objectWithoutProperties(props, _excluded$5);
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, rest), {}, {
orientation,
controlPosition,
controlVariant: "inline",
buildControl: switchControl
}));
}
//#endregion
//#region src/components/form-numeric-field.tsx
function numericControl(field, id) {
return { numericProps: {
id,
value: field.state.value,
onValueChange: (value) => field.handleChange(value !== null && value !== void 0 ? value : 0),
onBlur: field.handleBlur,
"aria-invalid": field.state.meta.errors.length > 0 ? true : void 0
} };
}
/**
* Numeric field wrapper — same threaded-`form` typing and shared shell as `FormSelectField`,
* with a `numericProps` bundle for the DS `NumericInput` (prefer it over `Input type="number"`).
* `name` is restricted to the form's **number** fields — a string/boolean `name` is a type error.
*
* @example
* <FormNumericField form={form} name="age" label="Age" validators={{ onChange }}>
* {(field, { numericProps }) => <NumericInput {...numericProps} />}
* </FormNumericField>
*/
function FormNumericField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: numericControl }));
}
//#endregion
//#region src/components/form-slider-field.tsx
function sliderControl(field, id) {
return { sliderProps: {
id,
value: [field.state.value],
onValueChange: (value) => {
var _value$;
return field.handleChange(Array.isArray(value) ? (_value$ = value[0]) !== null && _value$ !== void 0 ? _value$ : 0 : value);
},
onBlur: field.handleBlur
} };
}
/**
* Slider field wrapper — same threaded-`form` typing and shared shell as `FormSelectField`,
* with a `sliderProps` bundle. `name` is restricted to the form's **number** fields; the bundle
* converts to/from the `number[]` the range primitive uses internally.
*
* @example
* <FormSliderField form={form} name="volume" label="Volume" validators={{ onChange }}>
* {(field, { sliderProps }) => <Slider min={0} max={100} step={1} {...sliderProps} />}
* </FormSliderField>
*/
function FormSliderField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: sliderControl }));
}
//#endregion
//#region src/components/form-toggle-group-field.tsx
function toggleGroupControl(field, _id) {
return { toggleGroupProps: {
value: field.state.value ? [field.state.value] : [],
onValueChange: (values) => {
var _values;
return field.handleChange((_values = values[values.length - 1]) !== null && _values !== void 0 ? _values : null);
},
onBlur: field.handleBlur
} };
}
/**
* Single-select toggle-group field wrapper — same threaded-`form` typing and shared shell as
* `FormSelectField`, with a `toggleGroupProps` bundle. `name` is restricted to the form's
* **string | null** fields; the bundle converts to/from the `string[]` the multi-select
* primitive uses internally.
*
* @example
* <FormToggleGroupField form={form} name="align" label="Align" validators={{ onChange }}>
* {(field, { toggleGroupProps }) => (
* <ToggleGroup {...toggleGroupProps}>
* <ToggleGroupItem value="a">A</ToggleGroupItem>
* </ToggleGroup>
* )}
* </FormToggleGroupField>
*/
function FormToggleGroupField(props) {
return /* @__PURE__ */ jsx(FormFieldBase, _objectSpread2(_objectSpread2({}, props), {}, { buildControl: toggleGroupControl }));
}
//#endregion
//#region src/components/setting-card.tsx
const _excluded$4 = [
"className",
"level",
"disabled"
];
const _excluded2$3 = ["className"];
const _excluded3$3 = ["className"];
/**
* Root container for a setting card. Renders the DS `Card`.
*
* By default the level (and therefore the background) is detected automatically:
* a top-level card is level 1 (muted background, borderless); a card nested
* inside another card's `SettingCardContent` becomes level 2 (white background,
* border). Pass `level` to force a specific level — needed for the rare third
* nesting level, which reuses the level-1 look (`level={1}`).
*
* Pass `disabled` to dim the whole card and make its subtree non-interactive;
* the `Switch` or `Button` inside `SettingCardAction` needs no `disabled` of
* its own.
*
* @example
* ```tsx
* <SettingCard>
* <SettingCardHeader>
* <SettingCardTitle>Call recording</SettingCardTitle>
* <SettingCardDescription>Record inbound and outbound calls.</SettingCardDescription>
* <SettingCardAction>
* <Switch checked={enabled} onCheckedChange={setEnabled} />
* </SettingCardAction>
* </SettingCardHeader>
* {enabled && <SettingCardContent>…</SettingCardContent>}
* </SettingCard>
* ```
*
* @example
* ```tsx
* // With a save/discard bar: put the form on the OUTER level and drop
* // `form.CardSaveBar` in as the last child. It reads dirty/submitting/canSubmit
* // from form context, slides in when dirty, and carries `data-slot="card-footer"`
* // so the card flushes it to the edge automatically. No `className` needed.
* <form onSubmit={e => { e.preventDefault(); void form.handleSubmit(); }}>
* <SettingCard>
* <SettingCardHeader>
* <SettingCardTitle>Profile</SettingCardTitle>
* <SettingCardDescription>Update your display name.</SettingCardDescription>
* </SettingCardHeader>
* <SettingCardContent>…</SettingCardContent>
* <form.AppForm>
* <form.CardSaveBar resetLabel="Undo" />
* </form.AppForm>
* </SettingCard>
* </form>
* ```
*/
const SettingCard = React.forwardRef((componentProps, forwardRef) => {
const { className, level, disabled = false } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$4);
return /* @__PURE__ */ jsx(Card, _objectSpread2({
ref: forwardRef,
"data-level": level,
"data-disabled": disabled || void 0,
inert: disabled || void 0,
className: cn("max-w-4xl", level === void 0 && "bg-accent/50 border-transparent [[data-setting-card-content]_&]:border-border [[data-setting-card-content]_&]:bg-card", level === 1 && "bg-accent/50 border-transparent", level === 2 && "border-border bg-card", disabled && "cursor-not-allowed opacity-50 [&_*]:pointer-events-none", className)
}, props));
});
SettingCard.displayName = "SettingCard";
/**
* Header row of a setting card. Wraps DS `CardHeader`, which lays out the
* title/description on the left and the `SettingCardAction` on the right.
*/
const SettingCardHeader = React.forwardRef((componentProps, forwardRef) => /* @__PURE__ */ jsx(CardHeader, _objectSpread2({ ref: forwardRef }, componentProps)));
SettingCardHeader.displayName = "SettingCardHeader";
/**
* Title of a setting card. Wraps DS `CardTitle`, overriding its type ramp so
* weight and size follow the card's level (auto-detected from nesting, like the
* background): level 1 is 18px bold, level 2 (nested) is 16px medium. A forced
* level-1 card that is still nested (the rare 3rd level) keeps the 16px-medium
* title, matching the design guidance for that case.
*/
const SettingCardTitle = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$3);
return /* @__PURE__ */ jsx(CardTitle, _objectSpread2({
ref: forwardRef,
"data-slot": "setting-card-title",
className: cn("text-lg font-bold", "[[data-setting-card-content]_&]:text-base [[data-setting-card-content]_&]:font-medium", className)
}, props));
});
SettingCardTitle.displayName = "SettingCardTitle";
/**
* Supporting description under the title. Wraps DS `CardDescription` (14px muted).
* Inline links (e.g. "Learn more") can be placed inside its children.
*/
const SettingCardDescription = React.forwardRef((componentProps, forwardRef) => /* @__PURE__ */ jsx(CardDescription, _objectSpread2({ ref: forwardRef }, componentProps)));
SettingCardDescription.displayName = "SettingCardDescription";
/**
* Top-right control slot of the header — holds a single control, either a
* `<Switch>` (toggle card) or a `<Button>` (action card). Wraps DS `CardAction`.
* The two never co-occur, so this is one slot, not two.
*/
const SettingCardAction = React.forwardRef((componentProps, forwardRef) => /* @__PURE__ */ jsx(CardAction, _objectSpread2({ ref: forwardRef }, componentProps)));
SettingCardAction.displayName = "SettingCardAction";
/**
* Body of a setting card. Wraps DS `CardContent` and carries the
* `data-setting-card-content` marker that drives nesting detection: any
* `SettingCard` rendered inside here auto-detects level 2.
*
* For progressive disclosure, render this conditionally — mount it only when
* the header's toggle is on — so gated fields never mount while disabled.
*/
const SettingCardContent = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$3);
return /* @__PURE__ */ jsx(CardContent, _objectSpread2({
ref: forwardRef,
"data-setting-card-content": "",
className: cn("flex flex-col gap-4", className)
}, props));
});
SettingCardContent.displayName = "SettingCardContent";
//#endregion
//#region src/components/dashboard-sidebar.tsx
const _excluded$3 = ["className", "style"];
const _excluded2$2 = ["className", "children"];
const _excluded3$2 = ["className", "children"];
const _excluded4$2 = ["className", "onClick"];
const _excluded5$2 = ["className"];
const _excluded6$2 = ["className"];
const _excluded7$2 = ["className"];
const _excluded8$2 = ["className", "render"];
const _excluded9$2 = ["className"];
const _excluded10$2 = ["className"];
const _excluded11$2 = [
"className",
"render",
"isActive",
"disabled",
"icon",
"action",
"children"
];
const _excluded12$2 = ["className"];
const _excluded13$2 = ["className"];
const _excluded14$2 = ["className"];
const _excluded15$2 = [
"className",
"render",
"isActive"
];
/**
* Context provider and flex-row layout wrapping the sidebar and page content;
* renders a ds `SidebarProvider`. Provides the state consumed by `useDashboardSidebar`.
*
* @example
* ```tsx
* <DashboardSidebarProvider>
* <DashboardSidebar>
* <DashboardSidebarContent>
* <DashboardSidebarMenu>
* <DashboardSidebarMenuItem>
* <DashboardSidebarMenuButton icon={<Home />}>Home</DashboardSidebarMenuButton>
* </DashboardSidebarMenuItem>
* </DashboardSidebarMenu>
* </DashboardSidebarContent>
* </DashboardSidebar>
* <DashboardPageContent>…</DashboardPageContent>
* </DashboardSidebarProvider>
* ```
*/
const DashboardSidebarProvider = React.forwardRef((componentProps, forwardRef) => {
const { className, style } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$3);
return /* @__PURE__ */ jsx(SidebarProvider, _objectSpread2({
ref: forwardRef,
className: cn("!min-h-0 !w-auto flex flex-1 min-h-0 gap-2", "[&>[data-slot=tabs]]:flex-1 [&>[data-slot=tabs]]:min-w-0 [&>[data-slot=tabs]]:min-h-0 [&>[data-slot=tabs]]:flex [&>[data-slot=tabs]]:flex-col", className),
style: _objectSpread2({
"--sidebar-width": "14rem",
"--sidebar-width-icon": "3rem"
}, style)
}, props));
});
DashboardSidebarProvider.displayName = "DashboardSidebarProvider";
/**
* Root sidebar column that grows/shrinks with the collapse state; renders a `<div>`.
* Holds `DashboardSidebarHeader`, `DashboardSidebarContent`, and `DashboardSidebarFooter`.
*/
const DashboardSidebar = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$2);
const { state } = useSidebar();
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar",
"data-state": state,
"data-collapsible": state === "collapsed" ? "icon" : "",
className: cn("group/dbs flex flex-col h-full overflow-hidden", "w-(--sidebar-width) transition-[width] duration-200 ease-linear", "data-[collapsible=icon]:w-(--sidebar-width-icon)", className)
}, props), {}, { children }));
});
DashboardSidebar.displayName = "DashboardSidebar";
/**
* Top bar of the sidebar that houses the collapse toggle; renders a `<div>`.
*/
const DashboardSidebarHeader = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$2);
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-header",
className: cn("flex items-start gap-0 p-1 rounded-md shrink-0 justify-end w-full", "group-data-[collapsible=icon]/dbs:justify-center", className)
}, props), {}, { children }));
});
DashboardSidebarHeader.displayName = "DashboardSidebarHeader";
/**
* Collapse/expand toggle rendering a ghost, icon ds `Button`; renders a `<button>`.
* Swaps its icon and aria-label based on the sidebar state.
*/
const DashboardSidebarTrigger = React.forwardRef((componentProps, forwardRef) => {
const { className, onClick } = componentProps, props = _objectWithoutProperties(componentProps, _excluded4$2);
const { toggleSidebar, state } = useSidebar();
return /* @__PURE__ */ jsx(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-trigger",
variant: "ghost",
size: "icon",
className,
onClick: (event) => {
onClick === null || onClick === void 0 || onClick(event);
toggleSidebar();
},
"aria-label": state === "expanded" ? "Collapse sidebar" : "Expand sidebar"
}, props), {}, { children: state === "expanded" ? /* @__PURE__ */ jsx(PanelLeftClose, { className: "size-4" }) : /* @__PURE__ */ jsx(PanelLeftOpen, { className: "size-4" }) }));
});
DashboardSidebarTrigger.displayName = "DashboardSidebarTrigger";
/**
* Scrollable middle section of the sidebar (scrollbar hidden); renders a `<div>`.
*/
const DashboardSidebarContent = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded5$2);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-content",
className: cn("flex flex-1 flex-col overflow-y-auto overflow-x-hidden min-h-0", "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden", className)
}, props));
});
DashboardSidebarContent.displayName = "DashboardSidebarContent";
/**
* Frosted-glass bottom section of the sidebar; renders a `<div>`.
*/
const DashboardSidebarFooter = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded6$2);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-footer",
className: cn("bg-background/80 dark:bg-accent/50 rounded-xl p-1 flex flex-col shrink-0", "items-start w-full", "group-data-[collapsible=icon]/dbs:items-center group-data-[collapsible=icon]/dbs:w-auto", className)
}, props));
});
DashboardSidebarFooter.displayName = "DashboardSidebarFooter";
/**
* Groups a set of menu items (optionally labelled); renders a `<div>`.
*/
const DashboardSidebarGroup = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded7$2);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-group",
className: cn("flex flex-col gap-1 items-start p-1 w-full", className)
}, props));
});
DashboardSidebarGroup.displayName = "DashboardSidebarGroup";
/**
* Section heading for a group, hidden when the sidebar is collapsed; renders a `<div>`
* by default, overridable via the `render` prop.
*/
const DashboardSidebarGroupLabel = React.forwardRef((componentProps, forwardRef) => {
const { className, render } = componentProps, props = _objectWithoutProperties(componentProps, _excluded8$2);
return useRender({
defaultTagName: "div",
props: mergeProps({
ref: forwardRef,
className: cn("truncate text-sm font-medium text-navbar-foreground opacity-90 px-2 py-1.5 rounded-md w-full", "group-data-[collapsible=icon]/dbs:hidden", className)
}, props),
render,
state: { slot: "dashboard-sidebar-group-label" }
});
});
DashboardSidebarGroupLabel.displayName = "DashboardSidebarGroupLabel";
/**
* List container for menu items; renders a `<ul>`.
*/
const DashboardSidebarMenu = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded9$2);
return /* @__PURE__ */ jsx("ul", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-menu",
className: cn("flex w-full min-w-0 flex-col gap-1", className)
}, props));
});
DashboardSidebarMenu.displayName = "DashboardSidebarMenu";
/**
* Wrapper for a single menu row; renders a `<li>` (relative, for badge positioning).
*/
const DashboardSidebarMenuItem = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded10$2);
return /* @__PURE__ */ jsx("li", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-menu-item",
className: cn("relative list-none", className)
}, props));
});
DashboardSidebarMenuItem.displayName = "DashboardSidebarMenuItem";
/**
* Pill-shaped navigation item with a leading icon circle and optional trailing action;
* renders a `<button>` by default, overridable via the `render` prop (e.g. a router `<Link>`).
*/
const DashboardSidebarMenuButton = React.forwardRef((componentProps, forwardRef) => {
const { className, render, isActive = false, disabled = false, icon, action, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded11$2);
const buttonContent = /* @__PURE__ */ jsxs(Fragment, { children: [
icon && /* @__PURE__ */ jsx("span", {
className: cn("size-6 rounded-full bg-background text-foreground flex items-center justify-center shrink-0 overflow-hidden", "[&>svg]:size-4 [&>svg]:shrink-0"),
children: icon
}),
/* @__PURE__ */ jsx("span", {
className: "flex-1 min-w-0 flex items-center gap-2 truncate",
children
}),
action && /* @__PURE__ */ jsx("span", {
className: "flex shrink-0 items-center justify-center size-4 [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=icon]/dbs:hidden",
children: action
})
] });
const ownProps = {
ref: forwardRef,
type: "button",
"data-active": isActive,
className: cn("peer/dbs-button relative flex w-full items-center gap-2 h-10 p-2 rounded-full overflow-hidden", "text-sm font-medium transition-[width,padding,color,background-color] duration-200 ease-linear", disabled ? "cursor-default" : "cursor-pointer hover:bg-sidebar-accent", "focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50", disabled ? "text-muted-foreground" : "text-navbar-foreground", disabled && "opacity-60", isActive && "bg-navbar-accent text-navbar-accent-foreground hover:bg-navbar-accent", "aria-expanded:bg-navbar-accent aria-expanded:text-navbar-accent-foreground aria-expanded:hover:bg-navbar-accent", className),
children: buttonContent
};
return useRender({
defaultTagName: "button",
props: mergeProps(ownProps, props),
render,
state: {
slot: "dashboard-sidebar-menu-button",
active: isActive
}
});
});
DashboardSidebarMenuButton.displayName = "DashboardSidebarMenuButton";
/**
* Right-aligned slot (ml-auto) for trailing content inside a menu row; renders a `<div>`.
*/
const DashboardSidebarMenuAddon = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded12$2);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-menu-addon",
className: cn("ml-auto shrink-0 inline-flex items-center", className)
}, props));
});
DashboardSidebarMenuAddon.displayName = "DashboardSidebarMenuAddon";
/**
* Notification count badge for a menu item; renders a ds `CounterBadge` positioned
* absolutely at the row's right edge (moves to the icon corner when collapsed).
* Place it inside `DashboardSidebarMenuItem`, as a sibling of the button.
*/
const DashboardSidebarMenuBadge = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded13$2);
return /* @__PURE__ */ jsx(CounterBadge, _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-menu-badge",
className: cn("pointer-events-none absolute select-none", "right-2 top-1/2 -translate-y-1/2", "group-data-[collapsible=icon]/dbs:right-[-2px] group-data-[collapsible=icon]/dbs:top-[-3.5px] group-data-[collapsible=icon]/dbs:translate-y-0", "peer-data-[active=true]/dbs-button:bg-navbar-icon-bg peer-data-[active=true]/dbs-button:text-navbar-foreground", className)
}, props));
});
DashboardSidebarMenuBadge.displayName = "DashboardSidebarMenuBadge";
/**
* Popover-style panel (white card with border and shadow) opening beside a trigger;
* renders a `<div>`. Hosts `DashboardSidebarSubmenuItem` and `DashboardSidebarSubmenuSeparator`.
*/
const DashboardSidebarSubmenu = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded14$2);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "dashboard-sidebar-submenu",
className: cn("bg-popover border border-border shadow-md rounded-md p-1", "flex flex-col w-[238px]", className)
}, props));
});
DashboardSidebarSubmenu.displayName = "DashboardSidebarSubmenu";
/**
* One row inside a submenu; renders a `<div>` by default, overridable via the `render`
* prop (e.g. an `<a>` or router `<Link>` for navigation).
*/
const DashboardSidebarSubmenuItem = React.forwardRef((componentProps, forwardRef) => {
const { className, render, isActive = false } = componentProps, props = _objectWithoutProperties(componentProps, _excluded15$2);
return useRender({
defaultTagName: "div",
props: mergeProps({
ref: forwardRef,
className: cn("flex items-center gap-2 p-2 rounded-md cursor-pointer", "text-sm text-foreground leading-snug", "hover:bg-accent transition-colors", isActive && "bg-accent font-medium", className)
}, props),
render,
state: {
slot: "dashboard-sidebar-submenu-item",
active: isActive
}
});
});
DashboardSidebarSubmenuItem.displayName = "DashboardSidebarSubmenuItem";
/**
* Thin horizontal divider between submenu items; renders a `<div>`.
*/
const DashboardSidebarSubmenuSeparator = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps;
return /* @__PURE__ */ jsx("div", {
ref: forwardRef,
"data-slot": "dashboard-sidebar-submenu-separator",
className: cn("h-px bg-border -mx-1 my-1", className)
});
});
DashboardSidebarSubmenuSeparator.displayName = "DashboardSidebarSubmenuSeparator";
//#endregion
//#region src/components/dashboard-sidebar-nav.tsx
function SubmenuItems({ items, renderLink }) {
const sections = [];
let current = [];
let currentLabel;
const flush = () => {
if (current.length > 0) sections.push({
kind: "group",
label: currentLabel,
links: current
});
current = [];
currentLabel = void 0;
};
for (const item of items) if (item.kind === "separator") {
flush();
sections.push({ kind: "separator" });
} else if (item.kind === "label") {
flush();
currentLabel = item.label;
} else current.push(item);
flush();
const renderLink_ = (item, key) => {
const linkEl = item.href ? renderLink ? renderLink(item.href) : /* @__PURE__ */ jsx("a", { href: item.href }) : void 0;
return /* @__PURE__ */ jsxs(DropdownMenuItem, {
"data-active": item.isActive || void 0,
render: linkEl,
children: [item.label, item.badge && /* @__PURE__ */ jsx(DropdownMenuAddon, { children: item.badge })]
}, key);
};
return /* @__PURE__ */ jsx(Fragment, { children: sections.map((section, si) => {
if (section.kind === "separator") return /* @__PURE__ */ jsx(DropdownMenuSeparator, {}, si);
if (section.label !== void 0) return /* @__PURE__ */ jsxs(DropdownMenuGroup, { children: [/* @__PURE__ */ jsx(DropdownMenuLabel, { children: section.label }), section.links.map((item, ii) => renderLink_(item, `${si}-${ii}`))] }, si);
return section.links.map((item, ii) => renderLink_(item, `${si}-${ii}`));
}) });
}
function NavItem({ item, renderLink }) {
var _item$action;
const trailing = item.badge ? /* @__PURE__ */ jsx(DashboardSidebarMenuAddon, { children: item.badge }) : null;
if (item.kind === "submenu" && !item.disabled) {
const chevron = item.action !== void 0 ? item.action : /* @__PURE__ */ jsx(ChevronRight, {});
return /* @__PURE__ */ jsx(DashboardSidebarMenuItem, { children: /* @__PURE__ */ jsxs(DropdownMenu, { children: [/* @__PURE__ */ jsxs(DropdownMenuTrigger, {
render: /* @__PURE__ */ jsx(DashboardSidebarMenuButton, {
icon: item.icon,
isActive: item.isActive,
action: chevron
}),
children: [item.label, trailing]
}), /* @__PURE__ */ jsx(DropdownMenuContent, {
side: "right",
sideOffset: 8,
align: "start",
className: "w-56",
children: /* @__PURE__ */ jsx(SubmenuItems, {
items: item.items,
renderLink
})
})] }) });
}
const action = item.kind === "submenu" ? (_item$action = item.action) !== null && _item$action !== void 0 ? _item$action : null : item.action;
const hasCount = (item.kind === "link" || item.kind === "action") && item.count !== void 0;
const buttonEl = item.kind === "link" ? /* @__PURE__ */ jsx(DashboardSidebarMenuButton, {
icon: item.icon,
isActive: item.isActive,
disabled: item.disabled,
action,
render: item.disabled ? void 0 : renderLink ? renderLink(item.href) : /* @__PURE__ */ jsx("a", { href: item.href })
}) : /* @__PURE__ */ jsx(DashboardSidebarMenuButton, {
icon: item.icon,
isActive: item.isActive,
disabled: item.disabled,
action,
onClick: item.kind === "action" ? item.onClick : void 0
});
const countBadge = hasCount ? /* @__PURE__ */ jsx(DashboardSidebarMenuBadge, { children: item.count }) : null;
if (item.tooltip) return /* @__PURE__ */ jsxs(DashboardSidebarMenuItem, { children: [/* @__PURE__ */ jsxs(Tooltip, { children: [/* @__PURE__ */ jsxs(TooltipTrigger, {
render: buttonEl,
children: [item.label, trailing]
}), /* @__PURE__ */ jsx(TooltipContent, {
side: "right",
sideOffset: 8,
className: "max-w-64",
children: item.tooltip
})] }), countBadge] });
return /* @__PURE__ */ jsxs(DashboardSidebarMenuItem, { children: [React.cloneElement(buttonEl, {}, item.label, trailing), countBadge] });
}
/**
* Data-driven sidebar navigation. Maps a `groups` array into the
* `DashboardSidebar` primitives, rendering each item as a link, a dropdown
* submenu, or an action button (with optional badges, counts, and tooltips).
*
* Renders a fragment of `DashboardSidebarGroup` sections — it has no wrapper
* element of its own, so place it inside a `DashboardSidebarContent`.
*
* @example
* <DashboardSidebarNav
* groups={groups}
* renderLink={href => <Link to={href} />}
* />
*/
function DashboardSidebarNav({ groups, renderLink }) {
return /* @__PURE__ */ jsx(Fragment, { children: groups.map((group) => /* @__PURE__ */ jsxs(DashboardSidebarGroup, { children: [group.label && /* @__PURE__ */ jsx(DashboardSidebarGroupLabel, { children: group.label }), /* @__PURE__ */ jsx(DashboardSidebarMenu, { children: group.items.map((item) => /* @__PURE__ */ jsx(NavItem, {
item,
renderLink
}, item.id)) })] }, group.id)) });
}
//#endregion
//#region src/components/days-picker.tsx
const _excluded$2 = [
"value",
"defaultValue",
"onValueChange",
"disabled",
"labelFormat",
"testIdPrefix"
];
/** Fixed Mon->Sun order. Consumer cannot reorder. */
const DAY_ORDER = [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun"
];
/** Maps each day to its blocks i18n key for the single-letter visible label. */
const DAY_LABEL_KEY = {
mon: "daysPicker.mon",
tue: "daysPicker.tue",
wed: "daysPicker.wed",
thu: "daysPicker.thu",
fri: "daysPicker.fri",
sat: "daysPicker.sat",
sun: "daysPicker.sun"
};
const REFERENCE_MONDAY_UTC = Date.UTC(2024, 0, 1, 12);
const DAY_MS = 864e5;
/** Maps each day to its blocks i18n key for the full-name accessible label. */
const DAY_ARIA_KEY = {
mon: "daysPicker.mon.full",
tue: "daysPicker.tue.full",
wed: "daysPicker.wed.full",
thu: "daysPicker.thu.full",
fri: "daysPicker.fri.full",
sat: "daysPicker.sat.full",
sun: "daysPicker.sun.full"
};
/**
* A row of 7 independently-toggleable day buttons (Mon -> Sun), built directly on
* the DS `ToggleGroup` primitive (multiple mode, always multi-select).
*
* Localizes internally via the blocks i18n instance (`useDsTranslation('blocks')`):
* single-letter visible labels and full-name `aria-label` values are derived from
* `daysPicker.*` keys in the blocks i18n namespace. Supports en / fr / es / de / it.
*
* Supports controlled (`value` + `onValueChange`) and uncontrolled (`defaultValue`) modes,
* mirroring `ToggleGroup`.
*
* @example
* ```tsx
* import { DaysPicker, type DayOfWeek } from '@aircall/blocks';
*
* // Localized out of the box -- no labels prop needed
* const [days, setDays] = React.useState<DayOfWeek[]>(['mon', 'wed', 'fri']);
* <DaysPicker value={days} onValueChange={setDays} />
* ```
*/
const DaysPicker = React.forwardRef((componentProps, forwardRef) => {
const { value, defaultValue, onValueChange, disabled, labelFormat = "letter", testIdPrefix = "days-picker" } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$2);
const t = useDsTranslation("blocks");
const locale = useDsLocale();
const labels = React.useMemo(() => {
if (labelFormat === "letter") return Object.fromEntries(DAY_ORDER.map((d) => [d, t(DAY_LABEL_KEY[d])]));
const format = new Intl.DateTimeFormat(locale, {
weekday: "short",
timeZone: "UTC"
});
return Object.fromEntries(DAY_ORDER.map((d, index) => [d, format.format(REFERENCE_MONDAY_UTC + index * DAY_MS)]));
}, [
t,
locale,
labelFormat
]);
const ariaLabels = React.useMemo(() => Object.fromEntries(DAY_ORDER.map((d) => [d, t(DAY_ARIA_KEY[d])])), [t]);
const handleValueChange = React.useCallback((newValues) => {
onValueChange === null || onValueChange === void 0 || onValueChange(newValues);
}, [onValueChange]);
return /* @__PURE__ */ jsx(ToggleGroup, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "days-picker",
multiple: true,
spacing: 2,
value,
defaultValue,
onValueChange: handleValueChange,
disabled
}, props), {}, { children: DAY_ORDER.map((day) => /* @__PURE__ */ jsx(ToggleGroupItem, {
value: day,
"data-day": day,
"data-testid": `${testIdPrefix}-${day}`,
"aria-label": ariaLabels[day],
children: labels[day]
}, day)) }));
});
DaysPicker.displayName = "DaysPicker";
//#endregion
//#region src/components/wizard.tsx
const _excluded$1 = ["className", "children"];
const _excluded2$1 = ["className"];
const _excluded3$1 = ["className"];
const _excluded4$1 = ["className"];
const _excluded5$1 = ["className"];
const _excluded6$1 = ["className", "children"];
const _excluded7$1 = ["className", "children"];
const _excluded8$1 = ["className", "children"];
const _excluded9$1 = ["children"];
const _excluded10$1 = ["className"];
const _excluded11$1 = ["children"];
const _excluded12$1 = ["className"];
const _excluded13$1 = [
"children",
"onBeforeBack",
"onClick",
"disabled",
"variant"
];
const _excluded14$1 = [
"children",
"onClick",
"disabled",
"variant"
];
const _excluded15$1 = [
"children",
"onBeforeNext",
"onClick",
"disabled"
];
const _excluded16$1 = [
"children",
"always",
"onClick",
"disabled"
];
const WizardContext = React.createContext(void 0);
/**
* Reads the current `Wizard` context: step list, position, navigation and status.
* Use it to drive your own controls when `WizardBack` / `WizardNext` and friends
* do not fit - that is a first-class option, not an escape hatch.
*
* @throws if used outside a `Wizard`.
*/
function useWizard() {
const context = React.useContext(WizardContext);
if (!context) throw new Error("useWizard must be used within a Wizard");
return context;
}
/**
* Root of a wizard flow. Renders no DOM: it provides step state to `WizardLayout`,
* to the navigation controls, and to anything else on the page that needs the
* flow's position (typically a Publish button in the page header).
*
* @example
* ```tsx
* <Wizard steps={steps} onComplete={() => form.handleSubmit()}>
* <DashboardStandalonePage>…<WizardLayout>…</WizardLayout>…</DashboardStandalonePage>
* </Wizard>
* ```
*/
function Wizard(componentProps) {
var _steps$, _steps$index;
const { steps, value, defaultValue, onValueChange, onComplete, children } = componentProps;
const [selectedId = "", setSelectedId] = useControllableState({
prop: value,
defaultProp: defaultValue !== null && defaultValue !== void 0 ? defaultValue : (_steps$ = steps[0]) === null || _steps$ === void 0 ? void 0 : _steps$.id,
onChange: onValueChange
});
const [status, setStatus] = React.useState("idle");
const [skipped, setSkipped] = React.useState(() => /* @__PURE__ */ new Set());
const lastIndexRef = React.useRef(0);
const selectedIndex = steps.findIndex((step) => step.id === selectedId);
const index = selectedIndex === -1 ? Math.min(lastIndexRef.current, steps.length - 1) : selectedIndex;
lastIndexRef.current = Math.max(index, 0);
const goTo = React.useCallback((id) => setSelectedId(id), [setSelectedId]);
const clampedId = (_steps$index = steps[index]) === null || _steps$index === void 0 ? void 0 : _steps$index.id;
React.useEffect(() => {
if (selectedIndex === -1 && clampedId !== void 0) setSelectedId(clampedId);
}, [
selectedIndex,
clampedId,
setSelectedId
]);
if (skipped.size > 0) {
const visibleIds = new Set(steps.map((step) => step.id));
const live = [...skipped].filter((id) => visibleIds.has(id));
if (live.length !== skipped.size) setSkipped(new Set(live));
}
const positionRef = React.useRef({
steps,
index
});
positionRef.current = {
steps,
index
};
const getActiveStepId = React.useCallback(() => {
var _list$at;
const { steps: list, index: at } = positionRef.current;
return (_list$at = list[at]) === null || _list$at === void 0 ? void 0 : _list$at.id;
}, []);
const back = React.useCallback(() => {
const { steps: list, index: at } = positionRef.current;
const previous = list[at - 1];
if (previous) setSelectedId(previous.id);
}, [setSelectedId]);
const advance = React.useCallback((asSkipped) => {
const { steps: list, index: at } = positionRef.current;
const current = list[at];
const following = list[at + 1];
if (current) setSkipped((previous) => {
if (previous.has(current.id) === asSkipped) return previous;
const draft = new Set(previous);
if (asSkipped) draft.add(current.id);
else draft.delete(current.id);
return draft;
});
if (following) setSelectedId(following.id);
}, [setSelectedId]);
const next = React.useCallback(() => advance(false), [advance]);
const skip = React.useCallback(() => advance(true), [advance]);
const complete = React.useCallback(() => onComplete === null || onComplete === void 0 ? void 0 : onComplete(), [onComplete]);
const activeStep = steps[index];
const contextValue = React.useMemo(() => activeStep && {
steps,
activeStep,
activeId: activeStep.id,
index,
count: steps.length,
isFirst: index === 0,
isLast: index === steps.length - 1,
status,
setStatus,
getActiveStepId,
skipped,
back,
next,
skip,
goTo,
complete
}, [
activeStep,
steps,
index,
status,
getActiveStepId,
skipped,
back,
next,
skip,
goTo,
complete
]);
if (!contextValue) throw new Error("Wizard requires at least one step");
return /* @__PURE__ */ jsx(WizardContext.Provider, {
value: contextValue,
children
});
}
/**
* Grid that arranges `WizardMain`, `WizardAside` and `WizardActions`, and mounts the
* underlying ds `Stepper`; renders a `<div>` (the grid) inside the Stepper's `<div>`.
* Place it inside the page's scroll area.
*/
const WizardLayout = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded$1);
const { steps, activeId, goTo } = useWizard();
return /* @__PURE__ */ jsx(Stepper, {
steps,
value: activeId,
onValueChange: goTo,
className: "@container/wizard",
children: /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "wizard-layout",
className: cn("grid grid-cols-1 content-start gap-6", "@7xl/wizard:grid-rows-[1fr_auto] @7xl/wizard:gap-x-10", "@7xl/wizard:has-[[data-slot=wizard-aside]]:mx-auto @7xl/wizard:has-[[data-slot=wizard-aside]]:max-w-[1336px] @7xl/wizard:has-[[data-slot=wizard-aside]]:grid-cols-[minmax(0,1fr)_400px]", className)
}, props), {}, { children }))
});
});
WizardLayout.displayName = "WizardLayout";
function scrollableAncestor(from) {
let node = from;
while (node) {
const { overflowY } = getComputedStyle(node);
if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight) return node;
node = node.parentElement;
}
return null;
}
/**
* The step column: progress, step header, step bodies. Scrolls independently of
* `WizardAside` on wide layouts and resets to the top on step change; renders a `<div>`.
*/
const WizardMain = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2$1);
const { activeId } = useWizard();
const innerRef = React.useRef(null);
const composedRef = useComposedRefs(forwardRef, innerRef);
React.useEffect(() => {
var _scrollableAncestor;
(_scrollableAncestor = scrollableAncestor(innerRef.current)) === null || _scrollableAncestor === void 0 || _scrollableAncestor.scrollTo({ top: 0 });
}, [activeId]);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: composedRef,
"data-slot": "wizard-main",
className: cn("mx-auto flex w-full max-w-4xl flex-col gap-6", "@7xl/wizard:col-start-1 @7xl/wizard:row-start-1", className)
}, props));
});
WizardMain.displayName = "WizardMain";
/**
* Optional secondary column for contextual help or a summary card; renders a `<div>`.
* Put any ds `Card` inside it.
*
* On wide layouts it sticks to the top of the nearest scrolling ancestor, so it needs the
* wizard to sit inside a scroll area (`DashboardStandalonePageMain` is one). Without one
* it simply scrolls with the page.
*/
const WizardAside = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3$1);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "wizard-aside",
className: cn("mx-auto flex w-full max-w-4xl flex-col gap-6", "@7xl/wizard:sticky @7xl/wizard:top-0 @7xl/wizard:col-start-2 @7xl/wizard:row-span-2 @7xl/wizard:row-start-1 @7xl/wizard:mx-0 @7xl/wizard:max-w-none @7xl/wizard:self-start", className)
}, props));
});
WizardAside.displayName = "WizardAside";
/**
* Action row for the step: Back on the left, the rest on the right; renders a `<div>`.
* Sits after the step content, inside the page's single scroll area.
*/
const WizardActions = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded4$1);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "wizard-actions",
className: cn("mx-auto flex w-full max-w-4xl items-center justify-end gap-2", "[&>[data-slot=wizard-back]]:me-auto", "@7xl/wizard:col-start-1 @7xl/wizard:row-start-2", className)
}, props));
});
WizardActions.displayName = "WizardActions";
/**
* Progress segments plus the "Step x of y" line, both derived from the wizard's
* step list; renders a `<div>`. Non-interactive by design: it reports position,
* it is not navigation. Must sit inside `WizardLayout`, which mounts the ds `Stepper`.
*/
const WizardProgress = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded5$1);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "wizard-progress",
className: cn(className)
}, props), {}, { children: [/* @__PURE__ */ jsx(StepperProgress, {}), /* @__PURE__ */ jsx(StepperCounter, {})] }));
});
WizardProgress.displayName = "WizardProgress";
/**
* Title and description of the active step; renders a `<div>`. With no children it
* renders `WizardStepTitle` and `WizardStepDescription` from the step definition.
*/
const WizardStepHeader = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded6$1);
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "wizard-step-header",
className: cn("flex flex-col gap-0.5", className)
}, props), {}, { children: children !== null && children !== void 0 ? children : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(WizardStepTitle, {}), /* @__PURE__ */ jsx(WizardStepDescription, {})] }) }));
});
WizardStepHeader.displayName = "WizardStepHeader";
/**
* Heading of the active step, and the focus target when the step changes; renders an `<h2>`.
* Falls back to `activeStep.title` when given no children.
*/
const WizardStepTitle = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded7$1);
const { activeStep, activeId } = useWizard();
const innerRef = React.useRef(null);
const hasRenderedRef = React.useRef(false);
const composedRef = useComposedRefs(forwardRef, innerRef);
React.useEffect(() => {
var _innerRef$current;
if (!hasRenderedRef.current) {
hasRenderedRef.current = true;
return;
}
(_innerRef$current = innerRef.current) === null || _innerRef$current === void 0 || _innerRef$current.focus();
}, [activeId]);
return /* @__PURE__ */ jsx("h2", _objectSpread2(_objectSpread2({
ref: composedRef,
tabIndex: -1,
"data-slot": "wizard-step-title",
className: cn("text-lg leading-tight font-bold text-foreground outline-none", className)
}, props), {}, { children: children !== null && children !== void 0 ? children : activeStep.title }));
});
WizardStepTitle.displayName = "WizardStepTitle";
/**
* Sub-heading of the active step; renders a `<p>`, or nothing when the step has no
* description and no children.
*/
const WizardStepDescription = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded8$1);
const { activeStep } = useWizard();
const content = children !== null && children !== void 0 ? children : activeStep.description;
if (!content) return null;
return /* @__PURE__ */ jsx("p", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "wizard-step-description",
className: cn("text-sm text-muted-foreground", className)
}, props), {}, { children: content }));
});
WizardStepDescription.displayName = "WizardStepDescription";
const WizardSectionContext = React.createContext(void 0);
/**
* One section of a step: a ds `Card` exposed as a labelled group; renders a `<div>`.
* Compose it with `WizardSectionHeader` / `WizardSectionTitle` / `WizardSectionDescription`
* / `WizardSectionContent`, or with the ds `Card` parts directly when you do not need
* the heading - in that case it stays a plain card rather than a labelled group, since
* a group with no accessible name is noise. Pass your own `aria-label` to name one
* anyway.
*
* @example
* ```tsx
* <WizardSection>
* <WizardSectionHeader>
* <WizardSectionTitle>Campaign identity</WizardSectionTitle>
* <WizardSectionDescription>Name and describe it.</WizardSectionDescription>
* </WizardSectionHeader>
* <WizardSectionContent>…fields…</WizardSectionContent>
* </WizardSection>
* ```
*/
const WizardSection = React.forwardRef((componentProps, forwardRef) => {
const { children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded9$1);
const titleId = React.useId();
const [titleIds, setTitleIds] = React.useState(() => /* @__PURE__ */ new Set());
const contextValue = React.useMemo(() => ({
titleId,
registerTitle: (id, present) => setTitleIds((previous) => {
if (previous.has(id) === present) return previous;
const draft = new Set(previous);
if (present) draft.add(id);
else draft.delete(id);
return draft;
})
}), [titleId]);
const hasOwnLabel = props["aria-label"] != null || props["aria-labelledby"] != null;
const [labelledBy] = titleIds;
const isLabelled = labelledBy !== void 0 || hasOwnLabel;
return /* @__PURE__ */ jsx(WizardSectionContext.Provider, {
value: contextValue,
children: /* @__PURE__ */ jsx(Card, _objectSpread2(_objectSpread2(_objectSpread2(_objectSpread2({ ref: forwardRef }, isLabelled && { role: "group" }), labelledBy !== void 0 && !hasOwnLabel && { "aria-labelledby": labelledBy }), props), {}, { children }))
});
});
WizardSection.displayName = "WizardSection";
/** Header row of a section; renders the ds `CardHeader` `<div>`. */
const WizardSectionHeader = CardHeader;
/**
* Heading of a section, and the label its group is named by; renders an `<h3>` carrying
* the ds card-title styling, so `CardHeader`'s layout rules still apply.
*/
const WizardSectionTitle = React.forwardRef((componentProps, forwardRef) => {
var _props$id;
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded10$1);
const section = React.useContext(WizardSectionContext);
const registerTitle = section === null || section === void 0 ? void 0 : section.registerTitle;
const headingId = (_props$id = props.id) !== null && _props$id !== void 0 ? _props$id : section === null || section === void 0 ? void 0 : section.titleId;
React.useEffect(() => {
if (!registerTitle || headingId === void 0) return;
registerTitle(headingId, true);
return () => registerTitle(headingId, false);
}, [registerTitle, headingId]);
return /* @__PURE__ */ jsx("h3", _objectSpread2({
ref: forwardRef,
id: headingId,
"data-slot": "card-title",
className: cn("text-base leading-snug font-medium", className)
}, props));
});
WizardSectionTitle.displayName = "WizardSectionTitle";
/** Supporting copy under a section title; renders the ds `CardDescription` `<div>`. */
const WizardSectionDescription = CardDescription;
/**
* Body of a section; renders the ds `CardContent` `<div>`. Use `WizardSectionFields`
* when the body is a form.
*/
const WizardSectionContent = CardContent;
/** Body of a section made of form fields, wrapped in a ds `FieldGroup`; renders a `<div>`. */
const WizardSectionFields = React.forwardRef((componentProps, forwardRef) => {
const { children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded11$1);
return /* @__PURE__ */ jsx(CardContent, _objectSpread2(_objectSpread2({ ref: forwardRef }, props), {}, { children: /* @__PURE__ */ jsx(FieldGroup, { children }) }));
});
WizardSectionFields.displayName = "WizardSectionFields";
/**
* Body of one step, shown when its `value` matches the active step; renders a `<div>`
* (or nothing when inactive). Put the step's `WizardSection`s inside it. Must sit inside
* `WizardLayout`, which mounts the ds `Stepper`.
*/
const WizardStep = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded12$1);
return /* @__PURE__ */ jsx(StepperContent, _objectSpread2({
ref: forwardRef,
"data-slot": "wizard-step",
className: cn("flex flex-col gap-6", className)
}, props));
});
WizardStep.displayName = "WizardStep";
function useNavGuard() {
const { setStatus, getActiveStepId } = useWizard();
const [pending, setPending] = React.useState(false);
const [guardError, setGuardError] = React.useState(null);
const run = React.useCallback(function() {
var _ref = _asyncToGenerator(function* (guard, navigate) {
if (!guard) {
navigate();
return;
}
const startedOn = getActiveStepId();
setPending(true);
setStatus("pending");
try {
if ((yield guard()) && getActiveStepId() === startedOn) navigate();
} catch (error) {
setGuardError(() => error);
} finally {
setPending(false);
setStatus("idle");
}
});
return function(_x, _x2) {
return _ref.apply(this, arguments);
};
}(), [setStatus, getActiveStepId]);
if (guardError) throw guardError;
return {
pending,
run
};
}
/**
* Back button, disabled on the first step; renders a ds `Button` (ghost). Runs no
* validation unless you pass `onBeforeBack` - rule 3 is that going back never
* re-triggers validation.
*/
const WizardBack = React.forwardRef((componentProps, forwardRef) => {
const { children, onBeforeBack, onClick, disabled, variant = "ghost" } = componentProps, props = _objectWithoutProperties(componentProps, _excluded13$1);
const { isFirst, back, status } = useWizard();
const { pending, run } = useNavGuard();
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsxs(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
type: "button",
variant,
"data-slot": "wizard-back",
disabled: disabled || isFirst || status === "pending",
onClick: (event) => {
onClick === null || onClick === void 0 || onClick(event);
if (event.defaultPrevented) return;
run(onBeforeBack, back);
}
}, props), {}, { children: [pending ? /* @__PURE__ */ jsx(Spinner, {}) : /* @__PURE__ */ jsx(ArrowLeft, {}), children !== null && children !== void 0 ? children : t("wizard.back")] }));
});
WizardBack.displayName = "WizardBack";
/**
* Skip button; renders a ds `Button` (ghost) only when the active step is `optional`,
* and nothing otherwise. Skipping never validates and never affects other steps: the
* step id lands in `useWizard().skipped` so a completeness check can exclude it.
*/
const WizardSkip = React.forwardRef((componentProps, forwardRef) => {
const { children, onClick, disabled, variant = "ghost" } = componentProps, props = _objectWithoutProperties(componentProps, _excluded14$1);
const { activeStep, skip, status } = useWizard();
const t = useDsTranslation("blocks");
if (!activeStep.optional) return null;
return /* @__PURE__ */ jsx(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
type: "button",
variant,
"data-slot": "wizard-skip",
disabled: disabled || status === "pending",
onClick: (event) => {
onClick === null || onClick === void 0 || onClick(event);
if (event.defaultPrevented) return;
skip();
}
}, props), {}, { children: children !== null && children !== void 0 ? children : t("wizard.skip") }));
});
WizardSkip.displayName = "WizardSkip";
/**
* Next button; renders a ds `Button`, and nothing on the last step (where
* `WizardComplete` takes over). Pass `onBeforeNext` to gate the move: returning
* `false` keeps the user here, which is how validation errors get surfaced by the
* step's own fields rather than by the wizard.
*
* @example
* ```tsx
* <WizardNext onBeforeNext={async () => (await form.validateAllFields('submit')).length === 0} />
* ```
*/
const WizardNext = React.forwardRef((componentProps, forwardRef) => {
const { children, onBeforeNext, onClick, disabled } = componentProps, props = _objectWithoutProperties(componentProps, _excluded15$1);
const { isLast, next, status } = useWizard();
const { pending, run } = useNavGuard();
const t = useDsTranslation("blocks");
if (isLast) return null;
return /* @__PURE__ */ jsxs(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
type: "button",
"data-slot": "wizard-next",
disabled: disabled || status === "pending",
onClick: (event) => {
onClick === null || onClick === void 0 || onClick(event);
if (event.defaultPrevented) return;
run(onBeforeNext, next);
}
}, props), {}, { children: [children !== null && children !== void 0 ? children : t("wizard.next"), pending ? /* @__PURE__ */ jsx(Spinner, {}) : /* @__PURE__ */ jsx(ArrowRight, {})] }));
});
WizardNext.displayName = "WizardNext";
/**
* Primary CTA that finishes the flow (Publish, Launch, Create); renders a ds `Button`.
* Shows on the last step only, unless `always` is set - the page header instance
* passes `always` so the CTA is present throughout the flow. Its enabled state is
* yours: pass `disabled`, since only the product knows what "complete" means.
*/
const WizardComplete = React.forwardRef((componentProps, forwardRef) => {
const { children, always, onClick, disabled } = componentProps, props = _objectWithoutProperties(componentProps, _excluded16$1);
const { isLast, complete, status } = useWizard();
if (!always && !isLast) return null;
return /* @__PURE__ */ jsx(Button, _objectSpread2(_objectSpread2({
ref: forwardRef,
type: "button",
"data-slot": "wizard-complete",
disabled: disabled || status === "pending",
onClick: (event) => {
onClick === null || onClick === void 0 || onClick(event);
if (event.defaultPrevented) return;
complete();
}
}, props), {}, { children }));
});
WizardComplete.displayName = "WizardComplete";
//#endregion
//#region src/components/bubble/bubble.tsx
const _excluded = [
"className",
"align",
"variant",
"clickTarget",
"children"
];
const _excluded2 = ["className", "children"];
const _excluded3 = ["className"];
const _excluded4 = ["className"];
const _excluded5 = ["className"];
const _excluded6 = [
"className",
"action",
"children"
];
const _excluded7 = ["className"];
const _excluded8 = ["className"];
const _excluded9 = ["className"];
const _excluded10 = ["className"];
const _excluded11 = [
"className",
"icon",
"children"
];
const _excluded12 = ["className"];
const _excluded13 = [
"className",
"maxVisible",
"children"
];
const _excluded14 = [
"className",
"kind",
"src",
"alt",
"fileName",
"label",
"onClick",
"render"
];
const _excluded15 = [
"className",
"children",
"onNext",
"onPrevious",
"onKeyDown",
"showCloseButton"
];
const _excluded16 = ["className"];
const _excluded17 = ["className", "children"];
const _excluded18 = [
"className",
"avatar",
"name",
"fileName",
"timestamp"
];
const _excluded19 = ["className"];
const _excluded20 = ["className"];
const _excluded21 = ["className", "children"];
const _excluded22 = [
"className",
"kind",
"src",
"alt",
"fileName",
"selected",
"aria-label"
];
const _excluded23 = [
"className",
"kind",
"src",
"alt",
"fileName",
"label"
];
const _excluded24 = [
"className",
"author",
"children"
];
const _excluded25 = ["className"];
const _excluded26 = ["className"];
const _excluded27 = [
"className",
"icon",
"children"
];
const _excluded28 = ["className"];
const _excluded29 = ["className"];
const _excluded30 = ["className"];
const _excluded31 = ["className"];
const _excluded32 = [
"className",
"render",
"aria-label"
];
const _excluded33 = ["className"];
const _excluded34 = ["className"];
const _excluded35 = ["className"];
/**
* `class-variance-authority` config for {@link Bubble}. `variant` (`neutral`
* | `info` | `critical` | `success`, default `neutral`) sets `BubbleContent`'s
* surface background/border via a descendant selector, and cascades into
* `BubbleIcon`'s fill and `BubbleTitle`'s text colour the same way - moved
* here from `BubbleContent` itself (spec §10) so the `data-variant` the root
* `Bubble` carries is readable by every sibling of `BubbleContent`
* (`BubbleSenderLabel`, `BubbleQuickActions`, `BubbleHint`, `BubbleMeta`) via
* e.g. an `in-data-[variant=critical]:` rule - the same idiom
* `BubbleSenderLabel` already uses for `in-data-[align=end]` - without any of
* those siblings inheriting the tint themselves, since the selectors below
* only ever target `[data-slot=bubble-content]`/`bubble-icon`/`bubble-title`.
* Colours are Figma-literal, not `Alert`-derived (spec §12 - a Phase 6 audit
* reversal). Every variant's border is the flat neutral `border-border`
* (`base/border` #e4e6ea), because that is the token Figma binds on every
* message, AIVA and inbound-call state; the earlier tinted `border-info/35` /
* `border-success/35` came from `alertVariants`, not from this node, and
* matched nothing in it. `BubbleIcon` is the variant token at 40% (`bg-*\/40`)
* with the glyph in that same token at full strength, which is how Figma paints
* it (a white 60% film - `alpha/40` - over a solid variant fill); expressing it
* as alpha-on-a-token rather than the previous hardcoded
* `rgba(255,255,255,0.16)` film is also what makes it survive a theme flip,
* since a literal cannot.
*/
const bubbleVariants = cva("group/bubble relative grid w-fit max-w-full grid-cols-[auto_auto]", {
variants: {
variant: {
neutral: "[--bubble-icon-color:var(--color-muted-foreground)] [--bubble-icon-glyph:var(--color-foreground)] [--bubble-icon-film:var(--badge-film-70)] [&_[data-slot=bubble-content]]:bg-accent [&_[data-slot=bubble-content]]:border-border",
info: "[--bubble-icon-color:var(--color-info)] [--bubble-icon-glyph:var(--color-info)] [--bubble-icon-film:var(--badge-film-80)] [&_[data-slot=bubble-content]]:bg-info-background [&_[data-slot=bubble-content]]:border-border",
critical: "[--bubble-icon-color:var(--color-destructive)] [--bubble-icon-glyph:var(--color-destructive)] [--bubble-icon-film:var(--badge-film-70)] [&_[data-slot=bubble-content]]:bg-destructive-background [&_[data-slot=bubble-content]]:border-border [&_[data-slot=bubble-title]]:text-destructive",
success: "[--bubble-icon-color:var(--color-primary)] [--bubble-icon-glyph:var(--color-primary)] [--bubble-icon-film:var(--badge-film-70)] [&_[data-slot=bubble-content]]:bg-success-background [&_[data-slot=bubble-content]]:border-border"
},
clickable: {
true: cn("[&_[data-slot=bubble-content]]:transition-all", "[&_[data-slot=bubble-content]]:inset-shadow-[0_0_0_9999px_transparent]", "hover:[&_[data-slot=bubble-content]]:inset-shadow-[0_0_0_9999px_var(--badge-film-30)]", "has-[[data-slot=bubble-target]:active]:[&_[data-slot=bubble-content]]:inset-shadow-[0_0_0_9999px_transparent]", "has-[[data-slot=bubble-target]:focus-visible]:[&_[data-slot=bubble-content]]:ring-3", "has-[[data-slot=bubble-target]:focus-visible]:[&_[data-slot=bubble-content]]:ring-ring/50", "isolate", "[&_:is([data-slot=bubble-content],[data-slot=bubble-header],[data-slot=bubble-header-action],[data-slot=bubble-detail-list],[data-slot=bubble-media],[data-slot=bubble-media-grid],[data-slot=bubble-actions],[data-slot=bubble-footer],[data-slot=bubble-footer-start],[data-slot=bubble-footer-end],[data-slot=bubble-meta],[data-slot=bubble-meta-start],[data-slot=bubble-meta-end])>*:not([data-slot^=bubble-])]:relative", "[&_:is([data-slot=bubble-content],[data-slot=bubble-header],[data-slot=bubble-header-action],[data-slot=bubble-detail-list],[data-slot=bubble-media],[data-slot=bubble-media-grid],[data-slot=bubble-actions],[data-slot=bubble-footer],[data-slot=bubble-footer-start],[data-slot=bubble-footer-end],[data-slot=bubble-meta],[data-slot=bubble-meta-start],[data-slot=bubble-meta-end])>*:not([data-slot^=bubble-])]:z-2"),
false: ""
}
},
compoundVariants: [
{
variant: "neutral",
clickable: true,
class: "hover:[&_[data-slot=bubble-content]]:border-muted-foreground has-[[data-slot=bubble-target]:active]:[&_[data-slot=bubble-content]]:border-muted-foreground"
},
{
variant: "info",
clickable: true,
class: "hover:[&_[data-slot=bubble-content]]:border-info has-[[data-slot=bubble-target]:active]:[&_[data-slot=bubble-content]]:border-info"
},
{
variant: "critical",
clickable: true,
class: "hover:[&_[data-slot=bubble-content]]:border-destructive has-[[data-slot=bubble-target]:active]:[&_[data-slot=bubble-content]]:border-destructive"
},
{
variant: "success",
clickable: true,
class: "hover:[&_[data-slot=bubble-content]]:border-success has-[[data-slot=bubble-target]:active]:[&_[data-slot=bubble-content]]:border-success"
}
],
defaultVariants: {
variant: "neutral",
clickable: false
}
});
/**
* Root row of a conversation-timeline item. Owns thread-edge alignment, the
* click target, the hover context that reveals `BubbleQuickActions`, and (as
* of spec §10) the `variant` that tints `BubbleContent`.
*
* Pass `clickTarget` to make the whole bubble one click target - "open the call
* details sheet" and the like. It renders an absolutely positioned `<button>`
* **sibling** covering the sender-label/content/meta stack, with every
* interactive descendant raised above it, and switches on the Figma Hover and
* Active treatments (spec §14). One prop carries both the handler and the
* button's accessible name so the affordance and the action cannot drift apart;
* without it the bubble is inert and stays visually static, because a bubble
* that lights up under the cursor and then does nothing is a false affordance.
* The `<div>`'s own `onClick` is untouched and still passes through.
*
* Renders a `<div>` as a 2-column CSS grid, sized to fit its own content
* (`w-fit`) rather than stretching to its parent's width. `BubbleContent` and
* `BubbleHint` auto-place into column 1 / column 2 in document order, so they
* share a row exactly as wide as themselves; `BubbleSenderLabel` and
* `BubbleMeta` span both columns (`col-span-2`), so they render **exactly** as
* wide as `BubbleContent` + `BubbleHint` together, never the width of
* `Bubble`'s own parent (spec §10 - the previous flex-wrap layout let a
* `w-full` sender label or meta row stretch to the surrounding timeline
* container instead of the bubble itself). Edge alignment is `ml-auto` /
* `mr-auto`, not `justify-content` - `justify-content` only aligns content
* *inside* a box, and once `Bubble` is content-sized there is no leftover
* space inside it to justify; pushing the whole content-sized box to one
* edge of its own parent is a margin-auto job. Direct children are
* `BubbleSenderLabel`, `BubbleQuickActions`, `BubbleContent`, `BubbleHint`
* and `BubbleMeta` - all siblings, in that order (see spec §3.3).
*
* @example
* ```tsx
* <Bubble align="end" variant="info">
* <BubbleContent>
* <BubbleText>Sure, sending it right now</BubbleText>
* </BubbleContent>
* </Bubble>
*
* <Bubble
* align="start"
* variant="info"
* clickTarget={{ label: 'Open call details', onClick: openDetailsSheet }}
* >
* <BubbleContent>…</BubbleContent>
* </Bubble>
* ```
*/
const Bubble = React.forwardRef((componentProps, forwardRef) => {
const { className, align, variant, clickTarget, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble",
"data-align": align,
"data-variant": variant !== null && variant !== void 0 ? variant : "neutral",
className: cn(bubbleVariants({
variant,
clickable: Boolean(clickTarget)
}), "[&>[data-slot=bubble-sender-label]]:col-span-2 [&>[data-slot=bubble-sender-label]]:row-start-1", "[&>[data-slot=bubble-content]]:col-start-1 [&>[data-slot=bubble-content]]:row-start-2", "[&>.bubble-hint-slot]:col-start-2 [&>.bubble-hint-slot]:row-start-2", "[&>[data-slot=bubble-meta]]:col-span-2 [&>[data-slot=bubble-meta]]:row-start-3", "[&>[data-slot=bubble-quick-actions]]:col-start-1 [&>[data-slot=bubble-quick-actions]]:col-end-2 [&>[data-slot=bubble-quick-actions]]:row-start-2 [&>[data-slot=bubble-quick-actions]]:row-end-3", align === "end" ? "ml-auto" : "mr-auto", className)
}, props), {}, { children: [clickTarget && /* @__PURE__ */ jsx("button", {
type: "button",
"data-slot": "bubble-target",
"aria-label": clickTarget.label,
onClick: clickTarget.onClick,
className: cn("absolute inset-0 z-1 rounded-2xl", "cursor-pointer outline-none")
}), children] }));
});
Bubble.displayName = "Bubble";
/**
* Rule-Today-rule row that groups a run of `Bubble`s in a conversation
* timeline. Renders a `<div>` with a `<hr>`-like rule either side of
* `children`. NOT nested inside `Bubble` - it is a sibling of a run of
* `Bubble`s in the consumer's own thread/list container, the same
* timeline-level chrome as the day separator any messaging UI needs.
* Previously left unbuilt as "resolved, not escalated" (spec §9); rebuilt as
* a real part once a story had to hand-roll it to demonstrate a timeline
* (spec §10).
*
* @example
* ```tsx
* <BubbleDaySeparator>Today</BubbleDaySeparator>
* <Bubble align="start">…</Bubble>
* <Bubble align="end">…</Bubble>
* ```
*/
const BubbleDaySeparator = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded2);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-day-separator",
className: cn("flex items-center gap-3 text-xs text-muted-foreground", "[&>[data-slot=bubble-day-separator-rule]]:h-px [&>[data-slot=bubble-day-separator-rule]]:flex-1 [&>[data-slot=bubble-day-separator-rule]]:bg-border", className)
}, props), {}, { children: [
/* @__PURE__ */ jsx("div", { "data-slot": "bubble-day-separator-rule" }),
children,
/* @__PURE__ */ jsx("div", { "data-slot": "bubble-day-separator-rule" })
] }));
});
BubbleDaySeparator.displayName = "BubbleDaySeparator";
/**
* Sender/participant name shown above the bubble. Renders a `<div>` in small
* muted text. Optional - most bubbles in a single-participant thread omit it.
*
* @example
* ```tsx
* <BubbleSenderLabel>John Doe</BubbleSenderLabel>
* ```
*/
const BubbleSenderLabel = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded3);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-sender-label",
className: cn("mb-1 w-full px-1 text-xs font-medium text-muted-foreground in-data-[align=end]:text-right", className)
}, props));
});
BubbleSenderLabel.displayName = "BubbleSenderLabel";
/**
* Hover-reveal toolbar (copy / download / reply) overlaid on the top-right
* corner of `BubbleContent` - 8px in from that surface's top and right edges,
* matching Figma's `State=Hover` frame (`27904:8247`), and anchored to
* `BubbleContent`'s own grid cell rather than to the row, so it stays put on a
* bubble that also renders `BubbleHint`/`BubbleSenderLabel`/`BubbleMeta` (see
* the `col-end-2`/`row-end-3` note on `Bubble`'s className).
* Renders a `<div>`, `opacity-0` fading to `opacity-100` while any
* descendant of `Bubble` is hovered or focused - CSS only, no JS state.
* `pointer-events-none` while hidden (paired `pointer-events-auto` on
* reveal) so the invisible toolbar never intercepts clicks meant for the
* bubble underneath it - the same guard `EngagementPreviewBubble`
* (conversations-ui) already applies to its equivalent toolbar (spec §11).
* Consumer composes DS `<Button variant="ghost" size="icon">` (+ `<Tooltip>`)
* children directly; there is no `actions: Action[]` descriptor prop
* (spec §3.4).
*
* @example
* ```tsx
* <BubbleQuickActions>
* <Button variant="ghost" size="icon" aria-label="Copy"><Copy /></Button>
* </BubbleQuickActions>
* ```
*/
const BubbleQuickActions = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded4);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-quick-actions",
className: cn("absolute top-2 right-2 z-10 flex items-center gap-1 rounded-md bg-background p-1 opacity-0 shadow-sm transition-opacity pointer-events-none group-hover/bubble:pointer-events-auto group-hover/bubble:opacity-100 group-focus-within/bubble:pointer-events-auto group-focus-within/bubble:opacity-100", className)
}, props));
});
BubbleQuickActions.displayName = "BubbleQuickActions";
/**
* The colored/bordered surface that carries the bubble's content. Renders a
* `<div>`. The only required child besides `Bubble` itself (spec §3).
* Background/border tint (`variant`) is no longer a prop here - it lives on
* the parent `Bubble` and reaches this part via a descendant selector in
* {@link bubbleVariants}, so `Bubble`'s other children can never inherit it
* (spec §10). Every other anatomy part nests inside `BubbleContent`, in the
* order documented in spec §3.1.
*
* @example
* ```tsx
* <Bubble align="end" variant="critical">
* <BubbleContent>
* <BubbleText>Aircall is more than a business phone...</BubbleText>
* <BubbleErrorMessage icon={<CircleAlert />}>Not delivered.</BubbleErrorMessage>
* </BubbleContent>
* </Bubble>
* ```
*/
const BubbleContent = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded5);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-content",
className: cn("flex min-w-80 max-w-110 flex-col gap-2 rounded-2xl border p-3 text-sm text-foreground", className)
}, props));
});
BubbleContent.displayName = "BubbleContent";
/**
* Header region of `BubbleContent`, siblings of the content and
* {@link BubbleFooter} regions (spec §10 - mirrors `CardHeader`'s split from
* `CardContent`/`CardFooter`). Renders a `<div>` as a two-column grid: a
* {@link BubbleIcon} in column 1 (spanning both rows only when a
* {@link BubbleSubtitle} is present), a stacked {@link BubbleTitle} /
* `BubbleSubtitle` pair in column 2. Pass `action` for a trailing element
* (menu button, timestamp) - not a child, matching the `action`-prop-over-
* children pattern in `packages/blocks/AGENTS.md`.
*
* @example
* ```tsx
* <BubbleHeader>
* <BubbleIcon><PhoneOutgoing /></BubbleIcon>
* <BubbleTitle>Call ended</BubbleTitle>
* <BubbleSubtitle><span>made by Alex Finn</span><span>01:23</span></BubbleSubtitle>
* </BubbleHeader>
* ```
*/
const BubbleHeader = React.forwardRef((componentProps, forwardRef) => {
const { className, action, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded6);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-header",
className: cn("grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5", "[&>[data-slot=bubble-icon]]:col-start-1 [&>[data-slot=bubble-title]]:col-start-2 [&>[data-slot=bubble-subtitle]]:col-start-2", "has-[[data-slot=bubble-subtitle]]:[&>[data-slot=bubble-icon]]:row-span-2", "has-[[data-slot=bubble-subtitle]]:[&>[data-slot=bubble-header-action]]:row-span-2", action && "grid-cols-[auto_1fr_auto]", className)
}, props), {}, { children: [children, action && /* @__PURE__ */ jsx("div", {
"data-slot": "bubble-header-action",
className: "col-start-3 self-center justify-self-end",
children: action
})] }));
});
BubbleHeader.displayName = "BubbleHeader";
/**
* 32px icon-avatar circle for call/system bubbles. Renders a `<div>`; the
* consumer passes the icon as `children`. Background colour is **not** a
* prop - it reads the parent `Bubble`'s `variant` via a descendant selector
* on `bubbleVariants` (spec §5 - moved there from `BubbleContent` in spec
* §10), so it can never disagree with the bubble's own variant.
*
* @example
* ```tsx
* <BubbleIcon><PhoneOutgoing /></BubbleIcon>
* ```
*/
const BubbleIcon = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded7);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-icon",
className: cn("flex size-8 shrink-0 items-center justify-center rounded-full bg-[color:var(--bubble-icon-color)] bg-[image:linear-gradient(0deg,var(--bubble-icon-film,var(--badge-film-70))_0%,var(--bubble-icon-film,var(--badge-film-70))_100%)] text-[color:var(--bubble-icon-glyph)] [&>svg]:size-4", className)
}, props));
});
BubbleIcon.displayName = "BubbleIcon";
/**
* 14px semibold call/system-bubble headline ("Call ended", "Voicemail"...).
* Renders a `<div>`. Present whenever `BubbleIcon` is. Not Figma's "Title"
* layer - see `BubbleSectionLabel` and spec §7 #4 for that naming conflict.
*
* @example
* ```tsx
* <BubbleTitle>Call ended</BubbleTitle>
* ```
*/
const BubbleTitle = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded8);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-title",
className: cn("text-sm font-semibold", className)
}, props));
});
BubbleTitle.displayName = "BubbleTitle";
/**
* Bullet-joined secondary line under `BubbleTitle` (e.g. "made by X • 01:23").
* Renders a `<div>`; direct children are auto-joined with `•` via CSS - no
* manual separator markup.
*
* @example
* ```tsx
* <BubbleSubtitle>
* <span>made by Alex Finn</span>
* <span>01:23</span>
* </BubbleSubtitle>
* ```
*/
const BubbleSubtitle = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded9);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-subtitle",
className: cn("flex flex-wrap items-center gap-1 text-xs font-medium text-foreground [&>*+*]:before:mr-1 [&>*+*]:before:content-['•']", className)
}, props));
});
BubbleSubtitle.displayName = "BubbleSubtitle";
/**
* Flex-wrap row of {@link BubbleDetailItem}s (the "from X" / IVR-branch rows).
* Renders a `<div>`.
*
* @example
* ```tsx
* <BubbleDetailList>
* <BubbleDetailItem icon={<ArrowRight />}>from Number Support Level 1</BubbleDetailItem>
* </BubbleDetailList>
* ```
*/
const BubbleDetailList = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded10);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-detail-list",
className: cn("flex flex-wrap items-center gap-4", className)
}, props));
});
BubbleDetailList.displayName = "BubbleDetailList";
/**
* Icon + label leaf, reused both inside {@link BubbleDetailList} (from-number
* / IVR rows) and inside a {@link BubbleFooter} cluster (AI-insight chips) -
* the same shape in both places, not a separate `BubbleInsightItem` (spec
* §3.3). Renders a `<div>`.
*
* @example
* ```tsx
* <BubbleDetailItem icon={<StickyNote />}>1 note</BubbleDetailItem>
* ```
*/
const BubbleDetailItem = React.forwardRef((componentProps, forwardRef) => {
const { className, icon, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded11);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-detail-item",
className: cn("flex items-center gap-1 text-xs font-medium text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0", className)
}, props), {}, { children: [icon, children] }));
});
BubbleDetailItem.displayName = "BubbleDetailItem";
/**
* Thin row wrapper for a call recording. Renders a `<div>`; the consumer
* drops a DS `<AudioPlayer>` (`Audio` + `AudioPlayerRow` + friends) straight
* in - `Bubble` does not wrap, re-export, or configure it
* (`packages/blocks/AGENTS.md`: import DS primitives directly).
*
* @example
* ```tsx
* <BubbleMedia>
* <Audio src={recordingUrl}>
* <AudioPlayerRow>
* <AudioPlayerButton />
* <AudioPlayerBar />
* <AudioPlayerTime />
* </AudioPlayerRow>
* </Audio>
* </BubbleMedia>
* ```
*/
const BubbleMedia = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded12);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-media",
className: cn("flex items-center gap-2 [&>*]:min-w-0 [&>*:not(audio)]:flex-1", className)
}, props));
});
BubbleMedia.displayName = "BubbleMedia";
/**
* Splits a {@link BubbleMediaGrid} child count into what renders plainly and
* what collapses into the last tile's `+N` overlay.
*
* @internal Exported for `bubble.test.ts`; not part of the package API.
*/
function getBubbleMediaGridOverflow(itemCount, maxVisible) {
const visibleCount = Math.min(itemCount, Math.max(maxVisible, 0));
return {
visibleCount,
hiddenCount: itemCount - visibleCount
};
}
/**
* The whole layout decision for a media grid, as a pure function of how MANY
* children there are. Not of what they are, and not of what shape their sources
* are - `kind` is not even read here.
*
* Two rules, and that is all of it:
*
* 1. **`maxVisible` caps ITEMS, in source order.** A `file` occupies a tile like
* any other child, so four photos and a PDF shows the first four and a `+1`.
* That is the captain's ruling on what "up to 4" counts.
* 2. **An odd count spans its first tile.** A two-column track cannot divide an
* odd number, so the first tile takes both columns and the rest divide evenly
* beneath it. At the default cap of four this only ever fires at three; above
* it - `maxVisible` is a public prop - it is what stops five or seven tiles
* leaving a ragged trailing row.
*
* @internal Exported for `bubble.test.ts`; not part of the package API.
*/
function getBubbleMediaGridLayout(items, maxVisible) {
const { visibleCount, hiddenCount } = getBubbleMediaGridOverflow(items.length, maxVisible);
const visible = Array.from({ length: visibleCount }, (_, index) => index);
return {
tiles: visible.map((index) => ({
index,
span: visibleCount > 1 && visibleCount % 2 === 1 && index === 0 ? "wide" : "cell"
})),
hiddenCount,
overflowIndex: hiddenCount > 0 && visibleCount > 0 ? visible[visibleCount - 1] : -1
};
}
/**
* Cell geometry, keyed on how many tiles are in the mosaic. Reproduced from the
* approved model (`model-matrix.html`, column A), which is the contract:
*
* | Tiles | Track | Cell |
* |---|---|---|
* | 1 | one column | `4/3`, the full bubble width |
* | 2 | two columns | `3/4` each, so a pair reads as a tall diptych |
* | 3 | two columns | first spans both at `2/1`, two squares beneath |
* | 4+ | two columns | squares |
*
* Every cell is a FIXED shape and the source is cropped to fill it with
* `object-cover`. That is the whole point rather than a side effect: a portrait,
* a landscape and a square all render identically, so there is no ratio to
* distinguish, no dead space to distribute, and nothing for a consumer to
* supply. Any change that reintroduces a per-image measurement is wrong.
*/
const BUBBLE_MEDIA_CELL_ASPECT = (tileCount) => tileCount === 1 ? "aspect-[4/3]" : tileCount === 2 ? "aspect-[3/4]" : "aspect-square";
/**
* Message media grid. Every child - image, `file` or `unavailable` - packs into
* a two-column mosaic as an identically sized tile. Renders a `<div>`.
*
* The mosaic is **span-fill**: a uniform two-column track where the three-item
* hero spans both columns. That is what fixes the three-item case, which a fixed
* `grid-cols-2` cannot pack - it left one tile and a hole, measured at 26.7% of
* the block's area.
*
* It is **orientation-blind on purpose.** Nothing here asks how tall or wide a
* source is: every cell is one fixed box, and an image is shown WHOLE inside it
* with `object-contain`. Nothing is ever cropped. That matters because the real
* attachments in this product are screenshots, and cropping a screenshot destroys
* the text that made it worth sending.
*
* The trade is letterbox rather than crop: a source whose shape differs from the
* cell's leaves background on two sides. In exchange the layout needs NOTHING
* from the consumer, packs identically for every set of sources, and has no
* post-load reflow. The two real consumers pass only a file name and a URL, so a
* declared aspect would have gone unfilled and a measured one would have shifted
* the layout a paint after load.
*
* Tile geometry comes from the grid, never from the tile's own content, which is
* what retires the old `self-start py-6` branch: a `file` tile has no height of
* its own to disagree with its neighbours about. It is in the mosaic, at the same
* dimensions as a photo. The `+N` overlay sits directly ON its tile, with no
* intermediate wrapper to disagree with it about where the hit target is.
*
* @example
* ```tsx
* <BubbleMediaGrid maxVisible={4}>
* <BubbleMediaItem kind="image" src={photo1} alt="" />
* <BubbleMediaItem kind="image" src={photo2} alt="" />
* <BubbleMediaItem kind="file" fileName="invoice.pdf" />
* </BubbleMediaGrid>
* ```
*/
const BubbleMediaGrid = React.forwardRef((componentProps, forwardRef) => {
const { className, maxVisible = 4, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded13);
const t = useDsTranslation("blocks");
const items = React.Children.toArray(children).filter(React.isValidElement);
const layout = getBubbleMediaGridLayout(items.map(() => ({ kind: "image" })), maxVisible);
const tileCount = layout.tiles.length;
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-media-grid",
className: cn("flex flex-col gap-1", className)
}, props), {}, { children: [tileCount > 0 && /* @__PURE__ */ jsx("div", {
"data-slot": "bubble-media-grid-mosaic",
className: cn("grid gap-[3px] overflow-hidden rounded-[10px]", tileCount === 1 ? "grid-cols-1" : "grid-cols-2"),
children: layout.tiles.map(({ index, span }) => /* @__PURE__ */ jsxs("div", {
"data-slot": "bubble-media-grid-cell",
"data-span": span,
className: cn("relative w-full overflow-hidden bg-background", span === "wide" ? "col-span-2 aspect-[2/1]" : BUBBLE_MEDIA_CELL_ASPECT(tileCount)),
children: [items[index], index === layout.overflowIndex && /* @__PURE__ */ jsx("div", {
"aria-hidden": true,
"data-slot": "bubble-media-grid-overflow",
className: "pointer-events-none absolute inset-0 z-3 grid place-items-center bg-black/60 text-base font-semibold text-white",
children: t("bubble.moreMedia", { count: layout.hiddenCount })
})]
}, index))
}), layout.hiddenCount > 0 && /* @__PURE__ */ jsx("span", {
className: "sr-only",
children: t("bubble.moreMedia", { count: layout.hiddenCount })
})] }));
});
BubbleMediaGrid.displayName = "BubbleMediaGrid";
/**
* One item inside a {@link BubbleMediaGrid}: an image, a file, or an
* unavailable-media placeholder. Renders a `<div>` by default (swap via
* `render`). Interactive when `onClick` is passed - opening
* {@link BubbleMediaViewer} (or any other lightbox/gallery) is entirely the
* consumer's job; `Bubble` only fires the callback.
*
* Geometry is the GRID's, not the item's: an `image` fills the mosaic cell it
* was given and crops to it, a `file`/`unavailable` renders as a full-width row,
* and the one exception is a single item, which is sized to itself and crops
* nothing. There is deliberately no dimension or orientation prop - the layout
* asks the consumer for nothing beyond `kind` and `src`.
*
* @example
* ```tsx
* <BubbleMediaItem kind="image" src={photo} alt="" onClick={() => openViewer(0)} />
* <BubbleMediaItem kind="file" fileName="invoice.pdf" />
* <BubbleMediaItem kind="unavailable" />
* ```
*/
const BubbleMediaItem = React.forwardRef((componentProps, forwardRef) => {
const { className, kind, src, alt, fileName, label, onClick, render } = componentProps, props = _objectWithoutProperties(componentProps, _excluded14);
const t = useDsTranslation("blocks");
return useRender({
defaultTagName: "div",
props: mergeProps({
"data-slot": "bubble-media-item",
"data-kind": kind,
role: onClick ? "button" : void 0,
tabIndex: onClick ? 0 : void 0,
"aria-label": onClick && kind === "image" ? alt || t("bubble.openMedia") : void 0,
onClick,
onKeyDown: onClick ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
} : void 0,
className: cn("relative grid size-full place-items-center overflow-hidden", onClick && "z-2 cursor-pointer outline-none focus-visible:outline-3 focus-visible:outline-ring/50 focus-visible:-outline-offset-3 focus-visible:outline-solid", className),
children: /* @__PURE__ */ jsxs(Fragment, { children: [
kind === "image" && /* @__PURE__ */ jsx("img", {
src,
alt: alt !== null && alt !== void 0 ? alt : "",
className: "size-full object-cover"
}),
kind === "file" && /* @__PURE__ */ jsxs(Attachment, {
state: "done",
size: "sm",
className: "w-auto min-w-0 max-w-full flex-col items-center border-0 bg-transparent text-center",
children: [/* @__PURE__ */ jsx(AttachmentMedia, { children: /* @__PURE__ */ jsx(Paperclip, {}) }), /* @__PURE__ */ jsx(AttachmentContent, {
className: "items-center",
children: /* @__PURE__ */ jsx(AttachmentTitle, {
className: "line-clamp-2 max-w-full break-words whitespace-normal",
children: fileName
})
})]
}),
kind === "unavailable" && /* @__PURE__ */ jsxs(Attachment, {
state: "error",
size: "sm",
className: "w-auto min-w-0 max-w-full flex-col items-center border-0 bg-transparent text-center",
children: [/* @__PURE__ */ jsx(AttachmentMedia, { children: /* @__PURE__ */ jsx(ImageOff, {}) }), /* @__PURE__ */ jsx(AttachmentContent, {
className: "items-center",
children: /* @__PURE__ */ jsx(AttachmentTitle, {
className: "line-clamp-2 max-w-full break-words whitespace-normal text-destructive",
children: label !== null && label !== void 0 ? label : t("bubble.unavailableMedia")
})
})]
})
] })
}, { ref: forwardRef }, props),
render: render !== null && render !== void 0 ? render : /* @__PURE__ */ jsx("div", {})
});
});
BubbleMediaItem.displayName = "BubbleMediaItem";
/**
* Full-screen media viewer surface: a styled {@link DialogContent} sized to
* the viewport, laid out as a flex column for
* {@link BubbleMediaViewerHeader} / {@link BubbleMediaViewerCanvas} /
* {@link BubbleMediaViewerThumbnails} (spec §17 - no Figma node backs this
* chrome). `Bubble` does not wrap `Dialog`/`Carousel` themselves - the
* consumer places `<Dialog>` around this and owns `open`/index state, same
* split as `BubbleMediaItem`'s `onClick`.
*
* Escape-to-close and the backdrop come free from Base UI's `Dialog` and are
* not reimplemented. Arrow-key paging does NOT: DS `Carousel` binds
* `ArrowLeft`/`ArrowRight` on its own root, and a dialog puts initial focus on
* the popup - an ANCESTOR of that root - so the keys never reached it. Pass
* `onPrevious`/`onNext` (straight from `useBubbleMediaViewer`) and this
* component binds them, on TWO paths for one reason worth knowing about:
* a bubble-phase handler on the popup covers focus anywhere inside the viewer
* (bailing on `event.defaultPrevented` so the carousel's own capture handler
* pages one slide rather than two), and a capture-phase listener on the
* document covers the case where focus has fallen out to `<body>` - which it
* does, every time paging to the last slide disables `CarouselNext` under the
* user's own focus. See the comments on each for why the phases differ.
*
* `showCloseButton` defaults to `false` here, unlike `DialogContent`: the
* built-in close sits at `absolute top-3 right-4`, over the action row, and
* the viewer's own close is {@link BubbleMediaViewerClose} at the far left of
* the header instead. Renders a `sr-only` `DialogTitle`
* (`t('bubble.mediaViewerTitle')`) so the dialog has an accessible name even
* when the consumer omits the header (spec §11).
*
* @example
* ```tsx
* const viewer = useBubbleMediaViewer(openIndex);
*
* <Dialog open={open} onOpenChange={setOpen}>
* <BubbleMediaViewer onPrevious={viewer.previous} onNext={viewer.next}>
* <BubbleMediaViewerHeader>
* <BubbleMediaViewerClose />
* <BubbleMediaViewerAuthor name="Alex Finn" fileName="photo.jpg" timestamp="11:04am" />
* <BubbleMediaViewerActions>
* <Button variant="ghost" size="icon" onClick={download}><Download /></Button>
* </BubbleMediaViewerActions>
* </BubbleMediaViewerHeader>
* <BubbleMediaViewerCanvas>
* <Carousel setApi={viewer.setApi} opts={{ startIndex: openIndex }}>…</Carousel>
* </BubbleMediaViewerCanvas>
* <BubbleMediaViewerThumbnails>…</BubbleMediaViewerThumbnails>
* </BubbleMediaViewer>
* </Dialog>
* ```
*/
const BubbleMediaViewer = React.forwardRef((componentProps, forwardRef) => {
const { className, children, onNext, onPrevious, onKeyDown, showCloseButton = false } = componentProps, props = _objectWithoutProperties(componentProps, _excluded15);
const t = useDsTranslation("blocks");
const page = React.useCallback((event) => {
if (event.key === "ArrowLeft" && onPrevious) {
event.preventDefault();
onPrevious();
} else if (event.key === "ArrowRight" && onNext) {
event.preventDefault();
onNext();
}
}, [onPrevious, onNext]);
const handleKeyDown = (event) => {
onKeyDown === null || onKeyDown === void 0 || onKeyDown(event);
if (event.defaultPrevented) return;
page(event);
};
const [popup, setPopup] = React.useState(null);
const handleRef = React.useCallback((element) => {
setPopup(element);
if (typeof forwardRef === "function") forwardRef(element);
else if (forwardRef) forwardRef.current = element;
}, [forwardRef]);
React.useEffect(() => {
if (!popup) return;
const ownerDocument = popup.ownerDocument;
const handleOrphanedKeyDown = (event) => {
const target = event.target;
if (target && popup.contains(target)) return;
if (target !== ownerDocument.body && target !== ownerDocument.documentElement) return;
page(event);
};
ownerDocument.addEventListener("keydown", handleOrphanedKeyDown, true);
return () => ownerDocument.removeEventListener("keydown", handleOrphanedKeyDown, true);
}, [popup, page]);
return /* @__PURE__ */ jsxs(DialogContent, _objectSpread2(_objectSpread2({
ref: handleRef,
"data-slot": "bubble-media-viewer",
showCloseButton,
onKeyDown: handleKeyDown,
className: cn("inset-0 top-0 left-0 flex h-screen w-screen max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 bg-background/95 p-0 sm:max-w-none", className)
}, props), {}, { children: [/* @__PURE__ */ jsx(DialogTitle, {
className: "sr-only",
children: t("bubble.mediaViewerTitle")
}), children] }));
});
BubbleMediaViewer.displayName = "BubbleMediaViewer";
/**
* Top row of {@link BubbleMediaViewer}, in reading order:
* {@link BubbleMediaViewerClose} flush left, then
* {@link BubbleMediaViewerAuthor} beside it, then whatever space is left, then
* {@link BubbleMediaViewerActions} against the right edge. Renders a `<div>`.
*
* Close leads the row rather than trailing it (spec §17.10 - the captain's
* ruling on the conflict flagged in §17.5, resolved toward his words and
* toward what WhatsApp Web, Google Photos, Telegram and macOS Quick Look all
* do). `BubbleMediaViewerActions` carries its own `ml-auto`, so the row is a
* plain flex line rather than `justify-between` - with three children the
* latter would have parked the author in the middle of the viewport.
*
* `sm:px-8` is not the usual `sm:px-6`: 32px is exactly where
* {@link BubbleMediaViewerCanvas} puts the carousel chevrons, so close lines up
* with the prev chevron and the last action with the next one, instead of the
* header sitting 8px proud of the controls beneath it.
*/
const BubbleMediaViewerHeader = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded16);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-media-viewer-header",
className: cn("flex shrink-0 items-center gap-3 px-4 py-3 sm:px-8", className)
}, props));
});
BubbleMediaViewerHeader.displayName = "BubbleMediaViewerHeader";
/**
* The dismiss control, and the first thing in
* {@link BubbleMediaViewerHeader}. Renders the `DialogClose` `<button>`, so it
* needs no wiring - `Dialog` closes itself. Escape still works independently.
*
* It is a part of its own rather than a child of
* {@link BubbleMediaViewerActions} because it is no longer in that group: the
* actions act ON the asset, close leaves. Putting it far left is where every
* viewer this one was compared against puts it, and it keeps the destructive-
* ish control away from the ones a user reaches for repeatedly.
*/
const BubbleMediaViewerClose = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded17);
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsx(DialogClose, _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-media-viewer-close",
render: /* @__PURE__ */ jsx(Button, {
variant: "ghost",
size: "icon",
className: cn("shrink-0", className)
})
}, props), {}, { children: children !== null && children !== void 0 ? children : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(XIcon, {}), /* @__PURE__ */ jsx("span", {
className: "sr-only",
children: t("bubble.closeMediaViewer")
})] }) }));
});
BubbleMediaViewerClose.displayName = "BubbleMediaViewerClose";
/**
* Left half of {@link BubbleMediaViewerHeader}: an optional avatar beside the
* sender's name, with the file name and the message time on a second, muted
* line. Renders a `<div>`.
*
* Everything is optional and each piece is a `ReactNode`, so a caller with no
* avatar, or with a `<Tooltip>`-wrapped timestamp, composes rather than
* fights the part. `avatar` is deliberately not a DS `<Avatar>` baked in
* here - blocks does not know whether the consumer has an image, initials, or
* a presence badge to show.
*/
const BubbleMediaViewerAuthor = React.forwardRef((componentProps, forwardRef) => {
const { className, avatar, name, fileName, timestamp } = componentProps, props = _objectWithoutProperties(componentProps, _excluded18);
const hasCaption = Boolean(fileName) || Boolean(timestamp);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-media-viewer-author",
className: cn("flex min-w-0 items-center gap-3", className)
}, props), {}, { children: [avatar, /* @__PURE__ */ jsxs("div", {
className: "flex min-w-0 flex-col",
children: [name && /* @__PURE__ */ jsx("span", {
className: "truncate text-sm font-medium text-foreground",
children: name
}), hasCaption && /* @__PURE__ */ jsxs("span", {
className: "flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground",
children: [
fileName && /* @__PURE__ */ jsx("span", {
className: "truncate",
children: fileName
}),
fileName && timestamp && /* @__PURE__ */ jsx("span", {
"aria-hidden": "true",
children: "·"
}),
timestamp && /* @__PURE__ */ jsx("span", {
className: "shrink-0",
children: timestamp
})
]
})]
})] }));
});
BubbleMediaViewerAuthor.displayName = "BubbleMediaViewerAuthor";
/**
* The action row at the right end of {@link BubbleMediaViewerHeader}:
* everything a user can do TO the asset in view - download, and zoom if it is
* ever added - in the order the consumer passes them. Renders a `<div>`.
*
* It carries `ml-auto` itself rather than relying on the header to space it,
* so the header stays a plain flex line that reads left to right and a
* consumer can drop parts in or out without the arrangement collapsing.
*
* Close is deliberately NOT here any more; it is
* {@link BubbleMediaViewerClose}, at the far left of the header (spec §17.10).
*/
const BubbleMediaViewerActions = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded19);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-media-viewer-actions",
className: cn("ml-auto flex shrink-0 items-center gap-1", className)
}, props));
});
BubbleMediaViewerActions.displayName = "BubbleMediaViewerActions";
/**
* The middle band of {@link BubbleMediaViewer} - wrap the consumer's DS
* `<Carousel>` in it. Renders a `<div>` that takes the leftover column height
* and, crucially, carries the horizontal padding that keeps
* `CarouselPrevious`/`CarouselNext` OFF the viewport edge.
*
* That padding is the whole reason this part exists rather than being folded
* into the viewer root. Those buttons position themselves at `-left-12` /
* `-right-12` relative to the carousel, so with a full-bleed carousel they
* land flush against the screen border. `px-16 sm:px-20` pushes the carousel
* in by 64px/80px, which leaves the 32px buttons sitting 16px/32px inside the
* edge. The numbers are a judgement call, not a spec: no Figma node covers
* this chrome (spec §17).
*/
const BubbleMediaViewerCanvas = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded20);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-media-viewer-canvas",
className: cn("relative flex min-h-0 flex-1 items-center justify-center px-16 sm:px-20", className)
}, props));
});
BubbleMediaViewerCanvas.displayName = "BubbleMediaViewerCanvas";
/**
* Bottom strip of {@link BubbleMediaViewer}: a centred, horizontally
* scrollable row of {@link BubbleMediaViewerThumbnail}s. Renders a `<div>`
* wrapping a DS `<ScrollArea>` - **or nothing at all** when it holds fewer
* than two children, which is the legacy `Thumbnails`' `items.length > 1`
* rule (`MessageBubbleV2/components/Gallery`): one asset has nothing to page
* between, so a strip of one is chrome that only takes height away from the
* asset.
*
* `ScrollArea` is the captain's own suggestion and it holds up: it renders a
* horizontal `ScrollBar` as well as a vertical one, and both are `opacity-0`
* until hover or scroll, so a strip that fits carries no permanent visual
* weight. The inner row is `mx-auto w-max` rather than `justify-center` on
* purpose - centring a flex row that overflows its scroll container makes the
* FIRST items unreachable, because the overflow lands on the start edge where
* scrolling cannot reach it.
*/
const BubbleMediaViewerThumbnails = React.forwardRef((componentProps, forwardRef) => {
const { className, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded21);
if (React.Children.count(children) < 2) return null;
return /* @__PURE__ */ jsx("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-media-viewer-thumbnails",
className: cn("shrink-0 px-4 pb-4 sm:px-6", "[&_[data-slot=scroll-area-viewport]]:scroll-p-1", className)
}, props), {}, { children: /* @__PURE__ */ jsx(ScrollArea, { children: /* @__PURE__ */ jsx("div", {
className: "mx-auto flex w-max items-center gap-2 p-1",
children
}) }) }));
});
BubbleMediaViewerThumbnails.displayName = "BubbleMediaViewerThumbnails";
/**
* One item in {@link BubbleMediaViewerThumbnails}. Renders a `<button>`
* showing the image itself, or the same paperclip / broken-image glyph its
* {@link BubbleMediaViewerItem} slide uses. Mark the one in view with
* `selected` and it takes the primary ring and scrolls itself back into the
* strip.
*
* Keeping the selection visible is the one thing the legacy `Thumbnail` had to
* hand-roll - it nudged `containerRef.current.scrollLeft` by a fixed 40px off
* `getBoundingClientRect` readings. None of that arithmetic is reproduced
* here, and it could not be: DS `ScrollArea` renders its own viewport
* internally and forwards its ref to the Root, so there is no viewport handle
* to write `scrollLeft` on. `scrollIntoView` needs none - it walks to the
* nearest scrollable ancestor, which IS that viewport - and `block: 'nearest',
* inline: 'nearest'` means it moves the strip only when the thumbnail is
* actually out of view, and never scrolls the page vertically. Instant, not
* smooth: a smooth scroll mid-capture is a Chromatic flake.
*/
const BubbleMediaViewerThumbnail = React.forwardRef((componentProps, forwardRef) => {
const { className, kind, src, alt, fileName, selected = false, "aria-label": ariaLabel } = componentProps, props = _objectWithoutProperties(componentProps, _excluded22);
const t = useDsTranslation("blocks");
const [node, setNode] = React.useState(null);
const handleRef = React.useCallback((element) => {
setNode(element);
if (typeof forwardRef === "function") forwardRef(element);
else if (forwardRef) forwardRef.current = element;
}, [forwardRef]);
React.useEffect(() => {
if (!selected || !node) return;
node.scrollIntoView({
block: "nearest",
inline: "nearest"
});
}, [selected, node]);
return /* @__PURE__ */ jsxs("button", _objectSpread2(_objectSpread2({
ref: handleRef,
type: "button",
"data-slot": "bubble-media-viewer-thumbnail",
"data-kind": kind,
"data-selected": selected || void 0,
"aria-current": selected || void 0,
"aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : alt || fileName || t("bubble.openMedia"),
className: cn("relative flex size-12 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md bg-background text-muted-foreground ring-1 ring-border transition-[box-shadow,opacity] outline-none", "opacity-60 hover:opacity-100 focus-visible:ring-3 focus-visible:ring-ring/50", "data-selected:opacity-100 data-selected:ring-2 data-selected:ring-primary", className)
}, props), {}, { children: [
kind === "image" && /* @__PURE__ */ jsx("img", {
src,
alt: "",
className: "size-full object-cover"
}),
kind === "file" && /* @__PURE__ */ jsx(Paperclip, { className: "size-4" }),
kind === "unavailable" && /* @__PURE__ */ jsx(ImageOff, { className: "size-4 text-destructive" })
] }));
});
BubbleMediaViewerThumbnail.displayName = "BubbleMediaViewerThumbnail";
/**
* One full-viewport slide inside a {@link BubbleMediaViewer}'s `<Carousel>`.
* Same three `kind`s as {@link BubbleMediaItem}, sized for a full-screen
* slide rather than a grid tile: an image scales to fit, a file or
* unavailable-media placeholder centers its icon and label.
*
* It carries no download button any more. That moved into
* {@link BubbleMediaViewerActions} at the top right (spec §17) - one action
* row for the viewer, rather than a button floating over the bottom-right
* corner of whichever slide happens to be showing. The consumer renders the
* button and knows which item is current from `useBubbleMediaViewer`, so the
* per-item contract that replaced the legacy batch
* `MessageBubbleV2Props.onDownloadClick(media: Media[])` is unchanged in
* substance - only its position moved.
*
* @example
* ```tsx
* <BubbleMediaViewerItem kind="file" fileName="invoice.pdf" />
* ```
*/
const BubbleMediaViewerItem = React.forwardRef((componentProps, forwardRef) => {
const { className, kind, src, alt, fileName, label } = componentProps, props = _objectWithoutProperties(componentProps, _excluded23);
const t = useDsTranslation("blocks");
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-media-viewer-item",
"data-kind": kind,
className: cn("relative flex h-[calc(100svh-14rem)] w-full flex-col items-center justify-center gap-4 p-4", className)
}, props), {}, { children: [
kind === "image" && /* @__PURE__ */ jsx("img", {
src,
alt: alt !== null && alt !== void 0 ? alt : "",
className: "max-h-full max-w-full object-contain"
}),
kind === "file" && /* @__PURE__ */ jsxs("div", {
className: "flex flex-col items-center gap-2 text-sm font-medium text-muted-foreground",
children: [/* @__PURE__ */ jsx(Paperclip, { className: "size-8" }), /* @__PURE__ */ jsx("span", { children: fileName })]
}),
kind === "unavailable" && /* @__PURE__ */ jsxs("div", {
className: "flex flex-col items-center gap-2 text-sm font-medium text-muted-foreground",
children: [/* @__PURE__ */ jsx(ImageOff, { className: "size-8" }), /* @__PURE__ */ jsx("span", { children: label !== null && label !== void 0 ? label : t("bubble.unavailableMedia") })]
})
] }));
});
BubbleMediaViewerItem.displayName = "BubbleMediaViewerItem";
/**
* Quoted-message preview. Renders a `<div>`.
*
* @remarks
* **PROVISIONAL** - no Figma node backs this component anywhere in node
* `27904-3255` (spec §7 #3). Styled by inference from the rest of the
* anatomy pending a design pass; do not treat this visual as final.
*
* @example
* ```tsx
* <BubbleReply author="Alex Finn">Can you send the invoice again?</BubbleReply>
* ```
*/
const BubbleReply = React.forwardRef((componentProps, forwardRef) => {
const { className, author, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded24);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-reply",
className: cn("flex flex-col gap-0.5 rounded-lg border-l-2 border-border bg-muted/50 px-2 py-1.5 text-xs", className)
}, props), {}, { children: [author && /* @__PURE__ */ jsx("span", {
className: "font-semibold text-foreground",
children: author
}), /* @__PURE__ */ jsx("span", {
className: "line-clamp-2 text-muted-foreground",
children
})] }));
});
BubbleReply.displayName = "BubbleReply";
/**
* Small label above a text block ("Summary", "Transcript", a WhatsApp
* template title). Renders a `<div>`. Takes children, full stop - no
* `type: 'summary' | 'transcript' | 'note'` enum, since the label text is
* always consumer-owned localized copy (spec §5).
*
* @example
* ```tsx
* <BubbleSectionLabel>Summary</BubbleSectionLabel>
* ```
*/
const BubbleSectionLabel = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded25);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-section-label",
className: cn("text-xs font-semibold", className)
}, props));
});
BubbleSectionLabel.displayName = "BubbleSectionLabel";
/**
* Plain body text - message content, a section body, or template body/
* footer text. Renders a `<p>`. Always full content; a consumer wanting
* truncation wraps it in their own `line-clamp-*` + toggle (spec §5).
*
* @example
* ```tsx
* <BubbleText>Sure, sending it right now</BubbleText>
* ```
*/
const BubbleText = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded26);
return /* @__PURE__ */ jsx("p", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-text",
className: cn("text-sm break-words whitespace-pre-wrap", className)
}, props));
});
BubbleText.displayName = "BubbleText";
/**
* Destructive icon+text row for a delivery failure or unavailable media.
* Renders a `<div>`. Right-alignment for `align="end"` bubbles is inherited
* from `Bubble`'s own layout, not a separate prop here.
*
* @example
* ```tsx
* <BubbleErrorMessage icon={<CircleAlert />}>
* Not delivered. Recipient's number doesn't support SMS.
* </BubbleErrorMessage>
* ```
*/
const BubbleErrorMessage = React.forwardRef((componentProps, forwardRef) => {
const { className, icon, children } = componentProps, props = _objectWithoutProperties(componentProps, _excluded27);
return /* @__PURE__ */ jsxs("div", _objectSpread2(_objectSpread2({
ref: forwardRef,
"data-slot": "bubble-error-message",
className: cn("flex items-start gap-1.5 text-xs font-medium text-destructive [&_svg]:mt-0.5 [&_svg]:size-3.5 [&_svg]:shrink-0", className)
}, props), {}, { children: [icon, children] }));
});
BubbleErrorMessage.displayName = "BubbleErrorMessage";
/**
* Static in-content action row (e.g. "Generate email"). Renders a `<div>`;
* consumer passes DS `<Button>`(s) as children.
*
* @example
* ```tsx
* <BubbleActions>
* <Button variant="outline" size="sm">Generate email</Button>
* </BubbleActions>
* ```
*/
const BubbleActions = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded28);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-actions",
className: cn("flex flex-wrap items-center gap-2", className)
}, props));
});
BubbleActions.displayName = "BubbleActions";
/**
* Two-cluster grid row INSIDE `BubbleContent` only, a sibling of
* {@link BubbleHeader} (tags/labels start, AI-insight chips end). Renders a
* `<div>` as a 2-column grid; place a {@link BubbleFooterStart} and/or
* {@link BubbleFooterEnd} directly inside - each owns its own cluster's
* `flex`/`gap`, so a consumer never repeats that layout (see the
* `write-ds-story` skill's "stories do not do the component's job"). Not
* interchangeable with {@link BubbleMeta}, which is the sibling shape OUTSIDE
* `BubbleContent` (spec §7 #5) - different cross-axis alignment, different
* padding, different fixed-height behaviour.
*
* @example
* ```tsx
* <BubbleFooter>
* <BubbleFooterStart><Badge color="purple">Label</Badge></BubbleFooterStart>
* <BubbleFooterEnd>
* <BubbleDetailItem icon={<StickyNote />}>1 note</BubbleDetailItem>
* </BubbleFooterEnd>
* </BubbleFooter>
* ```
*/
const BubbleFooter = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded29);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-footer",
className: cn("grid grid-cols-[1fr_auto] items-end gap-2 pt-1 text-xs text-foreground", "[&>[data-slot=bubble-footer-start]]:col-start-1 [&>[data-slot=bubble-footer-end]]:col-start-2", className)
}, props));
});
BubbleFooter.displayName = "BubbleFooter";
/** Start (left) cluster of a {@link BubbleFooter}. Renders a `<div>`, `flex flex-wrap items-center gap-1`. */
const BubbleFooterStart = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded30);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-footer-start",
className: cn("flex flex-wrap items-center gap-1", className)
}, props));
});
BubbleFooterStart.displayName = "BubbleFooterStart";
/** End (right) cluster of a {@link BubbleFooter}. Renders a `<div>`, `flex flex-wrap items-center gap-2`. */
const BubbleFooterEnd = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded31);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-footer-end",
className: cn("flex flex-wrap items-center gap-2", className)
}, props));
});
BubbleFooterEnd.displayName = "BubbleFooterEnd";
/**
* Info icon beside `BubbleContent`, sibling of it inside `Bubble`, for
* contextual tooltips (missed-call reason, undelivered reason). Renders the
* default icon (an `<Info>` svg) unless `render` swaps it for a different
* icon - `render` here swaps the *visual*, not a wrapping host tag, so the
* rendered element is never actually the `<div>` `defaultTagName` implies.
* Always carries `tabIndex={0}` (overridable) so it stays keyboard-reachable
* regardless of which icon renders - an `<svg>` has no implicit tabindex the
* way a `<button>` does, and this part is meant to compose as a DS
* `<TooltipTrigger render={<BubbleHint />}>`, which swaps out
* `TooltipTrigger`'s own naturally-focusable default `<button>` host without
* carrying its interactivity along (spec §11) - `Bubble` never wraps
* `Tooltip` itself (spec §3.4). Also carries the `bubble-hint-slot` class
* alongside `data-slot="bubble-hint"` - `TooltipTrigger`'s own `mergeProps`
* call overwrites `data-slot` on the final rendered element (a plain
* attribute, last-merge-wins) but concatenates `className`, so `Bubble`'s
* grid-placement selector for this part targets the class, not the data
* attribute, and still finds it under a `TooltipTrigger` wrapper (spec §11).
*
* @example
* ```tsx
* <Tooltip>
* <TooltipTrigger render={<BubbleHint />} />
* <TooltipContent>Line busy</TooltipContent>
* </Tooltip>
* ```
*/
const BubbleHint = React.forwardRef((componentProps, forwardRef) => {
const { className, render, "aria-label": ariaLabel } = componentProps, props = _objectWithoutProperties(componentProps, _excluded32);
const t = useDsTranslation("blocks");
return useRender({
defaultTagName: "div",
props: mergeProps({
"data-slot": "bubble-hint",
"aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : t("bubble.hint"),
tabIndex: 0,
className: cn("bubble-hint-slot relative z-2 ml-1 flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50 [&>svg]:size-4", className)
}, { ref: forwardRef }, props),
render: render !== null && render !== void 0 ? render : /* @__PURE__ */ jsx(Info, {})
});
});
BubbleHint.displayName = "BubbleHint";
/**
* Two-cluster grid row OUTSIDE `BubbleContent`, sibling of it inside `Bubble`
* (channel + label start, timestamp + status end). Renders a `<div>` as a
* 2-column grid; place a {@link BubbleMetaStart} and/or {@link BubbleMetaEnd}
* directly inside, or `BubbleDetailItem` for a single icon+label cluster. A
* lone `BubbleMetaEnd` (a bare trailing timestamp, no channel) still lands in
* the end column - explicit slot targeting, not `justify-between`, which
* cannot right-align a single flex child. See {@link BubbleFooter} for why
* this is a distinct part rather than a reuse of it (spec §7 #5).
*
* @example
* ```tsx
* <BubbleMeta>
* <BubbleDetailItem icon={<SocialWhatsappOriginal />}>WhatsApp</BubbleDetailItem>
* <BubbleMetaEnd>10:59am <CheckCheck className="size-4" /></BubbleMetaEnd>
* </BubbleMeta>
* ```
*/
const BubbleMeta = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded33);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-meta",
className: cn("mt-1 grid h-4 w-full grid-cols-[1fr_auto] items-center gap-2 px-1 text-xs text-muted-foreground", "[&>[data-slot=bubble-meta-start]]:col-start-1 [&>[data-slot=bubble-meta-end]]:col-start-2", className)
}, props));
});
BubbleMeta.displayName = "BubbleMeta";
/** Start (left) cluster of a {@link BubbleMeta}. Renders a `<div>`, `flex items-center gap-1`. */
const BubbleMetaStart = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded34);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-meta-start",
className: cn("flex items-center gap-1", className)
}, props));
});
BubbleMetaStart.displayName = "BubbleMetaStart";
/**
* End (right) cluster of a {@link BubbleMeta} - also the part that makes a
* bare trailing timestamp (no channel cluster) land right-aligned rather
* than at the row's start (spec §10). Renders a `<div>`, `flex items-center
* justify-self-end gap-1`.
*/
const BubbleMetaEnd = React.forwardRef((componentProps, forwardRef) => {
const { className } = componentProps, props = _objectWithoutProperties(componentProps, _excluded35);
return /* @__PURE__ */ jsx("div", _objectSpread2({
ref: forwardRef,
"data-slot": "bubble-meta-end",
className: cn("flex items-center justify-self-end gap-1", className)
}, props));
});
BubbleMetaEnd.displayName = "BubbleMetaEnd";
//#endregion
//#region src/components/bubble/use-bubble-media-viewer.ts
/**
* Decides whether a render of {@link useBubbleMediaViewer} has to re-assert the
* opening index, and what it becomes. Returns `null` when there is nothing to
* do. Exported because it is the whole of the fix for the stale-index flash
* and it is the only part of that fix a test can actually observe: under the
* Storybook test runner every interaction is wrapped in `act()`, which flushes
* React's passive effects synchronously, so the intermediate render that
* causes the flash in a real browser never exists there. A story test of the
* reopen therefore passes with or without the fix - verified by disabling it -
* which is why this pure decision is unit-tested instead.
*
* @param startIndex The index the viewer is opening at; `null`/`undefined` means closed.
* @param appliedIndex The `startIndex` already applied on a previous render.
* @param selectedIndex The index currently shown, which Embla also moves.
*/
function getViewerIndexReset(startIndex, appliedIndex, selectedIndex) {
if (startIndex === appliedIndex) return null;
return {
appliedIndex: startIndex,
selectedIndex: startIndex !== null && startIndex !== void 0 ? startIndex : selectedIndex
};
}
/**
* Reads the live slide index out of a DS `<Carousel>` and hands back the
* commands {@link BubbleMediaViewer}'s chrome needs.
*
* Pass the index the viewer is opening at - the SAME state that drives
* `open`, so `number` while open and `null`/`undefined` while closed - and
* pass it to the carousel too (`opts={{ startIndex }}`), which is what
* actually scrolls it. This hook does not scroll anything; it makes the index
* authoritative for the chrome from the first render of each open.
*
* **Why it is read on every render and not just seeded once.** A consumer
* normally keeps this hook mounted alongside the bubble, above the `Dialog`
* that unmounts, so it outlives each open. Seeded once, it kept the last
* index until Embla emitted `select` - which lands a full paint later,
* measured at TWO painted frames in Chrome. For that flash the header named
* the previously viewed asset, the strip marked and scrolled to the wrong
* thumbnail, and the feature read as broken on the one interaction it exists
* for: opening on the tile you clicked. So a change in `startIndex` resets
* the index during render, before anything commits, which is React's own
* pattern for state that has to follow a prop.
*
* `null` is meaningful, not just "absent": it marks the viewer closed, and
* closing must reset the comparison so that reopening on the SAME index the
* user last paged away from still re-asserts it.
*
* @example
* ```tsx
* const [openIndex, setOpenIndex] = useState<number | null>(null);
* const viewer = useBubbleMediaViewer(openIndex);
*
* <Dialog open={openIndex !== null} onOpenChange={o => !o && setOpenIndex(null)}>
* <BubbleMediaViewer onPrevious={viewer.previous} onNext={viewer.next}>
* <Carousel setApi={viewer.setApi} opts={{ startIndex: openIndex }}>…</Carousel>
* </BubbleMediaViewer>
* </Dialog>
* ```
*/
function useBubbleMediaViewer(startIndex) {
const [api, setApi] = React.useState();
const [selectedIndex, setSelectedIndex] = React.useState(startIndex !== null && startIndex !== void 0 ? startIndex : 0);
const [appliedIndex, setAppliedIndex] = React.useState(startIndex);
const reset = getViewerIndexReset(startIndex, appliedIndex, selectedIndex);
if (reset) {
setAppliedIndex(reset.appliedIndex);
setSelectedIndex(reset.selectedIndex);
}
React.useEffect(() => {
if (!api) return;
const onSelect = () => setSelectedIndex(api.selectedScrollSnap());
onSelect();
api.on("select", onSelect);
api.on("reInit", onSelect);
return () => {
api.off("select", onSelect);
api.off("reInit", onSelect);
};
}, [api]);
return {
setApi,
selectedIndex,
select: React.useCallback((index) => {
api === null || api === void 0 || api.scrollTo(index);
}, [api]),
next: React.useCallback(() => {
api === null || api === void 0 || api.scrollNext();
}, [api]),
previous: React.useCallback(() => {
api === null || api === void 0 || api.scrollPrev();
}, [api])
};
}
//#endregion
export { BetaBadge, Bubble, BubbleActions, BubbleContent, BubbleDaySeparator, BubbleDetailItem, BubbleDetailList, BubbleErrorMessage, BubbleFooter, BubbleFooterEnd, BubbleFooterStart, BubbleHeader, BubbleHint, BubbleIcon, BubbleMedia, BubbleMediaGrid, BubbleMediaItem, BubbleMediaViewer, BubbleMediaViewerActions, BubbleMediaViewerAuthor, BubbleMediaViewerCanvas, BubbleMediaViewerClose, BubbleMediaViewerHeader, BubbleMediaViewerItem, BubbleMediaViewerThumbnail, BubbleMediaViewerThumbnails, BubbleMeta, BubbleMetaEnd, BubbleMetaStart, BubbleQuickActions, BubbleReply, BubbleSectionLabel, BubbleSenderLabel, BubbleSubtitle, BubbleText, BubbleTitle, 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, CountryCombobox, DASHBOARD_FILTERS_INLINE_LIMIT, DATE_RANGE_SHORTCUTS, DEFAULT_DATE_RANGE_SHORTCUTS, DashboardFilterField, DashboardFilters, DashboardFiltersClear, DashboardFiltersRow, DashboardFiltersSheet, DashboardPage, DashboardPageBanner, DashboardPageContent, DashboardPageHeader, DashboardPageHeaderAction, DashboardPageHeaderActions, DashboardPageHeaderDescription, DashboardPageHeaderNav, DashboardPageHeaderNavBack, DashboardPageHeaderPrefix, DashboardPageHeaderSubtitle, DashboardPageHeaderTitle, DashboardPageHeaderTitleGroup, DashboardPageMain, DashboardPageTabs, DashboardSidebar, DashboardSidebarContent, DashboardSidebarFooter, DashboardSidebarGroup, DashboardSidebarGroupLabel, DashboardSidebarHeader, DashboardSidebarMenu, DashboardSidebarMenuAddon, DashboardSidebarMenuBadge, DashboardSidebarMenuButton, DashboardSidebarMenuItem, DashboardSidebarNav, DashboardSidebarProvider, DashboardSidebarSubmenu, DashboardSidebarSubmenuItem, DashboardSidebarSubmenuSeparator, DashboardSidebarTrigger, DashboardStandalonePage, DashboardStandalonePageAction, DashboardStandalonePageActions, DashboardStandalonePageContent, DashboardStandalonePageDescription, DashboardStandalonePageHeader, DashboardStandalonePageMain, DashboardStandalonePageTitle, DateRangeCalendar, DaysPicker, DeprecatedBadge, EmptyState, EmptyStateActions, EmptyStateButton, EmptyStateDescription, EmptyStateExternalLink, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle, Filter, FilterClear, FilterCombobox, FilterCountry, FilterLanguage, FilterPanel, FilterRange, FilterTrigger, FormArrayField, FormComboboxField, FormFieldBase, FormFieldShell, FormInputField, FormMultiComboboxField, FormNumericField, FormOTPField, FormPhoneInputField, FormRadioGroupField, FormRichTextareaField, FormSelectField, FormSliderField, FormSwitchField, FormTextareaField, FormToggleGroupField, KpiCard, KpiDescription, KpiDescriptionSkeleton, KpiValue, KpiValueSkeleton, LANGUAGE_CODES, LANGUAGE_SEMANTIC_MAP, LanguageCombobox, LicenseAlert, LicenseAlertAction, LicenseBadge, NewBadge, NoDataDescription, NoDataEmptyState, NoDataMedia, NoDataTitle, NoSupportDescription, NoSupportEmptyState, NoSupportMedia, NoSupportTitle, NotFoundDescription, NotFoundEmptyState, NotFoundMedia, NotFoundTitle, PERCENTAGE_THRESHOLDS, PhoneInput, ProfessionalBadge, RestrictedAccessDescription, RestrictedAccessEmptyState, RestrictedAccessMedia, RestrictedAccessTitle, RoleBadge, SaveBar, ScoreBadge, SentimentBadge, SettingCard, SettingCardAction, SettingCardContent, SettingCardDescription, SettingCardHeader, SettingCardTitle, SubmitButton, TimezoneCombobox, TrialBadge, UnknownErrorDescription, UnknownErrorEmptyState, UnknownErrorMedia, UnknownErrorTitle, Wizard, WizardActions, WizardAside, WizardBack, WizardComplete, WizardLayout, WizardMain, WizardNext, WizardProgress, WizardSection, WizardSectionContent, WizardSectionDescription, WizardSectionFields, WizardSectionHeader, WizardSectionTitle, WizardSkip, WizardStep, WizardStepDescription, WizardStepHeader, WizardStepTitle, bubbleVariants, canonicalizeLanguageCode, capitalizeFirstGrapheme, createDashboardFilterId, dateRangeFromShortcut, fieldContext, filterCountriesByQuery, filterLabelVariants, formContext, getDefaultTimezones, getLanguageSemanticDefinition, getLanguageSemanticVariantKey, getPhoneNumberWithCountry, getUpdatedCursorPosition, isSameDateRange, matchDateRangeShortcut, matchesCountryQuery, renderCountryOption, resolveCountryName, resolveLanguageLabel, resolveLanguageOptionLabel, toFieldErrors, useBubbleMediaViewer, useCopyToClipboard, useCountryOptions, useDashboardFilterRegistration, useDashboardFilterStaging, useDashboardFilterSurface, useSidebar as useDashboardSidebar, useDetectedCountryCode, useFieldContext, useFilter, useForm, useFormContext, useIsTextTruncated, useLanguageOptions, useWizard, withFieldGroup, withForm };