Skip to content

Commit 92ffe76

Browse files
author
Samuel Abramov
committed
fix(toggle-group): prevent item clipping on narrow viewports (resolve #69)
Replace overflow: hidden with horizontal scroll (hidden scrollbar, touch-friendly) so all items remain accessible on narrow viewports. Add --wrap modifier for explicit multi-row wrapping. New Responsive story demonstrates both behaviors with 6-8 items at 320px width.
1 parent 15daf64 commit 92ffe76

2 files changed

Lines changed: 130 additions & 1 deletion

File tree

components/toggle-group.css

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@
2020
border-radius: var(--ct-toggle-group-radius);
2121
border: var(--border-thin) solid var(--ct-toggle-group-border);
2222
background: var(--ct-toggle-group-bg);
23-
overflow: hidden;
23+
max-width: 100%;
24+
overflow-x: auto;
25+
overflow-y: hidden;
26+
-webkit-overflow-scrolling: touch;
27+
scrollbar-width: none;
28+
}
29+
30+
.ct-toggle-group::-webkit-scrollbar {
31+
display: none;
2432
}
2533

2634
/* ── Item ── */
@@ -164,6 +172,21 @@
164172
flex: 1;
165173
}
166174

175+
/* ── Wrap variant ── */
176+
177+
.ct-toggle-group--wrap {
178+
flex-wrap: wrap;
179+
overflow: visible;
180+
}
181+
182+
.ct-toggle-group--wrap .ct-toggle-group__item {
183+
border-block-end: var(--border-thin) solid var(--ct-toggle-group-border);
184+
}
185+
186+
.ct-toggle-group--wrap .ct-toggle-group__item:last-child {
187+
border-block-end: none;
188+
}
189+
167190
/* ── Reduced motion ── */
168191

169192
@media (prefers-reduced-motion: reduce) {

stories/ToggleGroup.stories.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,112 @@ export const Disabled = {
740740
},
741741
};
742742

743+
// ── Responsive (Many Items) ──
744+
745+
export const Responsive = {
746+
parameters: {
747+
docs: {
748+
description: {
749+
story:
750+
'Toggle groups with many items remain usable on narrow viewports. ' +
751+
'The default behavior uses horizontal scrolling (hidden scrollbar, touch-friendly). ' +
752+
'The `--wrap` modifier wraps items to multiple rows instead.',
753+
},
754+
},
755+
},
756+
render: () => `
757+
<div class="ct-stack" style="--ct-stack-space: var(--space-6); max-width: 320px;">
758+
<div>
759+
<p style="margin: 0 0 var(--space-3); font-size: var(--font-size-sm); color: var(--color-text-secondary);">Default: horizontal scroll (6 items, 320px container)</p>
760+
<div class="ct-toggle-group" role="group" aria-label="Scrollable filters" data-testid="scroll-group">
761+
<button class="ct-toggle-group__item" type="button" aria-pressed="true" tabindex="0">All</button>
762+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Design</button>
763+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Dev</button>
764+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Marketing</button>
765+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Sales</button>
766+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Support</button>
767+
</div>
768+
</div>
769+
770+
<div>
771+
<p style="margin: 0 0 var(--space-3); font-size: var(--font-size-sm); color: var(--color-text-secondary);">Wrap variant (same items, wraps to rows)</p>
772+
<div class="ct-toggle-group ct-toggle-group--wrap" role="group" aria-label="Wrapped filters" data-testid="wrap-group">
773+
<button class="ct-toggle-group__item" type="button" aria-pressed="true" tabindex="0">All</button>
774+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Design</button>
775+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Dev</button>
776+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Marketing</button>
777+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Sales</button>
778+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Support</button>
779+
</div>
780+
</div>
781+
782+
<div>
783+
<p style="margin: 0 0 var(--space-3); font-size: var(--font-size-sm); color: var(--color-text-secondary);">Separated + Wrap (8 items)</p>
784+
<div class="ct-toggle-group ct-toggle-group--separated ct-toggle-group--wrap" role="group" aria-label="Tag filters" data-testid="sep-wrap-group">
785+
<button class="ct-toggle-group__item" type="button" aria-pressed="true" tabindex="0">React</button>
786+
<button class="ct-toggle-group__item" type="button" aria-pressed="true" tabindex="-1">Vue</button>
787+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Angular</button>
788+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Svelte</button>
789+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Solid</button>
790+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Lit</button>
791+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Preact</button>
792+
<button class="ct-toggle-group__item" type="button" aria-pressed="false" tabindex="-1">Qwik</button>
793+
</div>
794+
</div>
795+
</div>`,
796+
play: async ({ canvasElement }) => {
797+
const scrollGroup = canvasElement.querySelector('[data-testid="scroll-group"]');
798+
const wrapGroup = canvasElement.querySelector('[data-testid="wrap-group"]');
799+
const sepWrapGroup = canvasElement.querySelector('[data-testid="sep-wrap-group"]');
800+
801+
initToggleGroupKeyboard(scrollGroup);
802+
initToggleGroupKeyboard(wrapGroup);
803+
initToggleGroupKeyboard(sepWrapGroup);
804+
initToggleGroupSelect(scrollGroup);
805+
initToggleGroupSelect(wrapGroup);
806+
initToggleGroupSelect(sepWrapGroup);
807+
808+
// All 6 items exist and are in the DOM (not clipped away)
809+
const scrollItems = scrollGroup.querySelectorAll('.ct-toggle-group__item');
810+
expect(scrollItems).toHaveLength(6);
811+
812+
const wrapItems = wrapGroup.querySelectorAll('.ct-toggle-group__item');
813+
expect(wrapItems).toHaveLength(6);
814+
815+
const sepItems = sepWrapGroup.querySelectorAll('.ct-toggle-group__item');
816+
expect(sepItems).toHaveLength(8);
817+
818+
// Scroll group: overflow-x is auto (scrollable, not clipped)
819+
const scrollStyle = getComputedStyle(scrollGroup);
820+
expect(scrollStyle.overflowX).toBe('auto');
821+
822+
// Wrap group: has flex-wrap
823+
const wrapStyle = getComputedStyle(wrapGroup);
824+
expect(wrapStyle.flexWrap).toBe('wrap');
825+
826+
// All items are focusable via keyboard navigation
827+
scrollItems[0].focus();
828+
expect(scrollItems[0]).toHaveFocus();
829+
await userEvent.keyboard('{ArrowRight}');
830+
expect(scrollItems[1]).toHaveFocus();
831+
832+
// Navigate through remaining items and wrap around
833+
for (let i = 2; i < 6; i++) {
834+
await userEvent.keyboard('{ArrowRight}');
835+
}
836+
// After 5 ArrowRights total (from index 0): at index 5 (last)
837+
expect(scrollItems[5]).toHaveFocus();
838+
// One more wraps to first
839+
await userEvent.keyboard('{ArrowRight}');
840+
expect(scrollItems[0]).toHaveFocus();
841+
842+
// Wrap group keyboard nav works too
843+
wrapItems[0].focus();
844+
await userEvent.keyboard('{ArrowRight}');
845+
expect(wrapItems[1]).toHaveFocus();
846+
},
847+
};
848+
743849
// ── All Variants Overview ──
744850

745851
export const AllVariants = {

0 commit comments

Comments
 (0)