@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
@@ -0,0 +1,140 @@
1
+ import {
2
+ autoUpdate,
3
+ FloatingFocusManager,
4
+ FloatingPortal,
5
+ useClick,
6
+ useDismiss,
7
+ useFloating,
8
+ useInteractions,
9
+ useRole,
10
+ type Placement,
11
+ } from '@floating-ui/react';
12
+ import { createContext, useContext, type ComponentProps, type ReactElement, type ReactNode, type RefObject } from 'react';
13
+ import { cx, paddingClass, radiusClass } from '../classes';
14
+ import { floatingMiddleware, useTrigger } from '../floating';
15
+ import { useControllable } from '../hooks';
16
+ import type { Radius, Spacing } from '../types';
17
+ import { useDefaults } from '../runtime';
18
+
19
+
20
+ type PopoverContextValue = ReturnType<typeof usePopoverState>;
21
+
22
+ const PopoverContext = createContext<PopoverContextValue | null>(null);
23
+
24
+ function usePopoverContext(component: string) {
25
+ const context = useContext(PopoverContext);
26
+ if (!context) throw new Error(`yarcl: <${component}> must be inside <Popover>`);
27
+ return context;
28
+ }
29
+
30
+ function usePopoverState({ open: openProp, defaultOpen = false, onOpenChange, placement = 'bottom', modal = false }: PopoverProps) {
31
+ const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
32
+ const floating = useFloating({
33
+ open,
34
+ onOpenChange: setOpen,
35
+ placement,
36
+ transform: false,
37
+ whileElementsMounted: autoUpdate,
38
+ middleware: floatingMiddleware(),
39
+ });
40
+ const interactions = useInteractions([
41
+ useClick(floating.context),
42
+ useDismiss(floating.context),
43
+ useRole(floating.context, { role: 'dialog' }),
44
+ ]);
45
+ return { open, setOpen, modal, ...floating, ...interactions };
46
+ }
47
+
48
+ /** Props for {@link Popover}. */
49
+ export interface PopoverProps {
50
+ /** `Popover.Trigger` and `Popover.Content`. */
51
+ children?: ReactNode;
52
+ /** Controlled open state. */
53
+ open?: boolean;
54
+ /**
55
+ * Initial open state when uncontrolled.
56
+ * @default false
57
+ */
58
+ defaultOpen?: boolean;
59
+ /** Called when the popover opens or closes. */
60
+ onOpenChange?: (open: boolean) => void;
61
+ /**
62
+ * Preferred side. Flips when there isn't room.
63
+ * @default 'bottom'
64
+ */
65
+ placement?: Placement;
66
+ /**
67
+ * Traps focus inside the content while open.
68
+ * @default false
69
+ */
70
+ modal?: boolean;
71
+ }
72
+
73
+ function PopoverRoot(props: PopoverProps) {
74
+ const state = usePopoverState(props);
75
+ return <PopoverContext.Provider value={state}>{props.children}</PopoverContext.Provider>;
76
+ }
77
+
78
+ /** Props for `Popover.Trigger`. */
79
+ export interface PopoverTriggerProps {
80
+ /** A single element, usually a {@link Button}. It receives the click handler and ARIA attributes. */
81
+ children: ReactElement;
82
+ }
83
+
84
+ function PopoverTrigger({ children }: PopoverTriggerProps) {
85
+ const { refs, getReferenceProps } = usePopoverContext('Popover.Trigger');
86
+ return useTrigger(children, refs.setReference, getReferenceProps);
87
+ }
88
+
89
+ /** Props for `Popover.Content`. */
90
+ export interface PopoverContentProps extends ComponentProps<'div'> {
91
+ /**
92
+ * Inner padding, from the `spacing` config.
93
+ * @default config.defaults.padding
94
+ */
95
+ padding?: Spacing;
96
+ /**
97
+ * Corner radius, from the `radii` config.
98
+ * @default config.defaults.radius
99
+ */
100
+ radius?: Radius;
101
+ /**
102
+ * Element focused when the content opens: an index into its tabbable elements, or a ref.
103
+ * @default 0
104
+ */
105
+ initialFocus?: number | RefObject<HTMLElement | null>;
106
+ }
107
+
108
+ function PopoverContent({ padding, radius, initialFocus, className, style, ...props }: PopoverContentProps) {
109
+ const own = useDefaults('Popover');
110
+ const { open, modal, refs, floatingStyles, context, getFloatingProps } = usePopoverContext('Popover.Content');
111
+ if (!open) return null;
112
+ return (
113
+ <FloatingPortal>
114
+ <FloatingFocusManager context={context} modal={modal} initialFocus={initialFocus}>
115
+ <div
116
+ ref={refs.setFloating}
117
+ style={{ ...floatingStyles, ...style }}
118
+ className={cx('yarcl-floating yarcl-panel yarcl-popover', paddingClass(padding ?? own.padding), radiusClass(radius ?? own.radius), className)}
119
+ {...getFloatingProps(props)}
120
+ />
121
+ </FloatingFocusManager>
122
+ </FloatingPortal>
123
+ );
124
+ }
125
+
126
+ /**
127
+ * A floating panel anchored to a trigger, opened by click. Closes on Esc or outside click.
128
+ * The base for custom floating UI; use {@link Menu}, {@link Select} or {@link Combobox} for lists.
129
+ *
130
+ * @example
131
+ * ```tsx
132
+ * <Popover>
133
+ * <Popover.Trigger><Button variant="outline">Filters</Button></Popover.Trigger>
134
+ * <Popover.Content>
135
+ * <Stack><Checkbox>Archived</Checkbox></Stack>
136
+ * </Popover.Content>
137
+ * </Popover>
138
+ * ```
139
+ */
140
+ export const Popover = Object.assign(PopoverRoot, { Trigger: PopoverTrigger, Content: PopoverContent });
@@ -0,0 +1,122 @@
1
+ import { useId, type ComponentProps, type CSSProperties, type ReactNode } from 'react';
2
+ import { colorClass, cx, radiusClass, sizeClass } from '../classes';
3
+ import type { Color, Radius, Size } from '../types';
4
+ import { useDefaults } from '../runtime';
5
+
6
+ /** Props for {@link Progress}. */
7
+ export interface ProgressProps extends Omit<ComponentProps<'div'>, 'color' | 'children'> {
8
+ /**
9
+ * Current value, from 0 to `max`. Leave undefined for an indeterminate bar that animates until the work is measurable.
10
+ * @example
11
+ * ```tsx
12
+ * <Progress value={40} label="Uploading" />
13
+ * ```
14
+ */
15
+ value?: number;
16
+ /**
17
+ * The value that means complete.
18
+ * @default 100
19
+ */
20
+ max?: number;
21
+ /**
22
+ * Visible label above the bar. It also names the progress bar for screen readers;
23
+ * without it, pass `aria-label` or `aria-labelledby`.
24
+ */
25
+ label?: ReactNode;
26
+ /** Shows the value next to the label, as a percentage unless `formatValue` is set. Ignored when indeterminate. */
27
+ showValue?: boolean;
28
+ /**
29
+ * Formats the value for the visible text and `aria-valuetext`.
30
+ * @example
31
+ * ```tsx
32
+ * <Progress value={3} max={8} showValue formatValue={(v, max) => `${v} of ${max} files`} />
33
+ * ```
34
+ */
35
+ formatValue?: (value: number, max: number) => string;
36
+ /**
37
+ * Track height, scaled from the size's `iconSize` in the `sizes` config. Also sets the label's font size.
38
+ * @default config.defaults.size
39
+ */
40
+ size?: Size;
41
+ /**
42
+ * Bar color, from the `colors` config.
43
+ * @default config.defaults.color
44
+ */
45
+ color?: Color;
46
+ /**
47
+ * Corner radius of the track and bar, from the `radii` config.
48
+ * @default config.defaults.radius
49
+ */
50
+ radius?: Radius | 'size';
51
+ }
52
+
53
+ /**
54
+ * A progress bar. Determinate when `value` is set, indeterminate otherwise.
55
+ * Renders `role="progressbar"` with `aria-valuenow`, `aria-valuemin` and `aria-valuemax`
56
+ * (omitted when indeterminate), named by `label` or `aria-label`.
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * <Progress value={64} label="Storage used" showValue color="warning" />
61
+ * <Progress aria-label="Loading results" />
62
+ * ```
63
+ */
64
+ export function Progress({
65
+ value,
66
+ max = 100,
67
+ label,
68
+ showValue,
69
+ formatValue,
70
+ size,
71
+ color,
72
+ radius,
73
+ className,
74
+ style,
75
+ ...props
76
+ }: ProgressProps) {
77
+ const own = useDefaults('Progress');
78
+ const labelId = useId();
79
+ const indeterminate = value == null;
80
+ const limit = max > 0 ? max : 100;
81
+ const current = indeterminate ? 0 : Math.min(Math.max(value, 0), limit);
82
+ const text = formatValue ? formatValue(current, limit) : `${Math.round((current / limit) * 100)}%`;
83
+ const hasLabel = label != null;
84
+ const named = hasLabel || props['aria-label'] != null || props['aria-labelledby'] != null;
85
+ const valueVisible = showValue && !indeterminate;
86
+
87
+ return (
88
+ <div
89
+ role="progressbar"
90
+ aria-labelledby={hasLabel ? labelId : undefined}
91
+ aria-label={named ? undefined : 'Progress'}
92
+ aria-valuemin={indeterminate ? undefined : 0}
93
+ aria-valuemax={indeterminate ? undefined : limit}
94
+ aria-valuenow={indeterminate ? undefined : current}
95
+ aria-valuetext={indeterminate || !formatValue ? undefined : text}
96
+ data-indeterminate={indeterminate || undefined}
97
+ className={cx(
98
+ 'yarcl-progress',
99
+ sizeClass(size ?? own.size),
100
+ colorClass(color ?? own.color),
101
+ radiusClass(radius ?? own.radius, size ?? own.size),
102
+ className,
103
+ )}
104
+ style={{ ...(indeterminate ? null : { '--yarcl-progress': current / limit }), ...style } as CSSProperties}
105
+ {...props}
106
+ >
107
+ {(hasLabel || valueVisible) && (
108
+ <div className="yarcl-progress-header">
109
+ {hasLabel && (
110
+ <span id={labelId} className="yarcl-progress-label">
111
+ {label}
112
+ </span>
113
+ )}
114
+ {valueVisible && <span className="yarcl-progress-value">{text}</span>}
115
+ </div>
116
+ )}
117
+ <div className="yarcl-progress-track">
118
+ <div className="yarcl-progress-bar" />
119
+ </div>
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,59 @@
1
+ import type { ChangeEvent, ComponentProps, ReactNode } from 'react';
2
+ import { colorClass, cx, sizeClass } from '../classes';
3
+ import type { Color, Size } from '../types';
4
+ import { useRadioGroup } from './RadioGroup';
5
+ import { useDefaults } from '../runtime';
6
+
7
+
8
+ /** Props for {@link Radio}. Accepts all native `<input>` attributes except `type`, `color` and `size`. */
9
+ export interface RadioProps extends Omit<ComponentProps<'input'>, 'type' | 'color' | 'size' | 'children' | 'value'> {
10
+ /** Label shown next to the radio. */
11
+ children?: ReactNode;
12
+ /** Value reported to the {@link RadioGroup} and submitted with forms. */
13
+ value?: string;
14
+ /**
15
+ * Radio size, from the `sizes` config (uses the size's `iconSize`). Inherited from a {@link RadioGroup}.
16
+ * @default config.defaults.size
17
+ */
18
+ size?: Size;
19
+ /**
20
+ * Color of the selected radio, from the `colors` config. Inherited from a {@link RadioGroup}.
21
+ * @default config.defaults.color
22
+ */
23
+ color?: Color;
24
+ }
25
+
26
+ /**
27
+ * A radio button with an optional label. Place inside a {@link RadioGroup}, or group standalone
28
+ * radios with the same `name`.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <RadioGroup label="Plan" defaultValue="pro">
33
+ * <Radio value="free">Free</Radio>
34
+ * <Radio value="pro">Pro</Radio>
35
+ * </RadioGroup>
36
+ * ```
37
+ */
38
+ export function Radio({ children, size, color, value, className, style, onChange, ...props }: RadioProps) {
39
+ const own = useDefaults('Radio');
40
+ const group = useRadioGroup();
41
+ const groupProps = group && {
42
+ name: group.name,
43
+ checked: value !== undefined && group.value === value,
44
+ 'aria-invalid': group.invalid || undefined,
45
+ onChange: (event: ChangeEvent<HTMLInputElement>) => {
46
+ onChange?.(event);
47
+ if (event.target.checked && value !== undefined) group.select(value);
48
+ },
49
+ };
50
+ return (
51
+ <label
52
+ className={cx('yarcl-radio', sizeClass(size ?? group?.size ?? own.size), colorClass(color ?? group?.color ?? own.color), className)}
53
+ style={style}
54
+ >
55
+ <input type="radio" className="yarcl-radio-input" value={value} onChange={onChange} {...props} {...groupProps} />
56
+ {children != null && <span className="yarcl-radio-label">{children}</span>}
57
+ </label>
58
+ );
59
+ }
@@ -0,0 +1,114 @@
1
+ import { createContext, useContext, useId, type ComponentProps, type ReactNode } from 'react';
2
+ import { cx, typeClass } from '../classes';
3
+ import { useControllable } from '../hooks';
4
+ import type { Color, Size } from '../types';
5
+ import { useConfig } from '../runtime';
6
+
7
+ interface RadioGroupContextValue {
8
+ name: string;
9
+ value: string | null;
10
+ select: (value: string) => void;
11
+ size?: Size;
12
+ color?: Color;
13
+ invalid: boolean;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
18
+
19
+ export function useRadioGroup() {
20
+ return useContext(RadioGroupContext);
21
+ }
22
+
23
+ /** Props for {@link RadioGroup}. */
24
+ export interface RadioGroupProps extends Omit<ComponentProps<'fieldset'>, 'defaultValue' | 'onChange' | 'color'> {
25
+ /** Visible group label, rendered as the `<legend>`. */
26
+ label: ReactNode;
27
+ /** Helper text shown below the options. */
28
+ description?: ReactNode;
29
+ /** Error message. Marks the options invalid and styles them with `defaults.errorColor`. */
30
+ error?: ReactNode;
31
+ /** Form field name shared by the radios. Generated when omitted. */
32
+ name?: string;
33
+ /** Controlled selected value. */
34
+ value?: string | null;
35
+ /**
36
+ * Initially selected value when uncontrolled.
37
+ * @default null
38
+ */
39
+ defaultValue?: string | null;
40
+ /** Called with the selected radio's `value`. */
41
+ onValueChange?: (value: string) => void;
42
+ /** Size of every radio, from the `sizes` config. */
43
+ size?: Size;
44
+ /** Color of every radio, from the `colors` config. */
45
+ color?: Color;
46
+ /**
47
+ * Lays the radios out in a row or a column.
48
+ * @default 'vertical'
49
+ */
50
+ orientation?: 'horizontal' | 'vertical';
51
+ /** {@link Radio}s, each with a `value`. */
52
+ children: ReactNode;
53
+ }
54
+
55
+ /**
56
+ * A labelled group of {@link Radio}s. Renders a `<fieldset>` and `<legend>`, shares one `name`,
57
+ * and tracks the selected value. Radios inside don't need `name` or `checked`.
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * <RadioGroup label="Plan" defaultValue="pro" orientation="horizontal">
62
+ * <Radio value="free">Free</Radio>
63
+ * <Radio value="pro">Pro</Radio>
64
+ * </RadioGroup>
65
+ * ```
66
+ */
67
+ export function RadioGroup({
68
+ label,
69
+ description,
70
+ error,
71
+ name,
72
+ value: valueProp,
73
+ defaultValue = null,
74
+ onValueChange,
75
+ size,
76
+ color,
77
+ orientation = 'vertical',
78
+ disabled,
79
+ className,
80
+ children,
81
+ ...props
82
+ }: RadioGroupProps) {
83
+ const config = useConfig();
84
+ const id = useId();
85
+ const [value, setValue] = useControllable<string | null>(valueProp, defaultValue, (v) => v != null && onValueChange?.(v));
86
+ const descriptionId = description != null ? `${id}-description` : undefined;
87
+ const errorId = error != null && error !== false ? `${id}-error` : undefined;
88
+
89
+ return (
90
+ <RadioGroupContext.Provider
91
+ value={{ name: name ?? id, value, select: setValue, size, color, invalid: errorId != null, disabled }}
92
+ >
93
+ <fieldset
94
+ className={cx('yarcl-field yarcl-radio-group', className)}
95
+ aria-describedby={cx(descriptionId, errorId) || undefined}
96
+ disabled={disabled}
97
+ {...props}
98
+ >
99
+ <legend className={cx('yarcl-field-label', typeClass(config.defaults.labelStyle))}>{label}</legend>
100
+ <div className={cx('yarcl-radio-group-options', `yarcl-radio-group-${orientation}`)}>{children}</div>
101
+ {descriptionId && (
102
+ <p className={cx('yarcl-field-description', typeClass(config.defaults.helperStyle))} id={descriptionId}>
103
+ {description}
104
+ </p>
105
+ )}
106
+ {errorId && (
107
+ <p className={cx('yarcl-field-error', typeClass(config.defaults.helperStyle))} id={errorId}>
108
+ {error}
109
+ </p>
110
+ )}
111
+ </fieldset>
112
+ </RadioGroupContext.Provider>
113
+ );
114
+ }
@@ -0,0 +1,197 @@
1
+ import {
2
+ autoUpdate,
3
+ FloatingFocusManager,
4
+ FloatingPortal,
5
+ useClick,
6
+ useDismiss,
7
+ useFloating,
8
+ useInteractions,
9
+ useListNavigation,
10
+ useRole,
11
+ useTypeahead,
12
+ } from '@floating-ui/react';
13
+ import { useRef, useState, type ComponentProps, type ReactNode } from 'react';
14
+ import { colorClass, cx, radiusClass, sizeClass } from '../classes';
15
+ import { useFieldProps } from '../field-context';
16
+ import { CheckIcon, ChevronIcon, floatingMiddleware } from '../floating';
17
+ import { useControllable } from '../hooks';
18
+ import type { TokenProps } from '../types';
19
+ import { useDefaults } from '../runtime';
20
+
21
+
22
+ /** An option of a {@link Select} or {@link Combobox}. */
23
+ export interface SelectOption<V extends string = string> {
24
+ /** Value reported by `onValueChange` and submitted with forms. */
25
+ value: V;
26
+ /** Text shown to the user. Also used for type-to-select and filtering. */
27
+ label: string;
28
+ /** Prevents choosing this option. */
29
+ disabled?: boolean;
30
+ }
31
+
32
+ /** Props for {@link Select}. */
33
+ export interface SelectProps<V extends string = string>
34
+ extends TokenProps,
35
+ Omit<ComponentProps<'button'>, 'color' | 'value' | 'defaultValue' | 'onChange' | 'children'> {
36
+ /** The options to choose from. */
37
+ options: readonly SelectOption<V>[];
38
+ /** Controlled value. `null` means nothing is selected. */
39
+ value?: V | null;
40
+ /**
41
+ * Initial value when uncontrolled.
42
+ * @default null
43
+ */
44
+ defaultValue?: V | null;
45
+ /** Called with the chosen option's value. */
46
+ onValueChange?: (value: V | null) => void;
47
+ /** Text shown when nothing is selected. */
48
+ placeholder?: ReactNode;
49
+ /** Form field name. Renders a hidden input with the value. */
50
+ name?: string;
51
+ }
52
+
53
+ /**
54
+ * Picks one value from a list, like a native `<select>` but styled from the design tokens.
55
+ * Arrow keys and type-to-select work both open and closed.
56
+ * Shares the size scale with {@link Input} and {@link Button}. Works inside a {@link Field}.
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * <Select
61
+ * placeholder="Choose a plan"
62
+ * options={[
63
+ * { value: 'free', label: 'Free' },
64
+ * { value: 'pro', label: 'Pro' },
65
+ * ]}
66
+ * onValueChange={setPlan}
67
+ * />
68
+ * ```
69
+ */
70
+ export function Select<V extends string = string>(props: SelectProps<V>) {
71
+ const own = useDefaults('Select');
72
+ const {
73
+ options,
74
+ value: valueProp,
75
+ defaultValue = null,
76
+ onValueChange,
77
+ placeholder,
78
+ name,
79
+ size,
80
+ radius,
81
+ color,
82
+ className,
83
+ disabled,
84
+ ...rest
85
+ } = useFieldProps(props);
86
+
87
+ const [value, setValue] = useControllable<V | null>(valueProp, defaultValue, onValueChange);
88
+ const [open, setOpen] = useState(false);
89
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
90
+ const listRef = useRef<(HTMLElement | null)[]>([]);
91
+ const labelsRef = useRef<(string | null)[]>([]);
92
+ const typingRef = useRef(false);
93
+
94
+ labelsRef.current = options.map((option) => (option.disabled ? null : option.label));
95
+ const selectedIndex = options.findIndex((option) => option.value === value);
96
+ const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;
97
+ const disabledIndices = options.flatMap((option, i) => (option.disabled ? [i] : []));
98
+
99
+ function choose(index: number) {
100
+ const option = options[index];
101
+ if (!option || option.disabled) return;
102
+ setValue(option.value);
103
+ setOpen(false);
104
+ }
105
+
106
+ const { refs, floatingStyles, context } = useFloating({
107
+ open,
108
+ onOpenChange: setOpen,
109
+ placement: 'bottom-start',
110
+ transform: false,
111
+ whileElementsMounted: autoUpdate,
112
+ middleware: floatingMiddleware({ gap: 4, matchWidth: true }),
113
+ });
114
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
115
+ useClick(context, { enabled: !disabled }),
116
+ useDismiss(context),
117
+ useRole(context, { role: 'select' }),
118
+ useListNavigation(context, {
119
+ listRef,
120
+ activeIndex,
121
+ selectedIndex: selectedIndex >= 0 ? selectedIndex : null,
122
+ onNavigate: setActiveIndex,
123
+ disabledIndices,
124
+ loop: true,
125
+ }),
126
+ useTypeahead(context, {
127
+ listRef: labelsRef,
128
+ activeIndex,
129
+ selectedIndex: selectedIndex >= 0 ? selectedIndex : null,
130
+ onMatch: open ? setActiveIndex : choose,
131
+ onTypingChange: (typing) => {
132
+ typingRef.current = typing;
133
+ },
134
+ }),
135
+ ]);
136
+
137
+ return (
138
+ <>
139
+ <button
140
+ ref={refs.setReference}
141
+ type="button"
142
+ disabled={disabled}
143
+ className={cx('yarcl-input yarcl-select', sizeClass(size ?? own.size), radiusClass(radius ?? own.radius, size ?? own.size), colorClass(color ?? own.color), className)}
144
+ {...getReferenceProps(rest)}
145
+ >
146
+ <span className={cx('yarcl-select-value', !selected && 'yarcl-select-placeholder')}>
147
+ {selected ? selected.label : placeholder}
148
+ </span>
149
+ <ChevronIcon />
150
+ </button>
151
+ {name && <input type="hidden" name={name} value={value ?? ''} />}
152
+ {open && (
153
+ <FloatingPortal>
154
+ <FloatingFocusManager context={context} modal={false}>
155
+ <div
156
+ ref={refs.setFloating}
157
+ style={floatingStyles}
158
+ className={cx('yarcl-floating yarcl-panel yarcl-listbox', sizeClass(size ?? own.size), colorClass(color ?? own.color))}
159
+ {...getFloatingProps()}
160
+ >
161
+ {options.map((option, i) => {
162
+ const isSelected = i === selectedIndex;
163
+ const isActive = i === activeIndex;
164
+ return (
165
+ <div
166
+ key={option.value}
167
+ ref={(node) => {
168
+ listRef.current[i] = node;
169
+ }}
170
+ tabIndex={isActive ? 0 : -1}
171
+ aria-disabled={option.disabled || undefined}
172
+ data-active={isActive || undefined}
173
+ className="yarcl-option"
174
+ {...getItemProps({
175
+ active: isActive,
176
+ selected: isSelected,
177
+ onClick: () => choose(i),
178
+ onKeyDown(event) {
179
+ if (event.key === 'Enter' || (event.key === ' ' && !typingRef.current)) {
180
+ event.preventDefault();
181
+ choose(i);
182
+ }
183
+ },
184
+ })}
185
+ >
186
+ <span className="yarcl-option-label">{option.label}</span>
187
+ {isSelected && <CheckIcon />}
188
+ </div>
189
+ );
190
+ })}
191
+ </div>
192
+ </FloatingFocusManager>
193
+ </FloatingPortal>
194
+ )}
195
+ </>
196
+ );
197
+ }