Skip to content

Commit 1f0b16a

Browse files
committed
Critique FormGroup, Tabs and ConditionalField.
1 parent 53906a0 commit 1f0b16a

7 files changed

Lines changed: 158 additions & 38 deletions

File tree

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,51 @@
1+
@use '../../../styles/mixins' as *;
2+
3+
/* ---- keepMounted path: grid-template-rows collapse + opacity fade ---- */
4+
15
.keepMounted {
2-
/* Wrapper used only when keepMounted=true */
6+
display: grid;
7+
grid-template-rows: 1fr;
8+
opacity: 1;
9+
transition:
10+
grid-template-rows var(--transition-normal),
11+
opacity var(--transition-fast);
12+
13+
@include reduced-motion {
14+
transition: none;
15+
}
316
}
417

518
.hidden {
6-
display: none;
19+
grid-template-rows: 0fr;
20+
opacity: 0;
21+
pointer-events: none;
722
}
23+
24+
/* Inner wrapper required for the grid collapse technique */
25+
.inner {
26+
overflow: hidden;
27+
min-height: 0;
28+
}
29+
30+
/* ---- Default animated path: fade-in entrance on mount ---- */
31+
32+
.enter {
33+
animation: cfFadeIn var(--transition-normal) ease-out both;
34+
35+
@include reduced-motion {
36+
animation: none;
37+
}
38+
}
39+
40+
@keyframes cfFadeIn {
41+
from {
42+
opacity: 0;
43+
transform: translateY(-0.25rem);
44+
}
45+
46+
to {
47+
opacity: 1;
48+
transform: translateY(0);
49+
}
50+
}
51+

src/components/Form/ConditionalField/ConditionalField.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const SelectDriven: Story = {
6464
};
6565

6666
/**
67-
* With `keepMounted`, the hidden content stays in the DOM (aria-hidden).
67+
* With `keepMounted`, the hidden content stays in the DOM and **animates in/out** smoothly.
6868
* Useful when you need to preserve controlled state across visibility toggles.
6969
*/
7070
export const KeepMounted: Story = {

src/components/Form/ConditionalField/ConditionalField.test.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,23 @@ describe('ConditionalField', () => {
3030
expect(screen.getByTestId('content')).toBeInTheDocument();
3131
});
3232

33-
it('sets aria-hidden on wrapper when keepMounted and hidden', () => {
33+
it('sets inert on wrapper when keepMounted and hidden', () => {
3434
const { container } = render(
3535
<ConditionalField show={false} keepMounted>
3636
<span>inner</span>
3737
</ConditionalField>,
3838
);
39-
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
39+
// inert attribute is present (implicitly sets aria-hidden per HTML spec)
40+
expect(container.firstChild).toHaveAttribute('inert');
4041
});
4142

42-
it('does not set aria-hidden when keepMounted and visible', () => {
43+
it('does not set inert when keepMounted and visible', () => {
4344
const { container } = render(
4445
<ConditionalField show keepMounted>
4546
<span>inner</span>
4647
</ConditionalField>,
4748
);
48-
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
49+
expect(container.firstChild).not.toHaveAttribute('inert');
4950
});
5051

5152
it('renders nothing (null) when show is false without keepMounted', () => {
@@ -56,4 +57,24 @@ describe('ConditionalField', () => {
5657
);
5758
expect(container.firstChild).toBeNull();
5859
});
60+
61+
it('wraps children in a div when animated is true', () => {
62+
const { container } = render(
63+
<ConditionalField show animated>
64+
<span data-testid="content">hello</span>
65+
</ConditionalField>,
66+
);
67+
expect(container.firstChild?.nodeName).toBe('DIV');
68+
expect(screen.getByTestId('content')).toBeInTheDocument();
69+
});
70+
71+
it('does not render wrapper div when not animated (default)', () => {
72+
const { container } = render(
73+
<ConditionalField show>
74+
<span data-testid="content">hello</span>
75+
</ConditionalField>,
76+
);
77+
// Fragment renders — firstChild is the span itself
78+
expect(container.firstChild?.nodeName).toBe('SPAN');
79+
});
5980
});

src/components/Form/ConditionalField/ConditionalField.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ export interface ConditionalFieldProps {
55
/** When true, children are shown. When false, they are hidden (or unmounted). */
66
show: boolean;
77
/**
8-
* When true, children remain in the DOM but are visually hidden when `show` is false.
8+
* When true, children remain in the DOM and animate in/out instead of mounting/unmounting.
99
* Useful for preserving controlled input state across visibility toggles.
1010
* @default false
1111
*/
1212
keepMounted?: boolean;
13-
/** Additional class name for the wrapper element (only rendered when keepMounted). */
13+
/**
14+
* When true (and keepMounted is false), wraps children in a div with a fade-in entrance
15+
* animation on mount. Adds one wrapper element to the DOM.
16+
* @default false
17+
*/
18+
animated?: boolean;
19+
/** Additional class name for the wrapper element (only rendered when keepMounted or animated). */
1420
className?: string;
1521
children: ReactNode;
1622
}
@@ -22,16 +28,19 @@ export interface ConditionalFieldProps {
2228
* accessible default (hidden content is not in the DOM or focus order).
2329
*
2430
* Set `keepMounted` to preserve DOM state (e.g. controlled inputs that must
25-
* retain their value while hidden).
31+
* retain their value while hidden). Content animates in and out smoothly.
32+
*
33+
* Set `animated` (with default mode) to get a fade-in entrance animation on mount.
34+
* Note: this wraps children in a `<div>` — be aware of layout implications.
2635
*
2736
* @example
2837
* ```tsx
29-
* // Unmounts when condition is false (default)
38+
* // Default: unmounts when false
3039
* <ConditionalField show={country === 'us'}>
3140
* <Input label="State" />
3241
* </ConditionalField>
3342
*
34-
* // Keeps mounted but visually hidden
43+
* // Keeps mounted, animates in/out
3544
* <ConditionalField show={hasBillingAddress} keepMounted>
3645
* <FormGroup title="Billing Address">...</FormGroup>
3746
* </ConditionalField>
@@ -40,19 +49,28 @@ export interface ConditionalFieldProps {
4049
export function ConditionalField({
4150
show,
4251
keepMounted = false,
52+
animated = false,
4353
className,
4454
children,
4555
}: ConditionalFieldProps) {
4656
if (keepMounted) {
4757
const wrapperClasses = [styles.keepMounted, !show ? styles.hidden : '', className]
4858
.filter(Boolean)
4959
.join(' ');
60+
// inert implicitly applies aria-hidden — no need to set both
5061
return (
51-
<div className={wrapperClasses} aria-hidden={!show || undefined} inert={!show || undefined}>
52-
{children}
62+
<div className={wrapperClasses} inert={!show || undefined}>
63+
<div className={styles.inner}>{children}</div>
5364
</div>
5465
);
5566
}
5667

57-
return show ? <>{children}</> : null;
68+
if (!show) return null;
69+
70+
if (animated) {
71+
return <div className={[styles.enter, className].filter(Boolean).join(' ')}>{children}</div>;
72+
}
73+
74+
return <>{children}</>;
5875
}
76+

src/components/Form/FormGroup/FormGroup.module.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
border-radius: var(--radius-lg);
66
padding: var(--space-5) var(--space-6);
77
margin: 0;
8+
/* Prevent fieldset from overflowing flex/grid containers */
9+
min-width: 0;
810
font-family: var(--font-family);
911
}
1012

@@ -27,4 +29,6 @@
2729
display: flex;
2830
flex-direction: column;
2931
gap: var(--space-4);
32+
/* Consistent gap between legend and first field regardless of description */
33+
padding-top: var(--space-3);
3034
}

src/components/Tabs/Tabs.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ describe('Tabs', () => {
8484
expect(screen.getByText('Panel A')).toBeInTheDocument();
8585
});
8686

87+
it('arrow key navigation skips disabled tabs', async () => {
88+
const user = userEvent.setup();
89+
render(<SimpleTabs defaultTab="b" />);
90+
const tabB = screen.getByRole('tab', { name: 'Tab B' });
91+
tabB.focus();
92+
// ArrowRight from B would normally go to C (disabled) — should skip to A (wraps)
93+
await user.keyboard('{ArrowRight}');
94+
expect(screen.getByText('Panel A')).toBeInTheDocument();
95+
expect(screen.queryByText('Panel C')).not.toBeInTheDocument();
96+
});
97+
8798
it('throws when Tab is used outside Tabs', () => {
8899
expect(() =>
89100
render(<Tab id="x">orphan</Tab>),

src/components/Tabs/Tabs.tsx

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useState,
77
type HTMLAttributes,
88
type KeyboardEvent,
9+
type RefObject,
910
type ReactNode,
1011
type Ref,
1112
} from 'react';
@@ -17,7 +18,10 @@ interface TabsContextValue {
1718
activeTab: string;
1819
setActiveTab: (id: string) => void;
1920
registerTab: (id: string) => void;
20-
tabIds: React.MutableRefObject<string[]>;
21+
tabIds: RefObject<string[]>;
22+
/** Stable prefix generated once per Tabs instance — shared by Tab and TabPanel
23+
* so aria-controls / aria-labelledby IDs always match. */
24+
instanceId: string;
2125
}
2226

2327
const TabsContext = createContext<TabsContextValue | null>(null);
@@ -70,6 +74,7 @@ export function Tabs({
7074
ref,
7175
...rest
7276
}: TabsProps) {
77+
const instanceId = useId();
7378
const tabIds = useRef<string[]>([]);
7479
const [internalTab, setInternalTab] = useState(defaultTab ?? '');
7580

@@ -94,7 +99,7 @@ export function Tabs({
9499
const classNames = [styles.tabs, className].filter(Boolean).join(' ');
95100

96101
return (
97-
<TabsContext value={{ activeTab, setActiveTab, registerTab, tabIds }}>
102+
<TabsContext value={{ activeTab, setActiveTab, registerTab, tabIds, instanceId }}>
98103
<div ref={ref} className={classNames} {...rest}>
99104
{children}
100105
</div>
@@ -148,40 +153,55 @@ export interface TabProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'id'>
148153
* An individual tab button. Must be a direct child of `<TabList>`.
149154
*
150155
* Keyboard navigation:
151-
* - `ArrowLeft` / `ArrowRight` — move focus between tabs
152-
* - `Home` — focus first tab
153-
* - `End` — focus last tab
154-
* - `Enter` / `Space` — activate focused tab
156+
* - `ArrowLeft` / `ArrowRight` — move focus between tabs (skips disabled tabs)
157+
* - `Home` — focus first enabled tab
158+
* - `End` — focus last enabled tab
159+
* - `Enter` / `Space` — activate focused tab (native button behaviour)
155160
*/
156161
export function Tab({ id, disabled = false, children, className, ref, ...rest }: TabProps) {
157-
const { activeTab, setActiveTab, registerTab, tabIds } = useTabsContext();
158-
const generatedId = useId();
159-
const buttonId = `tab-${id}-${generatedId}`;
160-
const panelId = `tabpanel-${id}-${generatedId.replace(/:/g, '')}`;
162+
const { activeTab, setActiveTab, registerTab, tabIds, instanceId } = useTabsContext();
163+
164+
// IDs derived from the shared instanceId so Tab and TabPanel always agree
165+
const buttonId = `tab-${instanceId}-${id}`;
166+
const panelId = `tabpanel-${instanceId}-${id}`;
161167

162-
// Register on first render
163168
registerTab(id);
164169

165170
const isActive = activeTab === id;
166171
const classNames = [styles.tab, isActive ? styles.active : '', className].filter(Boolean).join(' ');
167172

173+
const isTabDisabled = (tabId: string) => {
174+
const btn = document.querySelector<HTMLButtonElement>(`[data-tab-id="${tabId}"]`);
175+
return btn?.disabled ?? false;
176+
};
177+
168178
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
169179
const ids = tabIds.current;
170180
const currentIndex = ids.indexOf(id);
171181

182+
const findNext = (startIdx: number, direction: 1 | -1): number | null => {
183+
for (let i = 1; i < ids.length; i++) {
184+
const idx = (startIdx + direction * i + ids.length) % ids.length;
185+
if (!isTabDisabled(ids[idx])) return idx;
186+
}
187+
return null;
188+
};
189+
172190
let nextIndex: number | null = null;
173-
if (e.key === 'ArrowRight') nextIndex = (currentIndex + 1) % ids.length;
174-
else if (e.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + ids.length) % ids.length;
175-
else if (e.key === 'Home') nextIndex = 0;
176-
else if (e.key === 'End') nextIndex = ids.length - 1;
191+
if (e.key === 'ArrowRight') nextIndex = findNext(currentIndex, 1);
192+
else if (e.key === 'ArrowLeft') nextIndex = findNext(currentIndex, -1);
193+
else if (e.key === 'Home') nextIndex = ids.findIndex((tid) => !isTabDisabled(tid));
194+
else if (e.key === 'End') {
195+
for (let i = ids.length - 1; i >= 0; i--) {
196+
if (!isTabDisabled(ids[i])) { nextIndex = i; break; }
197+
}
198+
}
177199

178-
if (nextIndex !== null) {
200+
if (nextIndex !== null && nextIndex >= 0) {
179201
e.preventDefault();
180202
const nextId = ids[nextIndex];
181203
setActiveTab(nextId);
182-
// Move DOM focus to the next tab button
183-
const nextButton = document.querySelector<HTMLButtonElement>(`[data-tab-id="${nextId}"]`);
184-
nextButton?.focus();
204+
document.querySelector<HTMLButtonElement>(`[data-tab-id="${nextId}"]`)?.focus();
185205
}
186206

187207
rest.onKeyDown?.(e);
@@ -228,10 +248,11 @@ export interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
228248
* ```
229249
*/
230250
export function TabPanel({ id, children, className, ref, ...rest }: TabPanelProps) {
231-
const { activeTab } = useTabsContext();
232-
const generatedId = useId();
233-
const panelId = `tabpanel-${id}-${generatedId.replace(/:/g, '')}`;
234-
const buttonId = `tab-${id}-${generatedId}`;
251+
const { activeTab, instanceId } = useTabsContext();
252+
253+
// IDs derived from the shared instanceId — must mirror what Tab produces
254+
const panelId = `tabpanel-${instanceId}-${id}`;
255+
const buttonId = `tab-${instanceId}-${id}`;
235256

236257
if (activeTab !== id) return null;
237258

@@ -251,3 +272,4 @@ export function TabPanel({ id, children, className, ref, ...rest }: TabPanelProp
251272
</div>
252273
);
253274
}
275+

0 commit comments

Comments
 (0)