@sproutsocial/seeds-react-tabs 1.3.11 → 1.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -49,17 +49,71 @@ var React = require("react");
49
49
  // src/styles.ts
50
50
  var Container = import_styled_components.default.div`
51
51
  display: ${(props) => props.fullWidth ? "flex" : "inline-flex"};
52
- justify-content: space-between;
53
52
  margin: 0;
54
53
  padding: 0;
54
+ min-width: 0;
55
+ max-width: 100%;
56
+ min-height: min-content;
57
+ flex-shrink: 0;
58
+ overflow-x: auto;
59
+ overflow-y: hidden;
60
+ scrollbar-width: none;
61
+ scroll-padding-inline: 48px;
62
+ scroll-behavior: smooth;
55
63
  border-bottom: ${(props) => props.theme.borders[500]}
56
64
  ${(props) => props.theme.colors.container.border.base};
57
65
 
66
+ &::-webkit-scrollbar {
67
+ display: none;
68
+ }
69
+
70
+ &[data-fade-right] {
71
+ -webkit-mask-image: linear-gradient(
72
+ to right,
73
+ black 0,
74
+ black calc(100% - 48px),
75
+ transparent
76
+ );
77
+ mask-image: linear-gradient(
78
+ to right,
79
+ black 0,
80
+ black calc(100% - 48px),
81
+ transparent
82
+ );
83
+ }
84
+
85
+ &[data-fade-left] {
86
+ -webkit-mask-image: linear-gradient(
87
+ to right,
88
+ transparent,
89
+ black 48px,
90
+ black 100%
91
+ );
92
+ mask-image: linear-gradient(to right, transparent, black 48px, black 100%);
93
+ }
94
+
95
+ &[data-fade-left][data-fade-right] {
96
+ -webkit-mask-image: linear-gradient(
97
+ to right,
98
+ transparent,
99
+ black 48px,
100
+ black calc(100% - 48px),
101
+ transparent
102
+ );
103
+ mask-image: linear-gradient(
104
+ to right,
105
+ transparent,
106
+ black 48px,
107
+ black calc(100% - 48px),
108
+ transparent
109
+ );
110
+ }
111
+
58
112
  ${import_seeds_react_system_props.COMMON}
59
113
  `;
60
114
  var TabButton = (0, import_styled_components.default)(import_seeds_react_button.Button)`
61
115
  margin-bottom: -1px;
62
- padding: ${(props) => `${props.theme.space[350]} 0`};
116
+ padding: ${(props) => `${props.theme.space[350]} ${props.fullWidth ? 0 : props.theme.space[350]}`};
63
117
  color: ${(props) => props.theme.colors.text.headline};
64
118
  border-radius: 0;
65
119
  border-width: 0 0 3px;
@@ -69,12 +123,6 @@ var TabButton = (0, import_styled_components.default)(import_seeds_react_button.
69
123
  flex-grow: 1;
70
124
  `};
71
125
 
72
- &:not(:last-child) {
73
- ${(props) => !props.fullWidth && import_styled_components.css`
74
- margin-right: ${(props2) => props2.theme.space[350]};
75
- `};
76
- }
77
-
78
126
  ${(props) => props.isSelected && import_styled_components.css`
79
127
  color: ${(props2) => props2.theme.colors.link.base};
80
128
  border-color: transparent transparent
@@ -153,8 +201,70 @@ var Tabs = class extends React2.Component {
153
201
  static Panel = TabPanel;
154
202
  buttonRefs = {};
155
203
  tabList = [];
204
+ containerRef = React2.createRef();
205
+ startSentinelRef = React2.createRef();
206
+ endSentinelRef = React2.createRef();
207
+ startObserver = null;
208
+ endObserver = null;
209
+ componentDidMount() {
210
+ const container = this.containerRef.current;
211
+ if (!container) return;
212
+ container.addEventListener("wheel", this.handleWheel, { passive: false });
213
+ const startSentinel = this.startSentinelRef.current;
214
+ const endSentinel = this.endSentinelRef.current;
215
+ if (!startSentinel || !endSentinel) return;
216
+ if (typeof IntersectionObserver === "undefined") return;
217
+ this.startObserver = new IntersectionObserver(
218
+ ([entry]) => {
219
+ container.toggleAttribute("data-fade-left", !entry?.isIntersecting);
220
+ },
221
+ { root: container, threshold: 1, rootMargin: "0px 0px 0px 1px" }
222
+ );
223
+ this.endObserver = new IntersectionObserver(
224
+ ([entry]) => {
225
+ container.toggleAttribute("data-fade-right", !entry?.isIntersecting);
226
+ },
227
+ { root: container, threshold: 1, rootMargin: "0px 1px 0px 0px" }
228
+ );
229
+ this.startObserver.observe(startSentinel);
230
+ this.endObserver.observe(endSentinel);
231
+ }
232
+ componentWillUnmount() {
233
+ this.containerRef.current?.removeEventListener("wheel", this.handleWheel);
234
+ this.startObserver?.disconnect();
235
+ this.endObserver?.disconnect();
236
+ this.startObserver = null;
237
+ this.endObserver = null;
238
+ }
239
+ handleWheel = (e) => {
240
+ const container = this.containerRef.current;
241
+ if (!container) return;
242
+ if (e.deltaY === 0 || e.deltaX !== 0) return;
243
+ if (container.scrollWidth <= container.clientWidth) return;
244
+ const atStart = container.scrollLeft <= 0;
245
+ const atEnd = container.scrollLeft + container.clientWidth >= container.scrollWidth;
246
+ if (atEnd && e.deltaY > 0 || atStart && e.deltaY < 0) return;
247
+ e.preventDefault();
248
+ const lineHeight = 16;
249
+ const pixelDelta = e.deltaMode === 1 ? e.deltaY * lineHeight : e.deltaMode === 2 ? e.deltaY * container.clientHeight : e.deltaY;
250
+ container.scrollBy({ left: pixelDelta, behavior: "instant" });
251
+ };
156
252
  getSelectedId = () => this.props.selectedId;
157
- onActivate = (id) => this.props.onSelect(id);
253
+ onActivate = (id) => {
254
+ this.props.onSelect(id);
255
+ this.scrollTabIntoView(id);
256
+ };
257
+ scrollTabIntoView = (id) => {
258
+ const button = this.buttonRefs[id]?.current;
259
+ const container = this.containerRef.current;
260
+ if (!button || !container) return;
261
+ const buttonRect = button.getBoundingClientRect();
262
+ const containerRect = container.getBoundingClientRect();
263
+ const fadePadding = 48;
264
+ const isFullyVisible = buttonRect.left >= containerRect.left + fadePadding && buttonRect.right <= containerRect.right - fadePadding;
265
+ if (isFullyVisible) return;
266
+ button.scrollIntoView?.({ block: "nearest", inline: "nearest" });
267
+ };
158
268
  onTabMount = (id, ref) => {
159
269
  if (!this.tabList.includes(id)) {
160
270
  this.tabList.push(id);
@@ -175,15 +285,16 @@ var Tabs = class extends React2.Component {
175
285
  }
176
286
  };
177
287
  selectNextTab = (id) => {
178
- const buttonRef = this.buttonRefs[id];
179
288
  this.props.onSelect(id);
180
- buttonRef && buttonRef.current && buttonRef.current.focus && buttonRef.current.focus();
289
+ this.buttonRefs[id]?.current?.focus?.({ preventScroll: true });
290
+ this.scrollTabIntoView(id);
181
291
  };
182
- keyHandler = ({ keyCode }) => {
183
- switch (keyCode) {
292
+ keyHandler = (e) => {
293
+ switch (e.keyCode) {
184
294
  // left arrow
185
295
  case 37:
186
296
  this.tabList.forEach((id, index) => {
297
+ e.preventDefault();
187
298
  if (id === this.getSelectedId()) {
188
299
  const count = this.tabList.length;
189
300
  const nextIndex = index - 1 >= 0 ? index - 1 : count - 1;
@@ -195,6 +306,7 @@ var Tabs = class extends React2.Component {
195
306
  // right arrow
196
307
  case 39:
197
308
  this.tabList.forEach((id, index) => {
309
+ e.preventDefault();
198
310
  if (id === this.getSelectedId()) {
199
311
  const count = this.tabList.length;
200
312
  const nextIndex = index + 1 < count ? index + 1 : 0;
@@ -208,30 +320,56 @@ var Tabs = class extends React2.Component {
208
320
  }
209
321
  };
210
322
  render() {
211
- const { children, qa, onSelect, ...rest } = this.props;
212
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
323
+ const {
324
+ children,
325
+ qa,
326
+ onSelect,
327
+ "aria-label": ariaLabel,
328
+ ...rest
329
+ } = this.props;
330
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
213
331
  styles_default,
214
332
  {
333
+ ref: this.containerRef,
215
334
  "data-qa-tabs": "",
216
- onKeyUp: this.keyHandler,
335
+ onKeyDown: this.keyHandler,
217
336
  onSelect,
218
337
  role: "tablist",
338
+ "aria-label": ariaLabel,
219
339
  ...qa,
220
340
  ...rest,
221
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
222
- TabButtonContext.Provider,
223
- {
224
- value: {
225
- selectedId: this.getSelectedId(),
226
- fullWidth: this.props.fullWidth,
227
- onActivate: this.onActivate,
228
- onTabMount: this.onTabMount,
229
- onTabUpdate: this.onTabUpdate,
230
- onTabUnmount: this.onTabUnmount
231
- },
232
- children
233
- }
234
- )
341
+ children: [
342
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
343
+ "div",
344
+ {
345
+ ref: this.startSentinelRef,
346
+ "aria-hidden": "true",
347
+ style: { flexShrink: 0, width: 1 }
348
+ }
349
+ ),
350
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
351
+ TabButtonContext.Provider,
352
+ {
353
+ value: {
354
+ selectedId: this.getSelectedId(),
355
+ fullWidth: this.props.fullWidth,
356
+ onActivate: this.onActivate,
357
+ onTabMount: this.onTabMount,
358
+ onTabUpdate: this.onTabUpdate,
359
+ onTabUnmount: this.onTabUnmount
360
+ },
361
+ children
362
+ }
363
+ ),
364
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
365
+ "div",
366
+ {
367
+ ref: this.endSentinelRef,
368
+ "aria-hidden": "true",
369
+ style: { flexShrink: 0, width: 1 }
370
+ }
371
+ )
372
+ ]
235
373
  }
236
374
  );
237
375
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/Tabs.tsx","../src/styles.ts","../src/TabsTypes.ts"],"sourcesContent":["import Tabs from \"./Tabs\";\n\nexport default Tabs;\nexport { Tabs };\nexport * from \"./TabsTypes\";\n","import * as React from \"react\";\nimport Container, { TabButton } from \"./styles\";\nimport type {\n TypeTabButtonsProps,\n TypeTabsProps,\n TypeTabPanelProps,\n} from \"./TabsTypes\";\n\ninterface TypeTabButtonRef {\n current: null | React.ElementRef<\"button\">;\n}\ninterface TypeTabButtonContext {\n onActivate: (arg0: string) => void;\n onTabMount: (id: string, ref: TypeTabButtonRef) => void;\n onTabUpdate: (previousId: string, nextId: string) => void;\n onTabUnmount: (id: string) => void;\n selectedId: string;\n fullWidth?: boolean;\n}\n\nconst TabButtonContext = React.createContext<Partial<TypeTabButtonContext>>({});\n\nclass TabItemButton extends React.Component<TypeTabButtonsProps> {\n static override contextType = TabButtonContext;\n // @ts-notes - using the legacy syntax here because `declare` does not play well with babel\n // eslint-disable-next-line @typescript-eslint/ban-ts-comment\n //@ts-ignore\n context!: React.ContextType<typeof TabButtonContext>;\n\n buttonRef: TypeTabButtonRef = React.createRef<React.ElementRef<\"button\">>();\n\n override componentDidMount() {\n this.context.onTabMount?.(this.props.id, this.buttonRef);\n }\n\n override componentDidUpdate(prevProps: TypeTabButtonsProps) {\n if (prevProps.id !== this.props.id) {\n this.context.onTabUpdate?.(prevProps.id, this.props.id);\n }\n }\n\n override componentWillUnmount() {\n this.context.onTabUnmount?.(this.props.id);\n }\n\n override render() {\n const { children, id, ...rest } = this.props;\n const { selectedId, onActivate, fullWidth } = this.context;\n const isSelected = selectedId === id;\n\n return (\n <TabButton\n key={id}\n innerRef={this.buttonRef}\n id={id}\n onClick={() => onActivate?.(id)}\n isSelected={isSelected}\n tabIndex={isSelected ? 0 : -1}\n fullWidth={fullWidth}\n data-qa-tab-button={id}\n data-qa-tab-button-state={isSelected}\n aria-selected={isSelected}\n role=\"tab\"\n aria-controls={`${id}-panel`}\n {...rest}\n >\n {children}\n </TabButton>\n );\n }\n}\n\nconst TabPanel: React.FC<TypeTabPanelProps> = ({ children, id, ...rest }) => {\n const panelId = `${id}-panel`;\n\n return (\n <div id={panelId} role=\"tabpanel\" aria-labelledby={id} {...rest}>\n {children}\n </div>\n );\n};\n\n/**\n * Render a group of buttons in a tab-heading style\n */\nclass Tabs extends React.Component<TypeTabsProps> {\n static Button = TabItemButton;\n static Panel = TabPanel;\n buttonRefs: Record<string, TypeTabButtonRef | null | undefined> = {};\n tabList: string[] = [];\n\n getSelectedId = () => this.props.selectedId;\n onActivate = (id: string) => this.props.onSelect(id);\n onTabMount = (id: string, ref: TypeTabButtonRef) => {\n if (!this.tabList.includes(id)) {\n this.tabList.push(id);\n this.buttonRefs[id] = ref;\n }\n };\n\n onTabUpdate = (previousId: string, newId: string) => {\n if (this.tabList.includes(previousId)) {\n this.tabList = this.tabList.map((id) => (id === previousId ? newId : id));\n this.buttonRefs[newId] = this.buttonRefs[previousId];\n this.buttonRefs[previousId] = undefined;\n }\n };\n\n onTabUnmount = (id: string) => {\n if (this.tabList.includes(id)) {\n this.tabList = this.tabList.filter((currentId) => currentId !== id);\n this.buttonRefs[id] = undefined;\n }\n };\n\n selectNextTab = (id: string) => {\n const buttonRef = this.buttonRefs[id];\n this.props.onSelect(id);\n buttonRef &&\n buttonRef.current &&\n buttonRef.current.focus &&\n buttonRef.current.focus();\n };\n\n keyHandler = ({ keyCode }: React.KeyboardEvent<HTMLDivElement>) => {\n switch (keyCode) {\n // left arrow\n case 37:\n this.tabList.forEach((id, index) => {\n if (id === this.getSelectedId()) {\n const count = this.tabList.length;\n const nextIndex = index - 1 >= 0 ? index - 1 : count - 1;\n const nextId = this.tabList[nextIndex];\n this.selectNextTab(nextId ? nextId : \"\");\n }\n });\n break;\n\n // right arrow\n case 39:\n this.tabList.forEach((id, index) => {\n if (id === this.getSelectedId()) {\n const count = this.tabList.length;\n const nextIndex = index + 1 < count ? index + 1 : 0;\n const nextId = this.tabList[nextIndex];\n this.selectNextTab(nextId ? nextId : \"\");\n }\n });\n break;\n\n default:\n break;\n }\n };\n\n override render() {\n const { children, qa, onSelect, ...rest } = this.props;\n return (\n <Container\n data-qa-tabs=\"\"\n onKeyUp={this.keyHandler}\n onSelect={onSelect}\n role=\"tablist\"\n {...qa}\n {...rest}\n >\n <TabButtonContext.Provider\n value={{\n selectedId: this.getSelectedId(),\n fullWidth: this.props.fullWidth,\n onActivate: this.onActivate,\n onTabMount: this.onTabMount,\n onTabUpdate: this.onTabUpdate,\n onTabUnmount: this.onTabUnmount,\n }}\n >\n {children}\n </TabButtonContext.Provider>\n </Container>\n );\n }\n}\n\nexport default Tabs;\n","import styled, { css } from \"styled-components\";\nimport { COMMON } from \"@sproutsocial/seeds-react-system-props\";\nimport { Button } from \"@sproutsocial/seeds-react-button\";\nimport { type TypeTabsProps } from \"./TabsTypes\";\n\ninterface TypeTabState {\n isSelected: boolean;\n}\ntype TypeFullWidth = Pick<TypeTabsProps, \"fullWidth\">;\ninterface TypeStyleProps extends TypeFullWidth, TypeTabState {}\n\nconst Container = styled.div<TypeTabsProps>`\n display: ${(props) => (props.fullWidth ? \"flex\" : \"inline-flex\")};\n justify-content: space-between;\n margin: 0;\n padding: 0;\n border-bottom: ${(props) => props.theme.borders[500]}\n ${(props) => props.theme.colors.container.border.base};\n\n ${COMMON}\n`;\n\nexport const TabButton = styled(Button)<TypeStyleProps>`\n margin-bottom: -1px;\n padding: ${(props) => `${props.theme.space[350]} 0`};\n color: ${(props) => props.theme.colors.text.headline};\n border-radius: 0;\n border-width: 0 0 3px;\n width: 100%;\n\n ${(props) =>\n props.fullWidth &&\n css`\n flex-grow: 1;\n `};\n\n &:not(:last-child) {\n ${(props) =>\n !props.fullWidth &&\n css`\n margin-right: ${(props) => props.theme.space[350]};\n `};\n }\n\n ${(props) =>\n props.isSelected &&\n css`\n color: ${(props) => props.theme.colors.link.base};\n border-color: transparent transparent\n ${(props) => props.theme.colors.button.primary.background.base};\n `};\n\n &:hover {\n ${(props) =>\n props.isSelected &&\n css`\n color: ${(props) => props.theme.colors.link.hover};\n `};\n }\n\n &:focus,\n &:active {\n box-shadow: none;\n }\n\n &:active {\n transform: none;\n }\n`;\nexport default Container;\n","import * as React from \"react\";\nimport type {\n TypeStyledComponentsCommonProps,\n TypeSystemCommonProps,\n} from \"@sproutsocial/seeds-react-system-props\";\nimport type { TypeButtonProps } from \"@sproutsocial/seeds-react-button\";\n\nexport interface TypeTabButtonsProps extends TypeButtonProps {\n children: React.ReactNode;\n\n /** Should be unique among sibling elements */\n id: string;\n}\n\nexport interface TypeTabPanelProps\n extends React.ComponentPropsWithoutRef<\"div\"> {\n children: React.ReactNode;\n\n /** Should match the corresponding tab button id */\n id: string;\n}\n\nexport interface TypeTabsProps\n extends TypeStyledComponentsCommonProps,\n TypeSystemCommonProps,\n Omit<\n React.ComponentPropsWithoutRef<\"ul\">,\n keyof TypeSystemCommonProps | \"onSelect\"\n > {\n children: React.ReactNode;\n onSelect: (arg0: string) => void;\n\n /** Whether or not the tabs should stretch to fill the width of their container */\n fullWidth?: boolean;\n qa?: object;\n\n /** ID of the selected tab */\n selectedId: string;\n}\n"],"mappings":"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","names":["React","styled","props"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/Tabs.tsx","../src/styles.ts","../src/TabsTypes.ts"],"sourcesContent":["import Tabs from \"./Tabs\";\n\nexport default Tabs;\nexport { Tabs };\nexport * from \"./TabsTypes\";\n","import * as React from \"react\";\nimport Container, { TabButton } from \"./styles\";\nimport type {\n TypeTabButtonsProps,\n TypeTabsProps,\n TypeTabPanelProps,\n} from \"./TabsTypes\";\n\ninterface TypeTabButtonRef {\n current: null | React.ElementRef<\"button\">;\n}\ninterface TypeTabButtonContext {\n onActivate: (arg0: string) => void;\n onTabMount: (id: string, ref: TypeTabButtonRef) => void;\n onTabUpdate: (previousId: string, nextId: string) => void;\n onTabUnmount: (id: string) => void;\n selectedId: string;\n fullWidth?: boolean;\n}\n\nconst TabButtonContext = React.createContext<Partial<TypeTabButtonContext>>({});\n\nclass TabItemButton extends React.Component<TypeTabButtonsProps> {\n static override contextType = TabButtonContext;\n // @ts-notes - using the legacy syntax here because `declare` does not play well with babel\n // eslint-disable-next-line @typescript-eslint/ban-ts-comment\n //@ts-ignore\n context!: React.ContextType<typeof TabButtonContext>;\n\n buttonRef: TypeTabButtonRef = React.createRef<React.ElementRef<\"button\">>();\n\n override componentDidMount() {\n this.context.onTabMount?.(this.props.id, this.buttonRef);\n }\n\n override componentDidUpdate(prevProps: TypeTabButtonsProps) {\n if (prevProps.id !== this.props.id) {\n this.context.onTabUpdate?.(prevProps.id, this.props.id);\n }\n }\n\n override componentWillUnmount() {\n this.context.onTabUnmount?.(this.props.id);\n }\n\n override render() {\n const { children, id, ...rest } = this.props;\n const { selectedId, onActivate, fullWidth } = this.context;\n const isSelected = selectedId === id;\n\n return (\n <TabButton\n key={id}\n innerRef={this.buttonRef}\n id={id}\n onClick={() => onActivate?.(id)}\n isSelected={isSelected}\n tabIndex={isSelected ? 0 : -1}\n fullWidth={fullWidth}\n data-qa-tab-button={id}\n data-qa-tab-button-state={isSelected}\n aria-selected={isSelected}\n role=\"tab\"\n aria-controls={`${id}-panel`}\n {...rest}\n >\n {children}\n </TabButton>\n );\n }\n}\n\nconst TabPanel: React.FC<TypeTabPanelProps> = ({ children, id, ...rest }) => {\n const panelId = `${id}-panel`;\n\n return (\n <div id={panelId} role=\"tabpanel\" aria-labelledby={id} {...rest}>\n {children}\n </div>\n );\n};\n\n/**\n * Render a group of buttons in a tab-heading style\n */\nclass Tabs extends React.Component<TypeTabsProps> {\n static Button = TabItemButton;\n static Panel = TabPanel;\n buttonRefs: Record<string, TypeTabButtonRef | null | undefined> = {};\n tabList: string[] = [];\n\n containerRef = React.createRef<HTMLDivElement>();\n startSentinelRef = React.createRef<HTMLDivElement>();\n endSentinelRef = React.createRef<HTMLDivElement>();\n startObserver: IntersectionObserver | null = null;\n endObserver: IntersectionObserver | null = null;\n\n override componentDidMount() {\n const container = this.containerRef.current;\n if (!container) return;\n container.addEventListener(\"wheel\", this.handleWheel, { passive: false });\n\n const startSentinel = this.startSentinelRef.current;\n const endSentinel = this.endSentinelRef.current;\n if (!startSentinel || !endSentinel) return;\n if (typeof IntersectionObserver === \"undefined\") return;\n this.startObserver = new IntersectionObserver(\n ([entry]) => {\n container.toggleAttribute(\"data-fade-left\", !entry?.isIntersecting);\n },\n { root: container, threshold: 1.0, rootMargin: \"0px 0px 0px 1px\" }\n );\n this.endObserver = new IntersectionObserver(\n ([entry]) => {\n container.toggleAttribute(\"data-fade-right\", !entry?.isIntersecting);\n },\n { root: container, threshold: 1.0, rootMargin: \"0px 1px 0px 0px\" }\n );\n this.startObserver.observe(startSentinel);\n this.endObserver.observe(endSentinel);\n }\n\n override componentWillUnmount() {\n this.containerRef.current?.removeEventListener(\"wheel\", this.handleWheel);\n this.startObserver?.disconnect();\n this.endObserver?.disconnect();\n this.startObserver = null;\n this.endObserver = null;\n }\n\n handleWheel = (e: WheelEvent) => {\n const container = this.containerRef.current;\n if (!container) return;\n // Only redirect pure-vertical wheel input. Trackpad horizontal swipes\n // (deltaX !== 0) already scroll natively — don't fight them.\n if (e.deltaY === 0 || e.deltaX !== 0) return;\n // No horizontal overflow means there's nothing to scroll.\n if (container.scrollWidth <= container.clientWidth) return;\n // Release at ends so the page can scroll past the tab bar instead of\n // trapping the user. WCAG 2.1.1 / 2.1.2 spirit.\n const atStart = container.scrollLeft <= 0;\n const atEnd =\n container.scrollLeft + container.clientWidth >= container.scrollWidth;\n if ((atEnd && e.deltaY > 0) || (atStart && e.deltaY < 0)) return;\n\n e.preventDefault();\n // Normalize deltaY across deltaMode units (Firefox can emit LINE/PAGE).\n const lineHeight = 16;\n const pixelDelta =\n e.deltaMode === 1\n ? e.deltaY * lineHeight\n : e.deltaMode === 2\n ? e.deltaY * container.clientHeight\n : e.deltaY;\n container.scrollBy({ left: pixelDelta, behavior: \"instant\" });\n };\n\n getSelectedId = () => this.props.selectedId;\n onActivate = (id: string) => {\n this.props.onSelect(id);\n this.scrollTabIntoView(id);\n };\n\n scrollTabIntoView = (id: string) => {\n const button = this.buttonRefs[id]?.current;\n const container = this.containerRef.current;\n if (!button || !container) return;\n const buttonRect = button.getBoundingClientRect();\n const containerRect = container.getBoundingClientRect();\n const fadePadding = 48;\n const isFullyVisible =\n buttonRect.left >= containerRect.left + fadePadding &&\n buttonRect.right <= containerRect.right - fadePadding;\n if (isFullyVisible) return;\n button.scrollIntoView?.({ block: \"nearest\", inline: \"nearest\" });\n };\n onTabMount = (id: string, ref: TypeTabButtonRef) => {\n if (!this.tabList.includes(id)) {\n this.tabList.push(id);\n this.buttonRefs[id] = ref;\n }\n };\n\n onTabUpdate = (previousId: string, newId: string) => {\n if (this.tabList.includes(previousId)) {\n this.tabList = this.tabList.map((id) => (id === previousId ? newId : id));\n this.buttonRefs[newId] = this.buttonRefs[previousId];\n this.buttonRefs[previousId] = undefined;\n }\n };\n\n onTabUnmount = (id: string) => {\n if (this.tabList.includes(id)) {\n this.tabList = this.tabList.filter((currentId) => currentId !== id);\n this.buttonRefs[id] = undefined;\n }\n };\n\n selectNextTab = (id: string) => {\n this.props.onSelect(id);\n this.buttonRefs[id]?.current?.focus?.({ preventScroll: true });\n this.scrollTabIntoView(id);\n };\n\n keyHandler = (e: React.KeyboardEvent<HTMLDivElement>) => {\n switch (e.keyCode) {\n // left arrow\n case 37:\n this.tabList.forEach((id, index) => {\n e.preventDefault();\n if (id === this.getSelectedId()) {\n const count = this.tabList.length;\n const nextIndex = index - 1 >= 0 ? index - 1 : count - 1;\n const nextId = this.tabList[nextIndex];\n this.selectNextTab(nextId ? nextId : \"\");\n }\n });\n break;\n\n // right arrow\n case 39:\n this.tabList.forEach((id, index) => {\n e.preventDefault();\n if (id === this.getSelectedId()) {\n const count = this.tabList.length;\n const nextIndex = index + 1 < count ? index + 1 : 0;\n const nextId = this.tabList[nextIndex];\n this.selectNextTab(nextId ? nextId : \"\");\n }\n });\n break;\n\n default:\n break;\n }\n };\n\n override render() {\n const {\n children,\n qa,\n onSelect,\n \"aria-label\": ariaLabel,\n ...rest\n } = this.props;\n return (\n <Container\n ref={this.containerRef}\n data-qa-tabs=\"\"\n onKeyDown={this.keyHandler}\n onSelect={onSelect}\n role=\"tablist\"\n aria-label={ariaLabel}\n {...qa}\n {...rest}\n >\n <div\n ref={this.startSentinelRef}\n aria-hidden=\"true\"\n style={{ flexShrink: 0, width: 1 }}\n />\n <TabButtonContext.Provider\n value={{\n selectedId: this.getSelectedId(),\n fullWidth: this.props.fullWidth,\n onActivate: this.onActivate,\n onTabMount: this.onTabMount,\n onTabUpdate: this.onTabUpdate,\n onTabUnmount: this.onTabUnmount,\n }}\n >\n {children}\n </TabButtonContext.Provider>\n <div\n ref={this.endSentinelRef}\n aria-hidden=\"true\"\n style={{ flexShrink: 0, width: 1 }}\n />\n </Container>\n );\n }\n}\n\nexport default Tabs;\n","import styled, { css } from \"styled-components\";\nimport { COMMON } from \"@sproutsocial/seeds-react-system-props\";\nimport { Button } from \"@sproutsocial/seeds-react-button\";\nimport { type TypeTabsProps } from \"./TabsTypes\";\n\ninterface TypeTabState {\n isSelected: boolean;\n}\ntype TypeFullWidth = Pick<TypeTabsProps, \"fullWidth\">;\ninterface TypeStyleProps extends TypeFullWidth, TypeTabState {}\n\nconst Container = styled.div<TypeTabsProps>`\n display: ${(props) => (props.fullWidth ? \"flex\" : \"inline-flex\")};\n margin: 0;\n padding: 0;\n min-width: 0;\n max-width: 100%;\n min-height: min-content;\n flex-shrink: 0;\n overflow-x: auto;\n overflow-y: hidden;\n scrollbar-width: none;\n scroll-padding-inline: 48px;\n scroll-behavior: smooth;\n border-bottom: ${(props) => props.theme.borders[500]}\n ${(props) => props.theme.colors.container.border.base};\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n &[data-fade-right] {\n -webkit-mask-image: linear-gradient(\n to right,\n black 0,\n black calc(100% - 48px),\n transparent\n );\n mask-image: linear-gradient(\n to right,\n black 0,\n black calc(100% - 48px),\n transparent\n );\n }\n\n &[data-fade-left] {\n -webkit-mask-image: linear-gradient(\n to right,\n transparent,\n black 48px,\n black 100%\n );\n mask-image: linear-gradient(to right, transparent, black 48px, black 100%);\n }\n\n &[data-fade-left][data-fade-right] {\n -webkit-mask-image: linear-gradient(\n to right,\n transparent,\n black 48px,\n black calc(100% - 48px),\n transparent\n );\n mask-image: linear-gradient(\n to right,\n transparent,\n black 48px,\n black calc(100% - 48px),\n transparent\n );\n }\n\n ${COMMON}\n`;\n\nexport const TabButton = styled(Button)<TypeStyleProps>`\n margin-bottom: -1px;\n padding: ${(props) =>\n `${props.theme.space[350]} ${\n props.fullWidth ? 0 : props.theme.space[350]\n }`};\n color: ${(props) => props.theme.colors.text.headline};\n border-radius: 0;\n border-width: 0 0 3px;\n width: 100%;\n\n ${(props) =>\n props.fullWidth &&\n css`\n flex-grow: 1;\n `};\n\n ${(props) =>\n props.isSelected &&\n css`\n color: ${(props) => props.theme.colors.link.base};\n border-color: transparent transparent\n ${(props) => props.theme.colors.button.primary.background.base};\n `};\n\n &:hover {\n ${(props) =>\n props.isSelected &&\n css`\n color: ${(props) => props.theme.colors.link.hover};\n `};\n }\n\n &:focus,\n &:active {\n box-shadow: none;\n }\n\n &:active {\n transform: none;\n }\n`;\nexport default Container;\n","import * as React from \"react\";\nimport type {\n TypeStyledComponentsCommonProps,\n TypeSystemCommonProps,\n} from \"@sproutsocial/seeds-react-system-props\";\nimport type { TypeButtonProps } from \"@sproutsocial/seeds-react-button\";\n\nexport interface TypeTabButtonsProps extends TypeButtonProps {\n children: React.ReactNode;\n\n /** Should be unique among sibling elements */\n id: string;\n}\n\nexport interface TypeTabPanelProps\n extends React.ComponentPropsWithoutRef<\"div\"> {\n children: React.ReactNode;\n\n /** Should match the corresponding tab button id */\n id: string;\n}\n\nexport interface TypeTabsProps\n extends TypeStyledComponentsCommonProps,\n TypeSystemCommonProps,\n Omit<\n React.ComponentPropsWithoutRef<\"ul\">,\n keyof TypeSystemCommonProps | \"onSelect\"\n > {\n children: React.ReactNode;\n onSelect: (arg0: string) => void;\n\n /** Whether or not the tabs should stretch to fill the width of their container */\n fullWidth?: boolean;\n qa?: object;\n\n /** ID of the selected tab */\n selectedId: string;\n\n /**\n * Accessible label for the tab list. Forwarded to the `role=\"tablist\"`\n * element. Recommended by the WAI-ARIA Tabs pattern so screen readers\n * can announce what the tabs represent (e.g. \"Report sections\").\n * Use `aria-labelledby` instead via `{...rest}` if you have a visible\n * heading to reference.\n */\n \"aria-label\"?: string;\n}\n"],"mappings":"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","names":["React","styled","props"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutsocial/seeds-react-tabs",
3
- "version": "1.3.11",
3
+ "version": "1.4.11",
4
4
  "description": "Seeds React Tabs",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -18,9 +18,9 @@
18
18
  "test:watch": "jest --watch --coverage=false"
19
19
  },
20
20
  "dependencies": {
21
- "@sproutsocial/seeds-react-theme": "^3.6.2",
22
- "@sproutsocial/seeds-react-system-props": "^3.0.1",
23
- "@sproutsocial/seeds-react-button": "^2.0.5"
21
+ "@sproutsocial/seeds-react-theme": "^4.1.0",
22
+ "@sproutsocial/seeds-react-system-props": "^3.1.1",
23
+ "@sproutsocial/seeds-react-button": "^2.2.1"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/react": "^18.0.0",
@@ -127,6 +127,62 @@ export const FullWidth: Story = {
127
127
  name: "Full width",
128
128
  };
129
129
 
130
+ export const Scrollable: Story = {
131
+ render: () => {
132
+ const tabs = [
133
+ "Overview",
134
+ "Notifications",
135
+ "Issues",
136
+ "Pull Requests",
137
+ "Discussions",
138
+ "Actions",
139
+ "Projects",
140
+ "Wiki",
141
+ "Security",
142
+ "Insights",
143
+ ];
144
+ const [selectedId, setSelectedId] = useState(tabs[0]);
145
+
146
+ return (
147
+ <>
148
+ <Box width="450px" mb={400}>
149
+ <Tabs
150
+ selectedId={selectedId ?? ""}
151
+ onSelect={(selected) => setSelectedId(selected)}
152
+ aria-label="Repository sections"
153
+ >
154
+ {tabs.map((tab) => (
155
+ <Tabs.Button key={tab} id={tab}>
156
+ <Text size="300" fontWeight="600" flexWrap={"nowrap"}>
157
+ {tab}
158
+ </Text>
159
+ </Tabs.Button>
160
+ ))}
161
+ </Tabs>
162
+ </Box>
163
+
164
+ {tabs.map((tab) => {
165
+ if (selectedId !== tab) return null;
166
+ return (
167
+ <Tabs.Panel id={tab}>
168
+ <Text color="text.body">{tab} Panel</Text>
169
+ </Tabs.Panel>
170
+ );
171
+ })}
172
+ </>
173
+ );
174
+ },
175
+ name: "Scrollable",
176
+ parameters: {
177
+ docs: {
178
+ description: {
179
+ story:
180
+ "When the tab list overflows its container, the bar scrolls horizontally and a fade indicator appears at each overflowing edge. Arrow-key and click activation auto-scroll the selected tab fully into view past the fade region. The fade uses CSS `mask-image` so it auto-adapts to any container background — no consumer configuration needed.",
181
+ },
182
+ },
183
+ },
184
+ };
185
+
130
186
  export const RemoveATab: Story = {
131
187
  render: (args) => {
132
188
  const [state, setState] = useState<TypeTabsStoryState>({
package/src/Tabs.tsx CHANGED
@@ -89,8 +89,91 @@ class Tabs extends React.Component<TypeTabsProps> {
89
89
  buttonRefs: Record<string, TypeTabButtonRef | null | undefined> = {};
90
90
  tabList: string[] = [];
91
91
 
92
+ containerRef = React.createRef<HTMLDivElement>();
93
+ startSentinelRef = React.createRef<HTMLDivElement>();
94
+ endSentinelRef = React.createRef<HTMLDivElement>();
95
+ startObserver: IntersectionObserver | null = null;
96
+ endObserver: IntersectionObserver | null = null;
97
+
98
+ override componentDidMount() {
99
+ const container = this.containerRef.current;
100
+ if (!container) return;
101
+ container.addEventListener("wheel", this.handleWheel, { passive: false });
102
+
103
+ const startSentinel = this.startSentinelRef.current;
104
+ const endSentinel = this.endSentinelRef.current;
105
+ if (!startSentinel || !endSentinel) return;
106
+ if (typeof IntersectionObserver === "undefined") return;
107
+ this.startObserver = new IntersectionObserver(
108
+ ([entry]) => {
109
+ container.toggleAttribute("data-fade-left", !entry?.isIntersecting);
110
+ },
111
+ { root: container, threshold: 1.0, rootMargin: "0px 0px 0px 1px" }
112
+ );
113
+ this.endObserver = new IntersectionObserver(
114
+ ([entry]) => {
115
+ container.toggleAttribute("data-fade-right", !entry?.isIntersecting);
116
+ },
117
+ { root: container, threshold: 1.0, rootMargin: "0px 1px 0px 0px" }
118
+ );
119
+ this.startObserver.observe(startSentinel);
120
+ this.endObserver.observe(endSentinel);
121
+ }
122
+
123
+ override componentWillUnmount() {
124
+ this.containerRef.current?.removeEventListener("wheel", this.handleWheel);
125
+ this.startObserver?.disconnect();
126
+ this.endObserver?.disconnect();
127
+ this.startObserver = null;
128
+ this.endObserver = null;
129
+ }
130
+
131
+ handleWheel = (e: WheelEvent) => {
132
+ const container = this.containerRef.current;
133
+ if (!container) return;
134
+ // Only redirect pure-vertical wheel input. Trackpad horizontal swipes
135
+ // (deltaX !== 0) already scroll natively — don't fight them.
136
+ if (e.deltaY === 0 || e.deltaX !== 0) return;
137
+ // No horizontal overflow means there's nothing to scroll.
138
+ if (container.scrollWidth <= container.clientWidth) return;
139
+ // Release at ends so the page can scroll past the tab bar instead of
140
+ // trapping the user. WCAG 2.1.1 / 2.1.2 spirit.
141
+ const atStart = container.scrollLeft <= 0;
142
+ const atEnd =
143
+ container.scrollLeft + container.clientWidth >= container.scrollWidth;
144
+ if ((atEnd && e.deltaY > 0) || (atStart && e.deltaY < 0)) return;
145
+
146
+ e.preventDefault();
147
+ // Normalize deltaY across deltaMode units (Firefox can emit LINE/PAGE).
148
+ const lineHeight = 16;
149
+ const pixelDelta =
150
+ e.deltaMode === 1
151
+ ? e.deltaY * lineHeight
152
+ : e.deltaMode === 2
153
+ ? e.deltaY * container.clientHeight
154
+ : e.deltaY;
155
+ container.scrollBy({ left: pixelDelta, behavior: "instant" });
156
+ };
157
+
92
158
  getSelectedId = () => this.props.selectedId;
93
- onActivate = (id: string) => this.props.onSelect(id);
159
+ onActivate = (id: string) => {
160
+ this.props.onSelect(id);
161
+ this.scrollTabIntoView(id);
162
+ };
163
+
164
+ scrollTabIntoView = (id: string) => {
165
+ const button = this.buttonRefs[id]?.current;
166
+ const container = this.containerRef.current;
167
+ if (!button || !container) return;
168
+ const buttonRect = button.getBoundingClientRect();
169
+ const containerRect = container.getBoundingClientRect();
170
+ const fadePadding = 48;
171
+ const isFullyVisible =
172
+ buttonRect.left >= containerRect.left + fadePadding &&
173
+ buttonRect.right <= containerRect.right - fadePadding;
174
+ if (isFullyVisible) return;
175
+ button.scrollIntoView?.({ block: "nearest", inline: "nearest" });
176
+ };
94
177
  onTabMount = (id: string, ref: TypeTabButtonRef) => {
95
178
  if (!this.tabList.includes(id)) {
96
179
  this.tabList.push(id);
@@ -114,19 +197,17 @@ class Tabs extends React.Component<TypeTabsProps> {
114
197
  };
115
198
 
116
199
  selectNextTab = (id: string) => {
117
- const buttonRef = this.buttonRefs[id];
118
200
  this.props.onSelect(id);
119
- buttonRef &&
120
- buttonRef.current &&
121
- buttonRef.current.focus &&
122
- buttonRef.current.focus();
201
+ this.buttonRefs[id]?.current?.focus?.({ preventScroll: true });
202
+ this.scrollTabIntoView(id);
123
203
  };
124
204
 
125
- keyHandler = ({ keyCode }: React.KeyboardEvent<HTMLDivElement>) => {
126
- switch (keyCode) {
205
+ keyHandler = (e: React.KeyboardEvent<HTMLDivElement>) => {
206
+ switch (e.keyCode) {
127
207
  // left arrow
128
208
  case 37:
129
209
  this.tabList.forEach((id, index) => {
210
+ e.preventDefault();
130
211
  if (id === this.getSelectedId()) {
131
212
  const count = this.tabList.length;
132
213
  const nextIndex = index - 1 >= 0 ? index - 1 : count - 1;
@@ -139,6 +220,7 @@ class Tabs extends React.Component<TypeTabsProps> {
139
220
  // right arrow
140
221
  case 39:
141
222
  this.tabList.forEach((id, index) => {
223
+ e.preventDefault();
142
224
  if (id === this.getSelectedId()) {
143
225
  const count = this.tabList.length;
144
226
  const nextIndex = index + 1 < count ? index + 1 : 0;
@@ -154,16 +236,29 @@ class Tabs extends React.Component<TypeTabsProps> {
154
236
  };
155
237
 
156
238
  override render() {
157
- const { children, qa, onSelect, ...rest } = this.props;
239
+ const {
240
+ children,
241
+ qa,
242
+ onSelect,
243
+ "aria-label": ariaLabel,
244
+ ...rest
245
+ } = this.props;
158
246
  return (
159
247
  <Container
248
+ ref={this.containerRef}
160
249
  data-qa-tabs=""
161
- onKeyUp={this.keyHandler}
250
+ onKeyDown={this.keyHandler}
162
251
  onSelect={onSelect}
163
252
  role="tablist"
253
+ aria-label={ariaLabel}
164
254
  {...qa}
165
255
  {...rest}
166
256
  >
257
+ <div
258
+ ref={this.startSentinelRef}
259
+ aria-hidden="true"
260
+ style={{ flexShrink: 0, width: 1 }}
261
+ />
167
262
  <TabButtonContext.Provider
168
263
  value={{
169
264
  selectedId: this.getSelectedId(),
@@ -176,6 +271,11 @@ class Tabs extends React.Component<TypeTabsProps> {
176
271
  >
177
272
  {children}
178
273
  </TabButtonContext.Provider>
274
+ <div
275
+ ref={this.endSentinelRef}
276
+ aria-hidden="true"
277
+ style={{ flexShrink: 0, width: 1 }}
278
+ />
179
279
  </Container>
180
280
  );
181
281
  }
package/src/TabsTypes.ts CHANGED
@@ -36,4 +36,13 @@ export interface TypeTabsProps
36
36
 
37
37
  /** ID of the selected tab */
38
38
  selectedId: string;
39
+
40
+ /**
41
+ * Accessible label for the tab list. Forwarded to the `role="tablist"`
42
+ * element. Recommended by the WAI-ARIA Tabs pattern so screen readers
43
+ * can announce what the tabs represent (e.g. "Report sections").
44
+ * Use `aria-labelledby` instead via `{...rest}` if you have a visible
45
+ * heading to reference.
46
+ */
47
+ "aria-label"?: string;
39
48
  }