@a4ui/core 0.25.0 → 0.26.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 +4 -4
- package/dist/elements.css +49 -0
- package/dist/full.css +49 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3009 -2814
- package/dist/lib/easing.d.ts +42 -0
- package/dist/ui/CategoryStrip.d.ts +24 -0
- package/dist/ui/CodeTabs.d.ts +27 -0
- package/dist/ui/MasterDetail.d.ts +27 -0
- package/dist/ui/PillSearch.d.ts +38 -0
- package/package.json +1 -1
- package/preset.js +20 -0
- package/src/index.ts +8 -1
- package/src/lib/easing.ts +43 -0
- package/src/ui/CategoryStrip.tsx +80 -0
- package/src/ui/CodeTabs.tsx +100 -0
- package/src/ui/MasterDetail.tsx +117 -0
- package/src/ui/PillSearch.tsx +86 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Named cubic-bezier easing curves, as CSS `transition-timing-function` strings. */
|
|
2
|
+
export declare const EASE: {
|
|
3
|
+
/** Gentle ease-out — the sensible default for entrances and most UI motion. */
|
|
4
|
+
readonly out: "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
5
|
+
/** Balanced ease-in-out for reversible state toggles. */
|
|
6
|
+
readonly inOut: "cubic-bezier(0.65, 0, 0.35, 1)";
|
|
7
|
+
/** Emphasized, decisive curve (fast start, long settle) for hero moments. */
|
|
8
|
+
readonly emphasized: "cubic-bezier(0.2, 0, 0, 1)";
|
|
9
|
+
/** No easing. */
|
|
10
|
+
readonly linear: "linear";
|
|
11
|
+
};
|
|
12
|
+
/** A key of {@link EASE}. */
|
|
13
|
+
export type EaseName = keyof typeof EASE;
|
|
14
|
+
/**
|
|
15
|
+
* Spring option presets for the `spring` helper (motion.dev), re-exported from
|
|
16
|
+
* `@a4ui/core`. Pass one straight through:
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { animate, spring, SPRING } from '@a4ui/core'
|
|
21
|
+
* animate(el, { y: 0 }, { type: spring, ...SPRING.snappy })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare const SPRING: {
|
|
25
|
+
/** Soft, natural settle — good for panels and layout shifts. */
|
|
26
|
+
readonly gentle: {
|
|
27
|
+
readonly stiffness: 170;
|
|
28
|
+
readonly damping: 26;
|
|
29
|
+
};
|
|
30
|
+
/** Quick and tight, minimal overshoot — good for toggles and small controls. */
|
|
31
|
+
readonly snappy: {
|
|
32
|
+
readonly stiffness: 320;
|
|
33
|
+
readonly damping: 30;
|
|
34
|
+
};
|
|
35
|
+
/** Playful overshoot — good for playful accents. Use sparingly. */
|
|
36
|
+
readonly bouncy: {
|
|
37
|
+
readonly stiffness: 420;
|
|
38
|
+
readonly damping: 18;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/** A key of {@link SPRING}. */
|
|
42
|
+
export type SpringName = keyof typeof SPRING;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** A single selectable category: value, label, and optional leading icon. */
|
|
3
|
+
export interface CategoryItem {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: JSX.Element;
|
|
7
|
+
}
|
|
8
|
+
export interface CategoryStripProps {
|
|
9
|
+
items: CategoryItem[];
|
|
10
|
+
value: string;
|
|
11
|
+
onChange: (value: string) => void;
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Horizontally-scrollable row of icon-over-label category tabs with a
|
|
16
|
+
* bottom-underline indicator on the active item. Left/Right arrow keys move
|
|
17
|
+
* the selection between items.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <CategoryStrip items={categories} value={category()} onChange={setCategory} />
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function CategoryStrip(props: CategoryStripProps): JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** A single tab's content: label, source code, and optional language tag. */
|
|
3
|
+
export interface CodeTab {
|
|
4
|
+
label: string;
|
|
5
|
+
code: string;
|
|
6
|
+
/** Language tag, e.g. `'tsx'`. Not used for highlighting; informational only. */
|
|
7
|
+
lang?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface CodeTabsProps {
|
|
10
|
+
tabs: CodeTab[];
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Card of tabbed code blocks with a copy button for the active tab, for
|
|
15
|
+
* showing several code samples or language variants side by side.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <CodeTabs
|
|
20
|
+
* tabs={[
|
|
21
|
+
* { label: 'npm', code: 'npm install @a4ui/core' },
|
|
22
|
+
* { label: 'pnpm', code: 'pnpm add @a4ui/core' },
|
|
23
|
+
* ]}
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function CodeTabs(props: CodeTabsProps): JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface MasterDetailItem {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
meta?: JSX.Element;
|
|
6
|
+
detail: JSX.Element;
|
|
7
|
+
}
|
|
8
|
+
export interface MasterDetailProps {
|
|
9
|
+
items: MasterDetailItem[];
|
|
10
|
+
/** Controlled selection. Uncontrolled (internal signal, defaults to the first item) when omitted. */
|
|
11
|
+
selectedId?: string;
|
|
12
|
+
onSelect?: (id: string) => void;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* List + detail-pane layout: a scrollable list of items on the left, the
|
|
17
|
+
* selected item's detail content on the right. Arrow keys (and Home/End)
|
|
18
|
+
* move the selection within the list.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <MasterDetail
|
|
23
|
+
* items={messages.map((m) => ({ id: m.id, label: m.subject, meta: <Badge>{m.from}</Badge>, detail: <MessageBody message={m} /> }))}
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function MasterDetail(props: MasterDetailProps): JSX.Element;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface PillField {
|
|
3
|
+
/** Stable identifier passed back via {@link PillSearchProps.onFieldClick}. */
|
|
4
|
+
key: string;
|
|
5
|
+
/** Small label shown above the value, e.g. `"Where"`. */
|
|
6
|
+
label: string;
|
|
7
|
+
/** Current value shown below the label. Falls back to `placeholder` when unset. */
|
|
8
|
+
value?: string;
|
|
9
|
+
/** Muted hint shown when `value` is unset. */
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface PillSearchProps {
|
|
13
|
+
fields: PillField[];
|
|
14
|
+
/** Called with the clicked field's `key`. */
|
|
15
|
+
onFieldClick?: (key: string) => void;
|
|
16
|
+
/** Called when the round search button is pressed. */
|
|
17
|
+
onSearch?: () => void;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Rounded-full search bar that packs several fields into segments with a
|
|
22
|
+
* round primary search button — the classic "Where / When / Who" pattern
|
|
23
|
+
* compressed into one compact control.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <PillSearch
|
|
28
|
+
* fields={[
|
|
29
|
+
* { key: 'where', label: 'Where', value: 'Lisbon' },
|
|
30
|
+
* { key: 'when', label: 'When', placeholder: 'Add dates' },
|
|
31
|
+
* { key: 'who', label: 'Who', placeholder: 'Add guests' },
|
|
32
|
+
* ]}
|
|
33
|
+
* onFieldClick={(key) => console.log('open', key)}
|
|
34
|
+
* onSearch={() => console.log('search')}
|
|
35
|
+
* />
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare function PillSearch(props: PillSearchProps): JSX.Element;
|
package/package.json
CHANGED
package/preset.js
CHANGED
|
@@ -28,6 +28,26 @@ const glass = plugin(({ addComponents, addVariant }) => {
|
|
|
28
28
|
// and a darker one on light to keep WCAG AA in both.
|
|
29
29
|
addVariant('light', "[data-theme='light'] &")
|
|
30
30
|
addComponents({
|
|
31
|
+
// ---- Elevation scale ----
|
|
32
|
+
// A layered depth system: `.elevation-1..4` for raised surfaces (rising
|
|
33
|
+
// shadow), `.pressed` for an inset/recessed look. Uses the same `--shadow`
|
|
34
|
+
// token as the glass surfaces, so it recolors per theme.
|
|
35
|
+
'.elevation-1': {
|
|
36
|
+
boxShadow: '0 1px 2px hsl(var(--shadow) / 0.06), 0 1px 3px hsl(var(--shadow) / 0.1)',
|
|
37
|
+
},
|
|
38
|
+
'.elevation-2': {
|
|
39
|
+
boxShadow: '0 2px 4px hsl(var(--shadow) / 0.06), 0 4px 12px hsl(var(--shadow) / 0.12)',
|
|
40
|
+
},
|
|
41
|
+
'.elevation-3': {
|
|
42
|
+
boxShadow: '0 2px 6px hsl(var(--shadow) / 0.08), 0 8px 24px hsl(var(--shadow) / 0.14)',
|
|
43
|
+
},
|
|
44
|
+
'.elevation-4': {
|
|
45
|
+
boxShadow: '0 4px 12px hsl(var(--shadow) / 0.1), 0 16px 40px hsl(var(--shadow) / 0.18)',
|
|
46
|
+
},
|
|
47
|
+
'.pressed': {
|
|
48
|
+
boxShadow: 'inset 0 1px 2px hsl(var(--shadow) / 0.18)',
|
|
49
|
+
},
|
|
50
|
+
|
|
31
51
|
// ---- Primary glass surface ----
|
|
32
52
|
'.card': {
|
|
33
53
|
position: 'relative',
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// import '@a4ui/core/styles.css'
|
|
9
9
|
// import { Button, Card, Modal } from '@a4ui/core'
|
|
10
10
|
|
|
11
|
-
export const A4UI_VERSION = '0.
|
|
11
|
+
export const A4UI_VERSION = '0.26.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -35,6 +35,7 @@ export { useMediaQuery } from './lib/media'
|
|
|
35
35
|
export { remeasureAfterLayout } from './lib/virtual'
|
|
36
36
|
export { createOptimistic } from './lib/createOptimistic'
|
|
37
37
|
export { startViewTransition } from './lib/viewTransition'
|
|
38
|
+
export { EASE, SPRING, type EaseName, type SpringName } from './lib/easing'
|
|
38
39
|
|
|
39
40
|
// UI components (src/ui) — all 18 extracted. See CLAUDE.md.
|
|
40
41
|
export { Accordion, type AccordionItem } from './ui/Accordion'
|
|
@@ -158,6 +159,12 @@ export { FloatingToolbar, type FloatingToolbarProps } from './ui/FloatingToolbar
|
|
|
158
159
|
export { PageTransition, type PageTransitionProps } from './ui/PageTransition'
|
|
159
160
|
export { InlineSelect, type InlineSelectProps, type InlineSelectOption } from './ui/InlineSelect'
|
|
160
161
|
|
|
162
|
+
// Phase 2 — code, search, navigation, master-detail.
|
|
163
|
+
export { CodeTabs, type CodeTabsProps, type CodeTab } from './ui/CodeTabs'
|
|
164
|
+
export { PillSearch, type PillSearchProps, type PillField } from './ui/PillSearch'
|
|
165
|
+
export { CategoryStrip, type CategoryStripProps, type CategoryItem } from './ui/CategoryStrip'
|
|
166
|
+
export { MasterDetail, type MasterDetailProps, type MasterDetailItem } from './ui/MasterDetail'
|
|
167
|
+
|
|
161
168
|
// Motion components (src/ui) — animation primitives built on the `motion` engine
|
|
162
169
|
// (adapted from motion.dev examples). All tree-shakeable and reduced-motion aware;
|
|
163
170
|
// `motion` is external, so importing one of these is the only thing that pulls it.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Shared motion vocabulary — named easing curves and spring presets, so every
|
|
2
|
+
// component transition can pull from one consistent set instead of hand-rolling
|
|
3
|
+
// cubic-beziers. `EASE` values are plain CSS `transition-timing-function`
|
|
4
|
+
// strings (drop into an inline style, a `transition-timing-function`, or a
|
|
5
|
+
// Tailwind `ease-[…]` arbitrary value). `SPRING` values are option objects for
|
|
6
|
+
// the `spring` helper re-exported from `@a4ui/core` (motion.dev).
|
|
7
|
+
|
|
8
|
+
/** Named cubic-bezier easing curves, as CSS `transition-timing-function` strings. */
|
|
9
|
+
export const EASE = {
|
|
10
|
+
/** Gentle ease-out — the sensible default for entrances and most UI motion. */
|
|
11
|
+
out: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
12
|
+
/** Balanced ease-in-out for reversible state toggles. */
|
|
13
|
+
inOut: 'cubic-bezier(0.65, 0, 0.35, 1)',
|
|
14
|
+
/** Emphasized, decisive curve (fast start, long settle) for hero moments. */
|
|
15
|
+
emphasized: 'cubic-bezier(0.2, 0, 0, 1)',
|
|
16
|
+
/** No easing. */
|
|
17
|
+
linear: 'linear',
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
/** A key of {@link EASE}. */
|
|
21
|
+
export type EaseName = keyof typeof EASE
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Spring option presets for the `spring` helper (motion.dev), re-exported from
|
|
25
|
+
* `@a4ui/core`. Pass one straight through:
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { animate, spring, SPRING } from '@a4ui/core'
|
|
30
|
+
* animate(el, { y: 0 }, { type: spring, ...SPRING.snappy })
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const SPRING = {
|
|
34
|
+
/** Soft, natural settle — good for panels and layout shifts. */
|
|
35
|
+
gentle: { stiffness: 170, damping: 26 },
|
|
36
|
+
/** Quick and tight, minimal overshoot — good for toggles and small controls. */
|
|
37
|
+
snappy: { stiffness: 320, damping: 30 },
|
|
38
|
+
/** Playful overshoot — good for playful accents. Use sparingly. */
|
|
39
|
+
bouncy: { stiffness: 420, damping: 18 },
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
/** A key of {@link SPRING}. */
|
|
43
|
+
export type SpringName = keyof typeof SPRING
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Horizontally-scrollable strip of icon+label category filters, with an
|
|
2
|
+
// active-underline indicator — e.g. a storefront category nav (All, Fresh,
|
|
3
|
+
// Bakery, Dairy, …). Scrollbar is hidden; Left/Right arrows move selection.
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { For } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
/** A single selectable category: value, label, and optional leading icon. */
|
|
10
|
+
export interface CategoryItem {
|
|
11
|
+
value: string
|
|
12
|
+
label: string
|
|
13
|
+
icon?: JSX.Element
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CategoryStripProps {
|
|
17
|
+
items: CategoryItem[]
|
|
18
|
+
value: string
|
|
19
|
+
onChange: (value: string) => void
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Horizontally-scrollable row of icon-over-label category tabs with a
|
|
25
|
+
* bottom-underline indicator on the active item. Left/Right arrow keys move
|
|
26
|
+
* the selection between items.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <CategoryStrip items={categories} value={category()} onChange={setCategory} />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function CategoryStrip(props: CategoryStripProps): JSX.Element {
|
|
34
|
+
const activeIndex = () => props.items.findIndex((item) => item.value === props.value)
|
|
35
|
+
|
|
36
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
37
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
38
|
+
event.preventDefault()
|
|
39
|
+
const count = props.items.length
|
|
40
|
+
if (count === 0) return
|
|
41
|
+
const current = activeIndex()
|
|
42
|
+
const delta = event.key === 'ArrowRight' ? 1 : -1
|
|
43
|
+
const next = ((current === -1 ? 0 : current) + delta + count) % count
|
|
44
|
+
props.onChange(props.items[next].value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
role="tablist"
|
|
50
|
+
class={cn(
|
|
51
|
+
'flex gap-6 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
|
52
|
+
props.class,
|
|
53
|
+
)}
|
|
54
|
+
onKeyDown={onKeyDown}
|
|
55
|
+
>
|
|
56
|
+
<For each={props.items}>
|
|
57
|
+
{(item) => {
|
|
58
|
+
const isActive = () => item.value === props.value
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
role="tab"
|
|
63
|
+
aria-selected={isActive()}
|
|
64
|
+
class={cn(
|
|
65
|
+
'flex shrink-0 flex-col items-center gap-1 border-b-2 px-1 pb-2 text-xs font-medium transition-colors',
|
|
66
|
+
isActive()
|
|
67
|
+
? 'border-primary text-foreground'
|
|
68
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
69
|
+
)}
|
|
70
|
+
onClick={() => props.onChange(item.value)}
|
|
71
|
+
>
|
|
72
|
+
{item.icon}
|
|
73
|
+
<span>{item.label}</span>
|
|
74
|
+
</button>
|
|
75
|
+
)
|
|
76
|
+
}}
|
|
77
|
+
</For>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Tabbed code blocks with a per-tab copy button, for embedding several code
|
|
2
|
+
// samples/languages (docs, marketing) in a single card. Plain monospace —
|
|
3
|
+
// no syntax highlighting.
|
|
4
|
+
import { Check, Copy } from 'lucide-solid'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
/** A single tab's content: label, source code, and optional language tag. */
|
|
11
|
+
export interface CodeTab {
|
|
12
|
+
label: string
|
|
13
|
+
code: string
|
|
14
|
+
/** Language tag, e.g. `'tsx'`. Not used for highlighting; informational only. */
|
|
15
|
+
lang?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CodeTabsProps {
|
|
19
|
+
tabs: CodeTab[]
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const COPIED_TIMEOUT_MS = 1500
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Card of tabbed code blocks with a copy button for the active tab, for
|
|
27
|
+
* showing several code samples or language variants side by side.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <CodeTabs
|
|
32
|
+
* tabs={[
|
|
33
|
+
* { label: 'npm', code: 'npm install @a4ui/core' },
|
|
34
|
+
* { label: 'pnpm', code: 'pnpm add @a4ui/core' },
|
|
35
|
+
* ]}
|
|
36
|
+
* />
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function CodeTabs(props: CodeTabsProps): JSX.Element {
|
|
40
|
+
const [activeIndex, setActiveIndex] = createSignal(0)
|
|
41
|
+
const [copied, setCopied] = createSignal(false)
|
|
42
|
+
|
|
43
|
+
const active = () => props.tabs[activeIndex()]
|
|
44
|
+
|
|
45
|
+
const copy = () => {
|
|
46
|
+
if (!navigator.clipboard) return
|
|
47
|
+
navigator.clipboard.writeText(active().code).then(() => {
|
|
48
|
+
setCopied(true)
|
|
49
|
+
setTimeout(() => setCopied(false), COPIED_TIMEOUT_MS)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div class={cn('overflow-hidden rounded-lg border border-border bg-card', props.class)}>
|
|
55
|
+
<div class="flex items-center justify-between border-b border-border pr-2">
|
|
56
|
+
<div class="flex" role="tablist">
|
|
57
|
+
<For each={props.tabs}>
|
|
58
|
+
{(tab, index) => (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
role="tab"
|
|
62
|
+
aria-selected={activeIndex() === index()}
|
|
63
|
+
class={cn(
|
|
64
|
+
'border-b-2 px-4 py-2 text-sm font-medium transition-colors',
|
|
65
|
+
activeIndex() === index()
|
|
66
|
+
? 'border-foreground text-foreground'
|
|
67
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
68
|
+
)}
|
|
69
|
+
onClick={() => setActiveIndex(index())}
|
|
70
|
+
>
|
|
71
|
+
{tab.label}
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
</For>
|
|
75
|
+
</div>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
79
|
+
onClick={copy}
|
|
80
|
+
>
|
|
81
|
+
<Show
|
|
82
|
+
when={copied()}
|
|
83
|
+
fallback={
|
|
84
|
+
<>
|
|
85
|
+
<Copy class="h-3.5 w-3.5" />
|
|
86
|
+
Copy
|
|
87
|
+
</>
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
<Check class="h-3.5 w-3.5" />
|
|
91
|
+
Copied
|
|
92
|
+
</Show>
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
<pre class="overflow-x-auto p-4 text-sm font-mono text-foreground">
|
|
96
|
+
<code>{active().code}</code>
|
|
97
|
+
</pre>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// List + detail-pane layout with keyboard navigation, e.g. an inbox or a
|
|
2
|
+
// command-result view. The list is a `listbox`; Up/Down move the selection,
|
|
3
|
+
// Home/End jump to the first/last item. Controlled (`selectedId`+`onSelect`)
|
|
4
|
+
// or uncontrolled (internal signal, defaults to the first item).
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createMemo, createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface MasterDetailItem {
|
|
11
|
+
id: string
|
|
12
|
+
label: string
|
|
13
|
+
meta?: JSX.Element
|
|
14
|
+
detail: JSX.Element
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MasterDetailProps {
|
|
18
|
+
items: MasterDetailItem[]
|
|
19
|
+
/** Controlled selection. Uncontrolled (internal signal, defaults to the first item) when omitted. */
|
|
20
|
+
selectedId?: string
|
|
21
|
+
onSelect?: (id: string) => void
|
|
22
|
+
class?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* List + detail-pane layout: a scrollable list of items on the left, the
|
|
27
|
+
* selected item's detail content on the right. Arrow keys (and Home/End)
|
|
28
|
+
* move the selection within the list.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <MasterDetail
|
|
33
|
+
* items={messages.map((m) => ({ id: m.id, label: m.subject, meta: <Badge>{m.from}</Badge>, detail: <MessageBody message={m} /> }))}
|
|
34
|
+
* />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function MasterDetail(props: MasterDetailProps): JSX.Element {
|
|
38
|
+
const [internalId, setInternalId] = createSignal<string | undefined>(props.items[0]?.id)
|
|
39
|
+
|
|
40
|
+
const selectedId = createMemo(() => props.selectedId ?? internalId())
|
|
41
|
+
|
|
42
|
+
const select = (id: string) => {
|
|
43
|
+
if (props.selectedId === undefined) setInternalId(id)
|
|
44
|
+
props.onSelect?.(id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const selectedItem = createMemo(() => props.items.find((item) => item.id === selectedId()))
|
|
48
|
+
|
|
49
|
+
let listEl: HTMLDivElement | undefined
|
|
50
|
+
|
|
51
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
52
|
+
const items = props.items
|
|
53
|
+
if (items.length === 0) return
|
|
54
|
+
const currentIndex = items.findIndex((item) => item.id === selectedId())
|
|
55
|
+
|
|
56
|
+
let nextIndex: number
|
|
57
|
+
if (event.key === 'ArrowDown')
|
|
58
|
+
nextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, items.length - 1)
|
|
59
|
+
else if (event.key === 'ArrowUp') nextIndex = currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0)
|
|
60
|
+
else if (event.key === 'Home') nextIndex = 0
|
|
61
|
+
else if (event.key === 'End') nextIndex = items.length - 1
|
|
62
|
+
else return
|
|
63
|
+
|
|
64
|
+
event.preventDefault()
|
|
65
|
+
const next = items[nextIndex]
|
|
66
|
+
if (!next) return
|
|
67
|
+
select(next.id)
|
|
68
|
+
listEl?.querySelector<HTMLElement>(`[data-item-id="${next.id}"]`)?.focus()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div class={cn('grid grid-cols-1 gap-4 sm:grid-cols-[16rem_1fr]', props.class)}>
|
|
73
|
+
<div
|
|
74
|
+
ref={listEl}
|
|
75
|
+
role="listbox"
|
|
76
|
+
aria-label="Items"
|
|
77
|
+
tabindex={0}
|
|
78
|
+
onKeyDown={onKeyDown}
|
|
79
|
+
class="flex max-h-96 flex-col gap-1 overflow-y-auto rounded-xl border border-border bg-card p-1 text-card-foreground sm:max-h-[28rem]"
|
|
80
|
+
>
|
|
81
|
+
<For each={props.items}>
|
|
82
|
+
{(item) => {
|
|
83
|
+
const isSelected = () => item.id === selectedId()
|
|
84
|
+
return (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
role="option"
|
|
88
|
+
aria-selected={isSelected()}
|
|
89
|
+
data-item-id={item.id}
|
|
90
|
+
tabindex={-1}
|
|
91
|
+
onClick={() => select(item.id)}
|
|
92
|
+
class={cn(
|
|
93
|
+
'flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors',
|
|
94
|
+
isSelected() ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60',
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
<span class="truncate">{item.label}</span>
|
|
98
|
+
<Show when={item.meta}>{item.meta}</Show>
|
|
99
|
+
</button>
|
|
100
|
+
)
|
|
101
|
+
}}
|
|
102
|
+
</For>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="rounded-xl border border-border bg-card p-5 text-card-foreground">
|
|
105
|
+
{/* keyed: re-render the detail when the selected item changes (a plain
|
|
106
|
+
Show wouldn't re-run its child when `when` stays truthy across items). */}
|
|
107
|
+
<Show
|
|
108
|
+
when={selectedItem()}
|
|
109
|
+
keyed
|
|
110
|
+
fallback={<p class="text-sm text-muted-foreground">Select an item to see its details.</p>}
|
|
111
|
+
>
|
|
112
|
+
{(item) => item.detail}
|
|
113
|
+
</Show>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Compact multi-parameter search entry: a single rounded-full bar that
|
|
2
|
+
// compresses several fields (e.g. Where / When / Who) into clickable
|
|
3
|
+
// segments, ending in a round primary search button.
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { For } from 'solid-js'
|
|
6
|
+
import { Search } from 'lucide-solid'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface PillField {
|
|
11
|
+
/** Stable identifier passed back via {@link PillSearchProps.onFieldClick}. */
|
|
12
|
+
key: string
|
|
13
|
+
/** Small label shown above the value, e.g. `"Where"`. */
|
|
14
|
+
label: string
|
|
15
|
+
/** Current value shown below the label. Falls back to `placeholder` when unset. */
|
|
16
|
+
value?: string
|
|
17
|
+
/** Muted hint shown when `value` is unset. */
|
|
18
|
+
placeholder?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PillSearchProps {
|
|
22
|
+
fields: PillField[]
|
|
23
|
+
/** Called with the clicked field's `key`. */
|
|
24
|
+
onFieldClick?: (key: string) => void
|
|
25
|
+
/** Called when the round search button is pressed. */
|
|
26
|
+
onSearch?: () => void
|
|
27
|
+
class?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rounded-full search bar that packs several fields into segments with a
|
|
32
|
+
* round primary search button — the classic "Where / When / Who" pattern
|
|
33
|
+
* compressed into one compact control.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <PillSearch
|
|
38
|
+
* fields={[
|
|
39
|
+
* { key: 'where', label: 'Where', value: 'Lisbon' },
|
|
40
|
+
* { key: 'when', label: 'When', placeholder: 'Add dates' },
|
|
41
|
+
* { key: 'who', label: 'Who', placeholder: 'Add guests' },
|
|
42
|
+
* ]}
|
|
43
|
+
* onFieldClick={(key) => console.log('open', key)}
|
|
44
|
+
* onSearch={() => console.log('search')}
|
|
45
|
+
* />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function PillSearch(props: PillSearchProps): JSX.Element {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
class={cn(
|
|
52
|
+
'inline-flex max-w-full items-center overflow-x-auto rounded-full border border-border bg-card shadow-sm',
|
|
53
|
+
props.class,
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<For each={props.fields}>
|
|
57
|
+
{(field, index) => (
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
aria-label={field.label}
|
|
61
|
+
onClick={() => props.onFieldClick?.(field.key)}
|
|
62
|
+
class={cn(
|
|
63
|
+
'flex min-w-[6.5rem] shrink-0 flex-col items-start px-4 py-2 text-left transition-colors duration-150 hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring',
|
|
64
|
+
index() === 0 ? 'rounded-l-full' : 'border-l border-border',
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<span class="text-xs font-medium text-muted-foreground">{field.label}</span>
|
|
68
|
+
<span class={cn('text-sm', field.value ? 'text-foreground' : 'text-muted-foreground')}>
|
|
69
|
+
{field.value ?? field.placeholder}
|
|
70
|
+
</span>
|
|
71
|
+
</button>
|
|
72
|
+
)}
|
|
73
|
+
</For>
|
|
74
|
+
<div class="shrink-0 py-1.5 pl-1 pr-1.5">
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
aria-label="Search"
|
|
78
|
+
onClick={() => props.onSearch?.()}
|
|
79
|
+
class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground transition-[color,background-color,transform] duration-150 hover:bg-primary/90 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
80
|
+
>
|
|
81
|
+
<Search class="h-4 w-4" aria-hidden="true" />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|