@purestack/ts-components 1.1.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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -0
  3. package/dist/ts-components.d.mts +1662 -0
  4. package/dist/ts-components.d.mts.map +1 -0
  5. package/dist/ts-components.mjs +9200 -0
  6. package/dist/ts-components.mjs.map +1 -0
  7. package/package.json +130 -0
  8. package/src/defineComponents.ts +87 -0
  9. package/src/index.ts +270 -0
  10. package/src/registerStyles.ts +63 -0
  11. package/src/standard/alertBox/alertBox.ts +64 -0
  12. package/src/standard/autoId.ts +25 -0
  13. package/src/standard/badge/badge.ts +41 -0
  14. package/src/standard/badge/badgeStyle.ts +21 -0
  15. package/src/standard/barChart/barChart.ts +338 -0
  16. package/src/standard/barChart/barChartStyle.ts +81 -0
  17. package/src/standard/btn/btn.ts +191 -0
  18. package/src/standard/btn/btnStyle.ts +59 -0
  19. package/src/standard/btnGroup/btnGroup.ts +208 -0
  20. package/src/standard/btnGroup/btnGroupStyle.ts +85 -0
  21. package/src/standard/chart/cartesianChart.ts +99 -0
  22. package/src/standard/chart/chartDefaults.ts +25 -0
  23. package/src/standard/chart/chartUtils.ts +84 -0
  24. package/src/standard/classicLogo/classicLogo.ts +429 -0
  25. package/src/standard/classicLogo/classicLogoStyle.ts +292 -0
  26. package/src/standard/componentVariant.ts +153 -0
  27. package/src/standard/composer/composer.ts +1035 -0
  28. package/src/standard/composer/composerCanvasInitialStyle.ts +225 -0
  29. package/src/standard/composer/composerCanvasRevertStyle.ts +14 -0
  30. package/src/standard/composer/composerCanvasSharedStyle.ts +60 -0
  31. package/src/standard/composer/composerCanvasStyleTypes.ts +8 -0
  32. package/src/standard/composer/composerCanvasThemeStyle.ts +53 -0
  33. package/src/standard/composer/composerStyle.ts +156 -0
  34. package/src/standard/composer/emailHtmlPolicy.ts +474 -0
  35. package/src/standard/consent/consent.ts +173 -0
  36. package/src/standard/consent/consentStyle.ts +44 -0
  37. package/src/standard/contactForm/contactForm.ts +135 -0
  38. package/src/standard/contactForm/contactFormStyle.ts +116 -0
  39. package/src/standard/doughnutChart/doughnutChart.ts +527 -0
  40. package/src/standard/doughnutChart/doughnutChartStyle.ts +64 -0
  41. package/src/standard/dropFiles/dropFiles.ts +480 -0
  42. package/src/standard/dropFiles/dropFilesStyle.ts +171 -0
  43. package/src/standard/expandablePanel/expandablePanel.ts +106 -0
  44. package/src/standard/expandablePanel/expandablePanelStyle.ts +56 -0
  45. package/src/standard/flex/flex.ts +269 -0
  46. package/src/standard/flex/flexStyle.ts +556 -0
  47. package/src/standard/footer/footer.ts +93 -0
  48. package/src/standard/footer/footerStyle.ts +39 -0
  49. package/src/standard/form/autoCompleteInput.ts +567 -0
  50. package/src/standard/form/form.ts +205 -0
  51. package/src/standard/form/formCheck.ts +96 -0
  52. package/src/standard/form/formInputField.ts +184 -0
  53. package/src/standard/form/formSelectField.ts +152 -0
  54. package/src/standard/form/formStyle.ts +585 -0
  55. package/src/standard/form/multiAutoCompleteInput.ts +880 -0
  56. package/src/standard/grid/grid.ts +177 -0
  57. package/src/standard/grid/gridStyle.ts +56 -0
  58. package/src/standard/icon/icon.ts +96 -0
  59. package/src/standard/icon/iconStyle.ts +50 -0
  60. package/src/standard/landing/codeShowcase.ts +54 -0
  61. package/src/standard/landing/comparisonColumn.ts +30 -0
  62. package/src/standard/landing/comparisonFeature.ts +22 -0
  63. package/src/standard/landing/comparisonTable.ts +38 -0
  64. package/src/standard/landing/ctaSection.ts +59 -0
  65. package/src/standard/landing/featureCard.ts +41 -0
  66. package/src/standard/landing/landing.ts +52 -0
  67. package/src/standard/landing/landingBand.ts +217 -0
  68. package/src/standard/landing/landingSection.ts +43 -0
  69. package/src/standard/landing/landingTypes.ts +141 -0
  70. package/src/standard/landing/metricItem.ts +25 -0
  71. package/src/standard/landing/metricStrip.ts +20 -0
  72. package/src/standard/lineChart/lineChart.ts +462 -0
  73. package/src/standard/lineChart/lineChartStyle.ts +91 -0
  74. package/src/standard/logo/logo.ts +192 -0
  75. package/src/standard/logo/logoStyle.ts +164 -0
  76. package/src/standard/modal/modal.ts +171 -0
  77. package/src/standard/modal/modalStore.ts +154 -0
  78. package/src/standard/modal/modalStyle.ts +190 -0
  79. package/src/standard/navMenu/navMenu.ts +278 -0
  80. package/src/standard/navMenu/navMenuStyle.ts +323 -0
  81. package/src/standard/pageLinks/pageLinks.ts +90 -0
  82. package/src/standard/pageScript/pageScript.ts +155 -0
  83. package/src/standard/pageToc/pageToc.ts +154 -0
  84. package/src/standard/pageToc/pageTocStyle.ts +372 -0
  85. package/src/standard/panel/panel.ts +55 -0
  86. package/src/standard/panel/panelStyle.ts +15 -0
  87. package/src/standard/pricing/pricing.ts +146 -0
  88. package/src/standard/searchBox/searchBox.ts +54 -0
  89. package/src/standard/searchBox/searchBoxStyle.ts +180 -0
  90. package/src/standard/sectionHeader/sectionHeader.ts +99 -0
  91. package/src/standard/signIn/signIn.ts +167 -0
  92. package/src/standard/signIn/signInStyle.ts +70 -0
  93. package/src/standard/tabs/tabs.ts +193 -0
  94. package/src/standard/tabs/tabsStyle.ts +231 -0
  95. package/src/standard/themeToggle/themeToggle.ts +33 -0
  96. package/src/standard/themeToggle/themeToggleStyle.ts +88 -0
  97. package/src/standard/toast/toastHost.ts +42 -0
  98. package/src/standard/toast/toastStore.ts +50 -0
  99. package/src/standard/topBar/topBar.ts +101 -0
  100. package/src/standard/topBar/topBarStyle.ts +169 -0
  101. package/src/standard/virtualList/variableVirtualTable.ts +371 -0
  102. package/src/standard/virtualList/virtualList.ts +423 -0
  103. package/src/standard/virtualList/virtualListStyle.ts +34 -0
  104. package/src/standard/virtualList/virtualTable.ts +179 -0
  105. package/src/standard/virtualList/virtualTableStyle.ts +24 -0
  106. package/tsconfig.json +23 -0
@@ -0,0 +1,192 @@
1
+ import {
2
+ type LogoConfig,
3
+ type TsSsgContext,
4
+ tryResolveTsSsgContext,
5
+ } from '@purestack/ts-common'
6
+ import {
7
+ getSemanticToneClass,
8
+ normalizeThemeVariableReference,
9
+ } from '@purestack/ts-style'
10
+ import {
11
+ type ComputedRef,
12
+ computed,
13
+ defineComponent,
14
+ flatten,
15
+ html,
16
+ type RefOrValue,
17
+ unref,
18
+ } from 'regor'
19
+
20
+ type ReactiveLogoOptions = {
21
+ [K in keyof LogoConfig]?: RefOrValue<LogoConfig[K]>
22
+ }
23
+
24
+ export interface SiteLogo extends ReactiveLogoOptions {
25
+ config?: RefOrValue<Partial<LogoConfig>>
26
+ options?: ComputedRef<LogoConfig>
27
+ classes?: ComputedRef<string>
28
+ logoStyle?: ComputedRef<Record<string, string>>
29
+ resolvedHref?: ComputedRef<string | undefined>
30
+ resolvedImage?: ComputedRef<string | undefined>
31
+ resolvedDarkImage?: ComputedRef<string | undefined>
32
+ accessibleName?: ComputedRef<string>
33
+ initials?: ComputedRef<string>
34
+ }
35
+
36
+ const logoTemplate = html`<a class="site-logo" :class="classes" :style="logoStyle"
37
+ :href="resolvedHref" :role="resolvedHref ? undefined : 'img'" :aria-label="accessibleName">
38
+ <span class="site-logo__mark" r-if="options.layout !== 'wordmark'" aria-hidden="true">
39
+ <slot name="mark">
40
+ <span class="site-logo__images" r-if="resolvedImage">
41
+ <img class="site-logo__image" :class="{ 'site-logo__image--light': resolvedDarkImage }" :src="resolvedImage" alt="" decoding="async"/>
42
+ <img class="site-logo__image site-logo__image--dark" r-if="resolvedDarkImage" :src="resolvedDarkImage" alt="" decoding="async"/>
43
+ </span>
44
+ <Icon r-else-if="options.icon" :name="options.icon"/>
45
+ <span class="site-logo__monogram" r-else>{{ initials }}</span>
46
+ </slot>
47
+ </span>
48
+ <span class="site-logo__copy" r-if="options.layout !== 'mark'" aria-hidden="true">
49
+ <span class="site-logo__name"><span class="site-logo__brand">{{ options.brand }}</span><span class="site-logo__suffix" r-if="options.suffix">{{ options.suffix }}</span></span>
50
+ <span class="site-logo__subtitle" r-if="options.subtitle">{{ options.subtitle }}</span>
51
+ </span>
52
+ </a>`
53
+
54
+ const logoProps = [
55
+ 'brand',
56
+ 'subtitle',
57
+ 'suffix',
58
+ 'href',
59
+ 'ariaLabel',
60
+ 'icon',
61
+ 'imageSrc',
62
+ 'imageSrcDark',
63
+ 'monogram',
64
+ 'layout',
65
+ 'size',
66
+ 'appearance',
67
+ 'markStyle',
68
+ 'wordmarkStyle',
69
+ 'shape',
70
+ 'tone',
71
+ 'brandColor',
72
+ 'accentColor',
73
+ 'markBackground',
74
+ 'markColor',
75
+ 'brandSize',
76
+ 'subtitleSize',
77
+ 'markSize',
78
+ 'gap',
79
+ ] as const satisfies readonly (keyof LogoConfig)[]
80
+
81
+ function resolveSiteLogo(props: SiteLogo, context?: TsSsgContext): SiteLogo {
82
+ const options = computed<LogoConfig>(() => {
83
+ const config = flatten(unref(props.config) ?? {}) as Partial<LogoConfig>
84
+ const read = <K extends keyof LogoConfig>(
85
+ key: K,
86
+ ): LogoConfig[K] | undefined => {
87
+ const value = unref(props[key]) as LogoConfig[K] | undefined
88
+ return value === undefined ? config[key] : value
89
+ }
90
+ return {
91
+ brand: read('brand') ?? '',
92
+ subtitle: read('subtitle'),
93
+ suffix: read('suffix'),
94
+ href: read('href'),
95
+ ariaLabel: read('ariaLabel'),
96
+ icon: read('icon'),
97
+ imageSrc: read('imageSrc'),
98
+ imageSrcDark: read('imageSrcDark'),
99
+ monogram: read('monogram'),
100
+ layout: read('layout') ?? 'horizontal',
101
+ size: read('size') ?? 'md',
102
+ appearance: read('appearance') ?? 'plain',
103
+ markStyle: read('markStyle') ?? 'plain',
104
+ wordmarkStyle: read('wordmarkStyle') ?? 'plain',
105
+ shape: read('shape') ?? 'rounded',
106
+ tone: read('tone') ?? 'accent',
107
+ brandColor: read('brandColor'),
108
+ accentColor: read('accentColor'),
109
+ markBackground: read('markBackground'),
110
+ markColor: read('markColor'),
111
+ brandSize: read('brandSize'),
112
+ subtitleSize: read('subtitleSize'),
113
+ markSize: read('markSize'),
114
+ gap: read('gap'),
115
+ }
116
+ })
117
+ const resolveUrl = (value: string | undefined) =>
118
+ value ? (context?.resolvePublicHref(value) ?? value) : undefined
119
+ return {
120
+ options,
121
+ classes: computed(() => {
122
+ const logo = options()
123
+ return [
124
+ `site-logo--${logo.layout}`,
125
+ `site-logo--${logo.size}`,
126
+ `site-logo--${logo.appearance}`,
127
+ `site-logo--mark-${logo.markStyle}`,
128
+ `site-logo--wordmark-${logo.wordmarkStyle}`,
129
+ `site-logo--${logo.shape}`,
130
+ getSemanticToneClass(logo.tone),
131
+ ].join(' ')
132
+ }),
133
+ logoStyle: computed(() => {
134
+ const logo = options()
135
+ const style: Record<string, string> = {}
136
+ const colors = {
137
+ '--ps-logo-brand-color': logo.brandColor,
138
+ '--ps-logo-accent-color': logo.accentColor,
139
+ '--ps-logo-mark-background': logo.markBackground,
140
+ '--ps-logo-mark-color': logo.markColor,
141
+ }
142
+ for (const [key, value] of Object.entries(colors)) {
143
+ if (value?.trim())
144
+ style[key] = normalizeThemeVariableReference(value.trim())
145
+ }
146
+ const lengths = {
147
+ '--ps-logo-brand-size': logo.brandSize,
148
+ '--ps-logo-subtitle-size': logo.subtitleSize,
149
+ '--ps-logo-mark-size': logo.markSize,
150
+ '--ps-logo-gap': logo.gap,
151
+ }
152
+ for (const [key, value] of Object.entries(lengths)) {
153
+ if (value?.trim()) style[key] = value.trim()
154
+ }
155
+ return style
156
+ }),
157
+ resolvedHref: computed(() => {
158
+ const href = options().href
159
+ return href === null || href === '' ? undefined : resolveUrl(href ?? '/')
160
+ }),
161
+ resolvedImage: computed(() => resolveUrl(options().imageSrc)),
162
+ resolvedDarkImage: computed(() => resolveUrl(options().imageSrcDark)),
163
+ accessibleName: computed(
164
+ () =>
165
+ options().ariaLabel?.trim() ||
166
+ options().brand.trim() ||
167
+ options().monogram?.trim() ||
168
+ 'Home',
169
+ ),
170
+ initials: computed(() => {
171
+ const logo = options()
172
+ if (logo.monogram) return logo.monogram
173
+ return logo.brand
174
+ .trim()
175
+ .split(/\s+/)
176
+ .slice(0, 2)
177
+ .map((word) => Array.from(word)[0] ?? '')
178
+ .join('')
179
+ .toLocaleUpperCase()
180
+ }),
181
+ }
182
+ }
183
+
184
+ export function defineLogoComponents() {
185
+ return {
186
+ siteLogo: defineComponent<SiteLogo>(logoTemplate, {
187
+ props: ['config', ...logoProps],
188
+ context: (head) =>
189
+ resolveSiteLogo(head.props, tryResolveTsSsgContext(head)),
190
+ }),
191
+ }
192
+ }
@@ -0,0 +1,164 @@
1
+ import { styleBuilder, themes } from '@purestack/ts-style'
2
+
3
+ export function registerLogoStyles() {
4
+ themes.forEach((theme, palette) => {
5
+ const neutral = palette.semanticTone.neutral
6
+ const accent = `var(--ps-logo-accent-color, ${palette.current.text.default})`
7
+ const brand = `var(--ps-logo-brand-color, ${neutral.text.default})`
8
+ styleBuilder.select('.site-logo', theme).css({
9
+ display: 'inline-flex',
10
+ alignItems: 'center',
11
+ gap: 'var(--ps-logo-gap, 0.375rem)',
12
+ width: 'fit-content',
13
+ maxWidth: '100%',
14
+ minWidth: '0',
15
+ verticalAlign: 'middle',
16
+ textDecoration: 'none',
17
+ color: brand,
18
+ lineHeight: '1',
19
+ borderRadius: palette.radii.md,
20
+ '--ps-logo-size-brand': 'clamp(1.25rem, 3vw, 1.5rem)',
21
+ '--ps-logo-size-mark': 'clamp(1.625rem, 4vw, 2rem)',
22
+ '--ps-logo-size-subtitle': '0.65rem',
23
+ })
24
+ styleBuilder.select('.site-logo--sm', theme).css({
25
+ '--ps-logo-size-brand': '1.25rem',
26
+ '--ps-logo-size-mark': '1.625rem',
27
+ '--ps-logo-size-subtitle': '0.6rem',
28
+ })
29
+ styleBuilder.select('.site-logo--lg', theme).css({
30
+ '--ps-logo-size-brand': '2rem',
31
+ '--ps-logo-size-mark': '2.75rem',
32
+ '--ps-logo-size-subtitle': '0.75rem',
33
+ })
34
+ styleBuilder.select('.site-logo--xl', theme).css({
35
+ '--ps-logo-size-brand': '3rem',
36
+ '--ps-logo-size-mark': '4rem',
37
+ '--ps-logo-size-subtitle': '0.875rem',
38
+ })
39
+ styleBuilder.select('.site-logo--badge, .site-logo--outline', theme).css({
40
+ padding: '0.75rem 1rem',
41
+ border: `1px solid ${neutral.border.subtle}`,
42
+ })
43
+ styleBuilder
44
+ .select('.site-logo--badge', theme)
45
+ .css({ background: neutral.surfaceAlt.rest.background })
46
+ styleBuilder.select('.site-logo--stacked', theme).css({
47
+ flexDirection: 'column',
48
+ textAlign: 'center',
49
+ gap: 'var(--ps-logo-gap, 0.5rem)',
50
+ })
51
+ styleBuilder
52
+ .select('.site-logo--stacked .site-logo__copy', theme)
53
+ .alignItems('center')
54
+ styleBuilder.select('.site-logo__mark', theme).css({
55
+ display: 'inline-grid',
56
+ placeItems: 'center',
57
+ flexShrink: '0',
58
+ width: 'var(--ps-logo-mark-size, var(--ps-logo-size-mark))',
59
+ height: 'var(--ps-logo-mark-size, var(--ps-logo-size-mark))',
60
+ borderRadius: palette.radii.md,
61
+ boxSizing: 'border-box',
62
+ overflow: 'hidden',
63
+ color: `var(--ps-logo-mark-color, ${accent})`,
64
+ background: 'var(--ps-logo-mark-background, transparent)',
65
+ })
66
+ styleBuilder
67
+ .select(
68
+ '.site-logo .site-logo__mark > .icon, .site-logo__mark > svg',
69
+ theme,
70
+ )
71
+ .css({ width: '100%', height: '100%' })
72
+ styleBuilder.select('.site-logo--mark-soft .site-logo__mark', theme).css({
73
+ padding: '0.2em',
74
+ background: `var(--ps-logo-mark-background, ${palette.current.surface.rest.background})`,
75
+ })
76
+ styleBuilder.select('.site-logo--mark-solid .site-logo__mark', theme).css({
77
+ padding: '0.2em',
78
+ background: `var(--ps-logo-mark-background, ${palette.current.button.rest.background})`,
79
+ color: `var(--ps-logo-mark-color, ${palette.current.button.rest.text})`,
80
+ })
81
+ styleBuilder
82
+ .select('.site-logo--mark-outline .site-logo__mark', theme)
83
+ .css({ padding: '0.2em', border: `1px solid ${accent}` })
84
+ styleBuilder
85
+ .select('.site-logo--square .site-logo__mark', theme)
86
+ .borderRadius('0')
87
+ styleBuilder
88
+ .select('.site-logo--circle .site-logo__mark', theme)
89
+ .borderRadius('50%')
90
+ styleBuilder.select('.site-logo__images, .site-logo__image', theme).css({
91
+ display: 'block',
92
+ width: '100%',
93
+ height: '100%',
94
+ objectFit: 'contain',
95
+ })
96
+ styleBuilder
97
+ .select('.site-logo__image--dark', theme)
98
+ .display(theme === 'dark' ? 'block' : 'none')
99
+ styleBuilder
100
+ .select('.site-logo__image--light', theme)
101
+ .display(theme === 'dark' ? 'none' : 'block')
102
+ styleBuilder.select('.site-logo__monogram', theme).css({
103
+ fontSize:
104
+ 'calc(var(--ps-logo-mark-size, var(--ps-logo-size-mark)) * 0.45)',
105
+ fontWeight: '750',
106
+ letterSpacing: '-0.04em',
107
+ })
108
+ styleBuilder.select('.site-logo__copy', theme).css({
109
+ display: 'flex',
110
+ flexDirection: 'column',
111
+ alignItems: 'flex-start',
112
+ minWidth: '0',
113
+ gap: '0.35rem',
114
+ })
115
+ styleBuilder.select('.site-logo__name', theme).css({
116
+ fontSize: 'var(--ps-logo-brand-size, var(--ps-logo-size-brand))',
117
+ fontWeight: '700',
118
+ letterSpacing: '-0.045em',
119
+ lineHeight: '1.05',
120
+ maxWidth: '100%',
121
+ whiteSpace: 'nowrap',
122
+ overflow: 'hidden',
123
+ textOverflow: 'ellipsis',
124
+ color: brand,
125
+ })
126
+ styleBuilder.select('.site-logo__brand', theme).color(brand)
127
+ styleBuilder.select('.site-logo__suffix', theme).color(accent)
128
+ styleBuilder
129
+ .select('.site-logo--wordmark-accent .site-logo__brand', theme)
130
+ .color(accent)
131
+ styleBuilder
132
+ .select('.site-logo--wordmark-gradient .site-logo__brand', theme)
133
+ .css({
134
+ backgroundImage: `linear-gradient(115deg, ${brand}, ${accent})`,
135
+ backgroundClip: 'text',
136
+ '-webkit-background-clip': 'text',
137
+ color: 'transparent',
138
+ })
139
+ styleBuilder.select('.site-logo__subtitle', theme).css({
140
+ fontSize: 'var(--ps-logo-subtitle-size, var(--ps-logo-size-subtitle))',
141
+ color: neutral.text.subtle,
142
+ lineHeight: '1.4',
143
+ letterSpacing: '0.06em',
144
+ maxWidth: '100%',
145
+ whiteSpace: 'nowrap',
146
+ overflow: 'hidden',
147
+ textOverflow: 'ellipsis',
148
+ })
149
+ styleBuilder
150
+ .select('.site-logo[href]', theme)
151
+ .css({ cursor: 'pointer', transition: 'opacity 160ms ease' })
152
+ styleBuilder
153
+ .select('.site-logo[href]:hover', theme)
154
+ .css({ textDecoration: 'none', opacity: '0.85' })
155
+ styleBuilder.select('.site-logo[href]:focus-visible', theme).css({
156
+ outline: `2px solid ${palette.current.border.focus}`,
157
+ outlineOffset: '5px',
158
+ })
159
+ styleBuilder
160
+ .select('.site-logo--wordmark-gradient .site-logo__brand', theme)
161
+ .media('(forced-colors: active)')
162
+ .css({ backgroundImage: 'none', color: 'LinkText' })
163
+ })
164
+ }
@@ -0,0 +1,171 @@
1
+ import { tryResolveTsSsgContext } from '@purestack/ts-common'
2
+ import type { SemanticTone } from '@purestack/ts-style'
3
+ import {
4
+ type ComponentHead,
5
+ type ComputedRef,
6
+ computed,
7
+ defineComponent,
8
+ type Emits,
9
+ html,
10
+ type RefOrValue,
11
+ unref,
12
+ } from 'regor'
13
+ import {
14
+ type ComponentVariant,
15
+ type ComponentVariantMode,
16
+ resolveComponentClasses,
17
+ } from '../componentVariant'
18
+ import { defineModalStoreComponent } from './modalStore'
19
+
20
+ export type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
21
+ export type ModalSlideFrom = 'none' | 'top' | 'right' | 'bottom' | 'left'
22
+
23
+ const DEFAULT_MODAL_VARIANT_MODE: ComponentVariantMode = 'stateless'
24
+
25
+ export interface Modal {
26
+ id?: string
27
+ title?: RefOrValue<string>
28
+ tone?: RefOrValue<SemanticTone>
29
+ variant?: RefOrValue<ComponentVariant>
30
+ variantMode?: RefOrValue<ComponentVariantMode>
31
+ size?: RefOrValue<ModalSize>
32
+ fade?: RefOrValue<boolean>
33
+ slideFrom?: RefOrValue<ModalSlideFrom>
34
+ showClose?: RefOrValue<boolean>
35
+ titleId?: string
36
+ rootClass?: ComputedRef<string>
37
+ classes?: ComputedRef<string>
38
+ emits?: Emits<'close' | 'cancel'>
39
+ }
40
+
41
+ export interface ModalTrigger {
42
+ target?: string
43
+ label?: RefOrValue<string>
44
+ tone?: RefOrValue<SemanticTone>
45
+ variant?: RefOrValue<ComponentVariant>
46
+ }
47
+
48
+ const modalTemplate = html`<dialog
49
+ class="modal"
50
+ :class="rootClass"
51
+ :id="id"
52
+ :data-modal-id="id"
53
+ data-modal-root
54
+ role="dialog"
55
+ aria-modal="true"
56
+ :aria-labelledby="titleId"
57
+ >
58
+ <slot name="content">
59
+ <article class="modal__panel" role="document" tabindex="-1">
60
+ <div class="modal__surface" :class="classes">
61
+ <header class="modal__header">
62
+ <slot name="header">
63
+ <h2 class="modal__title tone-text" :id="titleId" r-if="title">
64
+ {{ title }}
65
+ </h2>
66
+ </slot>
67
+ <Btn
68
+ r-if="showClose"
69
+ :variant="variant"
70
+ size="sm"
71
+ data-modal-close
72
+ aria-label="Close dialog"
73
+ >
74
+ <span aria-hidden="true">X</span>
75
+ </Btn>
76
+ </header>
77
+ <div class="modal__body">
78
+ <slot name="body">
79
+ <slot></slot>
80
+ </slot>
81
+ </div>
82
+ <footer class="modal__footer"><slot name="footer"></slot></footer>
83
+ </div>
84
+ </article>
85
+ </slot>
86
+ </dialog>`
87
+
88
+ const modalTriggerTemplate = html`<Btn
89
+ :tone="tone"
90
+ :variant="variant"
91
+ :data-modal-target="target"
92
+ data-modal-trigger
93
+ >
94
+ {{ label }}
95
+ </Btn>`
96
+
97
+ function defineModalComponent() {
98
+ return defineComponent<Modal>(modalTemplate, {
99
+ props: [
100
+ 'id',
101
+ 'title',
102
+ 'tone',
103
+ 'variant',
104
+ 'variantMode',
105
+ 'size',
106
+ 'fade',
107
+ 'slideFrom',
108
+ 'showClose',
109
+ ],
110
+ context: (head) => {
111
+ markModalRuntimeEmbed(head)
112
+ return resolveModal(head.props)
113
+ },
114
+ })
115
+ }
116
+
117
+ function defineModalTriggerComponent() {
118
+ return defineComponent<ModalTrigger>(modalTriggerTemplate, {
119
+ props: ['target', 'label', 'tone', 'variant'],
120
+ context: (head) => resolveModalTrigger(head.props),
121
+ })
122
+ }
123
+
124
+ export function defineModalComponents() {
125
+ return {
126
+ modal: defineModalComponent(),
127
+ modalTrigger: defineModalTriggerComponent(),
128
+ ...defineModalStoreComponent(),
129
+ }
130
+ }
131
+
132
+ function resolveModal(props: Modal): Modal {
133
+ return {
134
+ ...props,
135
+ titleId: `${unref(props.id)}-title`,
136
+ classes: computed(() =>
137
+ resolveComponentClasses(props, {
138
+ defaultVariant: 'surface',
139
+ defaultVariantMode: DEFAULT_MODAL_VARIANT_MODE,
140
+ }),
141
+ ),
142
+ rootClass: computed(() => resolveModalRootClass(props)),
143
+ }
144
+ }
145
+
146
+ function resolveModalTrigger(props: ModalTrigger): ModalTrigger {
147
+ return {
148
+ target: props.target,
149
+ label: props.label,
150
+ tone: props.tone,
151
+ variant: props.variant,
152
+ }
153
+ }
154
+
155
+ function resolveModalRootClass(props: Modal) {
156
+ const size = unref(props.size) || 'md'
157
+ const slideFrom = unref(props.slideFrom) || 'none'
158
+ const fade = unref(props.fade) ?? true
159
+ return [
160
+ `modal--size-${size}`,
161
+ fade ? 'modal--fade' : '',
162
+ slideFrom !== 'none' ? `modal--slide-${slideFrom}` : '',
163
+ fade || slideFrom !== 'none' ? 'modal--animated' : '',
164
+ ]
165
+ .filter(Boolean)
166
+ .join(' ')
167
+ }
168
+
169
+ function markModalRuntimeEmbed(head: ComponentHead<Modal>) {
170
+ tryResolveTsSsgContext(head)?.recordRuntimeEmbed('modal', 'head')
171
+ }
@@ -0,0 +1,154 @@
1
+ import { Base } from '@purestack/ts-util'
2
+ import { defineComponent, html, type RefOrValue, ref, sref, unref } from 'regor'
3
+
4
+ import type { Btn } from '../btn/btn'
5
+ import type { Modal } from './modal'
6
+
7
+ let nextModalId = 1
8
+ let modalStoreSingleton: ModalStore | undefined
9
+
10
+ export interface ModalActionContract extends Btn {
11
+ id?: string
12
+ label?: RefOrValue<string>
13
+ closeOnClick?: RefOrValue<boolean>
14
+ onClick?: (modal: ModalItem, store: ModalStore) => void | Promise<void>
15
+ }
16
+
17
+ export interface ModalContract extends Modal {
18
+ body?: RefOrValue<string>
19
+ note?: RefOrValue<string>
20
+ actions?: ModalActionContract[]
21
+ }
22
+
23
+ class ModalAction extends Base<ModalActionContract>() {
24
+ override readonly id: string
25
+ readonly running = ref(false)
26
+ constructor(opts: ModalActionContract) {
27
+ super(opts)
28
+ this.id = opts.id ?? `action-${nextModalId++}`
29
+ }
30
+ }
31
+ class ModalItem extends Base<ModalContract>() {
32
+ override readonly id: string
33
+ constructor(opts: ModalContract) {
34
+ super(opts)
35
+ this.id = opts.id || `modal-store-${nextModalId}`
36
+ nextModalId += 1
37
+ Object.assign(this, opts)
38
+ this.actions = opts.actions?.map?.((x) => new ModalAction(x))
39
+ }
40
+ }
41
+
42
+ export class ModalStore {
43
+ readonly modals = sref<ModalItem[]>([])
44
+
45
+ openModal = (options: ModalContract) => {
46
+ const modal = this.createModal(options)
47
+ const id = modal.id
48
+
49
+ setTimeout(() => {
50
+ window.tsSsgModal?.refresh()
51
+ window.tsSsgModal?.open(id)
52
+ }, 1)
53
+ return modal
54
+ }
55
+
56
+ closeModal = (id: string) => {
57
+ window.tsSsgModal?.close(id)
58
+ }
59
+
60
+ private findModal = (id: string) => {
61
+ return this.modals().find((modal) => modal.id === id)
62
+ }
63
+
64
+ runAction = (modal: ModalItem, action: ModalAction) => {
65
+ void this.runActionAsync(modal, action)
66
+ }
67
+
68
+ removeModal = (id: string) => {
69
+ this.modals(this.modals().filter((modal) => modal.id !== id))
70
+ }
71
+
72
+ private createModal(options: ModalContract) {
73
+ if (options.id && this.findModal(options.id)) {
74
+ this.closeModal(options.id)
75
+ }
76
+
77
+ const created = new ModalItem(options)
78
+ this.modals([...this.modals(), created])
79
+ return created
80
+ }
81
+
82
+ private async runActionAsync(modal: ModalItem, action: ModalAction) {
83
+ modal = unref(modal)
84
+ if (action.running()) return
85
+
86
+ action.running(true)
87
+ try {
88
+ await action.onClick?.(modal, this)
89
+ if ((unref(action.closeOnClick) ?? true) === true) {
90
+ this.closeModal(modal.id)
91
+ }
92
+ } catch (err) {
93
+ console.warn(err)
94
+ } finally {
95
+ action.running(false)
96
+ }
97
+ }
98
+ }
99
+
100
+ function ensureModalStore() {
101
+ if (!modalStoreSingleton) {
102
+ modalStoreSingleton = new ModalStore()
103
+ }
104
+ return modalStoreSingleton
105
+ }
106
+
107
+ export function useModalStore() {
108
+ if (!modalStoreSingleton) {
109
+ throw new Error(
110
+ 'ModalStore is not mounted. Render <ModalStore> once first.',
111
+ )
112
+ }
113
+ return modalStoreSingleton
114
+ }
115
+
116
+ const modalStoreTemplate = html`<template r-for="modal in modals">
117
+ <Modal
118
+ :id="modal.id"
119
+ :title="modal.title"
120
+ :tone="modal.tone"
121
+ :variant="modal.variant"
122
+ :variantMode="modal.variantMode"
123
+ :size="modal.size"
124
+ :fade="modal.fade"
125
+ :slideFrom="modal.slideFrom"
126
+ :showClose="modal.showClose"
127
+ @close="removeModal(modal.id)"
128
+ >
129
+ <p class="modal-store__body" r-if="modal.body">{{ modal.body }}</p>
130
+ <p class="modal-store__note" r-if="modal.note">{{ modal.note }}</p>
131
+ <template name="footer">
132
+ <Flex r-if="modal.actions.length > 0">
133
+ <Btn
134
+ r-for="action in modal.actions"
135
+ :tone="action.tone"
136
+ :variant="action.variant"
137
+ :icon="action.icon"
138
+ :disabled="action.disabled"
139
+ @click="runAction(modal, action)"
140
+ >
141
+ {{ action.label }}
142
+ </Btn>
143
+ </Flex>
144
+ </template>
145
+ </Modal>
146
+ </template>`
147
+
148
+ export function defineModalStoreComponent() {
149
+ return {
150
+ modalStore: defineComponent<ModalStore>(modalStoreTemplate, {
151
+ context: () => ensureModalStore(),
152
+ }),
153
+ }
154
+ }