@a4ui/core 0.25.0 → 0.27.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.
@@ -9,6 +9,12 @@ export interface AuroraProps {
9
9
  * on any `.glow-edge` cards). Reduced-motion aware (off when reduced). @default true
10
10
  */
11
11
  pointerGlow?: boolean;
12
+ /**
13
+ * Backdrop style: `'blobs'` (soft blurred color blobs) or `'mesh'` (a
14
+ * multi-point gradient mesh). Both are theme-tinted and drift when `animated`.
15
+ * @default 'blobs'
16
+ */
17
+ variant?: 'blobs' | 'mesh';
12
18
  class?: string;
13
19
  }
14
20
  /**
@@ -0,0 +1,30 @@
1
+ export interface LinkedSelection<Id> {
2
+ hoveredId: () => Id | undefined;
3
+ selectedId: () => Id | undefined;
4
+ setHovered: (id: Id | undefined) => void;
5
+ setSelected: (id: Id | undefined) => void;
6
+ isHovered: (id: Id) => boolean;
7
+ isSelected: (id: Id) => boolean;
8
+ /** Convenience props to spread onto an element for a given id (onPointerEnter/Leave + onClick). */
9
+ itemProps: (id: Id) => {
10
+ onPointerEnter: () => void;
11
+ onPointerLeave: () => void;
12
+ onClick: () => void;
13
+ };
14
+ }
15
+ /**
16
+ * Shared hovered/selected state for two synchronized views of the same
17
+ * collection (a map and a list, a chart and a table, …): hover a list row to
18
+ * highlight the map pin, and vice versa.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * const selection = createLinkedSelection<string>()
23
+ * return (
24
+ * <For each={items}>
25
+ * {(item) => <ListRow class={selection.isSelected(item.id) ? 'active' : ''} {...selection.itemProps(item.id)} />}
26
+ * </For>
27
+ * )
28
+ * ```
29
+ */
30
+ export declare function createLinkedSelection<Id = string>(initialSelected?: Id): LinkedSelection<Id>;
@@ -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,51 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A single table column for {@link DataView}'s table presentation. */
3
+ export interface DataViewColumn<T> {
4
+ /** Row property this column reads. */
5
+ key: string;
6
+ /** Column heading text. */
7
+ header: string;
8
+ /** Custom cell renderer; falls back to `String(row[key])` when omitted. */
9
+ cell?: (row: T) => JSX.Element;
10
+ }
11
+ /** Presentation mode offered by {@link DataView}. */
12
+ export type DataViewMode = 'table' | 'board' | 'gallery';
13
+ /** Props for {@link DataView}. */
14
+ export interface DataViewProps<T> {
15
+ data: T[];
16
+ /** Table columns; also used as the source of truth in board/gallery fallbacks. */
17
+ columns: DataViewColumn<T>[];
18
+ /** Stable row identifier, used as a DOM hook for tests/a11y. */
19
+ getId: (row: T) => string;
20
+ /** For board view: group rows into columns by this key. If omitted, board mode is not offered. */
21
+ groupBy?: (row: T) => string;
22
+ /** Card renderer for board + gallery. Falls back to a simple key/value list. */
23
+ card?: (row: T) => JSX.Element;
24
+ /** Which views to offer (in order). Default: `['table', 'gallery']` plus `'board'` when `groupBy` is set. */
25
+ views?: DataViewMode[];
26
+ /** Controlled current view. Uncontrolled (internal signal) when omitted. */
27
+ view?: DataViewMode;
28
+ onViewChange?: (view: DataViewMode) => void;
29
+ class?: string;
30
+ }
31
+ /**
32
+ * One dataset, switchable between table, board (grouped columns), and
33
+ * gallery (card grid) presentations. Generic over the row type — pass
34
+ * `columns` for the table, an optional `groupBy` to enable the board view,
35
+ * and an optional `card` renderer reused by board + gallery.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * <DataView
40
+ * data={tasks}
41
+ * getId={(t) => t.id}
42
+ * columns={[
43
+ * { key: 'title', header: 'Title' },
44
+ * { key: 'status', header: 'Status' },
45
+ * ]}
46
+ * groupBy={(t) => t.status}
47
+ * card={(t) => <span class="font-medium">{t.title}</span>}
48
+ * />
49
+ * ```
50
+ */
51
+ export declare function DataView<T>(props: DataViewProps<T>): 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;
@@ -0,0 +1,43 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A single insertable item in a {@link SlashMenu}. */
3
+ export interface SlashItem {
4
+ /** Value passed to `onSelect` when this item is picked. */
5
+ value: string;
6
+ /** Primary text shown on the row. */
7
+ label: string;
8
+ /** Optional secondary text shown muted below the label. */
9
+ description?: string;
10
+ /** Optional leading icon. */
11
+ icon?: JSX.Element;
12
+ /** Extra terms matched against the query, in addition to label + description. */
13
+ keywords?: string[];
14
+ }
15
+ export interface SlashMenuProps {
16
+ items: SlashItem[];
17
+ /** Filter text — typically whatever the user typed after '/'. */
18
+ query: string;
19
+ /** Invoked with the picked item's `value` (click or Enter). */
20
+ onSelect: (value: string) => void;
21
+ /** Renders nothing when `false`. @default true */
22
+ open?: boolean;
23
+ class?: string;
24
+ }
25
+ /**
26
+ * Floating, keyboard-navigable menu for a "/"-triggered insert/command flow.
27
+ * Filters `items` against `query` (case-insensitive match over label +
28
+ * description + keywords), highlights an active row (↑/↓, wraps), and picks
29
+ * it on Enter or click. Purely presentational: the caller owns the '/'
30
+ * trigger, `open`, and `query` — this component only renders the filtered
31
+ * list and reports the pick.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * const [query, setQuery] = createSignal('')
36
+ * <SlashMenu
37
+ * items={[{ value: 'heading', label: 'Heading', icon: <Heading size={16} /> }]}
38
+ * query={query()}
39
+ * onSelect={(value) => insertBlock(value)}
40
+ * />
41
+ * ```
42
+ */
43
+ export declare function SlashMenu(props: SlashMenuProps): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/preset.js CHANGED
@@ -28,6 +28,50 @@ 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
+
51
+ // ---- Refractive glass ----
52
+ // A heavier glass surface with a specular sheen + bright top edge, so it
53
+ // reads as light refracting through the material rather than a flat blur.
54
+ // Sits over the Aurora backdrop like `.card`; use for hero/feature panels.
55
+ '.glass-refractive': {
56
+ position: 'relative',
57
+ overflow: 'hidden',
58
+ background: 'hsl(var(--card) / 0.5)',
59
+ backdropFilter: 'blur(14px) saturate(180%)',
60
+ WebkitBackdropFilter: 'blur(14px) saturate(180%)',
61
+ border: '1px solid hsl(var(--foreground) / 0.14)',
62
+ borderRadius: 'var(--radius-xl, 1rem)',
63
+ boxShadow:
64
+ '0 1px 2px hsl(var(--shadow) / 0.06), 0 8px 24px hsl(var(--shadow) / 0.14), inset 0 1px 0 hsl(0 0% 100% / 0.18)',
65
+ '&::before': {
66
+ content: '""',
67
+ position: 'absolute',
68
+ inset: '0',
69
+ borderRadius: 'inherit',
70
+ background: 'linear-gradient(150deg, hsl(0 0% 100% / 0.16), transparent 42%)',
71
+ pointerEvents: 'none',
72
+ },
73
+ },
74
+
31
75
  // ---- Primary glass surface ----
32
76
  '.card': {
33
77
  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.25.0'
11
+ export const A4UI_VERSION = '0.27.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -35,6 +35,8 @@ 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'
39
+ export { createLinkedSelection, type LinkedSelection } from './lib/createLinkedSelection'
38
40
 
39
41
  // UI components (src/ui) — all 18 extracted. See CLAUDE.md.
40
42
  export { Accordion, type AccordionItem } from './ui/Accordion'
@@ -158,6 +160,14 @@ export { FloatingToolbar, type FloatingToolbarProps } from './ui/FloatingToolbar
158
160
  export { PageTransition, type PageTransitionProps } from './ui/PageTransition'
159
161
  export { InlineSelect, type InlineSelectProps, type InlineSelectOption } from './ui/InlineSelect'
160
162
 
163
+ // Phase 2 — code, search, navigation, master-detail.
164
+ export { CodeTabs, type CodeTabsProps, type CodeTab } from './ui/CodeTabs'
165
+ export { PillSearch, type PillSearchProps, type PillField } from './ui/PillSearch'
166
+ export { CategoryStrip, type CategoryStripProps, type CategoryItem } from './ui/CategoryStrip'
167
+ export { MasterDetail, type MasterDetailProps, type MasterDetailItem } from './ui/MasterDetail'
168
+ export { SlashMenu, type SlashMenuProps, type SlashItem } from './ui/SlashMenu'
169
+ export { DataView, type DataViewProps, type DataViewColumn, type DataViewMode } from './ui/DataView'
170
+
161
171
  // Motion components (src/ui) — animation primitives built on the `motion` engine
162
172
  // (adapted from motion.dev examples). All tree-shakeable and reduced-motion aware;
163
173
  // `motion` is external, so importing one of these is the only thing that pulls it.
@@ -8,7 +8,7 @@
8
8
  // Usage: render it once at the top of your layout and keep the page root's
9
9
  // background transparent (don't put `bg-background` on the root) so the Aurora
10
10
  // shows through — the component paints the base background itself.
11
- import { For, onCleanup, onMount, type JSX } from 'solid-js'
11
+ import { For, onCleanup, onMount, Show, type JSX } from 'solid-js'
12
12
 
13
13
  import { cn } from '../lib/cn'
14
14
  import { motionReduced } from '../lib/motion'
@@ -24,9 +24,24 @@ export interface AuroraProps {
24
24
  * on any `.glow-edge` cards). Reduced-motion aware (off when reduced). @default true
25
25
  */
26
26
  pointerGlow?: boolean
27
+ /**
28
+ * Backdrop style: `'blobs'` (soft blurred color blobs) or `'mesh'` (a
29
+ * multi-point gradient mesh). Both are theme-tinted and drift when `animated`.
30
+ * @default 'blobs'
31
+ */
32
+ variant?: 'blobs' | 'mesh'
27
33
  class?: string
28
34
  }
29
35
 
36
+ // Mesh variant — several token-tinted radial gradients composited into one
37
+ // backdrop (positions chosen to fill the corners without a hard seam).
38
+ const MESH_STOPS = [
39
+ { at: '18% 22%', token: '--primary', a: 1 },
40
+ { at: '82% 26%', token: '--accent', a: 0.9 },
41
+ { at: '72% 82%', token: '--primary', a: 0.8 },
42
+ { at: '14% 78%', token: '--accent', a: 0.85 },
43
+ ] as const
44
+
30
45
  // Static class strings (scanned by Tailwind). token = which theme color; a =
31
46
  // per-blob opacity weight, multiplied by `intensity`.
32
47
  const BLOBS = [
@@ -53,6 +68,11 @@ const BLOBS = [
53
68
  */
54
69
  export function Aurora(props: AuroraProps): JSX.Element {
55
70
  const intensity = (): number => props.intensity ?? 0.45
71
+ const meshBg = (): string =>
72
+ MESH_STOPS.map(
73
+ (s) =>
74
+ `radial-gradient(45% 45% at ${s.at}, hsl(var(${s.token}) / ${(intensity() * s.a).toFixed(2)}), transparent 70%)`,
75
+ ).join(', ')
56
76
  let root: HTMLDivElement | undefined
57
77
 
58
78
  onMount(() => {
@@ -69,16 +89,26 @@ export function Aurora(props: AuroraProps): JSX.Element {
69
89
  aria-hidden="true"
70
90
  class={cn('pointer-events-none fixed inset-0 -z-10 overflow-hidden bg-background', props.class)}
71
91
  >
72
- <For each={BLOBS}>
73
- {(b) => (
74
- <div
75
- class={cn('absolute rounded-full blur-3xl', b.pos, b.size, props.animated && 'aurora-drift')}
76
- style={{
77
- background: `radial-gradient(circle, hsl(var(${b.token}) / ${(intensity() * b.a).toFixed(2)}), transparent 70%)`,
78
- }}
79
- />
80
- )}
81
- </For>
92
+ <Show
93
+ when={props.variant === 'mesh'}
94
+ fallback={
95
+ <For each={BLOBS}>
96
+ {(b) => (
97
+ <div
98
+ class={cn('absolute rounded-full blur-3xl', b.pos, b.size, props.animated && 'aurora-drift')}
99
+ style={{
100
+ background: `radial-gradient(circle, hsl(var(${b.token}) / ${(intensity() * b.a).toFixed(2)}), transparent 70%)`,
101
+ }}
102
+ />
103
+ )}
104
+ </For>
105
+ }
106
+ >
107
+ <div
108
+ class={cn('absolute -inset-[15%] blur-2xl', props.animated && 'aurora-drift')}
109
+ style={{ background: meshBg() }}
110
+ />
111
+ </Show>
82
112
  {/* Soft glow that follows the cursor across the backdrop (positioned by
83
113
  bindPointerFx via left/top on pointermove). */}
84
114
  <div
@@ -0,0 +1,60 @@
1
+ // Shared hover/selection state for two views of the same collection (e.g. a
2
+ // map and a list) so interacting with one highlights the other.
3
+ import { createSignal } from 'solid-js'
4
+
5
+ export interface LinkedSelection<Id> {
6
+ hoveredId: () => Id | undefined
7
+ selectedId: () => Id | undefined
8
+ setHovered: (id: Id | undefined) => void
9
+ setSelected: (id: Id | undefined) => void
10
+ isHovered: (id: Id) => boolean
11
+ isSelected: (id: Id) => boolean
12
+ /** Convenience props to spread onto an element for a given id (onPointerEnter/Leave + onClick). */
13
+ itemProps: (id: Id) => { onPointerEnter: () => void; onPointerLeave: () => void; onClick: () => void }
14
+ }
15
+
16
+ /**
17
+ * Shared hovered/selected state for two synchronized views of the same
18
+ * collection (a map and a list, a chart and a table, …): hover a list row to
19
+ * highlight the map pin, and vice versa.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * const selection = createLinkedSelection<string>()
24
+ * return (
25
+ * <For each={items}>
26
+ * {(item) => <ListRow class={selection.isSelected(item.id) ? 'active' : ''} {...selection.itemProps(item.id)} />}
27
+ * </For>
28
+ * )
29
+ * ```
30
+ */
31
+ export function createLinkedSelection<Id = string>(initialSelected?: Id): LinkedSelection<Id> {
32
+ const [hoveredId, setHoveredId] = createSignal<Id | undefined>(undefined)
33
+ const [selectedId, setSelectedId] = createSignal<Id | undefined>(initialSelected)
34
+
35
+ function setHovered(id: Id | undefined) {
36
+ setHoveredId(() => id)
37
+ }
38
+
39
+ function setSelected(id: Id | undefined) {
40
+ setSelectedId(() => id)
41
+ }
42
+
43
+ function isHovered(id: Id): boolean {
44
+ return hoveredId() === id
45
+ }
46
+
47
+ function isSelected(id: Id): boolean {
48
+ return selectedId() === id
49
+ }
50
+
51
+ function itemProps(id: Id) {
52
+ return {
53
+ onPointerEnter: () => setHovered(id),
54
+ onPointerLeave: () => setHovered(undefined),
55
+ onClick: () => setSelected(id),
56
+ }
57
+ }
58
+
59
+ return { hoveredId, selectedId, setHovered, setSelected, isHovered, isSelected, itemProps }
60
+ }
@@ -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
+ }