@umodeapporg/ui 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,12 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
- import { useRef } from "react";
2
+ import { useRef, useEffect } from "react";
3
3
  import classNames from "classnames";
4
4
  import { useTranslation } from "react-i18next";
5
5
  import ControlledTooltip from "../UI/ControlledTooltip.js";
6
- import { useVoiceTranscription, VOICE_COMMANDS } from "../../hooks/useVoiceTranscription.js";
6
+ import { useVoiceTranscription, VOICE_COMMANDS, applyVoiceUpdate, appendInterimTranscript } from "../../hooks/useVoiceTranscription.js";
7
7
  import { MaterialSymbol } from "../UI/MaterialSymbol/index.js";
8
8
  import ControlledButton from "./ControlledButton.js";
9
9
  import ControlledTextarea from "./ControlledTextarea.js";
10
- const appendText = (current, addition) => {
11
- if (!addition) return current;
12
- const needsSpace = current.length > 0 && !/\s$/.test(current) && !/^[\s.,!?]/.test(addition);
13
- return current + (needsSpace ? " " : "") + addition;
14
- };
15
- const applyVoiceUpdate = (current, update) => {
16
- let next = current;
17
- for (let i = 0; i < update.deleteWords; i += 1) {
18
- next = next.replace(/\s*\S+\s*$/, "");
19
- }
20
- return appendText(next, update.insert);
21
- };
22
10
  const ControlledVoiceTextarea = ({
23
11
  value,
24
12
  onChange,
@@ -30,18 +18,34 @@ const ControlledVoiceTextarea = ({
30
18
  }) => {
31
19
  const { t } = useTranslation("ui");
32
20
  const committedRef = useRef(value);
33
- const { isSupported, isListening, toggle } = useVoiceTranscription({
21
+ const lastEmittedValueRef = useRef(value);
22
+ useEffect(() => {
23
+ if (value === lastEmittedValueRef.current) return;
24
+ committedRef.current = value;
25
+ lastEmittedValueRef.current = value;
26
+ }, [value]);
27
+ const emitValue = (next) => {
28
+ lastEmittedValueRef.current = next;
29
+ onChange(next);
30
+ };
31
+ const { isSupported, isListening, error, toggle } = useVoiceTranscription({
34
32
  lang,
35
33
  onCommit: (update) => {
36
34
  const next = applyVoiceUpdate(committedRef.current, update);
37
35
  committedRef.current = next;
38
- onChange(next);
36
+ emitValue(next);
39
37
  },
40
- onInterim: (text) => onChange(appendText(committedRef.current, text))
38
+ onInterim: (text) => emitValue(appendInterimTranscript(committedRef.current, text))
41
39
  });
42
40
  const handleManualChange = (next) => {
43
41
  committedRef.current = next;
44
- onChange(next);
42
+ emitValue(next);
43
+ };
44
+ const errorMessageByCode = {
45
+ permission: t("voiceInput.errors.permission"),
46
+ microphone: t("voiceInput.errors.microphone"),
47
+ network: t("voiceInput.errors.network"),
48
+ unavailable: t("voiceInput.errors.unavailable")
45
49
  };
46
50
  const commandHint = VOICE_COMMANDS.map((command) => command.phrases[0]).join(", ");
47
51
  return /* @__PURE__ */ jsxs("div", { className: classNames("relative w-full", className), children: [
@@ -79,6 +83,10 @@ const ControlledVoiceTextarea = ({
79
83
  )
80
84
  }
81
85
  ) })
86
+ ] }),
87
+ isSupported && error && /* @__PURE__ */ jsxs("span", { role: "alert", className: "mt-1 flex items-center gap-1 text-xs text-danger-emphasis", children: [
88
+ /* @__PURE__ */ jsx(MaterialSymbol, { icon: "mic_off", size: 14 }),
89
+ errorMessageByCode[error]
82
90
  ] })
83
91
  ] });
84
92
  };
@@ -1 +1 @@
1
- {"version":3,"file":"ControlledVoiceTextarea.js","sources":["../../../src/components/Form/ControlledVoiceTextarea.tsx"],"sourcesContent":["import { useRef } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport { IVoiceTranscriptionUpdate, useVoiceTranscription, VOICE_COMMANDS } from '@/hooks/useVoiceTranscription';\n\nimport { MaterialSymbol } from '../UI/MaterialSymbol';\n\nimport ControlledButton from './ControlledButton';\nimport ControlledTextarea from './ControlledTextarea';\n\ninterface IControlledVoiceTextareaProps {\n value: string;\n onChange: (value: string) => void;\n placeholder?: string;\n rows?: number;\n lang?: string;\n className?: string;\n disabled?: boolean;\n}\n\nconst appendText = (current: string, addition: string): string => {\n if (!addition) return current;\n const needsSpace = current.length > 0 && !/\\s$/.test(current) && !/^[\\s.,!?]/.test(addition);\n return current + (needsSpace ? ' ' : '') + addition;\n};\n\nconst applyVoiceUpdate = (current: string, update: IVoiceTranscriptionUpdate): string => {\n let next = current;\n for (let i = 0; i < update.deleteWords; i += 1) {\n next = next.replace(/\\s*\\S+\\s*$/, '');\n }\n return appendText(next, update.insert);\n};\n\nconst ControlledVoiceTextarea: React.FC<IControlledVoiceTextareaProps> = ({\n value,\n onChange,\n placeholder,\n rows = 4,\n lang = 'pt-BR',\n className,\n disabled,\n}) => {\n const { t } = useTranslation('ui');\n\n const committedRef = useRef(value);\n\n const { isSupported, isListening, toggle } = useVoiceTranscription({\n lang,\n onCommit: (update) => {\n const next = applyVoiceUpdate(committedRef.current, update);\n committedRef.current = next;\n onChange(next);\n },\n onInterim: (text) => onChange(appendText(committedRef.current, text)),\n });\n\n const handleManualChange = (next: string) => {\n committedRef.current = next;\n onChange(next);\n };\n\n const commandHint = VOICE_COMMANDS.map((command) => command.phrases[0]).join(', ');\n\n return (\n <div className={classNames('relative w-full', className)}>\n <ControlledTextarea\n value={value}\n rows={rows}\n resize=\"none\"\n disabled={disabled}\n placeholder={placeholder}\n onChange={(e) => handleManualChange(e.target.value)}\n className=\"pr-12\"\n />\n\n {isSupported && (\n <div className=\"absolute right-2 top-2 flex items-center gap-1\">\n <ControlledTooltip content={t('voiceInput.commandsHint', { commands: commandHint })} className=\"w-56\">\n <span className=\"flex size-7 items-center justify-center rounded-full text-foreground-faint\">\n <MaterialSymbol icon=\"help\" size={16} />\n </span>\n </ControlledTooltip>\n <ControlledTooltip content={isListening ? t('voiceInput.stop') : t('voiceInput.start')}>\n <ControlledButton\n defaultHtmlButton\n disabled={disabled}\n onClick={toggle}\n className={classNames(\n 'flex size-8 items-center justify-center rounded-full transition-colors',\n isListening\n ? 'bg-danger-soft text-danger-emphasis hover:bg-danger-soft'\n : 'text-foreground-faint hover:bg-muted-hover hover:text-brand-emphasis',\n )}\n >\n <MaterialSymbol\n icon={isListening ? 'stop' : 'mic'}\n size={18}\n className={isListening ? 'animate-pulse' : ''}\n />\n </ControlledButton>\n </ControlledTooltip>\n </div>\n )}\n </div>\n );\n};\n\nexport default ControlledVoiceTextarea;\n"],"names":[],"mappings":";;;;;;;;;AAsBA,MAAM,aAAa,CAAC,SAAiB,aAA6B;AAChE,MAAI,CAAC,SAAU,QAAO;AACtB,QAAM,aAAa,QAAQ,SAAS,KAAK,CAAC,MAAM,KAAK,OAAO,KAAK,CAAC,YAAY,KAAK,QAAQ;AAC3F,SAAO,WAAW,aAAa,MAAM,MAAM;AAC7C;AAEA,MAAM,mBAAmB,CAAC,SAAiB,WAA8C;AACvF,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,OAAO,aAAa,KAAK,GAAG;AAC9C,WAAO,KAAK,QAAQ,cAAc,EAAE;AAAA,EACtC;AACA,SAAO,WAAW,MAAM,OAAO,MAAM;AACvC;AAEA,MAAM,0BAAmE,CAAC;AAAA,EACxE;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,OAAO;AAAA,EACP;AAAA,EACA;AACF,MAAM;AACJ,QAAM,EAAE,EAAA,IAAM,eAAe,IAAI;AAEjC,QAAM,eAAe,OAAO,KAAK;AAEjC,QAAM,EAAE,aAAa,aAAa,OAAA,IAAW,sBAAsB;AAAA,IACjE;AAAA,IACA,UAAU,CAAC,WAAW;AACpB,YAAM,OAAO,iBAAiB,aAAa,SAAS,MAAM;AAC1D,mBAAa,UAAU;AACvB,eAAS,IAAI;AAAA,IACf;AAAA,IACA,WAAW,CAAC,SAAS,SAAS,WAAW,aAAa,SAAS,IAAI,CAAC;AAAA,EAAA,CACrE;AAED,QAAM,qBAAqB,CAAC,SAAiB;AAC3C,iBAAa,UAAU;AACvB,aAAS,IAAI;AAAA,EACf;AAEA,QAAM,cAAc,eAAe,IAAI,CAAC,YAAY,QAAQ,QAAQ,CAAC,CAAC,EAAE,KAAK,IAAI;AAEjF,8BACG,OAAA,EAAI,WAAW,WAAW,mBAAmB,SAAS,GACrD,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA,QAAO;AAAA,QACP;AAAA,QACA;AAAA,QACA,UAAU,CAAC,MAAM,mBAAmB,EAAE,OAAO,KAAK;AAAA,QAClD,WAAU;AAAA,MAAA;AAAA,IAAA;AAAA,IAGX,eACC,qBAAC,OAAA,EAAI,WAAU,kDACb,UAAA;AAAA,MAAA,oBAAC,mBAAA,EAAkB,SAAS,EAAE,2BAA2B,EAAE,UAAU,YAAA,CAAa,GAAG,WAAU,QAC7F,8BAAC,QAAA,EAAK,WAAU,8EACd,UAAA,oBAAC,gBAAA,EAAe,MAAK,QAAO,MAAM,IAAI,EAAA,CACxC,EAAA,CACF;AAAA,MACA,oBAAC,qBAAkB,SAAS,cAAc,EAAE,iBAAiB,IAAI,EAAE,kBAAkB,GACnF,UAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,mBAAiB;AAAA,UACjB;AAAA,UACA,SAAS;AAAA,UACT,WAAW;AAAA,YACT;AAAA,YACA,cACI,6DACA;AAAA,UAAA;AAAA,UAGN,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAM,cAAc,SAAS;AAAA,cAC7B,MAAM;AAAA,cACN,WAAW,cAAc,kBAAkB;AAAA,YAAA;AAAA,UAAA;AAAA,QAC7C;AAAA,MAAA,EACF,CACF;AAAA,IAAA,EAAA,CACF;AAAA,EAAA,GAEJ;AAEJ;"}
1
+ {"version":3,"file":"ControlledVoiceTextarea.js","sources":["../../../src/components/Form/ControlledVoiceTextarea.tsx"],"sourcesContent":["import { useEffect, useRef } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport {\n appendInterimTranscript,\n applyVoiceUpdate,\n TVoiceTranscriptionError,\n useVoiceTranscription,\n VOICE_COMMANDS,\n} from '@/hooks/useVoiceTranscription';\n\nimport { MaterialSymbol } from '../UI/MaterialSymbol';\n\nimport ControlledButton from './ControlledButton';\nimport ControlledTextarea from './ControlledTextarea';\n\ninterface IControlledVoiceTextareaProps {\n value: string;\n onChange: (value: string) => void;\n placeholder?: string;\n rows?: number;\n lang?: string;\n className?: string;\n disabled?: boolean;\n}\n\nconst ControlledVoiceTextarea: React.FC<IControlledVoiceTextareaProps> = ({\n value,\n onChange,\n placeholder,\n rows = 4,\n lang = 'pt-BR',\n className,\n disabled,\n}) => {\n const { t } = useTranslation('ui');\n\n const committedRef = useRef(value);\n const lastEmittedValueRef = useRef(value);\n\n useEffect(() => {\n if (value === lastEmittedValueRef.current) return;\n committedRef.current = value;\n lastEmittedValueRef.current = value;\n }, [value]);\n\n const emitValue = (next: string) => {\n lastEmittedValueRef.current = next;\n onChange(next);\n };\n\n const { isSupported, isListening, error, toggle } = useVoiceTranscription({\n lang,\n onCommit: (update) => {\n const next = applyVoiceUpdate(committedRef.current, update);\n committedRef.current = next;\n emitValue(next);\n },\n onInterim: (text) => emitValue(appendInterimTranscript(committedRef.current, text)),\n });\n\n const handleManualChange = (next: string) => {\n committedRef.current = next;\n emitValue(next);\n };\n\n const errorMessageByCode: Record<TVoiceTranscriptionError, string> = {\n permission: t('voiceInput.errors.permission'),\n microphone: t('voiceInput.errors.microphone'),\n network: t('voiceInput.errors.network'),\n unavailable: t('voiceInput.errors.unavailable'),\n };\n\n const commandHint = VOICE_COMMANDS.map((command) => command.phrases[0]).join(', ');\n\n return (\n <div className={classNames('relative w-full', className)}>\n <ControlledTextarea\n value={value}\n rows={rows}\n resize=\"none\"\n disabled={disabled}\n placeholder={placeholder}\n onChange={(e) => handleManualChange(e.target.value)}\n className=\"pr-12\"\n />\n\n {isSupported && (\n <div className=\"absolute right-2 top-2 flex items-center gap-1\">\n <ControlledTooltip content={t('voiceInput.commandsHint', { commands: commandHint })} className=\"w-56\">\n <span className=\"flex size-7 items-center justify-center rounded-full text-foreground-faint\">\n <MaterialSymbol icon=\"help\" size={16} />\n </span>\n </ControlledTooltip>\n <ControlledTooltip content={isListening ? t('voiceInput.stop') : t('voiceInput.start')}>\n <ControlledButton\n defaultHtmlButton\n disabled={disabled}\n onClick={toggle}\n className={classNames(\n 'flex size-8 items-center justify-center rounded-full transition-colors',\n isListening\n ? 'bg-danger-soft text-danger-emphasis hover:bg-danger-soft'\n : 'text-foreground-faint hover:bg-muted-hover hover:text-brand-emphasis',\n )}\n >\n <MaterialSymbol\n icon={isListening ? 'stop' : 'mic'}\n size={18}\n className={isListening ? 'animate-pulse' : ''}\n />\n </ControlledButton>\n </ControlledTooltip>\n </div>\n )}\n\n {isSupported && error && (\n <span role=\"alert\" className=\"mt-1 flex items-center gap-1 text-xs text-danger-emphasis\">\n <MaterialSymbol icon=\"mic_off\" size={14} />\n {errorMessageByCode[error]}\n </span>\n )}\n </div>\n );\n};\n\nexport default ControlledVoiceTextarea;\n"],"names":[],"mappings":";;;;;;;;;AA4BA,MAAM,0BAAmE,CAAC;AAAA,EACxE;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,OAAO;AAAA,EACP;AAAA,EACA;AACF,MAAM;AACJ,QAAM,EAAE,EAAA,IAAM,eAAe,IAAI;AAEjC,QAAM,eAAe,OAAO,KAAK;AACjC,QAAM,sBAAsB,OAAO,KAAK;AAExC,YAAU,MAAM;AACd,QAAI,UAAU,oBAAoB,QAAS;AAC3C,iBAAa,UAAU;AACvB,wBAAoB,UAAU;AAAA,EAChC,GAAG,CAAC,KAAK,CAAC;AAEV,QAAM,YAAY,CAAC,SAAiB;AAClC,wBAAoB,UAAU;AAC9B,aAAS,IAAI;AAAA,EACf;AAEA,QAAM,EAAE,aAAa,aAAa,OAAO,OAAA,IAAW,sBAAsB;AAAA,IACxE;AAAA,IACA,UAAU,CAAC,WAAW;AACpB,YAAM,OAAO,iBAAiB,aAAa,SAAS,MAAM;AAC1D,mBAAa,UAAU;AACvB,gBAAU,IAAI;AAAA,IAChB;AAAA,IACA,WAAW,CAAC,SAAS,UAAU,wBAAwB,aAAa,SAAS,IAAI,CAAC;AAAA,EAAA,CACnF;AAED,QAAM,qBAAqB,CAAC,SAAiB;AAC3C,iBAAa,UAAU;AACvB,cAAU,IAAI;AAAA,EAChB;AAEA,QAAM,qBAA+D;AAAA,IACnE,YAAY,EAAE,8BAA8B;AAAA,IAC5C,YAAY,EAAE,8BAA8B;AAAA,IAC5C,SAAS,EAAE,2BAA2B;AAAA,IACtC,aAAa,EAAE,+BAA+B;AAAA,EAAA;AAGhD,QAAM,cAAc,eAAe,IAAI,CAAC,YAAY,QAAQ,QAAQ,CAAC,CAAC,EAAE,KAAK,IAAI;AAEjF,8BACG,OAAA,EAAI,WAAW,WAAW,mBAAmB,SAAS,GACrD,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA,QAAO;AAAA,QACP;AAAA,QACA;AAAA,QACA,UAAU,CAAC,MAAM,mBAAmB,EAAE,OAAO,KAAK;AAAA,QAClD,WAAU;AAAA,MAAA;AAAA,IAAA;AAAA,IAGX,eACC,qBAAC,OAAA,EAAI,WAAU,kDACb,UAAA;AAAA,MAAA,oBAAC,mBAAA,EAAkB,SAAS,EAAE,2BAA2B,EAAE,UAAU,YAAA,CAAa,GAAG,WAAU,QAC7F,8BAAC,QAAA,EAAK,WAAU,8EACd,UAAA,oBAAC,gBAAA,EAAe,MAAK,QAAO,MAAM,IAAI,EAAA,CACxC,EAAA,CACF;AAAA,MACA,oBAAC,qBAAkB,SAAS,cAAc,EAAE,iBAAiB,IAAI,EAAE,kBAAkB,GACnF,UAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,mBAAiB;AAAA,UACjB;AAAA,UACA,SAAS;AAAA,UACT,WAAW;AAAA,YACT;AAAA,YACA,cACI,6DACA;AAAA,UAAA;AAAA,UAGN,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAM,cAAc,SAAS;AAAA,cAC7B,MAAM;AAAA,cACN,WAAW,cAAc,kBAAkB;AAAA,YAAA;AAAA,UAAA;AAAA,QAC7C;AAAA,MAAA,EACF,CACF;AAAA,IAAA,GACF;AAAA,IAGD,eAAe,SACd,qBAAC,UAAK,MAAK,SAAQ,WAAU,6DAC3B,UAAA;AAAA,MAAA,oBAAC,gBAAA,EAAe,MAAK,WAAU,MAAM,IAAI;AAAA,MACxC,mBAAmB,KAAK;AAAA,IAAA,EAAA,CAC3B;AAAA,EAAA,GAEJ;AAEJ;"}
@@ -7,7 +7,7 @@ import { twMerge } from "tailwind-merge";
7
7
  import ControlledTooltip from "./ControlledTooltip.js";
8
8
  import { MaterialSymbol } from "./MaterialSymbol/index.js";
9
9
  import { composeRefs } from "../../helpers/composeRefs.js";
10
- import { isPressOnLayeredSurface } from "../../helpers/layeredSurfaces.js";
10
+ import { LAYERED_SURFACE_ATTRIBUTE, isPressOnLayeredSurface } from "../../helpers/layeredSurfaces.js";
11
11
  import { getPortalHost } from "../../helpers/portalHost.js";
12
12
  import { useAnchoredFloating } from "../../hooks/useAnchoredFloating.js";
13
13
  import { floatingVariants } from "../../motion/motionPresets.js";
@@ -117,6 +117,7 @@ const ControlledMenuList = React.forwardRef(
117
117
  animate: "visible",
118
118
  exit: "exit",
119
119
  role: "menu",
120
+ ...LAYERED_SURFACE_ATTRIBUTE,
120
121
  ref: (node) => {
121
122
  setFloating(node);
122
123
  if (typeof forwardedRef === "function") forwardedRef(node);
@@ -1 +1 @@
1
- {"version":3,"file":"ControlledMenu.js","sources":["../../../src/components/UI/ControlledMenu.tsx"],"sourcesContent":["import React, {\n cloneElement,\n createContext,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useState,\n} from 'react';\nimport classNames from 'classnames';\nimport { AnimatePresence, motion } from 'motion/react';\nimport { createPortal } from 'react-dom';\nimport { twMerge } from 'tailwind-merge';\n\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport { MaterialSymbol, SymbolCodepoints } from '@/components/UI/MaterialSymbol';\nimport { composeRefs } from '@/helpers/composeRefs';\nimport { isPressOnLayeredSurface } from '@/helpers/layeredSurfaces';\nimport { getPortalHost } from '@/helpers/portalHost';\nimport { TFloatingPlacement, useAnchoredFloating } from '@/hooks/useAnchoredFloating';\nimport { floatingVariants } from '@/motion';\n\nexport type TMenuPlacement = TFloatingPlacement;\nexport type TMenuItemTone = 'default' | 'danger';\n\nexport interface IMenuDismissConfig {\n /** Closes the menu when an item is pressed. Multi-select surfaces pass false. */\n itemPress?: boolean;\n escapeKey?: boolean;\n outsidePress?: boolean;\n}\n\nexport interface IControlledMenuItem {\n key: string;\n label: string;\n icon?: SymbolCodepoints;\n hint?: string;\n onClick?: () => void;\n disabled?: boolean;\n tone?: TMenuItemTone;\n}\n\ninterface IMenuContext {\n open: boolean;\n setOpen: (open: boolean) => void;\n dismiss?: IMenuDismissConfig;\n listId: string;\n setReference: (node: HTMLElement | null) => void;\n setFloating: (node: HTMLElement | null) => void;\n floatingRef: React.MutableRefObject<HTMLElement | null>;\n referenceRef: React.MutableRefObject<HTMLElement | null>;\n}\n\nconst MenuContext = createContext<IMenuContext | null>(null);\n\nconst useMenuContext = (): IMenuContext => {\n const context = useContext(MenuContext);\n if (!context) throw new Error('Menu parts must be rendered inside ControlledMenuRoot');\n return context;\n};\n\ninterface IControlledMenuRootProps {\n children: React.ReactNode;\n open?: boolean;\n handler?: (open: boolean) => void;\n placement?: TMenuPlacement;\n offsetPx?: number;\n dismiss?: IMenuDismissConfig;\n matchTriggerWidth?: boolean;\n}\n\nexport const ControlledMenuRoot: React.FC<IControlledMenuRootProps> = ({\n children,\n open,\n handler,\n placement = 'bottom-start',\n offsetPx = 6,\n dismiss,\n matchTriggerWidth = false,\n}) => {\n const listId = useId();\n const [uncontrolledOpen, setUncontrolledOpen] = useState(false);\n\n const isControlled = open !== undefined;\n const isOpen = isControlled ? Boolean(open) : uncontrolledOpen;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!isControlled) setUncontrolledOpen(next);\n handler?.(next);\n },\n [isControlled, handler],\n );\n\n const { setReference, setFloating, floatingRef, referenceRef } = useAnchoredFloating({\n open: isOpen,\n placement,\n offsetPx,\n matchReferenceWidth: matchTriggerWidth,\n });\n\n const value = useMemo(\n () => ({ open: isOpen, setOpen, dismiss, listId, setReference, setFloating, floatingRef, referenceRef }),\n [isOpen, setOpen, dismiss, listId, setReference, setFloating, floatingRef, referenceRef],\n );\n\n return <MenuContext.Provider value={value}>{children}</MenuContext.Provider>;\n};\n\ninterface IHandlerChildProps {\n onClick?: (event: React.MouseEvent<HTMLElement>) => void;\n ref?: React.Ref<HTMLElement>;\n}\n\n/**\n * Repassa ao filho o que ele recebe de fora, além do que injeta.\n *\n * O gatilho é o alvo natural de um `ControlledTooltip`, e o tooltip se prende clonando o filho e\n * injetando `onPointerEnter`. Aceitando só `children`, este componente engolia essa injeção e o\n * tooltip simplesmente nunca abria, mesmo com a ordem que a documentação manda usar\n * (tooltip POR FORA do handler). Os `onClick` são encadeados: o de fora roda antes de abrir.\n */\nexport const ControlledMenuHandler = React.forwardRef<\n HTMLElement,\n {\n children: React.ReactNode;\n /**\n * Leaves the popup ARIA to the child: set it when the real trigger is an element INSIDE the\n * child that owns its own role (the select's combobox input), so the wrapper does not\n * claim `aria-haspopup=\"menu\"` for a listbox.\n */\n skipTriggerAria?: boolean;\n } & React.HTMLAttributes<HTMLElement>\n>(({ children, onClick: onOuterClick, skipTriggerAria = false, ...rest }, forwardedRef) => {\n const { open, setOpen, setReference, listId } = useMenuContext();\n\n if (!isValidElement(children)) return <>{children}</>;\n\n const child = children as React.ReactElement<IHandlerChildProps>;\n const triggerAria = skipTriggerAria\n ? {}\n : { 'aria-haspopup': 'menu', 'aria-expanded': open, 'aria-controls': open ? listId : undefined };\n\n return cloneElement(child, {\n ...rest,\n ref: composeRefs<HTMLElement>(setReference, forwardedRef, (child as { ref?: React.Ref<HTMLElement> }).ref),\n ...triggerAria,\n onClick: (event: React.MouseEvent<HTMLElement>) => {\n onOuterClick?.(event);\n child.props.onClick?.(event);\n setOpen(!open);\n },\n } as IHandlerChildProps);\n});\n\nControlledMenuHandler.displayName = 'ControlledMenuHandler';\n\ninterface IControlledMenuListProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n 'className' | 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart' | 'onAnimationEnd' | 'onAnimationIteration'\n> {\n children: React.ReactNode;\n className?: string;\n}\n\nconst FOCUSABLE_ITEM_SELECTOR = '[role=\"menuitem\"]:not([aria-disabled=\"true\"])';\n\nexport const ControlledMenuList = React.forwardRef<HTMLDivElement, IControlledMenuListProps>(\n ({ children, className, onClick, onKeyDown, ...rest }, forwardedRef) => {\n const { open, setOpen, dismiss, listId, setFloating, floatingRef, referenceRef } = useMenuContext();\n\n useEffect(() => {\n if (!open) return undefined;\n\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || dismiss?.escapeKey === false) return;\n setOpen(false);\n };\n\n const handlePointerDown = (event: PointerEvent) => {\n if (dismiss?.outsidePress === false) return;\n const target = event.target as Node | null;\n if (!target) return;\n if (floatingRef.current?.contains(target) || referenceRef.current?.contains(target)) return;\n if (isPressOnLayeredSurface(target, floatingRef.current)) return;\n setOpen(false);\n };\n\n document.addEventListener('keydown', handleKeyDown);\n document.addEventListener('pointerdown', handlePointerDown, true);\n\n return () => {\n document.removeEventListener('keydown', handleKeyDown);\n document.removeEventListener('pointerdown', handlePointerDown, true);\n };\n }, [open, setOpen, dismiss, floatingRef, referenceRef]);\n\n const handleListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(event);\n if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;\n\n const list = floatingRef.current;\n if (!list) return;\n\n const items = Array.from(list.querySelectorAll<HTMLElement>(FOCUSABLE_ITEM_SELECTOR));\n if (items.length === 0) return;\n\n event.preventDefault();\n const currentIndex = items.indexOf(document.activeElement as HTMLElement);\n const step = event.key === 'ArrowDown' ? 1 : -1;\n const nextIndex = currentIndex < 0 ? 0 : (currentIndex + step + items.length) % items.length;\n items[nextIndex]?.focus();\n };\n\n const portalHost = getPortalHost();\n if (portalHost === null) return null;\n\n return createPortal(\n <AnimatePresence>\n {open && (\n <motion.div\n key=\"menu-list\"\n id={listId}\n variants={floatingVariants}\n initial=\"hidden\"\n animate=\"visible\"\n exit=\"exit\"\n role=\"menu\"\n ref={(node) => {\n setFloating(node);\n if (typeof forwardedRef === 'function') forwardedRef(node);\n else if (forwardedRef) forwardedRef.current = node;\n }}\n onKeyDown={handleListKeyDown}\n onClick={(event) => {\n onClick?.(event);\n if (dismiss?.itemPress === false) return;\n const target = event.target as HTMLElement | null;\n if (target?.closest('[role=\"menuitem\"]')) setOpen(false);\n }}\n {...rest}\n className={twMerge(\n 'fixed left-0 top-0 z-[9999] min-w-44 overflow-auto rounded-lg border border-border p-1 text-sm text-foreground glass',\n className,\n )}\n >\n {children}\n </motion.div>\n )}\n </AnimatePresence>,\n portalHost,\n );\n },\n);\n\nControlledMenuList.displayName = 'ControlledMenuList';\n\ninterface IControlledMenuItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'className'> {\n children: React.ReactNode;\n className?: string;\n disabled?: boolean;\n}\n\nexport const ControlledMenuItem: React.FC<IControlledMenuItemProps> = ({\n children,\n className,\n disabled = false,\n onClick,\n ...rest\n}) => (\n <div\n role=\"menuitem\"\n tabIndex={disabled ? -1 : 0}\n aria-disabled={disabled || undefined}\n onClick={disabled ? undefined : onClick}\n onKeyDown={(event) => {\n if (disabled) return;\n if (event.key !== 'Enter' && event.key !== ' ') return;\n event.preventDefault();\n (event.currentTarget as HTMLElement).click();\n }}\n {...rest}\n className={twMerge(\n classNames(\n 'flex w-full cursor-pointer select-none items-center rounded-md px-3 py-2 text-sm outline-none',\n 'transition-colors hover:bg-muted-hover focus:bg-muted-hover',\n { 'pointer-events-none opacity-50': disabled },\n ),\n className,\n )}\n >\n {children}\n </div>\n);\n\ninterface IControlledMenuProps {\n trigger: React.ReactNode;\n items?: Array<IControlledMenuItem>;\n children?: React.ReactNode;\n triggerTooltip?: string;\n placement?: TMenuPlacement;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n disabled?: boolean;\n dismissOnItemClick?: boolean;\n listClassName?: string;\n tooltipClassName?: string;\n listRef?: React.Ref<HTMLDivElement>;\n}\n\nconst ITEM_TONE_CLASS: Record<TMenuItemTone, string> = {\n default: 'text-foreground-muted',\n danger: 'text-danger-emphasis',\n};\n\nconst ICON_TONE_CLASS: Record<TMenuItemTone, string> = {\n default: 'text-foreground-subtle',\n danger: 'text-danger-emphasis',\n};\n\n/**\n * A tooltip on the trigger must sit OUTSIDE the handler: the handler clones its child to\n * inject the click handler and the reference ref, and a tooltip in that position swallows\n * both, so the menu stops opening.\n */\nconst ControlledMenu: React.FC<IControlledMenuProps> = ({\n trigger,\n items,\n children,\n triggerTooltip,\n placement = 'bottom-start',\n open,\n onOpenChange,\n disabled = false,\n dismissOnItemClick = true,\n listClassName,\n tooltipClassName,\n listRef,\n}) => {\n const menuItems = useMemo(() => (Array.isArray(items) ? items : []), [items]);\n const dismiss = useMemo(() => ({ itemPress: dismissOnItemClick }), [dismissOnItemClick]);\n const handle = <ControlledMenuHandler>{trigger}</ControlledMenuHandler>;\n\n if (disabled) return <>{trigger}</>;\n\n return (\n <ControlledMenuRoot open={open} handler={onOpenChange} placement={placement} dismiss={dismiss}>\n {triggerTooltip ? (\n <ControlledTooltip content={triggerTooltip} className={tooltipClassName}>\n {handle}\n </ControlledTooltip>\n ) : (\n handle\n )}\n\n <ControlledMenuList ref={listRef} className={listClassName}>\n {children ??\n menuItems.map((item) => {\n const tone = item.tone ?? 'default';\n\n return (\n <ControlledMenuItem\n key={item.key}\n disabled={item.disabled}\n onClick={item.onClick}\n className={classNames('items-start gap-x-2', ITEM_TONE_CLASS[tone])}\n >\n {item.icon && <MaterialSymbol icon={item.icon} size={16} className={ICON_TONE_CLASS[tone]} />}\n\n <span className=\"flex flex-col items-start gap-y-0.5\">\n <span>{item.label}</span>\n {item.hint && <span className=\"text-xs text-foreground-subtle\">{item.hint}</span>}\n </span>\n </ControlledMenuItem>\n );\n })}\n </ControlledMenuList>\n </ControlledMenuRoot>\n );\n};\n\nexport default ControlledMenu;\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ControlledMenu.js","sources":["../../../src/components/UI/ControlledMenu.tsx"],"sourcesContent":["import React, {\n cloneElement,\n createContext,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useState,\n} from 'react';\nimport classNames from 'classnames';\nimport { AnimatePresence, motion } from 'motion/react';\nimport { createPortal } from 'react-dom';\nimport { twMerge } from 'tailwind-merge';\n\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport { MaterialSymbol, SymbolCodepoints } from '@/components/UI/MaterialSymbol';\nimport { composeRefs } from '@/helpers/composeRefs';\nimport { isPressOnLayeredSurface, LAYERED_SURFACE_ATTRIBUTE } from '@/helpers/layeredSurfaces';\nimport { getPortalHost } from '@/helpers/portalHost';\nimport { TFloatingPlacement, useAnchoredFloating } from '@/hooks/useAnchoredFloating';\nimport { floatingVariants } from '@/motion';\n\nexport type TMenuPlacement = TFloatingPlacement;\nexport type TMenuItemTone = 'default' | 'danger';\n\nexport interface IMenuDismissConfig {\n /** Closes the menu when an item is pressed. Multi-select surfaces pass false. */\n itemPress?: boolean;\n escapeKey?: boolean;\n outsidePress?: boolean;\n}\n\nexport interface IControlledMenuItem {\n key: string;\n label: string;\n icon?: SymbolCodepoints;\n hint?: string;\n onClick?: () => void;\n disabled?: boolean;\n tone?: TMenuItemTone;\n}\n\ninterface IMenuContext {\n open: boolean;\n setOpen: (open: boolean) => void;\n dismiss?: IMenuDismissConfig;\n listId: string;\n setReference: (node: HTMLElement | null) => void;\n setFloating: (node: HTMLElement | null) => void;\n floatingRef: React.MutableRefObject<HTMLElement | null>;\n referenceRef: React.MutableRefObject<HTMLElement | null>;\n}\n\nconst MenuContext = createContext<IMenuContext | null>(null);\n\nconst useMenuContext = (): IMenuContext => {\n const context = useContext(MenuContext);\n if (!context) throw new Error('Menu parts must be rendered inside ControlledMenuRoot');\n return context;\n};\n\ninterface IControlledMenuRootProps {\n children: React.ReactNode;\n open?: boolean;\n handler?: (open: boolean) => void;\n placement?: TMenuPlacement;\n offsetPx?: number;\n dismiss?: IMenuDismissConfig;\n matchTriggerWidth?: boolean;\n}\n\nexport const ControlledMenuRoot: React.FC<IControlledMenuRootProps> = ({\n children,\n open,\n handler,\n placement = 'bottom-start',\n offsetPx = 6,\n dismiss,\n matchTriggerWidth = false,\n}) => {\n const listId = useId();\n const [uncontrolledOpen, setUncontrolledOpen] = useState(false);\n\n const isControlled = open !== undefined;\n const isOpen = isControlled ? Boolean(open) : uncontrolledOpen;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!isControlled) setUncontrolledOpen(next);\n handler?.(next);\n },\n [isControlled, handler],\n );\n\n const { setReference, setFloating, floatingRef, referenceRef } = useAnchoredFloating({\n open: isOpen,\n placement,\n offsetPx,\n matchReferenceWidth: matchTriggerWidth,\n });\n\n const value = useMemo(\n () => ({ open: isOpen, setOpen, dismiss, listId, setReference, setFloating, floatingRef, referenceRef }),\n [isOpen, setOpen, dismiss, listId, setReference, setFloating, floatingRef, referenceRef],\n );\n\n return <MenuContext.Provider value={value}>{children}</MenuContext.Provider>;\n};\n\ninterface IHandlerChildProps {\n onClick?: (event: React.MouseEvent<HTMLElement>) => void;\n ref?: React.Ref<HTMLElement>;\n}\n\n/**\n * Repassa ao filho o que ele recebe de fora, além do que injeta.\n *\n * O gatilho é o alvo natural de um `ControlledTooltip`, e o tooltip se prende clonando o filho e\n * injetando `onPointerEnter`. Aceitando só `children`, este componente engolia essa injeção e o\n * tooltip simplesmente nunca abria, mesmo com a ordem que a documentação manda usar\n * (tooltip POR FORA do handler). Os `onClick` são encadeados: o de fora roda antes de abrir.\n */\nexport const ControlledMenuHandler = React.forwardRef<\n HTMLElement,\n {\n children: React.ReactNode;\n /**\n * Leaves the popup ARIA to the child: set it when the real trigger is an element INSIDE the\n * child that owns its own role (the select's combobox input), so the wrapper does not\n * claim `aria-haspopup=\"menu\"` for a listbox.\n */\n skipTriggerAria?: boolean;\n } & React.HTMLAttributes<HTMLElement>\n>(({ children, onClick: onOuterClick, skipTriggerAria = false, ...rest }, forwardedRef) => {\n const { open, setOpen, setReference, listId } = useMenuContext();\n\n if (!isValidElement(children)) return <>{children}</>;\n\n const child = children as React.ReactElement<IHandlerChildProps>;\n const triggerAria = skipTriggerAria\n ? {}\n : { 'aria-haspopup': 'menu', 'aria-expanded': open, 'aria-controls': open ? listId : undefined };\n\n return cloneElement(child, {\n ...rest,\n ref: composeRefs<HTMLElement>(setReference, forwardedRef, (child as { ref?: React.Ref<HTMLElement> }).ref),\n ...triggerAria,\n onClick: (event: React.MouseEvent<HTMLElement>) => {\n onOuterClick?.(event);\n child.props.onClick?.(event);\n setOpen(!open);\n },\n } as IHandlerChildProps);\n});\n\nControlledMenuHandler.displayName = 'ControlledMenuHandler';\n\ninterface IControlledMenuListProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n 'className' | 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart' | 'onAnimationEnd' | 'onAnimationIteration'\n> {\n children: React.ReactNode;\n className?: string;\n}\n\nconst FOCUSABLE_ITEM_SELECTOR = '[role=\"menuitem\"]:not([aria-disabled=\"true\"])';\n\nexport const ControlledMenuList = React.forwardRef<HTMLDivElement, IControlledMenuListProps>(\n ({ children, className, onClick, onKeyDown, ...rest }, forwardedRef) => {\n const { open, setOpen, dismiss, listId, setFloating, floatingRef, referenceRef } = useMenuContext();\n\n useEffect(() => {\n if (!open) return undefined;\n\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || dismiss?.escapeKey === false) return;\n setOpen(false);\n };\n\n const handlePointerDown = (event: PointerEvent) => {\n if (dismiss?.outsidePress === false) return;\n const target = event.target as Node | null;\n if (!target) return;\n if (floatingRef.current?.contains(target) || referenceRef.current?.contains(target)) return;\n if (isPressOnLayeredSurface(target, floatingRef.current)) return;\n setOpen(false);\n };\n\n document.addEventListener('keydown', handleKeyDown);\n document.addEventListener('pointerdown', handlePointerDown, true);\n\n return () => {\n document.removeEventListener('keydown', handleKeyDown);\n document.removeEventListener('pointerdown', handlePointerDown, true);\n };\n }, [open, setOpen, dismiss, floatingRef, referenceRef]);\n\n const handleListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(event);\n if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;\n\n const list = floatingRef.current;\n if (!list) return;\n\n const items = Array.from(list.querySelectorAll<HTMLElement>(FOCUSABLE_ITEM_SELECTOR));\n if (items.length === 0) return;\n\n event.preventDefault();\n const currentIndex = items.indexOf(document.activeElement as HTMLElement);\n const step = event.key === 'ArrowDown' ? 1 : -1;\n const nextIndex = currentIndex < 0 ? 0 : (currentIndex + step + items.length) % items.length;\n items[nextIndex]?.focus();\n };\n\n const portalHost = getPortalHost();\n if (portalHost === null) return null;\n\n return createPortal(\n <AnimatePresence>\n {open && (\n <motion.div\n key=\"menu-list\"\n id={listId}\n variants={floatingVariants}\n initial=\"hidden\"\n animate=\"visible\"\n exit=\"exit\"\n role=\"menu\"\n {...LAYERED_SURFACE_ATTRIBUTE}\n ref={(node) => {\n setFloating(node);\n if (typeof forwardedRef === 'function') forwardedRef(node);\n else if (forwardedRef) forwardedRef.current = node;\n }}\n onKeyDown={handleListKeyDown}\n onClick={(event) => {\n onClick?.(event);\n if (dismiss?.itemPress === false) return;\n const target = event.target as HTMLElement | null;\n if (target?.closest('[role=\"menuitem\"]')) setOpen(false);\n }}\n {...rest}\n className={twMerge(\n 'fixed left-0 top-0 z-[9999] min-w-44 overflow-auto rounded-lg border border-border p-1 text-sm text-foreground glass',\n className,\n )}\n >\n {children}\n </motion.div>\n )}\n </AnimatePresence>,\n portalHost,\n );\n },\n);\n\nControlledMenuList.displayName = 'ControlledMenuList';\n\ninterface IControlledMenuItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'className'> {\n children: React.ReactNode;\n className?: string;\n disabled?: boolean;\n}\n\nexport const ControlledMenuItem: React.FC<IControlledMenuItemProps> = ({\n children,\n className,\n disabled = false,\n onClick,\n ...rest\n}) => (\n <div\n role=\"menuitem\"\n tabIndex={disabled ? -1 : 0}\n aria-disabled={disabled || undefined}\n onClick={disabled ? undefined : onClick}\n onKeyDown={(event) => {\n if (disabled) return;\n if (event.key !== 'Enter' && event.key !== ' ') return;\n event.preventDefault();\n (event.currentTarget as HTMLElement).click();\n }}\n {...rest}\n className={twMerge(\n classNames(\n 'flex w-full cursor-pointer select-none items-center rounded-md px-3 py-2 text-sm outline-none',\n 'transition-colors hover:bg-muted-hover focus:bg-muted-hover',\n { 'pointer-events-none opacity-50': disabled },\n ),\n className,\n )}\n >\n {children}\n </div>\n);\n\ninterface IControlledMenuProps {\n trigger: React.ReactNode;\n items?: Array<IControlledMenuItem>;\n children?: React.ReactNode;\n triggerTooltip?: string;\n placement?: TMenuPlacement;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n disabled?: boolean;\n dismissOnItemClick?: boolean;\n listClassName?: string;\n tooltipClassName?: string;\n listRef?: React.Ref<HTMLDivElement>;\n}\n\nconst ITEM_TONE_CLASS: Record<TMenuItemTone, string> = {\n default: 'text-foreground-muted',\n danger: 'text-danger-emphasis',\n};\n\nconst ICON_TONE_CLASS: Record<TMenuItemTone, string> = {\n default: 'text-foreground-subtle',\n danger: 'text-danger-emphasis',\n};\n\n/**\n * A tooltip on the trigger must sit OUTSIDE the handler: the handler clones its child to\n * inject the click handler and the reference ref, and a tooltip in that position swallows\n * both, so the menu stops opening.\n */\nconst ControlledMenu: React.FC<IControlledMenuProps> = ({\n trigger,\n items,\n children,\n triggerTooltip,\n placement = 'bottom-start',\n open,\n onOpenChange,\n disabled = false,\n dismissOnItemClick = true,\n listClassName,\n tooltipClassName,\n listRef,\n}) => {\n const menuItems = useMemo(() => (Array.isArray(items) ? items : []), [items]);\n const dismiss = useMemo(() => ({ itemPress: dismissOnItemClick }), [dismissOnItemClick]);\n const handle = <ControlledMenuHandler>{trigger}</ControlledMenuHandler>;\n\n if (disabled) return <>{trigger}</>;\n\n return (\n <ControlledMenuRoot open={open} handler={onOpenChange} placement={placement} dismiss={dismiss}>\n {triggerTooltip ? (\n <ControlledTooltip content={triggerTooltip} className={tooltipClassName}>\n {handle}\n </ControlledTooltip>\n ) : (\n handle\n )}\n\n <ControlledMenuList ref={listRef} className={listClassName}>\n {children ??\n menuItems.map((item) => {\n const tone = item.tone ?? 'default';\n\n return (\n <ControlledMenuItem\n key={item.key}\n disabled={item.disabled}\n onClick={item.onClick}\n className={classNames('items-start gap-x-2', ITEM_TONE_CLASS[tone])}\n >\n {item.icon && <MaterialSymbol icon={item.icon} size={16} className={ICON_TONE_CLASS[tone]} />}\n\n <span className=\"flex flex-col items-start gap-y-0.5\">\n <span>{item.label}</span>\n {item.hint && <span className=\"text-xs text-foreground-subtle\">{item.hint}</span>}\n </span>\n </ControlledMenuItem>\n );\n })}\n </ControlledMenuList>\n </ControlledMenuRoot>\n );\n};\n\nexport default ControlledMenu;\n"],"names":[],"mappings":"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"}
@@ -1,3 +1,21 @@
1
+ /**
2
+ * Surfaces that portal to `document.body`: they land OUTSIDE their opener in the DOM while
3
+ * sitting visually ON TOP of it.
4
+ *
5
+ * A dismissible surface (drawer, popover, menu) must not read a press on one of these as
6
+ * "the user clicked outside, close me". Confirming a dialog or picking from a select's
7
+ * listbox is layered work happening ABOVE the surface, not a dismissal of it.
8
+ *
9
+ * A floating panel also carries `data-layered-surface`, whatever `role` its consumer gives it:
10
+ * the select renders its menu panel as `role="presentation"` (the real `role="listbox"` is the
11
+ * options container inside it), so a role-only selector missed the search field sitting in that
12
+ * panel and read a press on it as a press outside.
13
+ */
14
+ export declare const LAYERED_SURFACE_DATA_ATTRIBUTE = "data-layered-surface";
15
+ export declare const LAYERED_SURFACE_ATTRIBUTE: {
16
+ readonly "data-layered-surface": "";
17
+ };
18
+ export declare const LAYERED_SURFACE_SELECTOR = "[role=\"dialog\"],[role=\"menu\"],[role=\"tooltip\"],[role=\"listbox\"],[data-layered-surface]";
1
19
  /**
2
20
  * Whether the press landed on a surface stacked ABOVE `currentSurface`.
3
21
  *
@@ -1,4 +1,6 @@
1
- const LAYERED_SURFACE_SELECTOR = '[role="dialog"],[role="menu"],[role="tooltip"],[role="listbox"]';
1
+ const LAYERED_SURFACE_DATA_ATTRIBUTE = "data-layered-surface";
2
+ const LAYERED_SURFACE_ATTRIBUTE = { [LAYERED_SURFACE_DATA_ATTRIBUTE]: "" };
3
+ const LAYERED_SURFACE_SELECTOR = `[role="dialog"],[role="menu"],[role="tooltip"],[role="listbox"],[${LAYERED_SURFACE_DATA_ATTRIBUTE}]`;
2
4
  const ALWAYS_ON_TOP_SELECTOR = ".Toastify";
3
5
  const isPressOnLayeredSurface = (target, currentSurface) => {
4
6
  if (!(target instanceof Element)) return false;
@@ -9,6 +11,9 @@ const isPressOnLayeredSurface = (target, currentSurface) => {
9
11
  return Boolean(currentSurface.compareDocumentPosition(pressed) & Node.DOCUMENT_POSITION_FOLLOWING);
10
12
  };
11
13
  export {
14
+ LAYERED_SURFACE_ATTRIBUTE,
15
+ LAYERED_SURFACE_DATA_ATTRIBUTE,
16
+ LAYERED_SURFACE_SELECTOR,
12
17
  isPressOnLayeredSurface
13
18
  };
14
19
  //# sourceMappingURL=layeredSurfaces.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"layeredSurfaces.js","sources":["../../src/helpers/layeredSurfaces.ts"],"sourcesContent":["/**\n * Surfaces that portal to `document.body`: they land OUTSIDE their opener in the DOM while\n * sitting visually ON TOP of it.\n *\n * A dismissible surface (drawer, popover, menu) must not read a press on one of these as\n * \"the user clicked outside, close me\". Confirming a dialog or picking from a select's\n * listbox is layered work happening ABOVE the surface, not a dismissal of it.\n */\nconst LAYERED_SURFACE_SELECTOR = '[role=\"dialog\"],[role=\"menu\"],[role=\"tooltip\"],[role=\"listbox\"]';\n\n/**\n * Superfícies que estão acima de TUDO por construção, e não por posição no documento.\n *\n * O toast é o caso: ele vive num container próprio montado no topo da árvore do app, com\n * z-index máximo, e não é portado junto das outras superfícies. Então a comparação por ordem no\n * documento diz que ele vem ANTES de um drawer portado para o fim do `body`, e o drawer conclui\n * que o clique foi \"fora, feche-me\". Era isso que fazia o X do toast fechar o drawer do produto\n * junto com a notificação.\n *\n * Um toast nunca é dismissal de nada: ele aparece por conta própria, sobre o que estiver aberto.\n */\nconst ALWAYS_ON_TOP_SELECTOR = '.Toastify';\n\n/**\n * Whether the press landed on a surface stacked ABOVE `currentSurface`.\n *\n * The direction matters and checking only \"is it a layered surface\" is wrong: a select\n * opened inside a drawer sits above a panel that is itself `role=\"dialog\"`, so a press\n * anywhere in that drawer would look layered and the select would never close. Every one of\n * these surfaces portals to `document.body`, so the one appended LATER is the one painted on\n * top: document order is the stacking order.\n */\nexport const isPressOnLayeredSurface = (target: EventTarget | null, currentSurface?: Element | null): boolean => {\n if (!(target instanceof Element)) return false;\n if (target.closest(ALWAYS_ON_TOP_SELECTOR)) return true;\n\n const pressed = target.closest(LAYERED_SURFACE_SELECTOR);\n if (!pressed) return false;\n if (!currentSurface || pressed === currentSurface) return false;\n\n return Boolean(currentSurface.compareDocumentPosition(pressed) & Node.DOCUMENT_POSITION_FOLLOWING);\n};\n"],"names":[],"mappings":"AAQA,MAAM,2BAA2B;AAajC,MAAM,yBAAyB;AAWxB,MAAM,0BAA0B,CAAC,QAA4B,mBAA6C;AAC/G,MAAI,EAAE,kBAAkB,SAAU,QAAO;AACzC,MAAI,OAAO,QAAQ,sBAAsB,EAAG,QAAO;AAEnD,QAAM,UAAU,OAAO,QAAQ,wBAAwB;AACvD,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,CAAC,kBAAkB,YAAY,eAAgB,QAAO;AAE1D,SAAO,QAAQ,eAAe,wBAAwB,OAAO,IAAI,KAAK,2BAA2B;AACnG;"}
1
+ {"version":3,"file":"layeredSurfaces.js","sources":["../../src/helpers/layeredSurfaces.ts"],"sourcesContent":["/**\n * Surfaces that portal to `document.body`: they land OUTSIDE their opener in the DOM while\n * sitting visually ON TOP of it.\n *\n * A dismissible surface (drawer, popover, menu) must not read a press on one of these as\n * \"the user clicked outside, close me\". Confirming a dialog or picking from a select's\n * listbox is layered work happening ABOVE the surface, not a dismissal of it.\n *\n * A floating panel also carries `data-layered-surface`, whatever `role` its consumer gives it:\n * the select renders its menu panel as `role=\"presentation\"` (the real `role=\"listbox\"` is the\n * options container inside it), so a role-only selector missed the search field sitting in that\n * panel and read a press on it as a press outside.\n */\nexport const LAYERED_SURFACE_DATA_ATTRIBUTE = 'data-layered-surface';\nexport const LAYERED_SURFACE_ATTRIBUTE = { [LAYERED_SURFACE_DATA_ATTRIBUTE]: '' } as const;\n\nexport const LAYERED_SURFACE_SELECTOR = `[role=\"dialog\"],[role=\"menu\"],[role=\"tooltip\"],[role=\"listbox\"],[${LAYERED_SURFACE_DATA_ATTRIBUTE}]`;\n\n/**\n * Superfícies que estão acima de TUDO por construção, e não por posição no documento.\n *\n * O toast é o caso: ele vive num container próprio montado no topo da árvore do app, com\n * z-index máximo, e não é portado junto das outras superfícies. Então a comparação por ordem no\n * documento diz que ele vem ANTES de um drawer portado para o fim do `body`, e o drawer conclui\n * que o clique foi \"fora, feche-me\". Era isso que fazia o X do toast fechar o drawer do produto\n * junto com a notificação.\n *\n * Um toast nunca é dismissal de nada: ele aparece por conta própria, sobre o que estiver aberto.\n */\nconst ALWAYS_ON_TOP_SELECTOR = '.Toastify';\n\n/**\n * Whether the press landed on a surface stacked ABOVE `currentSurface`.\n *\n * The direction matters and checking only \"is it a layered surface\" is wrong: a select\n * opened inside a drawer sits above a panel that is itself `role=\"dialog\"`, so a press\n * anywhere in that drawer would look layered and the select would never close. Every one of\n * these surfaces portals to `document.body`, so the one appended LATER is the one painted on\n * top: document order is the stacking order.\n */\nexport const isPressOnLayeredSurface = (target: EventTarget | null, currentSurface?: Element | null): boolean => {\n if (!(target instanceof Element)) return false;\n if (target.closest(ALWAYS_ON_TOP_SELECTOR)) return true;\n\n const pressed = target.closest(LAYERED_SURFACE_SELECTOR);\n if (!pressed) return false;\n if (!currentSurface || pressed === currentSurface) return false;\n\n return Boolean(currentSurface.compareDocumentPosition(pressed) & Node.DOCUMENT_POSITION_FOLLOWING);\n};\n"],"names":[],"mappings":"AAaO,MAAM,iCAAiC;AACvC,MAAM,4BAA4B,EAAE,CAAC,8BAA8B,GAAG,GAAA;AAEtE,MAAM,2BAA2B,oEAAoE,8BAA8B;AAa1I,MAAM,yBAAyB;AAWxB,MAAM,0BAA0B,CAAC,QAA4B,mBAA6C;AAC/G,MAAI,EAAE,kBAAkB,SAAU,QAAO;AACzC,MAAI,OAAO,QAAQ,sBAAsB,EAAG,QAAO;AAEnD,QAAM,UAAU,OAAO,QAAQ,wBAAwB;AACvD,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,CAAC,kBAAkB,YAAY,eAAgB,QAAO;AAE1D,SAAO,QAAQ,eAAe,wBAAwB,OAAO,IAAI,KAAK,2BAA2B;AACnG;"}
@@ -1,4 +1,5 @@
1
1
  import { useRef, useEffect } from "react";
2
+ import { LAYERED_SURFACE_SELECTOR } from "../helpers/layeredSurfaces.js";
2
3
  const commitActiveElement = (container) => {
3
4
  const active = document.activeElement;
4
5
  if (active instanceof HTMLElement && container?.contains(active)) active.blur();
@@ -15,7 +16,7 @@ const useInlineEditor = (active, onStopEdit, onNavigate) => {
15
16
  const handlePointerDown = (event) => {
16
17
  const target = event.target instanceof Element ? event.target : null;
17
18
  if (!target || containerRef.current?.contains(target)) return;
18
- if (target.closest('[role="menu"], [role="listbox"]')) return;
19
+ if (target.closest(LAYERED_SURFACE_SELECTOR)) return;
19
20
  commitActiveElement(containerRef.current);
20
21
  onStopEdit();
21
22
  };
@@ -1 +1 @@
1
- {"version":3,"file":"useInlineEditor.js","sources":["../../src/hooks/useInlineEditor.ts"],"sourcesContent":["import React, { useEffect, useRef } from 'react';\n\n// Persiste editores que salvam no blur (ex.: ProductDataSheetField) antes do\n// container desmontar ao navegar/fechar.\nconst commitActiveElement = (container: HTMLElement | null) => {\n const active = document.activeElement;\n if (active instanceof HTMLElement && container?.contains(active)) active.blur();\n};\n\n// Wiring compartilhado dos editores inline: foca o primeiro controle ao abrir,\n// fecha no clique fora e navega entre campos editáveis com Tab / Shift+Tab.\nexport const useInlineEditor = (active: boolean, onStopEdit: () => void, onNavigate: (direction: 1 | -1) => void) => {\n const containerRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!active) return;\n const focusable = containerRef.current?.querySelector<HTMLElement>('input, textarea, select, button, [tabindex]');\n focusable?.focus();\n }, [active]);\n\n useEffect(() => {\n if (!active) return undefined;\n\n const handlePointerDown = (event: PointerEvent) => {\n const target = event.target instanceof Element ? event.target : null;\n if (!target || containerRef.current?.contains(target)) return;\n // Menus dos selects renderizam em portal fora do container; interagir\n // com eles não encerra a edição.\n if (target.closest('[role=\"menu\"], [role=\"listbox\"]')) return;\n commitActiveElement(containerRef.current);\n onStopEdit();\n };\n\n document.addEventListener('pointerdown', handlePointerDown);\n return () => document.removeEventListener('pointerdown', handlePointerDown);\n }, [active, onStopEdit]);\n\n const handleKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === 'Tab') {\n event.preventDefault();\n commitActiveElement(containerRef.current);\n onNavigate(event.shiftKey ? -1 : 1);\n } else if (event.key === 'Escape') {\n event.stopPropagation();\n commitActiveElement(containerRef.current);\n onStopEdit();\n }\n };\n\n return { containerRef, handleKeyDown };\n};\n"],"names":[],"mappings":";AAIA,MAAM,sBAAsB,CAAC,cAAkC;AAC7D,QAAM,SAAS,SAAS;AACxB,MAAI,kBAAkB,eAAe,WAAW,SAAS,MAAM,UAAU,KAAA;AAC3E;AAIO,MAAM,kBAAkB,CAAC,QAAiB,YAAwB,eAA4C;AACnH,QAAM,eAAe,OAAuB,IAAI;AAEhD,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,UAAM,YAAY,aAAa,SAAS,cAA2B,6CAA6C;AAChH,eAAW,MAAA;AAAA,EACb,GAAG,CAAC,MAAM,CAAC;AAEX,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ,QAAO;AAEpB,UAAM,oBAAoB,CAAC,UAAwB;AACjD,YAAM,SAAS,MAAM,kBAAkB,UAAU,MAAM,SAAS;AAChE,UAAI,CAAC,UAAU,aAAa,SAAS,SAAS,MAAM,EAAG;AAGvD,UAAI,OAAO,QAAQ,iCAAiC,EAAG;AACvD,0BAAoB,aAAa,OAAO;AACxC,iBAAA;AAAA,IACF;AAEA,aAAS,iBAAiB,eAAe,iBAAiB;AAC1D,WAAO,MAAM,SAAS,oBAAoB,eAAe,iBAAiB;AAAA,EAC5E,GAAG,CAAC,QAAQ,UAAU,CAAC;AAEvB,QAAM,gBAAgB,CAAC,UAA+B;AACpD,QAAI,MAAM,QAAQ,OAAO;AACvB,YAAM,eAAA;AACN,0BAAoB,aAAa,OAAO;AACxC,iBAAW,MAAM,WAAW,KAAK,CAAC;AAAA,IACpC,WAAW,MAAM,QAAQ,UAAU;AACjC,YAAM,gBAAA;AACN,0BAAoB,aAAa,OAAO;AACxC,iBAAA;AAAA,IACF;AAAA,EACF;AAEA,SAAO,EAAE,cAAc,cAAA;AACzB;"}
1
+ {"version":3,"file":"useInlineEditor.js","sources":["../../src/hooks/useInlineEditor.ts"],"sourcesContent":["import React, { useEffect, useRef } from 'react';\n\nimport { LAYERED_SURFACE_SELECTOR } from '@/helpers/layeredSurfaces';\n\n// Persiste editores que salvam no blur (ex.: ProductDataSheetField) antes do\n// container desmontar ao navegar/fechar.\nconst commitActiveElement = (container: HTMLElement | null) => {\n const active = document.activeElement;\n if (active instanceof HTMLElement && container?.contains(active)) active.blur();\n};\n\n// Wiring compartilhado dos editores inline: foca o primeiro controle ao abrir,\n// fecha no clique fora e navega entre campos editáveis com Tab / Shift+Tab.\nexport const useInlineEditor = (active: boolean, onStopEdit: () => void, onNavigate: (direction: 1 | -1) => void) => {\n const containerRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!active) return;\n const focusable = containerRef.current?.querySelector<HTMLElement>('input, textarea, select, button, [tabindex]');\n focusable?.focus();\n }, [active]);\n\n useEffect(() => {\n if (!active) return undefined;\n\n const handlePointerDown = (event: PointerEvent) => {\n const target = event.target instanceof Element ? event.target : null;\n if (!target || containerRef.current?.contains(target)) return;\n // Select menus portal outside the container; interacting with them (including the search\n // field, which sits in the panel but outside the listbox) does not end the edit.\n if (target.closest(LAYERED_SURFACE_SELECTOR)) return;\n commitActiveElement(containerRef.current);\n onStopEdit();\n };\n\n document.addEventListener('pointerdown', handlePointerDown);\n return () => document.removeEventListener('pointerdown', handlePointerDown);\n }, [active, onStopEdit]);\n\n const handleKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === 'Tab') {\n event.preventDefault();\n commitActiveElement(containerRef.current);\n onNavigate(event.shiftKey ? -1 : 1);\n } else if (event.key === 'Escape') {\n event.stopPropagation();\n commitActiveElement(containerRef.current);\n onStopEdit();\n }\n };\n\n return { containerRef, handleKeyDown };\n};\n"],"names":[],"mappings":";;AAMA,MAAM,sBAAsB,CAAC,cAAkC;AAC7D,QAAM,SAAS,SAAS;AACxB,MAAI,kBAAkB,eAAe,WAAW,SAAS,MAAM,UAAU,KAAA;AAC3E;AAIO,MAAM,kBAAkB,CAAC,QAAiB,YAAwB,eAA4C;AACnH,QAAM,eAAe,OAAuB,IAAI;AAEhD,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,UAAM,YAAY,aAAa,SAAS,cAA2B,6CAA6C;AAChH,eAAW,MAAA;AAAA,EACb,GAAG,CAAC,MAAM,CAAC;AAEX,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ,QAAO;AAEpB,UAAM,oBAAoB,CAAC,UAAwB;AACjD,YAAM,SAAS,MAAM,kBAAkB,UAAU,MAAM,SAAS;AAChE,UAAI,CAAC,UAAU,aAAa,SAAS,SAAS,MAAM,EAAG;AAGvD,UAAI,OAAO,QAAQ,wBAAwB,EAAG;AAC9C,0BAAoB,aAAa,OAAO;AACxC,iBAAA;AAAA,IACF;AAEA,aAAS,iBAAiB,eAAe,iBAAiB;AAC1D,WAAO,MAAM,SAAS,oBAAoB,eAAe,iBAAiB;AAAA,EAC5E,GAAG,CAAC,QAAQ,UAAU,CAAC;AAEvB,QAAM,gBAAgB,CAAC,UAA+B;AACpD,QAAI,MAAM,QAAQ,OAAO;AACvB,YAAM,eAAA;AACN,0BAAoB,aAAa,OAAO;AACxC,iBAAW,MAAM,WAAW,KAAK,CAAC;AAAA,IACpC,WAAW,MAAM,QAAQ,UAAU;AACjC,YAAM,gBAAA;AACN,0BAAoB,aAAa,OAAO;AACxC,iBAAA;AAAA,IACF;AAAA,EACF;AAEA,SAAO,EAAE,cAAc,cAAA;AACzB;"}
@@ -10,6 +10,9 @@ export interface IVoiceTranscriptionUpdate {
10
10
  insert: string;
11
11
  deleteWords: number;
12
12
  }
13
+ export declare const applyVoiceUpdate: (current: string, update: IVoiceTranscriptionUpdate) => string;
14
+ export declare const appendInterimTranscript: (committed: string, interim: string) => string;
15
+ export type TVoiceTranscriptionError = 'permission' | 'microphone' | 'network' | 'unavailable';
13
16
  interface IUseVoiceTranscriptionParams {
14
17
  lang?: string;
15
18
  onCommit: (update: IVoiceTranscriptionUpdate) => void;
@@ -18,6 +21,7 @@ interface IUseVoiceTranscriptionParams {
18
21
  interface IUseVoiceTranscriptionResult {
19
22
  isSupported: boolean;
20
23
  isListening: boolean;
24
+ error: TVoiceTranscriptionError | null;
21
25
  toggle: () => void;
22
26
  stop: () => void;
23
27
  }
@@ -45,34 +45,69 @@ const buildUpdateFromTranscript = (transcript) => {
45
45
  }
46
46
  return { insert, deleteWords };
47
47
  };
48
+ const appendTranscriptText = (current, addition) => {
49
+ if (!addition) return current;
50
+ const needsSpace = current.length > 0 && !/\s$/.test(current) && !/^[\s.,!?]/.test(addition);
51
+ return current + (needsSpace ? " " : "") + addition;
52
+ };
53
+ const applyVoiceUpdate = (current, update) => {
54
+ let next = current;
55
+ for (let wordIndex = 0; wordIndex < update.deleteWords; wordIndex += 1) {
56
+ next = next.replace(/\s*\S+\s*$/, "");
57
+ }
58
+ return appendTranscriptText(next, update.insert);
59
+ };
60
+ const appendInterimTranscript = (committed, interim) => appendTranscriptText(committed, interim);
61
+ const RECOGNITION_ERROR_TO_VOICE_ERROR = {
62
+ "not-allowed": "permission",
63
+ "service-not-allowed": "permission",
64
+ "audio-capture": "microphone",
65
+ network: "network",
66
+ "language-not-supported": "unavailable",
67
+ "bad-grammar": "unavailable"
68
+ };
69
+ const MAX_SILENT_RESTARTS = 2;
70
+ const detachRecognition = (recognition) => {
71
+ recognition.onresult = null;
72
+ recognition.onerror = null;
73
+ recognition.onend = null;
74
+ };
48
75
  const useVoiceTranscription = ({
49
76
  lang = "pt-BR",
50
77
  onCommit,
51
78
  onInterim
52
79
  }) => {
53
80
  const [isListening, setIsListening] = useState(false);
81
+ const [error, setError] = useState(null);
54
82
  const recognitionRef = useRef(null);
83
+ const wantsListeningRef = useRef(false);
84
+ const silentRestartCountRef = useRef(0);
55
85
  const onCommitRef = useRef(onCommit);
56
86
  const onInterimRef = useRef(onInterim);
57
87
  onCommitRef.current = onCommit;
58
88
  onInterimRef.current = onInterim;
59
89
  const isSupported = getRecognitionCtor() !== null;
60
90
  const stop = useCallback(() => {
91
+ wantsListeningRef.current = false;
61
92
  recognitionRef.current?.stop();
62
93
  }, []);
63
- const start = useCallback(() => {
94
+ const startSession = useCallback(() => {
64
95
  const Ctor = getRecognitionCtor();
65
- if (!Ctor) return;
96
+ if (!Ctor) return false;
66
97
  const recognition = new Ctor();
67
98
  recognition.lang = lang;
68
99
  recognition.continuous = true;
69
100
  recognition.interimResults = true;
101
+ let sessionHeardSpeech = false;
102
+ let sessionEndedByError = false;
70
103
  recognition.onresult = (event) => {
104
+ if (recognitionRef.current !== recognition) return;
105
+ sessionHeardSpeech = true;
71
106
  let interim = "";
72
- for (let i = event.resultIndex; i < event.results.length; i += 1) {
73
- const result = event.results[i];
74
- const transcript = result[0].transcript;
75
- if (result.isFinal) {
107
+ for (let resultIndex = event.resultIndex; resultIndex < event.results.length; resultIndex += 1) {
108
+ const result = event.results[resultIndex];
109
+ const transcript = result?.[0]?.transcript ?? "";
110
+ if (result?.isFinal) {
76
111
  onCommitRef.current(buildUpdateFromTranscript(transcript));
77
112
  } else {
78
113
  interim += transcript;
@@ -81,26 +116,66 @@ const useVoiceTranscription = ({
81
116
  onInterimRef.current?.(interim.trim());
82
117
  };
83
118
  recognition.onerror = (event) => {
119
+ if (recognitionRef.current !== recognition) return;
120
+ if (event.error === "no-speech") return;
121
+ sessionEndedByError = true;
122
+ if (event.error === "aborted") return;
84
123
  uiLogger.warn("[useVoiceTranscription] error", event.error);
85
- setIsListening(false);
124
+ setError(RECOGNITION_ERROR_TO_VOICE_ERROR[event.error] ?? "unavailable");
86
125
  };
87
126
  recognition.onend = () => {
127
+ if (recognitionRef.current !== recognition) return;
88
128
  onInterimRef.current?.("");
129
+ silentRestartCountRef.current = sessionHeardSpeech ? 0 : silentRestartCountRef.current + 1;
130
+ const shouldResume = wantsListeningRef.current && !sessionEndedByError && silentRestartCountRef.current <= MAX_SILENT_RESTARTS;
131
+ if (shouldResume && startSession()) return;
132
+ wantsListeningRef.current = false;
133
+ recognitionRef.current = null;
89
134
  setIsListening(false);
90
135
  };
91
136
  recognitionRef.current = recognition;
92
- recognition.start();
93
- setIsListening(true);
137
+ try {
138
+ recognition.start();
139
+ return true;
140
+ } catch (startError) {
141
+ uiLogger.warn("[useVoiceTranscription] start failed", startError);
142
+ detachRecognition(recognition);
143
+ recognitionRef.current = null;
144
+ setError("unavailable");
145
+ return false;
146
+ }
94
147
  }, [lang]);
148
+ const start = useCallback(() => {
149
+ setError(null);
150
+ silentRestartCountRef.current = 0;
151
+ wantsListeningRef.current = true;
152
+ if (startSession()) {
153
+ setIsListening(true);
154
+ return;
155
+ }
156
+ wantsListeningRef.current = false;
157
+ }, [startSession]);
95
158
  const toggle = useCallback(() => {
96
159
  if (isListening) stop();
97
160
  else start();
98
161
  }, [isListening, start, stop]);
99
- useEffect(() => () => recognitionRef.current?.stop(), []);
100
- return { isSupported, isListening, toggle, stop };
162
+ useEffect(
163
+ () => () => {
164
+ wantsListeningRef.current = false;
165
+ const recognition = recognitionRef.current;
166
+ if (!recognition) return;
167
+ detachRecognition(recognition);
168
+ recognitionRef.current = null;
169
+ recognition.abort();
170
+ },
171
+ []
172
+ );
173
+ return { isSupported, isListening, error, toggle, stop };
101
174
  };
102
175
  export {
103
176
  VOICE_COMMANDS,
177
+ appendInterimTranscript,
178
+ applyVoiceUpdate,
104
179
  useVoiceTranscription
105
180
  };
106
181
  //# sourceMappingURL=useVoiceTranscription.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useVoiceTranscription.js","sources":["../../src/hooks/useVoiceTranscription.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { uiLogger } from '@/helpers/uiLogger';\n\ninterface ISpeechRecognitionAlternative {\n transcript: string;\n}\n\ninterface ISpeechRecognitionResult {\n 0: ISpeechRecognitionAlternative;\n isFinal: boolean;\n}\n\ninterface ISpeechRecognitionEvent {\n resultIndex: number;\n results: { length: number; [index: number]: ISpeechRecognitionResult };\n}\n\ninterface ISpeechRecognition {\n lang: string;\n continuous: boolean;\n interimResults: boolean;\n start: () => void;\n stop: () => void;\n onresult: ((event: ISpeechRecognitionEvent) => void) | null;\n onerror: ((event: { error: string }) => void) | null;\n onend: (() => void) | null;\n}\n\ntype TSpeechRecognitionCtor = new () => ISpeechRecognition;\n\nconst getRecognitionCtor = (): TSpeechRecognitionCtor | null => {\n const w = window as unknown as {\n SpeechRecognition?: TSpeechRecognitionCtor;\n webkitSpeechRecognition?: TSpeechRecognitionCtor;\n };\n return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;\n};\n\nexport type TVoiceCommandKey = 'comma' | 'period' | 'newline' | 'space' | 'question' | 'exclamation' | 'deleteWord';\n\ninterface IVoiceCommand {\n key: TVoiceCommandKey;\n phrases: Array<string>;\n insert?: string;\n action?: 'deleteWord';\n}\n\n// Spoken keywords matched against the user's speech. They must stay as literal\n// recognition tokens (not UI strings), so the i18n check is disabled here.\n/* eslint-disable i18n-check/no-portuguese-strings */\nexport const VOICE_COMMANDS: Array<IVoiceCommand> = [\n { key: 'comma', phrases: ['vírgula', 'virgula', 'comma'], insert: ', ' },\n { key: 'period', phrases: ['ponto', 'ponto final', 'period'], insert: '. ' },\n { key: 'newline', phrases: ['nova linha', 'quebra de linha', 'parágrafo', 'paragrafo', 'new line'], insert: '\\n' },\n { key: 'space', phrases: ['espaço', 'espaco', 'space'], insert: ' ' },\n {\n key: 'question',\n phrases: ['interrogação', 'interrogacao', 'ponto de interrogação', 'question mark'],\n insert: '? ',\n },\n {\n key: 'exclamation',\n phrases: ['exclamação', 'exclamacao', 'ponto de exclamação', 'exclamation mark'],\n insert: '! ',\n },\n { key: 'deleteWord', phrases: ['apagar', 'apaga', 'apagar palavra', 'delete'], action: 'deleteWord' },\n];\n/* eslint-enable i18n-check/no-portuguese-strings */\n\nconst PHRASE_TO_COMMAND = new Map<string, IVoiceCommand>();\nfor (const command of VOICE_COMMANDS) {\n for (const phrase of command.phrases) PHRASE_TO_COMMAND.set(phrase, command);\n}\n\nconst normalize = (value: string): string =>\n value\n .trim()\n .toLowerCase()\n .replace(/[.,!?;:]+$/, '');\n\nexport interface IVoiceTranscriptionUpdate {\n insert: string;\n deleteWords: number;\n}\n\nconst buildUpdateFromTranscript = (transcript: string): IVoiceTranscriptionUpdate => {\n const words = transcript.trim().split(/\\s+/).filter(Boolean);\n let insert = '';\n let deleteWords = 0;\n\n for (const word of words) {\n const command = PHRASE_TO_COMMAND.get(normalize(word));\n if (command?.action === 'deleteWord') {\n deleteWords += 1;\n insert = '';\n continue;\n }\n if (command?.insert !== undefined) {\n insert = insert.replace(/\\s+$/, '') + command.insert;\n continue;\n }\n insert += (insert && !insert.endsWith(' ') && !insert.endsWith('\\n') ? ' ' : '') + word;\n }\n\n return { insert, deleteWords };\n};\n\ninterface IUseVoiceTranscriptionParams {\n lang?: string;\n onCommit: (update: IVoiceTranscriptionUpdate) => void;\n onInterim?: (text: string) => void;\n}\n\ninterface IUseVoiceTranscriptionResult {\n isSupported: boolean;\n isListening: boolean;\n toggle: () => void;\n stop: () => void;\n}\n\nexport const useVoiceTranscription = ({\n lang = 'pt-BR',\n onCommit,\n onInterim,\n}: IUseVoiceTranscriptionParams): IUseVoiceTranscriptionResult => {\n const [isListening, setIsListening] = useState(false);\n const recognitionRef = useRef<ISpeechRecognition | null>(null);\n const onCommitRef = useRef(onCommit);\n const onInterimRef = useRef(onInterim);\n\n onCommitRef.current = onCommit;\n onInterimRef.current = onInterim;\n\n const isSupported = getRecognitionCtor() !== null;\n\n const stop = useCallback(() => {\n recognitionRef.current?.stop();\n }, []);\n\n const start = useCallback(() => {\n const Ctor = getRecognitionCtor();\n if (!Ctor) return;\n\n const recognition = new Ctor();\n recognition.lang = lang;\n recognition.continuous = true;\n recognition.interimResults = true;\n\n recognition.onresult = (event) => {\n let interim = '';\n for (let i = event.resultIndex; i < event.results.length; i += 1) {\n const result = event.results[i];\n const transcript = result[0].transcript;\n if (result.isFinal) {\n onCommitRef.current(buildUpdateFromTranscript(transcript));\n } else {\n interim += transcript;\n }\n }\n onInterimRef.current?.(interim.trim());\n };\n\n recognition.onerror = (event) => {\n uiLogger.warn('[useVoiceTranscription] error', event.error);\n setIsListening(false);\n };\n\n recognition.onend = () => {\n onInterimRef.current?.('');\n setIsListening(false);\n };\n\n recognitionRef.current = recognition;\n recognition.start();\n setIsListening(true);\n }, [lang]);\n\n const toggle = useCallback(() => {\n if (isListening) stop();\n else start();\n }, [isListening, start, stop]);\n\n useEffect(() => () => recognitionRef.current?.stop(), []);\n\n return { isSupported, isListening, toggle, stop };\n};\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useVoiceTranscription.js","sources":["../../src/hooks/useVoiceTranscription.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { uiLogger } from '@/helpers/uiLogger';\n\ninterface ISpeechRecognitionAlternative {\n transcript: string;\n}\n\ninterface ISpeechRecognitionResult {\n 0: ISpeechRecognitionAlternative;\n isFinal: boolean;\n}\n\ninterface ISpeechRecognitionEvent {\n resultIndex: number;\n results: { length: number; [index: number]: ISpeechRecognitionResult };\n}\n\ninterface ISpeechRecognition {\n lang: string;\n continuous: boolean;\n interimResults: boolean;\n start: () => void;\n stop: () => void;\n abort: () => void;\n onresult: ((event: ISpeechRecognitionEvent) => void) | null;\n onerror: ((event: { error: string }) => void) | null;\n onend: (() => void) | null;\n}\n\ntype TSpeechRecognitionCtor = new () => ISpeechRecognition;\n\nconst getRecognitionCtor = (): TSpeechRecognitionCtor | null => {\n const w = window as unknown as {\n SpeechRecognition?: TSpeechRecognitionCtor;\n webkitSpeechRecognition?: TSpeechRecognitionCtor;\n };\n return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;\n};\n\nexport type TVoiceCommandKey = 'comma' | 'period' | 'newline' | 'space' | 'question' | 'exclamation' | 'deleteWord';\n\ninterface IVoiceCommand {\n key: TVoiceCommandKey;\n phrases: Array<string>;\n insert?: string;\n action?: 'deleteWord';\n}\n\n// Spoken keywords matched against the user's speech. They must stay as literal\n// recognition tokens (not UI strings), so the i18n check is disabled here.\n/* eslint-disable i18n-check/no-portuguese-strings */\nexport const VOICE_COMMANDS: Array<IVoiceCommand> = [\n { key: 'comma', phrases: ['vírgula', 'virgula', 'comma'], insert: ', ' },\n { key: 'period', phrases: ['ponto', 'ponto final', 'period'], insert: '. ' },\n { key: 'newline', phrases: ['nova linha', 'quebra de linha', 'parágrafo', 'paragrafo', 'new line'], insert: '\\n' },\n { key: 'space', phrases: ['espaço', 'espaco', 'space'], insert: ' ' },\n {\n key: 'question',\n phrases: ['interrogação', 'interrogacao', 'ponto de interrogação', 'question mark'],\n insert: '? ',\n },\n {\n key: 'exclamation',\n phrases: ['exclamação', 'exclamacao', 'ponto de exclamação', 'exclamation mark'],\n insert: '! ',\n },\n { key: 'deleteWord', phrases: ['apagar', 'apaga', 'apagar palavra', 'delete'], action: 'deleteWord' },\n];\n/* eslint-enable i18n-check/no-portuguese-strings */\n\nconst PHRASE_TO_COMMAND = new Map<string, IVoiceCommand>();\nfor (const command of VOICE_COMMANDS) {\n for (const phrase of command.phrases) PHRASE_TO_COMMAND.set(phrase, command);\n}\n\nconst normalize = (value: string): string =>\n value\n .trim()\n .toLowerCase()\n .replace(/[.,!?;:]+$/, '');\n\nexport interface IVoiceTranscriptionUpdate {\n insert: string;\n deleteWords: number;\n}\n\nconst buildUpdateFromTranscript = (transcript: string): IVoiceTranscriptionUpdate => {\n const words = transcript.trim().split(/\\s+/).filter(Boolean);\n let insert = '';\n let deleteWords = 0;\n\n for (const word of words) {\n const command = PHRASE_TO_COMMAND.get(normalize(word));\n if (command?.action === 'deleteWord') {\n deleteWords += 1;\n insert = '';\n continue;\n }\n if (command?.insert !== undefined) {\n insert = insert.replace(/\\s+$/, '') + command.insert;\n continue;\n }\n insert += (insert && !insert.endsWith(' ') && !insert.endsWith('\\n') ? ' ' : '') + word;\n }\n\n return { insert, deleteWords };\n};\n\nconst appendTranscriptText = (current: string, addition: string): string => {\n if (!addition) return current;\n const needsSpace = current.length > 0 && !/\\s$/.test(current) && !/^[\\s.,!?]/.test(addition);\n return current + (needsSpace ? ' ' : '') + addition;\n};\n\nexport const applyVoiceUpdate = (current: string, update: IVoiceTranscriptionUpdate): string => {\n let next = current;\n for (let wordIndex = 0; wordIndex < update.deleteWords; wordIndex += 1) {\n next = next.replace(/\\s*\\S+\\s*$/, '');\n }\n return appendTranscriptText(next, update.insert);\n};\n\nexport const appendInterimTranscript = (committed: string, interim: string): string =>\n appendTranscriptText(committed, interim);\n\nexport type TVoiceTranscriptionError = 'permission' | 'microphone' | 'network' | 'unavailable';\n\nconst RECOGNITION_ERROR_TO_VOICE_ERROR: Record<string, TVoiceTranscriptionError> = {\n 'not-allowed': 'permission',\n 'service-not-allowed': 'permission',\n 'audio-capture': 'microphone',\n network: 'network',\n 'language-not-supported': 'unavailable',\n 'bad-grammar': 'unavailable',\n};\n\nconst MAX_SILENT_RESTARTS = 2;\n\ninterface IUseVoiceTranscriptionParams {\n lang?: string;\n onCommit: (update: IVoiceTranscriptionUpdate) => void;\n onInterim?: (text: string) => void;\n}\n\ninterface IUseVoiceTranscriptionResult {\n isSupported: boolean;\n isListening: boolean;\n error: TVoiceTranscriptionError | null;\n toggle: () => void;\n stop: () => void;\n}\n\nconst detachRecognition = (recognition: ISpeechRecognition) => {\n recognition.onresult = null;\n recognition.onerror = null;\n recognition.onend = null;\n};\n\nexport const useVoiceTranscription = ({\n lang = 'pt-BR',\n onCommit,\n onInterim,\n}: IUseVoiceTranscriptionParams): IUseVoiceTranscriptionResult => {\n const [isListening, setIsListening] = useState(false);\n const [error, setError] = useState<TVoiceTranscriptionError | null>(null);\n const recognitionRef = useRef<ISpeechRecognition | null>(null);\n const wantsListeningRef = useRef(false);\n const silentRestartCountRef = useRef(0);\n const onCommitRef = useRef(onCommit);\n const onInterimRef = useRef(onInterim);\n\n onCommitRef.current = onCommit;\n onInterimRef.current = onInterim;\n\n const isSupported = getRecognitionCtor() !== null;\n\n const stop = useCallback(() => {\n wantsListeningRef.current = false;\n recognitionRef.current?.stop();\n }, []);\n\n const startSession = useCallback((): boolean => {\n const Ctor = getRecognitionCtor();\n if (!Ctor) return false;\n\n const recognition = new Ctor();\n recognition.lang = lang;\n recognition.continuous = true;\n recognition.interimResults = true;\n\n let sessionHeardSpeech = false;\n let sessionEndedByError = false;\n\n recognition.onresult = (event) => {\n if (recognitionRef.current !== recognition) return;\n sessionHeardSpeech = true;\n let interim = '';\n for (let resultIndex = event.resultIndex; resultIndex < event.results.length; resultIndex += 1) {\n const result = event.results[resultIndex];\n const transcript = result?.[0]?.transcript ?? '';\n if (result?.isFinal) {\n onCommitRef.current(buildUpdateFromTranscript(transcript));\n } else {\n interim += transcript;\n }\n }\n onInterimRef.current?.(interim.trim());\n };\n\n recognition.onerror = (event) => {\n if (recognitionRef.current !== recognition) return;\n if (event.error === 'no-speech') return;\n sessionEndedByError = true;\n if (event.error === 'aborted') return;\n uiLogger.warn('[useVoiceTranscription] error', event.error);\n setError(RECOGNITION_ERROR_TO_VOICE_ERROR[event.error] ?? 'unavailable');\n };\n\n recognition.onend = () => {\n if (recognitionRef.current !== recognition) return;\n onInterimRef.current?.('');\n silentRestartCountRef.current = sessionHeardSpeech ? 0 : silentRestartCountRef.current + 1;\n const shouldResume =\n wantsListeningRef.current && !sessionEndedByError && silentRestartCountRef.current <= MAX_SILENT_RESTARTS;\n if (shouldResume && startSession()) return;\n wantsListeningRef.current = false;\n recognitionRef.current = null;\n setIsListening(false);\n };\n\n recognitionRef.current = recognition;\n try {\n recognition.start();\n return true;\n } catch (startError) {\n uiLogger.warn('[useVoiceTranscription] start failed', startError);\n detachRecognition(recognition);\n recognitionRef.current = null;\n setError('unavailable');\n return false;\n }\n }, [lang]);\n\n const start = useCallback(() => {\n setError(null);\n silentRestartCountRef.current = 0;\n wantsListeningRef.current = true;\n if (startSession()) {\n setIsListening(true);\n return;\n }\n wantsListeningRef.current = false;\n }, [startSession]);\n\n const toggle = useCallback(() => {\n if (isListening) stop();\n else start();\n }, [isListening, start, stop]);\n\n useEffect(\n () => () => {\n wantsListeningRef.current = false;\n const recognition = recognitionRef.current;\n if (!recognition) return;\n detachRecognition(recognition);\n recognitionRef.current = null;\n recognition.abort();\n },\n [],\n );\n\n return { isSupported, isListening, error, toggle, stop };\n};\n"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -42,7 +42,7 @@ import { default as default36 } from "./components/UI/NoDataFound/index.js";
42
42
  import { default as default37 } from "./components/UI/SkeletonLoading.js";
43
43
  import { default as default38 } from "./components/UI/UserMenu.js";
44
44
  import { composeRefs } from "./helpers/composeRefs.js";
45
- import { isPressOnLayeredSurface } from "./helpers/layeredSurfaces.js";
45
+ import { LAYERED_SURFACE_ATTRIBUTE, LAYERED_SURFACE_DATA_ATTRIBUTE, LAYERED_SURFACE_SELECTOR, isPressOnLayeredSurface } from "./helpers/layeredSurfaces.js";
46
46
  import { readThemeToken, useThemeChartTokens } from "./helpers/themeTokens.js";
47
47
  import { configureUiLogger, uiLogger } from "./helpers/uiLogger.js";
48
48
  import { configureUiPreferences, readUiPreference, resolveUiPreferenceScopeId, writeUiPreference } from "./helpers/uiPreferences.js";
@@ -86,6 +86,9 @@ export {
86
86
  default33 as CustomCard,
87
87
  default14 as ErrorPage,
88
88
  default34 as InformationCard,
89
+ LAYERED_SURFACE_ATTRIBUTE,
90
+ LAYERED_SURFACE_DATA_ATTRIBUTE,
91
+ LAYERED_SURFACE_SELECTOR,
89
92
  default15 as LinkButton,
90
93
  default35 as LoadingSpinner,
91
94
  MOTION_OFFSET_PX,
@@ -502,7 +502,13 @@ declare const _default: {
502
502
  "voiceInput": {
503
503
  "start": "Transcribe by voice",
504
504
  "stop": "Stop transcription",
505
- "commandsHint": "Voice commands: say {{commands}} to insert punctuation or delete the last word."
505
+ "commandsHint": "Voice commands: say {{commands}} to insert punctuation or delete the last word.",
506
+ "errors": {
507
+ "permission": "Microphone access was blocked. Allow the microphone in your browser permissions and try again.",
508
+ "microphone": "No microphone was found. Connect a microphone and try again.",
509
+ "network": "The browser's transcription service did not respond. Check your connection or use Google Chrome or Microsoft Edge.",
510
+ "unavailable": "Voice transcription is not available in this browser. Use Google Chrome or Microsoft Edge."
511
+ }
506
512
  }
507
513
  }
508
514
  ;
@@ -32,7 +32,7 @@ const unifiedSearchBar = { "clearFilter": "Clear filter" };
32
32
  const uploader = { "dragOr": "Drag or", "selectFile": "select a file", "orPaste": "or paste (Ctrl+V)", "allowedFormats": "Allowed formats:", "upTo": "up to", "megabytesSuffix": "MB", "sendFiles": "Send file(s)", "filesIgnoredMaxSize": "Some files were ignored because they exceeded the maximum size of {{size}}MB", "invalidFormat": "Please add files in the allowed formats", "allowedFormatsTooltip": "Allowed formats: {{formats}} up to {{size}}MB" };
33
33
  const urlLinkCell = { "clickToAccess": "Click to access" };
34
34
  const userMenu = { "loading": "Loading...", "yourAccounts": "Your Accounts", "searchAccount": "Search account", "noAccounts": "No active accounts available", "logout": "Sign out" };
35
- const voiceInput = { "start": "Transcribe by voice", "stop": "Stop transcription", "commandsHint": "Voice commands: say {{commands}} to insert punctuation or delete the last word." };
35
+ const voiceInput = { "start": "Transcribe by voice", "stop": "Stop transcription", "commandsHint": "Voice commands: say {{commands}} to insert punctuation or delete the last word.", "errors": { "permission": "Microphone access was blocked. Allow the microphone in your browser permissions and try again.", "microphone": "No microphone was found. Connect a microphone and try again.", "network": "The browser's transcription service did not respond. Check your connection or use Google Chrome or Microsoft Edge.", "unavailable": "Voice transcription is not available in this browser. Use Google Chrome or Microsoft Edge." } };
36
36
  const en = {
37
37
  accordion,
38
38
  actions,
@@ -505,6 +505,12 @@ export declare const uiLocales: {
505
505
  start: string;
506
506
  stop: string;
507
507
  commandsHint: string;
508
+ errors: {
509
+ permission: string;
510
+ microphone: string;
511
+ network: string;
512
+ unavailable: string;
513
+ };
508
514
  };
509
515
  };
510
516
  readonly pt: {
@@ -1012,6 +1018,12 @@ export declare const uiLocales: {
1012
1018
  start: string;
1013
1019
  stop: string;
1014
1020
  commandsHint: string;
1021
+ errors: {
1022
+ permission: string;
1023
+ microphone: string;
1024
+ network: string;
1025
+ unavailable: string;
1026
+ };
1015
1027
  };
1016
1028
  };
1017
1029
  };
@@ -502,7 +502,13 @@ declare const _default: {
502
502
  "voiceInput": {
503
503
  "start": "Transcrever por voz",
504
504
  "stop": "Parar transcrição",
505
- "commandsHint": "Comandos de voz: diga {{commands}} para inserir pontuação ou apagar a última palavra."
505
+ "commandsHint": "Comandos de voz: diga {{commands}} para inserir pontuação ou apagar a última palavra.",
506
+ "errors": {
507
+ "permission": "O acesso ao microfone foi bloqueado. Libere o microfone nas permissões do navegador e tente de novo.",
508
+ "microphone": "Nenhum microfone foi encontrado. Conecte um microfone e tente de novo.",
509
+ "network": "O serviço de transcrição do navegador não respondeu. Verifique a conexão ou use o Google Chrome ou o Microsoft Edge.",
510
+ "unavailable": "A transcrição por voz não está disponível neste navegador. Use o Google Chrome ou o Microsoft Edge."
511
+ }
506
512
  }
507
513
  }
508
514
  ;
@@ -32,7 +32,7 @@ const unifiedSearchBar = { "clearFilter": "Limpar Filtro" };
32
32
  const uploader = { "dragOr": "Arraste ou", "selectFile": "selecione um arquivo", "orPaste": "ou cole (Ctrl+V)", "allowedFormats": "Formatos permitidos:", "upTo": "até", "megabytesSuffix": "MB", "sendFiles": "Enviar arquivo(s)", "filesIgnoredMaxSize": "Alguns arquivos foram ignorados porque excederam o tamanho máximo de {{size}}MB", "invalidFormat": "Por favor, insira arquivos nos formatos permitidos", "allowedFormatsTooltip": "Formatos permitidos: {{formats}} até {{size}}MB" };
33
33
  const urlLinkCell = { "clickToAccess": "Clique para acessar" };
34
34
  const userMenu = { "loading": "Carregando...", "yourAccounts": "Suas Contas", "searchAccount": "Buscar conta", "noAccounts": "Nenhuma conta ativa disponível", "logout": "Sair" };
35
- const voiceInput = { "start": "Transcrever por voz", "stop": "Parar transcrição", "commandsHint": "Comandos de voz: diga {{commands}} para inserir pontuação ou apagar a última palavra." };
35
+ const voiceInput = { "start": "Transcrever por voz", "stop": "Parar transcrição", "commandsHint": "Comandos de voz: diga {{commands}} para inserir pontuação ou apagar a última palavra.", "errors": { "permission": "O acesso ao microfone foi bloqueado. Libere o microfone nas permissões do navegador e tente de novo.", "microphone": "Nenhum microfone foi encontrado. Conecte um microfone e tente de novo.", "network": "O serviço de transcrição do navegador não respondeu. Verifique a conexão ou use o Google Chrome ou o Microsoft Edge.", "unavailable": "A transcrição por voz não está disponível neste navegador. Use o Google Chrome ou o Microsoft Edge." } };
36
36
  const pt = {
37
37
  accordion,
38
38
  actions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umodeapporg/ui",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "sideEffects": [