@xyhp915/slack-base-ui 0.0.2 → 0.0.4

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@xyhp915/slack-base-ui",
3
3
  "main": "libs/index.js",
4
4
  "types": "libs/index.d.ts",
5
- "version": "0.0.2",
5
+ "version": "0.0.4",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "dev": "vite",
@@ -1,7 +1,14 @@
1
1
  import clsx from 'clsx'
2
2
  import { Dialog as BaseDialog } from '@base-ui/react'
3
- import React from 'react'
3
+ import React, {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useMemo,
8
+ useState,
9
+ } from 'react'
4
10
  import { X } from 'lucide-react'
11
+ import { Button } from './Button'
5
12
 
6
13
  export type DialogSize = 'sm' | 'md' | 'lg' | 'xl';
7
14
 
@@ -127,3 +134,238 @@ DialogTrigger.displayName = 'DialogTrigger'
127
134
 
128
135
  // Re-export Close for custom usage
129
136
  export const DialogClose = BaseDialog.Close
137
+
138
+ // ─── Imperative Dialog API ────────────────────────────────────────────────────
139
+
140
+ export interface ShowDialogOptions {
141
+ title?: string
142
+ description?: string
143
+ /** Custom content rendered inside the dialog body */
144
+ content?: React.ReactNode
145
+ size?: DialogSize
146
+ showCloseButton?: boolean
147
+ className?: string
148
+ }
149
+
150
+ export interface ConfirmDialogOptions {
151
+ title?: string
152
+ description?: string
153
+ content?: React.ReactNode
154
+ size?: DialogSize
155
+ confirmLabel?: string
156
+ cancelLabel?: string
157
+ confirmVariant?: 'primary' | 'danger' | 'secondary'
158
+ }
159
+
160
+ export interface AlertDialogOptions {
161
+ title?: string
162
+ description?: string
163
+ content?: React.ReactNode
164
+ size?: DialogSize
165
+ confirmLabel?: string
166
+ }
167
+
168
+ export interface UseDialogReturn {
169
+ /** Show a generic dialog. Resolves when the dialog is closed. */
170
+ show: (options: ShowDialogOptions) => Promise<void>
171
+ /** Show a confirm dialog. Resolves `true` when confirmed, `false` when cancelled. */
172
+ confirm: (options: ConfirmDialogOptions) => Promise<boolean>
173
+ /** Show an alert dialog with a single OK button. Resolves when dismissed. */
174
+ alert: (options: AlertDialogOptions) => Promise<void>
175
+ }
176
+
177
+ interface DialogEntry {
178
+ id: string
179
+ type: 'show' | 'confirm' | 'alert'
180
+ title?: string
181
+ description?: string
182
+ content?: React.ReactNode
183
+ size?: DialogSize
184
+ showCloseButton?: boolean
185
+ confirmLabel?: string
186
+ cancelLabel?: string
187
+ confirmVariant?: 'primary' | 'danger' | 'secondary'
188
+ className?: string
189
+ resolve: (value: boolean) => void
190
+ }
191
+
192
+ const DialogImperativeContext = createContext<UseDialogReturn | null>(null)
193
+
194
+ /**
195
+ * Provides the imperative dialog API to all descendant components.
196
+ * Must wrap any component that calls `useDialog()`.
197
+ *
198
+ * @example
199
+ * ```tsx
200
+ * // main.tsx
201
+ * <DialogProvider>
202
+ * <App />
203
+ * </DialogProvider>
204
+ *
205
+ * // Any component
206
+ * const { show, confirm, alert } = useDialog()
207
+ * await confirm({ title: 'Delete?', confirmLabel: 'Delete', confirmVariant: 'danger' })
208
+ * ```
209
+ */
210
+ export const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
211
+ const [dialogs, setDialogs] = useState<DialogEntry[]>([])
212
+
213
+ const removeDialog = useCallback((id: string) => {
214
+ setDialogs(prev => prev.filter(d => d.id !== id))
215
+ }, [])
216
+
217
+ const show = useCallback((options: ShowDialogOptions): Promise<void> => {
218
+ return new Promise(resolve => {
219
+ const id = Math.random().toString(36).slice(2, 9)
220
+ setDialogs(prev => [
221
+ ...prev,
222
+ { ...options, id, type: 'show', resolve: () => resolve() },
223
+ ])
224
+ })
225
+ }, [])
226
+
227
+ const confirm = useCallback((options: ConfirmDialogOptions): Promise<boolean> => {
228
+ return new Promise(resolve => {
229
+ const id = Math.random().toString(36).slice(2, 9)
230
+ setDialogs(prev => [...prev, { ...options, id, type: 'confirm', resolve }])
231
+ })
232
+ }, [])
233
+
234
+ const alert = useCallback((options: AlertDialogOptions): Promise<void> => {
235
+ return new Promise(resolve => {
236
+ const id = Math.random().toString(36).slice(2, 9)
237
+ setDialogs(prev => [
238
+ ...prev,
239
+ { ...options, id, type: 'alert', resolve: () => resolve() },
240
+ ])
241
+ })
242
+ }, [])
243
+
244
+ const value = useMemo<UseDialogReturn>(
245
+ () => ({ show, confirm, alert }),
246
+ [show, confirm, alert],
247
+ )
248
+
249
+ return (
250
+ <DialogImperativeContext.Provider value={value}>
251
+ {children}
252
+ {dialogs.map(dialog => (
253
+ <ImperativeDialogItem
254
+ key={dialog.id}
255
+ dialog={dialog}
256
+ onRemove={() => removeDialog(dialog.id)}
257
+ />
258
+ ))}
259
+ </DialogImperativeContext.Provider>
260
+ )
261
+ }
262
+
263
+ DialogProvider.displayName = 'DialogProvider'
264
+
265
+ function ImperativeDialogItem({
266
+ dialog,
267
+ onRemove,
268
+ }: {
269
+ dialog: DialogEntry
270
+ onRemove: () => void
271
+ }) {
272
+ const [open, setOpen] = useState(true)
273
+
274
+ const handleClose = useCallback(
275
+ (result: boolean) => {
276
+ setOpen(false)
277
+ dialog.resolve(result)
278
+ // Allow close animation to finish before unmounting
279
+ setTimeout(onRemove, 300)
280
+ },
281
+ [dialog, onRemove],
282
+ )
283
+
284
+ const commonProps = {
285
+ open,
286
+ title: dialog.title,
287
+ description: dialog.description,
288
+ size: dialog.size,
289
+ className: dialog.className,
290
+ }
291
+
292
+ if (dialog.type === 'show') {
293
+ return (
294
+ <Dialog
295
+ {...commonProps}
296
+ showCloseButton={dialog.showCloseButton ?? true}
297
+ onOpenChange={o => !o && handleClose(false)}
298
+ >
299
+ {dialog.content}
300
+ </Dialog>
301
+ )
302
+ }
303
+
304
+ if (dialog.type === 'confirm') {
305
+ return (
306
+ <Dialog
307
+ {...commonProps}
308
+ size={dialog.size ?? 'sm'}
309
+ showCloseButton={false}
310
+ onOpenChange={o => !o && handleClose(false)}
311
+ >
312
+ {dialog.content}
313
+ <DialogFooter>
314
+ <Button variant="secondary" onClick={() => handleClose(false)}>
315
+ {dialog.cancelLabel ?? 'Cancel'}
316
+ </Button>
317
+ <Button
318
+ variant={dialog.confirmVariant ?? 'primary'}
319
+ onClick={() => handleClose(true)}
320
+ >
321
+ {dialog.confirmLabel ?? 'Confirm'}
322
+ </Button>
323
+ </DialogFooter>
324
+ </Dialog>
325
+ )
326
+ }
327
+
328
+ // alert
329
+ return (
330
+ <Dialog
331
+ {...commonProps}
332
+ size={dialog.size ?? 'sm'}
333
+ showCloseButton={false}
334
+ onOpenChange={o => !o && handleClose(true)}
335
+ >
336
+ {dialog.content}
337
+ <DialogFooter>
338
+ <Button variant="primary" onClick={() => handleClose(true)}>
339
+ {dialog.confirmLabel ?? 'OK'}
340
+ </Button>
341
+ </DialogFooter>
342
+ </Dialog>
343
+ )
344
+ }
345
+
346
+ /**
347
+ * Returns imperative methods for showing dialogs from anywhere in the component tree.
348
+ *
349
+ * Must be used inside `<DialogProvider>`.
350
+ *
351
+ * @example
352
+ * ```tsx
353
+ * const { show, confirm, alert } = useDialog()
354
+ *
355
+ * // Generic dialog
356
+ * await show({ title: 'Welcome', content: <p>Hello world</p> })
357
+ *
358
+ * // Confirm
359
+ * const ok = await confirm({ title: 'Delete item?', confirmLabel: 'Delete', confirmVariant: 'danger' })
360
+ * if (ok) deleteItem()
361
+ *
362
+ * // Alert
363
+ * await alert({ title: 'Error', description: 'Something went wrong.' })
364
+ * ```
365
+ */
366
+ export function useDialog(): UseDialogReturn {
367
+ const ctx = useContext(DialogImperativeContext)
368
+ if (!ctx) throw new Error('useDialog must be used within <DialogProvider>')
369
+ return ctx
370
+ }
371
+
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
2
2
  import { Popover as BasePopover } from '@base-ui/react'
3
3
  import clsx from 'clsx'
4
4
 
@@ -194,6 +194,174 @@ export const PopoverFooter: React.FC<{ children: React.ReactNode; className?: st
194
194
  )
195
195
  }
196
196
 
197
+ // ─── Imperative Popover API ───────────────────────────────────────────────────
198
+
199
+ export interface ImperativePopoverOptions {
200
+ /** Content rendered inside the popover */
201
+ content: React.ReactNode
202
+ side?: 'top' | 'right' | 'bottom' | 'left'
203
+ align?: 'start' | 'center' | 'end'
204
+ sideOffset?: number
205
+ alignOffset?: number
206
+ className?: string
207
+ }
208
+
209
+ export interface UseImperativePopoverReturn {
210
+ /**
211
+ * Show the popover anchored to the given element.
212
+ * If already open on the same anchor, calling `show` again replaces the content.
213
+ */
214
+ show: (anchor: HTMLElement | null, options: ImperativePopoverOptions) => void
215
+ /** Hide the currently open popover. */
216
+ hide: () => void
217
+ /**
218
+ * Toggle the popover for the given anchor.
219
+ * Closes it if the same anchor is already open; otherwise opens on the new anchor.
220
+ */
221
+ toggle: (anchor: HTMLElement | null, options: ImperativePopoverOptions) => void
222
+ /** Whether the imperative popover is currently open. */
223
+ isOpen: boolean
224
+ }
225
+
226
+ interface ImperativePopoverState {
227
+ open: boolean
228
+ anchor: HTMLElement | null
229
+ options: ImperativePopoverOptions
230
+ }
231
+
232
+ const ImperativePopoverContext = createContext<UseImperativePopoverReturn | null>(null)
233
+
234
+ /**
235
+ * Provides the imperative popover API to all descendant components.
236
+ * Only one popover is shown at a time (singleton).
237
+ * Must wrap any component that calls `useImperativePopover()`.
238
+ *
239
+ * @example
240
+ * ```tsx
241
+ * // main.tsx
242
+ * <ImperativePopoverProvider>
243
+ * <App />
244
+ * </ImperativePopoverProvider>
245
+ *
246
+ * // Any component
247
+ * const popover = useImperativePopover()
248
+ *
249
+ * <button onClick={e => popover.show(e.currentTarget, { content: <MyPanel /> })}>
250
+ * Open
251
+ * </button>
252
+ * ```
253
+ */
254
+ export const ImperativePopoverProvider: React.FC<{ children: React.ReactNode }> = ({
255
+ children,
256
+ }) => {
257
+ const [state, setState] = useState<ImperativePopoverState>({
258
+ open: false,
259
+ anchor: null,
260
+ options: { content: null },
261
+ })
262
+
263
+ const show = useCallback(
264
+ (anchor: HTMLElement | null, options: ImperativePopoverOptions) => {
265
+ setState({ open: true, anchor, options })
266
+ },
267
+ [],
268
+ )
269
+
270
+ const hide = useCallback(() => {
271
+ setState(prev => ({ ...prev, open: false }))
272
+ }, [])
273
+
274
+ const toggle = useCallback(
275
+ (anchor: HTMLElement | null, options: ImperativePopoverOptions) => {
276
+ setState(prev => {
277
+ if (prev.open && prev.anchor === anchor) {
278
+ return { ...prev, open: false }
279
+ }
280
+ return { open: true, anchor, options }
281
+ })
282
+ },
283
+ [],
284
+ )
285
+
286
+ const value = useMemo<UseImperativePopoverReturn>(
287
+ () => ({ show, hide, toggle, isOpen: state.open }),
288
+ [show, hide, toggle, state.open],
289
+ )
290
+
291
+ return (
292
+ <ImperativePopoverContext.Provider value={value}>
293
+ {children}
294
+ <ImperativePopoverPortal state={state} onClose={hide} />
295
+ </ImperativePopoverContext.Provider>
296
+ )
297
+ }
298
+
299
+ ImperativePopoverProvider.displayName = 'ImperativePopoverProvider'
300
+
301
+ function ImperativePopoverPortal({
302
+ state,
303
+ onClose,
304
+ }: {
305
+ state: ImperativePopoverState
306
+ onClose: () => void
307
+ }) {
308
+ if (!state.open || !state.anchor) return null
309
+
310
+ const { options } = state
311
+
312
+ return (
313
+ <BasePopover.Root open={state.open} onOpenChange={open => !open && onClose()}>
314
+ <BasePopover.Portal>
315
+ <BasePopover.Positioner
316
+ anchor={state.anchor}
317
+ side={options.side ?? 'bottom'}
318
+ align={options.align ?? 'center'}
319
+ sideOffset={options.sideOffset ?? 8}
320
+ alignOffset={options.alignOffset ?? 0}
321
+ className={'z-50'}
322
+ >
323
+ <BasePopover.Popup
324
+ className={clsx(
325
+ 'z-50 min-w-50 max-w-90 rounded-lg border border-(--border-light) bg-(--bg-primary) shadow-lg',
326
+ options.className,
327
+ )}
328
+ >
329
+ {options.content}
330
+ <BasePopover.Arrow className="fill-(--bg-primary) stroke-(--border-light)" />
331
+ </BasePopover.Popup>
332
+ </BasePopover.Positioner>
333
+ </BasePopover.Portal>
334
+ </BasePopover.Root>
335
+ )
336
+ }
337
+
338
+ /**
339
+ * Returns imperative methods for showing a popover anchored to any element.
340
+ *
341
+ * Must be used inside `<ImperativePopoverProvider>`.
342
+ *
343
+ * @example
344
+ * ```tsx
345
+ * const popover = useImperativePopover()
346
+ *
347
+ * // Show on button click
348
+ * <button onClick={e => popover.show(e.currentTarget, { content: <ProfileCard />, side: 'bottom' })}>
349
+ * Profile
350
+ * </button>
351
+ *
352
+ * // Toggle
353
+ * <button onClick={e => popover.toggle(e.currentTarget, { content: <Settings /> })}>
354
+ * Settings
355
+ * </button>
356
+ * ```
357
+ */
358
+ export function useImperativePopover(): UseImperativePopoverReturn {
359
+ const ctx = useContext(ImperativePopoverContext)
360
+ if (!ctx) throw new Error('useImperativePopover must be used within <ImperativePopoverProvider>')
361
+ return ctx
362
+ }
363
+
364
+
197
365
 
198
366
 
199
367
 
@@ -28,13 +28,17 @@ export {
28
28
  PopoverClose,
29
29
  PopoverHeader,
30
30
  PopoverBody,
31
- PopoverFooter
31
+ PopoverFooter,
32
+ ImperativePopoverProvider,
33
+ useImperativePopover,
32
34
  } from './Popover'
33
35
  export type {
34
36
  PopoverProps,
35
37
  PopoverTriggerProps,
36
38
  PopoverContentProps,
37
- PopoverCloseProps
39
+ PopoverCloseProps,
40
+ ImperativePopoverOptions,
41
+ UseImperativePopoverReturn,
38
42
  } from './Popover'
39
43
 
40
44
  // Menu Components
@@ -100,8 +104,25 @@ export type {
100
104
  } from './ContextMenu'
101
105
 
102
106
  // Dialog Components
103
- export { Dialog, DialogHeader, DialogBody, DialogFooter, DialogTrigger, DialogClose } from './Dialog'
104
- export type { DialogProps, DialogTriggerProps } from './Dialog'
107
+ export {
108
+ Dialog,
109
+ DialogHeader,
110
+ DialogBody,
111
+ DialogFooter,
112
+ DialogTrigger,
113
+ DialogClose,
114
+ DialogProvider,
115
+ useDialog,
116
+ } from './Dialog'
117
+ export type {
118
+ DialogProps,
119
+ DialogTriggerProps,
120
+ DialogSize,
121
+ ShowDialogOptions,
122
+ ConfirmDialogOptions,
123
+ AlertDialogOptions,
124
+ UseDialogReturn,
125
+ } from './Dialog'
105
126
 
106
127
  export { AlertDialog, AlertDialogTrigger } from './AlertDialog'
107
128
  export type { AlertDialogProps, AlertDialogTriggerProps } from './AlertDialog'
package/src/main.tsx CHANGED
@@ -3,13 +3,19 @@ import { createRoot } from 'react-dom/client'
3
3
  import './index.css'
4
4
  import App from './App.tsx'
5
5
  import { ThemeProvider } from './context/ThemeContext'
6
- import { ToastProvider } from './components/Toast'
6
+ import { ToastProvider } from './components'
7
+ import { DialogProvider } from './components'
8
+ import { ImperativePopoverProvider } from './components'
7
9
 
8
10
  createRoot(document.getElementById('root')!).render(
9
11
  <StrictMode>
10
12
  <ThemeProvider>
11
13
  <ToastProvider>
12
- <App />
14
+ <DialogProvider>
15
+ <ImperativePopoverProvider>
16
+ <App />
17
+ </ImperativePopoverProvider>
18
+ </DialogProvider>
13
19
  </ToastProvider>
14
20
  </ThemeProvider>
15
21
  </StrictMode>,