@workday/canvas-kit-preview-react 17.0.0-alpha.0631-next.0 → 17.0.0-alpha.0639-next.0

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.
Files changed (72) hide show
  1. package/dist/commonjs/color-picker/lib/ColorPicker.js +1 -1
  2. package/dist/commonjs/color-picker/lib/parts/ColorReset.js +1 -1
  3. package/dist/commonjs/color-picker/lib/parts/SwatchBook.js +1 -1
  4. package/dist/commonjs/divider/lib/Divider.js +3 -3
  5. package/dist/commonjs/loading-sparkles/lib/LoadingSparkles.js +2 -2
  6. package/dist/commonjs/multi-select/lib/MultiSelectInput.js +3 -3
  7. package/dist/commonjs/radio/lib/RadioGroup.js +3 -3
  8. package/dist/commonjs/radio/lib/RadioLabel.js +1 -1
  9. package/dist/commonjs/radio/lib/RadioText.js +3 -3
  10. package/dist/commonjs/radio/lib/StyledRadioButton.js +5 -5
  11. package/dist/commonjs/side-panel/lib/SidePanel.js +7 -7
  12. package/dist/commonjs/side-panel/lib/SidePanelToggleButton.js +11 -11
  13. package/dist/commonjs/status-indicator/lib/StatusIndicator.js +14 -14
  14. package/dist/commonjs/status-indicator/lib/StatusIndicatorLabel.js +1 -1
  15. package/dist/commonjs/switch/lib/SwitchBackground.js +1 -1
  16. package/dist/commonjs/switch/lib/SwitchCircle.js +2 -2
  17. package/dist/commonjs/switch/lib/SwitchContainer.js +1 -1
  18. package/dist/commonjs/switch/lib/SwitchIcon.js +2 -2
  19. package/dist/commonjs/switch/lib/SwitchInput.js +3 -3
  20. package/dist/commonjs/tabs/lib/TabsItem.d.ts +3 -1
  21. package/dist/commonjs/tabs/lib/TabsItem.d.ts.map +1 -1
  22. package/dist/commonjs/tabs/lib/TabsItem.js +60 -5
  23. package/dist/commonjs/tabs/lib/TabsList.d.ts +330 -2
  24. package/dist/commonjs/tabs/lib/TabsList.d.ts.map +1 -1
  25. package/dist/commonjs/tabs/lib/TabsList.js +65 -12
  26. package/dist/commonjs/tabs/lib/TabsOverflowButton.d.ts +24 -215
  27. package/dist/commonjs/tabs/lib/TabsOverflowButton.d.ts.map +1 -1
  28. package/dist/commonjs/tabs/lib/TabsOverflowButton.js +71 -6
  29. package/dist/commonjs/tabs/lib/TabsPanels.js +2 -2
  30. package/dist/commonjs/tabs/lib/useTabsModel.d.ts +17 -12
  31. package/dist/commonjs/tabs/lib/useTabsModel.d.ts.map +1 -1
  32. package/dist/commonjs/tabs/lib/useTabsModel.js +144 -5
  33. package/dist/commonjs/version/lib/version.js +1 -1
  34. package/dist/es6/color-picker/lib/ColorPicker.js +1 -1
  35. package/dist/es6/color-picker/lib/parts/ColorReset.js +1 -1
  36. package/dist/es6/color-picker/lib/parts/SwatchBook.js +1 -1
  37. package/dist/es6/divider/lib/Divider.js +3 -3
  38. package/dist/es6/loading-sparkles/lib/LoadingSparkles.js +2 -2
  39. package/dist/es6/multi-select/lib/MultiSelectInput.js +3 -3
  40. package/dist/es6/radio/lib/RadioGroup.js +3 -3
  41. package/dist/es6/radio/lib/RadioLabel.js +1 -1
  42. package/dist/es6/radio/lib/RadioText.js +3 -3
  43. package/dist/es6/radio/lib/StyledRadioButton.js +5 -5
  44. package/dist/es6/side-panel/lib/SidePanel.js +7 -7
  45. package/dist/es6/side-panel/lib/SidePanelToggleButton.js +11 -11
  46. package/dist/es6/status-indicator/lib/StatusIndicator.js +14 -14
  47. package/dist/es6/status-indicator/lib/StatusIndicatorLabel.js +1 -1
  48. package/dist/es6/switch/lib/SwitchBackground.js +1 -1
  49. package/dist/es6/switch/lib/SwitchCircle.js +2 -2
  50. package/dist/es6/switch/lib/SwitchContainer.js +1 -1
  51. package/dist/es6/switch/lib/SwitchIcon.js +2 -2
  52. package/dist/es6/switch/lib/SwitchInput.js +3 -3
  53. package/dist/es6/tabs/lib/TabsItem.d.ts +3 -1
  54. package/dist/es6/tabs/lib/TabsItem.d.ts.map +1 -1
  55. package/dist/es6/tabs/lib/TabsItem.js +38 -6
  56. package/dist/es6/tabs/lib/TabsList.d.ts +330 -2
  57. package/dist/es6/tabs/lib/TabsList.d.ts.map +1 -1
  58. package/dist/es6/tabs/lib/TabsList.js +67 -15
  59. package/dist/es6/tabs/lib/TabsOverflowButton.d.ts +24 -215
  60. package/dist/es6/tabs/lib/TabsOverflowButton.d.ts.map +1 -1
  61. package/dist/es6/tabs/lib/TabsOverflowButton.js +72 -8
  62. package/dist/es6/tabs/lib/TabsPanels.js +2 -2
  63. package/dist/es6/tabs/lib/useTabsModel.d.ts +17 -12
  64. package/dist/es6/tabs/lib/useTabsModel.d.ts.map +1 -1
  65. package/dist/es6/tabs/lib/useTabsModel.js +144 -5
  66. package/dist/es6/version/lib/version.js +1 -1
  67. package/package.json +4 -4
  68. package/tabs/lib/TabsItem.tsx +39 -0
  69. package/tabs/lib/TabsList.tsx +83 -5
  70. package/tabs/lib/TabsOverflowButton.tsx +77 -5
  71. package/tabs/lib/TabsPanels.tsx +2 -2
  72. package/tabs/lib/useTabsModel.tsx +161 -3
@@ -1,7 +1,12 @@
1
1
  import React from 'react';
2
2
  import { defaultGetId, useListModel, useOverflowListModel, } from '@workday/canvas-kit-react/collection';
3
- import { createModelHook, useModalityType } from '@workday/canvas-kit-react/common';
3
+ import { createModelHook, slugify, useModalityType } from '@workday/canvas-kit-react/common';
4
4
  import { useMenuModel } from '@workday/canvas-kit-react/menu';
5
+ /**
6
+ * This constant is used as the stable identifier for the overflow button in the tabs collection.
7
+ * It uses a `__` prefix to avoid conflicts with user-provided item IDs.
8
+ */
9
+ export const TABS_OVERFLOW_BUTTON_ID = '__tabs-overflow__';
5
10
  /**
6
11
  * The TabsModel extends the [Collection
7
12
  * System](/get-started/for-developers/guides/collection-api/). Tabs have tab items and
@@ -49,11 +54,73 @@ export const useTabsModel = createModelHook({
49
54
  requiredConfig: useOverflowListModel.requiredConfig,
50
55
  contextOverride: useOverflowListModel.Context,
51
56
  })(config => {
52
- var _a;
57
+ var _a, _b;
53
58
  const initialSelectedRef = React.useRef(config.initialTab);
54
- const getId = config.getId || defaultGetId;
59
+ const userGetId = config.getId || defaultGetId;
55
60
  const modality = useModalityType();
61
+ // Stable object so getId can recognize the synthetic overflow item. Callers
62
+ // often pass a getId that only understands their own item shape (for example
63
+ // `item => item.contextId`). Without this, the overflow item gets an empty id
64
+ // and Tabs.List renders it as a normal tab.
65
+ const overflowItemRef = React.useRef({
66
+ id: TABS_OVERFLOW_BUTTON_ID,
67
+ text: 'More',
68
+ });
69
+ const getId = (item) => {
70
+ if (item === overflowItemRef.current) {
71
+ return TABS_OVERFLOW_BUTTON_ID;
72
+ }
73
+ const id = userGetId(item);
74
+ if (id === TABS_OVERFLOW_BUTTON_ID) {
75
+ throw new Error(`Tabs item id "${TABS_OVERFLOW_BUTTON_ID}" is reserved for the overflow tab. Choose a different id.`);
76
+ }
77
+ return id;
78
+ };
79
+ // When using dynamic items, append a synthetic overflow button item to the
80
+ // items array. This ensures the overflow button is included in the navigation
81
+ // sequence for roving tabindex. The overflow button component will handle
82
+ // conditional rendering (not showing when there are no hidden items).
83
+ // The overflow button is NOT included in itemSizeCache (measured separately
84
+ // via setOverflowTargetSize), so it won't be incorrectly hidden.
85
+ const itemsWithOverflowButton = React.useMemo(() => {
86
+ var _a;
87
+ if ((_a = config.items) === null || _a === void 0 ? void 0 : _a.length) {
88
+ return [...config.items, overflowItemRef.current];
89
+ }
90
+ return config.items;
91
+ }, [config.items]);
92
+ // Track whether the overflow button should be non-interactive based on previous
93
+ // render's hiddenIds. We use a ref because hiddenIds isn't available until after
94
+ // the model is created, but we need to pass nonInteractiveIds to the model config.
95
+ // On first render, assume non-interactive (no overflow calculated yet).
96
+ // On subsequent renders, use the previous hiddenIds.length to determine.
97
+ const prevHiddenIdsLengthRef = React.useRef(0);
98
+ const [, setNonInteractiveTick] = React.useState(0);
99
+ const overflowButtonNonInteractive = !!((_a = config.items) === null || _a === void 0 ? void 0 : _a.length) && prevHiddenIdsLengthRef.current === 0;
100
+ // Compute nonInteractiveIds to pass to the model, including overflow button when appropriate
101
+ const nonInteractiveIdsForModel = React.useMemo(() => {
102
+ const baseIds = config.nonInteractiveIds || [];
103
+ if (overflowButtonNonInteractive) {
104
+ return [...baseIds, TABS_OVERFLOW_BUTTON_ID];
105
+ }
106
+ return baseIds;
107
+ }, [config.nonInteractiveIds, overflowButtonNonInteractive]);
108
+ // Stores the ID of a tab that should receive focus after the menu closes.
109
+ // This is used when selecting a tab from the overflow menu - we want focus
110
+ // to go to the newly selected tab, not back to the overflow button.
111
+ const pendingFocusTabIdRef = React.useRef(null);
112
+ const pendingFocusRafOuterRef = React.useRef(0);
113
+ const pendingFocusRafInnerRef = React.useRef(0);
114
+ React.useLayoutEffect(() => {
115
+ return () => {
116
+ cancelAnimationFrame(pendingFocusRafOuterRef.current);
117
+ cancelAnimationFrame(pendingFocusRafInnerRef.current);
118
+ };
119
+ }, []);
56
120
  const model = useOverflowListModel(useOverflowListModel.mergeConfig(config, {
121
+ items: itemsWithOverflowButton,
122
+ getId,
123
+ nonInteractiveIds: nonInteractiveIdsForModel,
57
124
  shouldCalculateOverflow: modality !== 'touch',
58
125
  orientation: config.orientation || 'horizontal',
59
126
  onRegisterItem(data) {
@@ -64,11 +131,64 @@ export const useTabsModel = createModelHook({
64
131
  },
65
132
  initialSelectedIds: config.initialTab
66
133
  ? [config.initialTab]
67
- : ((_a = config.items) === null || _a === void 0 ? void 0 : _a.length)
134
+ : ((_b = config.items) === null || _b === void 0 ? void 0 : _b.length)
68
135
  ? [getId(config.items[0])]
69
136
  : [],
70
137
  shouldVirtualize: false,
71
138
  }));
139
+ // Update the ref after each render so the next render knows the current hiddenIds.length.
140
+ // If hiddenIds.length changed (e.g. overflow just calculated), force a re-render so
141
+ // we pass the correct nonInteractiveIds and the overflow button is not stuck disabled.
142
+ React.useLayoutEffect(() => {
143
+ const nextLen = model.state.hiddenIds.length;
144
+ if (prevHiddenIdsLengthRef.current !== nextLen) {
145
+ prevHiddenIdsLengthRef.current = nextLen;
146
+ setNonInteractiveTick(t => t + 1);
147
+ }
148
+ else {
149
+ prevHiddenIdsLengthRef.current = nextLen;
150
+ }
151
+ }, [model.state.hiddenIds.length]);
152
+ // When the cursor is on an item that isn't in the DOM, move it to a visible one so the
153
+ // roving tabindex lands on a focusable element. This covers: (1) cursor on a hidden tab when
154
+ // only "More" is visible, and (2) cursor on the overflow button when we widen and it unmounts.
155
+ React.useLayoutEffect(() => {
156
+ var _a;
157
+ const { cursorId, hiddenIds, items } = model.state;
158
+ if (!cursorId) {
159
+ return;
160
+ }
161
+ const currentCursorId = typeof cursorId === 'string' ? cursorId : ((_a = cursorId.slice(-1)[0]) !== null && _a !== void 0 ? _a : '');
162
+ const cursorOnHidden = currentCursorId ? hiddenIds.includes(currentCursorId) : false;
163
+ const cursorOnOverflowWhenNoOverflow = currentCursorId === TABS_OVERFLOW_BUTTON_ID && hiddenIds.length === 0;
164
+ if (cursorOnHidden) {
165
+ // Move to first visible item (including overflow button when it's the only one visible)
166
+ const firstVisible = items.find(item => !hiddenIds.includes(item.id));
167
+ if (firstVisible) {
168
+ model.events.goTo({ id: firstVisible.id });
169
+ }
170
+ }
171
+ else if (cursorOnOverflowWhenNoOverflow) {
172
+ // Overflow button unmounted; move to selected tab or first tab (not the overflow button)
173
+ const selectedIds = model.state.selectedIds;
174
+ const selectedId = selectedIds !== 'all' && Array.isArray(selectedIds) && selectedIds.length
175
+ ? selectedIds[0]
176
+ : undefined;
177
+ const firstVisibleTab = items.find(item => item.id !== TABS_OVERFLOW_BUTTON_ID && !hiddenIds.includes(item.id));
178
+ const targetId = selectedId && !hiddenIds.includes(selectedId) ? selectedId : firstVisibleTab === null || firstVisibleTab === void 0 ? void 0 : firstVisibleTab.id;
179
+ if (targetId) {
180
+ model.events.goTo({ id: targetId });
181
+ }
182
+ }
183
+ // Use field-level deps instead of `model.state` so this does not re-run on every model update.
184
+ // eslint-disable-next-line react-hooks/exhaustive-deps
185
+ }, [
186
+ model.state.cursorId,
187
+ model.state.hiddenIds,
188
+ model.state.items,
189
+ model.state.selectedIds,
190
+ model.events,
191
+ ]);
72
192
  const panels = useListModel();
73
193
  const state = {
74
194
  ...model.state,
@@ -105,8 +225,27 @@ export const useTabsModel = createModelHook({
105
225
  items: overflowItems,
106
226
  nonInteractiveIds: state.nonInteractiveIds.filter(key => !state.hiddenIds.includes(key)),
107
227
  onSelect(data) {
108
- menu.events.hide();
228
+ // Store the tab ID to focus after the menu closes and React re-renders
229
+ pendingFocusTabIdRef.current = data.id;
230
+ // Update state: select the tab, move cursor, then close menu
109
231
  events.select(data);
232
+ model.events.goTo({ id: data.id });
233
+ menu.events.hide();
234
+ // Focus the selected tab AFTER React has re-rendered.
235
+ // Double rAF is required because useReturnFocus restores focus to the overflow
236
+ // button on a single rAF after hide; the second frame runs after that restore.
237
+ pendingFocusRafOuterRef.current = requestAnimationFrame(() => {
238
+ pendingFocusRafInnerRef.current = requestAnimationFrame(() => {
239
+ const tabId = pendingFocusTabIdRef.current;
240
+ if (tabId) {
241
+ const tabElement = document.querySelector(`[data-focus-id="${slugify(`${model.state.id}-${tabId}`)}"]`);
242
+ if (tabElement) {
243
+ tabElement.focus();
244
+ }
245
+ pendingFocusTabIdRef.current = null;
246
+ }
247
+ });
248
+ });
110
249
  },
111
250
  onShow() {
112
251
  // Always select the first item when the menu is opened
@@ -1 +1 @@
1
- export const version = '16.1.7';
1
+ export const version = '16.1.9';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workday/canvas-kit-preview-react",
3
- "version": "17.0.0-alpha.0631-next.0",
3
+ "version": "17.0.0-alpha.0639-next.0",
4
4
  "description": "Canvas Kit Preview is made up of components that have the full design and a11y review, are part of the DS ecosystem and are approved for use in product. The API's could be subject to change, but not without strong communication and migration strategies.",
5
5
  "author": "Workday, Inc. (https://www.workday.com)",
6
6
  "license": "Apache-2.0",
@@ -48,8 +48,8 @@
48
48
  "dependencies": {
49
49
  "@emotion/react": "^11.7.1",
50
50
  "@emotion/styled": "^11.6.0",
51
- "@workday/canvas-kit-react": "^17.0.0-alpha.0631-next.0",
52
- "@workday/canvas-kit-styling": "^17.0.0-alpha.0631-next.0",
51
+ "@workday/canvas-kit-react": "^17.0.0-alpha.0639-next.0",
52
+ "@workday/canvas-kit-styling": "^17.0.0-alpha.0639-next.0",
53
53
  "@workday/canvas-system-icons-web": "^5.0.3",
54
54
  "@workday/canvas-tokens-web": "^4.4.0",
55
55
  "@workday/design-assets-types": "^0.3.0"
@@ -60,5 +60,5 @@
60
60
  "react-hook-form": "7.36.1",
61
61
  "yup": "^0.32.11"
62
62
  },
63
- "gitHead": "cca2de38ec1c9736ce2d71f962e522a4861b82c2"
63
+ "gitHead": "8f605ef1c093e1ad20689d21e92665309c9e858d"
64
64
  }
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import {
4
+ ListRenderItemContext,
4
5
  isSelected,
5
6
  useListItemRegister,
6
7
  useListItemRovingFocus,
@@ -229,6 +230,43 @@ export const StyledTabItem = createComponent('button')<TabsItemProps>({
229
230
  },
230
231
  });
231
232
 
233
+ /**
234
+ * When the selected tab receives ArrowDown, move focus to its tab panel.
235
+ * Returning `null` is the mergeCallback sentinel that stops later-merged
236
+ * `onKeyDown` handlers (including roving focus) from also handling this key.
237
+ * This hook must be composed before `useListItemRovingFocus` so it runs after
238
+ * that hook and its return value can skip the roving handler.
239
+ * Tabs.OverflowButton does not use this hook.
240
+ *
241
+ * ArrowDown is only intercepted in horizontal orientation. In a vertical tablist,
242
+ * ArrowDown is the roving-navigation key and must reach `useListItemRovingFocus`.
243
+ */
244
+ const useTabsItemFocusPanelOnArrowDown = createElemPropsHook(useTabsModel)((
245
+ {state},
246
+ _,
247
+ elemProps: {'data-id'?: string} = {}
248
+ ) => {
249
+ const {item} = React.useContext(ListRenderItemContext);
250
+ const name = elemProps['data-id'] || item?.id || '';
251
+ const selected = !!name && isSelected(name, state);
252
+
253
+ return {
254
+ onKeyDown(event: React.KeyboardEvent<HTMLElement>) {
255
+ if (!selected || state.orientation !== 'horizontal' || event.key !== 'ArrowDown') {
256
+ return;
257
+ }
258
+ event.preventDefault();
259
+ const panelId = slugify(`tabpanel-${state.id}-${name}`);
260
+ const panel = document.getElementById(panelId);
261
+ if (panel) {
262
+ (panel as HTMLElement).focus();
263
+ }
264
+ // mergeCallback: `null` prevents the roving-focus onKeyDown from running.
265
+ return null;
266
+ },
267
+ };
268
+ });
269
+
232
270
  export const useTabsItem = composeHooks(
233
271
  createElemPropsHook(useTabsModel)(({state}, _, elemProps: {'data-id'?: string} = {}) => {
234
272
  const name = elemProps['data-id'] || '';
@@ -244,6 +282,7 @@ export const useTabsItem = composeHooks(
244
282
  }),
245
283
  useListItemSelect,
246
284
  useOverflowListItemMeasure,
285
+ useTabsItemFocusPanelOnArrowDown,
247
286
  useListItemRovingFocus,
248
287
  useListItemRegister
249
288
  );
@@ -1,23 +1,26 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import {
4
- useListRenderItems,
5
- useListResetCursorOnBlur,
4
+ ListRenderItemContext,
5
+ isCursor,
6
+ orientationKeyMap,
6
7
  useOverflowListMeasure,
7
8
  } from '@workday/canvas-kit-react/collection';
8
9
  import {
9
10
  ExtractProps,
11
+ Generic,
10
12
  composeHooks,
11
13
  createElemPropsHook,
12
14
  createSubcomponent,
13
15
  useLocalRef,
14
16
  useModalityType,
17
+ useMountLayout,
15
18
  } from '@workday/canvas-kit-react/common';
16
19
  import {Flex, mergeStyles} from '@workday/canvas-kit-react/layout';
17
20
  import {createStencil} from '@workday/canvas-kit-styling';
18
21
  import {system} from '@workday/canvas-tokens-web';
19
22
 
20
- import {useTabsModel} from './useTabsModel';
23
+ import {TABS_OVERFLOW_BUTTON_ID, useTabsModel} from './useTabsModel';
21
24
 
22
25
  export interface TabListProps<T = any> extends Omit<ExtractProps<typeof Flex, never>, 'children'> {
23
26
  /**
@@ -100,6 +103,49 @@ export const useTabOverflowScroll = createElemPropsHook(useTabsModel)((
100
103
  };
101
104
  });
102
105
 
106
+ /**
107
+ * Resets the tablist cursor on blur to the selected tab when visible, or to the first visible
108
+ * item (e.g. the overflow button) when the selected tab is hidden. This ensures the roving
109
+ * tabindex always lands on a focusable element when only the "More" button is visible.
110
+ */
111
+ export const useTabsListResetCursorOnBlur = createElemPropsHook(useTabsModel)(({state, events}) => {
112
+ const programmaticFocusRef = React.useRef(false);
113
+ const requestAnimationFrameRef = React.useRef(0);
114
+
115
+ useMountLayout(() => {
116
+ return () => {
117
+ cancelAnimationFrame(requestAnimationFrameRef.current);
118
+ };
119
+ });
120
+
121
+ return {
122
+ onKeyDown(event: React.KeyboardEvent) {
123
+ if (Object.keys(orientationKeyMap[state.orientation]).indexOf(event.key) !== -1) {
124
+ programmaticFocusRef.current = true;
125
+ }
126
+ },
127
+ onFocus() {
128
+ programmaticFocusRef.current = false;
129
+ },
130
+ onBlur() {
131
+ if (!programmaticFocusRef.current) {
132
+ requestAnimationFrameRef.current = requestAnimationFrame(() => {
133
+ const selectedId =
134
+ state.selectedIds !== 'all' && state.selectedIds.length
135
+ ? state.selectedIds[0]
136
+ : undefined;
137
+ const visibleItems = state.items.filter(item => !state.hiddenIds.includes(item.id));
138
+ const targetId =
139
+ selectedId && !state.hiddenIds.includes(selectedId) ? selectedId : visibleItems[0]?.id;
140
+ if (targetId && !isCursor(state, targetId)) {
141
+ events.goTo({id: targetId});
142
+ }
143
+ });
144
+ }
145
+ },
146
+ };
147
+ });
148
+
103
149
  export const useTabsList = composeHooks(
104
150
  useTabOverflowScroll,
105
151
  createElemPropsHook(useTabsModel)(model => {
@@ -109,13 +155,14 @@ export const useTabsList = composeHooks(
109
155
  } as const;
110
156
  }),
111
157
  useOverflowListMeasure,
112
- useListResetCursorOnBlur
158
+ useTabsListResetCursorOnBlur
113
159
  );
114
160
 
115
161
  export const tabsListStencil = createStencil({
116
162
  base: {
117
163
  display: 'flex',
118
164
  position: 'relative',
165
+ minWidth: 0,
119
166
  gap: system.legacy.gap.sm,
120
167
 
121
168
  '@media screen and (max-width: 768px)': {
@@ -156,6 +203,37 @@ export const tabsListStencil = createStencil({
156
203
  },
157
204
  });
158
205
 
206
+ /**
207
+ * Custom render function for tabs that filters out the synthetic overflow button item.
208
+ * This is needed because the overflow button is included in the model's items array
209
+ * for navigation purposes, but should not be rendered by the list or panel render function.
210
+ */
211
+ export function useTabsCollectionRenderItems(
212
+ model: ReturnType<typeof useTabsModel>,
213
+ children: ((item: Generic, index: number) => React.ReactNode) | React.ReactNode
214
+ ): React.ReactNode {
215
+ const itemsToRender = model.state.items.filter(item => item.id !== TABS_OVERFLOW_BUTTON_ID);
216
+
217
+ const items =
218
+ typeof children === 'function' ? (
219
+ itemsToRender.map(item => {
220
+ const child = (children as (item: Generic, index: number) => React.ReactNode)(
221
+ item.value,
222
+ item.index
223
+ );
224
+ return (
225
+ <ListRenderItemContext.Provider key={item.id || item.index} value={{item}}>
226
+ {child}
227
+ </ListRenderItemContext.Provider>
228
+ );
229
+ })
230
+ ) : (
231
+ <ListRenderItemContext.Provider value={{}}>{children}</ListRenderItemContext.Provider>
232
+ );
233
+
234
+ return items;
235
+ }
236
+
159
237
  export const TabsList = createSubcomponent('div')({
160
238
  displayName: 'Tabs.List',
161
239
  modelHook: useTabsModel,
@@ -173,7 +251,7 @@ export const TabsList = createSubcomponent('div')({
173
251
  })
174
252
  )}
175
253
  >
176
- {useListRenderItems(model, children)}
254
+ {useTabsCollectionRenderItems(model, children)}
177
255
  {overflowButton}
178
256
  </Element>
179
257
  );
@@ -1,11 +1,17 @@
1
1
  import * as React from 'react';
2
2
 
3
- import {useOverflowListTarget} from '@workday/canvas-kit-react/collection';
3
+ import {
4
+ useListItemRegister,
5
+ useListItemRovingFocus,
6
+ useOverflowListModel,
7
+ } from '@workday/canvas-kit-react/collection';
4
8
  import {
5
9
  composeHooks,
6
10
  createElemPropsHook,
7
11
  createSubModelElemPropsHook,
8
12
  createSubcomponent,
13
+ useLocalRef,
14
+ useResizeObserver,
9
15
  } from '@workday/canvas-kit-react/common';
10
16
  import {SystemIcon} from '@workday/canvas-kit-react/icon';
11
17
  import {mergeStyles} from '@workday/canvas-kit-react/layout';
@@ -14,7 +20,10 @@ import {createStencil} from '@workday/canvas-kit-styling';
14
20
  import {chevronDownSmallIcon} from '@workday/canvas-system-icons-web';
15
21
 
16
22
  import {StyledTabItem} from './TabsItem';
17
- import {useTabsModel} from './useTabsModel';
23
+ import {TABS_OVERFLOW_BUTTON_ID, useTabsModel} from './useTabsModel';
24
+
25
+ // Re-export for consumers who may need to reference the overflow button ID
26
+ export {TABS_OVERFLOW_BUTTON_ID} from './useTabsModel';
18
27
 
19
28
  export interface OverflowButtonProps {
20
29
  /**
@@ -32,14 +41,68 @@ const tabsOverflowButtonStencil = createStencil({
32
41
  },
33
42
  });
34
43
 
44
+ /**
45
+ * Measures the overflow button and reports its size to the model for overflow calculation.
46
+ * Unlike useOverflowListTarget, this does NOT set aria-hidden or visibility styles because
47
+ * we handle visibility via conditional rendering in the TabsOverflowButton component.
48
+ */
49
+ const useTabsOverflowButtonMeasure = createElemPropsHook(useOverflowListModel)((model, ref) => {
50
+ const {elementRef, localRef} = useLocalRef(ref as React.Ref<HTMLElement>);
51
+
52
+ useResizeObserver({
53
+ ref: localRef,
54
+ onResize: ({width = 0, height = 0}) => {
55
+ if (localRef.current && (width > 0 || height > 0)) {
56
+ const styles = getComputedStyle(localRef.current);
57
+ const w = width + parseFloat(styles.marginLeft) + parseFloat(styles.marginRight);
58
+ const h = height + parseFloat(styles.marginTop) + parseFloat(styles.marginBottom);
59
+ model.events.setOverflowTargetSize({width: w, height: h});
60
+ }
61
+ },
62
+ });
63
+
64
+ return {
65
+ ref: elementRef,
66
+ };
67
+ });
68
+
35
69
  export const useTabsOverflowButton = composeHooks(
70
+ // Measures the overflow button for overflow calculation (no aria-hidden or visibility styles)
71
+ useTabsOverflowButtonMeasure,
72
+ // Adds roving tabindex behavior for arrow key navigation within the tablist
73
+ useListItemRovingFocus,
74
+ // Registers the overflow button as an item in the collection for cursor navigation
75
+ useListItemRegister,
76
+ // Provides the stable data-id needed for collection registration
36
77
  createElemPropsHook(useTabsModel)(() => {
37
78
  return {
38
- 'aria-haspopup': true,
79
+ 'data-id': TABS_OVERFLOW_BUTTON_ID,
39
80
  } as const;
40
81
  }),
41
- useOverflowListTarget,
42
- createSubModelElemPropsHook(useTabsModel)(m => m.menu, useMenuTarget)
82
+ // Connects to the menu sub-model for popup behavior
83
+ createSubModelElemPropsHook(useTabsModel)(m => m.menu, useMenuTarget),
84
+ // Syncs the cursor to the overflow button when it receives focus.
85
+ // This is needed because when the menu closes and focus returns to the overflow button,
86
+ // the cursor state needs to be updated so arrow key navigation works correctly.
87
+ createElemPropsHook(useTabsModel)(model => {
88
+ return {
89
+ onFocus() {
90
+ model.events.goTo({id: TABS_OVERFLOW_BUTTON_ID});
91
+ },
92
+ };
93
+ }),
94
+ // Runs FIRST - Sets ARIA attributes for the overflow tab
95
+ // In composeHooks, hooks run right-to-left, and mergeProps gives precedence to elemProps
96
+ // (props from earlier-running hooks). So this runs first and its props win.
97
+ // Note: We intentionally do NOT include useListItemSelect here because clicking
98
+ // the overflow button should open the menu, not select it as the active tab
99
+ createElemPropsHook(useTabsModel)(() => {
100
+ return {
101
+ 'aria-haspopup': 'menu',
102
+ 'aria-selected': false,
103
+ role: 'tab',
104
+ } as const;
105
+ })
43
106
  );
44
107
 
45
108
  export const TabsOverflowButton = createSubcomponent('button')({
@@ -47,6 +110,15 @@ export const TabsOverflowButton = createSubcomponent('button')({
47
110
  modelHook: useTabsModel,
48
111
  elemPropsHook: useTabsOverflowButton,
49
112
  })<OverflowButtonProps>(({children, ...elemProps}, Element, model) => {
113
+ // Don't render the overflow button if there are no hidden items.
114
+ // This removes it from the DOM entirely, which:
115
+ // - Removes it from keyboard navigation (roving focus)
116
+ // - Removes it from screen reader announcements
117
+ // - Prevents the "8 tabs" announcement when only 7 are visible
118
+ if (model.state.hiddenIds.length === 0) {
119
+ return null;
120
+ }
121
+
50
122
  return (
51
123
  <StyledTabItem
52
124
  type="button"
@@ -1,8 +1,8 @@
1
1
  import * as React from 'react';
2
2
 
3
- import {useListRenderItems} from '@workday/canvas-kit-react/collection';
4
3
  import {createSubcomponent} from '@workday/canvas-kit-react/common';
5
4
 
5
+ import {useTabsCollectionRenderItems} from './TabsList';
6
6
  import {useTabsModel} from './useTabsModel';
7
7
 
8
8
  export interface TabsPanelsProps<T = any> {
@@ -16,5 +16,5 @@ export const TabsPanels = createSubcomponent()({
16
16
  displayName: 'Tabs.Panels',
17
17
  modelHook: useTabsModel,
18
18
  })<TabsPanelsProps>(({children}, _, model) => {
19
- return <>{useListRenderItems(model, children)}</>;
19
+ return <>{useTabsCollectionRenderItems(model, children)}</>;
20
20
  });