11import PropTypes from "prop-types" ;
2- import { Component } from "react" ;
2+ import { useEffect , useRef , useState } from "react" ;
33
4- export default class Dropdown extends Component {
5- static propTypes = {
6- className : PropTypes . string ,
7- items : PropTypes . array ,
8- } ;
9-
10- state = {
11- active : false ,
12- } ;
4+ export default function Dropdown ( { className = "" , items = [ ] } ) {
5+ const [ active , setActive ] = useState ( false ) ;
6+ const activeRef = useRef ( false ) ;
7+ const dropdownRef = useRef ( null ) ;
8+ const dropdownButtonRef = useRef ( null ) ;
9+ const linksRef = useRef ( [ ] ) ;
10+ const openedByClickRef = useRef ( false ) ;
1311
14- componentDidMount ( ) {
15- document . addEventListener ( "keyup" , this . _closeDropdownOnEsc , true ) ;
16- document . addEventListener ( "focus" , this . _closeDropdownIfFocusLost , true ) ;
17- document . addEventListener ( "click" , this . _closeDropdownIfFocusLost , true ) ;
18- }
12+ // Keep activeRef in sync for use inside stable event listeners
13+ useEffect ( ( ) => {
14+ activeRef . current = active ;
15+ } , [ active ] ) ;
1916
20- componentWillUnmount ( ) {
21- document . removeEventListener ( "keyup" , this . _closeDropdownOnEsc , true ) ;
22- document . removeEventListener ( "focus" , this . _closeDropdownIfFocusLost , true ) ;
23- document . removeEventListener ( "click" , this . _closeDropdownIfFocusLost , true ) ;
24- }
17+ useEffect ( ( ) => {
18+ const handleEsc = ( event ) => {
19+ if ( event . key === "Escape" && activeRef . current ) {
20+ setActive ( false ) ;
21+ dropdownButtonRef . current . focus ( ) ;
22+ }
23+ } ;
2524
26- _closeDropdownOnEsc = ( event ) => {
27- if ( event . key === "Escape" && this . state . active ) {
28- this . setState ( { active : false } , ( ) => {
29- this . dropdownButton . focus ( ) ;
30- } ) ;
31- }
32- } ;
25+ const handleFocusLost = ( event ) => {
26+ if ( activeRef . current && ! dropdownRef . current . contains ( event . target ) ) {
27+ setActive ( false ) ;
28+ }
29+ } ;
3330
34- _closeDropdownIfFocusLost = ( event ) => {
35- if ( this . state . active && ! this . dropdown . contains ( event . target ) ) {
36- this . setState ( { active : false } ) ;
37- }
38- } ;
31+ document . addEventListener ( "keyup" , handleEsc , true ) ;
32+ document . addEventListener ( "focus" , handleFocusLost , true ) ;
33+ document . addEventListener ( "click" , handleFocusLost , true ) ;
3934
40- render ( ) {
41- const { className = "" , items = [ ] } = this . props ;
35+ return ( ) => {
36+ document . removeEventListener ( "keyup" , handleEsc , true ) ;
37+ document . removeEventListener ( "focus" , handleFocusLost , true ) ;
38+ document . removeEventListener ( "click" , handleFocusLost , true ) ;
39+ } ;
40+ } , [ ] ) ;
4241
43- return (
44- < nav
45- className = { `relative ${ className } ` }
46- ref = { ( el ) => ( this . dropdown = el ) }
47- onMouseOver = { this . _handleMouseOver }
48- onMouseLeave = { this . _handleMouseLeave }
49- >
50- < button
51- ref = { ( el ) => ( this . dropdownButton = el ) }
52- aria-haspopup = "true"
53- aria-expanded = { String ( this . state . active ) }
54- aria-label = "Select language"
55- onClick = { this . _handleClick }
56- className = "cursor-pointer text-white border-none bg-transparent m-0 p-0 text-[length:inherit] flex items-center"
57- >
58- < svg
59- viewBox = "0 0 610 560"
60- xmlns = "http://www.w3.org/2000/svg"
61- className = "w-20 h-20 align-middle text-gray-100 hover:text-blue-200 transition-colors duration-200"
62- >
63- < path d = "m304.8 99.2-162.5-57.3v353.6l162.5-52.6z" />
64- < path
65- d = "m300.9 99 168.7-57.3v353.6l-168.7-52.5zm-200.7 358.4 200.7-66.9v-291.5l-200.7 66.9z"
66- fill = "currentColor"
67- />
68- < path d = "m392.4 461.8 28.4 46.8 15-43.5zm-223.9-262.3c-1.1-1 1.4 8.6 4.8 12 6.1 6.1 10.8 6.9 13.3 7 5.6.2 12.5-1.4 16.5-3.1s10.9-5.2 13.5-10.4c.6-1.1 2.1-3 1.1-7.5-.7-3.5-3-4.8-5.7-4.6s-11 2.4-15 3.6-12.2 3.7-15.8 4.5-11.5-.3-12.7-1.5zm101.2 114.8c-1.6-.6-34.3-14.1-38.9-16.4a368 368 0 0 0 -17.5-7.5c12.3-19 20.1-33.4 21.2-35.6 1.9-4 15-29.5 15.3-31.1s.7-7.5.4-8.9-5.1 1.3-11.6 3.5-18.9 10.2-23.7 11.2-20.1 6.8-28 9.4c-7.8 2.6-22.7 7.1-28.8 8.8-6.1 1.6-11.4 1.8-14.9 2.8 0 0 .5 4.8 1.4 6.2s4.1 5 7.9 5.9c3.8 1 10 .6 12.8-.1s7.7-3.1 8.4-4.1c.7-1.1-.3-4.3.8-5.3s16.1-4.5 21.7-6.2 27.2-9.2 30.1-8.8a916 916 0 0 1 -23.9 47.7 821 821 0 0 1 -45 63.3c-5.3 6-18 21.5-22.4 25 1.1.3 9-.4 10.4-1.3 8.9-5.5 23.8-24.1 28.6-29.7a489.1 489.1 0 0 0 36.7-49.4c1.9.8 17.6 13.6 21.7 16.4a293 293 0 0 0 23.7 13.3c3.5 1.5 16.9 7.7 17.5 5.6.7-1.8-2.3-14.1-3.9-14.7z" />
69- < path
70- clipRule = "evenodd"
71- d = "m194.1 484.7a204.9 204.9 0 0 0 30.7 14.5 233.6 233.6 0 0 0 46.4 12c.5 0 16 1.9 19.2 1.9h15.7c6.1-.5 11.8-.9 17.9-1.7 4.9-.7 10.3-1.6 15.5-2.8 3.8-.9 7.8-1.7 11.7-3 3.7-1 7.8-2.4 11.8-3.8l8.2-3.1c2.3-1 5.1-2.3 7.7-3.3a243 243 0 0 0 19.2-10c2.3-1.2 7.5-5.2 10.3-5.2 3.1 0 5.2 2.8 5.2 5.2 0 5.1-6.8 6.6-9.9 8.9-3.3 2.3-7.3 4-10.8 5.9-7 3.7-14.1 6.8-20.9 9.4a251 251 0 0 1 -27.3 8.5c-3.3.7-6.6 1.6-9.9 2.1-1.7.3-19.9 3.1-24.9 3.1h-23a293.9 293.9 0 0 1 -35.1-5.2 196 196 0 0 1 -33.1-10.3c-12-4.5-24.6-10.5-36.4-18.3-2.1-1.4-2.3-2.8-2.3-4.4a5 5 0 0 1 5.1-5.1c2.4.1 8 4.2 9 4.7zm101.4-98.1-189.9 63.2v-280.1l189.9-63.2zm10.6-288.3v292.7a6 6 0 0 1 -1.2 2.6c-.3.5-1 1.2-1.6 1.4a14621 14621 0 0 1 -203.1 67.6c-2.1 0-4-1.4-5.1-3.7 0-.2-.2-.3-.2-.7v-292.8c.3-.9.5-2.1 1.2-2.8 1.4-1.9 3.8-2.3 5.4-2.8 3-1 196.1-65.8 198.9-65.8 1.9 0 5.7 1.2 5.7 4.3z"
72- fillRule = "evenodd"
73- />
74- < path
75- clipRule = "evenodd"
76- d = "m464.3 388-158-49.1v-236l158-53.7zm10.6-345.8v352.4c-.2 4-3 5.7-5.6 5.7-2.3 0-18.6-5.6-21.4-6.4l-65.8-20.4-14.6-4.7-12.9-4c-18.6-5.7-37.6-11.5-56.3-17.8-.7-.2-2.4-2.6-2.4-3.1v-246.1c.3-.9.7-1.9 1.6-2.6 1.4-1.6 61.1-21.4 84.7-29.3 6.3-2.3 84.8-29.3 87.3-29.3 3 .1 5.4 2.3 5.4 5.6z"
77- fillRule = "evenodd"
78- />
79- < path d = "m515 461.8-211.1-67.3.9-292.8 210.2 66.9z" />
80- < path
81- clipRule = "evenodd"
82- d = "m408.6 232.5-20.7 50.1 38.1 11.5zm-12.4-47.2 27.2 8.2 49.5 178.6-27.9-8.5-10-36.7-57.7-17.5-12.4 29.9-27.9-8.5z"
83- fill = "currentColor"
84- fillRule = "evenodd"
85- />
86- </ svg >
87- { /* Commented out until media breakpoints are in place
88- <span>{ items[0].title }</span> */ }
89- < i aria-hidden = "true" className = "leading-none before:content-['▾']" />
90- </ button >
91- < div
92- className = { `${
93- this . state . active ? "block" : "hidden"
94- } absolute top-full right-0 text-[13px] bg-[#526b78] rounded-md shadow-[0_4px_12px_rgba(0,0,0,0.4)] z-[9999]`}
95- >
96- < ul className = "pt-1" >
97- { items . map ( ( item , i ) => (
98- < li
99- key = { item . title }
100- className = "py-1 px-2 list-none text-white transition-all duration-[250ms] hover:bg-[#175d96]"
101- >
102- < a
103- onKeyDown = { ( event ) =>
104- this . _handleArrowKeys ( i , items . length - 1 , event )
105- }
106- ref = { ( node ) =>
107- this . links ? this . links . push ( node ) : ( this . links = [ node ] )
108- }
109- href = { item . url }
110- className = "px-5 block text-white visited:text-white hover:text-white"
111- >
112- < span lang = { item . lang } className = "align-top text-left" >
113- { item . title }
114- </ span >
115- </ a >
116- </ li >
117- ) ) }
118- </ ul >
119- </ div >
120- </ nav >
121- ) ;
122- }
42+ // Focus first link when opened via click
43+ useEffect ( ( ) => {
44+ if ( active && openedByClickRef . current && linksRef . current . length > 0 ) {
45+ linksRef . current [ 0 ] . focus ( ) ;
46+ openedByClickRef . current = false ;
47+ }
48+ } , [ active ] ) ;
12349
124- _handleArrowKeys = ( currentIndex , lastIndex , event ) => {
50+ const handleArrowKeys = ( currentIndex , lastIndex , event ) => {
12551 if ( [ "ArrowDown" , "ArrowUp" ] . includes ( event . key ) ) {
12652 event . preventDefault ( ) ;
12753 }
@@ -141,22 +67,96 @@ export default class Dropdown extends Component {
14167 }
14268 }
14369
144- this . links [ newIndex ] . focus ( ) ;
145- } ;
146-
147- _handleClick = ( ) => {
148- this . setState ( { active : ! this . state . active } , ( ) => {
149- if ( this . state . active ) {
150- this . links [ 0 ] . focus ( ) ;
151- }
152- } ) ;
70+ linksRef . current [ newIndex ] . focus ( ) ;
15371 } ;
15472
155- _handleMouseOver = ( ) => {
156- this . setState ( { active : true } ) ;
73+ const handleClick = ( ) => {
74+ openedByClickRef . current = true ;
75+ setActive ( ( prev ) => ! prev ) ;
15776 } ;
15877
159- _handleMouseLeave = ( ) => {
160- this . setState ( { active : false } ) ;
161- } ;
78+ return (
79+ < nav
80+ className = { `relative ${ className } ` }
81+ ref = { dropdownRef }
82+ onMouseOver = { ( ) => setActive ( true ) }
83+ onMouseLeave = { ( ) => setActive ( false ) }
84+ >
85+ < button
86+ ref = { dropdownButtonRef }
87+ aria-haspopup = "true"
88+ aria-expanded = { String ( active ) }
89+ aria-label = "Select language"
90+ onClick = { handleClick }
91+ className = "cursor-pointer text-white border-none bg-transparent m-0 p-0 text-[length:inherit] flex items-center"
92+ >
93+ < svg
94+ viewBox = "0 0 610 560"
95+ xmlns = "http://www.w3.org/2000/svg"
96+ className = "w-20 h-20 align-middle text-gray-100 hover:text-blue-200 transition-colors duration-200"
97+ >
98+ < path d = "m304.8 99.2-162.5-57.3v353.6l162.5-52.6z" />
99+ < path
100+ d = "m300.9 99 168.7-57.3v353.6l-168.7-52.5zm-200.7 358.4 200.7-66.9v-291.5l-200.7 66.9z"
101+ fill = "currentColor"
102+ />
103+ < path d = "m392.4 461.8 28.4 46.8 15-43.5zm-223.9-262.3c-1.1-1 1.4 8.6 4.8 12 6.1 6.1 10.8 6.9 13.3 7 5.6.2 12.5-1.4 16.5-3.1s10.9-5.2 13.5-10.4c.6-1.1 2.1-3 1.1-7.5-.7-3.5-3-4.8-5.7-4.6s-11 2.4-15 3.6-12.2 3.7-15.8 4.5-11.5-.3-12.7-1.5zm101.2 114.8c-1.6-.6-34.3-14.1-38.9-16.4a368 368 0 0 0 -17.5-7.5c12.3-19 20.1-33.4 21.2-35.6 1.9-4 15-29.5 15.3-31.1s.7-7.5.4-8.9-5.1 1.3-11.6 3.5-18.9 10.2-23.7 11.2-20.1 6.8-28 9.4c-7.8 2.6-22.7 7.1-28.8 8.8-6.1 1.6-11.4 1.8-14.9 2.8 0 0 .5 4.8 1.4 6.2s4.1 5 7.9 5.9c3.8 1 10 .6 12.8-.1s7.7-3.1 8.4-4.1c.7-1.1-.3-4.3.8-5.3s16.1-4.5 21.7-6.2 27.2-9.2 30.1-8.8a916 916 0 0 1 -23.9 47.7 821 821 0 0 1 -45 63.3c-5.3 6-18 21.5-22.4 25 1.1.3 9-.4 10.4-1.3 8.9-5.5 23.8-24.1 28.6-29.7a489.1 489.1 0 0 0 36.7-49.4c1.9.8 17.6 13.6 21.7 16.4a293 293 0 0 0 23.7 13.3c3.5 1.5 16.9 7.7 17.5 5.6.7-1.8-2.3-14.1-3.9-14.7z" />
104+ < path
105+ clipRule = "evenodd"
106+ d = "m194.1 484.7a204.9 204.9 0 0 0 30.7 14.5 233.6 233.6 0 0 0 46.4 12c.5 0 16 1.9 19.2 1.9h15.7c6.1-.5 11.8-.9 17.9-1.7 4.9-.7 10.3-1.6 15.5-2.8 3.8-.9 7.8-1.7 11.7-3 3.7-1 7.8-2.4 11.8-3.8l8.2-3.1c2.3-1 5.1-2.3 7.7-3.3a243 243 0 0 0 19.2-10c2.3-1.2 7.5-5.2 10.3-5.2 3.1 0 5.2 2.8 5.2 5.2 0 5.1-6.8 6.6-9.9 8.9-3.3 2.3-7.3 4-10.8 5.9-7 3.7-14.1 6.8-20.9 9.4a251 251 0 0 1 -27.3 8.5c-3.3.7-6.6 1.6-9.9 2.1-1.7.3-19.9 3.1-24.9 3.1h-23a293.9 293.9 0 0 1 -35.1-5.2 196 196 0 0 1 -33.1-10.3c-12-4.5-24.6-10.5-36.4-18.3-2.1-1.4-2.3-2.8-2.3-4.4a5 5 0 0 1 5.1-5.1c2.4.1 8 4.2 9 4.7zm101.4-98.1-189.9 63.2v-280.1l189.9-63.2zm10.6-288.3v292.7a6 6 0 0 1 -1.2 2.6c-.3.5-1 1.2-1.6 1.4a14621 14621 0 0 1 -203.1 67.6c-2.1 0-4-1.4-5.1-3.7 0-.2-.2-.3-.2-.7v-292.8c.3-.9.5-2.1 1.2-2.8 1.4-1.9 3.8-2.3 5.4-2.8 3-1 196.1-65.8 198.9-65.8 1.9 0 5.7 1.2 5.7 4.3z"
107+ fillRule = "evenodd"
108+ />
109+ < path
110+ clipRule = "evenodd"
111+ d = "m464.3 388-158-49.1v-236l158-53.7zm10.6-345.8v352.4c-.2 4-3 5.7-5.6 5.7-2.3 0-18.6-5.6-21.4-6.4l-65.8-20.4-14.6-4.7-12.9-4c-18.6-5.7-37.6-11.5-56.3-17.8-.7-.2-2.4-2.6-2.4-3.1v-246.1c.3-.9.7-1.9 1.6-2.6 1.4-1.6 61.1-21.4 84.7-29.3 6.3-2.3 84.8-29.3 87.3-29.3 3 .1 5.4 2.3 5.4 5.6z"
112+ fillRule = "evenodd"
113+ />
114+ < path d = "m515 461.8-211.1-67.3.9-292.8 210.2 66.9z" />
115+ < path
116+ clipRule = "evenodd"
117+ d = "m408.6 232.5-20.7 50.1 38.1 11.5zm-12.4-47.2 27.2 8.2 49.5 178.6-27.9-8.5-10-36.7-57.7-17.5-12.4 29.9-27.9-8.5z"
118+ fill = "currentColor"
119+ fillRule = "evenodd"
120+ />
121+ </ svg >
122+ { /* Commented out until media breakpoints are in place
123+ <span>{ items[0].title }</span> */ }
124+ < i aria-hidden = "true" className = "leading-none before:content-['▾']" />
125+ </ button >
126+ < div
127+ className = { `${
128+ active ? "block" : "hidden"
129+ } absolute top-full right-0 text-[13px] bg-[#526b78] rounded-md shadow-[0_4px_12px_rgba(0,0,0,0.4)] z-[9999]`}
130+ >
131+ < ul className = "pt-1" >
132+ { items . map ( ( item , i ) => (
133+ < li
134+ key = { item . title }
135+ className = "py-1 px-2 list-none text-white transition-all duration-[250ms] hover:bg-[#175d96]"
136+ >
137+ < a
138+ onKeyDown = { ( event ) =>
139+ handleArrowKeys ( i , items . length - 1 , event )
140+ }
141+ ref = { ( node ) => {
142+ linksRef . current [ i ] = node ;
143+ } }
144+ href = { item . url }
145+ className = "px-5 block text-white visited:text-white hover:text-white"
146+ >
147+ < span lang = { item . lang } className = "align-top text-left" >
148+ { item . title }
149+ </ span >
150+ </ a >
151+ </ li >
152+ ) ) }
153+ </ ul >
154+ </ div >
155+ </ nav >
156+ ) ;
162157}
158+
159+ Dropdown . propTypes = {
160+ className : PropTypes . string ,
161+ items : PropTypes . array ,
162+ } ;
0 commit comments