@kaizen/components 3.1.6 → 3.3.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 (61) hide show
  1. package/dist/cjs/src/Avatar/Avatar.cjs +18 -14
  2. package/dist/cjs/src/DateInput/DateInput/DateInput.cjs +1 -2
  3. package/dist/cjs/src/DatePicker/DatePicker.cjs +6 -4
  4. package/dist/cjs/src/Focusable/Focusable.cjs +5 -4
  5. package/dist/cjs/src/MultiSelect/MultiSelect.cjs +4 -2
  6. package/dist/cjs/src/RichTextEditor/RichTextEditor/RichTextEditor.cjs +4 -1
  7. package/dist/cjs/src/RichTextEditor/RichTextEditor/utils/inputrules.cjs +0 -1
  8. package/dist/cjs/src/RichTextEditor/utils/core/createRichTextEditor.cjs +2 -1
  9. package/dist/cjs/src/RichTextEditor/utils/core/hooks/useRichTextEditor.cjs +24 -9
  10. package/dist/cjs/src/SingleSelect/SingleSelect.cjs +4 -2
  11. package/dist/cjs/src/Tabs/subcomponents/TabList/TabList.cjs +8 -1
  12. package/dist/cjs/src/TimeField/TimeField.cjs +9 -4
  13. package/dist/cjs/src/TimeField/subcomponents/TimeSegment/TimeSegment.cjs +6 -4
  14. package/dist/esm/src/Avatar/Avatar.mjs +18 -15
  15. package/dist/esm/src/DateInput/DateInput/DateInput.mjs +1 -2
  16. package/dist/esm/src/DatePicker/DatePicker.mjs +6 -4
  17. package/dist/esm/src/Focusable/Focusable.mjs +5 -4
  18. package/dist/esm/src/MultiSelect/MultiSelect.mjs +4 -2
  19. package/dist/esm/src/RichTextEditor/RichTextEditor/RichTextEditor.mjs +4 -1
  20. package/dist/esm/src/RichTextEditor/RichTextEditor/utils/inputrules.mjs +0 -1
  21. package/dist/esm/src/RichTextEditor/utils/core/createRichTextEditor.mjs +2 -1
  22. package/dist/esm/src/RichTextEditor/utils/core/hooks/useRichTextEditor.mjs +24 -9
  23. package/dist/esm/src/SingleSelect/SingleSelect.mjs +4 -2
  24. package/dist/esm/src/Tabs/subcomponents/TabList/TabList.mjs +8 -1
  25. package/dist/esm/src/TimeField/TimeField.mjs +9 -4
  26. package/dist/esm/src/TimeField/subcomponents/TimeSegment/TimeSegment.mjs +6 -4
  27. package/dist/styles.css +15 -15
  28. package/dist/types/DateInput/DateInputWithIconButton/DateInputWithIconButton.d.ts +2 -2
  29. package/dist/types/DatePicker/DatePicker.d.ts +3 -2
  30. package/dist/types/Focusable/Focusable.d.ts +8 -3
  31. package/dist/types/Input/Input/Input.d.ts +1 -1
  32. package/dist/types/MultiSelect/MultiSelect.d.ts +3 -2
  33. package/dist/types/Notification/index.d.ts +1 -0
  34. package/dist/types/RichTextEditor/RichTextEditor/RichTextEditor.d.ts +2 -1
  35. package/dist/types/RichTextEditor/utils/core/createRichTextEditor.d.ts +1 -0
  36. package/dist/types/RichTextEditor/utils/core/hooks/useRichTextEditor.d.ts +3 -2
  37. package/dist/types/SingleSelect/SingleSelect.d.ts +3 -3
  38. package/dist/types/TextArea/TextArea.d.ts +1 -1
  39. package/dist/types/TimeField/TimeField.d.ts +1 -0
  40. package/dist/types/TimeField/subcomponents/TimeSegment/TimeSegment.d.ts +3 -1
  41. package/package.json +7 -7
  42. package/src/Avatar/Avatar.tsx +19 -11
  43. package/src/DateInput/DateInput/DateInput.tsx +1 -2
  44. package/src/DateInput/DateInputWithIconButton/DateInputWithIconButton.tsx +2 -2
  45. package/src/DatePicker/DatePicker.tsx +6 -3
  46. package/src/Focusable/Focusable.tsx +17 -7
  47. package/src/Input/Input/Input.tsx +1 -1
  48. package/src/MultiSelect/MultiSelect.tsx +4 -1
  49. package/src/Notification/index.ts +1 -0
  50. package/src/RichTextEditor/RichTextEditor/RichTextEditor.tsx +3 -0
  51. package/src/RichTextEditor/utils/core/createRichTextEditor.spec.ts +1 -1
  52. package/src/RichTextEditor/utils/core/createRichTextEditor.ts +2 -0
  53. package/src/RichTextEditor/utils/core/hooks/useRichTextEditor.spec.tsx +8 -3
  54. package/src/RichTextEditor/utils/core/hooks/useRichTextEditor.ts +20 -7
  55. package/src/SingleSelect/SingleSelect.tsx +5 -3
  56. package/src/Tabs/_docs/Tabs.spec.stories.tsx +39 -0
  57. package/src/Tabs/_docs/Tabs.stories.tsx +38 -2
  58. package/src/Tabs/subcomponents/TabList/TabList.tsx +9 -1
  59. package/src/TextArea/TextArea.tsx +1 -1
  60. package/src/TimeField/TimeField.tsx +17 -15
  61. package/src/TimeField/subcomponents/TimeSegment/TimeSegment.tsx +6 -3
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useId, useState } from 'react'
2
- import { type UseFloatingReturn } from '@floating-ui/react-dom'
3
2
  import { useButton } from '@react-aria/button'
4
3
  import { HiddenSelect, useSelect } from '@react-aria/select'
4
+ import { mergeRefs } from '@react-aria/utils'
5
5
  import { useSelectState, type SelectProps as AriaSelectProps } from '@react-stately/select'
6
6
  import { type Key } from '@react-types/shared'
7
7
  import classnames from 'classnames'
@@ -32,6 +32,7 @@ export type SingleSelectProps<Option extends SingleSelectOption = SingleSelectOp
32
32
  */
33
33
  items: SingleSelectItem<Option>[]
34
34
  id?: string
35
+ inputRef?: React.Ref<HTMLButtonElement>
35
36
  /**
36
37
  * Optional render function that allows custom rendering of the trigger button.
37
38
  * The function receives the `selectToggleProps` and a `ref` to be applied
@@ -39,7 +40,7 @@ export type SingleSelectProps<Option extends SingleSelectOption = SingleSelectOp
39
40
  */
40
41
  trigger?: (
41
42
  selectToggleProps: SelectToggleProps & {
42
- ref: UseFloatingReturn<HTMLButtonElement>['refs']['setReference']
43
+ ref: React.Ref<HTMLButtonElement>
43
44
  },
44
45
  ) => JSX.Element
45
46
  /**
@@ -95,6 +96,7 @@ export const SingleSelect = <Option extends SingleSelectOption = SingleSelectOpt
95
96
  isDisabled,
96
97
  onSelectionChange,
97
98
  portalContainerId,
99
+ inputRef,
98
100
  ...restProps
99
101
  }: SingleSelectProps<Option>): JSX.Element => {
100
102
  const { refs } = useFloating<HTMLButtonElement>()
@@ -153,7 +155,7 @@ export const SingleSelect = <Option extends SingleSelectOption = SingleSelectOpt
153
155
  status,
154
156
  'isDisabled': triggerProps.isDisabled,
155
157
  isReversed,
156
- 'ref': refs.setReference,
158
+ 'ref': mergeRefs(inputRef, refs.setReference),
157
159
  'aria-describedby': classnames(validationMessage && validationId, description && descriptionId),
158
160
  'aria-required': isRequired,
159
161
  }
@@ -117,6 +117,45 @@ export const ArrowsShowingAndHidingRTL: Story = {
117
117
  },
118
118
  }
119
119
 
120
+ let scrollIntoViewCalls = 0
121
+ const originalScrollIntoView = HTMLElement.prototype.scrollIntoView
122
+
123
+ /**
124
+ * The selected tab must NOT be scrolled into view on mount (KZN-3363) — only when
125
+ * the selection changes via user interaction. This protects users who render Tabs
126
+ * below the fold from having the page jump on load.
127
+ */
128
+ export const ScrollsIntoViewOnSelectionOnly: Story = {
129
+ name: 'Scrolls into view on selection only',
130
+ beforeEach: () => {
131
+ scrollIntoViewCalls = 0
132
+ HTMLElement.prototype.scrollIntoView = function (): void {
133
+ scrollIntoViewCalls += 1
134
+ }
135
+ return () => {
136
+ HTMLElement.prototype.scrollIntoView = originalScrollIntoView
137
+ }
138
+ },
139
+ render: (args) => (
140
+ <div style={{ maxWidth: '500px' }}>
141
+ <Tabs defaultSelectedKey="one" {...args} />
142
+ </div>
143
+ ),
144
+ play: async ({ canvasElement, step }) => {
145
+ const canvas = within(canvasElement.parentElement!)
146
+
147
+ await step('No scroll into view on mount', async () => {
148
+ expect(scrollIntoViewCalls).toBe(0)
149
+ })
150
+
151
+ await step('Scrolls into view when a tab is selected', async () => {
152
+ const tabFour = await canvas.findByRole('tab', { name: 'Tab 4' })
153
+ await userEvent.click(tabFour)
154
+ await waitFor(() => expect(scrollIntoViewCalls).toBeGreaterThan(0))
155
+ })
156
+ },
157
+ }
158
+
120
159
  export const AsyncLoaded: Story = {
121
160
  render: () => {
122
161
  const [selectedKey, setSelectedKey] = useState<Key>(0)
@@ -1,6 +1,6 @@
1
1
  import React, { useState } from 'react'
2
2
  import { type Meta, type StoryObj } from '@storybook/react'
3
- import { Button } from '~components/ButtonV1'
3
+ import { Button } from '~components/Button'
4
4
  import { Text } from '~components/Text'
5
5
  import { Tab, TabList, TabPanel, Tabs, type Key } from '../index'
6
6
 
@@ -82,8 +82,44 @@ export const Controlled: Story = {
82
82
  <Text variant="body">Content 2</Text>
83
83
  </TabPanel>
84
84
  </Tabs>
85
- <Button label="Switch to tab 2" onClick={(): void => setSelectedKey('two')} />
85
+ <Button onClick={(): void => setSelectedKey('two')}>Switch to tab 2</Button>
86
86
  </>
87
87
  )
88
88
  },
89
89
  }
90
+
91
+ /**
92
+ * When a tab is selected via user interaction, `TabList` scrolls the selected tab
93
+ * into view within its horizontal strip. It deliberately does *not* do this on
94
+ * mount — so when `Tabs` render below the fold, landing on the page does not scroll
95
+ * the tabs into view or jump the page.
96
+ *
97
+ * This story pushes the `Tabs` below the fold with a full-viewport spacer. On load
98
+ * the page stays at the top. Scroll down and select a tab — only then does the
99
+ * selected tab scroll into view within the strip.
100
+ */
101
+ export const BelowTheFold: Story = {
102
+ render: () => (
103
+ <div style={{ maxWidth: '760px' }}>
104
+ <div style={{ height: '100dvh' }}>
105
+ <Text variant="body">Scroll down — the tabs are rendered below the fold.</Text>
106
+ </div>
107
+ <Tabs defaultSelectedKey="tab-0">
108
+ <TabList aria-label="Lorem ipsum tabs">
109
+ {Array.from({ length: 8 }, (_, i) => (
110
+ <Tab key={i} id={`tab-${i}`}>
111
+ Lorem {i + 1}
112
+ </Tab>
113
+ ))}
114
+ </TabList>
115
+ {Array.from({ length: 8 }, (_, i) => (
116
+ <TabPanel key={i} id={`tab-${i}`} className="p-24">
117
+ <Text variant="body">
118
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit — panel {i + 1}.
119
+ </Text>
120
+ </TabPanel>
121
+ ))}
122
+ </Tabs>
123
+ </div>
124
+ ),
125
+ }
@@ -44,6 +44,7 @@ export const TabList = (props: TabListProps): JSX.Element => {
44
44
  const [containerElement, setContainerElement] = useState<HTMLElement | null>()
45
45
  const tabListContext = useContext(TabListStateContext)
46
46
  const selectedKey = tabListContext?.selectedKey
47
+ const prevSelectedKey = useRef(selectedKey)
47
48
 
48
49
  useEffect(() => {
49
50
  if (!isDocumentReady) {
@@ -107,7 +108,14 @@ export const TabList = (props: TabListProps): JSX.Element => {
107
108
  return
108
109
  }
109
110
 
110
- // Scroll selected tab into view
111
+ // Only scroll the selected tab into view when the selection actually changes
112
+ // (i.e. user interaction). Skipping the no-op runs avoids scrolling the page
113
+ // on mount when the Tabs sit below the fold.
114
+ if (prevSelectedKey.current === selectedKey) {
115
+ return
116
+ }
117
+ prevSelectedKey.current = selectedKey
118
+
111
119
  containerElement
112
120
  ?.querySelector('[role="tab"][data-selected=true]')
113
121
  ?.scrollIntoView({ block: 'nearest', inline: 'center' })
@@ -4,7 +4,7 @@ import { type OverrideClassName } from '~components/types/OverrideClassName'
4
4
  import styles from './TextArea.module.css'
5
5
 
6
6
  export type TextAreaProps = {
7
- textAreaRef?: React.RefObject<HTMLTextAreaElement>
7
+ textAreaRef?: React.Ref<HTMLTextAreaElement>
8
8
  status?: 'default' | 'error' | 'caution'
9
9
  /**
10
10
  * Grows the input height as more content is added
@@ -27,6 +27,7 @@ export type TimeFieldProps = {
27
27
  value: ValueType | null
28
28
  status?: StatusType
29
29
  validationMessage?: React.ReactNode
30
+ inputRef?: React.Ref<HTMLSpanElement>
30
31
  } & OverrideClassName<Omit<TimeFieldStateOptions, OmittedTimeFieldProps>>
31
32
 
32
33
  // This needed to be placed directly below the props because
@@ -49,6 +50,7 @@ const TimeFieldComponent = ({
49
50
  validationMessage,
50
51
  isDisabled,
51
52
  classNameOverride,
53
+ inputRef,
52
54
  ...restProps
53
55
  }: TimeFieldProps): JSX.Element => {
54
56
  const reactId = useId()
@@ -79,7 +81,7 @@ const TimeFieldComponent = ({
79
81
  const hasError = !!validationMessage && status === 'error'
80
82
  const descriptionId = hasError ? `${id}-field-message` : undefined
81
83
 
82
- const inputRef = React.useRef(null)
84
+ const internalRef = React.useRef<HTMLDivElement>(null)
83
85
  const { fieldProps, labelProps } = useTimeField(
84
86
  {
85
87
  ...restProps,
@@ -88,36 +90,36 @@ const TimeFieldComponent = ({
88
90
  'aria-describedby': descriptionId,
89
91
  },
90
92
  state,
91
- inputRef,
93
+ internalRef,
92
94
  )
95
+ const firstEditableIndex = state.segments.findIndex((s) => s.isEditable)
96
+
93
97
  return (
94
98
  <div className={classNameOverride}>
95
99
  <Label disabled={state.isDisabled} {...labelProps} classNameOverride={styles.label}>
96
100
  {label}
97
101
  </Label>
98
102
  <div className={styles.wrapper}>
99
- {}
100
103
  <div
101
104
  {...fieldProps}
102
105
  id={id}
103
- ref={inputRef}
106
+ ref={internalRef}
104
107
  className={classnames(
105
108
  styles.input,
106
109
  state.isDisabled && styles.isDisabled,
107
110
  status === 'error' && styles.error,
108
111
  )}
109
112
  >
110
- {state.segments.map((segment, i) => {
111
- return (
112
- <TimeSegment
113
- key={i}
114
- segment={segment}
115
- state={state}
116
- hasPadding={![8294, 8297].includes(segment.text.charCodeAt(0))}
117
- // ^react-aria includes these characters to ensure correct RTL behaviour, but we want to avoid these adding random spacing
118
- />
119
- )
120
- })}
113
+ {state.segments.map((segment, i) => (
114
+ <TimeSegment
115
+ key={i}
116
+ segment={segment}
117
+ state={state}
118
+ inputRef={i === firstEditableIndex ? inputRef : undefined}
119
+ hasPadding={![8294, 8297].includes(segment.text.charCodeAt(0))}
120
+ // ^react-aria includes these characters to ensure correct RTL behaviour, but we want to avoid these adding random spacing
121
+ />
122
+ ))}
121
123
  <div className={styles.focusRing} />
122
124
  </div>
123
125
  </div>
@@ -1,5 +1,6 @@
1
1
  import React from 'react'
2
2
  import { useDateSegment } from '@react-aria/datepicker'
3
+ import { mergeRefs } from '@react-aria/utils'
3
4
  import { type DateFieldState, type DateSegment } from '@react-stately/datepicker'
4
5
  import classnames from 'classnames'
5
6
  import { generateSegmentDisplayText } from './utils/generateSegmentDisplayText'
@@ -9,15 +10,17 @@ export type TimeSegmentProps = {
9
10
  segment: DateSegment
10
11
  state: DateFieldState
11
12
  hasPadding?: boolean
13
+ inputRef?: React.Ref<HTMLSpanElement>
12
14
  }
13
15
 
14
16
  export const TimeSegment = ({
15
17
  segment,
16
18
  state,
17
19
  hasPadding = true,
20
+ inputRef,
18
21
  }: TimeSegmentProps): JSX.Element => {
19
- const ref = React.useRef<HTMLDivElement>(null)
20
- const { segmentProps } = useDateSegment(segment, state, ref)
22
+ const internalRef = React.useRef<HTMLSpanElement>(null)
23
+ const { segmentProps } = useDateSegment(segment, state, internalRef)
21
24
 
22
25
  // Chrome has a bug where `contenteditable` elements receive focus from external clicks.
23
26
  // This (in combination with the invisible character &#8203;) creates boundaries
@@ -28,7 +31,7 @@ export const TimeSegment = ({
28
31
  &#8203;
29
32
  <span
30
33
  {...segmentProps}
31
- ref={ref}
34
+ ref={mergeRefs<HTMLSpanElement>(internalRef, inputRef)}
32
35
  className={classnames(
33
36
  styles.timeSegment,
34
37
  segment.type === 'literal' && styles.literal,