@reddb-io/ui-kit 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/ui-kit",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Low-level RedDB UI design-system primitives.",
6
6
  "type": "module",
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ // The "live or empty — never fake" fallback: a centered icon + title +
3
+ // optional description, with an optional `action` slot (a fix command, a
4
+ // primary button). Used when a list has no rows or a surface is unreachable.
5
+ import type { Snippet } from 'svelte'
6
+
7
+ interface Props {
8
+ title: string
9
+ description?: string
10
+ /** Leading icon, rendered muted above the title. */
11
+ icon?: Snippet
12
+ /** Optional call to action beneath the copy. */
13
+ action?: Snippet
14
+ class?: string
15
+ }
16
+ let { title, description, icon, action, class: klass = '' }: Props = $props()
17
+ </script>
18
+
19
+ <div class="flex flex-col items-center justify-center gap-2 px-6 py-10 text-center {klass}">
20
+ {#if icon}
21
+ <span class="flex size-8 items-center justify-center text-fg-3">{@render icon()}</span>
22
+ {/if}
23
+ <p class="text-[13px] text-fg-1">{title}</p>
24
+ {#if description}
25
+ <p class="max-w-prose text-[12px] leading-relaxed text-fg-2">{description}</p>
26
+ {/if}
27
+ {#if action}
28
+ <div class="mt-2">{@render action()}</div>
29
+ {/if}
30
+ </div>
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ // A settings/governance row: a `title` (+ optional `description` and a mono
3
+ // `hint`) on the left, a right-aligned `action` slot, and an optional `below`
4
+ // slot that spans the row's full width (for inline editors, diffs, warnings).
5
+ //
6
+ // `wide` flips the layout to a stacked variant: the label block sits on top
7
+ // and the action drops to its own full-width line beneath — for actions that
8
+ // are too large to sit inline (a long input, a segmented control).
9
+ import type { Snippet } from 'svelte'
10
+
11
+ interface Props {
12
+ title: string
13
+ /** Secondary line under the title. */
14
+ description?: string
15
+ /** Mono hint — a value, key, or id rendered in the data typeface. */
16
+ hint?: string
17
+ /** Right-aligned (inline) or full-width-below (wide) action. */
18
+ action?: Snippet
19
+ /** Full-width content beneath the row — editors, diffs, warnings. */
20
+ below?: Snippet
21
+ /** Stack the action beneath the label block instead of inline-right. */
22
+ wide?: boolean
23
+ class?: string
24
+ }
25
+ let {
26
+ title,
27
+ description,
28
+ hint,
29
+ action,
30
+ below,
31
+ wide = false,
32
+ class: klass = '',
33
+ }: Props = $props()
34
+ </script>
35
+
36
+ <div
37
+ data-wide={wide ? '' : undefined}
38
+ class={[
39
+ 'px-3 py-2.5',
40
+ wide ? 'flex flex-col gap-2' : 'flex items-center gap-3',
41
+ klass,
42
+ ].join(' ')}
43
+ >
44
+ <div class={['flex min-w-0 items-baseline gap-2', wide ? '' : 'flex-1'].join(' ')}>
45
+ <div class="min-w-0">
46
+ <div class="flex items-baseline gap-2">
47
+ <span class="truncate text-[13px] text-fg-0">{title}</span>
48
+ {#if hint}
49
+ <span class="shrink-0 font-mono text-[11px] text-fg-2">{hint}</span>
50
+ {/if}
51
+ </div>
52
+ {#if description}
53
+ <p class="mt-0.5 text-[12px] leading-snug text-fg-2">{description}</p>
54
+ {/if}
55
+ </div>
56
+ </div>
57
+
58
+ {#if action}
59
+ <div class={['flex items-center gap-2', wide ? 'w-full' : 'shrink-0'].join(' ')}>
60
+ {@render action()}
61
+ </div>
62
+ {/if}
63
+
64
+ {#if below}
65
+ <div class="w-full">{@render below()}</div>
66
+ {/if}
67
+ </div>
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ // A quiet inline loading affordance — a spinning ring + optional label.
3
+ // Honors `prefers-reduced-motion` (the spin is dropped when the user asks for
4
+ // reduced motion). Pair with a surface while its data is in flight.
5
+ interface Props {
6
+ label?: string
7
+ class?: string
8
+ }
9
+ let { label = 'Loading…', class: klass = '' }: Props = $props()
10
+ </script>
11
+
12
+ <div class="flex items-center justify-center gap-2 px-6 py-10 text-fg-2 {klass}">
13
+ <span
14
+ class="size-3.5 rounded-full border-2 border-line-2 border-t-fg-2 motion-safe:animate-spin"
15
+ aria-hidden="true"
16
+ ></span>
17
+ {#if label}
18
+ <span class="text-[12px]">{label}</span>
19
+ {/if}
20
+ </div>
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ // A navigation entry for the SplitView nav pane: leading icon + label, an
3
+ // active state (accent-marked, `aria-current="page"`), and an optional
4
+ // trailing slot (a count Pill, a chevron). Renders as an `<a>` when `href` is
5
+ // given, otherwise a `<button>` — so it works for both routing and local
6
+ // section state.
7
+ import type { Snippet } from 'svelte'
8
+
9
+ interface Props {
10
+ label: string
11
+ icon?: Snippet
12
+ trailing?: Snippet
13
+ active?: boolean
14
+ href?: string
15
+ onclick?: (e: MouseEvent) => void
16
+ class?: string
17
+ }
18
+ let {
19
+ label,
20
+ icon,
21
+ trailing,
22
+ active = false,
23
+ href,
24
+ onclick,
25
+ class: klass = '',
26
+ }: Props = $props()
27
+
28
+ const cls = $derived(
29
+ [
30
+ 'inline-flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors',
31
+ active ? 'bg-bg-2 text-fg-0' : 'text-fg-2 hover:bg-bg-2 hover:text-fg-0',
32
+ klass,
33
+ ].join(' '),
34
+ )
35
+ </script>
36
+
37
+ {#if href}
38
+ <a {href} {onclick} aria-current={active ? 'page' : undefined} class={cls}>
39
+ {#if icon}
40
+ <span class="flex size-3.5 shrink-0 items-center justify-center">{@render icon()}</span>
41
+ {/if}
42
+ <span class="flex-1 truncate">{label}</span>
43
+ {#if trailing}<span class="shrink-0">{@render trailing()}</span>{/if}
44
+ </a>
45
+ {:else}
46
+ <button type="button" {onclick} aria-current={active ? 'page' : undefined} class={cls}>
47
+ {#if icon}
48
+ <span class="flex size-3.5 shrink-0 items-center justify-center">{@render icon()}</span>
49
+ {/if}
50
+ <span class="flex-1 truncate">{label}</span>
51
+ {#if trailing}<span class="shrink-0">{@render trailing()}</span>{/if}
52
+ </button>
53
+ {/if}
@@ -0,0 +1,28 @@
1
+ <script lang="ts" module>
2
+ import { tv } from 'tailwind-variants'
3
+ // A small meta chip for counts/labels next to a heading — quieter than Badge
4
+ // (no uppercase/tracking), tuned for inline use beside a SectionHeading.
5
+ export const pill = tv({
6
+ base:
7
+ 'inline-flex items-center gap-1 px-1.5 h-[18px] rounded-full text-[11px] ' +
8
+ 'font-mono leading-none border',
9
+ variants: {
10
+ tone: {
11
+ muted: 'bg-bg-2 text-fg-2 border-line-2',
12
+ accent: 'bg-accent/15 text-accent border-accent/30',
13
+ },
14
+ },
15
+ defaultVariants: { tone: 'muted' },
16
+ })
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ interface Props {
21
+ tone?: 'muted' | 'accent'
22
+ class?: string
23
+ children?: import('svelte').Snippet
24
+ }
25
+ let { tone = 'muted', class: klass = '', children }: Props = $props()
26
+ </script>
27
+
28
+ <span class={pill({ tone, class: klass })}>{@render children?.()}</span>
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ // A section title row: optional leading icon + title + optional trailing meta
3
+ // (typically a `Pill` count). Density-first — sits above a stack of ListRows.
4
+ import type { Snippet } from 'svelte'
5
+
6
+ interface Props {
7
+ title: string
8
+ /** Leading icon (e.g. a lucide component rendered in a snippet). */
9
+ icon?: Snippet
10
+ /** Trailing meta — usually a `Pill` with a count. */
11
+ meta?: Snippet
12
+ class?: string
13
+ }
14
+ let { title, icon, meta, class: klass = '' }: Props = $props()
15
+ </script>
16
+
17
+ <div class="flex items-center gap-2 {klass}">
18
+ {#if icon}
19
+ <span class="flex size-4 shrink-0 items-center justify-center text-fg-2">{@render icon()}</span>
20
+ {/if}
21
+ <h2 class="text-[11px] font-mono uppercase tracking-[0.1em] text-fg-2">{title}</h2>
22
+ {#if meta}
23
+ <span class="ml-0.5">{@render meta()}</span>
24
+ {/if}
25
+ </div>
@@ -0,0 +1,145 @@
1
+ <script lang="ts" module>
2
+ // Re-export the pure helpers so consumers can reach them from the same entry
3
+ // as the component (e.g. to share the breakpoint or the shortcut predicate).
4
+ export { splitViewGridClass, isSearchShortcut, SPLIT_VIEW_BREAKPOINT_REM } from './split-view'
5
+ </script>
6
+
7
+ <script lang="ts">
8
+ // A presentational two-pane shell — `nav | content` at `13rem | minmax(0,1fr)`,
9
+ // collapsing to a single column on a narrow *container* (not just viewport).
10
+ // It owns no routing and no domain concepts (ADR-0001): which section is
11
+ // active is the host's business, driven through these slots. Styling stays in
12
+ // Tailwind class strings and every region exposes a `*Class` override hook.
13
+ import type { Snippet } from 'svelte'
14
+ import { onMount, tick } from 'svelte'
15
+ import { isSearchShortcut, splitViewGridClass } from './split-view'
16
+
17
+ interface Props {
18
+ /** Left navigation pane content. */
19
+ nav?: Snippet
20
+ /** Right content pane. */
21
+ content?: Snippet
22
+ /** Pinned to the bottom of the nav pane. */
23
+ footer?: Snippet
24
+ /** Focusable search affordance — inline at the top of the nav by default. */
25
+ search?: Snippet
26
+ /**
27
+ * Force a single column (`true`) or two columns (`false`). Omit for the
28
+ * responsive default (collapses below the container breakpoint).
29
+ */
30
+ collapsed?: boolean
31
+ /** Render the search slot as a centered overlay instead of inline. */
32
+ searchOverlay?: boolean
33
+ /** Bindable open state for the search overlay. */
34
+ searchOpen?: boolean
35
+ /**
36
+ * Predicate for the keyboard idiom that focuses (inline) or toggles
37
+ * (overlay) the search. Defaults to ⌘K / Ctrl+K. Pass `null` to disable —
38
+ * e.g. when the host already owns ⌘K for a global command palette.
39
+ */
40
+ searchShortcut?: ((e: KeyboardEvent) => boolean) | null
41
+ /** Styling hooks. */
42
+ class?: string
43
+ navClass?: string
44
+ contentClass?: string
45
+ footerClass?: string
46
+ searchClass?: string
47
+ }
48
+
49
+ let {
50
+ nav,
51
+ content,
52
+ footer,
53
+ search,
54
+ collapsed,
55
+ searchOverlay = false,
56
+ searchOpen = $bindable(false),
57
+ searchShortcut = isSearchShortcut,
58
+ class: klass = '',
59
+ navClass = '',
60
+ contentClass = '',
61
+ footerClass = '',
62
+ searchClass = '',
63
+ }: Props = $props()
64
+
65
+ const gridClass = $derived(splitViewGridClass(collapsed))
66
+
67
+ let searchHost = $state<HTMLElement>()
68
+ let overlayHost = $state<HTMLElement>()
69
+
70
+ function focusFirst(host: HTMLElement | undefined) {
71
+ const el = host?.querySelector<HTMLElement>(
72
+ 'input, textarea, select, button, [href], [tabindex]:not([tabindex="-1"])',
73
+ )
74
+ el?.focus()
75
+ }
76
+
77
+ async function activateSearch() {
78
+ if (searchOverlay) {
79
+ searchOpen = true
80
+ await tick()
81
+ focusFirst(overlayHost)
82
+ } else {
83
+ focusFirst(searchHost)
84
+ }
85
+ }
86
+
87
+ onMount(() => {
88
+ const onKey = (e: KeyboardEvent) => {
89
+ if (searchShortcut && searchShortcut(e)) {
90
+ e.preventDefault()
91
+ void activateSearch()
92
+ return
93
+ }
94
+ // Esc closes the overlay regardless of whether the shortcut is enabled.
95
+ if (searchOverlay && searchOpen && e.key === 'Escape') {
96
+ e.preventDefault()
97
+ searchOpen = false
98
+ }
99
+ }
100
+ window.addEventListener('keydown', onKey)
101
+ return () => window.removeEventListener('keydown', onKey)
102
+ })
103
+ </script>
104
+
105
+ <div class="@container relative h-full min-h-0 w-full {klass}">
106
+ <div class="grid h-full min-h-0 {gridClass}">
107
+ <aside
108
+ class="flex min-h-0 flex-col border-line-1 bg-bg-1 border-b @min-[52rem]:border-b-0 @min-[52rem]:border-r {navClass}"
109
+ >
110
+ {#if search && !searchOverlay}
111
+ <div bind:this={searchHost} class="shrink-0 border-b border-line-1 p-2 {searchClass}">
112
+ {@render search()}
113
+ </div>
114
+ {/if}
115
+ <div class="min-h-0 flex-1 overflow-y-auto">
116
+ {@render nav?.()}
117
+ </div>
118
+ {#if footer}
119
+ <div class="mt-auto shrink-0 border-t border-line-1 {footerClass}">
120
+ {@render footer()}
121
+ </div>
122
+ {/if}
123
+ </aside>
124
+
125
+ <section class="min-h-0 min-w-0 overflow-auto bg-bg-0 {contentClass}">
126
+ {@render content?.()}
127
+ </section>
128
+ </div>
129
+
130
+ {#if search && searchOverlay && searchOpen}
131
+ <div
132
+ class="absolute inset-0 z-40 bg-black/50 backdrop-blur-sm"
133
+ role="presentation"
134
+ onclick={() => (searchOpen = false)}
135
+ ></div>
136
+ <div
137
+ bind:this={overlayHost}
138
+ class="absolute left-1/2 top-[15%] z-50 w-[min(36rem,92%)] -translate-x-1/2 rounded-lg border border-line-2 bg-bg-1 shadow-[var(--shadow-lg)] {searchClass}"
139
+ role="dialog"
140
+ aria-modal="true"
141
+ >
142
+ {@render search()}
143
+ </div>
144
+ {/if}
145
+ </div>
package/src/lib/index.ts CHANGED
@@ -1,5 +1,17 @@
1
- export { default as Button } from './Button.svelte'
2
- export { default as Badge } from './Badge.svelte'
3
- export { default as Card } from './Card.svelte'
4
- export { default as NodeBadge } from './NodeBadge.svelte'
5
- export { default as Kbd } from './Kbd.svelte'
1
+ export { default as Button } from "./Button.svelte";
2
+ export { default as Badge } from "./Badge.svelte";
3
+ export { default as Card } from "./Card.svelte";
4
+ export { default as NodeBadge } from "./NodeBadge.svelte";
5
+ export { default as Kbd } from "./Kbd.svelte";
6
+ export { default as SplitView } from "./SplitView.svelte";
7
+ export { default as ListRow } from "./ListRow.svelte";
8
+ export { default as SectionHeading } from "./SectionHeading.svelte";
9
+ export { default as Pill } from "./Pill.svelte";
10
+ export { default as NavItem } from "./NavItem.svelte";
11
+ export { default as EmptyState } from "./EmptyState.svelte";
12
+ export { default as LoadingState } from "./LoadingState.svelte";
13
+ export {
14
+ splitViewGridClass,
15
+ isSearchShortcut,
16
+ SPLIT_VIEW_BREAKPOINT_REM,
17
+ } from "./split-view";
@@ -0,0 +1,38 @@
1
+ // Pure, framework-free helpers for the `SplitView` shell, kept separate from
2
+ // the component so the grid math and the keyboard idiom can be unit-tested
3
+ // without mounting Svelte.
4
+
5
+ /** Container width (in `rem`) at/above which both panes show; below, one column. */
6
+ export const SPLIT_VIEW_BREAKPOINT_REM = 52;
7
+
8
+ /**
9
+ * Tailwind grid-template class for the two-pane shell.
10
+ *
11
+ * - `true` → always a single column
12
+ * - `false` → always two columns (`13rem | minmax(0,1fr)`)
13
+ * - omitted → responsive: one column below the container breakpoint, two at or
14
+ * above it (Tailwind v4 container query — the shell measures its own width,
15
+ * so it collapses in a narrow embed, not just a narrow viewport).
16
+ *
17
+ * Each branch returns a contiguous literal so Tailwind's source scanner emits
18
+ * the utilities — never assemble these class strings via interpolation.
19
+ */
20
+ export function splitViewGridClass(collapsed?: boolean): string {
21
+ if (collapsed === true) return "grid-cols-1";
22
+ if (collapsed === false) return "grid-cols-[13rem_minmax(0,1fr)]";
23
+ return "grid-cols-1 @min-[52rem]:grid-cols-[13rem_minmax(0,1fr)]";
24
+ }
25
+
26
+ /**
27
+ * The default "Cmd+K idiom": ⌘K on macOS, Ctrl+K elsewhere, with no Shift or
28
+ * Alt. Returns true when a keydown should focus the inline search (or toggle
29
+ * the search overlay) of a `SplitView`.
30
+ */
31
+ export function isSearchShortcut(e: KeyboardEvent): boolean {
32
+ return (
33
+ Boolean(e.metaKey || e.ctrlKey) &&
34
+ !e.shiftKey &&
35
+ !e.altKey &&
36
+ e.key.toLowerCase() === "k"
37
+ );
38
+ }