From 5616f4d04924d166b56789b8f98f8871edb1be69 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:31:01 -0700 Subject: [PATCH 01/12] Add isInverted background option to TabList. Flips active/inactive tab fills (active white, inactive gray-5) without changing default link styles on inactive tabs. --- src/components/tabs/tab.scss | 11 ++++ src/components/tabs/tab.stories.tsx | 85 ++++++++++++++++------------- src/components/tabs/tab.test.tsx | 13 +++++ src/components/tabs/tab.tsx | 10 +++- 4 files changed, 79 insertions(+), 40 deletions(-) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index 95de049710..c33e67162c 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -34,6 +34,17 @@ border-bottom-color: transparent; } } + + // Background-only invert: inactive keep default link styles. + &--inverted { + button.tab { + background: var(--gray-5); + + &--active { + background: var(--white); + } + } + } } .tab-panel { diff --git a/src/components/tabs/tab.stories.tsx b/src/components/tabs/tab.stories.tsx index 1438030e04..05bcca8b93 100644 --- a/src/components/tabs/tab.stories.tsx +++ b/src/components/tabs/tab.stories.tsx @@ -14,45 +14,52 @@ export default meta; type Story = StoryObj; +const TabsDemo = ({ isInverted = false }: { isInverted?: boolean }) => { + const [activeTab, setActiveTab] = useState('one'); + const onClick = (event: React.MouseEvent) => { + setActiveTab(event.currentTarget.value); + }; + return ( + <> + + + + + + + Panel {activeTab} + + + ); +}; + export const Default: Story = { name: 'Tabs', - render: () => { - const [activeTab, setActiveTab] = useState('one'); - const onClick = (event: React.MouseEvent) => { - setActiveTab(event.currentTarget.value); - }; - return ( - <> - - - - - - - Panel {activeTab} - - - ); - }, + render: () => , +}; + +export const Inverted: Story = { + name: 'Tabs (inverted backgrounds)', + render: () => , }; diff --git a/src/components/tabs/tab.test.tsx b/src/components/tabs/tab.test.tsx index ec6f357c9c..eee9a367fa 100644 --- a/src/components/tabs/tab.test.tsx +++ b/src/components/tabs/tab.test.tsx @@ -14,4 +14,17 @@ describe('', () => { const tabs = screen.getByRole('tablist'); expect(tabs).toBeInTheDocument(); }); + + it('applies inverted background class on TabList', () => { + render( + + + One tab + + Second tab + , + ); + + expect(screen.getByRole('tablist')).toHaveClass('tablist--inverted'); + }); }); diff --git a/src/components/tabs/tab.tsx b/src/components/tabs/tab.tsx index c6cb23af5d..ed266e2696 100644 --- a/src/components/tabs/tab.tsx +++ b/src/components/tabs/tab.tsx @@ -61,14 +61,22 @@ export const Tab = ({ export interface TabListProperties extends HTMLAttributes { className?: string; children?: ReactNode; + /** + * Invert tab backgrounds: inactive tabs use the default gray fill, + * active tab uses white. Link colors and other chrome are unchanged. + */ + isInverted?: boolean; } export const TabList = ({ className, children, + isInverted = false, ...properties }: TabListProperties): JSXElement => { - const cname = classnames('tablist', className); + const cname = classnames('tablist', className, { + 'tablist--inverted': isInverted, + }); return (
From 9dad8afb9194cfc5487e44aa779f1b1241a47566 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:33:35 -0700 Subject: [PATCH 02/12] Give inverted inactive tabs a full gray border. Inactive tabs were only getting the gray fill; keep a gray-40 border on all sides while the active tab still opens into the panel. --- src/components/tabs/tab.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index c33e67162c..29547505cf 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -35,13 +35,15 @@ } } - // Background-only invert: inactive keep default link styles. + // Invert fills; inactive keep default link styles but show a full border. &--inverted { button.tab { background: var(--gray-5); + border-color: var(--gray-40); &--active { background: var(--white); + border-bottom-color: transparent; } } } From 80015dc1590a6d0b563ab4b4cae5d71101eb424a Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:34:43 -0700 Subject: [PATCH 03/12] Overlap inverted tab side borders so neighbors share one divider. Adjacent 1px borders were stacking into a thicker seam; pull following tabs left by 1px and raise the active tab. --- src/components/tabs/tab.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index 29547505cf..0df024f5da 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -36,12 +36,20 @@ } // Invert fills; inactive keep default link styles but show a full border. + // Overlap adjacent borders so neighbors share a single 1px divider. &--inverted { button.tab { background: var(--gray-5); border-color: var(--gray-40); + margin-left: -1px; + + &:first-child { + margin-left: 0; + } &--active { + position: relative; + z-index: 1; background: var(--white); border-bottom-color: transparent; } From 1c56dbe2e2bc9e430c2606d995a97cd817203d2f Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:09:21 -0700 Subject: [PATCH 04/12] Fix inverted tab focus ring clipping and border overlap. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise :focus-visible above neighboring tabs and use a positive outline-offset so the ring isn’t eaten by the shared side border. --- src/components/tabs/tab.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index 0df024f5da..0764b5c55a 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -47,6 +47,14 @@ margin-left: 0; } + // Raise focused tab above neighbors and keep the outline outside the + // shared border so the ring isn’t clipped or drawn on top of gray-40. + &:focus-visible { + position: relative; + z-index: 2; + outline-offset: 2px; + } + &--active { position: relative; z-index: 1; From ecd68eb917f3ccf9f531609ec9d9a0f4e87e24b0 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:21:19 -0700 Subject: [PATCH 05/12] Simplify inverted tabs to transparent fills only. Drop gray/white fills and inactive borders; inverted now matches default tab chrome with no backgrounds. --- src/components/tabs/tab.scss | 24 +++--------------------- src/components/tabs/tab.stories.tsx | 2 +- src/components/tabs/tab.tsx | 4 ++-- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index 0764b5c55a..6916b7f4a9 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -35,31 +35,13 @@ } } - // Invert fills; inactive keep default link styles but show a full border. - // Overlap adjacent borders so neighbors share a single 1px divider. + // Like default tabs, but with no tab fills. &--inverted { button.tab { - background: var(--gray-5); - border-color: var(--gray-40); - margin-left: -1px; - - &:first-child { - margin-left: 0; - } - - // Raise focused tab above neighbors and keep the outline outside the - // shared border so the ring isn’t clipped or drawn on top of gray-40. - &:focus-visible { - position: relative; - z-index: 2; - outline-offset: 2px; - } + background: transparent; &--active { - position: relative; - z-index: 1; - background: var(--white); - border-bottom-color: transparent; + background: transparent; } } } diff --git a/src/components/tabs/tab.stories.tsx b/src/components/tabs/tab.stories.tsx index 05bcca8b93..53010579c0 100644 --- a/src/components/tabs/tab.stories.tsx +++ b/src/components/tabs/tab.stories.tsx @@ -60,6 +60,6 @@ export const Default: Story = { }; export const Inverted: Story = { - name: 'Tabs (inverted backgrounds)', + name: 'Tabs (no background)', render: () => , }; diff --git a/src/components/tabs/tab.tsx b/src/components/tabs/tab.tsx index ed266e2696..78cc3a85b9 100644 --- a/src/components/tabs/tab.tsx +++ b/src/components/tabs/tab.tsx @@ -62,8 +62,8 @@ export interface TabListProperties extends HTMLAttributes { className?: string; children?: ReactNode; /** - * Invert tab backgrounds: inactive tabs use the default gray fill, - * active tab uses white. Link colors and other chrome are unchanged. + * Render tabs without fills. Same chrome as default tabs (active border, + * inactive link styles), but transparent backgrounds on every tab. */ isInverted?: boolean; } From b56041a3fb103e76b09ef61366f3c0e1e83db9c2 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:23:36 -0700 Subject: [PATCH 06/12] Mask the tablist rule under inverted active tabs. Transparent fills were letting the list bottom border show through; cover that 1px so the active tab has no underline into the panel. --- src/components/tabs/tab.scss | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index 6916b7f4a9..eaa54c109d 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -35,13 +35,24 @@ } } - // Like default tabs, but with no tab fills. + // Like default tabs, but with no tab fills. Mask the tablist rule under the + // active tab so it still opens into the panel without a bottom border. &--inverted { button.tab { background: transparent; &--active { + position: relative; background: transparent; + + &::after { + content: ''; + position: absolute; + inset-inline: 0; + bottom: -1px; + height: 1px; + background: var(--white); + } } } } From d5bb42bbad9ec4d732d38810f170f6081b15fdf9 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:24:17 -0700 Subject: [PATCH 07/12] Rename inverted tabs Storybook title. --- src/components/tabs/tab.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/tabs/tab.stories.tsx b/src/components/tabs/tab.stories.tsx index 53010579c0..05bcca8b93 100644 --- a/src/components/tabs/tab.stories.tsx +++ b/src/components/tabs/tab.stories.tsx @@ -60,6 +60,6 @@ export const Default: Story = { }; export const Inverted: Story = { - name: 'Tabs (no background)', + name: 'Tabs (inverted backgrounds)', render: () => , }; From 889b479ec4ea74023d2c9b7f5e46ebcff92a1ba7 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:58:35 -0700 Subject: [PATCH 08/12] Harden inverted tab transparent backgrounds against .a-btn fills. App bundles can let equal-specificity button/tab background rules win; force transparent fills on tablist--inverted. --- src/components/tabs/tab.scss | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index eaa54c109d..19d392464b 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -37,13 +37,17 @@ // Like default tabs, but with no tab fills. Mask the tablist rule under the // active tab so it still opens into the panel without a bottom border. + // Use background-color + !important so DS `.a-btn` / default `.tab--active` + // fills cannot win on equal specificity in app bundles. &--inverted { button.tab { - background: transparent; + background-color: transparent !important; + background-image: none !important; &--active { position: relative; - background: transparent; + background-color: transparent !important; + background-image: none !important; &::after { content: ''; From f8e635271bcfeb982cda44628a8b493cb6dc2ddc Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:07:15 -0700 Subject: [PATCH 09/12] Hide the active tab hairline on white and retina displays. --- src/components/tabs/tab.scss | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index 19d392464b..915ca7ffcc 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -26,12 +26,27 @@ } &--active { + position: relative; color: var(--black); - background: var(--gray-5); + // Beat `.a-btn--link { background-color: transparent !important }` so the + // active fill can cover the tablist rule. + background-color: var(--gray-5) !important; + background-image: none !important; text-decoration: none; pointer-events: none; border-color: var(--gray-40); border-bottom-color: transparent; + + // Mask the tablist rule under the active tab. 2px tall so retina/mobile + // subpixel rounding cannot leave a hairline. + &::after { + content: ''; + position: absolute; + inset-inline: 0; + bottom: -1px; + height: 2px; + background-color: var(--gray-5); + } } } @@ -45,17 +60,11 @@ background-image: none !important; &--active { - position: relative; background-color: transparent !important; background-image: none !important; &::after { - content: ''; - position: absolute; - inset-inline: 0; - bottom: -1px; - height: 1px; - background: var(--white); + background-color: var(--white); } } } From 8b26abbe582ec5fefb367346629cab9a5683f1cf Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:11:33 -0700 Subject: [PATCH 10/12] Offset tab focus rings outside overlapping borders. --- src/components/tabs/tab.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index 915ca7ffcc..4465c0366b 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -21,8 +21,10 @@ outline: none; } + // Keep the focus ring outside the tab border so overlapping neighbor + // borders (same issue as footer links at mobile width) don’t clip it. &:focus-visible { - outline-offset: -1px; + outline-offset: 2px; } &--active { From 05150a0d755504b303baa4ec6d9f762c7dedb616 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:00:12 -0700 Subject: [PATCH 11/12] Remove the active-tab rule gap on mobile widths. --- src/components/tabs/tab.scss | 45 +++++++++++++++--------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/src/components/tabs/tab.scss b/src/components/tabs/tab.scss index 4465c0366b..3fbdbb0683 100644 --- a/src/components/tabs/tab.scss +++ b/src/components/tabs/tab.scss @@ -4,18 +4,26 @@ .tablist { display: flex; - border-bottom: 1px solid var(--gray-40); - - // margin-bottom: -1px; - // position: relative; - // z-index: 10; + // Inset shadow sits in the background layer so an opaque active tab can cover + // it. A real `border-bottom` paints above overlapping children and leaves a + // hairline under the active tab (especially with subpixel heights / retina). + box-shadow: inset 0 -1px 0 0 var(--gray-40); button.tab { @include heading-4($has-margin-bottom: false); + position: relative; + z-index: 1; padding: math.div(math.div($grid-gutter-width, 3), $base-font-size-px) + rem math.div($grid-gutter-width, $base-font-size-px) + rem; - margin-bottom: -1px; border: 1px solid transparent; + // `heading-4` still sets margin-bottom at the xs breakpoint even when + // `$has-margin-bottom` is false. That gap leaves the tablist rule hanging + // below the tab fills (especially obvious on phone widths). + margin-bottom: 0; + + @include respond-to-max($bp-xs-max) { + margin-bottom: 0; + } &:focus:not(:focus-visible) { outline: none; @@ -28,7 +36,6 @@ } &--active { - position: relative; color: var(--black); // Beat `.a-btn--link { background-color: transparent !important }` so the // active fill can cover the tablist rule. @@ -37,23 +44,12 @@ text-decoration: none; pointer-events: none; border-color: var(--gray-40); - border-bottom-color: transparent; - - // Mask the tablist rule under the active tab. 2px tall so retina/mobile - // subpixel rounding cannot leave a hairline. - &::after { - content: ''; - position: absolute; - inset-inline: 0; - bottom: -1px; - height: 2px; - background-color: var(--gray-5); - } + border-bottom-color: var(--gray-5); } } - // Like default tabs, but with no tab fills. Mask the tablist rule under the - // active tab so it still opens into the panel without a bottom border. + // Like default tabs, but with no inactive fills. Active tab uses an opaque + // page-colored fill so the tablist rule cannot show through underneath. // Use background-color + !important so DS `.a-btn` / default `.tab--active` // fills cannot win on equal specificity in app bundles. &--inverted { @@ -62,12 +58,9 @@ background-image: none !important; &--active { - background-color: transparent !important; + background-color: var(--white) !important; background-image: none !important; - - &::after { - background-color: var(--white); - } + border-bottom-color: var(--white); } } } From 5f6adf4b397f9901ea0209b33d034d9a7d7bdc76 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:15:10 -0700 Subject: [PATCH 12/12] Remove unused eslint-disable directives from SelectMulti. --- src/components/select/select-multi.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/select/select-multi.tsx b/src/components/select/select-multi.tsx index c8e3cd0d15..54c0103539 100644 --- a/src/components/select/select-multi.tsx +++ b/src/components/select/select-multi.tsx @@ -1,8 +1,3 @@ -// Lots of rules disabled because we're using DS code that is plain JS, not TS -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Multiselect } from '@cfpb/cfpb-design-system/src/components/cfpb-forms'; import { JSX, useEffect, useRef, useState } from 'react'; import { noOp } from '../../utils/no-op';