@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.
- package/README.md +166 -0
- package/package.json +67 -0
- package/src/components/actions/Button.tsx +612 -0
- package/src/components/actions/ButtonGroup.tsx +728 -0
- package/src/components/actions/Copy.tsx +98 -0
- package/src/components/actions/DarkModeToggle.tsx +80 -0
- package/src/components/actions/Link.tsx +37 -0
- package/src/components/actions/index.ts +19 -0
- package/src/components/actions/useCopyToClipboard.ts +90 -0
- package/src/components/charts/Chart.tsx +331 -0
- package/src/components/charts/Sparkline.tsx +156 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/data-display/Avatar.tsx +208 -0
- package/src/components/data-display/AvatarGroup.tsx +228 -0
- package/src/components/data-display/Badge.tsx +70 -0
- package/src/components/data-display/Carousel.tsx +214 -0
- package/src/components/data-display/ColorSwatch.tsx +56 -0
- package/src/components/data-display/DataTable.tsx +886 -0
- package/src/components/data-display/EmptyState.tsx +61 -0
- package/src/components/data-display/Image.tsx +277 -0
- package/src/components/data-display/Kbd.tsx +114 -0
- package/src/components/data-display/Persona.tsx +78 -0
- package/src/components/data-display/StatCard.tsx +338 -0
- package/src/components/data-display/Table.tsx +147 -0
- package/src/components/data-display/Tag.tsx +91 -0
- package/src/components/data-display/Timeline.tsx +200 -0
- package/src/components/data-display/TreeView.tsx +172 -0
- package/src/components/data-display/Video.tsx +95 -0
- package/src/components/data-display/avatar-utils.ts +32 -0
- package/src/components/data-display/index.ts +81 -0
- package/src/components/feedback/Loading.tsx +159 -0
- package/src/components/feedback/Progress.tsx +321 -0
- package/src/components/feedback/Skeleton.tsx +62 -0
- package/src/components/feedback/SkeletonBlocks.tsx +222 -0
- package/src/components/feedback/Toast.tsx +648 -0
- package/src/components/feedback/index.ts +44 -0
- package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
- package/src/components/feedback/password/password-strength.ts +115 -0
- package/src/components/feedback/password/password-validation-data.ts +66 -0
- package/src/components/feedback/password/password-validation.ts +93 -0
- package/src/components/forms/Autocomplete.tsx +268 -0
- package/src/components/forms/Checkbox.tsx +155 -0
- package/src/components/forms/CodeInput.tsx +237 -0
- package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
- package/src/components/forms/ColorPicker/color-utils.ts +75 -0
- package/src/components/forms/ColorPicker/index.ts +2 -0
- package/src/components/forms/DatePicker.tsx +516 -0
- package/src/components/forms/DateRangePicker.tsx +464 -0
- package/src/components/forms/FieldPicker.tsx +64 -0
- package/src/components/forms/FileUpload.tsx +614 -0
- package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
- package/src/components/forms/FilterBuilder.tsx +16 -0
- package/src/components/forms/FilterRuleRow.tsx +68 -0
- package/src/components/forms/Input.tsx +200 -0
- package/src/components/forms/MultiSelect.tsx +361 -0
- package/src/components/forms/NumberField.tsx +145 -0
- package/src/components/forms/RadioGroup.tsx +135 -0
- package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
- package/src/components/forms/ReorderableList.tsx +163 -0
- package/src/components/forms/Select.tsx +268 -0
- package/src/components/forms/Slider.tsx +260 -0
- package/src/components/forms/Switch.tsx +135 -0
- package/src/components/forms/TextArea.tsx +202 -0
- package/src/components/forms/ViewCustomizer.tsx +44 -0
- package/src/components/forms/index.ts +43 -0
- package/src/components/layout/Accordion.tsx +110 -0
- package/src/components/layout/Alert.tsx +156 -0
- package/src/components/layout/BlockQuote.tsx +70 -0
- package/src/components/layout/Card.tsx +166 -0
- package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
- package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
- package/src/components/layout/CodeBlock/prism.ts +81 -0
- package/src/components/layout/Collapsible.tsx +84 -0
- package/src/components/layout/Container.tsx +55 -0
- package/src/components/layout/Divider.tsx +64 -0
- package/src/components/layout/Form.tsx +39 -0
- package/src/components/layout/FormActions.tsx +50 -0
- package/src/components/layout/Grid.tsx +53 -0
- package/src/components/layout/PageHeading.tsx +46 -0
- package/src/components/layout/PromptWithAction.tsx +49 -0
- package/src/components/layout/Section.tsx +60 -0
- package/src/components/layout/TablePanel.tsx +24 -0
- package/src/components/layout/TableView/TableView.tsx +1018 -0
- package/src/components/layout/TableView/index.ts +3 -0
- package/src/components/layout/TableView/types.ts +51 -0
- package/src/components/layout/WizardStep.tsx +40 -0
- package/src/components/layout/WizardStepper.tsx +173 -0
- package/src/components/layout/index.ts +96 -0
- package/src/components/navigation/Breadcrumbs.tsx +66 -0
- package/src/components/navigation/DropdownMenu.tsx +86 -0
- package/src/components/navigation/MegaMenu.tsx +480 -0
- package/src/components/navigation/NavigationMenu.tsx +305 -0
- package/src/components/navigation/Pagination.tsx +298 -0
- package/src/components/navigation/Sidebar.tsx +280 -0
- package/src/components/navigation/Tabs.tsx +122 -0
- package/src/components/navigation/ViewSwitcher.tsx +314 -0
- package/src/components/navigation/index.ts +66 -0
- package/src/components/overlays/AlertDialog.tsx +174 -0
- package/src/components/overlays/ContextMenu.tsx +65 -0
- package/src/components/overlays/Dialog.tsx +279 -0
- package/src/components/overlays/Drawer.tsx +370 -0
- package/src/components/overlays/HoverCard.tsx +107 -0
- package/src/components/overlays/Popover.tsx +73 -0
- package/src/components/overlays/Tooltip.tsx +31 -0
- package/src/components/overlays/index.ts +71 -0
- package/src/components/typography/Code.tsx +72 -0
- package/src/components/typography/Icon.tsx +36 -0
- package/src/components/typography/index.ts +10 -0
- package/src/env.d.ts +9 -0
- package/src/index.ts +13 -0
- package/src/styles/theme.css +226 -0
- package/src/types/avatar-types.ts +11 -0
- package/src/types/filter-types.ts +35 -0
- package/src/utilities/classNames.ts +6 -0
- package/src/utilities/componentSize.ts +46 -0
- package/src/utilities/i18n.tsx +60 -0
- package/src/utilities/mergeRefs.ts +12 -0
- 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
|
+
}
|