@a4ui/core 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -37
- package/dist/elements.css +79 -9
- package/dist/full.css +3112 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +3392 -2537
- package/dist/lib/flyToCart.d.ts +25 -0
- package/dist/ui/Curtain.d.ts +44 -0
- package/dist/ui/Expandable.d.ts +34 -0
- package/dist/ui/FillText.d.ts +18 -0
- package/dist/ui/HoldToConfirm.d.ts +24 -0
- package/dist/ui/LoadingDots.d.ts +20 -0
- package/dist/ui/MultiStateBadge.d.ts +23 -0
- package/dist/ui/NotificationStack.d.ts +32 -0
- package/dist/ui/NowPlaying.d.ts +21 -0
- package/dist/ui/Parallax.d.ts +21 -0
- package/dist/ui/ScrambleText.d.ts +22 -0
- package/dist/ui/TextReveal.d.ts +23 -0
- package/package.json +8 -1
- package/src/charts/BarChart.tsx +55 -0
- package/src/charts/DonutChart.tsx +127 -0
- package/src/charts/Sparkline.tsx +101 -0
- package/src/charts/index.ts +5 -0
- package/src/commerce/CartLine.tsx +84 -0
- package/src/commerce/CartSummary.tsx +70 -0
- package/src/commerce/FilterGroup.tsx +70 -0
- package/src/commerce/PriceTag.tsx +62 -0
- package/src/commerce/ProductCard.tsx +121 -0
- package/src/commerce/ProductGrid.tsx +27 -0
- package/src/commerce/QuantityStepper.tsx +59 -0
- package/src/commerce/index.ts +10 -0
- package/src/elements.tsx +167 -0
- package/src/index.ts +186 -0
- package/src/layout/AppShell.tsx +102 -0
- package/src/layout/ChristmasBackground.tsx +177 -0
- package/src/layout/EffectsToggle.tsx +41 -0
- package/src/layout/NavGroup.tsx +45 -0
- package/src/layout/SnowScenery.tsx +76 -0
- package/src/layout/SpaceBackground.tsx +459 -0
- package/src/layout/ThemeToggle.tsx +40 -0
- package/src/layout/ThemedScenery.tsx +179 -0
- package/src/layout/sceneEffects.ts +49 -0
- package/src/lib/cn.ts +17 -0
- package/src/lib/effects.ts +71 -0
- package/src/lib/flyToCart.ts +103 -0
- package/src/lib/media.ts +27 -0
- package/src/lib/motion.ts +191 -0
- package/src/lib/theme.ts +113 -0
- package/src/lib/virtual.ts +33 -0
- package/src/styles/space.css +510 -0
- package/src/styles/tokens.css +213 -0
- package/src/themes/index.ts +102 -0
- package/src/themes/palettes.ts +368 -0
- package/src/ui/Accordion.tsx +65 -0
- package/src/ui/Affix.tsx +72 -0
- package/src/ui/Alert.tsx +55 -0
- package/src/ui/AlertDialog.tsx +58 -0
- package/src/ui/Anchor.tsx +100 -0
- package/src/ui/Avatar.tsx +34 -0
- package/src/ui/AvatarGroup.tsx +55 -0
- package/src/ui/BackToTop.tsx +51 -0
- package/src/ui/Badge.tsx +46 -0
- package/src/ui/BottomNavigation.tsx +65 -0
- package/src/ui/Breadcrumb.tsx +64 -0
- package/src/ui/Button.tsx +49 -0
- package/src/ui/Calendar.tsx +40 -0
- package/src/ui/CalendarHeatmap.tsx +180 -0
- package/src/ui/Card.tsx +84 -0
- package/src/ui/Carousel.tsx +121 -0
- package/src/ui/Cascader.tsx +158 -0
- package/src/ui/Checkbox.tsx +35 -0
- package/src/ui/Clock.tsx +221 -0
- package/src/ui/Collapse.tsx +48 -0
- package/src/ui/ColorPicker.tsx +89 -0
- package/src/ui/Combobox.tsx +67 -0
- package/src/ui/Command.tsx +158 -0
- package/src/ui/Comment.tsx +91 -0
- package/src/ui/ContextMenu.tsx +58 -0
- package/src/ui/Countdown.tsx +123 -0
- package/src/ui/Curtain.tsx +348 -0
- package/src/ui/DataGrid.tsx +177 -0
- package/src/ui/DateField.tsx +177 -0
- package/src/ui/DateRangePicker.tsx +204 -0
- package/src/ui/DateTimeField.tsx +68 -0
- package/src/ui/Descriptions.tsx +56 -0
- package/src/ui/Drawer.tsx +72 -0
- package/src/ui/Dropdown.tsx +71 -0
- package/src/ui/Dropzone.tsx +88 -0
- package/src/ui/Empty.tsx +53 -0
- package/src/ui/Expandable.tsx +229 -0
- package/src/ui/FileUpload.tsx +198 -0
- package/src/ui/FillText.tsx +63 -0
- package/src/ui/FloatingActionButton.tsx +48 -0
- package/src/ui/Form.tsx +108 -0
- package/src/ui/Highlight.tsx +53 -0
- package/src/ui/HoldToConfirm.tsx +141 -0
- package/src/ui/HoverCard.tsx +42 -0
- package/src/ui/Image.tsx +66 -0
- package/src/ui/Input.tsx +38 -0
- package/src/ui/Kbd.tsx +25 -0
- package/src/ui/List.tsx +71 -0
- package/src/ui/LoadingDots.tsx +75 -0
- package/src/ui/Marquee.tsx +48 -0
- package/src/ui/Mentions.tsx +170 -0
- package/src/ui/Meter.tsx +57 -0
- package/src/ui/Modal.tsx +106 -0
- package/src/ui/MultiSelect.tsx +236 -0
- package/src/ui/MultiStateBadge.tsx +93 -0
- package/src/ui/NotificationCenter.tsx +103 -0
- package/src/ui/NotificationStack.tsx +104 -0
- package/src/ui/NowPlaying.tsx +100 -0
- package/src/ui/NumberInput.tsx +48 -0
- package/src/ui/PageHeader.tsx +60 -0
- package/src/ui/Pagination.tsx +62 -0
- package/src/ui/Parallax.tsx +51 -0
- package/src/ui/Popover.tsx +42 -0
- package/src/ui/Portal.tsx +34 -0
- package/src/ui/Progress.tsx +55 -0
- package/src/ui/RadioGroup.tsx +65 -0
- package/src/ui/Rating.tsx +98 -0
- package/src/ui/Result.tsx +75 -0
- package/src/ui/RingProgress.tsx +75 -0
- package/src/ui/ScrambleText.tsx +95 -0
- package/src/ui/SegmentedControl.tsx +61 -0
- package/src/ui/Select.tsx +55 -0
- package/src/ui/Separator.tsx +39 -0
- package/src/ui/Skeleton.tsx +23 -0
- package/src/ui/Slider.tsx +57 -0
- package/src/ui/Sortable.tsx +174 -0
- package/src/ui/SpeedDial.tsx +85 -0
- package/src/ui/Spinner.tsx +33 -0
- package/src/ui/Splitter.tsx +115 -0
- package/src/ui/Stat.tsx +74 -0
- package/src/ui/Stepper.tsx +134 -0
- package/src/ui/Switch.tsx +44 -0
- package/src/ui/Table.tsx +128 -0
- package/src/ui/Tabs.tsx +72 -0
- package/src/ui/TagInput.tsx +85 -0
- package/src/ui/TextReveal.tsx +93 -0
- package/src/ui/Textarea.tsx +38 -0
- package/src/ui/TimeField.tsx +298 -0
- package/src/ui/Timeline.tsx +89 -0
- package/src/ui/Toast.tsx +68 -0
- package/src/ui/Toggle.tsx +41 -0
- package/src/ui/ToggleGroup.tsx +59 -0
- package/src/ui/Tooltip.tsx +42 -0
- package/src/ui/Tour.tsx +197 -0
- package/src/ui/Transfer.tsx +112 -0
- package/src/ui/Tree.tsx +100 -0
- package/src/ui/TreeSelect.tsx +151 -0
- package/src/ui/VirtualList.tsx +81 -0
- package/src/ui/internal/CalendarCore.tsx +276 -0
package/src/ui/Image.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Content image with an optional click-to-zoom lightbox, built on Kobalte's
|
|
2
|
+
// Dialog primitive. The thumbnail lazy-loads; when `preview` is on it becomes a
|
|
3
|
+
// button that opens a dimmed, backdrop-blurred overlay with an enlarged copy of
|
|
4
|
+
// the image and a corner close button (focus trap/portal handled by Kobalte).
|
|
5
|
+
import { Dialog } from '@kobalte/core/dialog'
|
|
6
|
+
import { X } from 'lucide-solid'
|
|
7
|
+
import type { JSX } from 'solid-js'
|
|
8
|
+
import { createSignal, Show } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
|
|
12
|
+
export interface ImageProps {
|
|
13
|
+
src: string
|
|
14
|
+
alt: string
|
|
15
|
+
class?: string
|
|
16
|
+
/** When true, clicking the image opens a zoomable lightbox. @default true */
|
|
17
|
+
preview?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lazy-loaded content image. Unless `preview` is disabled, the thumbnail is a
|
|
22
|
+
* zoom-in button that opens a centered lightbox (dimmed overlay + close button)
|
|
23
|
+
* on Kobalte's `Dialog` primitive.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <Image src="/photo.jpg" alt="A scenic view" />
|
|
28
|
+
* <Image src="/logo.svg" alt="Logo" preview={false} />
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function Image(props: ImageProps): JSX.Element {
|
|
32
|
+
const [open, setOpen] = createSignal(false)
|
|
33
|
+
const preview = () => props.preview !== false
|
|
34
|
+
|
|
35
|
+
const img = (
|
|
36
|
+
<img src={props.src} alt={props.alt} loading="lazy" class={cn('rounded-lg object-cover', props.class)} />
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Show when={preview()} fallback={img}>
|
|
41
|
+
<button type="button" class="cursor-zoom-in" aria-label={props.alt} onClick={() => setOpen(true)}>
|
|
42
|
+
{img}
|
|
43
|
+
</button>
|
|
44
|
+
<Dialog open={open()} onOpenChange={setOpen}>
|
|
45
|
+
<Dialog.Portal>
|
|
46
|
+
<Dialog.Overlay class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" />
|
|
47
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
48
|
+
<Dialog.Content role="dialog" class="relative">
|
|
49
|
+
<img
|
|
50
|
+
src={props.src}
|
|
51
|
+
alt={props.alt}
|
|
52
|
+
class="max-h-[85vh] max-w-[90vw] rounded-lg object-contain"
|
|
53
|
+
/>
|
|
54
|
+
<Dialog.CloseButton
|
|
55
|
+
class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg bg-background/70 text-foreground transition hover:bg-muted"
|
|
56
|
+
aria-label="Close"
|
|
57
|
+
>
|
|
58
|
+
<X class="h-5 w-5" />
|
|
59
|
+
</Dialog.CloseButton>
|
|
60
|
+
</Dialog.Content>
|
|
61
|
+
</div>
|
|
62
|
+
</Dialog.Portal>
|
|
63
|
+
</Dialog>
|
|
64
|
+
</Show>
|
|
65
|
+
)
|
|
66
|
+
}
|
package/src/ui/Input.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `disabled` is wired onto the <input> element (not just styled).
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
import { splitProps } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
const INPUT_BASE =
|
|
8
|
+
'w-full rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-2 text-sm outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50'
|
|
9
|
+
|
|
10
|
+
interface InputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onInput'> {
|
|
11
|
+
value: string
|
|
12
|
+
/** Called with the raw string value on every input event (controlled). */
|
|
13
|
+
onInput: (value: string) => void
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Themed text input with a value/onInput controlled-string API (instead of raw
|
|
19
|
+
* DOM events). All other native `<input>` attributes pass through, so `type`,
|
|
20
|
+
* `disabled`, `placeholder`, etc. work as usual.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const [name, setName] = createSignal('')
|
|
25
|
+
* <Input value={name()} onInput={setName} placeholder="Full name" />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function Input(props: InputProps): JSX.Element {
|
|
29
|
+
const [local, rest] = splitProps(props, ['value', 'onInput', 'class'])
|
|
30
|
+
return (
|
|
31
|
+
<input
|
|
32
|
+
class={cn(INPUT_BASE, local.class)}
|
|
33
|
+
value={local.value}
|
|
34
|
+
onInput={(ev) => local.onInput(ev.currentTarget.value)}
|
|
35
|
+
{...rest}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
}
|
package/src/ui/Kbd.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Kbd — keyboard key indicator. Renders a single keycap for documenting
|
|
2
|
+
// shortcuts; compose several side by side for chords (e.g. ⌘ + K). Purely
|
|
3
|
+
// presentational and theme-agnostic — colors come from semantic tokens.
|
|
4
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
interface KbdProps extends ParentProps {
|
|
9
|
+
class?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const KBD_BASE =
|
|
13
|
+
'inline-flex min-w-[1.5rem] items-center justify-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground shadow-sm'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Keycap-styled indicator for a keyboard key or shortcut token.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <Kbd>⌘</Kbd><Kbd>K</Kbd>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function Kbd(props: KbdProps): JSX.Element {
|
|
24
|
+
return <kbd class={cn(KBD_BASE, props.class)}>{props.children}</kbd>
|
|
25
|
+
}
|
package/src/ui/List.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Vertical list primitive (plain HTML + Tailwind, no headless dep). Renders a
|
|
2
|
+
// semantic <ul> of rows, each with an optional leading avatar, a title +
|
|
3
|
+
// optional description column, and an optional trailing meta/actions slot.
|
|
4
|
+
import { For, Show, type JSX } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
/** A single row in a {@link List}. */
|
|
9
|
+
export interface ListItem {
|
|
10
|
+
title: string
|
|
11
|
+
description?: string
|
|
12
|
+
avatar?: JSX.Element
|
|
13
|
+
meta?: JSX.Element
|
|
14
|
+
actions?: JSX.Element
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ListProps {
|
|
18
|
+
items: ListItem[]
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type { ListProps }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Vertical list of rows inside a rounded, bordered container with divider
|
|
26
|
+
* lines between items. Each item shows an optional leading `avatar`, a
|
|
27
|
+
* `title` with optional `description`, and an optional trailing `meta` /
|
|
28
|
+
* `actions` slot.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <List
|
|
33
|
+
* items={[
|
|
34
|
+
* {
|
|
35
|
+
* title: 'Alfredo Rivera',
|
|
36
|
+
* description: 'Owner',
|
|
37
|
+
* avatar: <Avatar name="Alfredo" />,
|
|
38
|
+
* meta: <span>2h ago</span>,
|
|
39
|
+
* actions: <Button size="sm">Edit</Button>,
|
|
40
|
+
* },
|
|
41
|
+
* ]}
|
|
42
|
+
* />
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function List(props: ListProps): JSX.Element {
|
|
46
|
+
return (
|
|
47
|
+
<ul class={cn('divide-y divide-border rounded-lg border border-border', props.class)}>
|
|
48
|
+
<For each={props.items}>
|
|
49
|
+
{(item) => (
|
|
50
|
+
<li class="flex items-center gap-3 px-4 py-3">
|
|
51
|
+
<Show when={item.avatar}>
|
|
52
|
+
<div class="shrink-0">{item.avatar}</div>
|
|
53
|
+
</Show>
|
|
54
|
+
<div class="min-w-0 flex-1">
|
|
55
|
+
<p class="text-sm font-medium text-foreground">{item.title}</p>
|
|
56
|
+
<Show when={item.description}>
|
|
57
|
+
<p class="text-xs text-muted-foreground">{item.description}</p>
|
|
58
|
+
</Show>
|
|
59
|
+
</div>
|
|
60
|
+
<Show when={item.meta}>
|
|
61
|
+
<div class="text-xs text-muted-foreground">{item.meta}</div>
|
|
62
|
+
</Show>
|
|
63
|
+
<Show when={item.actions}>
|
|
64
|
+
<div class="shrink-0">{item.actions}</div>
|
|
65
|
+
</Show>
|
|
66
|
+
</li>
|
|
67
|
+
)}
|
|
68
|
+
</For>
|
|
69
|
+
</ul>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Three dots bouncing in a staggered wave — a lightweight indeterminate
|
|
2
|
+
// loading indicator. The bounce is pure CSS (keyframes injected once into the
|
|
3
|
+
// document head) so there's no per-frame JS cost; reduced motion swaps the
|
|
4
|
+
// bounce for a gentle opacity pulse.
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { motionReduced } from '../lib/motion'
|
|
9
|
+
|
|
10
|
+
export interface LoadingDotsProps {
|
|
11
|
+
/** Dot diameter in px. @default 8 */
|
|
12
|
+
size?: number
|
|
13
|
+
/** Optional accessible label. @default 'Loading' */
|
|
14
|
+
label?: string
|
|
15
|
+
class?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STYLE_ID = 'a4ui-loading-dots-style'
|
|
19
|
+
|
|
20
|
+
function ensureStyleInjected(): void {
|
|
21
|
+
if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
|
|
22
|
+
const style = document.createElement('style')
|
|
23
|
+
style.id = STYLE_ID
|
|
24
|
+
style.textContent = `
|
|
25
|
+
@keyframes a4-dot-bounce {
|
|
26
|
+
0%, 100% { transform: translateY(0); }
|
|
27
|
+
50% { transform: translateY(-60%); }
|
|
28
|
+
}
|
|
29
|
+
@keyframes a4-dot-pulse {
|
|
30
|
+
0%, 100% { opacity: 1; }
|
|
31
|
+
50% { opacity: 0.35; }
|
|
32
|
+
}
|
|
33
|
+
`
|
|
34
|
+
document.head.appendChild(style)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Three dots bouncing in a staggered wave — an inline, indeterminate loading
|
|
39
|
+
* indicator. Dots use `bg-current`, so color/size follow the ancestor's text
|
|
40
|
+
* color; diameter is set via `size`. Falls back to a gentle opacity pulse
|
|
41
|
+
* under reduced motion.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <LoadingDots label="Loading results" class="text-primary" />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function LoadingDots(props: LoadingDotsProps): JSX.Element {
|
|
49
|
+
ensureStyleInjected()
|
|
50
|
+
|
|
51
|
+
const size = () => props.size ?? 8
|
|
52
|
+
const animationName = () => (motionReduced() ? 'a4-dot-pulse' : 'a4-dot-bounce')
|
|
53
|
+
|
|
54
|
+
const dotStyle = (delayMs: number): JSX.CSSProperties => ({
|
|
55
|
+
width: `${size()}px`,
|
|
56
|
+
height: `${size()}px`,
|
|
57
|
+
'animation-name': animationName(),
|
|
58
|
+
'animation-duration': '0.6s',
|
|
59
|
+
'animation-iteration-count': 'infinite',
|
|
60
|
+
'animation-timing-function': 'ease-in-out',
|
|
61
|
+
'animation-delay': `${delayMs}ms`,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<span
|
|
66
|
+
role="status"
|
|
67
|
+
aria-label={props.label ?? 'Loading'}
|
|
68
|
+
class={cn('inline-flex items-center gap-1 text-current', props.class)}
|
|
69
|
+
>
|
|
70
|
+
<span class="rounded-full bg-current" style={dotStyle(0)} />
|
|
71
|
+
<span class="rounded-full bg-current" style={dotStyle(120)} />
|
|
72
|
+
<span class="rounded-full bg-current" style={dotStyle(240)} />
|
|
73
|
+
</span>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Infinite horizontal scroller that loops its children seamlessly.
|
|
2
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn'
|
|
5
|
+
import { motionReduced } from '../lib/motion'
|
|
6
|
+
|
|
7
|
+
interface MarqueeProps extends ParentProps {
|
|
8
|
+
/** Seconds for one full loop of the track. Lower is faster. Defaults to `20`. */
|
|
9
|
+
speed?: number
|
|
10
|
+
/** Pause the scroll while the pointer hovers the strip. Defaults to `true`. */
|
|
11
|
+
pauseOnHover?: boolean
|
|
12
|
+
class?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A horizontally scrolling strip that loops its children seamlessly. The
|
|
17
|
+
* children are rendered twice inside a flex track; when the first copy has
|
|
18
|
+
* scrolled fully out of view the second copy sits exactly where the first
|
|
19
|
+
* began, so the animation restarts with no visible seam. Honors reduced
|
|
20
|
+
* motion (the track sits still) and, by default, pauses on hover.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <Marquee speed={30}>
|
|
25
|
+
* <span class="mx-4">A4ui</span>
|
|
26
|
+
* <span class="mx-4">SolidJS</span>
|
|
27
|
+
* <span class="mx-4">Tailwind</span>
|
|
28
|
+
* </Marquee>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function Marquee(props: MarqueeProps): JSX.Element {
|
|
32
|
+
const pauseOnHover = () => props.pauseOnHover ?? true
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div class={cn('overflow-hidden', props.class)}>
|
|
36
|
+
<style>{'@keyframes marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}'}</style>
|
|
37
|
+
<div
|
|
38
|
+
class={cn('flex w-max shrink-0', pauseOnHover() && 'hover:[animation-play-state:paused]')}
|
|
39
|
+
style={motionReduced() ? undefined : { animation: `marquee ${props.speed ?? 20}s linear infinite` }}
|
|
40
|
+
>
|
|
41
|
+
<div class="flex shrink-0">{props.children}</div>
|
|
42
|
+
<div class="flex shrink-0" aria-hidden="true">
|
|
43
|
+
{props.children}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Multi-line text field with @mention autocomplete over a native <textarea>.
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
import { For, Show, createSignal } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
export interface MentionOption {
|
|
8
|
+
value: string
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MentionsProps {
|
|
13
|
+
value: string
|
|
14
|
+
/** Called with the new string value on every input (not the raw DOM event). */
|
|
15
|
+
onChange: (value: string) => void
|
|
16
|
+
/** Options offered while typing an `@mention`. */
|
|
17
|
+
options: MentionOption[]
|
|
18
|
+
placeholder?: string
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TEXTAREA_BASE =
|
|
23
|
+
'w-full min-h-[80px] resize-y rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-2 text-sm outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50'
|
|
24
|
+
|
|
25
|
+
/** Match an active `@query` immediately before the caret. */
|
|
26
|
+
const MENTION_RE = /@(\w*)$/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Controlled multi-line text field that shows an autocomplete dropdown while
|
|
30
|
+
* the user types an `@mention`. On each input, the text before the caret is
|
|
31
|
+
* checked for a trailing `@query`; matching {@link MentionOption}s (by `label`
|
|
32
|
+
* or `value`, case-insensitive) are offered in a panel. Selecting one replaces
|
|
33
|
+
* the `@query` with `@{label} `. Arrow keys move the highlight, Enter accepts,
|
|
34
|
+
* Escape dismisses.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* const [body, setBody] = createSignal('')
|
|
39
|
+
* <Mentions
|
|
40
|
+
* value={body()}
|
|
41
|
+
* onChange={setBody}
|
|
42
|
+
* options={[
|
|
43
|
+
* { value: 'ada', label: 'Ada Lovelace' },
|
|
44
|
+
* { value: 'alan', label: 'Alan Turing' },
|
|
45
|
+
* ]}
|
|
46
|
+
* placeholder="Write a note, use @ to mention someone"
|
|
47
|
+
* />
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function Mentions(props: MentionsProps): JSX.Element {
|
|
51
|
+
let ref: HTMLTextAreaElement | undefined
|
|
52
|
+
const [open, setOpen] = createSignal(false)
|
|
53
|
+
const [query, setQuery] = createSignal('')
|
|
54
|
+
const [caret, setCaret] = createSignal(0)
|
|
55
|
+
const [highlighted, setHighlighted] = createSignal(0)
|
|
56
|
+
|
|
57
|
+
const filtered = (): MentionOption[] => {
|
|
58
|
+
const q = query().toLowerCase()
|
|
59
|
+
return props.options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const close = () => {
|
|
63
|
+
setOpen(false)
|
|
64
|
+
setQuery('')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sync = (el: HTMLTextAreaElement) => {
|
|
68
|
+
const pos = el.selectionStart ?? el.value.length
|
|
69
|
+
setCaret(pos)
|
|
70
|
+
const before = el.value.slice(0, pos)
|
|
71
|
+
const match = before.match(MENTION_RE)
|
|
72
|
+
if (match) {
|
|
73
|
+
setQuery(match[1])
|
|
74
|
+
setOpen(true)
|
|
75
|
+
setHighlighted(0)
|
|
76
|
+
} else {
|
|
77
|
+
close()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const select = (option: MentionOption) => {
|
|
82
|
+
const el = ref
|
|
83
|
+
const full = props.value
|
|
84
|
+
const pos = el ? (el.selectionStart ?? caret()) : caret()
|
|
85
|
+
const before = full.slice(0, pos)
|
|
86
|
+
const after = full.slice(pos)
|
|
87
|
+
const start = before.lastIndexOf('@')
|
|
88
|
+
if (start === -1) {
|
|
89
|
+
close()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const insert = `@${option.label} `
|
|
93
|
+
const next = full.slice(0, start) + insert + after
|
|
94
|
+
props.onChange(next)
|
|
95
|
+
close()
|
|
96
|
+
if (el) {
|
|
97
|
+
const nextCaret = start + insert.length
|
|
98
|
+
queueMicrotask(() => {
|
|
99
|
+
el.focus()
|
|
100
|
+
el.setSelectionRange(nextCaret, nextCaret)
|
|
101
|
+
setCaret(nextCaret)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const onKeyDown = (ev: KeyboardEvent) => {
|
|
107
|
+
if (!open()) return
|
|
108
|
+
const items = filtered()
|
|
109
|
+
if (items.length === 0) return
|
|
110
|
+
switch (ev.key) {
|
|
111
|
+
case 'ArrowDown':
|
|
112
|
+
ev.preventDefault()
|
|
113
|
+
setHighlighted((i) => (i + 1) % items.length)
|
|
114
|
+
break
|
|
115
|
+
case 'ArrowUp':
|
|
116
|
+
ev.preventDefault()
|
|
117
|
+
setHighlighted((i) => (i - 1 + items.length) % items.length)
|
|
118
|
+
break
|
|
119
|
+
case 'Enter':
|
|
120
|
+
ev.preventDefault()
|
|
121
|
+
select(items[Math.min(highlighted(), items.length - 1)])
|
|
122
|
+
break
|
|
123
|
+
case 'Escape':
|
|
124
|
+
ev.preventDefault()
|
|
125
|
+
close()
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div class={cn('relative', props.class)}>
|
|
132
|
+
<textarea
|
|
133
|
+
ref={ref}
|
|
134
|
+
class={TEXTAREA_BASE}
|
|
135
|
+
value={props.value}
|
|
136
|
+
placeholder={props.placeholder}
|
|
137
|
+
onInput={(ev) => {
|
|
138
|
+
props.onChange(ev.currentTarget.value)
|
|
139
|
+
sync(ev.currentTarget)
|
|
140
|
+
}}
|
|
141
|
+
onClick={(ev) => sync(ev.currentTarget)}
|
|
142
|
+
onKeyUp={(ev) => sync(ev.currentTarget)}
|
|
143
|
+
onKeyDown={onKeyDown}
|
|
144
|
+
onBlur={() => close()}
|
|
145
|
+
/>
|
|
146
|
+
<Show when={open() && filtered().length > 0}>
|
|
147
|
+
<div class="absolute z-50 mt-1 max-h-48 w-56 overflow-y-auto rounded-md border border-border bg-card p-1 shadow-md">
|
|
148
|
+
<For each={filtered()}>
|
|
149
|
+
{(option, i) => (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
class={cn(
|
|
153
|
+
'w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted',
|
|
154
|
+
i() === highlighted() && 'bg-muted',
|
|
155
|
+
)}
|
|
156
|
+
onMouseDown={(ev) => {
|
|
157
|
+
ev.preventDefault()
|
|
158
|
+
select(option)
|
|
159
|
+
}}
|
|
160
|
+
onMouseEnter={() => setHighlighted(i())}
|
|
161
|
+
>
|
|
162
|
+
{option.label}
|
|
163
|
+
</button>
|
|
164
|
+
)}
|
|
165
|
+
</For>
|
|
166
|
+
</div>
|
|
167
|
+
</Show>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
package/src/ui/Meter.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Static measurement gauge on Kobalte's Meter primitive (e.g. disk usage).
|
|
2
|
+
import { Meter as KMeter } from '@kobalte/core/meter'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { Show } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
interface MeterProps {
|
|
9
|
+
value: number
|
|
10
|
+
/** @default 100 */
|
|
11
|
+
max?: number
|
|
12
|
+
/** Label shown above the track, paired with an auto-formatted value label. */
|
|
13
|
+
label?: string
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Static measurement gauge (e.g. disk/quota usage) on Kobalte's `Meter` primitive.
|
|
19
|
+
* Unlike a progress bar, a meter represents a fixed measurement against a range,
|
|
20
|
+
* not an in-flight operation.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <Meter value={72} max={100} label="Storage used" />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function Meter(props: MeterProps): JSX.Element {
|
|
28
|
+
// Kobalte's Meter.Fill has no intrinsic width — we set it ourselves from the
|
|
29
|
+
// value as a fraction of the range, clamped to 0–100%. (Without this the bar
|
|
30
|
+
// ignored `max`, so e.g. value 38 of max 50 read "76%" but filled only ~38%.)
|
|
31
|
+
const percent = () => {
|
|
32
|
+
const max = props.max ?? 100
|
|
33
|
+
if (max <= 0) return 0
|
|
34
|
+
return Math.max(0, Math.min(100, (props.value / max) * 100))
|
|
35
|
+
}
|
|
36
|
+
return (
|
|
37
|
+
<KMeter
|
|
38
|
+
value={props.value}
|
|
39
|
+
minValue={0}
|
|
40
|
+
maxValue={props.max ?? 100}
|
|
41
|
+
class={cn('flex flex-col gap-1.5', props.class)}
|
|
42
|
+
>
|
|
43
|
+
<Show when={props.label}>
|
|
44
|
+
<div class="flex items-center justify-between text-sm text-foreground">
|
|
45
|
+
<KMeter.Label>{props.label}</KMeter.Label>
|
|
46
|
+
<KMeter.ValueLabel class="text-muted-foreground" />
|
|
47
|
+
</div>
|
|
48
|
+
</Show>
|
|
49
|
+
<KMeter.Track class="h-2 overflow-hidden rounded-full bg-muted">
|
|
50
|
+
<KMeter.Fill
|
|
51
|
+
class="h-full rounded-full bg-primary transition-all duration-500"
|
|
52
|
+
style={{ width: `${percent()}%` }}
|
|
53
|
+
/>
|
|
54
|
+
</KMeter.Track>
|
|
55
|
+
</KMeter>
|
|
56
|
+
)
|
|
57
|
+
}
|
package/src/ui/Modal.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Dialog on Kobalte's Dialog primitive. Two shapes via `variant`:
|
|
2
|
+
// - 'drawer' (default): right-anchored slide-over — create/edit forms, detail
|
|
3
|
+
// panels (more vertical room, less jarring than a centered pop).
|
|
4
|
+
// - 'center': the classic centered modal — short confirmations (destructive
|
|
5
|
+
// yes/no) where a full-height drawer would be overkill.
|
|
6
|
+
// The .drawer-content / .modal-content classes drive the slide/scale keyframes
|
|
7
|
+
// (styles.css) off Kobalte's data-expanded/data-closed presence tracking.
|
|
8
|
+
import { Dialog } from '@kobalte/core/dialog'
|
|
9
|
+
import { X } from 'lucide-solid'
|
|
10
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
11
|
+
import { Show } from 'solid-js'
|
|
12
|
+
|
|
13
|
+
import { cn } from '../lib/cn'
|
|
14
|
+
|
|
15
|
+
interface ModalProps extends ParentProps {
|
|
16
|
+
open: boolean
|
|
17
|
+
onOpenChange: (open: boolean) => void
|
|
18
|
+
/** Header title; omitting it hides the whole header bar. */
|
|
19
|
+
title?: string
|
|
20
|
+
/**
|
|
21
|
+
* `'drawer'` (default): right-anchored slide-over, more room, less jarring —
|
|
22
|
+
* use for forms/detail panels. `'center'`: classic centered modal — use for
|
|
23
|
+
* short confirmations (e.g. destructive yes/no).
|
|
24
|
+
* @default 'drawer'
|
|
25
|
+
*/
|
|
26
|
+
variant?: 'drawer' | 'center'
|
|
27
|
+
class?: string
|
|
28
|
+
/** Accessible label for the close button. @default 'Close' */
|
|
29
|
+
closeLabel?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Dialog on Kobalte's `Dialog` primitive (focus trap, portal, presence handled
|
|
34
|
+
* for you), rendered as either a slide-over drawer or a centered modal depending
|
|
35
|
+
* on `variant`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const [open, setOpen] = createSignal(false)
|
|
40
|
+
* <Modal open={open()} onOpenChange={setOpen} variant="center" title="Delete item?">
|
|
41
|
+
* <p>This action cannot be undone.</p>
|
|
42
|
+
* </Modal>
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function Modal(props: ModalProps): JSX.Element {
|
|
46
|
+
const isCenter = () => props.variant === 'center'
|
|
47
|
+
return (
|
|
48
|
+
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
|
49
|
+
<Dialog.Portal>
|
|
50
|
+
<Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" />
|
|
51
|
+
<Show
|
|
52
|
+
when={isCenter()}
|
|
53
|
+
fallback={
|
|
54
|
+
<div class="fixed inset-0 z-50 flex justify-end">
|
|
55
|
+
<Dialog.Content
|
|
56
|
+
class={cn(
|
|
57
|
+
'drawer-content bg-glass flex h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-border text-card-foreground shadow-2xl',
|
|
58
|
+
props.class,
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<Show when={props.title}>
|
|
62
|
+
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/85 px-6 py-4 backdrop-blur">
|
|
63
|
+
<Dialog.Title class="text-lg font-semibold leading-none tracking-tight">
|
|
64
|
+
{props.title}
|
|
65
|
+
</Dialog.Title>
|
|
66
|
+
<Dialog.CloseButton
|
|
67
|
+
class="grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
68
|
+
aria-label={props.closeLabel ?? 'Close'}
|
|
69
|
+
>
|
|
70
|
+
<X class="h-5 w-5" />
|
|
71
|
+
</Dialog.CloseButton>
|
|
72
|
+
</div>
|
|
73
|
+
</Show>
|
|
74
|
+
<div class="p-6 pt-4">{props.children}</div>
|
|
75
|
+
</Dialog.Content>
|
|
76
|
+
</div>
|
|
77
|
+
}
|
|
78
|
+
>
|
|
79
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
80
|
+
<Dialog.Content
|
|
81
|
+
class={cn(
|
|
82
|
+
'modal-content w-full max-w-lg rounded-xl border border-border bg-card text-card-foreground shadow-sm',
|
|
83
|
+
props.class,
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
<Show when={props.title}>
|
|
87
|
+
<div class="flex items-center justify-between border-b border-border p-6 pb-4">
|
|
88
|
+
<Dialog.Title class="text-lg font-semibold leading-none tracking-tight">
|
|
89
|
+
{props.title}
|
|
90
|
+
</Dialog.Title>
|
|
91
|
+
<Dialog.CloseButton
|
|
92
|
+
class="text-muted-foreground hover:text-foreground"
|
|
93
|
+
aria-label={props.closeLabel ?? 'Close'}
|
|
94
|
+
>
|
|
95
|
+
✕
|
|
96
|
+
</Dialog.CloseButton>
|
|
97
|
+
</div>
|
|
98
|
+
</Show>
|
|
99
|
+
<div class="p-6 pt-4">{props.children}</div>
|
|
100
|
+
</Dialog.Content>
|
|
101
|
+
</div>
|
|
102
|
+
</Show>
|
|
103
|
+
</Dialog.Portal>
|
|
104
|
+
</Dialog>
|
|
105
|
+
)
|
|
106
|
+
}
|