@seamapi/react 0.22.0 → 0.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/index.css +122 -0
  2. package/index.css.map +1 -1
  3. package/index.min.css +1 -1
  4. package/index.min.css.map +1 -1
  5. package/lib/copy.d.ts +1 -0
  6. package/lib/copy.js +4 -0
  7. package/lib/copy.js.map +1 -0
  8. package/lib/icons/Copy.d.ts +2 -0
  9. package/lib/icons/Copy.js +5 -0
  10. package/lib/icons/Copy.js.map +1 -0
  11. package/lib/ui/AccessCodeTable/AccessCodeTable.js +12 -1
  12. package/lib/ui/AccessCodeTable/AccessCodeTable.js.map +1 -1
  13. package/lib/ui/Menu/Menu.d.ts +19 -0
  14. package/lib/ui/Menu/Menu.js +93 -0
  15. package/lib/ui/Menu/Menu.js.map +1 -0
  16. package/lib/ui/Menu/MenuItem.d.ts +6 -0
  17. package/lib/ui/Menu/MenuItem.js +10 -0
  18. package/lib/ui/Menu/MenuItem.js.map +1 -0
  19. package/lib/ui/Menu/MoreActionsMenu.d.ts +6 -0
  20. package/lib/ui/Menu/MoreActionsMenu.js +11 -0
  21. package/lib/ui/Menu/MoreActionsMenu.js.map +1 -0
  22. package/lib/ui/SupportedDevices/SupportedDeviceRow.d.ts +5 -0
  23. package/lib/ui/SupportedDevices/SupportedDeviceRow.js +18 -0
  24. package/lib/ui/SupportedDevices/SupportedDeviceRow.js.map +1 -0
  25. package/lib/ui/SupportedDevices/SupportedDevices.d.ts +1 -0
  26. package/lib/ui/SupportedDevices/SupportedDevices.js +21 -0
  27. package/lib/ui/SupportedDevices/SupportedDevices.js.map +1 -0
  28. package/lib/ui/SupportedDevices/SupportedDevicesHeader.d.ts +1 -0
  29. package/lib/ui/SupportedDevices/SupportedDevicesHeader.js +5 -0
  30. package/lib/ui/SupportedDevices/SupportedDevicesHeader.js.map +1 -0
  31. package/lib/ui/SupportedDevices/types.d.ts +10 -0
  32. package/lib/ui/SupportedDevices/types.js +2 -0
  33. package/lib/ui/SupportedDevices/types.js.map +1 -0
  34. package/package.json +2 -2
  35. package/src/lib/copy.ts +3 -0
  36. package/src/lib/icons/Copy.tsx +30 -0
  37. package/src/lib/ui/AccessCodeTable/AccessCodeTable.tsx +27 -1
  38. package/src/lib/ui/Menu/Menu.tsx +179 -0
  39. package/src/lib/ui/Menu/MenuItem.tsx +24 -0
  40. package/src/lib/ui/Menu/MoreActionsMenu.tsx +27 -0
  41. package/src/lib/ui/SupportedDevices/SupportedDeviceRow.tsx +46 -0
  42. package/src/lib/ui/SupportedDevices/SupportedDevices.tsx +65 -0
  43. package/src/lib/ui/SupportedDevices/SupportedDevicesHeader.tsx +14 -0
  44. package/src/lib/ui/SupportedDevices/types.ts +10 -0
  45. package/src/styles/_access-code-table.scss +9 -0
  46. package/src/styles/_main.scss +4 -0
  47. package/src/styles/_menus.scss +46 -0
  48. package/src/styles/_supported-devices.scss +105 -0
@@ -0,0 +1,3 @@
1
+ export async function copy(value: string) {
2
+ await window.navigator.clipboard.writeText(value)
3
+ }
@@ -0,0 +1,30 @@
1
+ import type { SVGProps } from 'react'
2
+ export const CopyIcon = (props: SVGProps<SVGSVGElement>): JSX.Element => (
3
+ <svg
4
+ xmlns='http://www.w3.org/2000/svg'
5
+ width={18}
6
+ height={18}
7
+ fill='none'
8
+ {...props}
9
+ >
10
+ <mask
11
+ id='copy_svg__a'
12
+ width={18}
13
+ height={18}
14
+ x={0}
15
+ y={0}
16
+ maskUnits='userSpaceOnUse'
17
+ style={{
18
+ maskType: 'alpha',
19
+ }}
20
+ >
21
+ <path fill='#D9D9D9' d='M0 0h18v18H0z' />
22
+ </mask>
23
+ <g mask='url(#copy_svg__a)'>
24
+ <path
25
+ fill='#007AFC'
26
+ d='M3.75 16.5c-.413 0-.766-.147-1.06-.44A1.446 1.446 0 0 1 2.25 15V4.5h1.5V15H12v1.5H3.75Zm3-3c-.412 0-.766-.147-1.059-.44A1.445 1.445 0 0 1 5.25 12V3c0-.413.147-.766.441-1.06.293-.293.647-.44 1.059-.44h6.75c.412 0 .766.147 1.06.44.293.294.44.647.44 1.06v9c0 .412-.147.766-.44 1.06-.294.293-.648.44-1.06.44H6.75Zm0-1.5h6.75V3H6.75v9Z'
27
+ />
28
+ </g>
29
+ </svg>
30
+ )
@@ -1,11 +1,15 @@
1
1
  import { useState } from 'react'
2
2
  import type { AccessCode } from 'seamapi'
3
3
 
4
+ import { copy } from 'lib/copy.js'
4
5
  import { AccessCodeKeyIcon } from 'lib/icons/AccessCodeKey.js'
6
+ import { CopyIcon } from 'lib/icons/Copy.js'
5
7
  import { useAccessCodes } from 'lib/seam/access-codes/use-access-codes.js'
6
8
  import { AccessCodeDetails } from 'lib/ui/AccessCodeDetails/AccessCodeDetails.js'
7
9
  import { CodeDetails } from 'lib/ui/AccessCodeTable/CodeDetails.js'
8
10
  import { ContentHeader } from 'lib/ui/layout/ContentHeader.js'
11
+ import { MenuItem } from 'lib/ui/Menu/MenuItem.js'
12
+ import { MoreActionsMenu } from 'lib/ui/Menu/MoreActionsMenu.js'
9
13
  import { TableBody } from 'lib/ui/Table/TableBody.js'
10
14
  import { TableCell } from 'lib/ui/Table/TableCell.js'
11
15
  import { TableHeader } from 'lib/ui/Table/TableHeader.js'
@@ -71,7 +75,28 @@ export function AccessCodeTable(
71
75
  <Title>{code.name}</Title>
72
76
  <CodeDetails accessCode={code} />
73
77
  </TableCell>
74
- <TableCell className='seam-action-cell' />
78
+ <TableCell className='seam-action-cell'>
79
+ <MoreActionsMenu
80
+ MenuProps={{
81
+ BackgroundProps: {
82
+ className: 'seam-access-code-table-action-menu',
83
+ },
84
+ }}
85
+ >
86
+ <MenuItem
87
+ onClick={() => {
88
+ void copy(code.code ?? '')
89
+ }}
90
+ >
91
+ <div className='menu-item-copy'>
92
+ <span>
93
+ {t.copyCode} - {code.code}
94
+ </span>
95
+ <CopyIcon />
96
+ </div>
97
+ </MenuItem>
98
+ </MoreActionsMenu>
99
+ </TableCell>
75
100
  </TableRow>
76
101
  ))}
77
102
  </TableBody>
@@ -81,4 +106,5 @@ export function AccessCodeTable(
81
106
 
82
107
  const t = {
83
108
  accessCodes: 'Access Codes',
109
+ copyCode: 'Copy code',
84
110
  }
@@ -0,0 +1,179 @@
1
+ import classNames from 'classnames'
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useState,
9
+ } from 'react'
10
+ import { createPortal } from 'react-dom'
11
+
12
+ export interface MenuProps {
13
+ children: JSX.Element | JSX.Element[]
14
+ verticalOffset?: number
15
+ horizontalOffset?: number
16
+ edgeOffset?: number
17
+ button: (props: {
18
+ open: (event: React.MouseEvent<HTMLElement>) => void
19
+ }) => React.ReactElement
20
+ BackgroundProps?: Partial<{
21
+ className?: string
22
+ }>
23
+ }
24
+
25
+ interface MenuContextProps {
26
+ close: () => void
27
+ }
28
+
29
+ const MenuContext = createContext<MenuContextProps | undefined>(undefined)
30
+
31
+ const Menu = ({
32
+ verticalOffset = 5,
33
+ horizontalOffset = 0,
34
+ edgeOffset = 5,
35
+ children,
36
+ button,
37
+ BackgroundProps,
38
+ }: MenuProps) => {
39
+ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
40
+ const [documentEl, setDocumentEl] = useState<null | Element>(null)
41
+ const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null)
42
+ const [top, setTop] = useState(0)
43
+ const [left, setLeft] = useState(0)
44
+
45
+ useEffect(() => {
46
+ const containers = document.querySelectorAll('.seam-components')
47
+ const el = containers[containers.length - 1]
48
+ if (el != null) {
49
+ setDocumentEl(el)
50
+ }
51
+ }, [setDocumentEl])
52
+
53
+ const handleClose = () => {
54
+ setAnchorEl(null)
55
+ }
56
+
57
+ const open = (event: React.MouseEvent<HTMLElement>) => {
58
+ setAnchorEl(event.currentTarget)
59
+ }
60
+
61
+ const setPositions = useCallback(() => {
62
+ if (anchorEl == null || contentEl == null || documentEl == null) return
63
+
64
+ const { right: containerRight, bottom: containerBottom } =
65
+ documentEl.getBoundingClientRect()
66
+
67
+ const {
68
+ top: anchorTop,
69
+ left: anchorLeft,
70
+ height: anchorHeight,
71
+ } = anchorEl.getBoundingClientRect()
72
+
73
+ const { width: contentWidth, height: contentHeight } =
74
+ contentEl.getBoundingClientRect()
75
+
76
+ const anchorBottom = anchorTop + anchorHeight
77
+
78
+ const top = anchorBottom + verticalOffset
79
+ const left = anchorLeft + horizontalOffset
80
+ const right = left + contentWidth
81
+ const bottom = top + contentHeight
82
+
83
+ // If the content would overflow right, we'll need to set it relative
84
+ // to the right of the container.
85
+ const isOverflowingRight = right > containerRight
86
+ const visibleLeft = isOverflowingRight
87
+ ? containerRight - contentWidth - horizontalOffset - edgeOffset
88
+ : left
89
+ setLeft(visibleLeft)
90
+
91
+ // If the content would overflow bottom, we'll position it to be
92
+ // above the anchor.
93
+ const isOverFlowingBottom = bottom > containerBottom
94
+ const visibleTop = isOverFlowingBottom
95
+ ? anchorTop - contentHeight - verticalOffset
96
+ : top
97
+ setTop(visibleTop)
98
+ }, [
99
+ anchorEl,
100
+ horizontalOffset,
101
+ verticalOffset,
102
+ contentEl,
103
+ documentEl,
104
+ edgeOffset,
105
+ ])
106
+
107
+ const isOpen = Boolean(anchorEl)
108
+
109
+ useLayoutEffect(() => {
110
+ setPositions()
111
+ window.addEventListener('scroll', setPositions)
112
+ window.addEventListener('resize', setPositions)
113
+ return () => {
114
+ window.removeEventListener('scroll', setPositions)
115
+ window.removeEventListener('resize', setPositions)
116
+ }
117
+ }, [setPositions])
118
+
119
+ const hasSetPosition = top !== 0 && left !== 0
120
+ const visible = isOpen && hasSetPosition
121
+
122
+ if (documentEl == null) {
123
+ return null
124
+ }
125
+
126
+ return (
127
+ <MenuContext.Provider
128
+ value={{
129
+ close: handleClose,
130
+ }}
131
+ >
132
+ {button({ open })}
133
+ {createPortal(
134
+ <div
135
+ className={classNames('seam-menu-bg', BackgroundProps?.className)}
136
+ style={{
137
+ display: visible ? 'flex' : 'none',
138
+ }}
139
+ onClick={(e) => {
140
+ e.stopPropagation()
141
+ handleClose()
142
+ }}
143
+ >
144
+ <div
145
+ className='seam-menu-content'
146
+ style={{
147
+ top,
148
+ left,
149
+ }}
150
+ >
151
+ {children}
152
+ </div>
153
+ </div>,
154
+ documentEl
155
+ )}
156
+
157
+ {/*
158
+ * Render a shadow element to calculate the content size even if we're
159
+ * not actually showing the content yet.
160
+ */}
161
+ <div className='seam-menu-shadow'>
162
+ <div className='seam-menu-content' ref={setContentEl}>
163
+ {children}
164
+ </div>
165
+ </div>
166
+ </MenuContext.Provider>
167
+ )
168
+ }
169
+
170
+ export function useMenu() {
171
+ const context = useContext(MenuContext)
172
+ if (context === undefined) {
173
+ throw new Error('useMenu must be used within a Menu.')
174
+ }
175
+
176
+ return context
177
+ }
178
+
179
+ export default Menu
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { useMenu } from 'lib/ui/Menu/Menu.js'
4
+
5
+ export interface MenuItemProps {
6
+ children: ReactNode
7
+ onClick: () => void
8
+ }
9
+
10
+ export function MenuItem({ children, onClick }: MenuItemProps) {
11
+ const { close: closeMenu } = useMenu()
12
+
13
+ return (
14
+ <div
15
+ className='seam-menu-item'
16
+ onClick={() => {
17
+ onClick()
18
+ closeMenu()
19
+ }}
20
+ >
21
+ {children}
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,27 @@
1
+ import { DotsEllipsisMoreIcon } from 'lib/icons/DotsEllipsisMore.js'
2
+ import { IconButton } from 'lib/ui/IconButton.js'
3
+ import Menu, { type MenuProps } from 'lib/ui/Menu/Menu.js'
4
+
5
+ export interface MoreActionsMenuProps {
6
+ children: JSX.Element | JSX.Element[]
7
+ MenuProps?: Partial<MenuProps>
8
+ }
9
+ export function MoreActionsMenu({ children, MenuProps }: MoreActionsMenuProps) {
10
+ return (
11
+ <Menu
12
+ button={({ open }) => (
13
+ <IconButton
14
+ onClick={(e) => {
15
+ e.stopPropagation()
16
+ open(e)
17
+ }}
18
+ >
19
+ <DotsEllipsisMoreIcon />
20
+ </IconButton>
21
+ )}
22
+ {...MenuProps}
23
+ >
24
+ {children}
25
+ </Menu>
26
+ )
27
+ }
@@ -0,0 +1,46 @@
1
+ import classNames from 'classnames'
2
+
3
+ import type { DeviceModel } from 'lib/ui/SupportedDevices/types.js'
4
+
5
+ export interface SupportedDeviceRowProps {
6
+ deviceModel: DeviceModel
7
+ }
8
+
9
+ export default function SupportedDeviceRow({
10
+ deviceModel,
11
+ }: SupportedDeviceRowProps) {
12
+ function getColorFromSupportLevel() {
13
+ switch (deviceModel.support_level) {
14
+ case 'Live':
15
+ return 'green'
16
+ case 'Beta':
17
+ return 'blue'
18
+ case 'Unsupported':
19
+ return 'orange'
20
+ default:
21
+ return 'gray'
22
+ }
23
+ }
24
+
25
+ return (
26
+ <tr>
27
+ <td>
28
+ <img style={{ width: 40 }} src={deviceModel.icon_url} />
29
+ </td>
30
+ <td>{deviceModel.main_category}</td>
31
+ <td>{deviceModel.model_name}</td>
32
+ <td>{deviceModel.manufacturer_model_id}</td>
33
+ <td>{deviceModel.connection_type}</td>
34
+ <td>
35
+ <div
36
+ className={classNames(
37
+ 'seam-status-pill',
38
+ `status-${getColorFromSupportLevel()}`
39
+ )}
40
+ >
41
+ <span>{deviceModel.support_level}</span>
42
+ </div>
43
+ </td>
44
+ </tr>
45
+ )
46
+ }
@@ -0,0 +1,65 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import axios from 'axios'
3
+
4
+ import { Button } from 'lib/ui/Button.js'
5
+ import type { DeviceModel } from 'lib/ui/SupportedDevices/types.js'
6
+
7
+ import SupportedDeviceRow from './SupportedDeviceRow.js'
8
+ import SupportedDevicesHeader from './SupportedDevicesHeader.js'
9
+
10
+ export default function SupportedDevices() {
11
+ const { data, isLoading, isError, refetch } = useQuery<{
12
+ data: {
13
+ device_models?: DeviceModel[]
14
+ }
15
+ }>({
16
+ queryKey: ['supported_devices'],
17
+ queryFn: async () => {
18
+ return await axios.get('https://devicedb.seam.co/api/device_models/list')
19
+ },
20
+ })
21
+
22
+ return (
23
+ <>
24
+ <div className='seam-supported-devices-table-wrap'>
25
+ {isLoading && (
26
+ <div className='seam-supported-devices-table-state-block'>
27
+ <p>Loading device models...</p>
28
+ </div>
29
+ )}
30
+
31
+ {isError && (
32
+ <div className='seam-supported-devices-table-state-block'>
33
+ <p>There was an error fetching device models.</p>
34
+ <Button
35
+ variant='solid'
36
+ size='small'
37
+ onClick={() => {
38
+ void refetch()
39
+ }}
40
+ >
41
+ Retry
42
+ </Button>
43
+ </div>
44
+ )}
45
+
46
+ {!isLoading &&
47
+ !isError &&
48
+ data?.data?.device_models !== null &&
49
+ Array.isArray(data?.data?.device_models) && (
50
+ <table className='seam-supported-devices-table'>
51
+ <SupportedDevicesHeader />
52
+ <tbody>
53
+ {data.data.device_models.map((deviceModel) => (
54
+ <SupportedDeviceRow
55
+ key={deviceModel.manufacturer_model_id}
56
+ deviceModel={deviceModel}
57
+ />
58
+ ))}
59
+ </tbody>
60
+ </table>
61
+ )}
62
+ </div>
63
+ </>
64
+ )
65
+ }
@@ -0,0 +1,14 @@
1
+ export default function SupportedDevicesHeader() {
2
+ return (
3
+ <thead>
4
+ <tr>
5
+ <th />
6
+ <th>Category</th>
7
+ <th>Model Name</th>
8
+ <th>Manufacturer Model ID</th>
9
+ <th>Connection Type</th>
10
+ <th>Status</th>
11
+ </tr>
12
+ </thead>
13
+ )
14
+ }
@@ -0,0 +1,10 @@
1
+ export interface DeviceModel {
2
+ main_category: string
3
+ model_name: string
4
+ manufacturer_model_id: string
5
+ connection_type: string
6
+ support_level: 'Live' | 'Beta' | 'Unsupported'
7
+ brand: string
8
+ icon_url: string
9
+ seam_device_model_page_url: string
10
+ }
@@ -47,4 +47,13 @@
47
47
  }
48
48
  }
49
49
  }
50
+
51
+ .seam-access-code-table-action-menu {
52
+ .menu-item-copy {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: space-between;
56
+ flex: 1;
57
+ }
58
+ }
50
59
  }
@@ -7,9 +7,11 @@
7
7
  @use './access-code-table';
8
8
  @use './colors';
9
9
  @use './icons';
10
+ @use './menus';
10
11
  @use './device-details';
11
12
  @use './device-table';
12
13
  @use './access-code-details';
14
+ @use './supported-devices';
13
15
 
14
16
  .seam-components {
15
17
  // Reset
@@ -22,10 +24,12 @@
22
24
  @include buttons.all;
23
25
  @include layout.all;
24
26
  @include icons.all;
27
+ @include menus.all;
25
28
 
26
29
  // Components
27
30
  @include device-details.all;
28
31
  @include device-table.all;
29
32
  @include access-code-table.all;
30
33
  @include access-code-details.all;
34
+ @include supported-devices.all;
31
35
  }
@@ -0,0 +1,46 @@
1
+ @use './colors';
2
+
3
+ @mixin all {
4
+ .seam-menu-bg {
5
+ width: 100vw;
6
+ height: 100vh;
7
+ justify-content: center;
8
+ align-items: center;
9
+ position: fixed;
10
+ inset: 0;
11
+ z-index: 999999;
12
+ display: none; // Toggled in JS (component)
13
+ }
14
+
15
+ .seam-menu-content {
16
+ width: 250px;
17
+ position: absolute;
18
+ padding: 8px 0;
19
+ background: #fff;
20
+ box-shadow: 0 0 1px rgb(0 0 0 / 30%), 0 2px 16px 2px rgb(15 22 28 / 15%);
21
+ border-radius: 8px;
22
+
23
+ .seam-menu-item {
24
+ padding: 0 16px;
25
+ height: 36px;
26
+ display: flex;
27
+ align-items: center;
28
+ font-size: 16px;
29
+ line-height: 134%;
30
+ cursor: pointer;
31
+
32
+ &:hover {
33
+ background: colors.$item-hover-bg;
34
+ }
35
+ }
36
+ }
37
+
38
+ .seam-menu-shadow {
39
+ z-index: -1;
40
+ opacity: 0;
41
+ visibility: hidden;
42
+ top: 0;
43
+ right: 0;
44
+ position: fixed;
45
+ }
46
+ }
@@ -0,0 +1,105 @@
1
+ @use './colors';
2
+
3
+ $row-padding: 8px;
4
+
5
+ @mixin all {
6
+ .seam-supported-devices-table-state-block {
7
+ width: 100%;
8
+ background-color: colors.$bg-a;
9
+ padding: 32px 8px;
10
+ display: flex;
11
+ justify-content: center;
12
+ align-items: center;
13
+ flex-direction: column;
14
+ gap: 8px;
15
+ }
16
+
17
+ .seam-supported-devices-table {
18
+ width: 100%;
19
+ overflow-x: auto;
20
+ table-layout: auto;
21
+ border-collapse: collapse;
22
+
23
+ th,
24
+ td {
25
+ white-space: nowrap;
26
+ padding: 0 8px;
27
+ }
28
+
29
+ thead {
30
+ width: 100%;
31
+ height: 50px;
32
+ background: colors.$bg-a;
33
+ overflow: hidden;
34
+
35
+ tr {
36
+ border-radius: 8px;
37
+ }
38
+
39
+ th {
40
+ vertical-align: middle;
41
+ text-align: left;
42
+ font-size: 16px;
43
+ font-weight: 600;
44
+ }
45
+ }
46
+
47
+ tbody {
48
+ tr {
49
+ height: 60px;
50
+ border-bottom: 1px solid colors.$bg-b;
51
+ }
52
+
53
+ td {
54
+ vertical-align: middle;
55
+ }
56
+ }
57
+
58
+ // stylelint-disable-next-line no-descending-specificity
59
+ tr {
60
+ th:first-of-type,
61
+ td:first-of-type {
62
+ padding-left: $row-padding;
63
+ }
64
+
65
+ th:last-of-type,
66
+ td:last-of-type {
67
+ padding-right: $row-padding;
68
+ }
69
+ }
70
+
71
+ .seam-status-pill {
72
+ display: inline-flex;
73
+ padding: 4px 8px;
74
+ border-radius: 90px;
75
+ background-color: colors.$status-green;
76
+
77
+ & span {
78
+ color: white;
79
+ font-size: 14px;
80
+ font-weight: 600;
81
+ line-height: 1;
82
+ }
83
+
84
+ &.status-green {
85
+ background-color: colors.$status-green;
86
+ }
87
+
88
+ &.status-orange {
89
+ background-color: colors.$status-orange;
90
+ }
91
+
92
+ &.status-red {
93
+ background-color: colors.$status-red;
94
+ }
95
+
96
+ &.status-blue {
97
+ background-color: colors.$primary;
98
+ }
99
+
100
+ &.status-gray {
101
+ background-color: colors.$bg-d;
102
+ }
103
+ }
104
+ }
105
+ }