@kaizen/components 1.64.13 → 1.65.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 (64) hide show
  1. package/dist/cjs/Filter/FilterSelect/FilterSelect.cjs +2 -0
  2. package/dist/cjs/__future__/Select/subcomponents/ListBox/ListBox.cjs +46 -2
  3. package/dist/cjs/__future__/Select/subcomponents/Overlay/Overlay.cjs +1 -1
  4. package/dist/cjs/__future__/Tabs/Tabs.cjs +23 -0
  5. package/dist/cjs/__future__/Tabs/subcomponents/Tab/Tab.cjs +39 -0
  6. package/dist/cjs/__future__/Tabs/subcomponents/Tab/Tab.module.css.cjs +7 -0
  7. package/dist/cjs/__future__/Tabs/subcomponents/TabList/TabList.cjs +31 -0
  8. package/dist/cjs/__future__/Tabs/subcomponents/TabList/TabList.module.css.cjs +7 -0
  9. package/dist/cjs/__future__/Tabs/subcomponents/TabPanel/TabPanel.cjs +24 -0
  10. package/dist/cjs/__utilities__/useIsClientReady/useIsClientReady.cjs +20 -0
  11. package/dist/cjs/future.cjs +8 -0
  12. package/dist/esm/Filter/FilterSelect/FilterSelect.mjs +2 -0
  13. package/dist/esm/__future__/Select/subcomponents/ListBox/ListBox.mjs +47 -3
  14. package/dist/esm/__future__/Select/subcomponents/Overlay/Overlay.mjs +1 -1
  15. package/dist/esm/__future__/Tabs/Tabs.mjs +15 -0
  16. package/dist/esm/__future__/Tabs/subcomponents/Tab/Tab.mjs +30 -0
  17. package/dist/esm/__future__/Tabs/subcomponents/Tab/Tab.module.css.mjs +5 -0
  18. package/dist/esm/__future__/Tabs/subcomponents/TabList/TabList.mjs +22 -0
  19. package/dist/esm/__future__/Tabs/subcomponents/TabList/TabList.module.css.mjs +5 -0
  20. package/dist/esm/__future__/Tabs/subcomponents/TabPanel/TabPanel.mjs +16 -0
  21. package/dist/esm/__utilities__/useIsClientReady/useIsClientReady.mjs +18 -0
  22. package/dist/esm/future.mjs +4 -0
  23. package/dist/styles.css +244 -127
  24. package/dist/types/Tabs/subcomponents/index.d.ts +0 -1
  25. package/dist/types/__future__/Select/subcomponents/ListBox/ListBox.d.ts +2 -2
  26. package/dist/types/__future__/Tabs/Tabs.d.ts +11 -0
  27. package/dist/types/__future__/Tabs/index.d.ts +2 -0
  28. package/dist/types/__future__/Tabs/subcomponents/Tab/Tab.d.ts +12 -0
  29. package/dist/types/__future__/Tabs/subcomponents/Tab/index.d.ts +1 -0
  30. package/dist/types/__future__/Tabs/subcomponents/TabList/TabList.d.ts +17 -0
  31. package/dist/types/__future__/Tabs/subcomponents/TabList/index.d.ts +1 -0
  32. package/dist/types/__future__/Tabs/subcomponents/TabPanel/TabPanel.d.ts +6 -0
  33. package/dist/types/__future__/Tabs/subcomponents/TabPanel/index.d.ts +1 -0
  34. package/dist/types/__future__/Tabs/subcomponents/index.d.ts +3 -0
  35. package/dist/types/__future__/index.d.ts +1 -0
  36. package/dist/types/__utilities__/useIsClientReady/index.d.ts +1 -0
  37. package/dist/types/__utilities__/useIsClientReady/useIsClientReady.d.ts +5 -0
  38. package/package.json +2 -2
  39. package/src/Filter/FilterSelect/FilterSelect.spec.tsx +97 -2
  40. package/src/Filter/FilterSelect/FilterSelect.tsx +3 -0
  41. package/src/Tabs/subcomponents/index.ts +0 -1
  42. package/src/__future__/Select/Select.spec.tsx +5 -3
  43. package/src/__future__/Select/_docs/Select.mdx +1 -3
  44. package/src/__future__/Select/_docs/Select.stories.tsx +33 -17
  45. package/src/__future__/Select/subcomponents/ListBox/ListBox.tsx +56 -4
  46. package/src/__future__/Select/subcomponents/Overlay/Overlay.tsx +1 -1
  47. package/src/__future__/Tabs/Tabs.tsx +18 -0
  48. package/src/__future__/Tabs/_docs/Tabs--api-specification.mdx +43 -0
  49. package/src/__future__/Tabs/_docs/Tabs--migration-guide.mdx +93 -0
  50. package/src/__future__/Tabs/_docs/Tabs.stories.tsx +74 -0
  51. package/src/__future__/Tabs/index.ts +2 -0
  52. package/src/__future__/Tabs/subcomponents/Tab/Tab.module.css +94 -0
  53. package/src/__future__/Tabs/subcomponents/Tab/Tab.tsx +58 -0
  54. package/src/__future__/Tabs/subcomponents/Tab/index.ts +1 -0
  55. package/src/__future__/Tabs/subcomponents/TabList/TabList.module.css +8 -0
  56. package/src/__future__/Tabs/subcomponents/TabList/TabList.tsx +45 -0
  57. package/src/__future__/Tabs/subcomponents/TabList/index.ts +1 -0
  58. package/src/__future__/Tabs/subcomponents/TabPanel/TabPanel.module.css +12 -0
  59. package/src/__future__/Tabs/subcomponents/TabPanel/TabPanel.tsx +20 -0
  60. package/src/__future__/Tabs/subcomponents/TabPanel/index.ts +1 -0
  61. package/src/__future__/Tabs/subcomponents/index.ts +3 -0
  62. package/src/__future__/index.ts +1 -0
  63. package/src/__utilities__/useIsClientReady/index.ts +1 -0
  64. package/src/__utilities__/useIsClientReady/useIsClientReady.tsx +17 -0
@@ -48,7 +48,7 @@ describe("<FilterSelect>", () => {
48
48
  it("shows the options initially when isOpen is true", async () => {
49
49
  render(<FilterSelectWrapper isOpen />)
50
50
  await waitFor(() => {
51
- expect(screen.queryByRole("listbox")).toBeVisible()
51
+ expect(screen.getByRole("listbox")).toBeVisible()
52
52
  })
53
53
  })
54
54
 
@@ -107,10 +107,82 @@ describe("<FilterSelect>", () => {
107
107
  render(<FilterSelectWrapper isOpen />)
108
108
  expect(screen.queryByRole("listbox")).toBeVisible()
109
109
  await waitFor(() => {
110
- expect(screen.queryByRole("listbox")).toHaveFocus()
110
+ expect(screen.getAllByRole("option")[0]).toHaveFocus()
111
111
  })
112
112
  })
113
113
 
114
+ it("moves focus to the first item on ArrowDown if nothing has been selected", async () => {
115
+ render(<FilterSelectWrapper selectedKey={undefined} />)
116
+ const trigger = screen.getByRole("button", { name: "Coffee" })
117
+ await user.tab()
118
+ await waitFor(() => {
119
+ expect(trigger).toHaveFocus()
120
+ })
121
+ await user.keyboard("{ArrowDown}")
122
+
123
+ await waitFor(() => {
124
+ expect(screen.getAllByRole("option")[0]).toHaveFocus()
125
+ })
126
+ })
127
+ it("moves focus to the last item on ArrowUp if nothing has been selected", async () => {
128
+ render(<FilterSelectWrapper selectedKey={undefined} />)
129
+ const trigger = screen.getByRole("button", { name: "Coffee" })
130
+ await user.tab()
131
+ await waitFor(() => {
132
+ expect(trigger).toHaveFocus()
133
+ })
134
+ await user.keyboard("{ArrowUp}")
135
+
136
+ await waitFor(() => {
137
+ const options = screen.getAllByRole("option")
138
+ expect(options[options.length - 1]).toHaveFocus()
139
+ })
140
+ })
141
+ it("moves focus to the current selected item on Enter", async () => {
142
+ render(<FilterSelectWrapper selectedKey="hazelnut" />)
143
+ const trigger = screen.getByRole("button", {
144
+ name: "Coffee : Hazelnut",
145
+ })
146
+ await user.tab()
147
+ await waitFor(() => {
148
+ expect(trigger).toHaveFocus()
149
+ })
150
+ await user.keyboard("{Enter}")
151
+
152
+ await waitFor(() => {
153
+ expect(screen.getByRole("option", { name: "Hazelnut" })).toHaveFocus()
154
+ })
155
+ })
156
+ it("moves focus to the current selected item on ArrowUp", async () => {
157
+ render(<FilterSelectWrapper selectedKey="hazelnut" />)
158
+ const trigger = screen.getByRole("button", {
159
+ name: "Coffee : Hazelnut",
160
+ })
161
+ await user.tab()
162
+ await waitFor(() => {
163
+ expect(trigger).toHaveFocus()
164
+ })
165
+ await user.keyboard("{ArrowUp}")
166
+
167
+ await waitFor(() => {
168
+ expect(screen.getByRole("option", { name: "Hazelnut" })).toHaveFocus()
169
+ })
170
+ })
171
+ it("moves focus to the current selected item on ArrowDown", async () => {
172
+ render(<FilterSelectWrapper selectedKey="hazelnut" />)
173
+ const trigger = screen.getByRole("button", {
174
+ name: "Coffee : Hazelnut",
175
+ })
176
+ await user.tab()
177
+ await waitFor(() => {
178
+ expect(trigger).toHaveFocus()
179
+ })
180
+ await user.keyboard("{ArrowDown}")
181
+
182
+ await waitFor(() => {
183
+ expect(screen.getByRole("option", { name: "Hazelnut" })).toHaveFocus()
184
+ })
185
+ })
114
186
  it("closes when user hits the escape key", async () => {
115
187
  render(<FilterSelectWrapper isOpen />)
116
188
  expect(screen.queryByRole("listbox")).toBeVisible()
@@ -168,6 +240,29 @@ describe("<FilterSelect>", () => {
168
240
  })
169
241
  })
170
242
 
243
+ describe("Stringified object values", () => {
244
+ it("finds selected option when value is a stringified object", () => {
245
+ const { getByRole } = render(
246
+ <FilterSelectWrapper
247
+ items={[
248
+ {
249
+ value: '{"sortBy":"creator_name","sortOrder":"asc"}',
250
+ label: "Created by A-Z",
251
+ },
252
+ {
253
+ value: '{"sortBy":"creator_name","sortOrder":"dsc"}',
254
+ label: "Created by Z-A",
255
+ },
256
+ ]}
257
+ selectedKey='{"sortBy":"creator_name","sortOrder":"asc"}'
258
+ />
259
+ )
260
+ expect(
261
+ getByRole("button", { name: "Coffee : Created by A-Z" })
262
+ ).toBeInTheDocument()
263
+ })
264
+ })
265
+
171
266
  const defaultProps: FilterSelectProps = {
172
267
  label: "Coffee",
173
268
  isOpen: false,
@@ -78,6 +78,9 @@ export const FilterSelect = <Option extends SelectOption = SelectOption>({
78
78
  )
79
79
 
80
80
  const { buttonProps } = useButton(triggerProps, triggerRef)
81
+
82
+ // `aria-labelledby` and `aria-controls` are being remapped because the `buttonProps` ids generated by React Aria point to nothing.
83
+ // This should ideally be refactored but for now the `aria-controls` is set to the Filter's Listbox (menuProps.id) and the `aria-labelledby` to undefined so the accessible name is derived from the buttons content.
81
84
  const renderTriggerButtonProps = {
82
85
  ...buttonProps,
83
86
  "aria-labelledby": undefined,
@@ -2,4 +2,3 @@ export * from "./Tab"
2
2
  export * from "./TabList"
3
3
  export * from "./TabPanel"
4
4
  export * from "./TabPanels"
5
- export * from "./Tab"
@@ -194,11 +194,13 @@ describe("<Select />", () => {
194
194
  })
195
195
 
196
196
  describe("Given the menu is opened", () => {
197
- it("focuses the listbox initially", async () => {
198
- const { getByRole } = render(<SelectWrapper defaultOpen />)
197
+ it("focuses on the first item", async () => {
198
+ const { getByRole, getAllByRole } = render(
199
+ <SelectWrapper defaultOpen />
200
+ )
199
201
  expect(getByRole("listbox")).toBeVisible()
200
202
  await waitFor(() => {
201
- expect(getByRole("listbox")).toHaveFocus()
203
+ expect(getAllByRole("option")[0]).toHaveFocus()
202
204
  })
203
205
  })
204
206
  it("is closed when hits the escape key", async () => {
@@ -98,8 +98,6 @@ Set `isFullWidth` to `true` to have the Select span the full width of its contai
98
98
 
99
99
  By default, the Select's popover will attach itself to the `body` of the document using React's `createPortal`.
100
100
 
101
- You can change the default behaviour by providing a `portalContainerId` to attach this to different element in the DOM. This can help to resolve issues that may arise with `z-index` or having a Select in a modal.
101
+ You can change the default behavior by providing a `portalContainerId` to attach this to different element in the DOM. This can help to resolve issues that may arise with `z-index` or having a Select in a modal.
102
102
 
103
103
  <Canvas of={SelectStories.PortalContainer} />
104
-
105
- There is currently a known issue whereby a selected option will cause the page to scroll to the top of the window on open (click on [default to see example](https://cultureamp.design/?path=/docs/components-select-future--docs#portals)). This can be solved by setting a `portalContainerId` to the closest parent of the Select.
@@ -1,5 +1,6 @@
1
1
  import React from "react"
2
2
  import { Meta, StoryObj } from "@storybook/react"
3
+ import { ContextModal } from "~components/Modal"
3
4
  import { RadioField, RadioGroup } from "~components/Radio"
4
5
  import { Select } from "../Select"
5
6
  import { SelectOption } from "../types"
@@ -170,25 +171,40 @@ export const FullWidth: Story = {
170
171
  export const PortalContainer: Story = {
171
172
  render: args => {
172
173
  const portalContainerId = "id--portal-container"
174
+
175
+ const [isOpen, setIsOpen] = React.useState(false)
176
+
177
+ const handleOpen = (): void => setIsOpen(true)
178
+ const handleClose = (): void => setIsOpen(false)
173
179
  return (
174
180
  <>
175
- <div
176
- id={portalContainerId}
177
- className="flex gap-24 bg-gray-200 p-12 overflow-hidden h-[200px] relative"
178
- >
179
- <Select
180
- {...args}
181
- label="Default"
182
- selectedKey="batch-brew"
183
- id="id--select-default"
184
- />
185
- <Select
186
- {...args}
187
- label="Inner portal"
188
- selectedKey="batch-brew"
189
- id="id--select-inner"
190
- portalContainerId={portalContainerId}
191
- />
181
+ <div className=" h-[500px] mb-24 block bg-gray-100 flex flex-col gap-16 justify-center items-center">
182
+ Page content
183
+ <button
184
+ type="button"
185
+ className="border border-gray-500"
186
+ onClick={handleOpen}
187
+ >
188
+ Open Modal
189
+ </button>
190
+ <ContextModal
191
+ isOpen={isOpen}
192
+ onConfirm={handleClose}
193
+ onDismiss={handleClose}
194
+ title="Select test"
195
+ >
196
+ <div
197
+ className="flex gap-24 bg-gray-200 p-12"
198
+ id={portalContainerId}
199
+ >
200
+ <Select
201
+ {...args}
202
+ label="Select within a modal"
203
+ id="id--select-inner"
204
+ portalContainerId={portalContainerId}
205
+ />
206
+ </div>
207
+ </ContextModal>
192
208
  </div>
193
209
  </>
194
210
  )
@@ -1,6 +1,8 @@
1
- import React, { HTMLAttributes } from "react"
1
+ import React, { HTMLAttributes, Key, useEffect, useRef, ReactNode } from "react"
2
2
  import { AriaListBoxOptions, useListBox } from "@react-aria/listbox"
3
+ import { SelectState } from "@react-stately/select"
3
4
  import classnames from "classnames"
5
+ import { useIsClientReady } from "~components/__utilities__/useIsClientReady"
4
6
  import { OverrideClassName } from "~components/types/OverrideClassName"
5
7
  import { useSelectContext } from "../../context"
6
8
  import { SelectOption, SelectItem } from "../../types"
@@ -9,25 +11,75 @@ import styles from "./ListBox.module.scss"
9
11
  export type SingleListBoxProps<Option extends SelectOption> = OverrideClassName<
10
12
  HTMLAttributes<HTMLUListElement>
11
13
  > & {
12
- children: React.ReactNode
14
+ children: ReactNode
13
15
  /** Props for the popup. */
14
16
  menuProps: AriaListBoxOptions<SelectItem<Option>>
15
17
  }
16
18
 
19
+ /** A util to retrieve the key of the correct focusable items based of the focus strategy
20
+ * This is used to determine which element from the collection to focus to on open base on the keyboard event
21
+ * ie: UpArrow will set the focusStrategy to "last"
22
+ */
23
+ const getOptionKeyFromCollection = (
24
+ state: SelectState<SelectItem<any>>
25
+ ): Key | null => {
26
+ if (state.selectedItem) {
27
+ return state.selectedItem.key
28
+ } else if (state.focusStrategy === "last") {
29
+ return state.collection.getLastKey()
30
+ }
31
+ return state.collection.getFirstKey()
32
+ }
33
+
34
+ /** This makes the use of query selector less brittle in instances where a failed selector is passed in
35
+ */
36
+ const safeQuerySelector = (selector: string): HTMLElement | null => {
37
+ try {
38
+ return document.querySelector(selector)
39
+ } catch (error) {
40
+ // eslint-disable-next-line no-console
41
+ console.error("Kaizen querySelector failed:", error)
42
+ return null
43
+ }
44
+ }
45
+
17
46
  export const ListBox = <Option extends SelectOption>({
18
47
  children,
19
48
  menuProps,
20
49
  classNameOverride,
21
50
  ...restProps
22
51
  }: SingleListBoxProps<Option>): JSX.Element => {
52
+ const isClientReady = useIsClientReady()
23
53
  const { state } = useSelectContext<Option>()
24
- const ref = React.useRef<HTMLUListElement>(null)
54
+ const ref = useRef<HTMLUListElement>(null)
25
55
  const { listBoxProps } = useListBox(
26
- { ...menuProps, disallowEmptySelection: true },
56
+ {
57
+ ...menuProps,
58
+ disallowEmptySelection: true,
59
+ // This is to ensure that the listbox doesn't use React Aria's auto focus feature for Listbox, which creates a visual bug
60
+ autoFocus: false,
61
+ },
27
62
  state,
28
63
  ref
29
64
  )
30
65
 
66
+ /**
67
+ * This uses the new useIsClientReady to ensure document exists before trying to querySelector and give the time to focus to the correct element
68
+ */
69
+ useEffect(() => {
70
+ if (isClientReady) {
71
+ const optionKey = getOptionKeyFromCollection(state)
72
+ const focusToElement = safeQuerySelector(`[data-key='${optionKey}']`)
73
+
74
+ if (focusToElement) {
75
+ focusToElement.focus()
76
+ } else {
77
+ // If an element is not found, focus on the listbox. This ensures the list can still be navigated to via keyboard if the keys do not align to the data attributes of the list items.
78
+ ref.current?.focus()
79
+ }
80
+ }
81
+ }, [isClientReady])
82
+
31
83
  return (
32
84
  <ul
33
85
  ref={ref}
@@ -36,7 +36,7 @@ export const Overlay = <Option extends SelectOption>({
36
36
  {...restProps}
37
37
  >
38
38
  {/* eslint-disable-next-line jsx-a11y/no-autofocus */}
39
- <FocusScope autoFocus restoreFocus>
39
+ <FocusScope autoFocus={false} restoreFocus>
40
40
  <DismissButton onDismiss={state.close} />
41
41
  {children}
42
42
  <DismissButton onDismiss={state.close} />
@@ -0,0 +1,18 @@
1
+ import React from "react"
2
+ import {
3
+ Tabs as RACTabs,
4
+ TabsProps as RACTabsProps,
5
+ Key as RACKey,
6
+ } from "react-aria-components"
7
+
8
+ export type TabsProps = Omit<RACTabsProps, "orientation">
9
+ export type Key = RACKey
10
+
11
+ /**
12
+ * {@link https://cultureamp.atlassian.net/wiki/spaces/DesignSystem/pages/3081929117/Tabs Guidance} |
13
+ * {@link https://cultureamp.design/?path=/docs/components-tabs--controlled Storybook}
14
+ *
15
+ * Wrapper around all of the tab subcomponents
16
+ * Holds a TabList and TabPanels
17
+ */
18
+ export const Tabs = (props: TabsProps): JSX.Element => <RACTabs {...props} />
@@ -0,0 +1,43 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/blocks"
2
+ import { ResourceLinks, KAIOInstallation } from "~storybook/components"
3
+ import * as TabsStories from "./Tabs.stories"
4
+
5
+ <Meta title="Components/Tabs/Tabs (Future)/API Specification" />
6
+
7
+ # Tabs
8
+
9
+ <ResourceLinks
10
+ sourceCode="https://github.com/cultureamp/kaizen-design-system/tree/main/packages/components/src/Tabs"
11
+ figma="https://www.figma.com/file/eZKEE5kXbEMY3lx84oz8iN/%F0%9F%92%9C-UI-Kit%3A-Heart?type=design&node-id=1929%3A28886&mode=design&t=AGMmnoJia9RscurE-1"
12
+ designGuidelines="https://cultureamp.atlassian.net/wiki/spaces/DesignSystem/pages/3081929117/Tabs"
13
+
14
+ />
15
+
16
+ <KAIOInstallation
17
+ exportNames={["Tabs", "TabList", "Tab", "TabPanel"]}
18
+ isFuture
19
+ />
20
+
21
+ ## Overview
22
+
23
+ <Canvas of={TabsStories.Playground} />
24
+ <Controls of={TabsStories.Playground} />
25
+
26
+ ## Uncontrolled vs controlled
27
+
28
+ This component is uncontrolled by default. You can specify a default active tab on load with `defaultSelectedKey`.
29
+
30
+ If you need to control the state of the active tabs on the consuming side, use the `selectedKey` prop instead of `defaultSelectedKey`, and hook into `onSelectionChange`.
31
+
32
+ <Canvas of={TabsStories.Controlled} />
33
+
34
+
35
+ ## `tablist|tab` role vs `nav|link`
36
+
37
+ This component implements the `tablist` role and WAI ARIA guidelines for tabs:
38
+ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tablist_role
39
+ https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
40
+
41
+ It's not intended to be used for a navigation bar where you need links wrapped in a `<nav>` instead.
42
+
43
+ If you really need to, you can add a URL history change here (using onChange), but that's probably a sign that this component is being misused.
@@ -0,0 +1,93 @@
1
+ import { Meta } from "@storybook/blocks"
2
+
3
+ <Meta title="Components/Tabs/Tabs (Future)/Migration Guide" />
4
+
5
+ # Future Tabs migration guide
6
+
7
+ A brief guide on how and why to migrate from Kaizen's current `Tabs` to the `future` release.
8
+
9
+ ## Why the change?
10
+
11
+ Current Tabs uses the Reach UI library under the hood, which is no longer actively maintained. This switches the library used internally to React Aria Components.
12
+
13
+ ## Component and API changes at a glance
14
+
15
+ The Reach UI and React Aria APIs are fairly similar so there's not too much to adjust.
16
+
17
+ The biggest adjustment is that you now need to provide an `id` for each `<Tab>` and match it with the one on `<TabPanel>`
18
+
19
+ Additionally:
20
+ - `<TabPanel>`s no longer needs to be wrapped in a `<TabPanels>` component
21
+ - `classNameOverride` changes to `className`
22
+ - `<Tabs defaultIndex={}>` changes to `<Tabs defaultSelectedKey={}>`
23
+ - `<Tabs index={}>` changes to `<Tabs selectedKey={}>`
24
+ - `<Tabs onChange={}>` changes to `<Tabs onSelectionChange={}>`
25
+ - `<Tab disabled>` changes to `<Tab isDisabled>`
26
+
27
+ ## Migration examples
28
+
29
+ ### Uncontrolled
30
+
31
+ #### Before
32
+
33
+ ```tsx
34
+ <Tabs defaultIndex={1}>
35
+ <TabList>
36
+ <Tab>Tab 1</Tab>
37
+ <Tab>Tab 2</Tab>
38
+ <Tab disabled>Disabled tab</Tab>
39
+ </TabList>
40
+ <TabPanels>
41
+ <TabPanel classNameOverride="p-4">Content 1</TabPanel>
42
+ <TabPanel>Content 2</TabPanel>
43
+ <TabPanel>Disabled content</TabPanel>
44
+ </TabPanels>
45
+ </Tabs>
46
+ ```
47
+
48
+ #### After
49
+
50
+ ```tsx
51
+ <Tabs defaultSelectedKey="two">
52
+ <TabList>
53
+ <Tab id="one">Tab 1</Tab>
54
+ <Tab id="two">Tab 2</Tab>
55
+ <Tab id="three" isDisabled>Disabled tab</Tab>
56
+ </TabList>
57
+ <TabPanel id="one" className="p-4">Content 1</TabPanel>
58
+ <TabPanel id="two">Content 2</TabPanel>
59
+ </Tabs>
60
+ ```
61
+
62
+ ### Controlled
63
+
64
+ #### Before
65
+
66
+ ```tsx
67
+ <Tabs onChange={setSelectedTab} defaultIndex={1}>
68
+ <TabList>
69
+ <Tab>Tab 1</Tab>
70
+ <Tab>Tab 2</Tab>
71
+ <Tab disabled>Disabled tab</Tab>
72
+ </TabList>
73
+ <TabPanels>
74
+ <TabPanel>Content 1</TabPanel>
75
+ <TabPanel>Content 2</TabPanel>
76
+ <TabPanel>Disabled content</TabPanel>
77
+ </TabPanels>
78
+ </Tabs>
79
+ ```
80
+
81
+ #### After
82
+
83
+ ```tsx
84
+ <Tabs onSelectionChange={setSelectedTab} selectedKey="two">
85
+ <TabList>
86
+ <Tab id="one">Tab 1</Tab>
87
+ <Tab id="two">Tab 2</Tab>
88
+ <Tab id="three" isDisabled>Disabled tab</Tab>
89
+ </TabList>
90
+ <TabPanel id="one">Content 1</TabPanel>
91
+ <TabPanel id="two">Content 2</TabPanel>
92
+ </Tabs>
93
+ ```
@@ -0,0 +1,74 @@
1
+ import React, { useState } from "react"
2
+ import { Meta, StoryObj } from "@storybook/react"
3
+ import { Text } from "~components/Text"
4
+ import { Button } from "~components/__actions__/v2"
5
+ import { Tab, TabList, TabPanel, Tabs, Key } from "../index"
6
+
7
+ const meta = {
8
+ title: "Components/Tabs/Tabs (Future)",
9
+ component: Tabs,
10
+ args: {
11
+ children: (
12
+ <>
13
+ <TabList aria-label="Tabs">
14
+ <Tab id="one">Tab 1</Tab>
15
+ <Tab id="two">Tab 2</Tab>
16
+ <Tab id="three" badge="3">
17
+ Tab 3
18
+ </Tab>
19
+ <Tab id="four" isDisabled>
20
+ Disabled Tab
21
+ </Tab>
22
+ </TabList>
23
+ <TabPanel id="one" className="p-24">
24
+ <Text variant="body">Content 1</Text>
25
+ </TabPanel>
26
+ <TabPanel id="two" className="p-24">
27
+ <Text variant="body">Content 2</Text>
28
+ </TabPanel>
29
+ <TabPanel id="three" className="p-24">
30
+ <Text variant="body">Content 3</Text>
31
+ </TabPanel>
32
+ </>
33
+ ),
34
+ },
35
+ } satisfies Meta<typeof Tabs>
36
+
37
+ export default meta
38
+
39
+ type Story = StoryObj<typeof meta>
40
+
41
+ export const Playground: Story = {
42
+ parameters: {
43
+ chromatic: { disable: false },
44
+ docs: {
45
+ canvas: {
46
+ sourceState: "shown",
47
+ },
48
+ },
49
+ },
50
+ args: {
51
+ defaultSelectedKey: "one",
52
+ // eslint-disable-next-line no-console
53
+ onSelectionChange: (key): void => console.log("Tab changed to ", key),
54
+ },
55
+ }
56
+
57
+ export const Controlled: Story = {
58
+ render: args => {
59
+ const [selectedKey, setSelectedKey] = useState<Key>(0)
60
+ return (
61
+ <>
62
+ <Tabs
63
+ {...args}
64
+ selectedKey={selectedKey}
65
+ onSelectionChange={setSelectedKey}
66
+ />
67
+ <Button
68
+ label="Switch to tab 2"
69
+ onClick={(): void => setSelectedKey("two")}
70
+ />
71
+ </>
72
+ )
73
+ },
74
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./Tabs"
2
+ export * from "./subcomponents"
@@ -0,0 +1,94 @@
1
+ .tab {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ border: 2px solid transparent;
5
+ border-bottom: 0;
6
+ border-top-left-radius: var(--border-borderless-border-radius);
7
+ border-top-right-radius: var(--border-borderless-border-radius);
8
+ background: var(--color-white);
9
+ white-space: nowrap;
10
+ text-decoration: none;
11
+ padding: var(--spacing-md) var(--spacing-md);
12
+ margin: 0;
13
+ font-family: var(--typography-heading-4-font-family);
14
+ font-size: var(--typography-heading-4-font-size);
15
+ font-weight: var(--typography-heading-4-font-weight);
16
+ line-height: var(--typography-heading-4-line-height);
17
+ letter-spacing: var(--typography-heading-4-letter-spacing);
18
+ color: var(--color-purple-800);
19
+
20
+ &:focus {
21
+ outline: none;
22
+ }
23
+
24
+ &:focus-visible {
25
+ background: var(--color-blue-100);
26
+ color: var(--color-blue-500);
27
+ border-color: var(--color-blue-500);
28
+ }
29
+
30
+ &[data-disabled] {
31
+ opacity: 0.3;
32
+ }
33
+
34
+ &:not(:first-child) {
35
+ margin-inline-start: var(--spacing-xs);
36
+ }
37
+
38
+ &:not([data-disabled]):hover {
39
+ background: var(--color-blue-100);
40
+ color: var(--color-blue-500);
41
+ }
42
+ }
43
+
44
+ .tab[data-selected] {
45
+ position: relative;
46
+ color: var(--color-blue-500);
47
+
48
+ &::before {
49
+ content: "";
50
+ display: block;
51
+ border-top-left-radius: 5px;
52
+ border-top-right-radius: 5px;
53
+ background-color: currentcolor;
54
+ height: 5px;
55
+ width: 100%;
56
+ position: absolute;
57
+ left: 0;
58
+ right: 0;
59
+ bottom: 0;
60
+ }
61
+ }
62
+
63
+ .badge {
64
+ margin-inline-start: var(--spacing-sm);
65
+ display: inline-flex;
66
+ align-items: center;
67
+ }
68
+
69
+ @media (forced-colors: active) {
70
+ .tab {
71
+ border: 2px solid transparent;
72
+
73
+ &:focus-visible::after {
74
+ content: "";
75
+ position: absolute;
76
+ background: transparent;
77
+ border-radius: var(--border-focus-ring-border-radius);
78
+ border-width: var(--border-focus-ring-border-width);
79
+ border-style: var(--border-focus-ring-border-style);
80
+ border-color: transparent;
81
+ inset: -2px;
82
+ }
83
+ }
84
+
85
+ .tab[data-selected]::before {
86
+ /* High contrast also doesn't see the pseudo element created to show the active tab. */
87
+ content: "";
88
+ position: absolute;
89
+ left: 0;
90
+ right: 0;
91
+ bottom: 0;
92
+ border-bottom: 2px solid transparent;
93
+ }
94
+ }