@torch-ui/solid 0.1.3

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 (118) hide show
  1. package/README.md +166 -0
  2. package/package.json +67 -0
  3. package/src/components/actions/Button.tsx +612 -0
  4. package/src/components/actions/ButtonGroup.tsx +728 -0
  5. package/src/components/actions/Copy.tsx +98 -0
  6. package/src/components/actions/DarkModeToggle.tsx +80 -0
  7. package/src/components/actions/Link.tsx +37 -0
  8. package/src/components/actions/index.ts +19 -0
  9. package/src/components/actions/useCopyToClipboard.ts +90 -0
  10. package/src/components/charts/Chart.tsx +331 -0
  11. package/src/components/charts/Sparkline.tsx +156 -0
  12. package/src/components/charts/index.ts +13 -0
  13. package/src/components/data-display/Avatar.tsx +208 -0
  14. package/src/components/data-display/AvatarGroup.tsx +228 -0
  15. package/src/components/data-display/Badge.tsx +70 -0
  16. package/src/components/data-display/Carousel.tsx +214 -0
  17. package/src/components/data-display/ColorSwatch.tsx +56 -0
  18. package/src/components/data-display/DataTable.tsx +886 -0
  19. package/src/components/data-display/EmptyState.tsx +61 -0
  20. package/src/components/data-display/Image.tsx +277 -0
  21. package/src/components/data-display/Kbd.tsx +114 -0
  22. package/src/components/data-display/Persona.tsx +78 -0
  23. package/src/components/data-display/StatCard.tsx +338 -0
  24. package/src/components/data-display/Table.tsx +147 -0
  25. package/src/components/data-display/Tag.tsx +91 -0
  26. package/src/components/data-display/Timeline.tsx +200 -0
  27. package/src/components/data-display/TreeView.tsx +172 -0
  28. package/src/components/data-display/Video.tsx +95 -0
  29. package/src/components/data-display/avatar-utils.ts +32 -0
  30. package/src/components/data-display/index.ts +81 -0
  31. package/src/components/feedback/Loading.tsx +159 -0
  32. package/src/components/feedback/Progress.tsx +321 -0
  33. package/src/components/feedback/Skeleton.tsx +62 -0
  34. package/src/components/feedback/SkeletonBlocks.tsx +222 -0
  35. package/src/components/feedback/Toast.tsx +648 -0
  36. package/src/components/feedback/index.ts +44 -0
  37. package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
  38. package/src/components/feedback/password/password-strength.ts +115 -0
  39. package/src/components/feedback/password/password-validation-data.ts +66 -0
  40. package/src/components/feedback/password/password-validation.ts +93 -0
  41. package/src/components/forms/Autocomplete.tsx +268 -0
  42. package/src/components/forms/Checkbox.tsx +155 -0
  43. package/src/components/forms/CodeInput.tsx +237 -0
  44. package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
  45. package/src/components/forms/ColorPicker/color-utils.ts +75 -0
  46. package/src/components/forms/ColorPicker/index.ts +2 -0
  47. package/src/components/forms/DatePicker.tsx +516 -0
  48. package/src/components/forms/DateRangePicker.tsx +464 -0
  49. package/src/components/forms/FieldPicker.tsx +64 -0
  50. package/src/components/forms/FileUpload.tsx +614 -0
  51. package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
  52. package/src/components/forms/FilterBuilder.tsx +16 -0
  53. package/src/components/forms/FilterRuleRow.tsx +68 -0
  54. package/src/components/forms/Input.tsx +200 -0
  55. package/src/components/forms/MultiSelect.tsx +361 -0
  56. package/src/components/forms/NumberField.tsx +145 -0
  57. package/src/components/forms/RadioGroup.tsx +135 -0
  58. package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
  59. package/src/components/forms/ReorderableList.tsx +163 -0
  60. package/src/components/forms/Select.tsx +268 -0
  61. package/src/components/forms/Slider.tsx +260 -0
  62. package/src/components/forms/Switch.tsx +135 -0
  63. package/src/components/forms/TextArea.tsx +202 -0
  64. package/src/components/forms/ViewCustomizer.tsx +44 -0
  65. package/src/components/forms/index.ts +43 -0
  66. package/src/components/layout/Accordion.tsx +110 -0
  67. package/src/components/layout/Alert.tsx +156 -0
  68. package/src/components/layout/BlockQuote.tsx +70 -0
  69. package/src/components/layout/Card.tsx +166 -0
  70. package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
  71. package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
  72. package/src/components/layout/CodeBlock/prism.ts +81 -0
  73. package/src/components/layout/Collapsible.tsx +84 -0
  74. package/src/components/layout/Container.tsx +55 -0
  75. package/src/components/layout/Divider.tsx +64 -0
  76. package/src/components/layout/Form.tsx +39 -0
  77. package/src/components/layout/FormActions.tsx +50 -0
  78. package/src/components/layout/Grid.tsx +53 -0
  79. package/src/components/layout/PageHeading.tsx +46 -0
  80. package/src/components/layout/PromptWithAction.tsx +49 -0
  81. package/src/components/layout/Section.tsx +60 -0
  82. package/src/components/layout/TablePanel.tsx +24 -0
  83. package/src/components/layout/TableView/TableView.tsx +1018 -0
  84. package/src/components/layout/TableView/index.ts +3 -0
  85. package/src/components/layout/TableView/types.ts +51 -0
  86. package/src/components/layout/WizardStep.tsx +40 -0
  87. package/src/components/layout/WizardStepper.tsx +173 -0
  88. package/src/components/layout/index.ts +96 -0
  89. package/src/components/navigation/Breadcrumbs.tsx +66 -0
  90. package/src/components/navigation/DropdownMenu.tsx +86 -0
  91. package/src/components/navigation/MegaMenu.tsx +480 -0
  92. package/src/components/navigation/NavigationMenu.tsx +305 -0
  93. package/src/components/navigation/Pagination.tsx +298 -0
  94. package/src/components/navigation/Sidebar.tsx +280 -0
  95. package/src/components/navigation/Tabs.tsx +122 -0
  96. package/src/components/navigation/ViewSwitcher.tsx +314 -0
  97. package/src/components/navigation/index.ts +66 -0
  98. package/src/components/overlays/AlertDialog.tsx +174 -0
  99. package/src/components/overlays/ContextMenu.tsx +65 -0
  100. package/src/components/overlays/Dialog.tsx +279 -0
  101. package/src/components/overlays/Drawer.tsx +370 -0
  102. package/src/components/overlays/HoverCard.tsx +107 -0
  103. package/src/components/overlays/Popover.tsx +73 -0
  104. package/src/components/overlays/Tooltip.tsx +31 -0
  105. package/src/components/overlays/index.ts +71 -0
  106. package/src/components/typography/Code.tsx +72 -0
  107. package/src/components/typography/Icon.tsx +36 -0
  108. package/src/components/typography/index.ts +10 -0
  109. package/src/env.d.ts +9 -0
  110. package/src/index.ts +13 -0
  111. package/src/styles/theme.css +226 -0
  112. package/src/types/avatar-types.ts +11 -0
  113. package/src/types/filter-types.ts +35 -0
  114. package/src/utilities/classNames.ts +6 -0
  115. package/src/utilities/componentSize.ts +46 -0
  116. package/src/utilities/i18n.tsx +60 -0
  117. package/src/utilities/mergeRefs.ts +12 -0
  118. package/src/utilities/relativeDateDefault.ts +14 -0
@@ -0,0 +1,314 @@
1
+ import { type JSX, For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, splitProps } from 'solid-js'
2
+ import { Pin } from 'lucide-solid'
3
+ import { cn } from '../../utilities/classNames'
4
+ import { DropdownMenuContent, DropdownMenuItem } from './DropdownMenu'
5
+ import { DropdownMenu as KobalteDropdownMenu } from '@kobalte/core/dropdown-menu'
6
+
7
+ export type ViewScope = 'user' | 'tenant'
8
+
9
+ export interface ViewSwitcherItem {
10
+ id: string
11
+ label: string
12
+ count?: number
13
+ scope?: ViewScope
14
+ pinned?: boolean
15
+ }
16
+
17
+ export interface ViewSwitcherProps {
18
+ views: ViewSwitcherItem[]
19
+ activeId: string
20
+ onChange: (id: string) => void
21
+ onAdd?: () => void
22
+ addIcon?: JSX.Element
23
+ maxVisible?: number
24
+ moreLabel?: string
25
+ variant?: 'standalone' | 'embedded'
26
+ /** Accessible label for the view switcher group. Default: "Views". */
27
+ ariaLabel?: string
28
+ class?: string
29
+ }
30
+
31
+ const DIVIDER_WIDTH = 1
32
+ const ESTIMATED_TAB_WIDTH = 80
33
+ const OVERFLOW_TRIGGER_WIDTH = 100
34
+ const ADD_BUTTON_WIDTH = 40
35
+
36
+ /** View switcher with overflow dropdown (e.g. table views). Single use case: switch between views with optional count and overflow. */
37
+ export function ViewSwitcher(props: ViewSwitcherProps) {
38
+ const [local] = splitProps(props, [
39
+ 'views', 'activeId', 'onChange', 'onAdd', 'addIcon',
40
+ 'maxVisible', 'moreLabel', 'variant', 'ariaLabel', 'class',
41
+ ])
42
+
43
+ const [dynamicMax, setDynamicMax] = createSignal<number | null>(null)
44
+ let containerRef: HTMLDivElement | undefined
45
+ // Component lifetime: persists for the duration the component is mounted.
46
+ const widthCache = new Map<string, number>()
47
+ let lastContainerWidth = 0
48
+
49
+ const isEmbedded = () => local.variant === 'embedded'
50
+ const dividerWidth = () => (isEmbedded() ? DIVIDER_WIDTH : 0)
51
+
52
+ const maxVisible = () => {
53
+ if (local.maxVisible != null) return Math.max(local.maxVisible, 1)
54
+ const d = dynamicMax()
55
+ return d != null ? Math.max(d, 1) : local.views.length
56
+ }
57
+
58
+ // Pinned-first order for measurement (no active swap to avoid dynamicMax feedback loop).
59
+ const measureViews = createMemo(() => {
60
+ const pinned = local.views.filter((v) => v.pinned)
61
+ const unpinned = local.views.filter((v) => !v.pinned)
62
+ return [...pinned, ...unpinned]
63
+ })
64
+
65
+ // Sort pinned views first, then ensure the active view is visible.
66
+ // Measurement uses measureViews (pinned-first, no swap) to avoid dynamicMax feedback loop.
67
+ const renderViews = createMemo(() => {
68
+ const sorted = measureViews()
69
+ const max = maxVisible()
70
+ if (max <= 0) return sorted
71
+ const activeIdx = sorted.findIndex((v) => v.id === local.activeId)
72
+ if (activeIdx < 0 || activeIdx < max) return sorted
73
+ const result = [...sorted]
74
+ ;[result[max - 1], result[activeIdx]] = [result[activeIdx], result[max - 1]]
75
+ return result
76
+ })
77
+ const visibleAndOverflow = createMemo(() => {
78
+ const all = renderViews()
79
+ const max = Math.min(maxVisible(), all.length)
80
+ return { visible: all.slice(0, max), overflow: all.slice(max) }
81
+ })
82
+ const visibleViews = createMemo(() => visibleAndOverflow().visible)
83
+ const overflowViews = createMemo(() => visibleAndOverflow().overflow)
84
+
85
+ /** Count how many views fit in the available width using cached widths. */
86
+ const fitCount = (views: ViewSwitcherItem[], available: number, reserved: number) => {
87
+ const divW = dividerWidth()
88
+ let total = 0
89
+ let count = 0
90
+ for (const view of views) {
91
+ const w = widthCache.get(view.id) ?? ESTIMATED_TAB_WIDTH
92
+ const nextDividers = count * divW // dividers between tabs (count tabs → count dividers)
93
+ if (total + w + nextDividers + reserved > available && count > 0) break
94
+ total += w
95
+ count++
96
+ }
97
+ return count
98
+ }
99
+
100
+ const measure = (force = false) => {
101
+ if (!containerRef) return
102
+ // Use Math.ceil for consistency with tab width measurements
103
+ const containerWidth = Math.ceil(containerRef.getBoundingClientRect().width)
104
+ if (containerWidth <= 0) return
105
+ if (!force && containerWidth === lastContainerWidth) return
106
+ lastContainerWidth = containerWidth
107
+
108
+ // Width cache is keyed by view id, so render order (including active swap) doesn't matter.
109
+ const tabElements = containerRef.querySelectorAll<HTMLElement>('[data-view-tab]')
110
+ for (const el of tabElements) {
111
+ const id = el.dataset.viewId
112
+ if (id) {
113
+ // Ceil to avoid subpixel flicker at certain zoom levels
114
+ const width = Math.ceil(el.getBoundingClientRect().width) || el.offsetWidth
115
+ widthCache.set(id, width)
116
+ }
117
+ }
118
+
119
+ const views = measureViews()
120
+ const addReserve = local.onAdd ? ADD_BUTTON_WIDTH : 0
121
+
122
+ // First pass: assume no overflow trigger needed
123
+ let count = fitCount(views, containerWidth, addReserve)
124
+
125
+ // If that creates overflow, re-fit accounting for the overflow trigger
126
+ if (count < views.length) {
127
+ count = fitCount(views, containerWidth, addReserve + OVERFLOW_TRIGGER_WIDTH)
128
+ }
129
+
130
+ const next = Math.max(count, 1)
131
+ setDynamicMax((prev) => (prev !== next ? next : prev))
132
+ }
133
+
134
+ let raf = 0
135
+ const scheduleMeasure = (force = false) => {
136
+ cancelAnimationFrame(raf)
137
+ raf = requestAnimationFrame(() => measure(force))
138
+ }
139
+
140
+ onMount(() => {
141
+ if (local.maxVisible != null) return
142
+ if (!containerRef) return
143
+
144
+ const ro = new ResizeObserver(() => scheduleMeasure(false))
145
+ ro.observe(containerRef)
146
+
147
+ // First paint: force measure
148
+ scheduleMeasure(true)
149
+
150
+ // Fonts can shift widths without resizing the container
151
+ let cancelled = false
152
+ const fontsReady = typeof document !== 'undefined' ? document.fonts?.ready : undefined
153
+ fontsReady?.then(() => { if (!cancelled) scheduleMeasure(true) })
154
+
155
+ onCleanup(() => {
156
+ cancelled = true
157
+ cancelAnimationFrame(raf)
158
+ ro.disconnect()
159
+ })
160
+ })
161
+
162
+ createEffect(() => {
163
+ if (local.maxVisible != null) return
164
+ // Cheap hash-ish signature without allocating huge strings
165
+ let acc = local.views.length
166
+ for (const v of local.views) {
167
+ acc = (acc * 31 + v.id.length + v.label.length + (v.count ?? 0) + (v.pinned ? 1 : 0)) | 0
168
+ }
169
+ void acc
170
+ scheduleMeasure(true)
171
+ })
172
+
173
+ // Prune stale cache entries when views change
174
+ createEffect(() => {
175
+ const ids = new Set(local.views.map((v) => v.id))
176
+ for (const key of widthCache.keys()) {
177
+ if (!ids.has(key)) widthCache.delete(key)
178
+ }
179
+ })
180
+
181
+ // Automatic activation: arrow keys move focus and select (view switcher pattern).
182
+ const onTabKeyDown: JSX.EventHandlerUnion<HTMLButtonElement, KeyboardEvent> = (e) => {
183
+ const key = e.key
184
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key)) return
185
+ e.preventDefault()
186
+ const tabs = containerRef?.querySelectorAll<HTMLButtonElement>('[data-view-tab]')
187
+ if (!tabs || tabs.length === 0) return
188
+ const idx = Array.prototype.indexOf.call(tabs, e.currentTarget)
189
+ let next = idx
190
+ if (key === 'ArrowLeft') next = Math.max(0, idx - 1)
191
+ if (key === 'ArrowRight') next = Math.min(tabs.length - 1, idx + 1)
192
+ if (key === 'Home') next = 0
193
+ if (key === 'End') next = tabs.length - 1
194
+ tabs[next]?.focus()
195
+ const nextId = tabs[next]?.dataset.viewId
196
+ if (nextId) local.onChange(nextId)
197
+ }
198
+
199
+ const containerClass = () =>
200
+ isEmbedded()
201
+ ? 'flex min-w-0 w-full items-stretch gap-0 overflow-hidden rounded-t-2xl'
202
+ : 'inline-flex w-full max-w-full items-center gap-1.5 overflow-hidden rounded-xl border border-surface-border bg-surface-dim px-2 py-1.5'
203
+
204
+ return (
205
+ <div
206
+ ref={(el) => (containerRef = el)}
207
+ role="group"
208
+ aria-label={local.ariaLabel ?? 'Views'}
209
+ class={cn(containerClass(), local.class)}
210
+ >
211
+ {(() => {
212
+ const vis = visibleViews()
213
+ const ov = overflowViews()
214
+ return (
215
+ <>
216
+ <For each={vis}>
217
+ {(view, i) => {
218
+ const isActive = () => view.id === local.activeId
219
+ const isFirst = () => i() === 0
220
+ const isLastVisible = () => i() === vis.length - 1
221
+ const hideDivider = () => {
222
+ const next = vis[i() + 1]
223
+ return isActive() || next?.id === local.activeId
224
+ }
225
+ return (
226
+ <>
227
+ <button
228
+ type="button"
229
+ data-view-tab
230
+ data-view-id={view.id}
231
+ class={cn(
232
+ 'inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors',
233
+ isEmbedded() &&
234
+ 'max-w-[200px] shrink-0 rounded-t-2xl rounded-b-none border border-transparent border-b-0',
235
+ isEmbedded() && isFirst() && 'rounded-tl-2xl',
236
+ isEmbedded() && isLastVisible() && 'rounded-tr-2xl',
237
+ isEmbedded() &&
238
+ (isActive()
239
+ ? 'relative z-10 -mx-px -mt-px bg-surface-raised text-ink-900 border border-surface-border border-b-transparent'
240
+ : 'bg-transparent text-ink-500 hover:text-ink-700'),
241
+ !isEmbedded() &&
242
+ (isActive()
243
+ ? 'rounded-lg border border-surface-border bg-surface-raised text-ink-900 shadow-sm'
244
+ : 'rounded-lg border border-transparent text-ink-500 hover:text-ink-700 hover:bg-surface-overlay'),
245
+ )}
246
+ aria-current={isActive() ? 'page' : undefined}
247
+ tabIndex={isActive() ? 0 : -1}
248
+ onKeyDown={onTabKeyDown}
249
+ onClick={() => local.onChange(view.id)}
250
+ >
251
+ <Show when={view.pinned}>
252
+ <Pin size={12} class="shrink-0 text-ink-400" aria-hidden="true" />
253
+ </Show>
254
+ <span class="min-w-0 truncate">{view.label}</span>
255
+ <Show when={typeof view.count === 'number'}>
256
+ <span class="shrink-0 rounded-full bg-ink-200/80 px-2 py-0.5 text-xs font-semibold text-ink-600">
257
+ {view.count}
258
+ </span>
259
+ </Show>
260
+ </button>
261
+ <Show when={isEmbedded() && !isLastVisible() && !hideDivider()}>
262
+ <span class="w-px bg-surface-border" role="presentation" aria-hidden="true" />
263
+ </Show>
264
+ </>
265
+ )
266
+ }}
267
+ </For>
268
+ <Show when={ov.length > 0}>
269
+ <KobalteDropdownMenu>
270
+ <KobalteDropdownMenu.Trigger as="button" type="button" class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-ink-600 hover:bg-surface-raised hover:text-ink-900">
271
+ {local.moreLabel ?? 'More'}
272
+ <span class="rounded-full bg-ink-200 px-2 py-0.5 text-xs font-semibold text-ink-600">
273
+ {ov.length}
274
+ </span>
275
+ </KobalteDropdownMenu.Trigger>
276
+ <DropdownMenuContent>
277
+ <For each={ov}>
278
+ {(view) => (
279
+ <DropdownMenuItem onSelect={() => local.onChange(view.id)}>
280
+ <div class="flex w-full items-center justify-between gap-3">
281
+ <div class="flex min-w-0 flex-1 items-center gap-2">
282
+ <Show when={view.pinned}>
283
+ <Pin size={12} class="shrink-0 text-ink-400" aria-hidden="true" />
284
+ </Show>
285
+ <span class="truncate">{view.label}</span>
286
+ </div>
287
+ <Show when={typeof view.count === 'number'}>
288
+ <span class="shrink-0 rounded-full bg-ink-200 px-2 py-0.5 text-xs font-semibold text-ink-600">
289
+ {view.count}
290
+ </span>
291
+ </Show>
292
+ </div>
293
+ </DropdownMenuItem>
294
+ )}
295
+ </For>
296
+ </DropdownMenuContent>
297
+ </KobalteDropdownMenu>
298
+ </Show>
299
+ </>
300
+ )
301
+ })()}
302
+ <Show when={local.onAdd}>
303
+ <button
304
+ type="button"
305
+ class="ml-2 inline-flex h-7 w-7 shrink-0 items-center justify-center self-center rounded-lg border border-transparent text-ink-500 transition-colors hover:bg-surface-raised hover:text-ink-900"
306
+ aria-label="Add view"
307
+ onClick={local.onAdd}
308
+ >
309
+ {local.addIcon}
310
+ </button>
311
+ </Show>
312
+ </div>
313
+ )
314
+ }
@@ -0,0 +1,66 @@
1
+ /** Navigation: Breadcrumbs, Dropdown Menu, NavigationMenu, Pagination, Tabs, ViewSwitcher */
2
+ export { Breadcrumbs, type BreadcrumbsProps, type BreadcrumbItem } from './Breadcrumbs'
3
+
4
+ export {
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuSeparator,
8
+ type DropdownMenuContentProps,
9
+ type DropdownMenuItemProps,
10
+ type DropdownMenuSeparatorProps,
11
+ } from './DropdownMenu'
12
+
13
+ export { Pagination, type PaginationProps } from './Pagination'
14
+
15
+ export {
16
+ Tabs,
17
+ KobalteTabs,
18
+ TabsContent,
19
+ TabsList,
20
+ TabsTrigger,
21
+ type TabsProps,
22
+ type TabItem,
23
+ type TabsContentProps,
24
+ type TabsListProps,
25
+ type TabsTriggerProps,
26
+ } from './Tabs'
27
+
28
+ export { ViewSwitcher, type ViewSwitcherProps, type ViewSwitcherItem, type ViewScope } from './ViewSwitcher'
29
+
30
+ export {
31
+ MenuBar,
32
+ MenuBarMenu,
33
+ MenuBarTrigger,
34
+ MenuBarContent,
35
+ MenuBarItem,
36
+ MenuBarLink,
37
+ MenuBarLabel,
38
+ MenuBarDivider,
39
+ MenuBarNavLink,
40
+ type MenuBarProps,
41
+ type MenuBarTriggerProps,
42
+ type MenuBarContentProps,
43
+ type MenuBarItemProps,
44
+ type MenuBarLinkProps,
45
+ } from './NavigationMenu'
46
+
47
+ export {
48
+ MegaMenuBar,
49
+ MegaMenuMenu,
50
+ MegaMenuTrigger,
51
+ MegaMenuContent,
52
+ MegaMenuPanel,
53
+ MegaMenuColumn,
54
+ MegaMenuSection,
55
+ MegaMenuItem,
56
+ MegaMenuFeatured,
57
+ MegaMenuDivider,
58
+ MegaMenuFooter,
59
+ MegaMenuFooterLink,
60
+ type MegaMenuBarProps,
61
+ type MegaMenuTriggerProps,
62
+ type MegaMenuContentProps,
63
+ type MegaMenuPanelProps,
64
+ type MegaMenuItemProps,
65
+ type MegaMenuFeaturedProps,
66
+ } from './MegaMenu'
@@ -0,0 +1,174 @@
1
+ import { Show, splitProps, onMount, createSignal, createEffect } from 'solid-js'
2
+ import { AlertDialog as KobalteAlertDialog, type AlertDialogRootProps as KobalteAlertDialogRootProps } from '@kobalte/core/alert-dialog'
3
+ import { cn } from '../../utilities/classNames'
4
+ import { Button } from '../actions'
5
+
6
+ // Both constants are evaluated at module load and baked into alertDialogStyles
7
+ // as literal values. They are intentionally not runtime-configurable.
8
+ const DEFAULT_DURATION_MS = 200
9
+ const EXIT_DURATION_MS = Math.round(DEFAULT_DURATION_MS * 0.8)
10
+
11
+ const alertDialogStyles = `
12
+ @keyframes torchui-alert-overlay-fade-in {
13
+ from { opacity: 0; }
14
+ to { opacity: 1; }
15
+ }
16
+ @keyframes torchui-alert-overlay-fade-out {
17
+ from { opacity: 1; }
18
+ to { opacity: 0; }
19
+ }
20
+ @keyframes torchui-alert-panel-scale-in {
21
+ from { opacity: 0; transform: scale(0.96); }
22
+ to { opacity: 1; transform: scale(1); }
23
+ }
24
+ @keyframes torchui-alert-panel-scale-out {
25
+ from { opacity: 1; transform: scale(1); }
26
+ to { opacity: 0; transform: scale(0.96); }
27
+ }
28
+ .torchui-alert-dialog-overlay {
29
+ animation: torchui-alert-overlay-fade-out ${EXIT_DURATION_MS}ms ease-in forwards;
30
+ }
31
+ .torchui-alert-dialog-overlay[data-expanded] {
32
+ animation: torchui-alert-overlay-fade-in ${DEFAULT_DURATION_MS}ms ease-out forwards;
33
+ }
34
+ .torchui-alert-dialog-content {
35
+ animation: torchui-alert-panel-scale-out ${EXIT_DURATION_MS}ms ease-in forwards;
36
+ }
37
+ .torchui-alert-dialog-content[data-expanded] {
38
+ animation: torchui-alert-panel-scale-in ${DEFAULT_DURATION_MS}ms ease-out forwards;
39
+ }
40
+ `
41
+
42
+ // ID-based DOM guard: prevents duplicate style tags even across multiple module loads
43
+ // (e.g., microfrontends, test setups, different bundles)
44
+ const STYLE_ID = 'torchui-alert-dialog-styles'
45
+ function ensureAlertStyles() {
46
+ if (typeof document === 'undefined') return
47
+ if (document.getElementById(STYLE_ID)) return
48
+
49
+ const style = document.createElement('style')
50
+ style.id = STYLE_ID
51
+ style.textContent = alertDialogStyles
52
+ document.head.appendChild(style)
53
+ }
54
+
55
+ export interface AlertDialogProps extends Omit<KobalteAlertDialogRootProps, 'open' | 'onOpenChange'> {
56
+ /** Whether the dialog is open */
57
+ open: boolean
58
+ /** Called when open state changes */
59
+ onOpenChange?: (open: boolean) => void
60
+ /** Dialog title */
61
+ title: string
62
+ /** Dialog description */
63
+ description?: string
64
+ /** Label for the confirm/action button */
65
+ confirmLabel?: string
66
+ /** Label for the cancel button */
67
+ cancelLabel?: string
68
+ /** Called when user confirms. If it returns a Promise, the dialog stays open until resolved. */
69
+ onConfirm?: () => void | Promise<void>
70
+ /** Called when user cancels */
71
+ onCancel?: () => void
72
+ /** When true, confirm button uses destructive (red) styling */
73
+ destructive?: boolean
74
+ /** Additional class for the content panel */
75
+ class?: string
76
+ /** Additional class for the overlay */
77
+ overlayClass?: string
78
+ }
79
+
80
+ export function AlertDialog(props: AlertDialogProps) {
81
+ const [local] = splitProps(props, [
82
+ 'open', 'onOpenChange', 'onCancel', 'title', 'description',
83
+ 'confirmLabel', 'cancelLabel', 'onConfirm',
84
+ 'destructive', 'class', 'overlayClass',
85
+ ])
86
+
87
+ onMount(ensureAlertStyles)
88
+
89
+ const [pending, setPending] = createSignal(false)
90
+ let closingFromConfirm = false
91
+
92
+ if (import.meta.env?.DEV) {
93
+ createEffect(() => {
94
+ if (local.open && !local.onOpenChange) {
95
+ console.warn('[AlertDialog] open is true but onOpenChange is not provided. The dialog cannot be closed by user interaction.')
96
+ }
97
+ })
98
+ }
99
+
100
+ const handleOpenChange = (isOpen: boolean) => {
101
+ if (!isOpen) {
102
+ if (!closingFromConfirm) local.onCancel?.()
103
+ closingFromConfirm = false
104
+ }
105
+ local.onOpenChange?.(isOpen)
106
+ }
107
+
108
+ const handleConfirm = async () => {
109
+ if (pending()) return
110
+ setPending(true)
111
+ try {
112
+ await local.onConfirm?.()
113
+ closingFromConfirm = true
114
+ handleOpenChange(false) // ✅ actually closes through Root handler
115
+ } catch {
116
+ // Keep dialog open on failure; caller can surface error via toast/state.
117
+ } finally {
118
+ setPending(false)
119
+ }
120
+ }
121
+
122
+ return (
123
+ <KobalteAlertDialog
124
+ open={local.open}
125
+ onOpenChange={handleOpenChange}
126
+ >
127
+ <KobalteAlertDialog.Portal>
128
+ <KobalteAlertDialog.Overlay
129
+ class={cn(
130
+ 'torchui-alert-dialog-overlay fixed inset-0 z-[100]',
131
+ 'bg-black/30 dark:bg-black/50 backdrop-blur-md dark:backdrop-blur-sm',
132
+ local.overlayClass,
133
+ )}
134
+ />
135
+ <div class="fixed left-1/2 top-1/2 z-[101] w-full max-w-md -translate-x-1/2 -translate-y-1/2 p-4">
136
+ {/* ARIA alert-dialog: user must choose Cancel or Confirm; outside click should not dismiss. */}
137
+ <KobalteAlertDialog.Content
138
+ class="torchui-alert-dialog-content block w-full"
139
+ onInteractOutside={(e) => e.preventDefault()}
140
+ >
141
+ <div
142
+ class={cn(
143
+ 'torchui-alert-dialog-content-panel rounded-2xl border border-surface-border bg-surface-raised p-6 shadow-xl dark:shadow-[0_20px_50px_-12px_rgba(0,0,0,.5)]',
144
+ local.class,
145
+ )}
146
+ >
147
+ <KobalteAlertDialog.Title class="text-lg font-semibold text-ink-900">
148
+ {local.title}
149
+ </KobalteAlertDialog.Title>
150
+ <Show when={local.description}>
151
+ <KobalteAlertDialog.Description class="mt-2 text-sm text-ink-500">
152
+ {local.description}
153
+ </KobalteAlertDialog.Description>
154
+ </Show>
155
+ <div class="mt-6 flex justify-end gap-3">
156
+ <KobalteAlertDialog.CloseButton as={Button} variant="outlined" size="sm">
157
+ {local.cancelLabel ?? 'Cancel'}
158
+ </KobalteAlertDialog.CloseButton>
159
+ <Button
160
+ variant={local.destructive ? 'danger' : 'primary'}
161
+ size="sm"
162
+ disabled={pending()}
163
+ onClick={handleConfirm}
164
+ >
165
+ {local.confirmLabel ?? 'Confirm'}
166
+ </Button>
167
+ </div>
168
+ </div>
169
+ </KobalteAlertDialog.Content>
170
+ </div>
171
+ </KobalteAlertDialog.Portal>
172
+ </KobalteAlertDialog>
173
+ )
174
+ }
@@ -0,0 +1,65 @@
1
+ import { type JSX, splitProps } from 'solid-js'
2
+ import { ContextMenu as KobalteContextMenu, type ContextMenuContentProps as KobalteContextMenuContentProps, type ContextMenuItemProps as KobalteContextMenuItemProps, type ContextMenuSeparatorProps as KobalteContextMenuSeparatorProps } from '@kobalte/core/context-menu'
3
+ import { cn } from '../../utilities/classNames'
4
+
5
+ /** Pass-through to Kobalte's ContextMenu.Root. See Kobalte docs for available props (e.g. onOpenChange). */
6
+ export const ContextMenuRoot = KobalteContextMenu
7
+ /** Pass-through to Kobalte's ContextMenu.Trigger. Renders as the element that responds to right-click. */
8
+ export const ContextMenuTrigger = KobalteContextMenu.Trigger
9
+
10
+ export interface ContextMenuContentProps extends KobalteContextMenuContentProps {
11
+ class?: string
12
+ children?: JSX.Element
13
+ }
14
+
15
+ export function ContextMenuContent(props: ContextMenuContentProps) {
16
+ const [local, others] = splitProps(props, ['class', 'children'])
17
+ return (
18
+ <KobalteContextMenu.Portal>
19
+ <KobalteContextMenu.Content
20
+ class={cn(
21
+ 'z-50 min-w-[160px] rounded-lg border border-surface-border bg-surface-raised p-1 shadow-lg',
22
+ local.class
23
+ )}
24
+ {...others}
25
+ >
26
+ {local.children}
27
+ </KobalteContextMenu.Content>
28
+ </KobalteContextMenu.Portal>
29
+ )
30
+ }
31
+
32
+ export interface ContextMenuItemProps extends KobalteContextMenuItemProps {
33
+ class?: string
34
+ children: JSX.Element
35
+ }
36
+
37
+ export function ContextMenuItem(props: ContextMenuItemProps) {
38
+ const [local, others] = splitProps(props, ['class', 'children'])
39
+ return (
40
+ <KobalteContextMenu.Item
41
+ class={cn(
42
+ 'flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 text-sm text-ink-700 outline-none',
43
+ 'data-[highlighted]:bg-surface-overlay data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
44
+ local.class,
45
+ )}
46
+ {...others}
47
+ >
48
+ {local.children}
49
+ </KobalteContextMenu.Item>
50
+ )
51
+ }
52
+
53
+ export interface ContextMenuSeparatorProps extends KobalteContextMenuSeparatorProps {
54
+ class?: string
55
+ }
56
+
57
+ export function ContextMenuSeparator(props: ContextMenuSeparatorProps) {
58
+ const [local, others] = splitProps(props, ['class'])
59
+ return (
60
+ <KobalteContextMenu.Separator
61
+ class={cn('my-1 h-px bg-surface-border', local.class)}
62
+ {...others}
63
+ />
64
+ )
65
+ }