@woodsportal/hubspot-kit 1.0.4 → 1.0.5
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.
- package/dist/cli.js +24 -1
- package/dist/cli.js.map +1 -1
- package/package.json +4 -1
- package/src/dev/bootstrap-greenfield.tsx +51 -0
- package/src/dev/bootstrap.tsx +46 -0
- package/src/dev-module-config/__tests__/fixtures/fields.json +3014 -0
- package/src/dev-module-config/__tests__/fixtures/golden-module.fixture.ts +102 -0
- package/src/dev-module-config/__tests__/module-config-storage.test.ts +40 -0
- package/src/dev-module-config/__tests__/module-config-transform.test.ts +55 -0
- package/src/dev-module-config/__tests__/normalize-repeater-items.test.ts +46 -0
- package/src/dev-module-config/runtime/module-config-bridge.ts +27 -0
- package/src/dev-module-config/runtime/module-config-runtime.ts +185 -0
- package/src/dev-module-config/schema/build-default-item.ts +7 -0
- package/src/dev-module-config/schema/deep-merge.ts +27 -0
- package/src/dev-module-config/schema/extract-defaults.ts +62 -0
- package/src/dev-module-config/schema/normalize-choices.ts +18 -0
- package/src/dev-module-config/schema/normalize-repeater-items.ts +31 -0
- package/src/dev-module-config/schema/parse-fields-json.ts +2 -0
- package/src/dev-module-config/schema/types.ts +75 -0
- package/src/dev-module-config/schema/validate-module-config.ts +81 -0
- package/src/dev-module-config/schema/visibility.ts +130 -0
- package/src/dev-module-config/storage/config-json.ts +46 -0
- package/src/dev-module-config/storage/workspace-store.ts +139 -0
- package/src/dev-module-config/transform/build-hubspot-data.ts +409 -0
- package/src/dev-module-config/ui/FieldRenderer.tsx +353 -0
- package/src/dev-module-config/ui/ModuleConfigAnimatedPresence.tsx +72 -0
- package/src/dev-module-config/ui/ModuleConfigCollapsible.tsx +43 -0
- package/src/dev-module-config/ui/ModuleConfigDockSideIcon.tsx +27 -0
- package/src/dev-module-config/ui/ModuleConfigOverlay.tsx +134 -0
- package/src/dev-module-config/ui/ModuleConfigPersistenceStatus.tsx +89 -0
- package/src/dev-module-config/ui/ModuleConfigShell.tsx +354 -0
- package/src/dev-module-config/ui/ModuleConfigThemeToggle.tsx +32 -0
- package/src/dev-module-config/ui/ModuleConfigUiContext.tsx +65 -0
- package/src/dev-module-config/ui/editors/DeveloperOptionsEditor.tsx +61 -0
- package/src/dev-module-config/ui/editors/MenuOptionsEditor.tsx +103 -0
- package/src/dev-module-config/ui/hubspot/HsColorField.tsx +106 -0
- package/src/dev-module-config/ui/hubspot/HsColorPickerPopover.tsx +210 -0
- package/src/dev-module-config/ui/hubspot/HsFieldLabel.tsx +24 -0
- package/src/dev-module-config/ui/hubspot/HsFieldWidgets.tsx +248 -0
- package/src/dev-module-config/ui/hubspot/HsImageField.tsx +112 -0
- package/src/dev-module-config/ui/hubspot/HsOccurrenceGroupEditor.tsx +243 -0
- package/src/dev-module-config/ui/hubspot/HsRepeaterList.tsx +178 -0
- package/src/dev-module-config/ui/hubspot/HsSectionHeader.tsx +20 -0
- package/src/dev-module-config/ui/hubspot/HsToggle.tsx +39 -0
- package/src/dev-module-config/ui/hubspot/HsToggleRow.tsx +30 -0
- package/src/dev-module-config/ui/hubspot/hs-color-palette.ts +11 -0
- package/src/dev-module-config/ui/hubspot/module-config-typography.ts +26 -0
- package/src/dev-module-config/ui/module-config-motion.css +134 -0
- package/src/dev-module-config/ui/module-config-motion.ts +28 -0
- package/src/dev-module-config/ui/module-config-overlay-side.ts +29 -0
- package/src/dev-module-config/ui/module-config-surfaces.ts +27 -0
- package/src/dev-module-config/ui/module-config-theme.css +483 -0
- package/src/dev-module-config/ui/module-config-toolbar-theme.ts +35 -0
- package/src/dev-module-config/ui/navigation/ModuleConfigNavContext.tsx +188 -0
- package/src/dev-module-config/ui/use-dev-module-config-live-revision.ts +22 -0
- package/src/dev-module-config/ui/use-hubspot-live-exports.ts +27 -0
- package/src/dev-module-config/ui/use-module-config.ts +124 -0
- package/src/routing/build-api-routes.ts +37 -0
- package/src/routing/route-format.ts +19 -0
- package/templates/module-hybrid-cdn/index.html +12 -1
- package/templates/module-hybrid-cdn/package.json +1 -1
- package/templates/module-monolith/index.html +12 -0
- package/templates/module-monolith/package.json +1 -1
- package/templates/theme-starter/package.json +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { getNestedValue } from '../../runtime/module-config-runtime'
|
|
3
|
+
import { isFieldVisible } from '../../schema/visibility'
|
|
4
|
+
import { HsOccurrenceGroupEditor } from '../hubspot/HsOccurrenceGroupEditor'
|
|
5
|
+
import { HsToggleRow } from '../hubspot/HsToggleRow'
|
|
6
|
+
import { hsTypography } from '../hubspot/module-config-typography'
|
|
7
|
+
import { ModuleConfigAnimatedPresence } from '../ModuleConfigAnimatedPresence'
|
|
8
|
+
import { ModuleConfigCollapsible } from '../ModuleConfigCollapsible'
|
|
9
|
+
import type { HubSpotFieldDefinition, ModuleFieldValues } from '../../schema/types'
|
|
10
|
+
|
|
11
|
+
type MenuOptionsEditorProps = {
|
|
12
|
+
field: HubSpotFieldDefinition
|
|
13
|
+
moduleValues: ModuleFieldValues
|
|
14
|
+
onChange: (path: string, value: unknown) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function MenuOptionsEditor({ field, moduleValues, onChange }: MenuOptionsEditorProps) {
|
|
18
|
+
const [open, setOpen] = useState(false)
|
|
19
|
+
const menuPath = field.name
|
|
20
|
+
const menuValues = (getNestedValue(moduleValues, menuPath) as ModuleFieldValues) ?? {}
|
|
21
|
+
const children = field.children ?? []
|
|
22
|
+
|
|
23
|
+
const standardToggle = children.find((c) => c.name === 'hubspot_defined_object')
|
|
24
|
+
const objectsField = children.find((c) => c.name === 'objects')
|
|
25
|
+
const customToggle = children.find((c) => c.name === 'hubspot_custom_object')
|
|
26
|
+
const customObjectsField = children.find((c) => c.name === 'hubspot_custom_objects')
|
|
27
|
+
|
|
28
|
+
const standardEnabled = Boolean(menuValues.hubspot_defined_object)
|
|
29
|
+
const customEnabled = Boolean(menuValues.hubspot_custom_object)
|
|
30
|
+
const objects = Array.isArray(menuValues.objects) ? (menuValues.objects as Array<ModuleFieldValues>) : []
|
|
31
|
+
const customObjects = Array.isArray(menuValues.hubspot_custom_objects)
|
|
32
|
+
? (menuValues.hubspot_custom_objects as Array<ModuleFieldValues>)
|
|
33
|
+
: []
|
|
34
|
+
|
|
35
|
+
const setMenuValue = (key: string, value: unknown) => {
|
|
36
|
+
onChange(`${menuPath}.${key}`, value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<ModuleConfigCollapsible
|
|
41
|
+
open={open}
|
|
42
|
+
onToggle={() => setOpen((value) => !value)}
|
|
43
|
+
label={field.label}
|
|
44
|
+
headerClassName={hsTypography.groupHeader}
|
|
45
|
+
>
|
|
46
|
+
{standardToggle ? (
|
|
47
|
+
<HsToggleRow
|
|
48
|
+
label={standardToggle.label}
|
|
49
|
+
helpText={standardToggle.help_text}
|
|
50
|
+
required={standardToggle.required}
|
|
51
|
+
checked={standardEnabled}
|
|
52
|
+
onChange={(checked) => setMenuValue('hubspot_defined_object', checked)}
|
|
53
|
+
/>
|
|
54
|
+
) : null}
|
|
55
|
+
|
|
56
|
+
<ModuleConfigAnimatedPresence
|
|
57
|
+
show={Boolean(standardEnabled && objectsField && isFieldVisible(objectsField, moduleValues))}
|
|
58
|
+
enterFrom="bottom"
|
|
59
|
+
>
|
|
60
|
+
{objectsField ? (
|
|
61
|
+
<HsOccurrenceGroupEditor
|
|
62
|
+
field={objectsField}
|
|
63
|
+
items={objects}
|
|
64
|
+
moduleValues={moduleValues}
|
|
65
|
+
path={`${menuPath}.objects`}
|
|
66
|
+
onChange={onChange}
|
|
67
|
+
labelField="list_object_name"
|
|
68
|
+
sectionLabel="Menu Options"
|
|
69
|
+
visibilityContext={{ moduleValues }}
|
|
70
|
+
/>
|
|
71
|
+
) : null}
|
|
72
|
+
</ModuleConfigAnimatedPresence>
|
|
73
|
+
|
|
74
|
+
{customToggle ? (
|
|
75
|
+
<HsToggleRow
|
|
76
|
+
label={customToggle.label}
|
|
77
|
+
helpText={customToggle.help_text}
|
|
78
|
+
required={customToggle.required}
|
|
79
|
+
checked={customEnabled}
|
|
80
|
+
onChange={(checked) => setMenuValue('hubspot_custom_object', checked)}
|
|
81
|
+
/>
|
|
82
|
+
) : null}
|
|
83
|
+
|
|
84
|
+
<ModuleConfigAnimatedPresence
|
|
85
|
+
show={Boolean(customEnabled && customObjectsField && isFieldVisible(customObjectsField, moduleValues))}
|
|
86
|
+
enterFrom="bottom"
|
|
87
|
+
>
|
|
88
|
+
{customObjectsField ? (
|
|
89
|
+
<HsOccurrenceGroupEditor
|
|
90
|
+
field={customObjectsField}
|
|
91
|
+
items={customObjects}
|
|
92
|
+
moduleValues={moduleValues}
|
|
93
|
+
path={`${menuPath}.hubspot_custom_objects`}
|
|
94
|
+
onChange={onChange}
|
|
95
|
+
labelField="list_object_name"
|
|
96
|
+
sectionLabel="Custom Object Menu"
|
|
97
|
+
visibilityContext={{ moduleValues }}
|
|
98
|
+
/>
|
|
99
|
+
) : null}
|
|
100
|
+
</ModuleConfigAnimatedPresence>
|
|
101
|
+
</ModuleConfigCollapsible>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { HsFieldLabel } from './HsFieldLabel'
|
|
3
|
+
import { hsTypography } from './module-config-typography'
|
|
4
|
+
import { HsColorPickerPopover } from './HsColorPickerPopover'
|
|
5
|
+
import { useModuleConfigUi } from '../ModuleConfigUiContext'
|
|
6
|
+
|
|
7
|
+
export type HsColorValue = {
|
|
8
|
+
color?: string
|
|
9
|
+
opacity?: number | string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type HsColorFieldProps = {
|
|
13
|
+
label: string
|
|
14
|
+
required?: boolean
|
|
15
|
+
helpText?: string
|
|
16
|
+
value: HsColorValue
|
|
17
|
+
onChange: (value: HsColorValue) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeHex(color: string | undefined): string {
|
|
21
|
+
const raw = (color ?? '').replace('#', '').trim()
|
|
22
|
+
if (/^[0-9a-fA-F]{6}$/.test(raw)) return `#${raw.toUpperCase()}`
|
|
23
|
+
if (/^[0-9a-fA-F]{3}$/.test(raw)) {
|
|
24
|
+
return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`.toUpperCase()
|
|
25
|
+
}
|
|
26
|
+
return '#000000'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function previewRgba(color: string | undefined, opacity: number | string | undefined): string {
|
|
30
|
+
const hex = normalizeHex(color)
|
|
31
|
+
const r = Number.parseInt(hex.slice(1, 3), 16)
|
|
32
|
+
const g = Number.parseInt(hex.slice(3, 5), 16)
|
|
33
|
+
const b = Number.parseInt(hex.slice(5, 7), 16)
|
|
34
|
+
const alpha = Math.min(100, Math.max(0, Number(opacity ?? 100))) / 100
|
|
35
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function HsColorField({ label, required, helpText, value, onChange }: HsColorFieldProps) {
|
|
39
|
+
const swatchRef = useRef<HTMLButtonElement>(null)
|
|
40
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
41
|
+
const { colorPickerPlacement, theme } = useModuleConfigUi()
|
|
42
|
+
const color = value?.color ?? ''
|
|
43
|
+
const opacity = value?.opacity ?? 100
|
|
44
|
+
const swatchColor = useMemo(() => previewRgba(color, opacity), [color, opacity])
|
|
45
|
+
|
|
46
|
+
const setHex = (nextRaw: string) => {
|
|
47
|
+
const cleaned = nextRaw.replace(/[^0-9a-fA-F]/g, '').slice(0, 6)
|
|
48
|
+
onChange({ ...value, color: cleaned ? `#${cleaned.toUpperCase()}` : '' })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const setOpacity = (next: number) => {
|
|
52
|
+
const clamped = Math.min(100, Math.max(0, Number.isFinite(next) ? next : 0))
|
|
53
|
+
onChange({ ...value, opacity: clamped })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const setColor = (nextHex: string) => {
|
|
57
|
+
onChange({ ...value, color: normalizeHex(nextHex) })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="mb-4">
|
|
62
|
+
<HsFieldLabel label={label} required={required} helpText={helpText} />
|
|
63
|
+
<div className="flex items-center gap-2">
|
|
64
|
+
<span className={hsTypography.helper}>#</span>
|
|
65
|
+
<input
|
|
66
|
+
className={`w-24 rounded px-2 py-2 uppercase mc-input ${hsTypography.input}`}
|
|
67
|
+
value={color.replace('#', '')}
|
|
68
|
+
onChange={(event) => setHex(event.target.value)}
|
|
69
|
+
spellCheck={false}
|
|
70
|
+
aria-label={`${label} hex value`}
|
|
71
|
+
/>
|
|
72
|
+
<input
|
|
73
|
+
type="number"
|
|
74
|
+
min={0}
|
|
75
|
+
max={100}
|
|
76
|
+
className={`w-16 rounded px-2 py-2 mc-input ${hsTypography.input}`}
|
|
77
|
+
value={opacity}
|
|
78
|
+
onChange={(event) => setOpacity(Number(event.target.value))}
|
|
79
|
+
aria-label={`${label} opacity`}
|
|
80
|
+
/>
|
|
81
|
+
<span className={hsTypography.helper}>%</span>
|
|
82
|
+
<div className="relative shrink-0">
|
|
83
|
+
<button
|
|
84
|
+
ref={swatchRef}
|
|
85
|
+
type="button"
|
|
86
|
+
className="relative h-8 w-8 rounded-full p-0 focus:outline-none"
|
|
87
|
+
style={{ backgroundColor: swatchColor }}
|
|
88
|
+
aria-label={`${label} color picker`}
|
|
89
|
+
aria-expanded={pickerOpen}
|
|
90
|
+
onClick={() => setPickerOpen((open) => !open)}
|
|
91
|
+
/>
|
|
92
|
+
{pickerOpen ? (
|
|
93
|
+
<HsColorPickerPopover
|
|
94
|
+
value={color}
|
|
95
|
+
onChange={setColor}
|
|
96
|
+
onClose={() => setPickerOpen(false)}
|
|
97
|
+
anchorRef={swatchRef}
|
|
98
|
+
preferredPlacement={colorPickerPlacement}
|
|
99
|
+
theme={theme}
|
|
100
|
+
/>
|
|
101
|
+
) : null}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { hsTypography } from './module-config-typography'
|
|
4
|
+
import { HUBSPOT_SIMPLE_COLOR_ROWS } from './hs-color-palette'
|
|
5
|
+
import { mcSurface } from '../module-config-surfaces'
|
|
6
|
+
import type { ColorPickerPlacement, ModuleConfigToolbarTheme } from '../ModuleConfigUiContext'
|
|
7
|
+
|
|
8
|
+
type HsColorPickerPopoverProps = {
|
|
9
|
+
value: string
|
|
10
|
+
onChange: (hex: string) => void
|
|
11
|
+
onClose: () => void
|
|
12
|
+
anchorRef: RefObject<HTMLButtonElement | null>
|
|
13
|
+
preferredPlacement?: ColorPickerPlacement
|
|
14
|
+
theme: ModuleConfigToolbarTheme
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type PickerTab = 'simple' | 'advanced'
|
|
18
|
+
|
|
19
|
+
type PopoverPosition = {
|
|
20
|
+
top: number
|
|
21
|
+
left: number
|
|
22
|
+
placement: 'left' | 'right'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const POPOVER_WIDTH = 252
|
|
26
|
+
const GAP = 10
|
|
27
|
+
const VIEWPORT_PADDING = 8
|
|
28
|
+
|
|
29
|
+
function normalizeHex(color: string): string {
|
|
30
|
+
const raw = color.replace('#', '').trim()
|
|
31
|
+
if (/^[0-9a-fA-F]{6}$/.test(raw)) return `#${raw.toUpperCase()}`
|
|
32
|
+
if (/^[0-9a-fA-F]{3}$/.test(raw)) {
|
|
33
|
+
return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`.toUpperCase()
|
|
34
|
+
}
|
|
35
|
+
return '#000000'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function computePopoverPosition(
|
|
39
|
+
anchor: DOMRect,
|
|
40
|
+
popoverWidth: number,
|
|
41
|
+
popoverHeight: number,
|
|
42
|
+
preferredPlacement: ColorPickerPlacement,
|
|
43
|
+
): PopoverPosition {
|
|
44
|
+
const spaceRight = window.innerWidth - anchor.right - GAP - VIEWPORT_PADDING
|
|
45
|
+
const spaceLeft = anchor.left - GAP - VIEWPORT_PADDING
|
|
46
|
+
const canLeft = spaceLeft >= popoverWidth
|
|
47
|
+
const canRight = spaceRight >= popoverWidth
|
|
48
|
+
|
|
49
|
+
let openRight: boolean
|
|
50
|
+
if (preferredPlacement === 'left') {
|
|
51
|
+
openRight = !canLeft && canRight
|
|
52
|
+
} else {
|
|
53
|
+
openRight = canRight || !canLeft
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let left = openRight ? anchor.right + GAP : anchor.left - GAP - popoverWidth
|
|
57
|
+
left = Math.max(
|
|
58
|
+
VIEWPORT_PADDING,
|
|
59
|
+
Math.min(left, window.innerWidth - popoverWidth - VIEWPORT_PADDING),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
let top = anchor.top + anchor.height / 2
|
|
63
|
+
const halfHeight = popoverHeight / 2
|
|
64
|
+
top = Math.max(
|
|
65
|
+
VIEWPORT_PADDING + halfHeight,
|
|
66
|
+
Math.min(top, window.innerHeight - VIEWPORT_PADDING - halfHeight),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
top,
|
|
71
|
+
left,
|
|
72
|
+
placement: openRight ? 'right' : 'left',
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function HsColorPickerPopover({
|
|
77
|
+
value,
|
|
78
|
+
onChange,
|
|
79
|
+
onClose,
|
|
80
|
+
anchorRef,
|
|
81
|
+
preferredPlacement = 'right',
|
|
82
|
+
theme,
|
|
83
|
+
}: HsColorPickerPopoverProps) {
|
|
84
|
+
const popoverRef = useRef<HTMLDivElement>(null)
|
|
85
|
+
const [tab, setTab] = useState<PickerTab>('simple')
|
|
86
|
+
const [position, setPosition] = useState<PopoverPosition | null>(null)
|
|
87
|
+
const selected = normalizeHex(value)
|
|
88
|
+
const panelId = useId()
|
|
89
|
+
|
|
90
|
+
const updatePosition = useCallback(() => {
|
|
91
|
+
const anchor = anchorRef.current
|
|
92
|
+
if (!anchor) return
|
|
93
|
+
|
|
94
|
+
const popover = popoverRef.current
|
|
95
|
+
const width = popover?.offsetWidth ?? POPOVER_WIDTH
|
|
96
|
+
const height = popover?.offsetHeight ?? (tab === 'simple' ? 280 : 180)
|
|
97
|
+
setPosition(
|
|
98
|
+
computePopoverPosition(anchor.getBoundingClientRect(), width, height, preferredPlacement),
|
|
99
|
+
)
|
|
100
|
+
}, [anchorRef, preferredPlacement, tab])
|
|
101
|
+
|
|
102
|
+
useLayoutEffect(() => {
|
|
103
|
+
updatePosition()
|
|
104
|
+
window.addEventListener('resize', updatePosition)
|
|
105
|
+
window.addEventListener('scroll', updatePosition, true)
|
|
106
|
+
return () => {
|
|
107
|
+
window.removeEventListener('resize', updatePosition)
|
|
108
|
+
window.removeEventListener('scroll', updatePosition, true)
|
|
109
|
+
}
|
|
110
|
+
}, [updatePosition])
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
updatePosition()
|
|
114
|
+
}, [tab, updatePosition])
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
118
|
+
const target = event.target as Node
|
|
119
|
+
if (popoverRef.current?.contains(target)) return
|
|
120
|
+
if (anchorRef.current?.contains(target)) return
|
|
121
|
+
onClose()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
125
|
+
if (event.key === 'Escape') onClose()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
document.addEventListener('mousedown', onPointerDown)
|
|
129
|
+
document.addEventListener('keydown', onKeyDown)
|
|
130
|
+
return () => {
|
|
131
|
+
document.removeEventListener('mousedown', onPointerDown)
|
|
132
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
133
|
+
}
|
|
134
|
+
}, [anchorRef, onClose])
|
|
135
|
+
|
|
136
|
+
if (!position) return null
|
|
137
|
+
|
|
138
|
+
return createPortal(
|
|
139
|
+
<div
|
|
140
|
+
ref={popoverRef}
|
|
141
|
+
id={panelId}
|
|
142
|
+
role="dialog"
|
|
143
|
+
aria-label="Color picker"
|
|
144
|
+
data-mc-theme={theme}
|
|
145
|
+
className="mc-modal-panel fixed z-[9999] w-[252px] -translate-y-1/2"
|
|
146
|
+
style={{ top: position.top, left: position.left }}
|
|
147
|
+
>
|
|
148
|
+
<div className={`${mcSurface.tabBar} !flex-shrink`}>
|
|
149
|
+
{(['simple', 'advanced'] as Array<PickerTab>).map((item) => (
|
|
150
|
+
<button
|
|
151
|
+
key={item}
|
|
152
|
+
type="button"
|
|
153
|
+
className={tab === item ? mcSurface.tabButtonActive : mcSurface.tabButton}
|
|
154
|
+
onClick={() => setTab(item)}
|
|
155
|
+
>
|
|
156
|
+
{item}
|
|
157
|
+
</button>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{tab === 'simple' ? (
|
|
162
|
+
<div className="p-2">
|
|
163
|
+
<div className="grid grid-cols-9 gap-1">
|
|
164
|
+
{HUBSPOT_SIMPLE_COLOR_ROWS.flat().map((swatch) => {
|
|
165
|
+
const isSelected = normalizeHex(swatch) === selected
|
|
166
|
+
return (
|
|
167
|
+
<button
|
|
168
|
+
key={swatch}
|
|
169
|
+
type="button"
|
|
170
|
+
title={swatch}
|
|
171
|
+
aria-label={swatch}
|
|
172
|
+
aria-pressed={isSelected}
|
|
173
|
+
className={`h-6 w-6 rounded-sm ${isSelected ? 'mc-color-swatch-selected' : ''}`}
|
|
174
|
+
style={{ backgroundColor: swatch }}
|
|
175
|
+
onClick={() => {
|
|
176
|
+
onChange(swatch)
|
|
177
|
+
onClose()
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
)
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
) : (
|
|
185
|
+
<div className="space-y-3 p-3">
|
|
186
|
+
<input
|
|
187
|
+
type="color"
|
|
188
|
+
className="h-10 w-full cursor-pointer rounded mc-input"
|
|
189
|
+
value={selected}
|
|
190
|
+
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
|
191
|
+
aria-label="Advanced color picker"
|
|
192
|
+
/>
|
|
193
|
+
<div className="flex items-center gap-2">
|
|
194
|
+
<span className={hsTypography.helper}>#</span>
|
|
195
|
+
<input
|
|
196
|
+
className={`flex-1 rounded px-2 py-1.5 uppercase mc-input ${hsTypography.input}`}
|
|
197
|
+
value={selected.replace('#', '')}
|
|
198
|
+
onChange={(event) => {
|
|
199
|
+
const cleaned = event.target.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6)
|
|
200
|
+
if (cleaned.length === 6) onChange(`#${cleaned.toUpperCase()}`)
|
|
201
|
+
}}
|
|
202
|
+
spellCheck={false}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>,
|
|
208
|
+
document.body,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { hsTypography, stripHtml } from './module-config-typography'
|
|
2
|
+
|
|
3
|
+
type HsFieldLabelProps = {
|
|
4
|
+
label: string
|
|
5
|
+
required?: boolean
|
|
6
|
+
helpText?: string
|
|
7
|
+
htmlFor?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function HsFieldLabel({ label, required, helpText, htmlFor }: HsFieldLabelProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="mb-1 flex items-center gap-1">
|
|
13
|
+
<label htmlFor={htmlFor} className={hsTypography.fieldLabel}>
|
|
14
|
+
{label}
|
|
15
|
+
{required ? <span className="text-[var(--mc-error-text)]"> *</span> : null}
|
|
16
|
+
</label>
|
|
17
|
+
{helpText ? (
|
|
18
|
+
<span title={stripHtml(helpText)} className={hsTypography.infoIcon}>
|
|
19
|
+
i
|
|
20
|
+
</span>
|
|
21
|
+
) : null}
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { HsFieldLabel } from './HsFieldLabel'
|
|
3
|
+
import { hsTypography } from './module-config-typography'
|
|
4
|
+
import { mcMotion } from '../module-config-motion'
|
|
5
|
+
import { mcSurface } from '../module-config-surfaces'
|
|
6
|
+
import { useModuleConfigUi } from '../ModuleConfigUiContext'
|
|
7
|
+
|
|
8
|
+
type HsLinkFieldProps = {
|
|
9
|
+
label: string
|
|
10
|
+
required?: boolean
|
|
11
|
+
helpText?: string
|
|
12
|
+
value: {
|
|
13
|
+
url?: { href?: string; type?: string }
|
|
14
|
+
open_in_new_tab?: boolean
|
|
15
|
+
no_follow?: boolean
|
|
16
|
+
}
|
|
17
|
+
onChange: (value: HsLinkFieldProps['value']) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function HsLinkField({ label, required, helpText, value, onChange }: HsLinkFieldProps) {
|
|
21
|
+
const href = value.url?.href ?? ''
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="mb-4">
|
|
25
|
+
<HsFieldLabel label={label} required={required} helpText={helpText} />
|
|
26
|
+
<input
|
|
27
|
+
className={`w-full rounded px-3 py-2 focus:outline-none ${hsTypography.input} mc-input ${mcMotion.fieldControl}`}
|
|
28
|
+
placeholder="https://…"
|
|
29
|
+
value={href}
|
|
30
|
+
onChange={(event) =>
|
|
31
|
+
onChange({
|
|
32
|
+
...value,
|
|
33
|
+
url: { href: event.target.value, type: value.url?.type ?? 'EXTERNAL' },
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
/>
|
|
37
|
+
<div className="mt-2 flex flex-col gap-2">
|
|
38
|
+
<label className={`flex items-center gap-2 ${hsTypography.body}`}>
|
|
39
|
+
<input
|
|
40
|
+
type="checkbox"
|
|
41
|
+
checked={Boolean(value.open_in_new_tab)}
|
|
42
|
+
onChange={(event) => onChange({ ...value, open_in_new_tab: event.target.checked })}
|
|
43
|
+
className="h-4 w-4 mc-checkbox"
|
|
44
|
+
/>
|
|
45
|
+
Open in new tab
|
|
46
|
+
</label>
|
|
47
|
+
<label className={`flex items-center gap-2 ${hsTypography.body}`}>
|
|
48
|
+
<input
|
|
49
|
+
type="checkbox"
|
|
50
|
+
checked={Boolean(value.no_follow)}
|
|
51
|
+
onChange={(event) => onChange({ ...value, no_follow: event.target.checked })}
|
|
52
|
+
className="h-4 w-4 mc-checkbox"
|
|
53
|
+
/>
|
|
54
|
+
No follow
|
|
55
|
+
</label>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type HsRichTextFieldProps = {
|
|
62
|
+
label: string
|
|
63
|
+
required?: boolean
|
|
64
|
+
helpText?: string
|
|
65
|
+
value: string
|
|
66
|
+
onChange: (value: string) => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function wrapSelection(value: string, before: string, after: string): string {
|
|
70
|
+
return `${before}${value}${after}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function HsRichTextField({ label, required, helpText, value, onChange }: HsRichTextFieldProps) {
|
|
74
|
+
const [expanded, setExpanded] = useState(false)
|
|
75
|
+
const [draft, setDraft] = useState(value)
|
|
76
|
+
const { theme } = useModuleConfigUi()
|
|
77
|
+
|
|
78
|
+
const toolbar = useMemo(
|
|
79
|
+
() => [
|
|
80
|
+
{ label: 'B', action: () => onChange(wrapSelection(value, '<strong>', '</strong>')) },
|
|
81
|
+
{ label: 'I', action: () => onChange(wrapSelection(value, '<em>', '</em>')) },
|
|
82
|
+
{ label: 'U', action: () => onChange(wrapSelection(value, '<u>', '</u>')) },
|
|
83
|
+
{
|
|
84
|
+
label: 'Link',
|
|
85
|
+
action: () => {
|
|
86
|
+
const url = window.prompt('Link URL')
|
|
87
|
+
if (!url) return
|
|
88
|
+
onChange(wrapSelection(value, `<a href="${url}">`, '</a>'))
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
[onChange, value],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="mb-4">
|
|
97
|
+
<HsFieldLabel label={label} required={required} helpText={helpText} />
|
|
98
|
+
<div className={`overflow-hidden ${mcSurface.panel}`}>
|
|
99
|
+
<div className="mc-richtext-toolbar flex flex-wrap items-center gap-1 px-2 py-1.5">
|
|
100
|
+
{toolbar.map((item) => (
|
|
101
|
+
<button
|
|
102
|
+
key={item.label}
|
|
103
|
+
type="button"
|
|
104
|
+
className={`rounded px-2 py-1 text-xs font-semibold ${mcSurface.text} ${mcSurface.hover}`}
|
|
105
|
+
onClick={item.action}
|
|
106
|
+
>
|
|
107
|
+
{item.label}
|
|
108
|
+
</button>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
<textarea
|
|
112
|
+
className={`min-h-[120px] w-full resize-y px-3 py-2 text-sm leading-relaxed ${mcSurface.text} bg-transparent focus:outline-none`}
|
|
113
|
+
value={value}
|
|
114
|
+
onChange={(event) => onChange(event.target.value)}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className={`mt-2 text-sm ${mcSurface.textMuted} hover:text-[var(--mc-text)]`}
|
|
120
|
+
onClick={() => {
|
|
121
|
+
setDraft(value)
|
|
122
|
+
setExpanded(true)
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
Edit in expanded view
|
|
126
|
+
</button>
|
|
127
|
+
|
|
128
|
+
{expanded ? (
|
|
129
|
+
<div
|
|
130
|
+
data-mc-theme={theme}
|
|
131
|
+
className="fixed inset-0 z-[10000] flex items-center justify-center bg-[var(--mc-modal-backdrop)] p-4"
|
|
132
|
+
>
|
|
133
|
+
<div className="mc-modal-panel flex h-[min(80vh,640px)] w-[min(960px,100%)] flex-col">
|
|
134
|
+
<div className="mc-modal-section px-4 py-3">
|
|
135
|
+
<h3 className={`text-lg ${mcSurface.text}`}>{label}</h3>
|
|
136
|
+
</div>
|
|
137
|
+
<textarea
|
|
138
|
+
className="flex-1 resize-none px-4 py-3 font-mono text-sm leading-relaxed mc-input bg-transparent focus:outline-none"
|
|
139
|
+
value={draft}
|
|
140
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
141
|
+
/>
|
|
142
|
+
<div className="mc-modal-section flex justify-end gap-2 px-4 py-3">
|
|
143
|
+
<button type="button" className={`${mcSurface.footerButton} px-4 py-2`} onClick={() => setExpanded(false)}>
|
|
144
|
+
Cancel
|
|
145
|
+
</button>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
className={`mc-btn-primary px-4 py-2 text-sm`}
|
|
149
|
+
onClick={() => {
|
|
150
|
+
onChange(draft)
|
|
151
|
+
setExpanded(false)
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
Apply
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
) : null}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type HsTextFieldProps = {
|
|
165
|
+
label: string
|
|
166
|
+
required?: boolean
|
|
167
|
+
helpText?: string
|
|
168
|
+
value: string
|
|
169
|
+
onChange: (value: string) => void
|
|
170
|
+
multiline?: boolean
|
|
171
|
+
monospace?: boolean
|
|
172
|
+
darkEditor?: boolean
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function HsTextField({
|
|
176
|
+
label,
|
|
177
|
+
required,
|
|
178
|
+
helpText,
|
|
179
|
+
value,
|
|
180
|
+
onChange,
|
|
181
|
+
multiline = false,
|
|
182
|
+
monospace = false,
|
|
183
|
+
darkEditor = false,
|
|
184
|
+
}: HsTextFieldProps) {
|
|
185
|
+
const [dark, setDark] = useState(false)
|
|
186
|
+
const inputClass = `w-full rounded px-3 py-2 text-sm focus:outline-none mc-input ${mcMotion.fieldControl} ${
|
|
187
|
+
monospace ? 'font-mono text-xs leading-relaxed' : ''
|
|
188
|
+
} ${dark ? 'bg-[var(--mc-bg-hard)] text-[var(--mc-text-muted)]' : mcSurface.text}`
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className="mb-4">
|
|
192
|
+
<HsFieldLabel label={label} required={required} helpText={helpText} />
|
|
193
|
+
{multiline ? (
|
|
194
|
+
<textarea
|
|
195
|
+
className={`${inputClass} min-h-[120px]`}
|
|
196
|
+
value={value}
|
|
197
|
+
onChange={(event) => onChange(event.target.value)}
|
|
198
|
+
/>
|
|
199
|
+
) : (
|
|
200
|
+
<input className={inputClass} value={value} onChange={(event) => onChange(event.target.value)} />
|
|
201
|
+
)}
|
|
202
|
+
{darkEditor ? (
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
className={`mt-2 text-sm ${mcSurface.textMuted} hover:text-[var(--mc-text)]`}
|
|
206
|
+
onClick={() => setDark((current) => !current)}
|
|
207
|
+
>
|
|
208
|
+
{dark ? 'Turn off dark editor' : 'Turn on dark editor'}
|
|
209
|
+
</button>
|
|
210
|
+
) : null}
|
|
211
|
+
{label.toLowerCase().includes('svg') ? (
|
|
212
|
+
<p className={`mt-1 text-xs ${mcSurface.textMuted}`}>
|
|
213
|
+
You can search SVG{' '}
|
|
214
|
+
<a href="https://icons.hubspot.com/" target="_blank" rel="noreferrer" className={`${mcSurface.textMuted} hover:text-[var(--mc-text)]`}>
|
|
215
|
+
Here
|
|
216
|
+
</a>{' '}
|
|
217
|
+
and copy only the SVG start to end and paste it here.
|
|
218
|
+
</p>
|
|
219
|
+
) : null}
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function HsNumberField({
|
|
225
|
+
label,
|
|
226
|
+
required,
|
|
227
|
+
helpText,
|
|
228
|
+
value,
|
|
229
|
+
onChange,
|
|
230
|
+
}: {
|
|
231
|
+
label: string
|
|
232
|
+
required?: boolean
|
|
233
|
+
helpText?: string
|
|
234
|
+
value: number | string
|
|
235
|
+
onChange: (value: number) => void
|
|
236
|
+
}) {
|
|
237
|
+
return (
|
|
238
|
+
<div className="mb-4">
|
|
239
|
+
<HsFieldLabel label={label} required={required} helpText={helpText} />
|
|
240
|
+
<input
|
|
241
|
+
type="number"
|
|
242
|
+
className={`w-full rounded px-3 py-2 focus:outline-none ${hsTypography.input} mc-input ${mcMotion.fieldControl}`}
|
|
243
|
+
value={value}
|
|
244
|
+
onChange={(event) => onChange(Number(event.target.value))}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|