@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 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/styles.css` — design tokens + base styles
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';
@@ -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
+ }