@seamapi/react 4.5.0 → 4.7.0

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 (83) hide show
  1. package/README.md +2 -2
  2. package/dist/elements.js +11560 -9368
  3. package/dist/elements.js.map +1 -1
  4. package/dist/index.css +255 -3
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.min.css +1 -1
  7. package/dist/index.min.css.map +1 -1
  8. package/lib/icons/Trash.d.ts +2 -0
  9. package/lib/icons/Trash.js +5 -0
  10. package/lib/icons/Trash.js.map +1 -0
  11. package/lib/seam/components/AccessCodeDetails/AccessCodeDetails.js +8 -3
  12. package/lib/seam/components/AccessCodeDetails/AccessCodeDetails.js.map +1 -1
  13. package/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.js +17 -1
  14. package/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.js.map +1 -1
  15. package/lib/seam/thermostats/thermostat-device.d.ts +2 -1
  16. package/lib/seam/thermostats/thermostat-device.js.map +1 -1
  17. package/lib/seam/thermostats/unit-conversion.d.ts +5 -2
  18. package/lib/seam/thermostats/unit-conversion.js +5 -2
  19. package/lib/seam/thermostats/unit-conversion.js.map +1 -1
  20. package/lib/seam/thermostats/use-create-thermostat-climate-preset.d.ts +6 -0
  21. package/lib/seam/thermostats/use-create-thermostat-climate-preset.js +55 -0
  22. package/lib/seam/thermostats/use-create-thermostat-climate-preset.js.map +1 -0
  23. package/lib/seam/thermostats/use-delete-thermostat-climate-preset.d.ts +6 -0
  24. package/lib/seam/thermostats/use-delete-thermostat-climate-preset.js +44 -0
  25. package/lib/seam/thermostats/use-delete-thermostat-climate-preset.js.map +1 -0
  26. package/lib/seam/thermostats/use-update-thermostat-climate-preset.d.ts +6 -0
  27. package/lib/seam/thermostats/use-update-thermostat-climate-preset.js +55 -0
  28. package/lib/seam/thermostats/use-update-thermostat-climate-preset.js.map +1 -0
  29. package/lib/ui/Button.d.ts +3 -2
  30. package/lib/ui/Button.js +12 -4
  31. package/lib/ui/Button.js.map +1 -1
  32. package/lib/ui/IconButton.d.ts +5 -2
  33. package/lib/ui/IconButton.js +2 -2
  34. package/lib/ui/IconButton.js.map +1 -1
  35. package/lib/ui/Popover/Popover.d.ts +17 -0
  36. package/lib/ui/Popover/Popover.js +85 -0
  37. package/lib/ui/Popover/Popover.js.map +1 -0
  38. package/lib/ui/Popover/PopoverContentPrompt.d.ts +11 -0
  39. package/lib/ui/Popover/PopoverContentPrompt.js +12 -0
  40. package/lib/ui/Popover/PopoverContentPrompt.js.map +1 -0
  41. package/lib/ui/thermostat/ClimateModeMenu.d.ts +7 -2
  42. package/lib/ui/thermostat/ClimateModeMenu.js +7 -2
  43. package/lib/ui/thermostat/ClimateModeMenu.js.map +1 -1
  44. package/lib/ui/thermostat/ClimatePreset.d.ts +8 -0
  45. package/lib/ui/thermostat/ClimatePreset.js +141 -0
  46. package/lib/ui/thermostat/ClimatePreset.js.map +1 -0
  47. package/lib/ui/thermostat/ClimatePresets.d.ts +9 -0
  48. package/lib/ui/thermostat/ClimatePresets.js +72 -0
  49. package/lib/ui/thermostat/ClimatePresets.js.map +1 -0
  50. package/lib/ui/thermostat/FanModeMenu.d.ts +3 -1
  51. package/lib/ui/thermostat/FanModeMenu.js +5 -2
  52. package/lib/ui/thermostat/FanModeMenu.js.map +1 -1
  53. package/lib/ui/thermostat/ThermostatCard.d.ts +1 -0
  54. package/lib/ui/thermostat/ThermostatCard.js +4 -2
  55. package/lib/ui/thermostat/ThermostatCard.js.map +1 -1
  56. package/lib/ui/types.d.ts +3 -3
  57. package/lib/version.d.ts +1 -1
  58. package/lib/version.js +1 -1
  59. package/package.json +3 -2
  60. package/src/lib/icons/Trash.tsx +28 -0
  61. package/src/lib/seam/components/AccessCodeDetails/AccessCodeDetails.tsx +50 -34
  62. package/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx +52 -1
  63. package/src/lib/seam/thermostats/thermostat-device.ts +4 -0
  64. package/src/lib/seam/thermostats/unit-conversion.ts +12 -2
  65. package/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts +101 -0
  66. package/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts +84 -0
  67. package/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts +103 -0
  68. package/src/lib/ui/Button.tsx +20 -3
  69. package/src/lib/ui/IconButton.tsx +19 -2
  70. package/src/lib/ui/Popover/Popover.tsx +168 -0
  71. package/src/lib/ui/Popover/PopoverContentPrompt.tsx +58 -0
  72. package/src/lib/ui/thermostat/ClimateModeMenu.tsx +33 -1
  73. package/src/lib/ui/thermostat/ClimatePreset.tsx +373 -0
  74. package/src/lib/ui/thermostat/ClimatePresets.tsx +235 -0
  75. package/src/lib/ui/thermostat/FanModeMenu.tsx +20 -2
  76. package/src/lib/ui/thermostat/ThermostatCard.tsx +10 -4
  77. package/src/lib/ui/types.ts +3 -3
  78. package/src/lib/version.ts +1 -1
  79. package/src/styles/_buttons.scss +56 -2
  80. package/src/styles/_main.scss +2 -0
  81. package/src/styles/_popover.scss +46 -0
  82. package/src/styles/_spinner.scss +1 -1
  83. package/src/styles/_thermostat.scss +154 -2
@@ -14,6 +14,7 @@ import { useHeatCoolThermostat } from 'lib/seam/thermostats/use-heat-cool-thermo
14
14
  import { useHeatThermostat } from 'lib/seam/thermostats/use-heat-thermostat.js'
15
15
  import { useSetThermostatFanMode } from 'lib/seam/thermostats/use-set-thermostat-fan-mode.js'
16
16
  import { useSetThermostatOff } from 'lib/seam/thermostats/use-set-thermostat-off.js'
17
+ import { Button } from 'lib/ui/Button.js'
17
18
  import { AccordionRow } from 'lib/ui/layout/AccordionRow.js'
18
19
  import { ContentHeader } from 'lib/ui/layout/ContentHeader.js'
19
20
  import { DetailRow } from 'lib/ui/layout/DetailRow.js'
@@ -21,6 +22,7 @@ import { DetailSection } from 'lib/ui/layout/DetailSection.js'
21
22
  import { DetailSectionGroup } from 'lib/ui/layout/DetailSectionGroup.js'
22
23
  import { Snackbar } from 'lib/ui/Snackbar/Snackbar.js'
23
24
  import { ClimateModeMenu } from 'lib/ui/thermostat/ClimateModeMenu.js'
25
+ import { ClimatePresets } from 'lib/ui/thermostat/ClimatePresets.js'
24
26
  import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js'
25
27
  import { FanModeMenu } from 'lib/ui/thermostat/FanModeMenu.js'
26
28
  import { TemperatureControlGroup } from 'lib/ui/thermostat/TemperatureControlGroup.js'
@@ -40,16 +42,37 @@ export function ThermostatDeviceDetails({
40
42
  className,
41
43
  onEditName,
42
44
  }: ThermostatDeviceDetailsProps): JSX.Element | null {
45
+ const [temperatureUnit, setTemperatureUnit] = useState<
46
+ 'fahrenheit' | 'celsius'
47
+ >('fahrenheit')
48
+ const [climateSettingsVisible, setClimateSettingsVisible] = useState(false)
49
+
43
50
  if (device == null) {
44
51
  return null
45
52
  }
46
53
 
54
+ if (climateSettingsVisible) {
55
+ return (
56
+ <ClimatePresets
57
+ device={device}
58
+ temperatureUnit={temperatureUnit}
59
+ onBack={() => {
60
+ setClimateSettingsVisible(false)
61
+ }}
62
+ />
63
+ )
64
+ }
65
+
47
66
  return (
48
67
  <div className={classNames('seam-device-details', className)}>
49
68
  <ContentHeader title={t.thermostat} onBack={onBack} />
50
69
 
51
70
  <div className='seam-body'>
52
- <ThermostatCard device={device} onEditName={onEditName} />
71
+ <ThermostatCard
72
+ onTemperatureUnitChange={setTemperatureUnit}
73
+ device={device}
74
+ onEditName={onEditName}
75
+ />
53
76
 
54
77
  <div className='seam-thermostat-device-details'>
55
78
  <DetailSectionGroup>
@@ -58,6 +81,12 @@ export function ThermostatDeviceDetails({
58
81
  tooltipContent={t.currentSettingsTooltip}
59
82
  >
60
83
  <ClimateSettingRow device={device} />
84
+ <ClimatePresetRow
85
+ onClickManage={() => {
86
+ setClimateSettingsVisible(true)
87
+ }}
88
+ device={device}
89
+ />
61
90
  <FanModeRow device={device} />
62
91
  </DetailSection>
63
92
 
@@ -299,16 +328,38 @@ function ClimateSettingRow({
299
328
  )
300
329
  }
301
330
 
331
+ interface ClimatePresetRowProps {
332
+ device: ThermostatDevice
333
+ onClickManage: () => void
334
+ }
335
+
336
+ function ClimatePresetRow({
337
+ device,
338
+ onClickManage,
339
+ }: ClimatePresetRowProps): JSX.Element {
340
+ return (
341
+ <DetailRow label={t.climatePresets}>
342
+ <Button onClick={onClickManage}>
343
+ {t.manageNPresets(
344
+ (device.properties.available_climate_presets ?? []).length
345
+ )}
346
+ </Button>
347
+ </DetailRow>
348
+ )
349
+ }
350
+
302
351
  const t = {
303
352
  thermostat: 'Thermostat',
304
353
  currentSettings: 'Current settings',
305
354
  currentSettingsTooltip:
306
355
  'These are the settings currently on the device. If you change them here, they change on the device.',
307
356
  climate: 'Climate',
357
+ climatePresets: 'Climate presets',
308
358
  fanMode: 'Fan mode',
309
359
  none: 'None',
310
360
  fanModeSuccess: 'Successfully updated fan mode!',
311
361
  fanModeError: 'Error updating fan mode. Please try again.',
312
362
  climateSettingError: 'Error updating climate setting. Please try again.',
313
363
  saved: 'Saved',
364
+ manageNPresets: (n: number) => `Manage (${n} Preset${n <= 1 ? '' : 's'})`,
314
365
  }
@@ -13,6 +13,7 @@ export type ThermostatDevice = Omit<Device, 'properties'> & {
13
13
  | 'available_hvac_mode_settings'
14
14
  | 'fan_mode_setting'
15
15
  | 'current_climate_setting'
16
+ | 'available_climate_presets'
16
17
  >
17
18
  >
18
19
  }
@@ -36,3 +37,6 @@ export interface ClimateSetting {
36
37
  export const isThermostatDevice = (
37
38
  device: Device
38
39
  ): device is ThermostatDevice => 'is_fan_running' in device.properties
40
+
41
+ export type ThermostatClimatePreset =
42
+ ThermostatDevice['properties']['available_climate_presets'][number]
@@ -1,8 +1,12 @@
1
1
  import type { Device } from '@seamapi/types/connect'
2
2
 
3
- export const celsiusToFahrenheit = (t: number): number => (t * 9) / 5 + 32
3
+ type ConversionReturn<T> = T extends NonNullable<number> ? number : T
4
4
 
5
- export const fahrenheitToCelsius = (t: number): number => (t - 32) * (5 / 9)
5
+ export const celsiusToFahrenheit = <T>(t: T): ConversionReturn<T> =>
6
+ (typeof t === 'number' ? (t * 9) / 5 + 32 : t) as ConversionReturn<T>
7
+
8
+ export const fahrenheitToCelsius = <T>(t: T): ConversionReturn<T> =>
9
+ (typeof t === 'number' ? (t - 32) * (5 / 9) : t) as ConversionReturn<T>
6
10
 
7
11
  export const getCoolingSetPointCelsius = (
8
12
  variables: {
@@ -87,3 +91,9 @@ export const getHeatingSetPointFahrenheit = (
87
91
  undefined
88
92
  )
89
93
  }
94
+
95
+ export function getTemperatureUnitSymbol(
96
+ type: 'fahrenheit' | 'celsius'
97
+ ): string {
98
+ return type === 'fahrenheit' ? '°F' : '°C'
99
+ }
@@ -0,0 +1,101 @@
1
+ import type {
2
+ SeamHttpApiError,
3
+ ThermostatsCreateClimatePresetBody,
4
+ } from '@seamapi/http/connect'
5
+ import {
6
+ useMutation,
7
+ type UseMutationResult,
8
+ useQueryClient,
9
+ } from '@tanstack/react-query'
10
+
11
+ import type {
12
+ ThermostatClimatePreset,
13
+ ThermostatDevice,
14
+ } from 'lib/seam/thermostats/thermostat-device.js'
15
+ import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js'
16
+ import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js'
17
+
18
+ export type UseCreateThermostatClimatePresetParams = never
19
+ export type UseCreateThermostatClimatePresetData = undefined
20
+
21
+ export type UseCreateThermostatClimatePresetVariables =
22
+ ThermostatsCreateClimatePresetBody
23
+
24
+ export function useCreateThermostatClimatePreset(): UseMutationResult<
25
+ UseCreateThermostatClimatePresetData,
26
+ SeamHttpApiError,
27
+ UseCreateThermostatClimatePresetVariables
28
+ > {
29
+ const { client } = useSeamClient()
30
+ const queryClient = useQueryClient()
31
+
32
+ return useMutation<
33
+ UseCreateThermostatClimatePresetData,
34
+ SeamHttpApiError,
35
+ UseCreateThermostatClimatePresetVariables
36
+ >({
37
+ mutationFn: async (variables) => {
38
+ if (client === null) throw new NullSeamClientError()
39
+ await client.thermostats.createClimatePreset(variables)
40
+ },
41
+ onSuccess: (_data, variables) => {
42
+ queryClient.setQueryData<ThermostatDevice | null>(
43
+ ['devices', 'get', { device_id: variables.device_id }],
44
+ (device) => {
45
+ if (device == null) {
46
+ return
47
+ }
48
+
49
+ return getUpdatedDevice(device, variables)
50
+ }
51
+ )
52
+
53
+ queryClient.setQueryData<ThermostatDevice[]>(
54
+ ['devices', 'list', { device_id: variables.device_id }],
55
+ (devices): ThermostatDevice[] => {
56
+ if (devices == null) {
57
+ return []
58
+ }
59
+
60
+ return devices.map((device) => {
61
+ if (device.device_id === variables.device_id) {
62
+ return getUpdatedDevice(device, variables)
63
+ }
64
+
65
+ return device
66
+ })
67
+ }
68
+ )
69
+ },
70
+ })
71
+ }
72
+
73
+ const getUpdatedDevice = (
74
+ device: ThermostatDevice,
75
+ variables: UseCreateThermostatClimatePresetVariables
76
+ ): ThermostatDevice => {
77
+ const preset: ThermostatClimatePreset = {
78
+ ...variables,
79
+ cooling_set_point_celsius: fahrenheitToCelsius(
80
+ variables.cooling_set_point_fahrenheit
81
+ ),
82
+ heating_set_point_celsius: fahrenheitToCelsius(
83
+ variables.heating_set_point_fahrenheit
84
+ ),
85
+ display_name: variables.name ?? variables.climate_preset_key,
86
+ can_delete: true,
87
+ can_edit: true,
88
+ manual_override_allowed: false,
89
+ }
90
+
91
+ return {
92
+ ...device,
93
+ properties: {
94
+ ...device.properties,
95
+ available_climate_presets: [
96
+ preset,
97
+ ...(device.properties.available_climate_presets ?? []),
98
+ ],
99
+ },
100
+ }
101
+ }
@@ -0,0 +1,84 @@
1
+ import type {
2
+ SeamHttpApiError,
3
+ ThermostatsDeleteClimatePresetBody,
4
+ } from '@seamapi/http/connect'
5
+ import {
6
+ useMutation,
7
+ type UseMutationResult,
8
+ useQueryClient,
9
+ } from '@tanstack/react-query'
10
+
11
+ import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js'
12
+ import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js'
13
+
14
+ export type UseDeleteThermostatClimatePresetParams = never
15
+
16
+ export type UseDeleteThermostatClimatePresetData = undefined
17
+
18
+ export type UseDeleteThermostatClimatePresetVariables =
19
+ ThermostatsDeleteClimatePresetBody
20
+
21
+ export function useDeleteThermostatClimatePreset(): UseMutationResult<
22
+ UseDeleteThermostatClimatePresetData,
23
+ SeamHttpApiError,
24
+ UseDeleteThermostatClimatePresetVariables
25
+ > {
26
+ const { client } = useSeamClient()
27
+ const queryClient = useQueryClient()
28
+
29
+ return useMutation<
30
+ UseDeleteThermostatClimatePresetData,
31
+ SeamHttpApiError,
32
+ UseDeleteThermostatClimatePresetVariables
33
+ >({
34
+ mutationFn: async (variables) => {
35
+ if (client === null) throw new NullSeamClientError()
36
+ await client.thermostats.deleteClimatePreset(variables)
37
+ },
38
+ onSuccess: (_data, variables) => {
39
+ queryClient.setQueryData<ThermostatDevice | null>(
40
+ ['devices', 'get', { device_id: variables.device_id }],
41
+ (device) => {
42
+ if (device == null) {
43
+ return
44
+ }
45
+
46
+ return getUpdatedDevice(device, variables)
47
+ }
48
+ )
49
+
50
+ queryClient.setQueryData<ThermostatDevice[]>(
51
+ ['devices', 'list', { device_id: variables.device_id }],
52
+ (devices): ThermostatDevice[] => {
53
+ if (devices == null) {
54
+ return []
55
+ }
56
+
57
+ return devices.map((device) => {
58
+ if (device.device_id === variables.device_id) {
59
+ return getUpdatedDevice(device, variables)
60
+ }
61
+
62
+ return device
63
+ })
64
+ }
65
+ )
66
+ },
67
+ })
68
+ }
69
+
70
+ function getUpdatedDevice(
71
+ device: ThermostatDevice,
72
+ variables: UseDeleteThermostatClimatePresetVariables
73
+ ): ThermostatDevice {
74
+ return {
75
+ ...device,
76
+ properties: {
77
+ ...device.properties,
78
+ available_climate_presets:
79
+ device.properties.available_climate_presets.filter((preset) => {
80
+ return preset.climate_preset_key !== variables.climate_preset_key
81
+ }),
82
+ },
83
+ }
84
+ }
@@ -0,0 +1,103 @@
1
+ import type {
2
+ SeamHttpApiError,
3
+ ThermostatsUpdateClimatePresetBody,
4
+ } from '@seamapi/http/connect'
5
+ import {
6
+ useMutation,
7
+ type UseMutationResult,
8
+ useQueryClient,
9
+ } from '@tanstack/react-query'
10
+
11
+ import type {
12
+ ThermostatClimatePreset,
13
+ ThermostatDevice,
14
+ } from 'lib/seam/thermostats/thermostat-device.js'
15
+ import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js'
16
+ import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js'
17
+
18
+ export type UseUpdateThermostatClimatePresetParams = never
19
+ export type UseUpdateThermostatClimatePresetData = undefined
20
+
21
+ export type UseUpdateThermostatClimatePresetVariables = Omit<
22
+ ThermostatsUpdateClimatePresetBody,
23
+ 'manual_override_allowed'
24
+ >
25
+
26
+ export function useUpdateThermostatClimatePreset(): UseMutationResult<
27
+ UseUpdateThermostatClimatePresetData,
28
+ SeamHttpApiError,
29
+ UseUpdateThermostatClimatePresetVariables
30
+ > {
31
+ const { client } = useSeamClient()
32
+ const queryClient = useQueryClient()
33
+
34
+ return useMutation<
35
+ UseUpdateThermostatClimatePresetData,
36
+ SeamHttpApiError,
37
+ UseUpdateThermostatClimatePresetVariables
38
+ >({
39
+ mutationFn: async (variables) => {
40
+ if (client === null) throw new NullSeamClientError()
41
+ await client.thermostats.createClimatePreset(variables)
42
+ },
43
+ onSuccess: (_data, variables) => {
44
+ queryClient.setQueryData<ThermostatDevice | null>(
45
+ ['devices', 'get', { device_id: variables.device_id }],
46
+ (device) => {
47
+ if (device == null) {
48
+ return
49
+ }
50
+
51
+ return getUpdatedDevice(device, variables)
52
+ }
53
+ )
54
+
55
+ queryClient.setQueryData<ThermostatDevice[]>(
56
+ ['devices', 'list', { device_id: variables.device_id }],
57
+ (devices): ThermostatDevice[] => {
58
+ if (devices == null) {
59
+ return []
60
+ }
61
+
62
+ return devices.map((device) => {
63
+ if (device.device_id === variables.device_id) {
64
+ return getUpdatedDevice(device, variables)
65
+ }
66
+
67
+ return device
68
+ })
69
+ }
70
+ )
71
+ },
72
+ })
73
+ }
74
+
75
+ function getUpdatedDevice(
76
+ device: ThermostatDevice,
77
+ variables: UseUpdateThermostatClimatePresetVariables
78
+ ): ThermostatDevice {
79
+ const preset: ThermostatClimatePreset = {
80
+ ...variables,
81
+ cooling_set_point_celsius: fahrenheitToCelsius(
82
+ variables.cooling_set_point_fahrenheit
83
+ ),
84
+ heating_set_point_celsius: fahrenheitToCelsius(
85
+ variables.heating_set_point_fahrenheit
86
+ ),
87
+ display_name: variables.name ?? variables.climate_preset_key,
88
+ can_delete: true,
89
+ can_edit: true,
90
+ manual_override_allowed: true,
91
+ }
92
+
93
+ return {
94
+ ...device,
95
+ properties: {
96
+ ...device.properties,
97
+ available_climate_presets: [
98
+ preset,
99
+ ...(device.properties.available_climate_presets ?? []),
100
+ ],
101
+ },
102
+ }
103
+ }
@@ -1,14 +1,17 @@
1
1
  import classNames from 'classnames'
2
2
  import type { MouseEventHandler, PropsWithChildren } from 'react'
3
3
 
4
+ import { Spinner } from 'lib/ui/Spinner/Spinner.js'
5
+
4
6
  interface ButtonProps extends PropsWithChildren {
5
- variant?: 'solid' | 'outline' | 'neutral'
7
+ variant?: 'solid' | 'outline' | 'neutral' | 'danger'
6
8
  size?: 'small' | 'medium' | 'large'
7
9
  type?: 'button' | 'submit'
8
10
  disabled?: boolean
9
11
  onClick?: MouseEventHandler<HTMLButtonElement>
10
12
  className?: string
11
13
  onMouseDown?: MouseEventHandler<HTMLButtonElement>
14
+ loading?: boolean
12
15
  }
13
16
 
14
17
  export function Button({
@@ -20,6 +23,7 @@ export function Button({
20
23
  className,
21
24
  onMouseDown,
22
25
  type = 'button',
26
+ loading = false,
23
27
  }: ButtonProps): JSX.Element {
24
28
  return (
25
29
  <button
@@ -27,15 +31,28 @@ export function Button({
27
31
  `seam-btn seam-btn-${variant} seam-btn-${size}`,
28
32
  {
29
33
  'seam-btn-disabled': disabled,
34
+ 'seam-btn-loading': loading,
30
35
  },
31
36
  className
32
37
  )}
33
38
  disabled={disabled}
34
- onClick={onClick}
39
+ onClick={(e) => {
40
+ if (loading || disabled) {
41
+ e.preventDefault()
42
+ return
43
+ }
44
+
45
+ onClick?.(e)
46
+ }}
35
47
  onMouseDown={onMouseDown}
36
48
  type={type}
37
49
  >
38
- {children}
50
+ <span className='seam-btn-content'>{children}</span>
51
+ {loading && (
52
+ <div className='seam-btn-loading'>
53
+ <Spinner size='small' />
54
+ </div>
55
+ )}
39
56
  </button>
40
57
  )
41
58
  }
@@ -1,9 +1,26 @@
1
1
  import classNames from 'classnames'
2
+ import type { Ref } from 'react'
2
3
 
3
4
  import type { ButtonProps } from 'lib/ui/types.js'
4
5
 
5
- export function IconButton({ className, ...props }: ButtonProps): JSX.Element {
6
+ export type IconProps = ButtonProps & {
7
+ elRef?: Ref<HTMLButtonElement>
8
+ }
9
+
10
+ export function IconButton({
11
+ className,
12
+ elRef,
13
+ ...props
14
+ }: IconProps): JSX.Element {
6
15
  return (
7
- <button {...props} className={classNames('seam-icon-btn', className)} />
16
+ <button
17
+ {...props}
18
+ ref={elRef}
19
+ className={classNames(
20
+ 'seam-icon-btn',
21
+ props.disabled === true && 'seam-icon-btn-disabled',
22
+ className
23
+ )}
24
+ />
8
25
  )
9
26
  }
@@ -0,0 +1,168 @@
1
+ import {
2
+ autoUpdate,
3
+ flip,
4
+ limitShift,
5
+ offset,
6
+ type ReferenceElement,
7
+ shift,
8
+ useFloating,
9
+ } from '@floating-ui/react'
10
+ import {
11
+ type ReactNode,
12
+ type Ref,
13
+ useCallback,
14
+ useEffect,
15
+ useImperativeHandle,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ } from 'react'
20
+ import { createPortal } from 'react-dom'
21
+
22
+ import { seamComponentsClassName } from 'lib/seam/SeamProvider.js'
23
+
24
+ export interface PopoverInstance {
25
+ show: () => void
26
+ hide: () => void
27
+ toggle: () => void
28
+ }
29
+
30
+ type PopoverChildren = (
31
+ params: {
32
+ setRef: (ref: HTMLElement | undefined | null) => void
33
+ } & PopoverInstance
34
+ ) => ReactNode
35
+
36
+ export interface PopoverProps {
37
+ children: PopoverChildren
38
+ content: ReactNode | ((instance: PopoverInstance) => ReactNode)
39
+ instanceRef?: Ref<PopoverInstance>
40
+ preventCloseOnClickOutside?: boolean
41
+ }
42
+
43
+ export function Popover(props: PopoverProps): JSX.Element {
44
+ const { children, content, instanceRef, preventCloseOnClickOutside } = props
45
+
46
+ const [open, setOpen] = useState(false)
47
+
48
+ const { refs, floatingStyles } = useFloating({
49
+ whileElementsMounted: autoUpdate,
50
+ transform: false,
51
+ open,
52
+ onOpenChange: setOpen,
53
+ placement: 'bottom',
54
+ middleware: [
55
+ shift({
56
+ crossAxis: true,
57
+ limiter: limitShift(),
58
+ }),
59
+ flip(),
60
+ offset(5),
61
+ ],
62
+ })
63
+
64
+ const referenceEl = useRef<HTMLElement | null>()
65
+ const floatingEl = useRef<HTMLElement | null>()
66
+
67
+ const setFLoating = useCallback(
68
+ (ref: HTMLElement | null): void => {
69
+ refs.setFloating(ref)
70
+ floatingEl.current = ref
71
+ },
72
+ [refs, floatingEl]
73
+ )
74
+
75
+ const toggle = useCallback(() => {
76
+ setOpen((value) => !value)
77
+ }, [])
78
+
79
+ const instance = useMemo(
80
+ () => ({
81
+ show: () => {
82
+ setOpen(true)
83
+ },
84
+ hide: () => {
85
+ setOpen(false)
86
+ },
87
+ toggle,
88
+ }),
89
+ [toggle]
90
+ )
91
+
92
+ const setReference = useCallback(
93
+ (ref: ReferenceElement | undefined | null): void => {
94
+ if (!(ref instanceof HTMLElement) || referenceEl.current === ref) return
95
+
96
+ if (referenceEl.current != null) {
97
+ referenceEl.current.removeEventListener('click', toggle)
98
+ }
99
+
100
+ refs.setReference(ref)
101
+ ref.addEventListener('click', toggle)
102
+ referenceEl.current = ref
103
+ },
104
+ [toggle, refs]
105
+ )
106
+
107
+ useImperativeHandle(instanceRef, () => instance)
108
+
109
+ /**
110
+ * Closes the popover when the user clicks outside of it.
111
+ */
112
+ const windowClickHandler = useCallback((e: MouseEvent): void => {
113
+ const target = e.target as HTMLElement
114
+
115
+ // If the target is the reference element, do nothing.
116
+ if (
117
+ referenceEl.current === target ||
118
+ referenceEl.current?.contains(target) === true
119
+ ) {
120
+ return
121
+ }
122
+
123
+ const closest = target.closest('[data-seam-popover]')
124
+
125
+ // Prevents closing if target is floating element, also adds support for nested popovers somehow :)
126
+ if (
127
+ closest != null &&
128
+ referenceEl.current != null &&
129
+ !closest.contains(referenceEl.current)
130
+ ) {
131
+ return
132
+ }
133
+
134
+ setOpen(false)
135
+ }, [])
136
+
137
+ useEffect(() => {
138
+ setTimeout(() => {
139
+ if (preventCloseOnClickOutside === false) return
140
+
141
+ globalThis.addEventListener('click', windowClickHandler)
142
+ }, 0)
143
+
144
+ return () => {
145
+ globalThis.removeEventListener('click', windowClickHandler)
146
+ }
147
+ }, [windowClickHandler, preventCloseOnClickOutside])
148
+
149
+ return (
150
+ <>
151
+ {children({ setRef: setReference, ...instance })}
152
+ {open &&
153
+ createPortal(
154
+ <div
155
+ className={seamComponentsClassName}
156
+ data-seam-popover=''
157
+ ref={setFLoating}
158
+ style={floatingStyles}
159
+ >
160
+ <div className='seam-popover'>
161
+ {typeof content === 'function' ? content(instance) : content}
162
+ </div>
163
+ </div>,
164
+ globalThis.document.body
165
+ )}
166
+ </>
167
+ )
168
+ }