diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b33dc5..3926894a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Versions match the git tags on the `development` branch. ### Fixed +- **`PasswordInput` (2.0) — a disabled field no longer draws a toggle it cannot act on** — the field masked its value correctly while disabled, but still rendered the reveal button beside it in a disabled state. The design draws a masked field with nothing after it (see [ai-dial-chat#8791](https://github.com/epam/ai-dial-chat/issues/8791), where a toolset's OAuth client secret sits in exactly that state once the user has logged in), and a control whose only two outcomes are both unreachable is space spent on nothing. The toggle is now absent while `disabled`, and masking resets with it: a field that is disabled and then enabled again comes back masked rather than silently restoring an earlier reveal, which previously left a revealed secret on screen with no pressed toggle to explain it. Nothing changes for an enabled field; a test that asserted on the disabled toggle should assert its absence instead. - **An optional editor peer broke the build of every consumer that did not install it** — this release moved `@monaco-editor/react`, `monaco-editor` and `@uiw/react-md-editor` to optional peers, so `npm install` stops bringing them in; but `JsonEditor` still reached `Editor` and the 2.0 markdown toolbar still reached its command set through **named** imports. A named import of a package that is not installed is a build error, not a runtime one: the root entry re-exports `LazyDialJsonEditor`/`LazyMarkdownEditor`, a bundler resolves a dynamic import target to chunk it, and Vite's stub for the absent optional peer exports nothing — so a host that installed nothing but this kit and React got `[MISSING_EXPORT] "Editor" is not exported by "__vite-optional-peer-dep:@monaco-editor/react:@epam/ai-dial-ui-kit"` and no bundle. Both modules now read their bindings off a namespace import, inside the render/getter that needs them, so nothing resolves by name at build time and a missing engine surfaces where it belongs: on the consumer that renders an editor without installing it. Types stay named — they are erased before any bundler sees them. Nothing changes for a host that installs the editor peers. - **`DialAnalyticsBarGroup` — compare-mode hover now highlights both bars of an entry as one block** — click already targeted the whole entry (label, delta, both bars), but hover used CSS `:hover` on each bar's own container, so only the bar under the pointer lit up (and a per-bar fill left a gap between the pair). The compare entry wrapper is now a Tailwind `group`; when `onBarClick` is set the shared bars container gets a default `group-hover` / `group-focus-visible` accent fill, and in compare mode `barClassName` applies to that container so consumers can use `group-hover:` for a continuous pair highlight. Single-bar mode is unchanged. - **`Tooltip` (2.0) and `DialTooltip` — no tooltip anywhere in an app embedded below 640px, mouse or not** — both bubbles asked `useIsMobileScreen()`, a `window.innerWidth < 640` test, and rendered `null` when it was true. Width is the wrong question: an app running inside an iframe reads the _iframe's_ width, so a chat in a 380px overlay panel lost every tooltip it had — the agent name on a conversation row included — while the desktop mouse hovering it worked exactly as before. The gate is now the `(hover: none)` media query, via the new internal `useHasHover`: a touch-only phone still gets nothing, since nothing there can reveal a hover-only bubble, and a narrow embed driven by a pointer gets its tooltips back. A hybrid device that gains or loses a hovering pointer re-evaluates without a reload, and an environment that cannot answer the question at all (SSR, or no `matchMedia`) counts as hover-capable, so a tooltip is never dropped on a guess. `(pointer: coarse)` is deliberately not part of the query — a touchscreen laptop on a mouse hovers fine. Tooltips remain unusable by touch, so a control still must not depend on one for its accessible name. diff --git a/src/components/New/PasswordInput/PasswordInput.spec.tsx b/src/components/New/PasswordInput/PasswordInput.spec.tsx index d56959f8..2bde5f4a 100644 --- a/src/components/New/PasswordInput/PasswordInput.spec.tsx +++ b/src/components/New/PasswordInput/PasswordInput.spec.tsx @@ -98,8 +98,7 @@ describe('Dial UI Kit :: PasswordInput', () => { ).toBeInTheDocument(); }); - test('a disabled field stays masked and its toggle is disabled', async () => { - const user = userEvent.setup(); + test('a disabled field stays masked and draws no reveal toggle', () => { render( { />, ); - const toggle = screen.getByRole('button', { name: 'Show password' }); - expect(toggle).toBeDisabled(); - - await user.click(toggle); expect(getField()).toHaveAttribute('type', 'password'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); // The value must not leak through the tooltip `Input` adds to disabled fields. expect(screen.queryByText('secret')).not.toBeInTheDocument(); }); + test('a field revealed before being disabled comes back masked', async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Show password' })); + expect(getField()).toHaveAttribute('type', 'text'); + + rerender( + , + ); + expect(getField()).toHaveAttribute('type', 'password'); + + rerender( + , + ); + expect(getField()).toHaveAttribute('type', 'password'); + expect( + screen.getByRole('button', { name: 'Show password' }), + ).toHaveAttribute('aria-pressed', 'false'); + }); + test('reports changes through the Input onChange signature', async () => { const onChange = vi.fn(); const user = userEvent.setup(); diff --git a/src/components/New/PasswordInput/PasswordInput.stories.tsx b/src/components/New/PasswordInput/PasswordInput.stories.tsx index 732894ae..d90b16e3 100644 --- a/src/components/New/PasswordInput/PasswordInput.stories.tsx +++ b/src/components/New/PasswordInput/PasswordInput.stories.tsx @@ -100,8 +100,10 @@ export const Disabled: Story = { docs: { description: { story: - 'A disabled field stays masked and its toggle is disabled, so the value ' + - 'cannot be revealed while the field is out of reach.', + 'A disabled field stays masked and draws no toggle: a control that ' + + 'cannot act is not worth the space, and the value stays unreadable ' + + 'while the field is out of reach. Enabling the field again brings ' + + 'the toggle back, masked.', }, }, }, diff --git a/src/components/New/PasswordInput/PasswordInput.tsx b/src/components/New/PasswordInput/PasswordInput.tsx index e124c719..93bcf5e9 100644 --- a/src/components/New/PasswordInput/PasswordInput.tsx +++ b/src/components/New/PasswordInput/PasswordInput.tsx @@ -1,5 +1,5 @@ import { IconEye, IconEyeOff } from '@tabler/icons-react'; -import { type FC, useState } from 'react'; +import { type FC, useEffect, useState } from 'react'; import { GhostIconButton } from '@/components/New/IconButton/IconButtonWrappers'; import { Input, type InputProps } from '@/components/New/Input/Input'; @@ -26,6 +26,9 @@ export interface PasswordInputProps extends Omit< * both its purpose and its state. `type` and `iconAfter` are owned by this * component; every other {@link Input} prop is passed through. * + * A disabled field is masked with no toggle at all, and comes back masked if it + * is enabled again. + * * @example * ```tsx * = ({ showPasswordLabel = 'Show password', @@ -50,11 +53,25 @@ export const PasswordInput: FC = ({ }) => { const [isVisible, setIsVisible] = useState(false); - // A disabled field is never revealed: its toggle cannot be reached to mask the - // value again, and `Input` exposes the value of a disabled non-password field - // through a tooltip, which would leak the password. + /* + * A disabled field is never revealed, and draws no toggle at all: there is + * nothing a control in that state could do, and the design draws a masked + * field with no trailing button. (`Input` also exposes the value of a + * disabled non-password field through a tooltip, which is why `type` stays + * `password` here rather than only the toggle going away.) + */ const isRevealed = isVisible && !disabled; + /* + * Masking is also reset while disabled, so a field that is disabled and then + * enabled again — a form that locks its inputs during a request, say — comes + * back masked instead of silently restoring a reveal the user asked for + * before, with no toggle on screen to tell them it is still on. + */ + useEffect(() => { + if (disabled) setIsVisible(false); + }, [disabled]); + return ( = ({ disabled={disabled} type={isRevealed ? 'text' : 'password'} iconAfter={ -