diff --git a/assets/icons/close.svg b/assets/icons/close.svg new file mode 100644 index 000000000..d24768990 --- /dev/null +++ b/assets/icons/close.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 000000000..1fe1276bb --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/lib/icons/Close.tsx b/src/lib/icons/Close.tsx new file mode 100644 index 000000000..827d71bba --- /dev/null +++ b/src/lib/icons/Close.tsx @@ -0,0 +1,30 @@ +import type { SVGProps } from 'react' +export const CloseIcon = (props: SVGProps): JSX.Element => ( + + + + + + + + +) diff --git a/src/lib/icons/Search.tsx b/src/lib/icons/Search.tsx new file mode 100644 index 000000000..b998e5f6b --- /dev/null +++ b/src/lib/icons/Search.tsx @@ -0,0 +1,30 @@ +import type { SVGProps } from 'react' +export const SearchIcon = (props: SVGProps): JSX.Element => ( + + + + + + + + +) diff --git a/src/lib/ui/DeviceTable/DeviceTable.tsx b/src/lib/ui/DeviceTable/DeviceTable.tsx index a7d1d3b94..119849afe 100644 --- a/src/lib/ui/DeviceTable/DeviceTable.tsx +++ b/src/lib/ui/DeviceTable/DeviceTable.tsx @@ -16,6 +16,7 @@ import { EmptyPlaceholder } from 'lib/ui/Table/EmptyPlaceholder.js' import { TableBody } from 'lib/ui/Table/TableBody.js' import { TableHeader } from 'lib/ui/Table/TableHeader.js' import { TableTitle } from 'lib/ui/Table/TableTitle.js' +import { SearchTextField } from 'lib/ui/TextField/SearchTextField.js' import { Caption } from 'lib/ui/typography/Caption.js' export type DeviceTableProps = Props & UseDevicesParams @@ -31,6 +32,7 @@ export function DeviceTable({ const { devices, isLoading, isError, error } = useDevices(props) const [selectedDeviceId, selectDevice] = useState(null) + const [searchTerm, setSearchTerm] = useState('') if (selectedDeviceId != null) { return ( @@ -57,6 +59,14 @@ export function DeviceTable({ const deviceCount = devices.length + const filteredDevices = devices.filter((device) => { + if (searchTerm === '') { + return true + } + + return new RegExp(searchTerm, 'i').test(device.properties.name) + }) + return (
@@ -64,9 +74,14 @@ export function DeviceTable({ {t.devices} ({deviceCount}) + - +
) diff --git a/src/lib/ui/Table/TableHeader.tsx b/src/lib/ui/Table/TableHeader.tsx index e5b80c1c8..35d79f74a 100644 --- a/src/lib/ui/Table/TableHeader.tsx +++ b/src/lib/ui/Table/TableHeader.tsx @@ -1,5 +1,9 @@ import type { PropsWithChildren } from 'react' export function TableHeader({ children }: PropsWithChildren): JSX.Element { - return
{children}
+ return ( +
+
{children}
+
+ ) } diff --git a/src/lib/ui/TextField/SearchTextField.tsx b/src/lib/ui/TextField/SearchTextField.tsx index 9e8fd1ab7..b766d3192 100644 --- a/src/lib/ui/TextField/SearchTextField.tsx +++ b/src/lib/ui/TextField/SearchTextField.tsx @@ -1,15 +1,94 @@ import classNames from 'classnames' +import { useEffect, useState } from 'react' +import { CloseIcon } from 'lib/icons/Close.js' +import { SearchIcon } from 'lib/icons/Search.js' import { TextField, type TextFieldProps } from 'lib/ui/TextField/TextField.js' export function SearchTextField({ className, + value, + onChange, ...props }: TextFieldProps): JSX.Element { + const [inputEl, setInputEl] = useState(null) + + const valueIsEmpty = useValueIsEmpty(value, inputEl) + const clearInput = () => { + if (onChange != null) { + onChange('') + return + } + + if (inputEl != null) { + inputEl.value = '' + } + } + return ( } + inputProps={{ + ref: setInputEl, + placeholder: 'Search', + }} + endAdornment={ + + } /> ) } + +function useValueIsEmpty( + value: string | undefined, + inputEl: HTMLInputElement | null +) { + const [valueIsEmpty, setValueIsEmpty] = useState(true) + + // If this is a controlled element, we'll just look at `value` + useEffect(() => { + setValueIsEmpty(value == null || value === '') + }, [value]) + + // If this is not a controlled element, we'll need to listen to `input` + // events. + useEffect(() => { + if (inputEl == null) { + return + } + + const handler = (event: Event) => { + if (value !== undefined) { + return + } + + if (event.target == null || !('value' in event.target)) { + return + } + + const inputValue = event.target.value + if (value === undefined) { + setValueIsEmpty(inputValue === '') + } + } + + inputEl.addEventListener('input', handler) + + return () => { + inputEl.removeEventListener('input', handler) + } + }, [inputEl, value]) + + return valueIsEmpty +} diff --git a/src/lib/ui/TextField/TextField.tsx b/src/lib/ui/TextField/TextField.tsx index c19c2f9f2..405feb664 100644 --- a/src/lib/ui/TextField/TextField.tsx +++ b/src/lib/ui/TextField/TextField.tsx @@ -1,21 +1,52 @@ import classNames from 'classnames' -import type { ChangeEvent, InputHTMLAttributes } from 'react' +import type { ChangeEvent, InputHTMLAttributes, MutableRefObject } from 'react' -export type TextFieldProps = { +export interface TextFieldProps { value?: string - onChange: (value: string) => void -} & Omit, 'value' | 'onChange'> + onChange?: (value: string) => void + startAdornment?: JSX.Element + endAdornment?: JSX.Element + disabled?: boolean + className?: string + inputProps?: { + ref?: + | MutableRefObject + | ((inputEl: HTMLInputElement) => void) + } & Omit, 'value' | 'onChange'> +} export function TextField(props: TextFieldProps): JSX.Element { - const { value, onChange, className, ...inputProps } = props + const { + value, + onChange, + className, + startAdornment, + endAdornment, + inputProps, + disabled, + } = props + return ( - +
+ {startAdornment != null && ( +
{startAdornment}
+ )} + + {endAdornment != null && ( +
{endAdornment}
+ )} +
) } diff --git a/src/styles/_colors.scss b/src/styles/_colors.scss index a63adb172..47ce82788 100644 --- a/src/styles/_colors.scss +++ b/src/styles/_colors.scss @@ -18,6 +18,7 @@ $bg-d: #cbd0d3; $bg-c: #dde0e2; $bg-b: #e9edef; $bg-a: #f1f3f4; +$bg-gray: #ececec; $status-red: #e36857; $status-orange: #f3980f; $status-green: #27ae60; diff --git a/src/styles/_device-table.scss b/src/styles/_device-table.scss index ebd3cdd5e..22594ef0c 100644 --- a/src/styles/_device-table.scss +++ b/src/styles/_device-table.scss @@ -2,6 +2,10 @@ @mixin all { .seam-device-table { + .seam-search-text-field { + width: 170px; + } + .seam-table-row { cursor: pointer; diff --git a/src/styles/_inputs.scss b/src/styles/_inputs.scss index efe09069b..676b1c5f6 100644 --- a/src/styles/_inputs.scss +++ b/src/styles/_inputs.scss @@ -2,10 +2,82 @@ @mixin text-field { .seam-text-field { - padding: 0 8px; - height: 40px; border: 1px solid colors.$text-gray-3; border-radius: 8px; + padding: 0 8px; + display: flex; + align-items: center; + + &:hover { + border-color: colors.$text-default; + } + + &:focus-within { + border-color: colors.$primary; + } + + &.seam-disabled { + border-color: colors.$text-gray-3; + background: colors.$bg-gray; + cursor: not-allowed; + + .seam-text-field-input { + cursor: not-allowed; + } + } + + .seam-text-field-input { + height: 40px; + border: 0; + + &:focus { + outline: none; + } + } + + .seam-adornment { + display: flex; + align-items: center; + + svg { + scale: 0.8333; + } + + &.seam-start { + margin-right: 4px; + } + + button { + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-items: center; + appearance: none; + background-color: transparent; + padding: 0; + margin: 0; + border: 0; + box-shadow: none; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: colors.$bg-b; + } + + &.seam-hidden { + opacity: 0; + visibility: hidden; + } + } + } + + &.seam-search-text-field { + .seam-text-field-input { + width: 100%; + } + } } } diff --git a/src/styles/_tables.scss b/src/styles/_tables.scss index 48f555f58..a7d12a20b 100644 --- a/src/styles/_tables.scss +++ b/src/styles/_tables.scss @@ -4,9 +4,16 @@ .seam-table-header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; height: 56px; padding: 0 16px; + + .seam-body { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } } .seam-table-title {