@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,279 @@
1
+ import { Show, onMount, type JSX, splitProps, createEffect, on } from 'solid-js'
2
+ import { Dialog as KobalteDialog } from '@kobalte/core/dialog'
3
+ import { cn } from '../../utilities/classNames'
4
+
5
+ const DEFAULT_DURATION_MS = 200
6
+
7
+ export type DialogSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
8
+
9
+ /** Overlay animation. Default fade. */
10
+ export type DialogOverlayAnimation = 'fade' | 'none'
11
+
12
+ /** Panel animation. Default scale. */
13
+ export type DialogPanelAnimation = 'fade' | 'scale' | 'slide-up' | 'none'
14
+
15
+ export interface DialogProps extends JSX.HTMLAttributes<HTMLDivElement> {
16
+ /** Whether the dialog is open */
17
+ open: boolean
18
+ /** Called when open state changes */
19
+ onOpenChange?: (open: boolean) => void
20
+ /** Called when dialog closes (alias for onOpenChange with false) */
21
+ onClose?: () => void
22
+ /** Header content (e.g. title text or heading element). Rendered in a row alongside the close button. Referenced by aria-labelledby for screen readers.
23
+ * If not provided, consider passing aria-label or aria-labelledby for accessibility. */
24
+ header?: JSX.Element
25
+ /** Optional footer content (e.g. action buttons). Rendered below the body with a top border and padding. */
26
+ footer?: JSX.Element
27
+ /** Dialog size */
28
+ size?: DialogSize
29
+ /** Show overlay */
30
+ overlay?: boolean
31
+ /** Close on overlay click */
32
+ closeOnOverlayClick?: boolean
33
+ /** Custom overlay class */
34
+ overlayClass?: string
35
+ /** Dark semi-transparent overlay; default true. Set false to keep background visible (e.g. reference data). */
36
+ overlayDim?: boolean
37
+ /** Backdrop blur on overlay; default true. Set false for no blur. */
38
+ overlayBlur?: boolean
39
+ /** Show a circular close (X) button in the top-right when onClose or onOpenChange is provided */
40
+ showCloseButton?: boolean
41
+ /** Overlay animation. Default fade. */
42
+ overlayAnimation?: DialogOverlayAnimation
43
+ /** Panel animation. Default scale. */
44
+ panelAnimation?: DialogPanelAnimation
45
+ /** Duration in ms for enter animation. Default 200. */
46
+ animationDuration?: number
47
+ /** Duration in ms for exit animation. Default 80% of animationDuration. */
48
+ animationExitDuration?: number
49
+ }
50
+
51
+ const sizeClasses: Record<DialogSize, string> = {
52
+ xs: 'max-w-xs',
53
+ sm: 'max-w-sm',
54
+ md: 'max-w-md',
55
+ lg: 'max-w-lg',
56
+ xl: 'max-w-xl',
57
+ full: '', // No longer needed - positioning handled conditionally
58
+ }
59
+
60
+ const dialogStyles = `
61
+ @keyframes torchui-dialog-fade-in {
62
+ from { opacity: 0; }
63
+ to { opacity: 1; }
64
+ }
65
+ @keyframes torchui-dialog-fade-out {
66
+ from { opacity: 1; }
67
+ to { opacity: 0; }
68
+ }
69
+ @keyframes torchui-dialog-scale-in {
70
+ from { opacity: 0; transform: scale(0.96); }
71
+ to { opacity: 1; transform: scale(1); }
72
+ }
73
+ @keyframes torchui-dialog-scale-out {
74
+ from { opacity: 1; transform: scale(1); }
75
+ to { opacity: 0; transform: scale(0.96); }
76
+ }
77
+ @keyframes torchui-dialog-slide-up-in {
78
+ from { opacity: 0; transform: translateY(0.5rem); }
79
+ to { opacity: 1; transform: translateY(0); }
80
+ }
81
+ @keyframes torchui-dialog-slide-up-out {
82
+ from { opacity: 1; transform: translateY(0); }
83
+ to { opacity: 0; transform: translateY(0.5rem); }
84
+ }
85
+ .torchui-dialog-overlay {
86
+ opacity: 0;
87
+ animation: torchui-dialog-fade-out var(--dialog-exit-duration, 0.16s) ease-in forwards;
88
+ }
89
+ .torchui-dialog-overlay[data-expanded] {
90
+ opacity: 1;
91
+ animation: torchui-dialog-fade-in var(--dialog-duration, 0.2s) ease-out forwards;
92
+ }
93
+ .torchui-dialog-content[data-animation="fade"] {
94
+ opacity: 0;
95
+ animation: torchui-dialog-fade-out var(--dialog-exit-duration, 0.16s) ease-in forwards;
96
+ }
97
+ .torchui-dialog-content[data-animation="fade"][data-expanded] {
98
+ opacity: 1;
99
+ animation: torchui-dialog-fade-in var(--dialog-duration, 0.2s) ease-out forwards;
100
+ }
101
+ .torchui-dialog-content[data-animation="scale"] {
102
+ opacity: 0;
103
+ animation: torchui-dialog-scale-out var(--dialog-exit-duration, 0.16s) ease-in forwards;
104
+ }
105
+ .torchui-dialog-content[data-animation="scale"][data-expanded] {
106
+ opacity: 1;
107
+ animation: torchui-dialog-scale-in var(--dialog-duration, 0.2s) ease-out forwards;
108
+ }
109
+ .torchui-dialog-content[data-animation="slide-up"] {
110
+ opacity: 0;
111
+ animation: torchui-dialog-slide-up-out var(--dialog-exit-duration, 0.16s) ease-in forwards;
112
+ }
113
+ .torchui-dialog-content[data-animation="slide-up"][data-expanded] {
114
+ opacity: 1;
115
+ animation: torchui-dialog-slide-up-in var(--dialog-duration, 0.2s) ease-out forwards;
116
+ }
117
+ `
118
+
119
+ // ID-based DOM guard: prevents duplicate style tags even across multiple module loads
120
+ // (e.g., microfrontends, test setups, different bundles)
121
+ const STYLE_ID = 'torchui-dialog-styles'
122
+ function ensureDialogStyles() {
123
+ if (typeof document === 'undefined') return
124
+ if (document.getElementById(STYLE_ID)) return
125
+
126
+ const style = document.createElement('style')
127
+ style.id = STYLE_ID
128
+ style.textContent = dialogStyles
129
+ document.head.appendChild(style)
130
+ }
131
+
132
+ export function Dialog(props: DialogProps) {
133
+ const [local, others] = splitProps(props, [
134
+ 'open',
135
+ 'onOpenChange',
136
+ 'onClose',
137
+ 'size',
138
+ 'overlay',
139
+ 'closeOnOverlayClick',
140
+ 'overlayClass',
141
+ 'overlayDim',
142
+ 'overlayBlur',
143
+ 'showCloseButton',
144
+ 'overlayAnimation',
145
+ 'panelAnimation',
146
+ 'animationDuration',
147
+ 'animationExitDuration',
148
+ 'class',
149
+ 'children',
150
+ 'header',
151
+ 'footer',
152
+ ])
153
+
154
+ // Dev warning for accessibility
155
+ if (import.meta.env?.DEV) {
156
+ const hasAccessibleNameProp = () =>
157
+ ('aria-label' in others) ||
158
+ ('aria-labelledby' in others) ||
159
+ ('ariaLabel' in others) ||
160
+ ('ariaLabelledby' in others)
161
+
162
+ createEffect(on(
163
+ () => local.open,
164
+ (open) => {
165
+ if (open && !local.header && !hasAccessibleNameProp()) {
166
+ console.warn('[Dialog] Provide header, aria-label, or aria-labelledby for an accessible name.')
167
+ }
168
+ }
169
+ ))
170
+ }
171
+
172
+ onMount(ensureDialogStyles)
173
+
174
+ const duration = () => local.animationDuration ?? DEFAULT_DURATION_MS
175
+ const exitDuration = () => local.animationExitDuration ?? Math.round((local.animationDuration ?? DEFAULT_DURATION_MS) * 0.8)
176
+ const cssVars = (): JSX.CSSProperties => ({
177
+ '--dialog-duration': `${duration()}ms`,
178
+ '--dialog-exit-duration': `${exitDuration()}ms`,
179
+ })
180
+
181
+ const sizeClass = () => sizeClasses[local.size ?? 'md']
182
+ const isFull = () => (local.size ?? 'md') === 'full'
183
+ const showOverlay = () => local.overlay !== false
184
+ const closeOnOverlay = () => local.closeOnOverlayClick !== false
185
+ const overlayAnimation = () => local.overlayAnimation ?? 'fade'
186
+ const panelAnimation = () => local.panelAnimation ?? 'scale'
187
+ const hasCloseRow = () => (local.onClose != null || local.onOpenChange != null) && local.showCloseButton !== false
188
+ const hasHeaderRow = () => !!(local.header || hasCloseRow())
189
+
190
+ return (
191
+ <KobalteDialog
192
+ open={local.open}
193
+ onOpenChange={(isOpen) => {
194
+ local.onOpenChange?.(isOpen)
195
+ if (!isOpen) local.onClose?.()
196
+ }}
197
+ modal
198
+ >
199
+ <KobalteDialog.Portal>
200
+ <div class="contents" style={cssVars()}>
201
+ <Show when={showOverlay()}>
202
+ <KobalteDialog.Overlay
203
+ class={cn(
204
+ 'fixed inset-0 z-[60]',
205
+ overlayAnimation() !== 'none' && 'torchui-dialog-overlay',
206
+ local.overlayDim !== false && 'bg-black/30 dark:bg-black/50',
207
+ local.overlayBlur !== false && 'backdrop-blur-md dark:backdrop-blur-sm',
208
+ local.overlayClass,
209
+ )}
210
+ />
211
+ </Show>
212
+ {/* Positioning wrapper: centering only, never animated */}
213
+ <div
214
+ class={cn(
215
+ 'fixed z-[70] w-full',
216
+ isFull()
217
+ ? 'inset-0 p-0' // no centering for full dialogs
218
+ : 'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 p-4', // centering for normal dialogs
219
+ !isFull() && sizeClass(),
220
+ local.class,
221
+ )}
222
+ >
223
+ {/* Content: focus trap, role=dialog, aria-modal, escape key, animation */}
224
+ <KobalteDialog.Content
225
+ class={cn(
226
+ panelAnimation() !== 'none' && 'torchui-dialog-content',
227
+ isFull() && 'h-full min-h-0 flex flex-col',
228
+ )}
229
+ data-animation={panelAnimation() !== 'none' ? panelAnimation() : undefined}
230
+ onInteractOutside={(e) => { if (!closeOnOverlay()) e.preventDefault() }}
231
+ {...others}
232
+ >
233
+ {/* Visual panel */}
234
+ <div
235
+ class={cn(
236
+ 'overflow-y-auto bg-surface-raised text-ink-900',
237
+ isFull()
238
+ ? 'h-full min-h-0 flex-1 flex flex-col p-0' // Full screen: no padding on container
239
+ : 'max-h-[90vh] rounded-lg border border-surface-border p-6 shadow-[0_20px_50px_-12px_rgba(0,0,0,.2)] dark:shadow-[0_20px_50px_-12px_rgba(0,0,0,.5)]', // Normal dialog
240
+ )}
241
+ >
242
+ <Show when={hasHeaderRow()}>
243
+ <div class={cn('flex items-center justify-between gap-4', isFull() && 'p-4')}>
244
+ <Show when={local.header}>
245
+ <KobalteDialog.Title as="div" class="min-w-0 flex-1">
246
+ {local.header}
247
+ </KobalteDialog.Title>
248
+ </Show>
249
+ <Show when={!local.header}>
250
+ <span />
251
+ </Show>
252
+ <Show when={hasCloseRow()}>
253
+ <KobalteDialog.CloseButton
254
+ aria-label="Close"
255
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-overlay text-ink-500 hover:bg-surface-dim hover:text-ink-700 dark:hover:text-ink-200"
256
+ >
257
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
258
+ <path d="M18 6 6 18M6 6l12 12" />
259
+ </svg>
260
+ </KobalteDialog.CloseButton>
261
+ </Show>
262
+ </div>
263
+ </Show>
264
+ <div class={cn(hasHeaderRow() && (isFull() ? '' : 'mt-7'), isFull() && 'px-4 pb-4')}>
265
+ {local.children}
266
+ </div>
267
+ <Show when={local.footer}>
268
+ <div class={cn('mt-8 border-t border-surface-border pt-5', isFull() && 'px-4 pb-4')}>
269
+ {local.footer}
270
+ </div>
271
+ </Show>
272
+ </div>
273
+ </KobalteDialog.Content>
274
+ </div>
275
+ </div>
276
+ </KobalteDialog.Portal>
277
+ </KobalteDialog>
278
+ )
279
+ }
@@ -0,0 +1,370 @@
1
+ import { Show, onMount, type JSX, splitProps } from 'solid-js'
2
+ import { Dialog as KobalteDialog } from '@kobalte/core/dialog'
3
+ import { Button } from '../actions'
4
+ import { cn } from '../../utilities/classNames'
5
+
6
+ export type DrawerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
7
+ export type DrawerSide = 'start' | 'end' | 'top' | 'bottom' | 'left' | 'right'
8
+
9
+ /** Where to place the action buttons (Cancel/Save). Default bottom. */
10
+ export type DrawerActionsPosition = 'bottom' | 'top-end'
11
+
12
+ export interface DrawerProps extends JSX.HTMLAttributes<HTMLElement> {
13
+ open: boolean
14
+ onClose?: () => void
15
+ /** Called when open state changes */
16
+ onOpenChange?: (open: boolean) => void
17
+ size?: DrawerSize
18
+ side?: DrawerSide
19
+ overlay?: boolean
20
+ closeOnOverlayClick?: boolean
21
+ overlayClass?: string
22
+ /** Dark semi-transparent overlay; default true. Set false to keep background visible (e.g. reference data). */
23
+ overlayDim?: boolean
24
+ /** Backdrop blur on overlay; default true. Set false for no blur. */
25
+ overlayBlur?: boolean
26
+ /** Show a circular close (X) button when onClose is provided */
27
+ showCloseButton?: boolean
28
+ /** Cancel action. Also used as overlay/outside-click fallback before onClose. */
29
+ onCancel?: () => void
30
+ /** Primary action (e.g. Save) */
31
+ onSave?: () => void
32
+ cancelLabel?: string
33
+ saveLabel?: string
34
+ /** Where to place Cancel/Save actions. Default bottom. */
35
+ actionsPosition?: DrawerActionsPosition
36
+ /** Disable page scroll (hide body scrollbar) while drawer is open. Default true. */
37
+ lockScroll?: boolean
38
+ /** Inset from viewport edges (Tailwind spacing: 0, 2=0.5rem, 4=1rem, 6=1.5rem). No offset when size is full. Default 0. */
39
+ offset?: '0' | '2' | '4' | '6'
40
+ }
41
+
42
+ // Width for start/end; height for top/bottom
43
+ const sizeWidthClasses: Record<DrawerSize, string> = {
44
+ xs: 'w-[280px]',
45
+ sm: 'w-[320px]',
46
+ md: 'w-[384px]',
47
+ lg: 'w-[448px]',
48
+ xl: 'w-[512px]',
49
+ '2xl': 'w-[42rem]',
50
+ full: 'w-full',
51
+ }
52
+
53
+ const sizeHeightClasses: Record<DrawerSize, string> = {
54
+ xs: 'h-[280px]',
55
+ sm: 'h-[320px]',
56
+ md: 'h-[384px]',
57
+ lg: 'h-[448px]',
58
+ xl: 'h-[512px]',
59
+ '2xl': 'h-[42rem]',
60
+ full: 'h-full',
61
+ }
62
+
63
+ export type DrawerOffset = '0' | '2' | '4' | '6'
64
+
65
+ // Inset from viewport (top/right/bottom/left) so height/width are constrained. When offset 0, panel is flush to edges.
66
+ const insetClassesBySide: Record<'start' | 'end' | 'top' | 'bottom', Record<DrawerOffset, string>> = {
67
+ end: { '0': 'right-0 top-0 bottom-0', '2': 'top-2 right-2 bottom-2', '4': 'top-4 right-4 bottom-4', '6': 'top-6 right-6 bottom-6' },
68
+ start: { '0': 'left-0 top-0 bottom-0', '2': 'top-2 left-2 bottom-2', '4': 'top-4 left-4 bottom-4', '6': 'top-6 left-6 bottom-6' },
69
+ top: { '0': 'left-0 right-0 top-0', '2': 'left-2 right-2 top-2', '4': 'left-4 right-4 top-4', '6': 'left-6 right-6 top-6' },
70
+ bottom: { '0': 'left-0 right-0 bottom-0', '2': 'left-2 right-2 bottom-2', '4': 'left-4 right-4 bottom-4', '6': 'left-6 right-6 bottom-6' },
71
+ }
72
+
73
+ const decorationBySide: Record<'start' | 'end' | 'top' | 'bottom', string> = {
74
+ start: 'rounded-r-lg border-r',
75
+ end: 'rounded-l-lg border-l',
76
+ top: 'rounded-b-lg border-b',
77
+ bottom: 'rounded-t-lg border-t',
78
+ }
79
+
80
+ const drawerStyles = `
81
+ @keyframes torchui-drawer-fade-in {
82
+ from { opacity: 0; }
83
+ to { opacity: 1; }
84
+ }
85
+ @keyframes torchui-drawer-fade-out {
86
+ from { opacity: 1; }
87
+ to { opacity: 0; }
88
+ }
89
+ @keyframes torchui-drawer-slide-in-end {
90
+ from { transform: translateX(100%); }
91
+ to { transform: translateX(0); }
92
+ }
93
+ @keyframes torchui-drawer-slide-in-start {
94
+ from { transform: translateX(-100%); }
95
+ to { transform: translateX(0); }
96
+ }
97
+ @keyframes torchui-drawer-slide-in-top {
98
+ from { transform: translateY(-100%); }
99
+ to { transform: translateY(0); }
100
+ }
101
+ @keyframes torchui-drawer-slide-in-bottom {
102
+ from { transform: translateY(100%); }
103
+ to { transform: translateY(0); }
104
+ }
105
+ @keyframes torchui-drawer-slide-out-end {
106
+ from { transform: translateX(0); }
107
+ to { transform: translateX(100%); }
108
+ }
109
+ @keyframes torchui-drawer-slide-out-start {
110
+ from { transform: translateX(0); }
111
+ to { transform: translateX(-100%); }
112
+ }
113
+ @keyframes torchui-drawer-slide-out-top {
114
+ from { transform: translateY(0); }
115
+ to { transform: translateY(-100%); }
116
+ }
117
+ @keyframes torchui-drawer-slide-out-bottom {
118
+ from { transform: translateY(0); }
119
+ to { transform: translateY(100%); }
120
+ }
121
+ .torchui-drawer-overlay {
122
+ opacity: 0;
123
+ animation: torchui-drawer-fade-out 0.2s ease-in forwards;
124
+ }
125
+ .torchui-drawer-overlay[data-expanded] {
126
+ opacity: 1;
127
+ animation: torchui-drawer-fade-in 0.25s ease-out forwards;
128
+ }
129
+ .torchui-drawer-panel[data-side="end"] {
130
+ transform: translateX(100%);
131
+ animation: torchui-drawer-slide-out-end 0.2s ease-in forwards;
132
+ }
133
+ .torchui-drawer-panel[data-side="end"][data-expanded] {
134
+ transform: translateX(0);
135
+ animation: torchui-drawer-slide-in-end 0.25s ease-out forwards;
136
+ }
137
+ .torchui-drawer-panel[data-side="start"] {
138
+ transform: translateX(-100%);
139
+ animation: torchui-drawer-slide-out-start 0.2s ease-in forwards;
140
+ }
141
+ .torchui-drawer-panel[data-side="start"][data-expanded] {
142
+ transform: translateX(0);
143
+ animation: torchui-drawer-slide-in-start 0.25s ease-out forwards;
144
+ }
145
+ .torchui-drawer-panel[data-side="top"] {
146
+ transform: translateY(-100%);
147
+ animation: torchui-drawer-slide-out-top 0.2s ease-in forwards;
148
+ }
149
+ .torchui-drawer-panel[data-side="top"][data-expanded] {
150
+ transform: translateY(0);
151
+ animation: torchui-drawer-slide-in-top 0.25s ease-out forwards;
152
+ }
153
+ .torchui-drawer-panel[data-side="bottom"] {
154
+ transform: translateY(100%);
155
+ animation: torchui-drawer-slide-out-bottom 0.2s ease-in forwards;
156
+ }
157
+ .torchui-drawer-panel[data-side="bottom"][data-expanded] {
158
+ transform: translateY(0);
159
+ animation: torchui-drawer-slide-in-bottom 0.25s ease-out forwards;
160
+ }
161
+ `
162
+
163
+ // ID-based DOM guard: prevents duplicate style tags even across multiple module loads
164
+ // (e.g., microfrontends, test setups, different bundles)
165
+ const STYLE_ID = 'torchui-drawer-styles'
166
+ function ensureDrawerStyles() {
167
+ if (typeof document === 'undefined') return
168
+ if (document.getElementById(STYLE_ID)) return
169
+
170
+ const style = document.createElement('style')
171
+ style.id = STYLE_ID
172
+ style.textContent = drawerStyles
173
+ document.head.appendChild(style)
174
+ }
175
+
176
+ export function Drawer(props: DrawerProps) {
177
+ const [local, others] = splitProps(props, [
178
+ 'open',
179
+ 'onClose',
180
+ 'onOpenChange',
181
+ 'size',
182
+ 'side',
183
+ 'overlay',
184
+ 'closeOnOverlayClick',
185
+ 'overlayClass',
186
+ 'overlayDim',
187
+ 'overlayBlur',
188
+ 'showCloseButton',
189
+ 'onCancel',
190
+ 'onSave',
191
+ 'cancelLabel',
192
+ 'saveLabel',
193
+ 'actionsPosition',
194
+ 'lockScroll',
195
+ 'offset',
196
+ 'class',
197
+ 'children',
198
+ ])
199
+
200
+ onMount(ensureDrawerStyles)
201
+
202
+ // Normalize left/right to start/end for positioning
203
+ const side = (): 'start' | 'end' | 'top' | 'bottom' => {
204
+ const s = local.side ?? 'end'
205
+ if (s === 'left') return 'start'
206
+ if (s === 'right') return 'end'
207
+ return s
208
+ }
209
+ const isHorizontal = () => side() === 'start' || side() === 'end'
210
+ const sizeClass = () =>
211
+ isHorizontal() ? sizeWidthClasses[local.size ?? 'md'] : sizeHeightClasses[local.size ?? 'md']
212
+ const showOverlay = () => local.overlay !== false
213
+ const closeOnOverlay = () => local.closeOnOverlayClick !== false
214
+ const hasFooter = () => showCancel() || local.onSave != null
215
+ const actionsPosition = () => local.actionsPosition ?? 'bottom'
216
+
217
+ // Track close reason to distinguish between cancel and close actions
218
+ let closeReason: 'cancel' | 'close' | null = null
219
+
220
+ // Single source of truth for open state changes
221
+ const handleOpenChange = (isOpen: boolean) => {
222
+ local.onOpenChange?.(isOpen)
223
+
224
+ if (!isOpen) {
225
+ if (closeReason === 'cancel') local.onCancel?.()
226
+ closeReason = null
227
+ local.onClose?.()
228
+ }
229
+ }
230
+
231
+ // For user intent cancel (overlay click / cancel button)
232
+ const setCancelReason = () => {
233
+ closeReason = 'cancel'
234
+ }
235
+
236
+ // For "just close" (X button) - let Kobalte drive the close event
237
+ const setCloseReason = () => {
238
+ closeReason = 'close'
239
+ }
240
+
241
+ // Check if drawer can be closed (controlled component needs onOpenChange)
242
+ const canClose = () => local.onOpenChange != null
243
+
244
+ // Show Cancel button and close button only when drawer is closable
245
+ const showCancel = () => canClose()
246
+
247
+ const currentSize = () => local.size ?? 'md'
248
+ const isFull = () => currentSize() === 'full'
249
+ const offset = (): DrawerOffset => local.offset ?? '0'
250
+ const hasInsetOffset = () => !isFull() && offset() !== '0'
251
+ const panelInsetClasses = () => insetClassesBySide[side()][offset()]
252
+ const panelDecorationClasses = () => (hasInsetOffset() ? 'rounded-lg' : decorationBySide[side()])
253
+ // When offset is set, height/width come from insets; otherwise use h-full/w-full
254
+ const panelSizeStretch = () =>
255
+ hasInsetOffset() ? '' : isHorizontal() ? 'h-full' : 'w-full'
256
+
257
+ const closeButtonEl = () => (
258
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
259
+ <path d="M18 6 6 18M6 6l12 12" />
260
+ </svg>
261
+ )
262
+
263
+ const actionsBlock = () => (
264
+ <div class="flex items-center justify-end gap-3">
265
+ <Show when={showCancel()} fallback={<span />}>
266
+ <KobalteDialog.CloseButton
267
+ as={Button}
268
+ variant="ghost"
269
+ size="sm"
270
+ onClick={setCancelReason}
271
+ >
272
+ {local.cancelLabel ?? 'Cancel'}
273
+ </KobalteDialog.CloseButton>
274
+ </Show>
275
+ <Show when={local.onSave}>
276
+ <Button
277
+ variant="primary"
278
+ size="sm"
279
+ class="rounded-lg"
280
+ onClick={local.onSave}
281
+ >
282
+ {local.saveLabel ?? 'Save'}
283
+ </Button>
284
+ </Show>
285
+ </div>
286
+ )
287
+
288
+ return (
289
+ <KobalteDialog
290
+ open={local.open}
291
+ onOpenChange={handleOpenChange}
292
+ modal
293
+ preventScroll={local.lockScroll !== false}
294
+ >
295
+ <KobalteDialog.Portal>
296
+ <Show when={showOverlay()}>
297
+ <KobalteDialog.Overlay
298
+ class={cn(
299
+ 'torchui-drawer-overlay fixed inset-0 z-[60] min-h-screen',
300
+ local.overlayDim !== false && 'bg-black/30 dark:bg-black/60',
301
+ local.overlayBlur !== false && 'backdrop-blur-md dark:backdrop-blur-md',
302
+ local.overlayClass,
303
+ )}
304
+ onPointerDown={() => { if (closeOnOverlay()) setCancelReason() }}
305
+ />
306
+ </Show>
307
+ <KobalteDialog.Content
308
+ as="aside"
309
+ class={cn(
310
+ 'torchui-drawer-panel fixed z-[70] flex flex-col bg-surface-raised text-ink-900 shadow-[0_20px_50px_-12px_rgba(0,0,0,.15)]',
311
+ 'border border-surface-border dark:shadow-[0_20px_50px_-12px_rgba(0,0,0,.5)]',
312
+ panelInsetClasses(),
313
+ panelDecorationClasses(),
314
+ sizeClass(),
315
+ panelSizeStretch(),
316
+ local.class,
317
+ )}
318
+ data-side={side()}
319
+ onInteractOutside={(e) => {
320
+ if (!closeOnOverlay()) {
321
+ e.preventDefault()
322
+ return
323
+ }
324
+ // allow default so Kobalte emits onOpenChange(false)
325
+ setCancelReason()
326
+ }}
327
+ {...others}
328
+ >
329
+ {/* Header row when actions are top-end: actions + close (or just close if no footer) */}
330
+ <Show when={actionsPosition() === 'top-end' && (hasFooter() || (canClose() && local.showCloseButton !== false))}>
331
+ <div class="flex shrink-0 items-center justify-end gap-2 border-b border-surface-border px-6 py-4">
332
+ <Show when={hasFooter()}>{actionsBlock()}</Show>
333
+ <Show when={canClose() && local.showCloseButton !== false}>
334
+ <KobalteDialog.CloseButton
335
+ aria-label="Close"
336
+ class="flex h-9 w-9 items-center justify-center rounded-full bg-surface-overlay text-ink-500 hover:bg-surface-dim hover:text-ink-700 dark:hover:text-ink-100"
337
+ onClick={setCloseReason}
338
+ >
339
+ {closeButtonEl()}
340
+ </KobalteDialog.CloseButton>
341
+ </Show>
342
+ </div>
343
+ </Show>
344
+
345
+ <div class="relative flex flex-1 flex-col overflow-hidden p-6">
346
+ {/* Close button in content area when actions are at bottom */}
347
+ <Show when={actionsPosition() === 'bottom' && canClose() && local.showCloseButton !== false}>
348
+ <KobalteDialog.CloseButton
349
+ aria-label="Close"
350
+ class="absolute right-6 top-6 flex h-9 w-9 items-center justify-center rounded-full bg-surface-overlay text-ink-500 hover:bg-surface-dim hover:text-ink-700 dark:hover:text-ink-100"
351
+ onClick={setCloseReason}
352
+ >
353
+ {closeButtonEl()}
354
+ </KobalteDialog.CloseButton>
355
+ </Show>
356
+ <div class={cn('flex min-h-0 flex-1 flex-col overflow-y-auto', actionsPosition() === 'bottom' && canClose() && local.showCloseButton !== false && 'pr-10', hasFooter() && actionsPosition() === 'bottom' && 'min-h-0')}>
357
+ {local.children}
358
+ </div>
359
+ </div>
360
+
361
+ <Show when={actionsPosition() === 'bottom' && hasFooter()}>
362
+ <div class="flex shrink-0 items-center justify-end gap-3 border-t border-surface-border px-6 py-4">
363
+ {actionsBlock()}
364
+ </div>
365
+ </Show>
366
+ </KobalteDialog.Content>
367
+ </KobalteDialog.Portal>
368
+ </KobalteDialog>
369
+ )
370
+ }