diff --git a/config/eslintrc.yaml b/config/eslintrc.yaml index 1c338b90b7..c10c940903 100644 --- a/config/eslintrc.yaml +++ b/config/eslintrc.yaml @@ -141,6 +141,8 @@ rules: react/forbid-foreign-prop-types: off # Prevent undefined components. react/jsx-no-undef: warn + # Allow arrow functions as props + react/jsx-no-bind: off # Prevent vague prop types. react/forbid-prop-types: - warn diff --git a/pkg/webui/account/containers/clients-table/index.js b/pkg/webui/account/containers/clients-table/index.js index 36d2c5f33c..d09437f5b8 100644 --- a/pkg/webui/account/containers/clients-table/index.js +++ b/pkg/webui/account/containers/clients-table/index.js @@ -151,7 +151,7 @@ const ClientsTable = () => { }), render: details => ( - - ) : external ? ( - + ) : ( + {Boolean(iconElement) ? iconElement : null} - ) : ( - - {iconElement} - - ) const handleMouseEnter = useCallback(() => { + if (leaveDelayTimer) { + clearTimeout(leaveDelayTimer) + setLeaveDelayTimer(null) + } setExpandedSubmenu(true) - }, [setExpandedSubmenu]) + }, [leaveDelayTimer]) const handleMouseLeave = useCallback(() => { - setExpandedSubmenu(false) - }, [setExpandedSubmenu]) + // Set a timer when mouse leaves, to only close after a delay. + // This prevents the menu from closing when the mouse moves over the submenu + // and also makes the UI more forgiving when the mouse accidentally leaves the menu. + setLeaveDelayTimer( + setTimeout(() => { + setExpandedSubmenu(false) + }, 250), + ) + }, []) + + useEffect( + () => () => { + if (leaveDelayTimer) { + clearTimeout(leaveDelayTimer) + } + }, + [leaveDelayTimer], + ) const withSubmenu = ( ) diff --git a/pkg/webui/components/header/index.js b/pkg/webui/components/header/index.js index fbe07991a8..4e9bd79180 100644 --- a/pkg/webui/components/header/index.js +++ b/pkg/webui/components/header/index.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useRef } from 'react' +import React from 'react' import classnames from 'classnames' import Button from '@ttn-lw/components/button' @@ -25,8 +25,7 @@ import style from './header.styl' const Header = ({ brandLogo, - logo, - breadcrumbs, + Logo, className, addDropdownItems, starDropdownItems, @@ -34,43 +33,30 @@ const Header = ({ user, onMenuClick, ...rest -}) => { - const addRef = useRef(null) - const starRef = useRef(null) - +}) => ( // Const isGuest = !Boolean(user) - return ( -
-
-
-
+
+
+
+
-
-
-
- ) -} +
+
+
+) const imgPropType = PropTypes.shape({ src: PropTypes.string.isRequired, @@ -78,15 +64,12 @@ const imgPropType = PropTypes.shape({ }) Header.propTypes = { + Logo: PropTypes.elementType.isRequired, /** The dropdown items when the add button is clicked. */ addDropdownItems: PropTypes.node.isRequired, brandLogo: imgPropType, - /** A list of breadcrumb elements. */ - breadcrumbs: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.element])) - .isRequired, /** The classname applied to the component. */ className: PropTypes.string, - logo: imgPropType.isRequired, /** A handler for when the menu button is clicked. */ onMenuClick: PropTypes.func.isRequired, /** The dropdown items when the profile button is clicked. */ diff --git a/pkg/webui/components/input/input.styl b/pkg/webui/components/input/input.styl index dc1da341cb..7dd975485e 100644 --- a/pkg/webui/components/input/input.styl +++ b/pkg/webui/components/input/input.styl @@ -20,7 +20,7 @@ input-width-classes() box-sizing: border-box background: white - border-radius: $br.xs + border-radius: $br.m padding: 0 position: relative transition: border-color $ad.s diff --git a/pkg/webui/components/key-value-map/entry.js b/pkg/webui/components/key-value-map/entry.js index 91c1a778de..d7d1552ffe 100644 --- a/pkg/webui/components/key-value-map/entry.js +++ b/pkg/webui/components/key-value-map/entry.js @@ -143,7 +143,8 @@ const Entry = ({ title={m.deleteEntry} message={removeMessage} disabled={readOnly} - danger={!Boolean(removeMessage)} + danger + naked /> )} diff --git a/pkg/webui/components/key-value-map/index.js b/pkg/webui/components/key-value-map/index.js index 9ae7679f93..9de98697bb 100644 --- a/pkg/webui/components/key-value-map/index.js +++ b/pkg/webui/components/key-value-map/index.js @@ -107,6 +107,7 @@ const KeyValueMap = ({ onClick={addEmptyEntry} disabled={disabled} icon="add" + secondary /> diff --git a/pkg/webui/components/logo/index.js b/pkg/webui/components/logo/index.js index 570da164e3..ca93c8369c 100644 --- a/pkg/webui/components/logo/index.js +++ b/pkg/webui/components/logo/index.js @@ -15,30 +15,11 @@ import React from 'react' import classnames from 'classnames' -import Link from '@ttn-lw/components/link' - import PropTypes from '@ttn-lw/lib/prop-types' import style from './logo.styl' -const LogoLink = ({ safe, to, ...rest }) => { - if (safe) { - return - } - - return -} - -LogoLink.propTypes = { - safe: PropTypes.bool, - to: PropTypes.string.isRequired, -} - -LogoLink.defaultProps = { - safe: false, -} - -const Logo = ({ className, logo, brandLogo, vertical, safe }) => { +const Logo = ({ className, logo, brandLogo, vertical }) => { const classname = classnames(style.container, className, { [style.vertical]: vertical, [style.customBranding]: Boolean(brandLogo), @@ -46,17 +27,8 @@ const Logo = ({ className, logo, brandLogo, vertical, safe }) => { return (
- {Boolean(brandLogo) && ( -
- -
- )}
- - - +
) diff --git a/pkg/webui/components/navigation/side/item/item.styl b/pkg/webui/components/navigation/side/item/item.styl deleted file mode 100644 index ae017616e8..0000000000 --- a/pkg/webui/components/navigation/side/item/item.styl +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright © 2019 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. - -.item - &:hover - .fly-out-list - display: block - -.button - reset-button() - width: 100% - transition: 0.2s background-color ease-in, 0.2s color ease-in - text-decoration: none - display: flex - align-items: center - position: relative - gap: $cs.xs !important - - span - &:not(:first-child) - color: $c.grey-700 !important - - &:hover&:not(&-active) - background: none !important - color: $tc-deep-gray !important - text-decoration: none - - span - &:not(:first-child) - color: $c.grey-900 !important - - .icon - color: inherit - - &-active - background: $c.tts-primary-150 - color: $c.grey-900 !important - - &:hover - background: $c.tts-primary-150 !important - color: $c.grey-900 !important - - span - &:not(:first-child) - color: $c.grey-900 !important - - .icon - color: inherit - - .icon - color: inherit - -.message - vertical-align: middle - -.expand-icon - position: absolute - right: $cs.xs - top: 50% - transform: translateY(-50%) - transition: transform $ad.m ease-in - - &-open - transform: translateY(-50%) rotate(180deg) - -.fly-out-list-container - top: 0 - left: 0 - position: absolute - width: 180% - height: 13rem - -.fly-out-list - display: none - left: 5rem !important - top: 0 !important diff --git a/pkg/webui/components/profile-dropdown/index.js b/pkg/webui/components/profile-dropdown/index.js index 6b9046b837..b9a4d7bc59 100644 --- a/pkg/webui/components/profile-dropdown/index.js +++ b/pkg/webui/components/profile-dropdown/index.js @@ -12,11 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useCallback, useRef, useState } from 'react' +import React from 'react' import classnames from 'classnames' import Icon from '@ttn-lw/components/icon' -import Dropdown from '@ttn-lw/components/dropdown' import ProfilePicture from '@ttn-lw/components/profile-picture' import Button from '@ttn-lw/components/button' import style from '@ttn-lw/components/button/button.styl' @@ -26,45 +25,22 @@ import PropTypes from '@ttn-lw/lib/prop-types' import styles from './profile-dropdown.styl' const ProfileDropdown = props => { - const [expanded, setExpanded] = useState(false) - const node = useRef(null) const { brandLogo, className, children, profilePicture, ...rest } = props - const handleClickOutside = useCallback(e => { - if (node.current && !node.current.contains(e.target)) { - setExpanded(false) - } - }, []) - - const toggleDropdown = useCallback(() => { - setExpanded(oldExpanded => { - const newState = !oldExpanded - if (newState) document.addEventListener('mousedown', handleClickOutside) - else document.removeEventListener('mousedown', handleClickOutside) - return newState - }) - }, [handleClickOutside]) - return ( - +
+ +
) } diff --git a/pkg/webui/components/profile-dropdown/profile-dropdown.styl b/pkg/webui/components/profile-dropdown/profile-dropdown.styl index e22504b7f5..0d9c05d1d1 100644 --- a/pkg/webui/components/profile-dropdown/profile-dropdown.styl +++ b/pkg/webui/components/profile-dropdown/profile-dropdown.styl @@ -13,7 +13,7 @@ // limitations under the License. .container - padding: 0 0.25rem 0 0.5rem + padding: 0 0.25rem 0 0 position: relative gap: 0 diff --git a/pkg/webui/components/sidebar/dedicated-entity/dedicated-entity.styl b/pkg/webui/components/sidebar/dedicated-entity/dedicated-entity.styl index 77937e4698..00c448ea7e 100644 --- a/pkg/webui/components/sidebar/dedicated-entity/dedicated-entity.styl +++ b/pkg/webui/components/sidebar/dedicated-entity/dedicated-entity.styl @@ -16,9 +16,35 @@ text-decoration: none line-height: 1.6rem color: $c.grey-900 + position: relative + height: 2.5rem + + &-curtain + position: absolute + overflow: hidden + border-radius: $br.l + width: 2.5rem + transition: 0.3s 0s all cubic-bezier(0.770, 0.000, 0.175, 1.000) + top: 0 + left: 0 + z-index: $zi.slight + + &:hover + transition: 0.3s 0.2s all cubic-bezier(0.770, 0.000, 0.175, 1.000) + width: 100% + + // Reveal the button label. + button span:last-child + opacity: 1 &-item display: flex + color: $c.grey-900 + opacity: 1 + position: absolute + left: 2.5rem + height: 100% + text-decoration: none &-label font-size: $fs.m @@ -27,30 +53,35 @@ &-divider display: list-item - height: 1.8rem + height: 100% margin: 0 $cs.xs 0 $cs.s border-left: 1px solid $c.grey-200 background-color: $c.grey-200 &-button border-radius: $br.l - max-width: 2.5rem padding: 0 $cs.xs 0 0.73rem + background-color: $c.grey-900 + position: relative + display: inline-flex + border-radius: $br.l + outline: 0 + cursor: pointer + justify-content: center + align-items: center + gap: $cs.xxs + height: 2.5rem + text-decoration: none + padding: 0 .75rem + white-space: nowrap + color: white + width: 100% + justify-content: flex-start - span - &:not(:first-child) - display: none - - &:hover - .dedicated-entity-button - justify-content: start - max-width: 100% - width: 100% - padding: 0 $cs.m 0 0.73rem - - span - &:not(:first-child) - display: flex + span:last-child + transition: 0.2s 0.2s all ease-in-out + opacity: 0 - .dedicated-entity-item - display: none + &:not(:disabled) + +focus-visible() + background-color: hoverize($c.grey-500) diff --git a/pkg/webui/components/sidebar/dedicated-entity/index.js b/pkg/webui/components/sidebar/dedicated-entity/index.js index 9255137997..ba13c22dc7 100644 --- a/pkg/webui/components/sidebar/dedicated-entity/index.js +++ b/pkg/webui/components/sidebar/dedicated-entity/index.js @@ -13,7 +13,7 @@ // limitations under the License. import React from 'react' -import { NavLink } from 'react-router-dom' +import { Link } from 'react-router-dom' import classnames from 'classnames' import Button from '@ttn-lw/components/button' @@ -24,40 +24,37 @@ import PropTypes from '@ttn-lw/lib/prop-types' import style from './dedicated-entity.styl' -const DedicatedEntity = ({ label, icon, className, buttonMessage, path, exact, handleClick }) => ( - - + {!isMinimized && ( + + {children} + + )} + + ) +} + +CollapsibleItem.propTypes = { + children: PropTypes.node, + currentPathName: PropTypes.string.isRequired, + depth: PropTypes.number.isRequired, + icon: PropTypes.string, + isExpanded: PropTypes.bool.isRequired, + isMinimized: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, + onDropdownItemsClick: PropTypes.func, + title: PropTypes.message.isRequired, +} + +CollapsibleItem.defaultProps = { + children: undefined, + icon: undefined, + onDropdownItemsClick: () => null, +} + +export default CollapsibleItem diff --git a/pkg/webui/components/navigation/side/item/index.js b/pkg/webui/components/sidebar/side-menu/item/index.js similarity index 57% rename from pkg/webui/components/navigation/side/item/index.js rename to pkg/webui/components/sidebar/side-menu/item/index.js index b5ad39610a..b9b7768230 100644 --- a/pkg/webui/components/navigation/side/item/index.js +++ b/pkg/webui/components/sidebar/side-menu/item/index.js @@ -15,16 +15,10 @@ import React, { useCallback, useEffect, useState } from 'react' import classnames from 'classnames' -import Dropdown from '@ttn-lw/components/dropdown' -import MenuLink from '@ttn-lw/components/sidebar/side-menu-link' -import Button from '@ttn-lw/components/button' -import Icon from '@ttn-lw/components/icon' - -import Message from '@ttn-lw/lib/components/message' - import PropTypes from '@ttn-lw/lib/prop-types' -import SideNavigationList from '../list' +import MenuLink from './link' +import CollapsibleItem from './collapsible' import style from './item.styl' @@ -63,7 +57,7 @@ const SideNavigationItem = props => { return (
  • {Boolean(children) ? ( - { - const subItems = children - .filter(item => Boolean(item) && 'props' in item) - .map(item => ({ - title: item.props.title, - path: item.props.path, - icon: item.props.icon, - })) - - const subItemActive = subItems.some(item => currentPathName.includes(item.path)) - - return ( - <> - - {!isMinimized && ( - - {children} - - )} - - ) -} - -CollapsableItem.propTypes = { - children: PropTypes.node, - currentPathName: PropTypes.string.isRequired, - depth: PropTypes.number.isRequired, - icon: PropTypes.string, - isExpanded: PropTypes.bool.isRequired, - isMinimized: PropTypes.bool.isRequired, - onClick: PropTypes.func.isRequired, - onDropdownItemsClick: PropTypes.func, - title: PropTypes.message.isRequired, -} - -CollapsableItem.defaultProps = { - children: undefined, - icon: undefined, - onDropdownItemsClick: () => null, -} - const LinkItem = ({ onClick, title, icon, exact, path }) => { const handleLinkItemClick = useCallback( event => { @@ -216,9 +121,7 @@ const LinkItem = ({ onClick, title, icon, exact, path }) => { ) return ( - <> - - + ) } diff --git a/pkg/webui/components/sidebar/side-menu-link/side-menu-link.styl b/pkg/webui/components/sidebar/side-menu/item/item.styl similarity index 61% rename from pkg/webui/components/sidebar/side-menu-link/side-menu-link.styl rename to pkg/webui/components/sidebar/side-menu/item/item.styl index fdab8ef356..3dc423ae50 100644 --- a/pkg/webui/components/sidebar/side-menu-link/side-menu-link.styl +++ b/pkg/webui/components/sidebar/side-menu/item/item.styl @@ -1,4 +1,4 @@ -// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// Copyright © 2024 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. @@ -12,8 +12,37 @@ // See the License for the specific language governing permissions and // limitations under the License. +.item + &:hover + .fly-out-list-container + display: block + +.message + vertical-align: middle + +.expand-icon + position: absolute + right: $cs.xs + top: 50% + transform: translateY(-50%) + transition: transform $ad.m ease-in-out + + &-open + transform: translateY(-50%) rotate(180deg) + +.fly-out-list-container + display: none + top: - $cs.m + left: calc(3rem - 2px) + position: absolute + padding-left: 1rem + z-index: $zi.dropdown + +.fly-out-list + position: static !important + .link - transition: 0.2s all ease-in-out + transition: color 50ms linear border-radius: $br.l text-decoration: none color: $c.grey-700 @@ -22,8 +51,11 @@ gap: $cs.xs padding: $cs.xs position: relative + box-sizing: border-box + width: 100% .icon + transition: color 50ms linear color: $c.grey-500 &.active @@ -45,3 +77,4 @@ .icon color: inherit + diff --git a/pkg/webui/components/sidebar/side-menu-link/index.js b/pkg/webui/components/sidebar/side-menu/item/link.js similarity index 84% rename from pkg/webui/components/sidebar/side-menu-link/index.js rename to pkg/webui/components/sidebar/side-menu/item/link.js index 9171362354..b81b6256c5 100644 --- a/pkg/webui/components/sidebar/side-menu-link/index.js +++ b/pkg/webui/components/sidebar/side-menu/item/link.js @@ -17,6 +17,7 @@ import { NavLink } from 'react-router-dom' import classnames from 'classnames' import Icon from '@ttn-lw/components/icon' +import Dropdown from '@ttn-lw/components/dropdown' import Message from '@ttn-lw/lib/components/message' @@ -24,7 +25,7 @@ import SidebarContext from '@console/containers/side-bar/context' import PropTypes from '@ttn-lw/lib/prop-types' -import style from './side-menu-link.styl' +import style from './item.styl' const MenuLink = ({ icon, title, path, onClick, exact, disabled }) => { const { isMinimized } = useContext(SidebarContext) @@ -43,6 +44,13 @@ const MenuLink = ({ icon, title, path, onClick, exact, disabled }) => { {icon && }{' '} {!isMinimized && } + {isMinimized && ( +
    + + + +
    + )}
    ) } diff --git a/pkg/webui/components/sidebar/side-menu-link/story.js b/pkg/webui/components/sidebar/side-menu/item/story.js similarity index 98% rename from pkg/webui/components/sidebar/side-menu-link/story.js rename to pkg/webui/components/sidebar/side-menu/item/story.js index ddc8c6077e..32c0b185b3 100644 --- a/pkg/webui/components/sidebar/side-menu-link/story.js +++ b/pkg/webui/components/sidebar/side-menu/item/story.js @@ -16,7 +16,7 @@ import React from 'react' import SidebarContext from '@console/containers/side-bar/context' -import Link from '.' +import Link from './link' export default { title: 'Side Menu Link', diff --git a/pkg/webui/components/navigation/side/list/index.js b/pkg/webui/components/sidebar/side-menu/list/index.js similarity index 100% rename from pkg/webui/components/navigation/side/list/index.js rename to pkg/webui/components/sidebar/side-menu/list/index.js diff --git a/pkg/webui/components/navigation/side/list/list.styl b/pkg/webui/components/sidebar/side-menu/list/list.styl similarity index 78% rename from pkg/webui/components/navigation/side/list/list.styl rename to pkg/webui/components/sidebar/side-menu/list/list.styl index baddbf7c23..c4af86598d 100644 --- a/pkg/webui/components/navigation/side/list/list.styl +++ b/pkg/webui/components/sidebar/side-menu/list/list.styl @@ -22,23 +22,21 @@ & > li:not(:last-child) margin-bottom: 0 - // We need to rely on the child list items for the reveal animation, the - // height of the list is variable, making proper slide up transitions - // impossible. - & > li - transition: height $ad.m ease-in-out - &-nested > li height: 0px transition: height $ad.m ease-in-out - &-nested:not(.list-expanded) > li - visibility: hidden - &-expanded > li height: 2.4rem + &-nested + transition: opacity $ad.m ease-in-out + + &:not(.list-expanded) + opacity: 0 + &-expanded, &-nested + opacity: 1 & li a padding-left: $cs.l + $cs.xs diff --git a/pkg/webui/components/navigation/side/side.styl b/pkg/webui/components/sidebar/side-menu/side.styl similarity index 100% rename from pkg/webui/components/navigation/side/side.styl rename to pkg/webui/components/sidebar/side-menu/side.styl diff --git a/pkg/webui/components/navigation/side/story.js b/pkg/webui/components/sidebar/side-menu/story.js similarity index 100% rename from pkg/webui/components/navigation/side/story.js rename to pkg/webui/components/sidebar/side-menu/story.js diff --git a/pkg/webui/components/sidebar/switcher/index.js b/pkg/webui/components/sidebar/switcher/index.js index c77d8bc723..7d484b86b3 100644 --- a/pkg/webui/components/sidebar/switcher/index.js +++ b/pkg/webui/components/sidebar/switcher/index.js @@ -13,7 +13,7 @@ // limitations under the License. import React, { useCallback } from 'react' -import { NavLink, useLocation } from 'react-router-dom' +import { NavLink } from 'react-router-dom' import classnames from 'classnames' import Icon from '@ttn-lw/components/icon' @@ -26,40 +26,40 @@ import PropTypes from '@ttn-lw/lib/prop-types' import style from './switcher.styl' const Switcher = ({ isMinimized }) => { - const { pathname } = useLocation() - // effectively ignores the end prop and only matches when you're at the root route. - // https://reactrouter.com/en/main/components/nav-link - const overviewClassName = classnames( - style.link, - { [style.active]: !pathname.includes('/applications') && !pathname.includes('/gateways') }, - 'p-vert-cs-s', - 'p-sides-0', - ) + const paddingClass = isMinimized ? 'p-vert-cs-xs' : 'p-vert-cs-s' - const className = useCallback( + const getNavLinkClass = useCallback( ({ isActive }) => - classnames(style.link, { [style.active]: isActive }, 'p-vert-cs-s', 'p-sides-0'), - [], + classnames(style.link, isActive ? style.active : '', paddingClass, 'p-sides-0'), + [paddingClass], ) return (
    - - {isMinimized ? : } + + {isMinimized ? ( + + ) : ( + + )} - + {isMinimized ? ( - + ) : ( )} - - {isMinimized ? : } + + {isMinimized ? ( + + ) : ( + + )}
    ) diff --git a/pkg/webui/components/sidebar/switcher/switcher.styl b/pkg/webui/components/sidebar/switcher/switcher.styl index 9ccca11953..d3039557da 100644 --- a/pkg/webui/components/sidebar/switcher/switcher.styl +++ b/pkg/webui/components/sidebar/switcher/switcher.styl @@ -16,22 +16,25 @@ border-radius: $br.l border: 1px solid $c.grey-100 background: white - box-shadow: 0px 2px 7px 0px rgba(0, 0, 0, 0.05) - font-weight: $fw.light + box-shadow: 0px 2px 7px 1px rgba(0, 0, 0, 0.06) + display: flex + gap: $cs.xxs + justify-content: center + padding: $cs.xxs .link - transition: 0.2s all ease-in-out border-radius: $br.m text-decoration: none color: $c.grey-700 display: flex justify-content: center width: 100% - font-weight: $fw.bold + transition: background 200ms ease-in-out &.active background: $c.grey-900 color: $c.white + font-weight: $fw.bold &:visited text-decoration: none @@ -42,3 +45,7 @@ text-decoration: none &:not(.active) background: $c.grey-100 + + .icon + font-size: $fs.xl + font-weight: 300 diff --git a/pkg/webui/console/components/gateway-api-keys-modal/index.js b/pkg/webui/console/components/gateway-api-keys-modal/index.js index df1ebfc799..e941a43576 100644 --- a/pkg/webui/console/components/gateway-api-keys-modal/index.js +++ b/pkg/webui/console/components/gateway-api-keys-modal/index.js @@ -50,7 +50,13 @@ const GatewayApiKeysModal = ({ > {lnsKey && ( -