@medalsocial/meda 2.8.1 → 3.0.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 +96 -1
- package/dist/primitives/avatar.d.ts +24 -0
- package/dist/primitives/avatar.js +44 -0
- package/dist/primitives/button.d.ts +21 -0
- package/dist/primitives/button.js +51 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/input.d.ts +10 -0
- package/dist/primitives/input.js +9 -0
- package/dist/primitives/toggle-group.d.ts +46 -0
- package/dist/primitives/toggle-group.js +148 -0
- package/dist/styles/base.css +40 -0
- package/dist/styles/bridge.css +159 -0
- package/dist/styles/calendar.css +8 -0
- package/dist/styles/primitives.css +10 -0
- package/dist/styles/theme.css +12 -162
- package/dist/styles/vendor/xyflow.css +626 -0
- package/dist/styles/workflow-builder.css +17 -0
- package/package.json +38 -9
package/README.md
CHANGED
|
@@ -12,6 +12,56 @@ pnpm add @medalsocial/meda lucide-react
|
|
|
12
12
|
|
|
13
13
|
Peer deps: `react >= 19`, `react-dom >= 19`, and `lucide-react`.
|
|
14
14
|
|
|
15
|
+
Feature peers are **optional** — install them only for the entry points that use
|
|
16
|
+
them (package managers no longer auto-install them):
|
|
17
|
+
|
|
18
|
+
| Peer | Needed by |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` | `@medalsocial/meda` (root barrel), `/shell`, `/kanban`, `/email-builder` |
|
|
21
|
+
| `@xyflow/react` | `@medalsocial/meda` (root barrel), `/workflow-builder`. (Its stylesheet is vendored into `styles.css` / `workflow-builder/styles.css`, so the CSS compiles without the package.) |
|
|
22
|
+
| `three`, `@react-three/fiber` | `/voice` (`VoiceOrb`) |
|
|
23
|
+
| `react-markdown`, `remark-gfm`, `rehype-highlight` | `/markdown-view` |
|
|
24
|
+
| `next-themes` | `NextThemesAdapter` / `themeAdapter="next-themes"` in `/shell` |
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Full Medal app (root barrel / shell):
|
|
28
|
+
pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @xyflow/react
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Lean surfaces such as `@medalsocial/meda/calendar` and
|
|
32
|
+
`@medalsocial/meda/primitives` need none of them. Fonts are not bundled either:
|
|
33
|
+
the tokens name `Geist` / `Geist Mono`, so load them yourself (for example
|
|
34
|
+
`@fontsource-variable/geist` + `@fontsource-variable/geist-mono`, or
|
|
35
|
+
`next/font`).
|
|
36
|
+
|
|
37
|
+
## Upgrading to 3.0
|
|
38
|
+
|
|
39
|
+
3.0 makes the feature peers **optional**, so npm and pnpm no longer install them
|
|
40
|
+
for you. Nothing else in the API changed.
|
|
41
|
+
|
|
42
|
+
**If you import any of** the root `@medalsocial/meda`, `@medalsocial/meda/shell`,
|
|
43
|
+
`/email-builder`, `/kanban`, `/workflow-builder`, or `@medalsocial/meda/styles.css`,
|
|
44
|
+
add the peers to your own `package.json`:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @xyflow/react
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**If you render the 3D `VoiceOrb`** (`@medalsocial/meda/voice`), also add:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm add three @react-three/fiber
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`three` used to be a regular dependency of meda; it is now an optional peer.
|
|
57
|
+
The `@fontsource-variable/geist*` packages are no longer installed by meda
|
|
58
|
+
either — if you `@import` them, list them yourself.
|
|
59
|
+
|
|
60
|
+
**Lean consumers need nothing:** `@medalsocial/meda/calendar`,
|
|
61
|
+
`@medalsocial/meda/primitives`, `styles/bridge.css`, `styles/base.css` and the
|
|
62
|
+
per-feature stylesheets never reach an optional peer (see
|
|
63
|
+
[Lean consumer setup](#lean-consumer-setup)).
|
|
64
|
+
|
|
15
65
|
## Tailwind CSS v4 setup
|
|
16
66
|
|
|
17
67
|
Meda ships a `styles.css` with its design tokens. Import it once in your entry stylesheet or entry script:
|
|
@@ -42,6 +92,49 @@ Avoid placing token overrides before the Meda import; `tokens.css` defines the p
|
|
|
42
92
|
@import '@medalsocial/meda/styles.css';
|
|
43
93
|
```
|
|
44
94
|
|
|
95
|
+
### Lean consumer setup
|
|
96
|
+
|
|
97
|
+
`styles.css` is batteries-included: it scans **every** meda component so
|
|
98
|
+
Tailwind generates all of their utilities (~22 KB gzip of CSS), and it pulls in
|
|
99
|
+
the `@xyflow/react` stylesheet. That is right for full Medal apps; it is
|
|
100
|
+
overkill for a marketing or customer site that renders a calendar and a few
|
|
101
|
+
primitives. Use the lean entries instead — each feature stylesheet generates
|
|
102
|
+
only the utilities its own compiled output uses:
|
|
103
|
+
|
|
104
|
+
```css
|
|
105
|
+
@import 'tailwindcss';
|
|
106
|
+
|
|
107
|
+
/* 1. Foundation — pick ONE */
|
|
108
|
+
@import '@medalsocial/meda/styles/base.css'; /* meda tokens + theme bridge + base layer */
|
|
109
|
+
/* @import '@medalsocial/meda/styles/bridge.css'; */ /* theme bridge only: bring your own tokens */
|
|
110
|
+
|
|
111
|
+
/* 2. Only the surfaces you render */
|
|
112
|
+
@import '@medalsocial/meda/primitives/styles.css';
|
|
113
|
+
@import '@medalsocial/meda/calendar/styles.css';
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Entry | Contains |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `styles.css` | `styles/base.css` + every feature (full component scan + xyflow CSS). Unchanged for existing consumers. |
|
|
119
|
+
| `styles/base.css` | `tokens.css` + `bridge.css` + the `color-scheme` base layer + Geist font mapping. No component scan, no xyflow. |
|
|
120
|
+
| `styles/bridge.css` | `@custom-variant dark` + the `@theme inline` mappings (`bg-primary`, `text-muted-foreground`, `bg-brand-500`, ...). No token values, no fonts. |
|
|
121
|
+
| `styles/tokens.css` | Raw CSS custom properties only. |
|
|
122
|
+
| `primitives/styles.css` | Utilities for `@medalsocial/meda/primitives` (Button, Input, ToggleGroup, Avatar, Card, Skeleton, StatusPill, EmptyState, FilterRail; not MarkdownView). |
|
|
123
|
+
| `calendar/styles.css` | Utilities for `@medalsocial/meda/calendar`. |
|
|
124
|
+
| `workflow-builder/styles.css` | `@xyflow/react`'s stylesheet + utilities for `@medalsocial/meda/workflow-builder`. The only lean entry that ships xyflow CSS. |
|
|
125
|
+
|
|
126
|
+
Feature stylesheets never include the foundation, so importing several of them
|
|
127
|
+
does not duplicate tokens — import exactly one foundation first. Never combine
|
|
128
|
+
the lean entries with `styles.css` (it already contains all of them).
|
|
129
|
+
|
|
130
|
+
**Bring your own tokens.** meda components use shadcn-style semantic tokens
|
|
131
|
+
(`--background`, `--foreground`, `--primary`, `--primary-foreground`, `--card`,
|
|
132
|
+
`--muted`, `--muted-foreground`, `--accent`, `--border`, `--input`, `--ring`,
|
|
133
|
+
`--destructive`, ...). A site that already defines those names can import
|
|
134
|
+
`styles/bridge.css` instead of `styles/base.css`, and meda parts re-theme to the
|
|
135
|
+
site's palette automatically. `bridge.css` does not map `--font-sans`, so the
|
|
136
|
+
site's own font stack is kept.
|
|
137
|
+
|
|
45
138
|
## Usage
|
|
46
139
|
|
|
47
140
|
Use `AppShell` for the styled shell surface:
|
|
@@ -219,7 +312,9 @@ See the [demo app](./demo) for a live playground.
|
|
|
219
312
|
- `@medalsocial/meda/recipes/next` — copyable Next.js adoption recipe metadata
|
|
220
313
|
- `@medalsocial/meda/theme` — app-scoped token bridge helpers
|
|
221
314
|
- `@medalsocial/meda/marketing` — marketing sections and campaign blocks
|
|
222
|
-
- `@medalsocial/meda/
|
|
315
|
+
- `@medalsocial/meda/primitives` — foundation primitives (`Button`, `Input`, `ToggleGroup`, `Avatar` + `getInitials`, `Card`, `Skeleton`, `StatusPill`, `EmptyState`, `FilterRail`); also re-exported from the root
|
|
316
|
+
- `@medalsocial/meda/styles.css` — design tokens + base styles + every component's utilities
|
|
317
|
+
- `@medalsocial/meda/styles/base.css`, `@medalsocial/meda/styles/bridge.css`, `@medalsocial/meda/<feature>/styles.css` — lean stylesheet entries (see [Lean consumer setup](#lean-consumer-setup))
|
|
223
318
|
|
|
224
319
|
## Alternative: shadcn registry
|
|
225
320
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ComponentProps } from 'react';
|
|
2
|
+
export type AvatarSize = 'sm' | 'md' | 'lg';
|
|
3
|
+
export interface AvatarProps extends Omit<ComponentProps<'span'>, 'children'> {
|
|
4
|
+
/** Person or entity name. Drives the accessible name and the initials. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Image URL. Falls back to initials when missing or when it fails to load. */
|
|
7
|
+
src?: string | null;
|
|
8
|
+
/** Override the computed initials (e.g. a single brand letter). */
|
|
9
|
+
initials?: string;
|
|
10
|
+
size?: AvatarSize;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Up to two initials from a name, letters only: the first letter of the first
|
|
14
|
+
* and of the last word. Digits, punctuation and emoji are ignored, so
|
|
15
|
+
* "Ali (Medal) 2" gives "AM" and " " gives "". Any script is supported via
|
|
16
|
+
* Unicode letter classes ("Åse Øien" -> "ÅØ").
|
|
17
|
+
*/
|
|
18
|
+
export declare function getInitials(name: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Image avatar with an initials fallback. The wrapper carries `role="img"` +
|
|
21
|
+
* `aria-label={name}`, so the name is announced once whether the photo or the
|
|
22
|
+
* initials are showing.
|
|
23
|
+
*/
|
|
24
|
+
export declare function Avatar({ name, src, initials, size, className, ...props }: AvatarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils.js';
|
|
5
|
+
const SIZE_CLASSES = {
|
|
6
|
+
sm: 'size-8 text-xs',
|
|
7
|
+
md: 'size-10 text-sm',
|
|
8
|
+
lg: 'size-12 text-base',
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Up to two initials from a name, letters only: the first letter of the first
|
|
12
|
+
* and of the last word. Digits, punctuation and emoji are ignored, so
|
|
13
|
+
* "Ali (Medal) 2" gives "AM" and " " gives "". Any script is supported via
|
|
14
|
+
* Unicode letter classes ("Åse Øien" -> "ÅØ").
|
|
15
|
+
*/
|
|
16
|
+
export function getInitials(name) {
|
|
17
|
+
const words = name
|
|
18
|
+
.split(/\s+/)
|
|
19
|
+
.map((word) => word.replace(/[^\p{L}]/gu, ''))
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
if (words.length === 0)
|
|
22
|
+
return '';
|
|
23
|
+
const first = words[0] ?? '';
|
|
24
|
+
const last = words.length > 1 ? (words[words.length - 1] ?? '') : '';
|
|
25
|
+
return (Array.from(first)[0] ?? '').concat(Array.from(last)[0] ?? '').toLocaleUpperCase();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Image avatar with an initials fallback. The wrapper carries `role="img"` +
|
|
29
|
+
* `aria-label={name}`, so the name is announced once whether the photo or the
|
|
30
|
+
* initials are showing.
|
|
31
|
+
*/
|
|
32
|
+
export function Avatar({ name, src, initials, size = 'md', className, ...props }) {
|
|
33
|
+
// Keyed by URL, so a new `src` automatically gets a fresh chance.
|
|
34
|
+
const [failedSrc, setFailedSrc] = useState(null);
|
|
35
|
+
const showImage = Boolean(src) && failedSrc !== src;
|
|
36
|
+
const text = initials ?? getInitials(name);
|
|
37
|
+
// An SSR-rendered <img> can fail before React hydrates and attaches
|
|
38
|
+
// onError; catch that case by inspecting the element once it mounts.
|
|
39
|
+
const imageRef = useCallback((image) => {
|
|
40
|
+
if (image?.complete && image.naturalWidth === 0 && src)
|
|
41
|
+
setFailedSrc(src);
|
|
42
|
+
}, [src]);
|
|
43
|
+
return (_jsx("span", { role: "img", "aria-label": name, "data-slot": "avatar", "data-size": size, "data-state": showImage ? 'image' : 'fallback', className: cn('relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground', SIZE_CLASSES[size], className), ...props, children: showImage ? (_jsx("img", { ref: imageRef, src: src ?? undefined, alt: "", decoding: "async", loading: "lazy", onError: () => setFailedSrc(src ?? null), className: "size-full object-cover" })) : (_jsx("span", { "aria-hidden": "true", "data-slot": "avatar-fallback", children: text })) }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react';
|
|
2
|
+
import { type RenderElement } from '../lib/render-element.js';
|
|
3
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline';
|
|
4
|
+
export type ButtonSize = 'sm' | 'md' | 'lg';
|
|
5
|
+
export interface ButtonProps extends ComponentProps<'button'> {
|
|
6
|
+
variant?: ButtonVariant;
|
|
7
|
+
size?: ButtonSize;
|
|
8
|
+
/**
|
|
9
|
+
* Shows a spinner, sets `aria-busy`, and swallows clicks while keeping the
|
|
10
|
+
* button focusable (so focus is not lost mid-submit) and its width stable.
|
|
11
|
+
*/
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
/** Screen-reader text announced while `loading`. Defaults to "Loading". */
|
|
14
|
+
loadingLabel?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Render as another element (e.g. a router `<Link>` or `<a>`), merging
|
|
17
|
+
* meda's props and classes onto it — same pattern as `AuthProviderButton`.
|
|
18
|
+
*/
|
|
19
|
+
render?: RenderElement<ButtonHTMLAttributes<HTMLElement>>;
|
|
20
|
+
}
|
|
21
|
+
export declare function Button({ variant, size, loading, loadingLabel, render, type, disabled, className, children, onClick, ...props }: ButtonProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { LoaderCircle } from 'lucide-react';
|
|
4
|
+
import { renderElement } from '../lib/render-element.js';
|
|
5
|
+
import { cn } from '../lib/utils.js';
|
|
6
|
+
// Semantic tokens only, so a consumer's own `--primary` / `--secondary` /
|
|
7
|
+
// `--accent` re-theme the button without touching meda.
|
|
8
|
+
const VARIANT_CLASSES = {
|
|
9
|
+
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
10
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
11
|
+
ghost: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
|
12
|
+
outline: 'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground',
|
|
13
|
+
};
|
|
14
|
+
const SIZE_CLASSES = {
|
|
15
|
+
sm: 'h-8 gap-1.5 px-3 text-xs [&_svg]:size-3.5',
|
|
16
|
+
md: 'h-10 gap-2 px-4 text-sm [&_svg]:size-4',
|
|
17
|
+
lg: 'h-11 gap-2 px-5 text-base [&_svg]:size-4',
|
|
18
|
+
};
|
|
19
|
+
export function Button({ variant = 'primary', size = 'md', loading = false, loadingLabel = 'Loading', render, type = 'button', disabled, className, children, onClick, ...props }) {
|
|
20
|
+
// A rendered link can't be natively disabled, so swallow its clicks too.
|
|
21
|
+
const inert = loading || (render !== undefined && Boolean(disabled));
|
|
22
|
+
const handleClick = (event) => {
|
|
23
|
+
if (inert) {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
onClick?.(event);
|
|
28
|
+
};
|
|
29
|
+
const content = (_jsxs(_Fragment, { children: [loading ? (_jsxs(_Fragment, { children: [_jsx(LoaderCircle, { "data-slot": "button-spinner", "aria-hidden": "true", className: "animate-spin motion-reduce:animate-none" }), _jsx("span", { className: "sr-only", children: loadingLabel })] })) : null, children] }));
|
|
30
|
+
const buttonProps = {
|
|
31
|
+
...props,
|
|
32
|
+
'data-slot': 'button',
|
|
33
|
+
'data-variant': variant,
|
|
34
|
+
'data-size': size,
|
|
35
|
+
'data-loading': loading || undefined,
|
|
36
|
+
'aria-busy': loading || undefined,
|
|
37
|
+
'aria-disabled': loading || undefined,
|
|
38
|
+
className: cn('relative inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 data-loading:cursor-progress [&_svg]:pointer-events-none [&_svg]:shrink-0', VARIANT_CLASSES[variant], SIZE_CLASSES[size], className),
|
|
39
|
+
onClick: handleClick,
|
|
40
|
+
children: content,
|
|
41
|
+
};
|
|
42
|
+
if (render) {
|
|
43
|
+
// Non-button hosts (links) have no `disabled`; expose it via ARIA instead.
|
|
44
|
+
return renderElement(render, {
|
|
45
|
+
...buttonProps,
|
|
46
|
+
'aria-disabled': inert || undefined,
|
|
47
|
+
'data-disabled': disabled || undefined,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return _jsx("button", { type: type, disabled: disabled, ...buttonProps });
|
|
51
|
+
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
export type { AvatarProps, AvatarSize } from './avatar.js';
|
|
2
|
+
export { Avatar, getInitials } from './avatar.js';
|
|
3
|
+
export type { ButtonProps, ButtonSize, ButtonVariant } from './button.js';
|
|
4
|
+
export { Button } from './button.js';
|
|
1
5
|
export type { CardBodyProps, CardFooterProps, CardHeaderProps, CardProps, } from './card.js';
|
|
2
6
|
export { Card } from './card.js';
|
|
3
7
|
export type { EmptyStateProps, EmptyStateVariant } from './empty-state.js';
|
|
4
8
|
export { EmptyState } from './empty-state.js';
|
|
5
9
|
export type { FilterRailGroupProps, FilterRailProps } from './filter-rail.js';
|
|
6
10
|
export { FilterRail } from './filter-rail.js';
|
|
11
|
+
export type { InputProps } from './input.js';
|
|
12
|
+
export { Input } from './input.js';
|
|
7
13
|
export type { SkeletonProps } from './skeleton.js';
|
|
8
14
|
export { Skeleton } from './skeleton.js';
|
|
9
15
|
export type { StatusPillProps, StatusPillSize, StatusPillTone } from './status-pill.js';
|
|
10
16
|
export { StatusPill } from './status-pill.js';
|
|
17
|
+
export type { ToggleGroupItemProps, ToggleGroupMultipleProps, ToggleGroupOrientation, ToggleGroupProps, ToggleGroupSingleProps, ToggleGroupSize, } from './toggle-group.js';
|
|
18
|
+
export { ToggleGroup } from './toggle-group.js';
|
package/dist/primitives/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
export { Avatar, getInitials } from './avatar.js';
|
|
2
|
+
export { Button } from './button.js';
|
|
1
3
|
export { Card } from './card.js';
|
|
2
4
|
export { EmptyState } from './empty-state.js';
|
|
3
5
|
export { FilterRail } from './filter-rail.js';
|
|
6
|
+
export { Input } from './input.js';
|
|
4
7
|
export { Skeleton } from './skeleton.js';
|
|
5
8
|
export { StatusPill } from './status-pill.js';
|
|
9
|
+
export { ToggleGroup } from './toggle-group.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react';
|
|
2
|
+
export interface InputProps extends ComponentProps<'input'> {
|
|
3
|
+
/**
|
|
4
|
+
* Marks the field invalid: sets `aria-invalid="true"` and the destructive
|
|
5
|
+
* border/ring. Pair it with `aria-describedby` pointing at the error text.
|
|
6
|
+
* An explicit `aria-invalid` prop still wins.
|
|
7
|
+
*/
|
|
8
|
+
invalid?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function Input({ invalid, type, className, 'aria-invalid': ariaInvalid, ...props }: InputProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from '../lib/utils.js';
|
|
4
|
+
export function Input({ invalid = false, type = 'text', className, 'aria-invalid': ariaInvalid, ...props }) {
|
|
5
|
+
const resolvedInvalid = ariaInvalid ?? (invalid || undefined);
|
|
6
|
+
return (_jsx("input", { type: type, "data-slot": "input", "data-invalid": resolvedInvalid === true || resolvedInvalid === 'true' ? '' : undefined, "aria-invalid": resolvedInvalid, className: cn(
|
|
7
|
+
// text-base below `sm` keeps iOS Safari from zooming on focus.
|
|
8
|
+
'flex h-10 w-full min-w-0 rounded-md border border-input bg-background px-3 py-2 text-base text-foreground transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/30 file:border-0 file:bg-transparent file:text-sm file:font-medium sm:text-sm', className), ...props }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ComponentProps } from 'react';
|
|
2
|
+
export type ToggleGroupSize = 'sm' | 'md';
|
|
3
|
+
export type ToggleGroupOrientation = 'horizontal' | 'vertical';
|
|
4
|
+
interface ToggleGroupBaseProps extends Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange' | 'role'> {
|
|
5
|
+
size?: ToggleGroupSize;
|
|
6
|
+
orientation?: ToggleGroupOrientation;
|
|
7
|
+
/** Disables every item. */
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ToggleGroupSingleProps extends ToggleGroupBaseProps {
|
|
11
|
+
/**
|
|
12
|
+
* `single` (default) behaves like a radio group: exactly one chip can be
|
|
13
|
+
* chosen, arrow keys move focus AND selection (WAI-ARIA radio pattern), and
|
|
14
|
+
* the chosen chip cannot be un-chosen by clicking it again.
|
|
15
|
+
*/
|
|
16
|
+
type?: 'single';
|
|
17
|
+
value?: string | null;
|
|
18
|
+
defaultValue?: string | null;
|
|
19
|
+
onValueChange?: (value: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface ToggleGroupMultipleProps extends ToggleGroupBaseProps {
|
|
22
|
+
/**
|
|
23
|
+
* `multiple` is a group of independent toggle buttons (`aria-pressed`);
|
|
24
|
+
* arrow keys only move focus, Space / Enter toggles.
|
|
25
|
+
*/
|
|
26
|
+
type: 'multiple';
|
|
27
|
+
value?: string[];
|
|
28
|
+
defaultValue?: string[];
|
|
29
|
+
onValueChange?: (value: string[]) => void;
|
|
30
|
+
}
|
|
31
|
+
export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;
|
|
32
|
+
export interface ToggleGroupItemProps extends Omit<ComponentProps<'button'>, 'value'> {
|
|
33
|
+
value: string;
|
|
34
|
+
}
|
|
35
|
+
declare function ToggleGroupRoot(props: ToggleGroupProps): import("react").JSX.Element;
|
|
36
|
+
declare function ToggleGroupItem({ value, disabled: itemDisabled, className, children, onClick, onKeyDown, onFocus, ...props }: ToggleGroupItemProps): import("react").JSX.Element;
|
|
37
|
+
/**
|
|
38
|
+
* Chip-style single- or multi-select. `type="single"` (default) is a
|
|
39
|
+
* `radiogroup`; `type="multiple"` is a `group` of `aria-pressed` toggles.
|
|
40
|
+
* Both use a roving tabindex (one Tab stop) with Arrow / Home / End keys.
|
|
41
|
+
* Give the group an accessible name via `aria-label` or `aria-labelledby`.
|
|
42
|
+
*/
|
|
43
|
+
export declare const ToggleGroup: typeof ToggleGroupRoot & {
|
|
44
|
+
Item: typeof ToggleGroupItem;
|
|
45
|
+
};
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useLayoutEffect, useRef, useState, } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils.js';
|
|
5
|
+
const ToggleGroupContext = createContext(null);
|
|
6
|
+
function useToggleGroupContext() {
|
|
7
|
+
const context = useContext(ToggleGroupContext);
|
|
8
|
+
if (!context)
|
|
9
|
+
throw new Error('ToggleGroup.Item must be rendered inside <ToggleGroup>.');
|
|
10
|
+
return context;
|
|
11
|
+
}
|
|
12
|
+
const ITEM_SELECTOR = '[data-slot="toggle-group-item"]';
|
|
13
|
+
const NEXT_KEYS = {
|
|
14
|
+
horizontal: ['ArrowRight', 'ArrowDown'],
|
|
15
|
+
vertical: ['ArrowDown', 'ArrowRight'],
|
|
16
|
+
};
|
|
17
|
+
const PREV_KEYS = {
|
|
18
|
+
horizontal: ['ArrowLeft', 'ArrowUp'],
|
|
19
|
+
vertical: ['ArrowUp', 'ArrowLeft'],
|
|
20
|
+
};
|
|
21
|
+
const SIZE_CLASSES = {
|
|
22
|
+
sm: 'h-8 px-3 text-xs [&_svg]:size-3.5',
|
|
23
|
+
md: 'h-10 px-4 text-sm [&_svg]:size-4',
|
|
24
|
+
};
|
|
25
|
+
function toArray(value) {
|
|
26
|
+
if (Array.isArray(value))
|
|
27
|
+
return value;
|
|
28
|
+
return value == null ? [] : [value];
|
|
29
|
+
}
|
|
30
|
+
function ToggleGroupRoot(props) {
|
|
31
|
+
const { type = 'single', value, defaultValue, onValueChange, size = 'md', orientation = 'horizontal', disabled = false, className, children, ...rest } = props;
|
|
32
|
+
const rootRef = useRef(null);
|
|
33
|
+
const [uncontrolled, setUncontrolled] = useState(() => toArray(defaultValue));
|
|
34
|
+
const isControlled = value !== undefined;
|
|
35
|
+
const selected = isControlled ? toArray(value) : uncontrolled;
|
|
36
|
+
const [focused, setFocused] = useState(null);
|
|
37
|
+
const [tabbable, setTabbable] = useState(undefined);
|
|
38
|
+
const enabledItems = useCallback(() => Array.from(rootRef.current?.querySelectorAll(ITEM_SELECTOR) ?? []).filter((item) => !item.disabled), []);
|
|
39
|
+
// Roving tabindex: exactly one enabled item is in the tab order — the last
|
|
40
|
+
// focused one, else the (first) selected one, else the first enabled one.
|
|
41
|
+
// Measured from the DOM after each render so nested / conditional items
|
|
42
|
+
// just work; the state update bails out when nothing changed.
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
const values = enabledItems().map((item) => item.dataset.value ?? '');
|
|
45
|
+
const next = [focused, ...selected].find((v) => v != null && values.includes(v)) ?? values[0];
|
|
46
|
+
setTabbable(next ?? null);
|
|
47
|
+
});
|
|
48
|
+
const commit = (next) => {
|
|
49
|
+
if (!isControlled)
|
|
50
|
+
setUncontrolled(next);
|
|
51
|
+
if (type === 'multiple') {
|
|
52
|
+
onValueChange?.(next);
|
|
53
|
+
}
|
|
54
|
+
else if (next[0] !== undefined) {
|
|
55
|
+
onValueChange?.(next[0]);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const toggle = (itemValue) => {
|
|
59
|
+
if (type === 'multiple') {
|
|
60
|
+
commit(selected.includes(itemValue)
|
|
61
|
+
? selected.filter((v) => v !== itemValue)
|
|
62
|
+
: [...selected, itemValue]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Radio semantics: re-choosing the current value is a no-op.
|
|
66
|
+
if (selected[0] !== itemValue)
|
|
67
|
+
commit([itemValue]);
|
|
68
|
+
};
|
|
69
|
+
const onItemKeyDown = (event) => {
|
|
70
|
+
const items = enabledItems();
|
|
71
|
+
const index = items.indexOf(event.currentTarget);
|
|
72
|
+
if (index === -1 || items.length === 0)
|
|
73
|
+
return;
|
|
74
|
+
let target;
|
|
75
|
+
if (NEXT_KEYS[orientation].includes(event.key))
|
|
76
|
+
target = items[(index + 1) % items.length];
|
|
77
|
+
else if (PREV_KEYS[orientation].includes(event.key))
|
|
78
|
+
target = items[(index - 1 + items.length) % items.length];
|
|
79
|
+
else if (event.key === 'Home')
|
|
80
|
+
target = items[0];
|
|
81
|
+
else if (event.key === 'End')
|
|
82
|
+
target = items[items.length - 1];
|
|
83
|
+
if (!target)
|
|
84
|
+
return;
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
target.focus();
|
|
87
|
+
const targetValue = target.dataset.value ?? '';
|
|
88
|
+
setFocused(targetValue);
|
|
89
|
+
if (type === 'single')
|
|
90
|
+
toggle(targetValue);
|
|
91
|
+
};
|
|
92
|
+
// `aria-orientation` is only defined for radiogroup, not for group.
|
|
93
|
+
const groupSemantics = type === 'single'
|
|
94
|
+
? {
|
|
95
|
+
role: 'radiogroup',
|
|
96
|
+
'aria-orientation': orientation,
|
|
97
|
+
'aria-disabled': disabled || undefined,
|
|
98
|
+
}
|
|
99
|
+
: { role: 'group', 'aria-disabled': disabled || undefined };
|
|
100
|
+
const context = {
|
|
101
|
+
type,
|
|
102
|
+
size,
|
|
103
|
+
orientation,
|
|
104
|
+
disabled,
|
|
105
|
+
selected,
|
|
106
|
+
tabbable,
|
|
107
|
+
toggle,
|
|
108
|
+
focusItem: setFocused,
|
|
109
|
+
onItemKeyDown,
|
|
110
|
+
};
|
|
111
|
+
return (_jsx(ToggleGroupContext.Provider, { value: context, children: _jsx("div", { ref: rootRef, ...groupSemantics, "data-slot": "toggle-group", "data-type": type, "data-orientation": orientation, className: cn('flex flex-wrap gap-2', orientation === 'vertical' && 'flex-col items-stretch', className), ...rest, children: children }) }));
|
|
112
|
+
}
|
|
113
|
+
function ToggleGroupItem({ value, disabled: itemDisabled, className, children, onClick, onKeyDown, onFocus, ...props }) {
|
|
114
|
+
const group = useToggleGroupContext();
|
|
115
|
+
const isOn = group.selected.includes(value);
|
|
116
|
+
const disabled = group.disabled || Boolean(itemDisabled);
|
|
117
|
+
// Before the group has measured (SSR / first paint) keep the selected item
|
|
118
|
+
// — or every item when nothing is selected — reachable by Tab.
|
|
119
|
+
const inTabOrder = group.tabbable === undefined ? group.selected.length === 0 || isOn : group.tabbable === value;
|
|
120
|
+
const semantics = group.type === 'single'
|
|
121
|
+
? { role: 'radio', 'aria-checked': isOn }
|
|
122
|
+
: { 'aria-pressed': isOn };
|
|
123
|
+
return (_jsx("button", { type: "button", "data-slot": "toggle-group-item", "data-value": value, "data-state": isOn ? 'on' : 'off', disabled: disabled, tabIndex: inTabOrder ? 0 : -1, ...semantics, className: cn('inline-flex shrink-0 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-full border font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', isOn
|
|
124
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
125
|
+
: 'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground', SIZE_CLASSES[group.size], className), onClick: (event) => {
|
|
126
|
+
onClick?.(event);
|
|
127
|
+
if (event.defaultPrevented)
|
|
128
|
+
return;
|
|
129
|
+
group.focusItem(value);
|
|
130
|
+
group.toggle(value);
|
|
131
|
+
}, onKeyDown: (event) => {
|
|
132
|
+
onKeyDown?.(event);
|
|
133
|
+
if (!event.defaultPrevented)
|
|
134
|
+
group.onItemKeyDown(event);
|
|
135
|
+
}, onFocus: (event) => {
|
|
136
|
+
onFocus?.(event);
|
|
137
|
+
group.focusItem(value);
|
|
138
|
+
}, ...props, children: children }));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Chip-style single- or multi-select. `type="single"` (default) is a
|
|
142
|
+
* `radiogroup`; `type="multiple"` is a `group` of `aria-pressed` toggles.
|
|
143
|
+
* Both use a roving tabindex (one Tab stop) with Arrow / Home / End keys.
|
|
144
|
+
* Give the group an accessible name via `aria-label` or `aria-labelledby`.
|
|
145
|
+
*/
|
|
146
|
+
export const ToggleGroup = Object.assign(ToggleGroupRoot, {
|
|
147
|
+
Item: ToggleGroupItem,
|
|
148
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* @medalsocial/meda/styles/base.css
|
|
2
|
+
*
|
|
3
|
+
* Lean foundation for consumers that render only a few meda surfaces:
|
|
4
|
+
* canonical design tokens + the Tailwind theme bridge + the base layer.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately does NOT `@source` meda's component output and does NOT pull
|
|
7
|
+
* in `@xyflow/react`'s stylesheet. Pair it with the per-feature stylesheets
|
|
8
|
+
* for the surfaces you render, e.g.
|
|
9
|
+
*
|
|
10
|
+
* @import "tailwindcss";
|
|
11
|
+
* @import "@medalsocial/meda/styles/base.css";
|
|
12
|
+
* @import "@medalsocial/meda/primitives/styles.css";
|
|
13
|
+
* @import "@medalsocial/meda/calendar/styles.css";
|
|
14
|
+
*
|
|
15
|
+
* `@medalsocial/meda/styles.css` (theme.css) remains the batteries-included
|
|
16
|
+
* entry: base + every feature, including workflow-builder.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
@import "./tokens.css";
|
|
20
|
+
@import "./bridge.css";
|
|
21
|
+
|
|
22
|
+
@theme inline {
|
|
23
|
+
/* Font families — Geist + Geist Mono only (no Inter) */
|
|
24
|
+
--font-sans: var(--font-family-sans);
|
|
25
|
+
--font-mono: var(--font-family-mono);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@layer base {
|
|
29
|
+
:root {
|
|
30
|
+
color-scheme: light;
|
|
31
|
+
--auth-gradient-primary: var(--color-brand-500);
|
|
32
|
+
--auth-gradient-secondary: var(--color-brand-700);
|
|
33
|
+
--auth-gradient-base: var(--color-brand-800);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.dark,
|
|
37
|
+
[data-theme="dark"] {
|
|
38
|
+
color-scheme: dark;
|
|
39
|
+
}
|
|
40
|
+
}
|