@overdoser/react-toolkit 0.0.16 → 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/AGENTS.md CHANGED
@@ -19,6 +19,11 @@ For exhaustive component reference (every prop, every variant, every signature),
19
19
  import '@overdoser/react-toolkit/theme.css';
20
20
  ```
21
21
  Without this, components render unstyled.
22
+ - **Want `className` / `classes.*` overrides to always win regardless of bundler order?** Import the layered twin instead:
23
+ ```ts
24
+ import '@overdoser/react-toolkit/theme.layered.css';
25
+ ```
26
+ It wraps all toolkit CSS in a `crk` cascade layer, so any of your own *unlayered* rules beat it deterministically. See "Customizing styles" below.
22
27
 
23
28
  ## Import rules
24
29
 
@@ -44,6 +49,8 @@ For exhaustive component reference (every prop, every variant, every signature),
44
49
  - Multi-pick with chips → `<Select multiple options={...} onValuesChange={...} />`.
45
50
  - You want it to look like a "menu trigger" instead of a form input → `<Dropdown options={...} value={...} onChange={...} />` (select-mode dropdown).
46
51
 
52
+ - **Need to pick several values from a small fixed set?** → `<CheckboxGroup options={...} value={...} onChange={...} />` (a checkbox list, or `variant="chips"` for toggle chips). For a large/searchable set, prefer `<Select multiple>` instead.
53
+
47
54
  - **Need a menu (Edit / Delete / Archive)?** → `<Dropdown trigger={...}>` with `<DropdownItem>` children.
48
55
 
49
56
  - **Need a tooltip / hover card / contextual panel?** → `<Popover trigger={...} content={...} />`.
@@ -96,6 +103,26 @@ For exhaustive component reference (every prop, every variant, every signature),
96
103
  </FormField>
97
104
  ```
98
105
 
106
+ ### Wire a CheckboxGroup (multi-value) inside a Form
107
+ ```tsx
108
+ <FormField name="interests" label="Interests" rules={{ validate: (v) => v.length > 0 || 'Pick at least one' }}>
109
+ <CheckboxGroup
110
+ variant="chips" // or omit for a checkbox list
111
+ options={[
112
+ { value: 'design', label: 'Design' },
113
+ { value: 'eng', label: 'Engineering' },
114
+ ]}
115
+ />
116
+ </FormField>
117
+ ```
118
+ `CheckboxGroup` binds `value: string[]` / `onChange(values)` directly — no bridge needed.
119
+
120
+ ## Customizing styles
121
+
122
+ - **Theme tokens:** override `--crk-*` custom properties on `:root` (colors, fonts, spacing, radii). This is the primary, stable customization surface.
123
+ - **Per-element overrides:** use each component's `classes` prop (and `className`) — internal class names are hashed and unstable, so never target them directly.
124
+ - **Make overrides deterministic:** `className` / `classes.*` are plain CSS classes; they only beat the toolkit's built-in class if your stylesheet is inserted *after* it, which the bundler controls. To guarantee precedence, import `@overdoser/react-toolkit/theme.layered.css` (everything ships in a `crk` cascade layer, so your unlayered rules always win), **or** add `@import '@overdoser/react-toolkit/theme.css' layer(crk);` as the first line of your global CSS. Caveat: an aggressive global reset that is itself unlayered will then also override the toolkit's base styles — put your reset in its own earlier layer if so. `theme.css` and `theme.layered.css` are identical except the layer wrapper — import exactly one, and token (`--crk-*`) theming works the same in either.
125
+
99
126
  ## Recipes
100
127
 
101
128
  Full copy-paste-able files live alongside this doc. Each is one self-contained file that you can drop into a project. Adjust types/imports for your codebase.
package/CHANGELOG.md ADDED
@@ -0,0 +1,75 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@overdoser/react-toolkit` are documented here.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
5
+ project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0]
8
+
9
+ This release adds a `CheckboxGroup` component, a cascade-layered stylesheet for
10
+ deterministic style overrides, and viewport-aware popovers — plus a round of
11
+ `Form` ergonomics. A few **defaults changed** (forms, popovers, links); they are
12
+ all opt-out (see ⚠️ below).
13
+
14
+ ### ⚠️ Notable default changes (visual)
15
+
16
+ No API was removed or renamed, but some components now **render differently by
17
+ default**. To restore the previous behavior:
18
+
19
+ - **Forms reserve space for validation messages** so an error no longer shifts
20
+ the layout, the error now appears *between the input and the description*
21
+ (instead of replacing the description), inter-field/element gaps are tighter,
22
+ and labels are bolder (weight 700). Opt out per field or form-wide with
23
+ `reserveErrorSpace={false}`.
24
+ - **Popovers reposition to stay in the viewport** (flip to the opposite side /
25
+ shift near a screen edge). Opt out with `<Popover autoPosition={false} />`.
26
+ - **Links that open in a new tab show an inline ↗ icon** (`external` or a manual
27
+ `target="_blank"`). Opt out with `<Link hideExternalIcon />`.
28
+ - `theme.css` is **unchanged** — the new cascade layer is opt-in via
29
+ `theme.layered.css`, so existing stylesheet imports are unaffected.
30
+
31
+ ### Added
32
+
33
+ - **`CheckboxGroup` component** — multi-value selection from a fixed option set,
34
+ rendered as a checkbox list (`variant="checkbox"`, default) or toggleable chips
35
+ (`variant="chips"`). Supports `chipShape` (`pill | rounded | square`),
36
+ `chipCheckmark` (with constant chip width — padding compensates for the mark),
37
+ `orientation`, `disabled`, `error`, `name`, `required`, and a `classes`
38
+ override (`{ group, option, chip, chipSelected }`). Binds directly inside
39
+ `FormField` (`value: string[]` / `onChange(values)`), no bridge needed.
40
+ - **`theme.layered.css` stylesheet export** — identical to `theme.css` but
41
+ wrapped in a `@layer crk` cascade layer, so consumer `className` / `classes.*`
42
+ overrides win deterministically regardless of bundler import order. Import
43
+ exactly one of the two.
44
+ - **`Form` props** `reserveErrorSpace` (form-wide default for all fields) and
45
+ `fieldClasses` (form-wide `classes` default, merged with each field's own).
46
+ - **`FormField` prop** `reserveErrorSpace` (`boolean | number | string`) and a
47
+ `--crk-field-error-min-height` token to size the reserved space.
48
+ - **`Popover` prop** `autoPosition` (default `true`) for viewport-aware flipping
49
+ and edge shifting.
50
+ - **`Link` prop** `hideExternalIcon` to suppress the new-tab icon.
51
+ - `FormFieldClasses` gained a `message` key (the validation/reserve slot).
52
+
53
+ ### Changed
54
+
55
+ - `FormField` validation layout reworked so the field's outer height stays
56
+ constant when a one-line error toggles (the reserved spacer sits after the
57
+ description and swaps 1:1 with the error). Multi-line errors grow past it.
58
+ - Form-wide reservation off (`<Form reserveErrorSpace={false}>`) uses a slightly
59
+ larger inter-field gap to compensate for the absent reserved line.
60
+
61
+ ### Fixed
62
+
63
+ - **Searchable single-select** dropdown was mispositioned / rendered as a ~1px
64
+ sliver in portal mode because it anchored to the trigger button (hidden while
65
+ open); it now anchors to the always-visible wrapper.
66
+ - **Native `Select`** silently dropped `onValueChange` and could trip React's
67
+ controlled-field warning when given `value` + `onValueChange`; it now wires
68
+ both `onChange` and `onValueChange`.
69
+
70
+ ### Docs
71
+
72
+ - `README`, `llms.txt`, `manifest.json`, and `AGENTS.md` updated for all of the
73
+ above, including a `theme.css` vs `theme.layered.css` comparison and the
74
+ cascade-layer caveat (an unlayered global reset also beats layered toolkit
75
+ styles — put resets in an earlier layer).
package/README.md CHANGED
@@ -28,22 +28,79 @@ Override `--crk-*` CSS custom properties to customize the theme:
28
28
  }
29
29
  ```
30
30
 
31
+ For per-element tweaks, every component accepts `className` and a `classes` prop
32
+ (internal class names are hashed — never target them directly).
33
+
34
+ ### `theme.css` vs `theme.layered.css`
35
+
36
+ The package ships two stylesheets with **identical content** — same rules, same
37
+ hashed class names, same `--crk-*` tokens. The only difference is that
38
+ `theme.layered.css` wraps everything in a CSS cascade layer:
39
+
40
+ ```css
41
+ @layer crk {
42
+ /* ...the entire stylesheet... */
43
+ }
44
+ ```
45
+
46
+ That wrapper changes how your overrides compete with the toolkit's styles.
47
+ Import **exactly one** of the two (never both).
48
+
49
+ **The problem with plain `theme.css`.** The overrides you pass via `className` /
50
+ `classes.*` are plain CSS classes, so they tie with the toolkit's own class on
51
+ specificity — the winner is decided by **stylesheet source order**, which your
52
+ bundler controls. In many setups the toolkit CSS is injected *after* your app
53
+ CSS, so your overrides silently lose.
54
+
55
+ **What the layer fixes.** A core rule of cascade layers: **any unlayered style
56
+ beats any layered style, regardless of specificity or import order.** Since
57
+ `theme.layered.css` puts the whole toolkit in the `crk` layer, your own
58
+ (unlayered) rules — including every `className` / `classes.*` override — always
59
+ win, deterministically:
60
+
61
+ ```ts
62
+ import '@overdoser/react-toolkit/theme.layered.css'; // instead of theme.css
63
+ ```
64
+
65
+ (If you import CSS from a `.css` entry instead of JS, the equivalent is
66
+ `@import '@overdoser/react-toolkit/theme.css' layer(crk);` as the first line.)
67
+
68
+ | | `theme.css` | `theme.layered.css` |
69
+ | --- | --- | --- |
70
+ | Override wins by | specificity, then import order (bundler-dependent) | **always** (unlayered beats layered) |
71
+ | `--crk-*` token theming | ✅ | ✅ (identical) |
72
+ | Affected by a global CSS reset | only via normal specificity/order | ⚠️ an *unlayered* reset also beats the toolkit's base styles |
73
+
74
+ **The one caveat.** Because layered styles lose to *all* unlayered styles, an
75
+ aggressive global reset (e.g. `button { font: inherit }`) would also override the
76
+ toolkit's base styles under `theme.layered.css`. If you ship a heavy reset, put
77
+ it in its own earlier layer so the order is explicit:
78
+
79
+ ```css
80
+ @layer reset, crk;
81
+ ```
82
+
83
+ **Rule of thumb:** if you heavily customize component internals via
84
+ `classes` / `className`, use `theme.layered.css`. Otherwise `theme.css` is fine.
85
+ Theme-token (`--crk-*`) overrides work the same in both.
86
+
31
87
  ## Components
32
88
 
33
89
  | Component | Description |
34
90
  | --- | --- |
35
91
  | **Button** | Variants: `primary`, `secondary`, `danger`, `ghost`. Multiple sizes. Loading states with `dots`, `shimmer`, and `border` animations. |
36
- | **Link** | Styled link with variants and external link support. |
92
+ | **Link** | Styled link with variants and external link support (inline new-tab icon, toggleable). |
37
93
  | **Typography** | Renders `h1`-`h6`, `p`, `span`, `label`. Supports `weight`, `color`, `align`, and `truncate`. |
38
94
  | **List / ListItem** | Ordered and unordered lists with configurable spacing. |
39
95
  | **Table** | Sortable columns, multi-sort with Ctrl+click, pagination, and server-side sort support. |
40
96
  | **Dropdown** | Menu dropdown with chevron indicator. Also works as a selectable form input with `options`, `value`, and `onChange`. |
41
- | **Popover** | Positioned popover anchored to a trigger element. |
97
+ | **Popover** | Positioned popover anchored to a trigger element, with viewport-aware auto-flip. |
42
98
  | **Modal** | Portal-based modal with `Header`, `Body`, and `Footer` compound components. Includes focus trap and escape/backdrop close. |
43
99
  | **Form / FormField** | Form wrapper with react-hook-form integration. |
44
100
  | **Input** | Text input with sizes, error state, and prefix/suffix slots. |
45
101
  | **Select** | Native `<select>` with a custom arrow indicator. |
46
102
  | **Checkbox** | Checkbox with label and indeterminate state support. |
103
+ | **CheckboxGroup** | Multi-value selection as a checkbox list or toggleable chips. |
47
104
  | **Radio / RadioGroup** | Context-based radio group. |
48
105
  | **Textarea** | Textarea with resize control. |
49
106
 
@@ -1,4 +1,5 @@
1
1
  import { UseFormReturn, FieldValues, SubmitHandler } from 'react-hook-form';
2
+ import { FormFieldClasses } from './FormField';
2
3
  export interface FormProps<T extends FieldValues> {
3
4
  /** The result of `useForm()`. */
4
5
  form: UseFormReturn<T>;
@@ -6,6 +7,20 @@ export interface FormProps<T extends FieldValues> {
6
7
  onSubmit: SubmitHandler<T>;
7
8
  /** Top-of-form error messages. Rendered above children with `role="alert"`. */
8
9
  errors?: React.ReactNode[];
10
+ /**
11
+ * Default `reserveErrorSpace` applied to every `FormField` inside this form
12
+ * (each field can still override it via its own `reserveErrorSpace` prop).
13
+ * Same accepted values as `FormField`: `boolean | number | string`.
14
+ * @default true
15
+ */
16
+ reserveErrorSpace?: boolean | number | string;
17
+ /**
18
+ * Default `classes` applied to every `FormField` inside this form. These are
19
+ * *merged* with each field's own `classes` (both class names are applied), so
20
+ * a field can add to — not just replace — the form-wide defaults. Keys:
21
+ * `{ field, label, message, error, helperText }`.
22
+ */
23
+ fieldClasses?: Partial<FormFieldClasses>;
9
24
  className?: string;
10
25
  style?: React.CSSProperties;
11
26
  children: React.ReactNode;
@@ -23,4 +38,4 @@ export interface FormProps<T extends FieldValues> {
23
38
  * <Button type="submit">Save</Button>
24
39
  * </Form>
25
40
  */
26
- export declare function Form<T extends FieldValues>({ form, onSubmit, errors, className, style, children, }: FormProps<T>): import("react/jsx-runtime").JSX.Element;
41
+ export declare function Form<T extends FieldValues>({ form, onSubmit, errors, reserveErrorSpace, fieldClasses, className, style, children, }: FormProps<T>): import("react/jsx-runtime").JSX.Element;
@@ -2,6 +2,7 @@ import { ReactElement, CSSProperties, ReactNode } from 'react';
2
2
  export interface FormFieldClasses {
3
3
  field: string;
4
4
  label: string;
5
+ message: string;
5
6
  error: string;
6
7
  helperText: string;
7
8
  }
@@ -10,12 +11,26 @@ export interface FormFieldProps {
10
11
  name: string;
11
12
  /** Field label rendered above the input. */
12
13
  label?: ReactNode;
13
- /** Helper text rendered below the input (replaced by error message when invalid). */
14
+ /** Helper/description text rendered below the input. Stays visible when invalid — the error message appears above it, between the input and this text. */
14
15
  helperText?: ReactNode;
15
16
  /** Renders a `*` indicator next to the label. Does NOT add validation rules — pass those in `rules`. */
16
17
  required?: boolean;
17
18
  /** react-hook-form `useController` rules (e.g., `{ required: 'msg', minLength: { value: 8, message: '…' } }`). */
18
19
  rules?: Record<string, unknown>;
20
+ /**
21
+ * Reserve vertical space (after the description) for the validation message so
22
+ * the field's outer height does not change when an error appears/disappears.
23
+ * - `true`: reserve one line via the `--crk-field-error-min-height` token.
24
+ * - `false`: no reservation — an error pushes content down (legacy behaviour).
25
+ * - `number`: reserved height in pixels.
26
+ * - `string`: any CSS length (e.g. `'2.5em'` to fit two lines).
27
+ *
28
+ * Multi-line messages are allowed to grow past the reserved height. When unset,
29
+ * inherits the `reserveErrorSpace` set on the enclosing `<Form>`, falling back
30
+ * to `true`.
31
+ * @default inherited from `<Form>`, else `true`
32
+ */
33
+ reserveErrorSpace?: boolean | number | string;
19
34
  /** Override class names on internal elements. */
20
35
  classes?: Partial<FormFieldClasses>;
21
36
  className?: string;
@@ -41,4 +56,4 @@ export interface FormFieldProps {
41
56
  * <Input type="email" />
42
57
  * </FormField>
43
58
  */
44
- export declare function FormField({ name, label, helperText, required, rules, classes, className, style, children, }: FormFieldProps): import("react/jsx-runtime").JSX.Element;
59
+ export declare function FormField({ name, label, helperText, required, rules, reserveErrorSpace, classes, className, style, children, }: FormFieldProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { FormFieldClasses } from './FormField';
2
+ /** Field-level defaults supplied by `<Form>` and inherited by every `<FormField>`. */
3
+ export interface FormFieldDefaults {
4
+ /** Default `reserveErrorSpace` for fields that don't set their own. */
5
+ reserveErrorSpace?: boolean | number | string;
6
+ /** Default `classes` merged into every field (each field's own `classes` are also applied). */
7
+ classes?: Partial<FormFieldClasses>;
8
+ }
9
+ export declare const FormFieldDefaultsContext: import('react').Context<FormFieldDefaults | null>;
@@ -9,10 +9,20 @@ export interface LinkProps extends ComponentPropsWithRef<'a'> {
9
9
  * @default false
10
10
  */
11
11
  external?: boolean;
12
+ /**
13
+ * Hide the inline "opens in a new tab" icon that is shown by default whenever
14
+ * the link opens in a new tab (`external` or `target="_blank"`). The
15
+ * screen-reader suffix is kept regardless. @default false
16
+ */
17
+ hideExternalIcon?: boolean;
12
18
  }
13
19
  /**
14
20
  * Styled `<a>` with variants and safe external-link defaults.
15
21
  *
22
+ * When the link opens in a new tab (via `external`, or a manual
23
+ * `target="_blank"`), a small inline ↗ icon is appended to the right so users
24
+ * know the link leaves the current tab. Pass `hideExternalIcon` to suppress it.
25
+ *
16
26
  * @example <Link href="https://example.com" external>Docs</Link>
17
27
  */
18
28
  export declare const Link: import('react').ForwardRefExoticComponent<Omit<LinkProps, "ref"> & import('react').RefAttributes<HTMLAnchorElement>>;
@@ -26,6 +26,13 @@ export interface PopoverProps {
26
26
  content: React.ReactNode;
27
27
  /** Side of the trigger to anchor the panel to. @default 'bottom' */
28
28
  position?: 'top' | 'bottom' | 'left' | 'right';
29
+ /**
30
+ * Keep the panel inside the viewport: flip to the opposite side when the
31
+ * requested one would overflow, and shift along the cross-axis so the panel
32
+ * stays fully visible near a screen edge. The requested `position` is honored
33
+ * whenever it fits. @default true
34
+ */
35
+ autoPosition?: boolean;
29
36
  /** Controlled open state. */
30
37
  open?: boolean;
31
38
  /** Called when the open state changes. */
@@ -0,0 +1,83 @@
1
+ import { CSSProperties, ReactNode } from 'react';
2
+ export interface CheckboxGroupOption {
3
+ /** Value added to / removed from the selected array. */
4
+ value: string;
5
+ /** Visible label (checkbox row label, or chip body). */
6
+ label: ReactNode;
7
+ /** Non-interactive when true. */
8
+ disabled?: boolean;
9
+ }
10
+ export interface CheckboxGroupClasses {
11
+ group: string;
12
+ /** Each rendered option (a `Checkbox` row, or a chip button). */
13
+ option: string;
14
+ /** The chip button (only in `variant="chips"`). */
15
+ chip: string;
16
+ /** Applied to a chip when its value is selected. */
17
+ chipSelected: string;
18
+ }
19
+ export interface CheckboxGroupProps {
20
+ /** Selectable options. */
21
+ options: CheckboxGroupOption[];
22
+ /** Controlled array of selected values. @default [] */
23
+ value?: string[];
24
+ /** Fires with the next selected array whenever an option is toggled. */
25
+ onChange?: (values: string[]) => void;
26
+ /**
27
+ * Rendering style:
28
+ * - `'checkbox'` (default): a list of themed checkboxes.
29
+ * - `'chips'`: toggleable chips laid out one after another.
30
+ * @default 'checkbox'
31
+ */
32
+ variant?: 'checkbox' | 'chips';
33
+ /**
34
+ * Chip corner style (`variant="chips"` only):
35
+ * - `'pill'`: fully rounded (default).
36
+ * - `'rounded'`: medium radius.
37
+ * - `'square'`: small radius — more rectangular.
38
+ * @default 'pill'
39
+ */
40
+ chipShape?: 'pill' | 'rounded' | 'square';
41
+ /**
42
+ * Show a leading checkmark on selected chips (`variant="chips"` only). The
43
+ * chip's total width stays constant: when checked, the horizontal padding
44
+ * shrinks on both sides by exactly half the mark+gap width, so the freed
45
+ * space fits the mark and the centered content does not jump. @default true
46
+ */
47
+ chipCheckmark?: boolean;
48
+ /**
49
+ * Layout direction. `'horizontal'` wraps options onto multiple lines.
50
+ * Defaults to `'vertical'` for `checkbox` and `'horizontal'` for `chips`.
51
+ */
52
+ orientation?: 'vertical' | 'horizontal';
53
+ /** Disables every option. */
54
+ disabled?: boolean;
55
+ /** Apply error styling (e.g. when a required group is empty). @default false */
56
+ error?: boolean;
57
+ /** Shared `name` for the underlying checkboxes (checkbox variant). */
58
+ name?: string;
59
+ /** Override class names on internal elements. */
60
+ classes?: Partial<CheckboxGroupClasses>;
61
+ className?: string;
62
+ style?: CSSProperties;
63
+ id?: string;
64
+ /** Accessible label. Use this OR `aria-labelledby`. */
65
+ 'aria-label'?: string;
66
+ 'aria-labelledby'?: string;
67
+ 'aria-describedby'?: string;
68
+ 'aria-invalid'?: boolean | 'true' | 'false';
69
+ /** Marks the group as required (`aria-required`). */
70
+ required?: boolean;
71
+ }
72
+ /**
73
+ * Multi-value selection from a fixed set of options. Renders either a list of
74
+ * checkboxes (default) or a row of toggleable chips. Works standalone or inside
75
+ * a `<FormField>` (its `value: string[]` / `onChange(values)` bridge directly).
76
+ *
77
+ * @example Checkboxes:
78
+ * <CheckboxGroup options={topics} value={picked} onChange={setPicked} aria-label="Topics" />
79
+ *
80
+ * @example Chips:
81
+ * <CheckboxGroup variant="chips" options={tags} value={picked} onChange={setPicked} aria-label="Tags" />
82
+ */
83
+ export declare const CheckboxGroup: import('react').ForwardRefExoticComponent<CheckboxGroupProps & import('react').RefAttributes<HTMLDivElement>>;
@@ -1,2 +1,4 @@
1
1
  export { Checkbox } from './Checkbox';
2
2
  export type { CheckboxProps, CheckboxClasses } from './Checkbox';
3
+ export { CheckboxGroup } from './CheckboxGroup';
4
+ export type { CheckboxGroupProps, CheckboxGroupClasses, CheckboxGroupOption, } from './CheckboxGroup';