Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 38 additions & 6 deletions src/components/New/PasswordInput/PasswordInput.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PasswordInput
id="pw"
Expand All @@ -109,15 +108,48 @@ describe('Dial UI Kit :: PasswordInput', () => {
/>,
);

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(
<PasswordInput
id="pw"
labelProps={{ label: 'Password' }}
value="secret"
/>,
);

await user.click(screen.getByRole('button', { name: 'Show password' }));
expect(getField()).toHaveAttribute('type', 'text');

rerender(
<PasswordInput
id="pw"
labelProps={{ label: 'Password' }}
value="secret"
disabled
/>,
);
expect(getField()).toHaveAttribute('type', 'password');

rerender(
<PasswordInput
id="pw"
labelProps={{ label: 'Password' }}
value="secret"
/>,
);
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();
Expand Down
6 changes: 4 additions & 2 deletions src/components/New/PasswordInput/PasswordInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
},
},
Expand Down
72 changes: 45 additions & 27 deletions src/components/New/PasswordInput/PasswordInput.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
* <PasswordInput
Expand All @@ -39,7 +42,7 @@ export interface PasswordInputProps extends Omit<
* @param [showPasswordLabel="Show password"] - Accessible name of the toggle while the value is masked
* @param [hidePasswordLabel="Hide password"] - Accessible name of the toggle while the value is visible
* @param [size=ElementSize.Standard] - Field height: standard is 40px, small is 24px
* @param [disabled=false] - Disables the field and its reveal toggle
* @param [disabled=false] - Disables the field, masks it, and hides the reveal toggle
*/
export const PasswordInput: FC<PasswordInputProps> = ({
showPasswordLabel = 'Show password',
Expand All @@ -50,40 +53,55 @@ export const PasswordInput: FC<PasswordInputProps> = ({
}) => {
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 (
<Input
{...props}
size={size}
disabled={disabled}
type={isRevealed ? 'text' : 'password'}
iconAfter={
<GhostIconButton
size={ElementSize.Small}
disabled={disabled}
aria-label={isRevealed ? hidePasswordLabel : showPasswordLabel}
aria-pressed={isRevealed}
icon={
isRevealed ? (
<IconEyeOff
size={DIAL_ICON_SIZE.SM}
stroke={DIAL_KIT_ICON_STROKE}
aria-hidden="true"
/>
) : (
<IconEye
size={DIAL_ICON_SIZE.SM}
stroke={DIAL_KIT_ICON_STROKE}
aria-hidden="true"
/>
)
}
onClick={() => setIsVisible((prev) => !prev)}
/>
disabled ? undefined : (
<GhostIconButton
size={ElementSize.Small}
aria-label={isRevealed ? hidePasswordLabel : showPasswordLabel}
aria-pressed={isRevealed}
icon={
isRevealed ? (
<IconEyeOff
size={DIAL_ICON_SIZE.SM}
stroke={DIAL_KIT_ICON_STROKE}
aria-hidden="true"
/>
) : (
<IconEye
size={DIAL_ICON_SIZE.SM}
stroke={DIAL_KIT_ICON_STROKE}
aria-hidden="true"
/>
)
}
onClick={() => setIsVisible((prev) => !prev)}
/>
)
}
/>
);
Expand Down