@kaizen/components 0.0.0-canary-fix-filter-select-jump-issue-useHasStableYPosition-20250120225815 → 0.0.0-canary-fix-filter-select-jump-issue-useHasStableYPosition-test-20250129223936

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.
@@ -2,4 +2,4 @@
2
2
  * Due to the floating element's position starting as a negative value on render and then jumping to the correct position, this caused the focus to jump to the top of the page.
3
3
  * This now polls to check if the element's position is stable by comparing the first and last position.
4
4
  */
5
- export declare const useHasStableYPosition: (ref: React.RefObject<HTMLElement>) => boolean;
5
+ export declare const useHasCalculatedListboxPosition: (ref: React.RefObject<HTMLElement>) => boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaizen/components",
3
- "version": "0.0.0-canary-fix-filter-select-jump-issue-useHasStableYPosition-20250120225815",
3
+ "version": "0.0.0-canary-fix-filter-select-jump-issue-useHasStableYPosition-test-20250129223936",
4
4
  "description": "Kaizen component library",
5
5
  "author": "Geoffrey Chong <geoff.chong@cultureamp.com>",
6
6
  "homepage": "https://cultureamp.design",
@@ -3,10 +3,11 @@
3
3
 
4
4
  .content {
5
5
  max-width: $layout-content-max-width;
6
- padding: 0 $layout-content-side-margin;
6
+ margin: 0 $layout-content-side-margin;
7
7
  width: 100%;
8
8
 
9
9
  @media (max-width: calc(#{$layout-breakpoints-large} - 1px)) {
10
- padding: 0 $content-margin-width-on-medium-and-small;
10
+ margin: 0 $content-margin-width-on-medium-and-small;
11
+ width: calc(100% - 2 * #{$content-margin-width-on-medium-and-small});
11
12
  }
12
13
  }
@@ -4,7 +4,7 @@ import * as FilterDRPStories from './FilterDateRangePicker.stories'
4
4
 
5
5
  <Meta of={FilterDRPStories} />
6
6
 
7
- # Filter
7
+ # FilterDateRangePicker
8
8
 
9
9
  <ResourceLinks
10
10
  sourceCode="https://github.com/cultureamp/kaizen-design-system/tree/main/packages/components/src/FilterDateRangePicker"
@@ -4,7 +4,7 @@ import * as FilterMultiSelectStories from './FilterMultiSelect.stories'
4
4
 
5
5
  <Meta of={FilterMultiSelectStories} />
6
6
 
7
- # Filter Bar
7
+ # FilterMultiSelect
8
8
 
9
9
  <ResourceLinks
10
10
  sourceCode="https://github.com/cultureamp/kaizen-design-system/tree/main/packages/components/src/FilterMultiSelect"
@@ -2,6 +2,7 @@ import React, { useState } from 'react'
2
2
  import { type Meta, type StoryObj } from '@storybook/react'
3
3
  import { fn } from '@storybook/test'
4
4
  import { renderTriggerControls } from '~components/Filter/_docs/controls/renderTriggerControls'
5
+ import { Well } from '~components/Well'
5
6
  import { FilterButton } from '../../FilterButton'
6
7
  import { FilterSelect } from '../FilterSelect'
7
8
  import { type SelectOption } from '../types'
@@ -104,44 +105,40 @@ export const AdditionalProperties: Story = {
104
105
  /**
105
106
  * Extend the option type to have additional properties to use for rendering.
106
107
  */
107
- export const TestPageWithFilterSelect: Story = {
108
- render: (args) => {
108
+ export const FilterSelectBelowPageContent: Story = {
109
+ render: () => {
109
110
  const [isOpen, setIsOpen] = useState<boolean>(false)
110
111
 
111
112
  return (
112
113
  <div>
113
- <div style={{ color: 'coral', display: 'block', height: '1500px' }}>Content</div>
114
- <FilterSelect<SelectOption & { isFruit: boolean }>
115
- {...args}
116
- label="Custom"
114
+ <Well color="gray" style={{ height: '1500px' }}>
115
+ Page content above the FilterSelect
116
+ </Well>
117
+ <FilterSelect
118
+ label="Label"
117
119
  isOpen={isOpen}
118
120
  setIsOpen={setIsOpen}
119
- items={[
120
- { label: 'Bubblegum', value: 'bubblegum', isFruit: false },
121
- { label: 'Strawberry', value: 'strawberry', isFruit: true },
122
- { label: 'Chocolate', value: 'chocolate', isFruit: false },
123
- { label: 'Apple', value: 'apple', isFruit: true },
124
- { label: 'Lemon', value: 'lemon', isFruit: true },
125
- ]}
121
+ renderTrigger={(triggerProps) => <FilterButton {...triggerProps} />}
122
+ items={groupedMockItems}
126
123
  >
127
124
  {({ items }): JSX.Element[] =>
128
- items.map((item) =>
129
- item.type === 'item' ? (
130
- <FilterSelect.Option
131
- key={item.key}
132
- item={{
133
- ...item,
134
- rendered: item.value?.isFruit ? `${item.rendered} (Fruit)` : item.rendered,
135
- }}
136
- />
137
- ) : (
138
- <FilterSelect.ItemDefaultRender key={item.key} item={item} />
139
- ),
140
- )
125
+ items.map((item) => {
126
+ if (item.type === 'item') {
127
+ return (
128
+ <FilterSelect.Option
129
+ key={item.key}
130
+ item={{
131
+ ...item,
132
+ }}
133
+ />
134
+ )
135
+ }
136
+ return <FilterSelect.ItemDefaultRender key={item.key} item={item} />
137
+ })
141
138
  }
142
139
  </FilterSelect>
143
140
  </div>
144
141
  )
145
142
  },
146
- name: 'Additional option properties',
143
+ name: 'FilterSelect below page content',
147
144
  }
@@ -188,14 +188,24 @@ describe('<Select />', () => {
188
188
  })
189
189
  })
190
190
  it('is closed when hits the escape key', async () => {
191
- const { getByRole } = render(<SelectWrapper defaultOpen />)
192
- const menu = getByRole('listbox')
191
+ const { getByRole, queryByRole } = render(<SelectWrapper />)
192
+ const trigger = getByRole('combobox', {
193
+ name: 'Mock Label',
194
+ })
195
+ await user.tab()
196
+ await waitFor(() => {
197
+ expect(trigger).toHaveFocus()
198
+ })
199
+ await user.keyboard('{Enter}')
200
+
193
201
  await waitFor(() => {
194
- expect(menu).toBeVisible()
202
+ expect(queryByRole('listbox')).toBeVisible()
195
203
  })
204
+
196
205
  await user.keyboard('{Escape}')
206
+
197
207
  await waitFor(() => {
198
- expect(menu).not.toBeInTheDocument()
208
+ expect(queryByRole('listbox')).toBe(null)
199
209
  })
200
210
  })
201
211
  })
@@ -4,8 +4,8 @@ import { useEffect, useState } from 'react'
4
4
  * Due to the floating element's position starting as a negative value on render and then jumping to the correct position, this caused the focus to jump to the top of the page.
5
5
  * This now polls to check if the element's position is stable by comparing the first and last position.
6
6
  */
7
- export const useHasStableYPosition = (ref: React.RefObject<HTMLElement>): boolean => {
8
- const [isStable, setIsStable] = useState(false)
7
+ export const useHasCalculatedListboxPosition = (ref: React.RefObject<HTMLElement>): boolean => {
8
+ const [hasStablePosition, setHasStablePosition] = useState(false)
9
9
  const [lastYPosition, setLastYPosition] = useState<number | null>(null)
10
10
 
11
11
  useEffect(() => {
@@ -14,18 +14,24 @@ export const useHasStableYPosition = (ref: React.RefObject<HTMLElement>): boolea
14
14
  const { y } = ref.current.getBoundingClientRect()
15
15
  if (lastYPosition === null) {
16
16
  setLastYPosition(y)
17
- } else if (y === lastYPosition) {
18
- setIsStable(true)
17
+ } else if (y === lastYPosition && y >= 0) {
18
+ setHasStablePosition(true)
19
19
  } else {
20
20
  setLastYPosition(y)
21
21
  }
22
22
  }
23
23
  }
24
24
 
25
- const intervalId = setInterval(checkPosition, 1)
25
+ const intervalId = setInterval(() => {
26
+ if (hasStablePosition) {
27
+ clearInterval(intervalId)
28
+ return
29
+ }
30
+ checkPosition()
31
+ }, 1)
26
32
 
27
33
  return () => clearInterval(intervalId)
28
- }, [ref, lastYPosition])
34
+ }, [ref, lastYPosition, hasStablePosition])
29
35
 
30
- return isStable
36
+ return hasStablePosition
31
37
  }
@@ -4,8 +4,8 @@ import { type SelectState } from '@react-stately/select'
4
4
  import classnames from 'classnames'
5
5
  import { type OverrideClassName } from '~components/types/OverrideClassName'
6
6
  import { useSelectContext } from '../../context'
7
- import { useHasStableYPosition } from '../../hooks/useHasStableYPosition'
8
- import { type SelectItem, type SelectOption } from '../../types'
7
+ import { useHasCalculatedListboxPosition } from '../../hooks/useHasCalculatedListboxPosition'
8
+ import { type SelectItem, type SelectItemNode, type SelectOption } from '../../types'
9
9
  import styles from './ListBox.module.scss'
10
10
 
11
11
  export type SingleListBoxProps<Option extends SelectOption> = OverrideClassName<
@@ -16,17 +16,38 @@ export type SingleListBoxProps<Option extends SelectOption> = OverrideClassName<
16
16
  menuProps: AriaListBoxOptions<SelectItem<Option>>
17
17
  }
18
18
 
19
- /** A util to retrieve the key of the correct focusable items based of the focus strategy
19
+ /** determines is the first or last key passed in is a section. If not it will return the key, otherwise will return the first option key of that section */
20
+ const getOptionOrSectionKey = (
21
+ optionKey: SelectOption['value'] | null,
22
+ state: SelectState<SelectItem<any>>,
23
+ ): Key | null => {
24
+ if (!optionKey) return null
25
+
26
+ const option = state.collection.getItem(optionKey) as SelectItemNode | null
27
+ const optionType = option?.type
28
+
29
+ if (optionType === 'section') {
30
+ const sectionOptions = option?.value?.options
31
+
32
+ return sectionOptions ? Array.from(sectionOptions)[0]?.value : null
33
+ }
34
+ return optionKey
35
+ }
36
+
37
+ /** A util to retrieve the key of the correct focusable option based of the focus strategy
20
38
  * This is used to determine which element from the collection to focus to on open base on the keyboard event
21
39
  * ie: UpArrow will set the focusStrategy to "last"
22
40
  */
23
41
  const getOptionKeyFromCollection = (state: SelectState<SelectItem<any>>): Key | null => {
24
42
  if (state.selectedItem) {
25
43
  return state.selectedItem.key
26
- } else if (state.focusStrategy === 'last') {
27
- return state.collection.getLastKey()
28
44
  }
29
- return state.collection.getFirstKey()
45
+
46
+ if (state.focusStrategy === 'last') {
47
+ return getOptionOrSectionKey(state.collection.getLastKey(), state)
48
+ }
49
+
50
+ return getOptionOrSectionKey(state.collection.getFirstKey(), state)
30
51
  }
31
52
 
32
53
  /** This makes the use of query selector less brittle in instances where a failed selector is passed in
@@ -49,7 +70,7 @@ export const ListBox = <Option extends SelectOption>({
49
70
  }: SingleListBoxProps<Option>): JSX.Element => {
50
71
  const { state } = useSelectContext<Option>()
51
72
  const ref = useRef<HTMLUListElement>(null)
52
- const hasStableYPosition = useHasStableYPosition(ref)
73
+ const hasCalculatedListboxPosition = useHasCalculatedListboxPosition(ref)
53
74
  const { listBoxProps } = useListBox(
54
75
  {
55
76
  ...menuProps,
@@ -62,10 +83,10 @@ export const ListBox = <Option extends SelectOption>({
62
83
  )
63
84
 
64
85
  /**
65
- * This uses the hasStableYPosition to determine if the position is stable within the window
86
+ * When the Listbox is opened the initial position starts above the window, which can cause the out of the box behaviour in react-aria's listbox to jump a user to the top of the page.
66
87
  */
67
88
  useEffect(() => {
68
- if (hasStableYPosition) {
89
+ if (hasCalculatedListboxPosition) {
69
90
  const optionKey = getOptionKeyFromCollection(state)
70
91
  const focusToElement = safeQuerySelector(`[data-key='${optionKey}']`)
71
92
 
@@ -77,7 +98,7 @@ export const ListBox = <Option extends SelectOption>({
77
98
  }
78
99
  // Only run this effect for checking the first successful render
79
100
  // eslint-disable-next-line react-hooks/exhaustive-deps
80
- }, [hasStableYPosition])
101
+ }, [hasCalculatedListboxPosition])
81
102
 
82
103
  return (
83
104
  <ul