diff --git a/pkg/webui/components/button-v2/button.styl b/pkg/webui/components/button-v2/button.styl
index 91a0612d45..79a71358ed 100644
--- a/pkg/webui/components/button-v2/button.styl
+++ b/pkg/webui/components/button-v2/button.styl
@@ -14,6 +14,7 @@
.button
reset-button()
+ position: relative
display: inline-flex
transition: background $ad.s
transition-timing-function: ease-out
@@ -87,3 +88,6 @@
.icon
margin-left: - $cs.xxs
+
+.dropdown
+ top: 2.3rem
diff --git a/pkg/webui/components/button-v2/index.js b/pkg/webui/components/button-v2/index.js
index 3d7a7459af..19f195a9f1 100644
--- a/pkg/webui/components/button-v2/index.js
+++ b/pkg/webui/components/button-v2/index.js
@@ -12,11 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import React, { useCallback, forwardRef, useMemo } from 'react'
+import React, { useCallback, forwardRef, useMemo, useState } from 'react'
import classnames from 'classnames'
import { useIntl } from 'react-intl'
import Icon from '@ttn-lw/components/icon'
+import Dropdown from '@ttn-lw/components/dropdown-v2'
import Message from '@ttn-lw/lib/components/message'
@@ -32,17 +33,17 @@ const filterDataProps = props =>
return acc
}, {})
-const assembleClassnames = ({ message, primary, naked, icon, withDropdown, className }) =>
+const assembleClassnames = ({ message, primary, naked, icon, dropdownItems, className }) =>
classnames(style.button, className, {
[style.primary]: primary,
[style.naked]: naked,
[style.withIcon]: icon !== undefined && message,
[style.onlyIcon]: icon !== undefined && !message,
- [style.withDropdown]: withDropdown,
+ [style.withDropdown]: Boolean(dropdownItems),
})
const buttonChildren = props => {
- const { withDropdown, icon, message, children } = props
+ const { dropdownItems, icon, message, expanded, children } = props
const content = Boolean(children) ? (
children
@@ -50,7 +51,12 @@ const buttonChildren = props => {
<>
{icon ? : null}
{message ? : null}
- {withDropdown ? : null}
+ {dropdownItems ? (
+ <>
+
+ {expanded ? {dropdownItems} : null}
+ >
+ ) : null}
>
)
@@ -60,7 +66,7 @@ const buttonChildren = props => {
const Button = forwardRef((props, ref) => {
const {
autoFocus,
- withDropdown,
+ dropdownItems,
name,
type,
value,
@@ -70,17 +76,40 @@ const Button = forwardRef((props, ref) => {
form,
...rest
} = props
+ const [expanded, setExpanded] = useState(false)
const dataProps = useMemo(() => filterDataProps(rest), [rest])
+ const handleClickOutside = useCallback(
+ e => {
+ if (ref.current && !ref.current.contains(e.target)) {
+ setExpanded(false)
+ }
+ },
+ [ref],
+ )
+
+ const toggleDropdown = useCallback(() => {
+ setExpanded(oldExpanded => {
+ const newState = !oldExpanded
+ if (newState) document.addEventListener('mousedown', handleClickOutside)
+ else document.removeEventListener('mousedown', handleClickOutside)
+ return newState
+ })
+ }, [handleClickOutside])
+
const handleClick = useCallback(
evt => {
+ if (dropdownItems) {
+ toggleDropdown()
+ return
+ }
// Passing a value to the onClick handler is useful for components that
// are rendered multiple times, e.g. in a list. The value can be used to
// identify the component that was clicked.
onClick(evt, value)
},
- [onClick, value],
+ [dropdownItems, onClick, toggleDropdown, value],
)
const intl = useIntl()
@@ -96,7 +125,7 @@ const Button = forwardRef((props, ref) => {
@@ -137,6 +166,8 @@ const commonPropTypes = {
autoFocus: PropTypes.bool,
/** A message to be evaluated and passed to the element. */
title: PropTypes.message,
+ /** Dropdown items of the button. */
+ dropdownItems: PropTypes.node,
}
buttonChildren.propTypes = {
@@ -145,6 +176,7 @@ buttonChildren.propTypes = {
* Spinner, Icon, and/or Message.
*/
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]),
+ expanded: PropTypes.bool,
icon: commonPropTypes.icon,
message: commonPropTypes.message,
}
@@ -153,6 +185,7 @@ buttonChildren.defaultProps = {
icon: undefined,
message: undefined,
children: null,
+ expanded: false,
}
Button.propTypes = {
diff --git a/pkg/webui/components/button-v2/story.js b/pkg/webui/components/button-v2/story.js
index 441f9708fe..d9fd9313e4 100644
--- a/pkg/webui/components/button-v2/story.js
+++ b/pkg/webui/components/button-v2/story.js
@@ -12,7 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import React from 'react'
+import React, { useRef } from 'react'
+
+import Dropdown from '@ttn-lw/components/dropdown-v2'
import Button from '.'
@@ -20,62 +22,85 @@ export default {
title: 'Button V2',
}
+const dropdownItems = (
+
+
+
+
+)
+
export const Primary = () => (
-
+
)
export const WithIcon = () => (
-
+
)
export const PrimayOnlyIcon = () => (
-
+
)
-export const PrimayDropdown = () => (
-
-
-
-)
+export const PrimayDropdown = () => {
+ const ref = useRef()
-export const PrimayOnlyIconDropdown = () => (
-
-
-
-)
+ return (
+
+
+
+ )
+}
+
+export const PrimayOnlyIconDropdown = () => {
+ const ref = useRef()
+
+ return (
+
+
+
+ )
+}
export const Naked = () => (
-
+
)
export const NakedWithIcon = () => (
-
+
)
export const NakedOnlyIcon = () => (
-
+
)
-export const nakedDropdown = () => (
-
-
-
-)
+export const NakedDropdown = () => {
+ const ref = useRef()
-export const NakedOnlyIconDropdown = () => (
-
-
-
-)
+ return (
+
+
+
+ )
+}
+
+export const NakedOnlyIconDropdown = () => {
+ const ref = useRef()
+
+ return (
+
+
+
+ )
+}
diff --git a/pkg/webui/components/dropdown-v2/dropdown.styl b/pkg/webui/components/dropdown-v2/dropdown.styl
new file mode 100644
index 0000000000..d20d8aedd8
--- /dev/null
+++ b/pkg/webui/components/dropdown-v2/dropdown.styl
@@ -0,0 +1,93 @@
+// Copyright © 2023 The Things Network Foundation, The Things Industries B.V.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+ul.dropdown
+ transition: color $ad.s, background $ad.s
+ transition-timing-function: ease-out
+ border-radius: $br.l
+ border: 1px solid $c.grey-200
+ background: $c.white
+ box-shadow: 0px 3px 16px 0px rgba(0, 0, 0, 0.06)
+ z-index: $zi.nav
+ right: 0
+ position: absolute
+ padding: $cs.s
+ min-width: 14rem
+ z-index: $zi.dropdown
+ margin: 0
+
+ &.larger
+ li.dropdown-item
+ & > a.button, & > button.button
+ padding: $cs.l
+
+ hr
+ margin-top: 0
+ height: .1rem
+ background-color: $c-divider
+
+ li.dropdown-header-item
+ display: block
+ margin-bottom: 0
+ font-weight: $fw.bold
+
+ span
+ line-height: 1
+ display: block
+ padding: $cs.m
+
+ li.dropdown-item
+ display: block
+ margin-bottom: 0
+ text-align: left
+
+ button.button
+ reset-button()
+
+ & > a.button, & > button.button
+ box-sizing: border-box
+ line-height: 1
+ display: block
+ text-decoration: none
+ padding: 0.5rem
+ width: 100%
+ &:not(.active)
+ color: $c.grey-700
+ transition: color 0.2s ease
+
+ .icon
+ color: $c.grey-700
+ transition: color 0.2s ease
+
+ &.active
+ border-radius: $br.m
+ background: $c.tts-primary-150
+ transition: background 0.2s ease
+ color: $tc-deep-gray
+
+ .icon
+ color: $tc-deep-gray
+
+ &:hover
+ color: $tc-deep-gray
+
+ .icon
+ color: $tc-deep-gray
+
+ .icon
+ margin-right: $cs.xs
+
+ &:hover
+ color: $tc-deep-gray
+ text-decoration: none
diff --git a/pkg/webui/components/dropdown-v2/index.js b/pkg/webui/components/dropdown-v2/index.js
new file mode 100644
index 0000000000..1c25fd8462
--- /dev/null
+++ b/pkg/webui/components/dropdown-v2/index.js
@@ -0,0 +1,138 @@
+// Copyright © 2023 The Things Network Foundation, The Things Industries B.V.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { useCallback } from 'react'
+import classnames from 'classnames'
+import { NavLink } from 'react-router-dom'
+
+import Icon from '@ttn-lw/components/icon'
+import Link from '@ttn-lw/components/link'
+
+import Message from '@ttn-lw/lib/components/message'
+
+import PropTypes from '@ttn-lw/lib/prop-types'
+
+import style from './dropdown.styl'
+
+const Dropdown = React.forwardRef(({ className, children, larger, onItemsClick }, ref) => (
+
+))
+
+Dropdown.propTypes = {
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+ larger: PropTypes.bool,
+ onItemsClick: PropTypes.func,
+}
+
+Dropdown.defaultProps = {
+ className: undefined,
+ larger: false,
+ onItemsClick: () => null,
+}
+
+const DropdownItem = ({
+ active,
+ icon,
+ title,
+ path,
+ action,
+ exact,
+ showActive,
+ tabIndex,
+ external,
+ ...rest
+}) => {
+ const iconElement = icon &&
+ const activeClassName = classnames({
+ [style.active]: (!Boolean(action) && showActive) || active,
+ })
+ const cls = useCallback(
+ ({ isActive }) => classnames(style.button, { [activeClassName]: isActive }),
+ [activeClassName],
+ )
+ const ItemElement = action ? (
+
+ ) : external ? (
+
+ {Boolean(iconElement) ? iconElement : null}
+
+
+ ) : (
+
+ {iconElement}
+
+
+ )
+ return (
+
+ {ItemElement}
+
+ )
+}
+
+DropdownItem.propTypes = {
+ action: PropTypes.func,
+ active: PropTypes.bool,
+ exact: PropTypes.bool,
+ external: PropTypes.bool,
+ icon: PropTypes.string,
+ path: PropTypes.string,
+ showActive: PropTypes.bool,
+ tabIndex: PropTypes.string,
+ title: PropTypes.message.isRequired,
+}
+
+DropdownItem.defaultProps = {
+ active: false,
+ action: undefined,
+ exact: false,
+ external: false,
+ icon: undefined,
+ path: undefined,
+ showActive: true,
+ tabIndex: '0',
+}
+
+const DropdownHeaderItem = ({ title }) => (
+
+
+
+
+
+)
+
+DropdownHeaderItem.propTypes = {
+ title: PropTypes.message.isRequired,
+}
+
+Dropdown.Item = DropdownItem
+Dropdown.HeaderItem = DropdownHeaderItem
+
+export default Dropdown
diff --git a/pkg/webui/components/dropdown-v2/story.js b/pkg/webui/components/dropdown-v2/story.js
new file mode 100644
index 0000000000..96d1cb6e32
--- /dev/null
+++ b/pkg/webui/components/dropdown-v2/story.js
@@ -0,0 +1,32 @@
+// Copyright © 2023 The Things Network Foundation, The Things Industries B.V.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react'
+
+import Dropdown from '.'
+
+export default {
+ title: 'Dropdown V2',
+ component: Dropdown,
+}
+
+export const Default = () => (
+
+
+
+
+
+
+
+)