Skip to content

Commit 4d318af

Browse files
committed
Dropdown component
1 parent d8dac08 commit 4d318af

File tree

11 files changed

+299
-21
lines changed

11 files changed

+299
-21
lines changed

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export default typescript.config(
5959
'no-useless-return': 'error',
6060
'no-var': 'error',
6161
'object-curly-spacing': ['error', 'always'],
62+
'object-shorthand': 'error',
6263
'prefer-const': 'warn',
6364
'prefer-destructuring': ['warn', {
6465
object: true,

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"watch:url": "NODE_ENV=development nodemon bin/cli.js https://hyperparam.blob.core.windows.net/hyperparam/starcoderdata-js-00000-of-00065.parquet"
5151
},
5252
"dependencies": {
53-
"hightable": "0.13.4",
53+
"hightable": "0.14.0",
5454
"hyparquet": "1.10.3",
5555
"hyparquet-compressors": "1.1.1",
5656
"icebird": "0.1.13",
@@ -65,17 +65,17 @@
6565
"@types/react-dom": "19.0.4",
6666
"@vitejs/plugin-react": "4.3.4",
6767
"@vitest/coverage-v8": "3.1.1",
68-
"eslint": "9.23.0",
69-
"eslint-plugin-react": "7.37.4",
68+
"eslint": "9.24.0",
69+
"eslint-plugin-react": "7.37.5",
7070
"eslint-plugin-react-hooks": "5.2.0",
7171
"eslint-plugin-react-refresh": "0.4.19",
7272
"globals": "16.0.0",
7373
"jsdom": "26.0.0",
7474
"nodemon": "3.1.9",
7575
"npm-run-all": "4.1.5",
76-
"typescript": "5.8.2",
76+
"typescript": "5.8.3",
7777
"typescript-eslint": "8.29.0",
78-
"vite": "6.2.4",
78+
"vite": "6.2.5",
7979
"vitest": "3.1.1"
8080
}
8181
}

src/components/Cell/Cell.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import Breadcrumb from '../Breadcrumb/Breadcrumb.js'
77
import Layout from '../Layout/Layout.js'
88

99
interface CellProps {
10-
source: FileSource;
11-
row: number;
12-
col: number;
10+
source: FileSource
11+
row: number
12+
col: number
1313
}
1414

1515
/**
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
.dropdown {
2+
display: inline-block;
3+
position: relative;
4+
text-overflow: ellipsis;
5+
user-select: none;
6+
white-space: nowrap;
7+
}
8+
9+
.dropdownButton,
10+
.dropdownButton:active,
11+
.dropdownButton:focus,
12+
.dropdownButton:hover {
13+
align-items: center;
14+
background: inherit;
15+
border: none;
16+
color: inherit;
17+
cursor: pointer;
18+
display: flex;
19+
font-size: initial;
20+
overflow-x: hidden;
21+
padding: 0;
22+
}
23+
.dropdownButton:active,
24+
.dropdownButton:focus,
25+
.dropdownButton:hover {
26+
color: #113;
27+
}
28+
29+
/* caret */
30+
.dropdownButton::before {
31+
content: "\25bc";
32+
display: inline-block;
33+
font-size: 10px;
34+
margin-right: 4px;
35+
transform: rotate(-90deg);
36+
transition: transform 0.1s;
37+
}
38+
.open .dropdownButton::before {
39+
transform: rotate(0deg);
40+
}
41+
42+
/* alignment */
43+
.dropdownLeft .dropdownContent {
44+
left: 0;
45+
}
46+
47+
.dropdownContent {
48+
background-color: #eee;
49+
position: absolute;
50+
right: 0;
51+
border-radius: 6px;
52+
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
53+
display: flex;
54+
flex-direction: column;
55+
max-height: 0;
56+
max-width: 300px;
57+
min-width: 160px;
58+
transition: max-height 0.1s ease-out;
59+
overflow-y: hidden;
60+
z-index: 20;
61+
}
62+
63+
.open .dropdownContent {
64+
max-height: 170px;
65+
overflow-y: auto;
66+
}
67+
68+
.dropdownContent > * {
69+
display: block;
70+
}
71+
72+
.dropdownContent a,
73+
.dropdownContent button {
74+
background: none;
75+
border: none;
76+
border-radius: 0;
77+
color: inherit;
78+
flex-shrink: 0;
79+
font-size: 12px;
80+
overflow: hidden;
81+
text-overflow: ellipsis;
82+
padding: 8px 16px;
83+
text-align: left;
84+
text-decoration: none;
85+
width: 100%;
86+
}
87+
.dropdownContent a:active,
88+
.dropdownContent a:focus,
89+
.dropdownContent a:hover,
90+
.dropdownContent button:active,
91+
.dropdownContent button:focus,
92+
.dropdownContent button:hover {
93+
background-color: rgba(31, 30, 33, 0.1);
94+
}
95+
.dropdownContent input {
96+
margin: 4px 8px;
97+
}
98+
99+
.scroller {
100+
display: flex;
101+
flex-direction: column;
102+
max-height: 100%;
103+
overflow-y: auto;
104+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { fireEvent, render } from '@testing-library/react'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import Dropdown from './Dropdown'
4+
import styles from './Dropdown.module.css'
5+
6+
describe('Dropdown Component', () => {
7+
it('renders dropdown with its children', () => {
8+
const { container: { children: [ div ] }, queryByText } = render(
9+
<Dropdown><div>Child 1</div><div>Child 2</div></Dropdown>
10+
)
11+
expect(div?.classList).not.toContain(styles.open)
12+
expect(queryByText('Child 1')).toBeDefined()
13+
expect(queryByText('Child 2')).toBeDefined()
14+
expect(div?.classList).not.toContain(styles.dropdownLeft)
15+
})
16+
17+
it('toggles dropdown content on button click', () => {
18+
const { container: { children: [ div ] }, getByRole } = render(
19+
<Dropdown label='go'><div>Child 1</div><div>Child 2</div></Dropdown>
20+
)
21+
22+
const dropdownButton = getByRole('button')
23+
fireEvent.click(dropdownButton)
24+
25+
// Check if dropdown content appears
26+
expect(div?.classList).toContain(styles.open)
27+
28+
// Click again to close
29+
fireEvent.click(dropdownButton)
30+
expect(div?.classList).not.toContain(styles.open)
31+
})
32+
33+
it('closes dropdown when clicking outside', () => {
34+
const { container: { children: [ div ] }, getByRole } = render(
35+
<Dropdown><div>Child 1</div><div>Child 2</div></Dropdown>
36+
)
37+
38+
const dropdownButton = getByRole('button')
39+
fireEvent.click(dropdownButton) // open dropdown
40+
expect(div?.classList).toContain(styles.open)
41+
42+
// Simulate a click outside
43+
fireEvent.mouseDown(document)
44+
expect(div?.classList).not.toContain(styles.open)
45+
})
46+
47+
it('does not close dropdown when clicking inside', () => {
48+
const { container: { children: [ div ] }, getByRole, getByText } = render(
49+
<Dropdown><div>Child 1</div><div>Child 2</div></Dropdown>
50+
)
51+
52+
const dropdownButton = getByRole('button')
53+
fireEvent.click(dropdownButton) // open dropdown
54+
expect(div?.classList).toContain(styles.open)
55+
56+
const dropdownContent = getByText('Child 1').parentElement
57+
if (!dropdownContent) throw new Error('Dropdown content not found')
58+
fireEvent.mouseDown(dropdownContent)
59+
expect(div?.classList).toContain(styles.open)
60+
})
61+
62+
it('closes dropdown on escape key press', () => {
63+
const { container: { children: [ div ] }, getByRole } = render(
64+
<Dropdown><div>Child 1</div><div>Child 2</div></Dropdown>
65+
)
66+
67+
const dropdownButton = getByRole('button')
68+
fireEvent.click(dropdownButton) // open dropdown
69+
expect(div?.classList).toContain(styles.open)
70+
71+
// Press escape key
72+
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
73+
expect(div?.classList).not.toContain(styles.open)
74+
})
75+
76+
it('adds dropdownLeft class when align is left', () => {
77+
const { container: { children: [ div ] } } = render(
78+
<Dropdown align='left'><div>Child 1</div><div>Child 2</div></Dropdown>
79+
)
80+
expect(div?.classList).toContain(styles.dropdownLeft)
81+
})
82+
83+
it('cleans up event listeners on unmount', () => {
84+
const { unmount } = render(<Dropdown><div>Dropdown Content</div></Dropdown>)
85+
86+
// Mock function to replace the actual document event listener
87+
const mockRemoveEventListener = vi.spyOn(document, 'removeEventListener')
88+
89+
// Unmount the component
90+
unmount()
91+
92+
// Check if the event listener was removed
93+
expect(mockRemoveEventListener).toHaveBeenCalledWith('click', expect.any(Function))
94+
expect(mockRemoveEventListener).toHaveBeenCalledWith('keydown', expect.any(Function))
95+
expect(mockRemoveEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function))
96+
})
97+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { ReactNode, useEffect, useRef, useState } from 'react'
2+
import { cn } from '../../lib/utils'
3+
import styles from './Dropdown.module.css'
4+
5+
interface DropdownProps {
6+
label?: string
7+
align?: 'left' | 'right'
8+
className?: string
9+
children: ReactNode
10+
}
11+
12+
/**
13+
* Dropdown menu component.
14+
*
15+
* @example
16+
* <Dropdown label='Menu'>
17+
* <button>Item 1</button>
18+
* <button>Item 2</button>
19+
* </Dropdown>
20+
*/
21+
export default function Dropdown({ label, align, className, children }: DropdownProps) {
22+
const [isOpen, setIsOpen] = useState(false)
23+
const dropdownRef = useRef<HTMLDivElement>(null)
24+
const menuRef = useRef<HTMLDivElement>(null)
25+
26+
function toggleDropdown() {
27+
setIsOpen(!isOpen)
28+
}
29+
30+
useEffect(() => {
31+
function handleClickInside(event: MouseEvent) {
32+
const target = event.target as Element
33+
if (menuRef.current && menuRef.current.contains(target) && target.tagName !== 'INPUT') {
34+
setIsOpen(false)
35+
}
36+
}
37+
function handleClickOutside(event: MouseEvent) {
38+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
39+
setIsOpen(false)
40+
}
41+
}
42+
function handleEscape(event: KeyboardEvent) {
43+
if (event.key === 'Escape') {
44+
setIsOpen(false)
45+
}
46+
}
47+
document.addEventListener('click', handleClickInside)
48+
document.addEventListener('keydown', handleEscape)
49+
document.addEventListener('mousedown', handleClickOutside)
50+
return () => {
51+
document.removeEventListener('click', handleClickInside)
52+
document.removeEventListener('keydown', handleEscape)
53+
document.removeEventListener('mousedown', handleClickOutside)
54+
}
55+
}, [])
56+
57+
return (
58+
<div
59+
className={cn(styles.dropdown, align === 'left' && styles.dropdownLeft, className, isOpen && styles.open)}
60+
ref={dropdownRef}>
61+
<button
62+
className={styles.dropdownButton}
63+
onClick={toggleDropdown}
64+
aria-haspopup='menu'
65+
aria-expanded={isOpen}>
66+
{label}
67+
</button>
68+
<div className={styles.dropdownContent} ref={menuRef} role='menu'>
69+
{children}
70+
</div>
71+
</div>
72+
)
73+
}

src/components/ErrorBar/ErrorBar.module.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
overflow: hidden;
77
transition: max-height 0.3s;
88
white-space: pre-wrap;
9-
font-family: monospace;
9+
* {
10+
font-family: monospace;
11+
}
1012

1113
&[data-visible="true"] {
1214
display: block;

src/components/Viewer/Viewer.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import TableView from '../ParquetView/ParquetView.js'
88
import TextView from '../TextView/TextView.js'
99

1010
interface ViewerProps {
11-
source: FileSource;
12-
setError: (error: Error | undefined) => void;
13-
setProgress: (progress: number | undefined) => void;
11+
source: FileSource
12+
setError: (error: Error | undefined) => void
13+
setProgress: (progress: number | undefined) => void
1414
}
1515

1616
/**

src/components/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Breadcrumb from './Breadcrumb/Breadcrumb.js'
22
import Cell from './Cell/Cell.js'
33
import ContentWrapper from './ContentWrapper/ContentWrapper.js'
4+
import Dropdown from './Dropdown/Dropdown.js'
45
import ErrorBar from './ErrorBar/ErrorBar.js'
56
import File from './File/File.js'
67
import Folder from './Folder/Folder.js'
@@ -14,4 +15,4 @@ import SlidePanel from './SlidePanel/SlidePanel.js'
1415
import Spinner from './Spinner/Spinner.js'
1516
import TextView from './TextView/TextView.js'
1617
import Viewer from './Viewer/Viewer.js'
17-
export { Breadcrumb, Cell, ContentWrapper, ErrorBar, File, Folder, ImageView, Layout, Markdown, MarkdownView, Page, ParquetView, SlidePanel, Spinner, TextView, Viewer }
18+
export { Breadcrumb, Cell, ContentWrapper, Dropdown, ErrorBar, File, Folder, ImageView, Layout, Markdown, MarkdownView, Page, ParquetView, SlidePanel, Spinner, TextView, Viewer }

src/lib/workers/parquetWorker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ self.onmessage = async ({ data }: { data: ClientMessage }) => {
4747
lastValue: undefined as unknown,
4848
lastRank: 0,
4949
}).ranks
50-
postColumnRanksMessage({ columnRanks: columnRanks, queryId })
50+
postColumnRanksMessage({ columnRanks, queryId })
5151
} catch (error) {
5252
postErrorMessage({ error: error as Error, queryId })
5353
}

0 commit comments

Comments
 (0)