steadymeet-ui 0.0.0-stage → 0.1.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/Button.d.ts +9 -0
- package/Button.js +7 -0
- package/Checkbox.d.ts +7 -0
- package/Checkbox.js +20 -0
- package/Dropdown.d.ts +17 -0
- package/Dropdown.js +25 -0
- package/ErrorState.d.ts +11 -0
- package/ErrorState.js +14 -0
- package/Input.d.ts +4 -0
- package/Input.js +7 -0
- package/Link.d.ts +10 -0
- package/Link.js +18 -0
- package/OtpInput.d.ts +11 -0
- package/OtpInput.js +52 -0
- package/Pagination.d.ts +9 -0
- package/Pagination.js +23 -0
- package/README.md +84 -2
- package/Radio.d.ts +17 -0
- package/Radio.js +5 -0
- package/Select.d.ts +17 -0
- package/Select.js +8 -0
- package/Skeleton.d.ts +23 -0
- package/Skeleton.js +18 -0
- package/Slider.d.ts +13 -0
- package/Slider.js +5 -0
- package/Switch.d.ts +9 -0
- package/Switch.js +8 -0
- package/Table.d.ts +13 -0
- package/Table.js +6 -0
- package/TableSkeleton.d.ts +5 -0
- package/TableSkeleton.js +6 -0
- package/Tabs.d.ts +13 -0
- package/Tabs.js +5 -0
- package/Textarea.d.ts +4 -0
- package/Textarea.js +7 -0
- package/Toast.d.ts +7 -0
- package/Toast.js +20 -0
- package/cn.d.ts +2 -0
- package/cn.js +15 -0
- package/index.d.ts +37 -0
- package/index.js +20 -0
- package/otp.d.ts +16 -0
- package/otp.js +18 -0
- package/package.json +34 -4
- package/styles/components.css +117 -0
- package/styles/tokens.css +86 -0
package/Button.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ButtonHTMLAttributes } from 'react';
|
|
2
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
|
3
|
+
export type ButtonSize = 'sm' | 'md' | 'icon';
|
|
4
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
5
|
+
variant?: ButtonVariant;
|
|
6
|
+
size?: ButtonSize;
|
|
7
|
+
}
|
|
8
|
+
declare const Button: import("react").ForwardRefExoticComponent<ButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
9
|
+
export default Button;
|
package/Button.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef } from 'react';
|
|
3
|
+
import { cn } from './cn.js';
|
|
4
|
+
const Button = forwardRef(function Button({ variant = 'primary', size = 'md', className, type = 'button', ...props }, ref) {
|
|
5
|
+
return _jsx("button", { ref: ref, type: type, className: cn('ui-btn', `ui-btn-${variant}`, `ui-btn-${size}`, className), ...props });
|
|
6
|
+
});
|
|
7
|
+
export default Button;
|
package/Checkbox.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { InputHTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
|
|
3
|
+
label?: ReactNode;
|
|
4
|
+
indeterminate?: boolean;
|
|
5
|
+
}
|
|
6
|
+
declare const Checkbox: import("react").ForwardRefExoticComponent<CheckboxProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
7
|
+
export default Checkbox;
|
package/Checkbox.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, useEffect, useRef } from 'react';
|
|
4
|
+
import { Check, Minus } from 'lucide-react';
|
|
5
|
+
import { cn } from './cn.js';
|
|
6
|
+
const Checkbox = forwardRef(function Checkbox({ label, indeterminate, className, id, ...props }, forwardedRef) {
|
|
7
|
+
const innerRef = useRef(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (innerRef.current)
|
|
10
|
+
innerRef.current.indeterminate = Boolean(indeterminate);
|
|
11
|
+
}, [indeterminate]);
|
|
12
|
+
return _jsxs("label", { className: cn('ui-checkbox', className), htmlFor: id, children: [_jsx("input", { ref: (node) => {
|
|
13
|
+
innerRef.current = node;
|
|
14
|
+
if (typeof forwardedRef === 'function')
|
|
15
|
+
forwardedRef(node);
|
|
16
|
+
else if (forwardedRef)
|
|
17
|
+
forwardedRef.current = node;
|
|
18
|
+
}, type: "checkbox", id: id, ...props }), _jsx("span", { className: "ui-checkbox-box", "aria-hidden": "true", children: indeterminate ? _jsx(Minus, { size: 12, strokeWidth: 3 }) : _jsx(Check, { size: 12, strokeWidth: 3 }) }), label !== undefined && _jsx("span", { className: "ui-checkbox-label", children: label })] });
|
|
19
|
+
});
|
|
20
|
+
export default Checkbox;
|
package/Dropdown.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as RadixDropdown from '@radix-ui/react-dropdown-menu';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
export interface DropdownProps {
|
|
4
|
+
trigger: ReactNode;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
align?: 'start' | 'center' | 'end';
|
|
7
|
+
className?: string;
|
|
8
|
+
open?: boolean;
|
|
9
|
+
onOpenChange?: (open: boolean) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function Dropdown({ trigger, children, align, className, open, onOpenChange }: DropdownProps): import("react").JSX.Element;
|
|
12
|
+
export declare function DropdownItem({ className, ...props }: RadixDropdown.DropdownMenuItemProps): import("react").JSX.Element;
|
|
13
|
+
export declare function DropdownSeparator({ className, ...props }: RadixDropdown.DropdownMenuSeparatorProps): import("react").JSX.Element;
|
|
14
|
+
export declare function DropdownLabel({ className, ...props }: RadixDropdown.DropdownMenuLabelProps): import("react").JSX.Element;
|
|
15
|
+
export declare function DropdownSub(props: RadixDropdown.DropdownMenuSubProps): import("react").JSX.Element;
|
|
16
|
+
export declare function DropdownSubTrigger({ className, ...props }: RadixDropdown.DropdownMenuSubTriggerProps): import("react").JSX.Element;
|
|
17
|
+
export declare function DropdownSubContent({ className, ...props }: RadixDropdown.DropdownMenuSubContentProps): import("react").JSX.Element;
|
package/Dropdown.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as RadixDropdown from '@radix-ui/react-dropdown-menu';
|
|
4
|
+
import { cn } from './cn.js';
|
|
5
|
+
export function Dropdown({ trigger, children, align = 'end', className, open, onOpenChange }) {
|
|
6
|
+
return _jsxs(RadixDropdown.Root, { open: open, onOpenChange: onOpenChange, children: [_jsx(RadixDropdown.Trigger, { asChild: true, children: trigger }), _jsx(RadixDropdown.Portal, { children: _jsx(RadixDropdown.Content, { align: align, sideOffset: 8, className: cn('ui-dropdown-content', className), children: children }) })] });
|
|
7
|
+
}
|
|
8
|
+
export function DropdownItem({ className, ...props }) {
|
|
9
|
+
return _jsx(RadixDropdown.Item, { className: cn('ui-dropdown-item', className), ...props });
|
|
10
|
+
}
|
|
11
|
+
export function DropdownSeparator({ className, ...props }) {
|
|
12
|
+
return _jsx(RadixDropdown.Separator, { className: cn('ui-dropdown-separator', className), ...props });
|
|
13
|
+
}
|
|
14
|
+
export function DropdownLabel({ className, ...props }) {
|
|
15
|
+
return _jsx(RadixDropdown.Label, { className: cn('ui-dropdown-label', className), ...props });
|
|
16
|
+
}
|
|
17
|
+
export function DropdownSub(props) {
|
|
18
|
+
return _jsx(RadixDropdown.Sub, { ...props });
|
|
19
|
+
}
|
|
20
|
+
export function DropdownSubTrigger({ className, ...props }) {
|
|
21
|
+
return _jsx(RadixDropdown.SubTrigger, { className: cn('ui-dropdown-item', 'ui-dropdown-sub-trigger', className), ...props });
|
|
22
|
+
}
|
|
23
|
+
export function DropdownSubContent({ className, ...props }) {
|
|
24
|
+
return _jsx(RadixDropdown.Portal, { children: _jsx(RadixDropdown.SubContent, { sideOffset: 6, alignOffset: -6, className: cn('ui-dropdown-content', className), ...props }) });
|
|
25
|
+
}
|
package/ErrorState.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
export type ErrorCode = '400' | '401' | '403' | '404' | '500' | '503';
|
|
3
|
+
export interface ErrorStateProps {
|
|
4
|
+
code?: ErrorCode;
|
|
5
|
+
title: ReactNode;
|
|
6
|
+
description?: ReactNode;
|
|
7
|
+
icon?: ReactNode;
|
|
8
|
+
actions?: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
export default function ErrorState({ code, title, description, icon, actions, className }: ErrorStateProps): import("react").JSX.Element;
|
package/ErrorState.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Ghost, Lock, ServerCrash, ShieldAlert, TriangleAlert, WifiOff } from 'lucide-react';
|
|
3
|
+
import { cn } from './cn.js';
|
|
4
|
+
const defaultIcons = {
|
|
5
|
+
'400': _jsx(TriangleAlert, { size: 26 }),
|
|
6
|
+
'401': _jsx(Lock, { size: 26 }),
|
|
7
|
+
'403': _jsx(ShieldAlert, { size: 26 }),
|
|
8
|
+
'404': _jsx(Ghost, { size: 26 }),
|
|
9
|
+
'500': _jsx(ServerCrash, { size: 26 }),
|
|
10
|
+
'503': _jsx(WifiOff, { size: 26 }),
|
|
11
|
+
};
|
|
12
|
+
export default function ErrorState({ code = '500', title, description, icon, actions, className }) {
|
|
13
|
+
return _jsxs("div", { className: cn('error-page-card', className), children: [_jsx("span", { className: "error-page-icon", "aria-hidden": "true", children: icon ?? defaultIcons[code] }), _jsx("p", { className: "error-page-code", children: `Error ${code}` }), _jsx("h1", { children: title }), description !== undefined && _jsx("p", { children: description }), actions !== undefined && _jsx("div", { className: "error-page-actions", children: actions })] });
|
|
14
|
+
}
|
package/Input.d.ts
ADDED
package/Input.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef } from 'react';
|
|
3
|
+
import { cn } from './cn.js';
|
|
4
|
+
const Input = forwardRef(function Input({ className, ...props }, ref) {
|
|
5
|
+
return _jsx("input", { ref: ref, className: cn('ui-input', className), ...props });
|
|
6
|
+
});
|
|
7
|
+
export default Input;
|
package/Link.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AnchorHTMLAttributes } from 'react';
|
|
2
|
+
export type LinkVariant = 'text' | 'muted' | 'button-primary' | 'button-secondary';
|
|
3
|
+
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
4
|
+
variant?: LinkVariant;
|
|
5
|
+
}
|
|
6
|
+
/** The classes a link of this variant wears, for a router's own link component (Next's, React Router's) to take. */
|
|
7
|
+
export declare function linkClassName(variant?: LinkVariant, className?: string): string;
|
|
8
|
+
/** A plain <a>. Apps with a client-side router wrap their router's link with `linkClassName` instead. */
|
|
9
|
+
declare const Link: import("react").ForwardRefExoticComponent<LinkProps & import("react").RefAttributes<HTMLAnchorElement>>;
|
|
10
|
+
export default Link;
|
package/Link.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef } from 'react';
|
|
3
|
+
import { cn } from './cn.js';
|
|
4
|
+
const variantClassName = {
|
|
5
|
+
text: 'ui-link',
|
|
6
|
+
muted: 'ui-link ui-link-muted',
|
|
7
|
+
'button-primary': 'ui-btn ui-btn-primary ui-btn-md',
|
|
8
|
+
'button-secondary': 'ui-btn ui-btn-secondary ui-btn-md',
|
|
9
|
+
};
|
|
10
|
+
/** The classes a link of this variant wears, for a router's own link component (Next's, React Router's) to take. */
|
|
11
|
+
export function linkClassName(variant = 'text', className) {
|
|
12
|
+
return cn(variantClassName[variant], className);
|
|
13
|
+
}
|
|
14
|
+
/** A plain <a>. Apps with a client-side router wrap their router's link with `linkClassName` instead. */
|
|
15
|
+
const Link = forwardRef(function Link({ variant = 'text', className, ...props }, ref) {
|
|
16
|
+
return _jsx("a", { ref: ref, className: linkClassName(variant, className), ...props });
|
|
17
|
+
});
|
|
18
|
+
export default Link;
|
package/OtpInput.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface OtpInputProps {
|
|
2
|
+
length?: number;
|
|
3
|
+
value: string;
|
|
4
|
+
onChange: (value: string) => void;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
autoFocus?: boolean;
|
|
7
|
+
/** Marks the boxes as wrong (red border, a short shake). Clear it when the person edits. */
|
|
8
|
+
invalid?: boolean;
|
|
9
|
+
'aria-label'?: string;
|
|
10
|
+
}
|
|
11
|
+
export default function OtpInput({ length, value, onChange, disabled, autoFocus, invalid, 'aria-label': ariaLabel }: OtpInputProps): import("react").JSX.Element;
|
package/OtpInput.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { otpBackspace, otpDigits, otpEdit } from './otp.js';
|
|
5
|
+
// One box per digit, with a row of dots under them that fill as the code is typed. `value` is always
|
|
6
|
+
// the digits typed so far with no gaps, so a click on a later box lands on the next empty one.
|
|
7
|
+
export default function OtpInput({ length = 6, value, onChange, disabled, autoFocus, invalid, 'aria-label': ariaLabel }) {
|
|
8
|
+
const inputsRef = useRef([]);
|
|
9
|
+
// The digits typed so far, kept current between a change and the re-render it causes: focus moves in
|
|
10
|
+
// the same event, before `value` has caught up, and a stale length would bounce it back.
|
|
11
|
+
const filledRef = useRef(value.length);
|
|
12
|
+
useEffect(() => { filledRef.current = value.length; }, [value]);
|
|
13
|
+
const focusBox = (index) => inputsRef.current[Math.max(0, Math.min(index, length - 1))]?.focus();
|
|
14
|
+
function handleChange(index, event) {
|
|
15
|
+
const edit = otpEdit(value, index, event.target.value, length);
|
|
16
|
+
onChange(edit.value);
|
|
17
|
+
filledRef.current = edit.value.length;
|
|
18
|
+
focusBox(edit.focus);
|
|
19
|
+
}
|
|
20
|
+
function handleKeyDown(index, event) {
|
|
21
|
+
if (event.key === 'Backspace' && !value[index] && index > 0) {
|
|
22
|
+
event.preventDefault();
|
|
23
|
+
const edit = otpBackspace(value, index);
|
|
24
|
+
onChange(edit.value);
|
|
25
|
+
filledRef.current = edit.value.length;
|
|
26
|
+
focusBox(edit.focus);
|
|
27
|
+
}
|
|
28
|
+
else if (event.key === 'ArrowLeft') {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
focusBox(index - 1);
|
|
31
|
+
}
|
|
32
|
+
else if (event.key === 'ArrowRight') {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
focusBox(Math.min(index + 1, filledRef.current));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function handlePaste(event) {
|
|
38
|
+
const pasted = otpDigits(event.clipboardData.getData('text'), length);
|
|
39
|
+
if (!pasted)
|
|
40
|
+
return;
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
onChange(pasted);
|
|
43
|
+
filledRef.current = pasted.length;
|
|
44
|
+
focusBox(pasted.length);
|
|
45
|
+
}
|
|
46
|
+
return _jsxs("div", { className: "otp-field", children: [_jsx("div", { className: "otp-input", role: "group", "aria-label": ariaLabel, "data-invalid": invalid ? 'true' : undefined, children: Array.from({ length }, (_, index) => (_jsx("input", { ref: (el) => { inputsRef.current[index] = el; }, className: "otp-input-box", type: "text", inputMode: "numeric", autoComplete: index === 0 ? 'one-time-code' : 'off', value: value[index] ?? '', disabled: disabled, autoFocus: autoFocus && index === 0, "aria-label": ariaLabel ? `${ariaLabel} ${index + 1}/${length}` : undefined, "aria-invalid": invalid || undefined, onChange: (event) => handleChange(index, event), onKeyDown: (event) => handleKeyDown(index, event), onPaste: handlePaste, onFocus: (event) => {
|
|
47
|
+
if (index > filledRef.current)
|
|
48
|
+
focusBox(filledRef.current);
|
|
49
|
+
else
|
|
50
|
+
event.target.select();
|
|
51
|
+
} }, index))) }), _jsx("div", { className: "otp-dots", "aria-hidden": "true", children: Array.from({ length }, (_, index) => _jsx("span", { className: "otp-dot", "data-filled": index < value.length ? 'true' : undefined }, index)) })] });
|
|
52
|
+
}
|
package/Pagination.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface PaginationProps {
|
|
2
|
+
page: number;
|
|
3
|
+
totalPages: number;
|
|
4
|
+
onPageChange: (page: number) => void;
|
|
5
|
+
previousLabel?: string;
|
|
6
|
+
nextLabel?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
export default function Pagination({ page, totalPages, onPageChange, previousLabel, nextLabel, className }: PaginationProps): import("react").JSX.Element | null;
|
package/Pagination.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
3
|
+
import { cn } from './cn.js';
|
|
4
|
+
function pageList(page, totalPages) {
|
|
5
|
+
if (totalPages <= 7)
|
|
6
|
+
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
|
7
|
+
const pages = new Set([1, totalPages, page, page - 1, page + 1]);
|
|
8
|
+
const sorted = [...pages].filter((value) => value >= 1 && value <= totalPages).sort((a, b) => a - b);
|
|
9
|
+
const result = [];
|
|
10
|
+
sorted.forEach((value, index) => {
|
|
11
|
+
if (index > 0 && value - sorted[index - 1] > 1)
|
|
12
|
+
result.push('ellipsis');
|
|
13
|
+
result.push(value);
|
|
14
|
+
});
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
export default function Pagination({ page, totalPages, onPageChange, previousLabel = 'Previous', nextLabel = 'Next', className }) {
|
|
18
|
+
if (totalPages <= 1)
|
|
19
|
+
return null;
|
|
20
|
+
return _jsxs("nav", { className: cn('ui-pagination', className), "aria-label": "Pagination", children: [_jsx("button", { type: "button", className: "ui-pagination-nav", onClick: () => onPageChange(page - 1), disabled: page <= 1, "aria-label": previousLabel, children: _jsx(ChevronLeft, { size: 16 }) }), pageList(page, totalPages).map((entry, index) => entry === 'ellipsis'
|
|
21
|
+
? _jsx("span", { className: "ui-pagination-ellipsis", children: "\u2026" }, `ellipsis-${index}`)
|
|
22
|
+
: _jsx("button", { type: "button", className: cn('ui-pagination-page', { active: entry === page }), "aria-current": entry === page ? 'page' : undefined, onClick: () => onPageChange(entry), children: entry }, entry)), _jsx("button", { type: "button", className: "ui-pagination-nav", onClick: () => onPageChange(page + 1), disabled: page >= totalPages, "aria-label": nextLabel, children: _jsx(ChevronRight, { size: 16 }) })] });
|
|
23
|
+
}
|
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# steadymeet-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SteadyMeet's React UI kit: accessible components and the design tokens behind them, styled with Tailwind CSS v4. SteadyMeet's web app uses it, and it is meant to be reused in other apps.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Requirements: React 19 and Tailwind CSS v4.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install steadymeet-ui
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
In the app's main stylesheet:
|
|
14
|
+
|
|
15
|
+
```css
|
|
16
|
+
@import "tailwindcss";
|
|
17
|
+
@import "steadymeet-ui/tokens.css"; /* colours, light/dark, accent presets */
|
|
18
|
+
@import "steadymeet-ui/components.css"; /* the components' styles */
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`components.css` uses `@apply` with the kit's theme, so it must be imported in a Tailwind stylesheet after `tokens.css`. It also tells Tailwind to scan the kit's source for utility classes.
|
|
22
|
+
|
|
23
|
+
Wrap the app once in the toast provider:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { ToastProvider } from 'steadymeet-ui';
|
|
27
|
+
|
|
28
|
+
<ToastProvider>{children}</ToastProvider>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Theming
|
|
32
|
+
|
|
33
|
+
Colours are CSS variables prefixed `--steadymeet-`, so they can't clash with an app's own variables. You use them through Tailwind classes such as `bg-panel`, `text-ink`, `border-line`, `bg-brand` and `text-brand-text`:
|
|
34
|
+
|
|
35
|
+
- **Dark mode:** put `class="dark"` on `<html>` or any ancestor.
|
|
36
|
+
- **Accent:** set `data-accent="blue" | "green" | "purple" | "red"` on `<html>`. Orange is the default.
|
|
37
|
+
- **Your own palette:** after importing `tokens.css`, redefine the variables (`--steadymeet-ink`, `--steadymeet-panel`, `--steadymeet-brand`, `--steadymeet-brand-dark`, `--steadymeet-brand-text`…). `brand-text` is the accent used as text and focus rings, so keep it readable (WCAG AA) on `--steadymeet-panel`.
|
|
38
|
+
|
|
39
|
+
## Components
|
|
40
|
+
|
|
41
|
+
| Component | Notes |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `Button` | `variant`: primary, secondary, ghost, danger. `size`: sm, md, icon. |
|
|
44
|
+
| `Input`, `Textarea` | Plain fields that forward refs. |
|
|
45
|
+
| `OtpInput` | One box per digit. Handles paste and one-time-code autofill. `invalid` shakes it. |
|
|
46
|
+
| `Checkbox` | `label`, `indeterminate`. |
|
|
47
|
+
| `RadioGroup` | Options with an optional `description` each. |
|
|
48
|
+
| `Switch` | `role="switch"`. With `icon` or `description` it becomes a settings row. |
|
|
49
|
+
| `Slider` | Range input with a label and a value badge. |
|
|
50
|
+
| `Select` | Radix select. |
|
|
51
|
+
| `Dropdown`, `DropdownItem`, `DropdownSeparator`, `DropdownLabel`, `DropdownSub*` | Radix menu. |
|
|
52
|
+
| `Tabs` | Segmented tabs (`role="tablist"`). |
|
|
53
|
+
| `Table`, `TableSkeleton` | Column definitions with `render`. Takes an `emptyState`. |
|
|
54
|
+
| `Pagination` | Collapses long ranges with ellipses. |
|
|
55
|
+
| `Skeleton`, `SkeletonGroup`, `ListSkeleton`, `LinesSkeleton` | Loading placeholders. The shimmer stops under reduced motion. |
|
|
56
|
+
| `ErrorState` | Error card for 400–503 pages and failed loads. |
|
|
57
|
+
| `ToastProvider`, `useToast` | `useToast()(message)`. |
|
|
58
|
+
| `Link`, `linkClassName` | A plain `<a>`. With a router, pass `linkClassName(variant)` to the router's own link. |
|
|
59
|
+
| `cn` | Joins class names; skips falsy values and takes `{ class: boolean }` objects. |
|
|
60
|
+
|
|
61
|
+
## Rules for screens built on the kit
|
|
62
|
+
|
|
63
|
+
- Build from the kit, never raw `<button>`, `<input>` or hex colours. If a piece is missing, add it to the kit.
|
|
64
|
+
- While data loads, show skeletons shaped like the content, inside a `SkeletonGroup` with a translated label. Never a bare "Loading…".
|
|
65
|
+
- Every list or table has an empty state and an error state (`ErrorState`).
|
|
66
|
+
- Text is passed in as props (`label`, `previousLabel`…), so the app translates it. The kit has no locale of its own.
|
|
67
|
+
|
|
68
|
+
## Developing
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm test -w steadymeet-ui # unit tests (node:test)
|
|
72
|
+
npm run typecheck -w steadymeet-ui
|
|
73
|
+
npm run lint -w steadymeet-ui
|
|
74
|
+
npm run build -w steadymeet-ui # the publishable package, in dist/
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Inside the monorepo the apps use `src/` directly, so there is nothing to build while developing. `npm run build` writes `dist/`: compiled JavaScript (with each file's `'use client'` kept), type declarations, the styles and a `package.json` pointing at them.
|
|
78
|
+
|
|
79
|
+
### Publishing
|
|
80
|
+
|
|
81
|
+
1. Raise `version` in `package.json` (semver: a breaking change to a component's props or a renamed token is a major).
|
|
82
|
+
2. `npm run build -w steadymeet-ui`
|
|
83
|
+
3. `npm publish ./packages/ui/dist` (signed in with `npm login`). Publish `dist`, never `packages/ui`: that folder is marked private so it can't be published by mistake.
|
|
84
|
+
|
|
85
|
+
The kit must not import app code (APIs, locales, routers, workspace state). Anything that needs app code stays in the app: SteadyMeet keeps its avatars, presence, emoji and stickers in `apps/web/components/ui`.
|
package/Radio.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
export interface RadioOption {
|
|
3
|
+
value: string;
|
|
4
|
+
label: ReactNode;
|
|
5
|
+
description?: ReactNode;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface RadioGroupProps {
|
|
9
|
+
name: string;
|
|
10
|
+
value: string;
|
|
11
|
+
onChange: (value: string) => void;
|
|
12
|
+
options: RadioOption[];
|
|
13
|
+
legend?: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export default function RadioGroup({ name, value, onChange, options, legend, className, disabled }: RadioGroupProps): import("react").JSX.Element;
|
package/Radio.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from './cn.js';
|
|
3
|
+
export default function RadioGroup({ name, value, onChange, options, legend, className, disabled }) {
|
|
4
|
+
return _jsxs("fieldset", { className: cn('ui-radio-group', className), disabled: disabled, children: [legend !== undefined && _jsx("legend", { className: "ui-radio-legend", children: legend }), options.map((option) => _jsxs("label", { className: "ui-radio", htmlFor: `${name}-${option.value}`, children: [_jsx("input", { type: "radio", id: `${name}-${option.value}`, name: name, value: option.value, checked: value === option.value, onChange: () => onChange(option.value), disabled: option.disabled }), _jsx("span", { className: "ui-radio-dot", "aria-hidden": "true" }), _jsxs("span", { className: "ui-radio-text", children: [_jsx("span", { className: "ui-radio-label", children: option.label }), option.description !== undefined && _jsx("span", { className: "ui-radio-description", children: option.description })] })] }, option.value))] });
|
|
5
|
+
}
|
package/Select.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SelectOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface SelectProps {
|
|
7
|
+
value: string;
|
|
8
|
+
onChange: (value: string) => void;
|
|
9
|
+
options: SelectOption[];
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
triggerClassName?: string;
|
|
14
|
+
'aria-label'?: string;
|
|
15
|
+
id?: string;
|
|
16
|
+
}
|
|
17
|
+
export default function Select({ value, onChange, options, placeholder, disabled, className, triggerClassName, id, ...rest }: SelectProps): import("react").JSX.Element;
|
package/Select.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as RadixSelect from '@radix-ui/react-select';
|
|
4
|
+
import { Check, ChevronDown } from 'lucide-react';
|
|
5
|
+
import { cn } from './cn.js';
|
|
6
|
+
export default function Select({ value, onChange, options, placeholder, disabled, className, triggerClassName, id, ...rest }) {
|
|
7
|
+
return _jsxs(RadixSelect.Root, { value: value, onValueChange: onChange, disabled: disabled, children: [_jsxs(RadixSelect.Trigger, { id: id, className: cn('ui-select-trigger', triggerClassName), "aria-label": rest['aria-label'], children: [_jsx(RadixSelect.Value, { placeholder: placeholder }), _jsx(RadixSelect.Icon, { className: "ui-select-icon", children: _jsx(ChevronDown, { size: 15 }) })] }), _jsx(RadixSelect.Portal, { children: _jsx(RadixSelect.Content, { className: cn('ui-select-content', className), position: "popper", sideOffset: 6, children: _jsx(RadixSelect.Viewport, { children: options.map((option) => _jsxs(RadixSelect.Item, { value: option.value, disabled: option.disabled, className: "ui-select-item", children: [_jsx(RadixSelect.ItemText, { children: option.label }), _jsx(RadixSelect.ItemIndicator, { className: "ui-select-item-indicator", children: _jsx(Check, { size: 14 }) })] }, option.value)) }) }) })] });
|
|
8
|
+
}
|
package/Skeleton.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
export interface SkeletonProps {
|
|
3
|
+
className?: string;
|
|
4
|
+
style?: CSSProperties;
|
|
5
|
+
}
|
|
6
|
+
/** A shimmering placeholder block shown where data is still loading. Decorative: wrap a group in `SkeletonGroup` so it announces "loading" once. */
|
|
7
|
+
export default function Skeleton({ className, style }: SkeletonProps): import("react").JSX.Element;
|
|
8
|
+
/** Marks an area as busy for assistive tech while its placeholders show. */
|
|
9
|
+
export declare function SkeletonGroup({ label, className, children }: {
|
|
10
|
+
label: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
14
|
+
/** A row of avatar + two lines, repeated: the shape of a people or conversation list while it loads. */
|
|
15
|
+
export declare function ListSkeleton({ rows, className }: {
|
|
16
|
+
rows?: number;
|
|
17
|
+
className?: string;
|
|
18
|
+
}): import("react").JSX.Element;
|
|
19
|
+
/** Plain lines of text, repeated: the shape of a settings panel or a report while it loads. */
|
|
20
|
+
export declare function LinesSkeleton({ rows, className }: {
|
|
21
|
+
rows?: number;
|
|
22
|
+
className?: string;
|
|
23
|
+
}): import("react").JSX.Element;
|
package/Skeleton.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from './cn.js';
|
|
3
|
+
/** A shimmering placeholder block shown where data is still loading. Decorative: wrap a group in `SkeletonGroup` so it announces "loading" once. */
|
|
4
|
+
export default function Skeleton({ className, style }) {
|
|
5
|
+
return _jsx("span", { className: cn('skeleton', className), style: style, "aria-hidden": "true" });
|
|
6
|
+
}
|
|
7
|
+
/** Marks an area as busy for assistive tech while its placeholders show. */
|
|
8
|
+
export function SkeletonGroup({ label, className, children }) {
|
|
9
|
+
return _jsx("div", { className: className, role: "status", "aria-busy": "true", "aria-label": label, children: children });
|
|
10
|
+
}
|
|
11
|
+
/** A row of avatar + two lines, repeated: the shape of a people or conversation list while it loads. */
|
|
12
|
+
export function ListSkeleton({ rows = 6, className }) {
|
|
13
|
+
return _jsx("div", { className: cn('grid gap-3', className), "aria-hidden": "true", children: Array.from({ length: rows }, (_, row) => _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Skeleton, { className: "size-10 shrink-0 rounded-full" }), _jsxs("div", { className: "grid flex-1 gap-2", children: [_jsx(Skeleton, { className: "h-3.5", style: { width: `${40 + ((row * 17) % 30)}%` } }), _jsx(Skeleton, { className: "h-3 w-1/4" })] })] }, row)) });
|
|
14
|
+
}
|
|
15
|
+
/** Plain lines of text, repeated: the shape of a settings panel or a report while it loads. */
|
|
16
|
+
export function LinesSkeleton({ rows = 4, className }) {
|
|
17
|
+
return _jsx("div", { className: cn('grid max-w-xl gap-3', className), "aria-hidden": "true", children: Array.from({ length: rows }, (_, row) => _jsx(Skeleton, { className: "h-4", style: { width: `${row === 0 ? 45 : 70 + ((row * 11) % 30)}%` } }, row)) });
|
|
18
|
+
}
|
package/Slider.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
export interface SliderProps {
|
|
3
|
+
value: number;
|
|
4
|
+
min: number;
|
|
5
|
+
max: number;
|
|
6
|
+
step?: number;
|
|
7
|
+
onChange: (value: number) => void;
|
|
8
|
+
label?: ReactNode;
|
|
9
|
+
valueLabel?: ReactNode;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
export default function Slider({ value, min, max, step, onChange, label, valueLabel, disabled, className }: SliderProps): import("react").JSX.Element;
|
package/Slider.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from './cn.js';
|
|
3
|
+
export default function Slider({ value, min, max, step = 1, onChange, label, valueLabel, disabled, className }) {
|
|
4
|
+
return _jsxs("div", { className: cn('ui-slider', className), children: [(label !== undefined || valueLabel !== undefined) && _jsxs("div", { className: "ui-slider-header", children: [label !== undefined && _jsx("span", { className: "ui-slider-label", children: label }), valueLabel !== undefined && _jsx("span", { className: "ui-slider-value", "aria-hidden": "true", children: valueLabel })] }), _jsx("input", { type: "range", min: min, max: max, step: step, value: value, disabled: disabled, onChange: (event) => onChange(Number(event.target.value)), "aria-label": typeof label === 'string' ? label : undefined, "aria-valuenow": value, "aria-valuemin": min, "aria-valuemax": max })] });
|
|
5
|
+
}
|
package/Switch.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ButtonHTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
export interface SwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'type' | 'role'> {
|
|
3
|
+
checked: boolean;
|
|
4
|
+
onChange?: (checked: boolean) => void;
|
|
5
|
+
label?: ReactNode;
|
|
6
|
+
icon?: ReactNode;
|
|
7
|
+
description?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
export default function Switch({ checked, onChange, label, icon, description, className, disabled, ...props }: SwitchProps): import("react").JSX.Element;
|
package/Switch.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from './cn.js';
|
|
3
|
+
export default function Switch({ checked, onChange, label, icon, description, className, disabled, ...props }) {
|
|
4
|
+
const detailed = icon !== undefined || description !== undefined;
|
|
5
|
+
return _jsxs("button", { type: "button", role: "switch", "aria-checked": checked, disabled: disabled, className: cn('ui-switch', { 'ui-switch-detailed': detailed, 'ui-switch-plain': detailed && icon === undefined }, className), onClick: () => onChange?.(!checked), ...props, children: [icon !== undefined && _jsx("span", { className: "ui-switch-icon", "aria-hidden": "true", children: icon }), label !== undefined && (detailed
|
|
6
|
+
? _jsxs("span", { className: "ui-switch-text", children: [_jsx("span", { className: "ui-switch-label", children: label }), description !== undefined && _jsx("span", { className: "ui-switch-description", children: description })] })
|
|
7
|
+
: _jsx("span", { className: "ui-switch-label", children: label })), _jsx("span", { className: cn('toggle-switch', { enabled: checked }), "aria-hidden": "true", children: _jsx("i", {}) })] });
|
|
8
|
+
}
|
package/Table.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
export interface TableColumn<T> {
|
|
3
|
+
key: string;
|
|
4
|
+
header: ReactNode;
|
|
5
|
+
render: (row: T) => ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
export default function Table<T>({ columns, rows, rowKey, emptyState }: {
|
|
9
|
+
columns: TableColumn<T>[];
|
|
10
|
+
rows: T[];
|
|
11
|
+
rowKey: (row: T) => string;
|
|
12
|
+
emptyState?: ReactNode;
|
|
13
|
+
}): import("react").JSX.Element;
|
package/Table.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export default function Table({ columns, rows, rowKey, emptyState }) {
|
|
3
|
+
if (rows.length === 0 && emptyState)
|
|
4
|
+
return _jsx("div", { className: "data-table-wrap", children: _jsx("div", { className: "data-table-empty", children: emptyState }) });
|
|
5
|
+
return _jsx("div", { className: "data-table-wrap", children: _jsxs("table", { className: "data-table", children: [_jsx("thead", { children: _jsx("tr", { children: columns.map((column) => _jsx("th", { className: column.className, children: column.header }, column.key)) }) }), _jsx("tbody", { children: rows.map((row) => _jsx("tr", { children: columns.map((column) => _jsx("td", { className: column.className, children: column.render(row) }, column.key)) }, rowKey(row))) })] }) });
|
|
6
|
+
}
|
package/TableSkeleton.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Skeleton from './Skeleton.js';
|
|
3
|
+
/** Placeholder rows shaped like a `Table` while its data loads. */
|
|
4
|
+
export default function TableSkeleton({ columns = 4, rows = 6 }) {
|
|
5
|
+
return _jsx("div", { className: "data-table-wrap", "aria-hidden": "true", children: _jsxs("table", { className: "data-table", children: [_jsx("thead", { children: _jsx("tr", { children: Array.from({ length: columns }, (_, column) => _jsx("th", { children: _jsx(Skeleton, { className: "h-3.5 w-20" }) }, column)) }) }), _jsx("tbody", { children: Array.from({ length: rows }, (_, row) => _jsx("tr", { children: Array.from({ length: columns }, (_, column) => _jsx("td", { children: _jsx(Skeleton, { className: "h-4", style: { width: `${55 + ((row * 7 + column * 13) % 40)}%` } }) }, column)) }, row)) })] }) });
|
|
6
|
+
}
|
package/Tabs.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
export interface TabItem {
|
|
3
|
+
key: string;
|
|
4
|
+
label: ReactNode;
|
|
5
|
+
}
|
|
6
|
+
export interface TabsProps {
|
|
7
|
+
items: TabItem[];
|
|
8
|
+
active: string;
|
|
9
|
+
onChange: (key: string) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
tabClassName?: string;
|
|
12
|
+
}
|
|
13
|
+
export default function Tabs({ items, active, onChange, className, tabClassName }: TabsProps): import("react").JSX.Element;
|
package/Tabs.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from './cn.js';
|
|
3
|
+
export default function Tabs({ items, active, onChange, className, tabClassName = 'ui-tab' }) {
|
|
4
|
+
return _jsx("div", { className: cn('ui-tabs', className), role: "tablist", children: items.map((item) => _jsx("button", { type: "button", role: "tab", "aria-selected": active === item.key, className: cn(tabClassName, { active: active === item.key }), onClick: () => onChange(item.key), children: item.label }, item.key)) });
|
|
5
|
+
}
|
package/Textarea.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { TextareaHTMLAttributes } from 'react';
|
|
2
|
+
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
3
|
+
declare const Textarea: import("react").ForwardRefExoticComponent<TextareaProps & import("react").RefAttributes<HTMLTextAreaElement>>;
|
|
4
|
+
export default Textarea;
|
package/Textarea.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef } from 'react';
|
|
3
|
+
import { cn } from './cn.js';
|
|
4
|
+
const Textarea = forwardRef(function Textarea({ className, ...props }, ref) {
|
|
5
|
+
return _jsx("textarea", { ref: ref, className: cn('ui-textarea', className), ...props });
|
|
6
|
+
});
|
|
7
|
+
export default Textarea;
|
package/Toast.d.ts
ADDED
package/Toast.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useRef, useState } from 'react';
|
|
4
|
+
const ToastContext = createContext(null);
|
|
5
|
+
export function ToastProvider({ children }) {
|
|
6
|
+
const [message, setMessage] = useState('');
|
|
7
|
+
const timeoutRef = useRef(undefined);
|
|
8
|
+
const showToast = useCallback((next) => {
|
|
9
|
+
setMessage(next);
|
|
10
|
+
clearTimeout(timeoutRef.current);
|
|
11
|
+
timeoutRef.current = setTimeout(() => setMessage(''), 2600);
|
|
12
|
+
}, []);
|
|
13
|
+
return _jsxs(ToastContext.Provider, { value: showToast, children: [children, message && _jsx("div", { className: "toast", role: "status", children: message })] });
|
|
14
|
+
}
|
|
15
|
+
export function useToast() {
|
|
16
|
+
const context = useContext(ToastContext);
|
|
17
|
+
if (!context)
|
|
18
|
+
throw new Error('useToast must be used within ToastProvider');
|
|
19
|
+
return context;
|
|
20
|
+
}
|
package/cn.d.ts
ADDED
package/cn.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function cn(...values) {
|
|
2
|
+
const classes = [];
|
|
3
|
+
for (const value of values) {
|
|
4
|
+
if (!value)
|
|
5
|
+
continue;
|
|
6
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
7
|
+
classes.push(String(value));
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
for (const key in value)
|
|
11
|
+
if (value[key])
|
|
12
|
+
classes.push(key);
|
|
13
|
+
}
|
|
14
|
+
return classes.join(' ');
|
|
15
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export { default as Button } from './Button.js';
|
|
2
|
+
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button.js';
|
|
3
|
+
export { default as Input } from './Input.js';
|
|
4
|
+
export type { InputProps } from './Input.js';
|
|
5
|
+
export { default as OtpInput } from './OtpInput.js';
|
|
6
|
+
export type { OtpInputProps } from './OtpInput.js';
|
|
7
|
+
export { otpDigits, otpEdit, otpBackspace } from './otp.js';
|
|
8
|
+
export { default as Textarea } from './Textarea.js';
|
|
9
|
+
export type { TextareaProps } from './Textarea.js';
|
|
10
|
+
export { default as Checkbox } from './Checkbox.js';
|
|
11
|
+
export type { CheckboxProps } from './Checkbox.js';
|
|
12
|
+
export { default as RadioGroup } from './Radio.js';
|
|
13
|
+
export type { RadioGroupProps, RadioOption } from './Radio.js';
|
|
14
|
+
export { default as Switch } from './Switch.js';
|
|
15
|
+
export type { SwitchProps } from './Switch.js';
|
|
16
|
+
export { default as Slider } from './Slider.js';
|
|
17
|
+
export type { SliderProps } from './Slider.js';
|
|
18
|
+
export { default as Select } from './Select.js';
|
|
19
|
+
export type { SelectProps, SelectOption } from './Select.js';
|
|
20
|
+
export { Dropdown, DropdownItem, DropdownSeparator, DropdownLabel, DropdownSub, DropdownSubTrigger, DropdownSubContent } from './Dropdown.js';
|
|
21
|
+
export type { DropdownProps } from './Dropdown.js';
|
|
22
|
+
export { default as Pagination } from './Pagination.js';
|
|
23
|
+
export type { PaginationProps } from './Pagination.js';
|
|
24
|
+
export { default as Link, linkClassName } from './Link.js';
|
|
25
|
+
export type { LinkProps, LinkVariant } from './Link.js';
|
|
26
|
+
export { default as Table } from './Table.js';
|
|
27
|
+
export type { TableColumn } from './Table.js';
|
|
28
|
+
export { default as Tabs } from './Tabs.js';
|
|
29
|
+
export type { TabsProps, TabItem } from './Tabs.js';
|
|
30
|
+
export { ToastProvider, useToast } from './Toast.js';
|
|
31
|
+
export { default as Skeleton, SkeletonGroup, ListSkeleton, LinesSkeleton } from './Skeleton.js';
|
|
32
|
+
export { default as TableSkeleton } from './TableSkeleton.js';
|
|
33
|
+
export type { SkeletonProps } from './Skeleton.js';
|
|
34
|
+
export { default as ErrorState } from './ErrorState.js';
|
|
35
|
+
export type { ErrorStateProps, ErrorCode } from './ErrorState.js';
|
|
36
|
+
export { cn } from './cn.js';
|
|
37
|
+
export type { ClassValue } from './cn.js';
|
package/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { default as Button } from './Button.js';
|
|
2
|
+
export { default as Input } from './Input.js';
|
|
3
|
+
export { default as OtpInput } from './OtpInput.js';
|
|
4
|
+
export { otpDigits, otpEdit, otpBackspace } from './otp.js';
|
|
5
|
+
export { default as Textarea } from './Textarea.js';
|
|
6
|
+
export { default as Checkbox } from './Checkbox.js';
|
|
7
|
+
export { default as RadioGroup } from './Radio.js';
|
|
8
|
+
export { default as Switch } from './Switch.js';
|
|
9
|
+
export { default as Slider } from './Slider.js';
|
|
10
|
+
export { default as Select } from './Select.js';
|
|
11
|
+
export { Dropdown, DropdownItem, DropdownSeparator, DropdownLabel, DropdownSub, DropdownSubTrigger, DropdownSubContent } from './Dropdown.js';
|
|
12
|
+
export { default as Pagination } from './Pagination.js';
|
|
13
|
+
export { default as Link, linkClassName } from './Link.js';
|
|
14
|
+
export { default as Table } from './Table.js';
|
|
15
|
+
export { default as Tabs } from './Tabs.js';
|
|
16
|
+
export { ToastProvider, useToast } from './Toast.js';
|
|
17
|
+
export { default as Skeleton, SkeletonGroup, ListSkeleton, LinesSkeleton } from './Skeleton.js';
|
|
18
|
+
export { default as TableSkeleton } from './TableSkeleton.js';
|
|
19
|
+
export { default as ErrorState } from './ErrorState.js';
|
|
20
|
+
export { cn } from './cn.js';
|
package/otp.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Keeps only the digits of `text`, at most `length` of them. */
|
|
2
|
+
export declare const otpDigits: (text: string, length: number) => string;
|
|
3
|
+
/**
|
|
4
|
+
* What a change in box `index` does to the code typed so far (always digits with no gaps). One digit
|
|
5
|
+
* replaces that box, several at once (a one-time-code autofill) fill from there, and an emptied box
|
|
6
|
+
* drops its digit and closes the gap. `focus` is the box to move to.
|
|
7
|
+
*/
|
|
8
|
+
export declare function otpEdit(value: string, index: number, text: string, length: number): {
|
|
9
|
+
value: string;
|
|
10
|
+
focus: number;
|
|
11
|
+
};
|
|
12
|
+
/** Backspace in an empty box deletes the digit before it. */
|
|
13
|
+
export declare function otpBackspace(value: string, index: number): {
|
|
14
|
+
value: string;
|
|
15
|
+
focus: number;
|
|
16
|
+
};
|
package/otp.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Keeps only the digits of `text`, at most `length` of them. */
|
|
2
|
+
export const otpDigits = (text, length) => text.replace(/\D/g, '').slice(0, length);
|
|
3
|
+
/**
|
|
4
|
+
* What a change in box `index` does to the code typed so far (always digits with no gaps). One digit
|
|
5
|
+
* replaces that box, several at once (a one-time-code autofill) fill from there, and an emptied box
|
|
6
|
+
* drops its digit and closes the gap. `focus` is the box to move to.
|
|
7
|
+
*/
|
|
8
|
+
export function otpEdit(value, index, text, length) {
|
|
9
|
+
const typed = otpDigits(text, length);
|
|
10
|
+
if (!typed)
|
|
11
|
+
return { value: value.slice(0, index) + value.slice(index + 1), focus: index };
|
|
12
|
+
const next = otpDigits(value.slice(0, index) + typed + value.slice(index + (typed.length === 1 ? 1 : 0)), length);
|
|
13
|
+
return { value: next, focus: Math.min(index + typed.length, length - 1) };
|
|
14
|
+
}
|
|
15
|
+
/** Backspace in an empty box deletes the digit before it. */
|
|
16
|
+
export function otpBackspace(value, index) {
|
|
17
|
+
return { value: value.slice(0, index - 1) + value.slice(index), focus: index - 1 };
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "steadymeet-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SteadyMeet's React UI kit: accessible components and design tokens, styled with Tailwind CSS v4.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"*.css"
|
|
8
|
+
],
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
|
11
|
+
"@radix-ui/react-select": "^2.3.7",
|
|
12
|
+
"lucide-react": "^1.33.0"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"react": ">=19",
|
|
16
|
+
"react-dom": ">=19",
|
|
17
|
+
"tailwindcss": "^4"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"react",
|
|
21
|
+
"ui",
|
|
22
|
+
"components",
|
|
23
|
+
"tailwindcss",
|
|
24
|
+
"design-system"
|
|
25
|
+
],
|
|
26
|
+
"main": "./index.js",
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./index.d.ts",
|
|
31
|
+
"default": "./index.js"
|
|
32
|
+
},
|
|
33
|
+
"./tokens.css": "./styles/tokens.css",
|
|
34
|
+
"./components.css": "./styles/components.css"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* The kit's component styles. Import after tokens.css, inside a Tailwind v4 stylesheet: the rules use @apply with its theme. */
|
|
2
|
+
@source "..";
|
|
3
|
+
|
|
4
|
+
@layer components {
|
|
5
|
+
.otp-field { @apply flex flex-col items-center gap-4; }
|
|
6
|
+
.otp-input { @apply mt-1 grid w-full grid-cols-6 gap-2 sm:gap-3; }
|
|
7
|
+
.otp-input-box { @apply aspect-square w-full min-w-0 rounded-2xl border border-line bg-panel text-center font-serif text-2xl text-ink outline-none transition-colors focus:border-brand-text focus:ring-3 focus:ring-brand/15 disabled:cursor-not-allowed disabled:opacity-60 sm:text-3xl; }
|
|
8
|
+
.otp-input-box[aria-invalid='true'] { @apply border-danger-text focus:border-danger-text focus:ring-danger-text/15; }
|
|
9
|
+
.otp-input[data-invalid='true'] { animation: otp-shake 0.4s ease-in-out; }
|
|
10
|
+
.otp-dots { @apply flex items-center justify-center gap-2; }
|
|
11
|
+
.otp-dot { @apply size-2 rounded-full bg-line transition-colors; }
|
|
12
|
+
.otp-dot[data-filled='true'] { @apply bg-ink; }
|
|
13
|
+
@keyframes otp-shake { 0%, 100% { transform: translateX(0); } 20%, 60% { transform: translateX(-6px); } 40%, 80% { transform: translateX(6px); } }
|
|
14
|
+
@media (prefers-reduced-motion: reduce) { .otp-input[data-invalid='true'] { animation: none; } }
|
|
15
|
+
|
|
16
|
+
.data-table-wrap { @apply overflow-x-auto rounded-2xl border border-line bg-canvas; }
|
|
17
|
+
.data-table thead { @apply bg-workspace-secondary; }
|
|
18
|
+
.data-table { @apply w-full min-w-[720px] border-collapse text-left text-sm; }
|
|
19
|
+
.data-table thead th { @apply border-b border-line px-4 py-3 text-xs font-bold tracking-wide text-muted uppercase; }
|
|
20
|
+
.data-table tbody td { @apply border-b border-line px-4 py-3.5 align-middle; }
|
|
21
|
+
.data-table tbody tr:last-child td { @apply border-b-0; }
|
|
22
|
+
.data-table tbody tr:hover { @apply bg-hover; }
|
|
23
|
+
.data-table-empty { @apply grid justify-items-center gap-2 p-10 text-center text-sm text-muted; }
|
|
24
|
+
|
|
25
|
+
.toggle-switch { @apply relative h-6 w-11 shrink-0 rounded-full bg-[#c9cfcc] transition-colors; }
|
|
26
|
+
.toggle-switch i { @apply absolute top-0.5 left-0.5 size-5 rounded-full bg-white transition-transform; }
|
|
27
|
+
.toggle-switch.enabled { @apply bg-brand ring-1 ring-brand-text; }
|
|
28
|
+
.toggle-switch.enabled i { @apply translate-x-5; }
|
|
29
|
+
|
|
30
|
+
.toast { @apply fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-xl bg-[#18201d] px-4 py-3 text-sm font-bold text-white; }
|
|
31
|
+
|
|
32
|
+
.ui-btn { @apply inline-flex items-center justify-center gap-2 rounded-xl border-0 px-[18px] py-3 text-sm font-bold transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-brand-text focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-wait disabled:opacity-70; }
|
|
33
|
+
.ui-btn-primary { @apply bg-brand text-ink hover:bg-brand-dark; }
|
|
34
|
+
.ui-btn-secondary { @apply border border-line bg-panel text-ink hover:border-brand-text hover:bg-brand/8; }
|
|
35
|
+
.ui-btn-ghost { @apply border-0 bg-transparent text-muted hover:bg-hover hover:text-ink; }
|
|
36
|
+
.ui-btn-danger { @apply bg-danger text-white hover:bg-[#9c3a32]; }
|
|
37
|
+
.ui-btn-sm { @apply px-3 py-2 text-xs; }
|
|
38
|
+
.ui-btn-md { @apply px-[18px] py-3 text-sm; }
|
|
39
|
+
.ui-btn-icon { @apply size-10 p-0; }
|
|
40
|
+
.ui-input { @apply w-full rounded-xl border border-line bg-panel px-3.5 py-3 text-sm font-normal text-ink outline-none placeholder:text-muted focus:border-brand-text focus:ring-3 focus:ring-brand/15 disabled:cursor-not-allowed disabled:opacity-60; }
|
|
41
|
+
.ui-textarea { @apply w-full rounded-xl border border-line bg-panel px-3.5 py-3 text-sm font-normal text-ink outline-none focus:border-brand-text focus:ring-3 focus:ring-brand/15 disabled:cursor-not-allowed disabled:opacity-60; }
|
|
42
|
+
.ui-checkbox { @apply inline-flex cursor-pointer items-center gap-2.5 text-sm text-ink has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50; }
|
|
43
|
+
.ui-checkbox input { @apply sr-only; }
|
|
44
|
+
.ui-checkbox-box { @apply grid size-[18px] shrink-0 place-items-center rounded-md border border-line bg-panel text-transparent transition-colors; }
|
|
45
|
+
.ui-checkbox input:checked + .ui-checkbox-box, .ui-checkbox input:indeterminate + .ui-checkbox-box { @apply border-brand bg-brand text-ink; }
|
|
46
|
+
.ui-checkbox input:focus-visible + .ui-checkbox-box { @apply ring-2 ring-brand-text ring-offset-2; }
|
|
47
|
+
.ui-checkbox-label { @apply select-none; }
|
|
48
|
+
.ui-radio-group { @apply m-0 grid gap-2 border-0 p-0; }
|
|
49
|
+
.ui-radio-legend { @apply mb-1 p-0 text-xs font-bold text-ink; }
|
|
50
|
+
.ui-radio { @apply flex cursor-pointer items-start gap-2.5 rounded-xl border border-line bg-panel p-3 text-sm text-ink hover:border-brand-text has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50; }
|
|
51
|
+
.ui-radio input { @apply sr-only; }
|
|
52
|
+
.ui-radio-dot { @apply mt-0.5 grid size-[18px] shrink-0 place-items-center rounded-full border border-line bg-panel; }
|
|
53
|
+
.ui-radio-dot::after { content: ''; @apply size-2 scale-0 rounded-full bg-brand transition-transform; }
|
|
54
|
+
.ui-radio input:checked ~ .ui-radio-dot { @apply border-brand; }
|
|
55
|
+
.ui-radio input:checked ~ .ui-radio-dot::after { @apply scale-100; }
|
|
56
|
+
.ui-radio input:focus-visible ~ .ui-radio-dot { @apply ring-2 ring-brand-text ring-offset-2; }
|
|
57
|
+
.ui-radio-text { @apply grid gap-0.5; }
|
|
58
|
+
.ui-radio-label { @apply font-bold; }
|
|
59
|
+
.ui-radio-description { @apply text-xs font-normal text-muted; }
|
|
60
|
+
.ui-switch { @apply flex w-full items-center justify-between gap-3 rounded-xl border-0 bg-transparent p-0 text-left text-ink focus-visible:ring-2 focus-visible:ring-brand-text focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50; }
|
|
61
|
+
.ui-switch .toggle-switch { @apply ml-auto; }
|
|
62
|
+
.ui-switch-label { @apply text-sm font-bold; }
|
|
63
|
+
.ui-switch-detailed { @apply grid grid-cols-[30px_minmax(0,1fr)_auto] items-center gap-2 rounded-xl bg-hover px-3 py-2.5 hover:bg-hover-strong; }
|
|
64
|
+
/* A detailed switch without an icon has no icon column: the text takes the first one. */
|
|
65
|
+
.ui-switch-plain { grid-template-columns: minmax(0, 1fr) auto; }
|
|
66
|
+
.ui-switch-icon { @apply text-brand-text; }
|
|
67
|
+
.ui-switch-text { @apply grid; }
|
|
68
|
+
.ui-switch-detailed .ui-switch-label { @apply text-xs; }
|
|
69
|
+
.ui-switch-description { @apply mt-1 block text-[10px] leading-4 font-normal text-muted; }
|
|
70
|
+
.ui-switch-detailed .toggle-switch { @apply h-5 w-9; }
|
|
71
|
+
.ui-switch-detailed .toggle-switch i { @apply size-4; }
|
|
72
|
+
.ui-switch-detailed .toggle-switch.enabled i { @apply translate-x-4; }
|
|
73
|
+
.ui-slider { @apply grid gap-2; }
|
|
74
|
+
.ui-slider-header { @apply flex items-center justify-between text-sm; }
|
|
75
|
+
.ui-slider-label { @apply font-bold text-ink; }
|
|
76
|
+
.ui-slider-value { @apply grid min-w-6 place-items-center rounded-full bg-hover px-1.5 py-0.5 text-[11px] font-bold text-muted; }
|
|
77
|
+
.ui-slider input[type='range'] { @apply h-1.5 w-full cursor-pointer appearance-none rounded-full bg-line accent-brand disabled:cursor-not-allowed disabled:opacity-50; }
|
|
78
|
+
.ui-slider input[type='range']::-webkit-slider-thumb { @apply size-4 appearance-none rounded-full bg-brand shadow ring-2 ring-white; }
|
|
79
|
+
.ui-slider input[type='range']::-moz-range-thumb { @apply size-4 rounded-full border-0 bg-brand shadow ring-2 ring-white; }
|
|
80
|
+
.ui-select-trigger { @apply flex w-full items-center justify-between gap-2 rounded-xl border border-line bg-panel px-3.5 py-2.5 text-sm font-normal text-ink outline-none focus:border-brand-text focus:ring-3 focus:ring-brand/15 disabled:cursor-not-allowed disabled:opacity-60 data-[placeholder]:text-muted; }
|
|
81
|
+
.ui-select-icon { @apply text-muted; }
|
|
82
|
+
.ui-select-content { @apply z-[100] overflow-hidden rounded-xl border border-line bg-panel p-1 text-sm text-ink shadow-lg; }
|
|
83
|
+
.ui-select-content [data-radix-select-viewport] { @apply grid gap-0.5; }
|
|
84
|
+
.ui-select-item { @apply flex cursor-pointer items-center justify-between gap-2 rounded-lg px-2.5 py-2 outline-none data-[highlighted]:bg-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50; }
|
|
85
|
+
.ui-select-item-indicator { @apply text-brand-text; }
|
|
86
|
+
.ui-dropdown-content { @apply z-[100] grid min-w-[180px] gap-0.5 rounded-xl border border-line bg-panel p-1.5 text-sm text-ink shadow-lg; }
|
|
87
|
+
.ui-dropdown-item { @apply flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left outline-none data-[highlighted]:bg-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50; }
|
|
88
|
+
.ui-dropdown-separator { @apply my-1 h-px bg-line; }
|
|
89
|
+
.ui-dropdown-sub-trigger { @apply justify-between; }
|
|
90
|
+
.ui-dropdown-sub-trigger::after { content: '\203a'; margin-left: 0.75rem; color: var(--color-muted); }
|
|
91
|
+
.ui-dropdown-label { @apply px-2.5 py-1.5 text-xs font-bold tracking-wide text-muted uppercase; }
|
|
92
|
+
|
|
93
|
+
.ui-pagination { @apply flex items-center justify-center gap-1; }
|
|
94
|
+
.ui-pagination-nav, .ui-pagination-page { @apply grid size-9 place-items-center rounded-lg border-0 bg-transparent text-sm font-bold text-muted hover:bg-hover hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-text focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent; }
|
|
95
|
+
.ui-pagination-page.active { @apply bg-brand/12 text-brand-text; }
|
|
96
|
+
.ui-pagination-ellipsis { @apply grid size-9 place-items-center text-sm text-muted; }
|
|
97
|
+
|
|
98
|
+
.ui-link { @apply font-bold text-brand-text hover:underline; }
|
|
99
|
+
.ui-link-muted { @apply font-normal text-muted hover:text-ink; }
|
|
100
|
+
|
|
101
|
+
.ui-tabs { @apply flex gap-1 rounded-xl bg-hover p-1; }
|
|
102
|
+
.ui-tab { @apply flex-1 rounded-lg border-0 bg-transparent px-3 py-2 text-xs font-bold text-muted hover:text-ink; }
|
|
103
|
+
.ui-tab.active { @apply bg-panel text-ink; }
|
|
104
|
+
|
|
105
|
+
.error-page-shell { @apply grid min-h-screen place-items-center bg-canvas p-6; }
|
|
106
|
+
.error-page-card { @apply w-full max-w-[480px] rounded-3xl border border-line bg-panel p-10 text-center max-sm:p-6; }
|
|
107
|
+
.error-page-icon { @apply mx-auto mb-5 grid size-16 place-items-center rounded-2xl bg-brand/12 text-brand-text; }
|
|
108
|
+
.error-page-code { @apply mb-2 text-xs font-extrabold tracking-[0.18em] text-brand-text uppercase; }
|
|
109
|
+
.error-page-card h1 { @apply mt-0 mb-3 font-serif text-[clamp(28px,4vw,38px)] leading-[1.05] tracking-[-0.03em]; }
|
|
110
|
+
.error-page-card > p:not(.error-page-code) { @apply mb-0 text-[15px] leading-6 text-muted; }
|
|
111
|
+
.error-page-actions { @apply mt-7 flex flex-wrap items-center justify-center gap-2; }
|
|
112
|
+
|
|
113
|
+
/* Loading placeholders: a soft shimmer sweeping over a muted block. Still (no sweep) when motion is reduced. */
|
|
114
|
+
.skeleton { @apply block rounded-lg bg-hover; background-image: linear-gradient(90deg, transparent, var(--steadymeet-hover-strong), transparent); background-size: 200% 100%; background-repeat: no-repeat; animation: skeleton-shimmer 1.4s ease-in-out infinite; }
|
|
115
|
+
@keyframes skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
|
116
|
+
@media (prefers-reduced-motion: reduce) { .skeleton { animation: none; background-image: none; } }
|
|
117
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* The kit's design tokens: Tailwind theme colours pointing at CSS variables, so dark mode (`.dark` on any ancestor)
|
|
2
|
+
and the accent presets (`data-accent` on <html>) repaint everything by remapping variables. */
|
|
3
|
+
|
|
4
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
5
|
+
|
|
6
|
+
@theme {
|
|
7
|
+
--color-ink: var(--steadymeet-ink);
|
|
8
|
+
--color-muted: var(--steadymeet-muted);
|
|
9
|
+
--color-paper: var(--steadymeet-paper);
|
|
10
|
+
--color-panel: var(--steadymeet-panel);
|
|
11
|
+
--color-canvas: var(--steadymeet-canvas);
|
|
12
|
+
--color-workspace-secondary: var(--steadymeet-workspace-secondary);
|
|
13
|
+
--color-inset: var(--steadymeet-inset);
|
|
14
|
+
--color-hover: var(--steadymeet-hover);
|
|
15
|
+
--color-hover-strong: var(--steadymeet-hover-strong);
|
|
16
|
+
--color-line: var(--steadymeet-line);
|
|
17
|
+
--color-brand: var(--steadymeet-brand);
|
|
18
|
+
--color-brand-dark: var(--steadymeet-brand-dark);
|
|
19
|
+
--color-brand-text: var(--steadymeet-brand-text);
|
|
20
|
+
--color-danger: #b6453c;
|
|
21
|
+
--color-success-bg: var(--steadymeet-success-bg);
|
|
22
|
+
--color-success-text: var(--steadymeet-success-text);
|
|
23
|
+
--color-danger-bg: var(--steadymeet-danger-bg);
|
|
24
|
+
--color-danger-text: var(--steadymeet-danger-text);
|
|
25
|
+
--color-warning-bg: var(--steadymeet-warning-bg);
|
|
26
|
+
--color-warning-text: var(--steadymeet-warning-text);
|
|
27
|
+
--font-serif: Georgia, "Times New Roman", serif;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Base palette (light). Dark mode and accent presets below only remap these
|
|
31
|
+
custom properties, so every `bg-panel`/`text-ink`/`bg-brand`-style utility
|
|
32
|
+
repaints automatically without per-component dark: variants. */
|
|
33
|
+
:root {
|
|
34
|
+
--steadymeet-ink: #17201d;
|
|
35
|
+
--steadymeet-muted: #646b77;
|
|
36
|
+
--steadymeet-paper: #ffffff;
|
|
37
|
+
--steadymeet-panel: #ffffff;
|
|
38
|
+
--steadymeet-canvas: #ffffff;
|
|
39
|
+
--steadymeet-workspace-secondary: #f0f0f1;
|
|
40
|
+
/* Boxes set into a card (search fields, empty states, lists) — a step off the white card. */
|
|
41
|
+
--steadymeet-inset: #f4f4f5;
|
|
42
|
+
--steadymeet-hover: #f4f4f5;
|
|
43
|
+
--steadymeet-hover-strong: #e9e9ec;
|
|
44
|
+
--steadymeet-line: #e4e4e7;
|
|
45
|
+
--steadymeet-success-bg: #e7f3ea;
|
|
46
|
+
--steadymeet-success-text: #1f7a3d;
|
|
47
|
+
--steadymeet-danger-bg: #fbe9e7;
|
|
48
|
+
--steadymeet-danger-text: #a73129;
|
|
49
|
+
--steadymeet-warning-bg: #fff4e9;
|
|
50
|
+
--steadymeet-warning-text: #a3620e;
|
|
51
|
+
|
|
52
|
+
--steadymeet-brand: #FF7900;
|
|
53
|
+
--steadymeet-brand-dark: #E56800;
|
|
54
|
+
--steadymeet-brand-text: #9A4600;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
:root[data-accent='blue'] { --steadymeet-brand: #2490FF; --steadymeet-brand-dark: #0d6fd6; --steadymeet-brand-text: #0b4a91; }
|
|
58
|
+
:root[data-accent='green'] { --steadymeet-brand: #22A559; --steadymeet-brand-dark: #17803f; --steadymeet-brand-text: #146c34; }
|
|
59
|
+
:root[data-accent='purple'] { --steadymeet-brand: #7C5CFC; --steadymeet-brand-dark: #5f3fe0; --steadymeet-brand-text: #4a2fc4; }
|
|
60
|
+
:root[data-accent='red'] { --steadymeet-brand: #E5484D; --steadymeet-brand-dark: #c53137; --steadymeet-brand-text: #a3262b; }
|
|
61
|
+
|
|
62
|
+
.dark {
|
|
63
|
+
--steadymeet-ink: #eef1ef;
|
|
64
|
+
--steadymeet-muted: #9aa79f;
|
|
65
|
+
--steadymeet-paper: #0d1110;
|
|
66
|
+
--steadymeet-panel: #1c2220;
|
|
67
|
+
--steadymeet-canvas: #0d1114;
|
|
68
|
+
--steadymeet-workspace-secondary: #252a2e;
|
|
69
|
+
--steadymeet-inset: #14181a;
|
|
70
|
+
--steadymeet-hover: #242a27;
|
|
71
|
+
--steadymeet-hover-strong: #2c332f;
|
|
72
|
+
--steadymeet-line: #2b322e;
|
|
73
|
+
--steadymeet-success-bg: #123321;
|
|
74
|
+
--steadymeet-success-text: #6fcf97;
|
|
75
|
+
--steadymeet-danger-bg: #3a1a17;
|
|
76
|
+
--steadymeet-danger-text: #ff8a80;
|
|
77
|
+
--steadymeet-warning-bg: #332512;
|
|
78
|
+
--steadymeet-warning-text: #f5b942;
|
|
79
|
+
|
|
80
|
+
--steadymeet-brand-text: #ffb37a;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.dark[data-accent='blue'] { --steadymeet-brand-text: #8fc4ff; }
|
|
84
|
+
.dark[data-accent='green'] { --steadymeet-brand-text: #7be0a3; }
|
|
85
|
+
.dark[data-accent='purple'] { --steadymeet-brand-text: #c0afff; }
|
|
86
|
+
.dark[data-accent='red'] { --steadymeet-brand-text: #ff9b9e; }
|