@yarcl/react 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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/color.d.ts +12 -0
  4. package/dist/color.js +41 -0
  5. package/dist/css.d.ts +10 -0
  6. package/dist/css.js +175 -0
  7. package/dist/define.d.ts +383 -0
  8. package/dist/define.js +29 -0
  9. package/dist/plugin.d.ts +33 -0
  10. package/dist/plugin.js +80 -0
  11. package/package.json +66 -0
  12. package/src/apply.ts +46 -0
  13. package/src/classes.ts +23 -0
  14. package/src/color.ts +48 -0
  15. package/src/components/Accordion.tsx +274 -0
  16. package/src/components/Alert.tsx +85 -0
  17. package/src/components/Badge.tsx +79 -0
  18. package/src/components/Breadcrumb.tsx +239 -0
  19. package/src/components/Button.tsx +58 -0
  20. package/src/components/ButtonGroup.tsx +61 -0
  21. package/src/components/Card.tsx +50 -0
  22. package/src/components/Checkbox.tsx +49 -0
  23. package/src/components/Combobox.tsx +522 -0
  24. package/src/components/CommandPalette.tsx +452 -0
  25. package/src/components/DatePicker.tsx +432 -0
  26. package/src/components/Dialog.tsx +39 -0
  27. package/src/components/Divider.tsx +34 -0
  28. package/src/components/Drawer.tsx +31 -0
  29. package/src/components/Field.tsx +61 -0
  30. package/src/components/Heading.tsx +40 -0
  31. package/src/components/HoverCard.tsx +108 -0
  32. package/src/components/IconButton.tsx +59 -0
  33. package/src/components/Inline.tsx +42 -0
  34. package/src/components/Input.tsx +28 -0
  35. package/src/components/Label.tsx +58 -0
  36. package/src/components/Link.tsx +41 -0
  37. package/src/components/Menu.tsx +207 -0
  38. package/src/components/Modal.tsx +164 -0
  39. package/src/components/NumberInput.tsx +208 -0
  40. package/src/components/Pagination.tsx +227 -0
  41. package/src/components/Popover.tsx +140 -0
  42. package/src/components/Progress.tsx +122 -0
  43. package/src/components/Radio.tsx +59 -0
  44. package/src/components/RadioGroup.tsx +114 -0
  45. package/src/components/Select.tsx +197 -0
  46. package/src/components/Skeleton.tsx +95 -0
  47. package/src/components/Slider.tsx +315 -0
  48. package/src/components/Spinner.tsx +38 -0
  49. package/src/components/Stack.tsx +57 -0
  50. package/src/components/Switch.tsx +42 -0
  51. package/src/components/Table.tsx +99 -0
  52. package/src/components/Tabs.tsx +159 -0
  53. package/src/components/Text.tsx +67 -0
  54. package/src/components/Textarea.tsx +26 -0
  55. package/src/components/Toast.tsx +190 -0
  56. package/src/components/ToggleGroup.tsx +227 -0
  57. package/src/components/Tooltip.tsx +78 -0
  58. package/src/contrast.ts +82 -0
  59. package/src/css.ts +228 -0
  60. package/src/define.check.ts +259 -0
  61. package/src/define.ts +379 -0
  62. package/src/field-context.ts +31 -0
  63. package/src/floating.tsx +61 -0
  64. package/src/hooks.ts +19 -0
  65. package/src/index.ts +119 -0
  66. package/src/plugin.ts +94 -0
  67. package/src/reference/DesignReference.tsx +374 -0
  68. package/src/reference/index.ts +2 -0
  69. package/src/reference/reference.css +58 -0
  70. package/src/runtime.ts +38 -0
  71. package/src/styles.css +1971 -0
  72. package/src/themes/bloom.ts +72 -0
  73. package/src/themes/brutalist.ts +63 -0
  74. package/src/themes/compact.ts +63 -0
  75. package/src/themes/contract.ts +24 -0
  76. package/src/themes/editorial.ts +55 -0
  77. package/src/themes/index.ts +47 -0
  78. package/src/types.ts +69 -0
  79. package/src/yarcl.config.ts +104 -0
package/src/color.ts ADDED
@@ -0,0 +1,48 @@
1
+ type Rgb = [number, number, number];
2
+
3
+ export function parseHex(value: string): Rgb | null {
4
+ const hex = value.trim().replace(/^#/, '');
5
+ if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) return null;
6
+ const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex;
7
+ return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)) as Rgb;
8
+ }
9
+
10
+ function luminance([r, g, b]: Rgb): number {
11
+ const [lr, lg, lb] = [r, g, b].map((c) => {
12
+ const s = c / 255;
13
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
14
+ });
15
+ return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
16
+ }
17
+
18
+ export function contrast(a: Rgb, b: Rgb): number {
19
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
20
+ return (hi + 0.05) / (lo + 0.05);
21
+ }
22
+
23
+ const WHITE: Rgb = [255, 255, 255];
24
+ const BLACK: Rgb = [0, 0, 0];
25
+
26
+ export function mix(a: Rgb, b: Rgb, amount: number): Rgb {
27
+ return a.map((channel, i) => Math.round(channel + (b[i] - channel) * amount)) as Rgb;
28
+ }
29
+
30
+ export function toHex(rgb: Rgb): string {
31
+ return `#${rgb.map((c) => c.toString(16).padStart(2, '0')).join('')}`;
32
+ }
33
+
34
+ /**
35
+ * The color, mixed toward `toward` in small steps until it reaches `min` contrast
36
+ * against every background. Returns the color unchanged when it already passes.
37
+ */
38
+ export function readableText(color: Rgb, backgrounds: Rgb[], toward: Rgb, min: number): Rgb {
39
+ for (let step = 0; step <= 20; step++) {
40
+ const candidate = mix(color, toward, step / 20);
41
+ if (backgrounds.every((bg) => contrast(candidate, bg) >= min)) return candidate;
42
+ }
43
+ return toward;
44
+ }
45
+
46
+ export function readableOn(background: Rgb): string {
47
+ return contrast(background, WHITE) >= contrast(background, BLACK) ? '#ffffff' : '#000000';
48
+ }
@@ -0,0 +1,274 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useId,
5
+ type ComponentProps,
6
+ type KeyboardEvent,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { colorClass, cx, radiusClass, sizeClass } from '../classes';
10
+ import { ChevronIcon } from '../floating';
11
+ import { useControllable } from '../hooks';
12
+ import type { Color, Radius, Size } from '../types';
13
+ import { useDefaults } from '../runtime';
14
+
15
+ interface AccordionContextValue {
16
+ open: string[];
17
+ toggle: (value: string) => void;
18
+ disabled?: boolean;
19
+ idFor: (kind: 'trigger' | 'content', value: string) => string;
20
+ }
21
+
22
+ interface AccordionItemContextValue {
23
+ value: string;
24
+ open: boolean;
25
+ disabled?: boolean;
26
+ }
27
+
28
+ const AccordionContext = createContext<AccordionContextValue | null>(null);
29
+ const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
30
+
31
+ function useAccordionContext(component: string) {
32
+ const context = useContext(AccordionContext);
33
+ if (!context) throw new Error(`yarcl: <${component}> must be inside <Accordion>`);
34
+ return context;
35
+ }
36
+
37
+ function useItemContext(component: string) {
38
+ const context = useContext(AccordionItemContext);
39
+ if (!context) throw new Error(`yarcl: <${component}> must be inside <Accordion.Item>`);
40
+ return context;
41
+ }
42
+
43
+ /** Props shared by both forms of {@link AccordionProps}. */
44
+ export interface AccordionBaseProps extends Omit<ComponentProps<'div'>, 'defaultValue' | 'color'> {
45
+ /**
46
+ * Trigger height, padding and font size, from the `sizes` config.
47
+ * @default config.defaults.size
48
+ */
49
+ size?: Size;
50
+ /**
51
+ * Corner radius of the outer frame, from the `radii` config, or `'size'` to match the size.
52
+ * @default config.defaults.radius
53
+ */
54
+ radius?: Radius | 'size';
55
+ /**
56
+ * Color of the open item's indicator, from the `colors` config.
57
+ * @default config.defaults.color
58
+ */
59
+ color?: Color;
60
+ /** Disables every item. */
61
+ disabled?: boolean;
62
+ /** `Accordion.Item`s. */
63
+ children?: ReactNode;
64
+ }
65
+
66
+ /** Props for {@link Accordion}. `type` decides whether one or many items can be open. */
67
+ export type AccordionProps = AccordionBaseProps &
68
+ (
69
+ | {
70
+ /** One item open at a time. */
71
+ type: 'single';
72
+ /** Open item (controlled), or `null` when all are closed. */
73
+ value?: string | null;
74
+ /** Initially open item (uncontrolled). */
75
+ defaultValue?: string | null;
76
+ /** Called with the open item, or `null` when it closes. */
77
+ onValueChange?: (value: string | null) => void;
78
+ /**
79
+ * Lets the open item close, leaving all closed. When `false`, one item always stays open.
80
+ * @default true
81
+ */
82
+ collapsible?: boolean;
83
+ }
84
+ | {
85
+ /** Any number of items open. */
86
+ type: 'multiple';
87
+ /** Open items (controlled). */
88
+ value?: string[];
89
+ /** Initially open items (uncontrolled). */
90
+ defaultValue?: string[];
91
+ /** Called with all open items. */
92
+ onValueChange?: (value: string[]) => void;
93
+ collapsible?: never;
94
+ }
95
+ );
96
+
97
+ const toArray = (value: string | string[] | null | undefined) => (value == null ? [] : Array.isArray(value) ? value : [value]);
98
+
99
+ function AccordionRoot(props: AccordionProps) {
100
+ const own = useDefaults('Accordion');
101
+ const {
102
+ type,
103
+ value,
104
+ defaultValue,
105
+ onValueChange,
106
+ collapsible = true,
107
+ size,
108
+ radius,
109
+ color,
110
+ disabled,
111
+ className,
112
+ onKeyDown,
113
+ ...rest
114
+ } = props;
115
+
116
+ const [open, setOpen] = useControllable<string[]>(
117
+ value === undefined ? undefined : toArray(value),
118
+ toArray(defaultValue),
119
+ (next) =>
120
+ type === 'multiple'
121
+ ? (onValueChange as (v: string[]) => void)?.(next)
122
+ : (onValueChange as (v: string | null) => void)?.(next[0] ?? null),
123
+ );
124
+
125
+ const baseId = useId();
126
+ const idFor = (kind: 'trigger' | 'content', v: string) => `${baseId}-${kind}-${v.replace(/[^\w-]/g, '_')}`;
127
+
128
+ function toggle(item: string) {
129
+ const on = open.includes(item);
130
+ if (type === 'multiple') setOpen(on ? open.filter((v) => v !== item) : [...open, item]);
131
+ else if (!on) setOpen([item]);
132
+ else if (collapsible) setOpen([]);
133
+ }
134
+
135
+ function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
136
+ onKeyDown?.(event);
137
+ const root = event.currentTarget;
138
+ const triggers = [...root.querySelectorAll<HTMLButtonElement>('.yarcl-accordion-trigger:not(:disabled)')].filter(
139
+ (trigger) => trigger.closest('.yarcl-accordion') === root,
140
+ );
141
+ const current = triggers.indexOf(document.activeElement as HTMLButtonElement);
142
+ if (current === -1) return;
143
+ const next = {
144
+ ArrowDown: (current + 1) % triggers.length,
145
+ ArrowUp: (current - 1 + triggers.length) % triggers.length,
146
+ Home: 0,
147
+ End: triggers.length - 1,
148
+ }[event.key];
149
+ if (next === undefined) return;
150
+ event.preventDefault();
151
+ triggers[next].focus();
152
+ }
153
+
154
+ const resolvedSize = size ?? own.size;
155
+ return (
156
+ <AccordionContext.Provider value={{ open, toggle, disabled, idFor }}>
157
+ <div
158
+ className={cx(
159
+ 'yarcl-accordion',
160
+ sizeClass(resolvedSize),
161
+ radiusClass(radius ?? own.radius, resolvedSize),
162
+ colorClass(color ?? own.color),
163
+ className,
164
+ )}
165
+ onKeyDown={handleKeyDown}
166
+ {...rest}
167
+ />
168
+ </AccordionContext.Provider>
169
+ );
170
+ }
171
+
172
+ /** Props for `Accordion.Item`. */
173
+ export interface AccordionItemProps extends Omit<ComponentProps<'div'>, 'value'> {
174
+ /** Identifies the item in the accordion's value. */
175
+ value: string;
176
+ /** Prevents opening or closing this item. */
177
+ disabled?: boolean;
178
+ }
179
+
180
+ function AccordionItem({ value, disabled, className, ...props }: AccordionItemProps) {
181
+ const context = useAccordionContext('Accordion.Item');
182
+ const open = context.open.includes(value);
183
+ const itemDisabled = disabled || context.disabled;
184
+ return (
185
+ <AccordionItemContext.Provider value={{ value, open, disabled: itemDisabled }}>
186
+ <div
187
+ data-state={open ? 'open' : 'closed'}
188
+ data-disabled={itemDisabled || undefined}
189
+ className={cx('yarcl-accordion-item', className)}
190
+ {...props}
191
+ />
192
+ </AccordionItemContext.Provider>
193
+ );
194
+ }
195
+
196
+ /** Props for `Accordion.Trigger`. */
197
+ export interface AccordionTriggerProps extends ComponentProps<'button'> {
198
+ /**
199
+ * Level of the heading that wraps the button, to fit the page outline.
200
+ * @default 3
201
+ */
202
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
203
+ }
204
+
205
+ function AccordionTrigger({ level = 3, className, onClick, children, ...props }: AccordionTriggerProps) {
206
+ const context = useAccordionContext('Accordion.Trigger');
207
+ const item = useItemContext('Accordion.Trigger');
208
+ const Heading = `h${level}` as 'h3';
209
+ return (
210
+ <Heading className="yarcl-accordion-heading">
211
+ <button
212
+ type="button"
213
+ id={context.idFor('trigger', item.value)}
214
+ aria-controls={context.idFor('content', item.value)}
215
+ aria-expanded={item.open}
216
+ disabled={item.disabled}
217
+ className={cx('yarcl-accordion-trigger', className)}
218
+ onClick={(event) => {
219
+ onClick?.(event);
220
+ if (!event.defaultPrevented) context.toggle(item.value);
221
+ }}
222
+ {...props}
223
+ >
224
+ <span className="yarcl-accordion-label">{children}</span>
225
+ <ChevronIcon />
226
+ </button>
227
+ </Heading>
228
+ );
229
+ }
230
+
231
+ /** Props for `Accordion.Content`. */
232
+ export type AccordionContentProps = ComponentProps<'div'>;
233
+
234
+ function AccordionContent({ className, children, ...props }: AccordionContentProps) {
235
+ const context = useAccordionContext('Accordion.Content');
236
+ const item = useItemContext('Accordion.Content');
237
+ return (
238
+ <div
239
+ role="region"
240
+ id={context.idFor('content', item.value)}
241
+ aria-labelledby={context.idFor('trigger', item.value)}
242
+ data-state={item.open ? 'open' : 'closed'}
243
+ className={cx('yarcl-accordion-content', className)}
244
+ {...props}
245
+ >
246
+ <div className="yarcl-accordion-body">{children}</div>
247
+ </div>
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Stacked sections that expand and collapse. Each trigger is a button inside a heading;
253
+ * Up and Down move between triggers, Home and End jump to the ends. The panel height animates
254
+ * with the `motion` tokens, and snaps when reduced motion is preferred.
255
+ *
256
+ * @example
257
+ * ```tsx
258
+ * <Accordion type="single" defaultValue="shipping">
259
+ * <Accordion.Item value="shipping">
260
+ * <Accordion.Trigger>Shipping</Accordion.Trigger>
261
+ * <Accordion.Content>Orders ship within two working days.</Accordion.Content>
262
+ * </Accordion.Item>
263
+ * <Accordion.Item value="returns">
264
+ * <Accordion.Trigger>Returns</Accordion.Trigger>
265
+ * <Accordion.Content>Free returns within 30 days.</Accordion.Content>
266
+ * </Accordion.Item>
267
+ * </Accordion>
268
+ * ```
269
+ */
270
+ export const Accordion = Object.assign(AccordionRoot, {
271
+ Item: AccordionItem,
272
+ Trigger: AccordionTrigger,
273
+ Content: AccordionContent,
274
+ });
@@ -0,0 +1,85 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+ import { colorClass, cx, radiusClass, softVariantClass } from '../classes';
3
+ import type { Color, Radius, Variant } from '../types';
4
+ import { useDefaults } from '../runtime';
5
+
6
+
7
+ /** Props for {@link Alert}. */
8
+ export interface AlertProps extends Omit<ComponentProps<'div'>, 'color' | 'title'> {
9
+ /** Bold first line. */
10
+ title?: ReactNode;
11
+ /** Icon shown before the text. */
12
+ icon?: ReactNode;
13
+ /** Actions shown after the text, e.g. a {@link Button} or {@link Link}. */
14
+ action?: ReactNode;
15
+ /**
16
+ * Semantic color, from the `colors` config.
17
+ * @default config.defaults.color
18
+ */
19
+ color?: Color;
20
+ /**
21
+ * Style recipe, from the `variants` config.
22
+ * @default config.defaults.softVariant
23
+ */
24
+ variant?: Variant;
25
+ /**
26
+ * Corner radius, from the `radii` config.
27
+ * @default config.defaults.radius
28
+ */
29
+ radius?: Radius;
30
+ /** Shows a dismiss button. Called when it is pressed; hide the alert in response. */
31
+ onDismiss?: () => void;
32
+ /**
33
+ * Announces the alert when it appears: `'polite'` (`role="status"`) or `'assertive'` (`role="alert"`).
34
+ * Leave unset for messages present on page load.
35
+ */
36
+ live?: 'polite' | 'assertive';
37
+ }
38
+
39
+ /**
40
+ * An inline message that stays on the page, e.g. a warning above a form.
41
+ * For transient messages use {@link toast}.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <Alert color="warning" title="Trial ends in 3 days" action={<Button size="sm">Upgrade</Button>}>
46
+ * Add a payment method to keep your projects.
47
+ * </Alert>
48
+ * ```
49
+ */
50
+ export function Alert({
51
+ title,
52
+ icon,
53
+ action,
54
+ color,
55
+ variant,
56
+ radius,
57
+ onDismiss,
58
+ live,
59
+ className,
60
+ children,
61
+ ...props
62
+ }: AlertProps) {
63
+ const own = useDefaults('Alert');
64
+ return (
65
+ <div
66
+ role={live === 'assertive' ? 'alert' : live === 'polite' ? 'status' : undefined}
67
+ className={cx('yarcl-alert', colorClass(color ?? own.color), softVariantClass(variant ?? own.variant), radiusClass(radius ?? own.radius), className)}
68
+ {...props}
69
+ >
70
+ {icon != null && <span className="yarcl-alert-icon">{icon}</span>}
71
+ <div className="yarcl-alert-text">
72
+ {title != null && <div className="yarcl-alert-title">{title}</div>}
73
+ {children != null && <div className="yarcl-alert-body">{children}</div>}
74
+ </div>
75
+ {action != null && <div className="yarcl-alert-action">{action}</div>}
76
+ {onDismiss && (
77
+ <button type="button" className="yarcl-alert-dismiss" aria-label="Dismiss" onClick={onDismiss}>
78
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
79
+ <path d="M6 6l12 12M18 6 6 18" />
80
+ </svg>
81
+ </button>
82
+ )}
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,79 @@
1
+ import type { ComponentProps } from 'react';
2
+ import { colorClass, cx, radiusClass, sizeClass, softVariantClass } from '../classes';
3
+ import type { Color, Radius, Size, Variant } from '../types';
4
+ import { useDefaults } from '../runtime';
5
+
6
+
7
+ /** Props for {@link Badge}. */
8
+ export interface BadgeProps extends Omit<ComponentProps<'span'>, 'color'> {
9
+ /**
10
+ * Semantic color, from the `colors` config.
11
+ * @default config.defaults.color
12
+ */
13
+ color?: Color;
14
+ /**
15
+ * Style recipe, from the `variants` config.
16
+ * @default config.defaults.softVariant
17
+ */
18
+ variant?: Variant;
19
+ /**
20
+ * Scales with the size's font size, from the `sizes` config.
21
+ * @default config.defaults.size
22
+ */
23
+ size?: Size;
24
+ /**
25
+ * Corner radius, from the `radii` config.
26
+ * @default config.defaults.radius
27
+ */
28
+ radius?: Radius | 'size';
29
+ /** Shows a remove button, making the badge a removable tag. Called when it is pressed. */
30
+ onRemove?: () => void;
31
+ /**
32
+ * Accessible label of the remove button.
33
+ * @default 'Remove'
34
+ */
35
+ removeLabel?: string;
36
+ }
37
+
38
+ /**
39
+ * A small label for status, counts or categories. With `onRemove`, a removable tag.
40
+ * Uses the same variant recipes as {@link Button}.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * <Badge color="success">Active</Badge>
45
+ * <Badge onRemove={() => removeTag('react')}>react</Badge>
46
+ * ```
47
+ */
48
+ export function Badge({
49
+ color,
50
+ variant,
51
+ size,
52
+ radius,
53
+ onRemove,
54
+ removeLabel = 'Remove',
55
+ className,
56
+ children,
57
+ ...props
58
+ }: BadgeProps) {
59
+ const own = useDefaults('Badge');
60
+ return (
61
+ <span
62
+ className={cx('yarcl-badge', colorClass(color ?? own.color), softVariantClass(variant ?? own.variant), sizeClass(size ?? own.size), radiusClass(radius ?? own.radius, size ?? own.size), className)}
63
+ {...props}
64
+ >
65
+ {children}
66
+ {onRemove && <BadgeRemove aria-label={removeLabel} onClick={onRemove} />}
67
+ </span>
68
+ );
69
+ }
70
+
71
+ export function BadgeRemove(props: Omit<ComponentProps<'button'>, 'children'>) {
72
+ return (
73
+ <button type="button" className="yarcl-badge-remove" {...props}>
74
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true">
75
+ <path d="M7 7l10 10M17 7 7 17" />
76
+ </svg>
77
+ </button>
78
+ );
79
+ }