Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Button v2 dropdown enhancement #6727

Merged
merged 2 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkg/webui/components/button-v2/button.styl
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

.button
reset-button()
position: relative
display: inline-flex
transition: background $ad.s
transition-timing-function: ease-out
Expand Down Expand Up @@ -87,3 +88,6 @@

.icon
margin-left: - $cs.xxs

.dropdown
top: 2.3rem
49 changes: 41 additions & 8 deletions pkg/webui/components/button-v2/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -32,25 +33,30 @@ 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
) : (
<>
{icon ? <Icon className={style.icon} icon={icon} /> : null}
{message ? <Message content={message} className={style.linkButtonMessage} /> : null}
{withDropdown ? <Icon icon="expand_more" /> : null}
{dropdownItems ? (
<>
<Icon icon={`${!expanded ? 'expand_more' : 'expand_less'}`} />
{expanded ? <Dropdown className={style.dropdown}>{dropdownItems}</Dropdown> : null}
</>
) : null}
</>
)

Expand All @@ -60,7 +66,7 @@ const buttonChildren = props => {
const Button = forwardRef((props, ref) => {
const {
autoFocus,
withDropdown,
dropdownItems,
name,
type,
value,
Expand All @@ -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()
Expand All @@ -96,7 +125,7 @@ const Button = forwardRef((props, ref) => {
<button
className={buttonClassNames}
onClick={handleClick}
children={buttonChildren(props)}
children={buttonChildren({ ...props, expanded })}
ref={ref}
{...htmlProps}
/>
Expand Down Expand Up @@ -137,6 +166,8 @@ const commonPropTypes = {
autoFocus: PropTypes.bool,
/** A message to be evaluated and passed to the <button /> element. */
title: PropTypes.message,
/** Dropdown items of the button. */
dropdownItems: PropTypes.node,
}

buttonChildren.propTypes = {
Expand All @@ -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,
}
Expand All @@ -153,6 +185,7 @@ buttonChildren.defaultProps = {
icon: undefined,
message: undefined,
children: null,
expanded: false,
}

Button.propTypes = {
Expand Down
79 changes: 52 additions & 27 deletions pkg/webui/components/button-v2/story.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,70 +12,95 @@
// 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 '.'

export default {
title: 'Button V2',
}

const dropdownItems = (
<React.Fragment>
<Dropdown.Item title="Profile Settings" icon="settings" path="/profile-settings" />
<Dropdown.Item title="Logout" icon="power_settings_new" path="/logout" />
</React.Fragment>
)

export const Primary = () => (
<div>
<div style={{ textAlign: 'center' }}>
<Button primary message="Primary" />
</div>
)

export const WithIcon = () => (
<div>
<div style={{ textAlign: 'center' }}>
<Button primary icon="favorite" message="With Icon" />
</div>
)

export const PrimayOnlyIcon = () => (
<div>
<div style={{ textAlign: 'center' }}>
<Button primary icon="favorite" />
</div>
)

export const PrimayDropdown = () => (
<div>
<Button primary icon="favorite" message="Dropdown" withDropdown />
</div>
)
export const PrimayDropdown = () => {
const ref = useRef()

export const PrimayOnlyIconDropdown = () => (
<div>
<Button primary icon="favorite" withDropdown />
</div>
)
return (
<div style={{ textAlign: 'center', height: '6rem' }}>
<Button primary icon="favorite" message="Dropdown" ref={ref} dropdownItems={dropdownItems} />
</div>
)
}

export const PrimayOnlyIconDropdown = () => {
const ref = useRef()

return (
<div style={{ textAlign: 'center', height: '6rem' }}>
<Button primary icon="favorite" dropdownItems={dropdownItems} ref={ref} />
</div>
)
}

export const Naked = () => (
<div>
<div style={{ textAlign: 'center' }}>
<Button naked message="Naked" />
</div>
)

export const NakedWithIcon = () => (
<div>
<div style={{ textAlign: 'center' }}>
<Button naked icon="favorite" message="Naked With Icon" />
</div>
)

export const NakedOnlyIcon = () => (
<div>
<div style={{ textAlign: 'center' }}>
<Button naked icon="favorite" />
</div>
)

export const nakedDropdown = () => (
<div>
<Button naked icon="favorite" message="Dropdown" withDropdown />
</div>
)
export const NakedDropdown = () => {
const ref = useRef()

export const NakedOnlyIconDropdown = () => (
<div>
<Button naked icon="favorite" withDropdown />
</div>
)
return (
<div style={{ textAlign: 'center', height: '6rem' }}>
<Button naked icon="favorite" message="Dropdown" dropdownItems={dropdownItems} ref={ref} />
</div>
)
}

export const NakedOnlyIconDropdown = () => {
const ref = useRef()

return (
<div style={{ textAlign: 'center', height: '6rem' }}>
<Button naked icon="favorite" dropdownItems={dropdownItems} ref={ref} />
</div>
)
}
93 changes: 93 additions & 0 deletions pkg/webui/components/dropdown-v2/dropdown.styl
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading