@carbonbits/sixr 1.2.0 → 1.4.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 (48) hide show
  1. package/README.md +124 -15
  2. package/dist/chunk-NZHHNVXZ.js +118 -0
  3. package/dist/chunk-NZHHNVXZ.js.map +1 -0
  4. package/dist/email-input.cjs +390 -0
  5. package/dist/email-input.cjs.map +1 -0
  6. package/dist/email-input.d.cts +14 -0
  7. package/dist/email-input.d.ts +14 -0
  8. package/dist/email-input.js +367 -0
  9. package/dist/email-input.js.map +1 -0
  10. package/dist/form.cjs +124 -0
  11. package/dist/form.cjs.map +1 -0
  12. package/dist/form.d.cts +17 -0
  13. package/dist/form.d.ts +17 -0
  14. package/dist/form.js +103 -0
  15. package/dist/form.js.map +1 -0
  16. package/dist/index-DwjxC4l4.d.cts +62 -0
  17. package/dist/index-DwjxC4l4.d.ts +62 -0
  18. package/dist/index.cjs +112 -0
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +48 -9
  21. package/dist/index.d.ts +48 -9
  22. package/dist/index.js +1 -0
  23. package/dist/password-input.cjs +406 -0
  24. package/dist/password-input.cjs.map +1 -0
  25. package/dist/password-input.d.cts +20 -0
  26. package/dist/password-input.d.ts +20 -0
  27. package/dist/password-input.js +385 -0
  28. package/dist/password-input.js.map +1 -0
  29. package/dist/phone-input.cjs +386 -0
  30. package/dist/phone-input.cjs.map +1 -0
  31. package/dist/phone-input.d.cts +12 -0
  32. package/dist/phone-input.d.ts +12 -0
  33. package/dist/phone-input.js +363 -0
  34. package/dist/phone-input.js.map +1 -0
  35. package/dist/styles.css +1 -1
  36. package/dist/text-input.cjs +228 -59
  37. package/dist/text-input.cjs.map +1 -1
  38. package/dist/text-input.d.cts +10 -58
  39. package/dist/text-input.d.ts +10 -58
  40. package/dist/text-input.js +226 -60
  41. package/dist/text-input.js.map +1 -1
  42. package/dist/typography.cjs +120 -0
  43. package/dist/typography.cjs.map +1 -0
  44. package/dist/typography.d.cts +46 -0
  45. package/dist/typography.d.ts +46 -0
  46. package/dist/typography.js +3 -0
  47. package/dist/typography.js.map +1 -0
  48. package/package.json +56 -2
package/README.md CHANGED
@@ -70,43 +70,99 @@ type CartProduct = {
70
70
  Pass an `id` whenever a cart can hold two lines with the same name. Prices are
71
71
  formatted with `Intl.NumberFormat`, and the total is summed from `price`.
72
72
 
73
- ### `TextInput`
73
+ ### `Typography`
74
+
75
+ ```tsx
76
+ import { Typography } from "@carbonbits/sixr/typography";
77
+ ```
78
+
79
+ The type scale. Every piece of text the library renders goes through it, and it is
80
+ the component to reach for when yours does too. Server-safe.
81
+
82
+ | Prop | Type | Default | Description |
83
+ | -------------- | ------------------------------------------------------------------------- | ------------------ | -------------------------------------------------------- |
84
+ | `variant` | `"display" \| "heading" \| "subheading" \| "body" \| "label" \| "hint" \| "code"` | `"body"` | Size, default element and default weight. |
85
+ | `tone` | `"default" \| "muted" \| "subtle" \| "accent" \| "on-accent" \| "success" \| "warning" \| "info" \| "danger"` | `"default"` | Colour role from the design tokens. |
86
+ | `weight` | `"regular" \| "medium" \| "semibold" \| "bold"` | per variant | Overrides the variant's weight. |
87
+ | `align` | `"start" \| "center" \| "end"` | — | Text alignment. |
88
+ | `truncate` | `boolean` | `false` | One line, clipped with an ellipsis. |
89
+ | `icon` | `ReactNode` | — | An icon beside the text, sized to it, `aria-hidden`. |
90
+ | `iconPosition` | `"leading" \| "trailing"` | `"leading"` | Which side the icon sits on. |
91
+ | `as` | `"p" \| "span" \| "div" \| "h1"…"h6" \| "label" \| "code" \| …` | per variant | The element to render. |
92
+ | `asChild` | `boolean` | `false` | Merge the styles onto the child element instead. |
93
+
94
+ Default elements: `display` → `h1`, `heading` → `h2`, `subheading` → `h3`, `body` and
95
+ `hint` → `p`, `label` → `span`, `code` → `code`. Any other HTML attribute passes through.
96
+
97
+ ```tsx
98
+ <Typography variant="heading">Welcome back</Typography>
99
+ <Typography tone="muted">Sign in to your Carbonbits workspace.</Typography>
100
+ <Typography variant="hint" tone="success" icon={<CheckIcon />}>
101
+ Verification code accepted
102
+ </Typography>
103
+
104
+ // Style your own element — a Next.js Link, a Radix Label — with `asChild`.
105
+ <Typography variant="label" tone="accent" asChild>
106
+ <Link href="/reset">Forgot your password?</Link>
107
+ </Typography>
108
+ ```
109
+
110
+ ### Inputs: `TextInput`, `EmailInput`, `PasswordInput`, `PhoneInput`
74
111
 
75
112
  ```tsx
76
113
  import { TextInput } from "@carbonbits/sixr/text-input";
114
+ import { EmailInput } from "@carbonbits/sixr/email-input";
115
+ import { PasswordInput } from "@carbonbits/sixr/password-input";
116
+ import { PhoneInput } from "@carbonbits/sixr/phone-input";
77
117
  ```
78
118
 
79
- A labelled text field with optional schema validation. Interactive, so it ships its
80
- own `"use client"` directive and is reachable only at this subpath — it is not on the
81
- root barrel, which stays server-safe.
119
+ Labelled single-line fields with optional schema validation. Each one is for a single
120
+ kind of value and fixes the input type, keyboard and autofill hints that value needs:
121
+
122
+ | Component | Value | Fixes |
123
+ | --------------- | ---------------- | ---------------------------------------------------------------------- |
124
+ | `TextInput` | Free text | `type="text"`. Adds `mono` for the monospace face (verification codes). |
125
+ | `EmailInput` | An email address | `type="email"`, `inputMode="email"`, `autoComplete="email"`, no autocapitalise or spellcheck. |
126
+ | `PasswordInput` | A secret | `type="password"`, `autoComplete="current-password"`, and a built-in Show/Hide toggle. |
127
+ | `PhoneInput` | A phone number | `type="tel"`, `inputMode="tel"`, `autoComplete="tel"`. |
128
+
129
+ Interactive, so each ships its own `"use client"` directive and is reachable only at
130
+ its subpath — none is on the root barrel, which stays server-safe.
131
+
132
+ They share these props:
82
133
 
83
134
  | Prop | Type | Default | Description |
84
135
  | -------------------- | ------------------------------------ | -------- | ----------------------------------------------------- |
85
136
  | `label` | `string` | — | Field label, associated with the input. |
86
137
  | `hint` | `ReactNode` | — | Guidance below the field. Replaced by an error. |
87
138
  | `error` | `string` | — | Caller-supplied error. Outranks the schema. |
88
- | `schema` | `TextInputSchema` | — | Any zod schema. Only `safeParse` is called. |
139
+ | `schema` | `FieldSchema` | — | Any zod schema. Only `safeParse` is called. |
89
140
  | `validateOn` | `"blur" \| "change" \| "never"` | `"blur"` | When the schema runs. |
90
- | `onValidationChange` | `(state: TextInputValidation) => void` | — | Fires on each validation pass. |
141
+ | `onValidationChange` | `(state: FieldValidation) => void` | — | Fires on each validation pass. |
91
142
  | `onChange` | `(value: string) => void` | — | Receives the value, not the event. |
92
143
  | `value` | `string` | — | Makes the field controlled. |
93
144
  | `defaultValue` | `string` | `""` | Initial value when uncontrolled. |
94
145
  | `optional` | `boolean` | `false` | Shows an "Optional" marker on the label. |
95
146
  | `action` | `ReactNode` | — | Trailing label slot, e.g. a "Forgot?" link. |
96
- | `trailing` | `ReactNode` | — | Trailing in-field slot, e.g. a password peek. |
97
- | `mono` | `boolean` | `false` | Renders the value in the monospace face. |
98
- | `type` | `"text" \| "email" \| "password" \| "tel" \| "url" \| "search"` | `"text"` | Input type. |
147
+ | `trailing` | `ReactNode` | — | Trailing in-field slot. Not on `PasswordInput`, which owns it. |
148
+
149
+ And their own:
99
150
 
100
- Any other `<input>` attribute — `placeholder`, `maxLength`, `disabled`, `inputMode` —
101
- is passed straight through.
151
+ | Component | Prop | Type | Default | Description |
152
+ | --------------- | -------------- | -------------------------------- | -------------------------------- | ------------------------------------ |
153
+ | `TextInput` | `mono` | `boolean` | `false` | Renders the value in the monospace face. |
154
+ | `PasswordInput` | `revealable` | `boolean` | `true` | Shows the Show/Hide toggle. |
155
+ | `PasswordInput` | `revealLabels` | `{ show: string; hide: string }` | `{ show: "Show", hide: "Hide" }` | Toggle text, for localisation. |
156
+
157
+ Any other `<input>` attribute — `placeholder`, `maxLength`, `disabled`, `autoComplete` —
158
+ is passed straight through, and overrides the component's default where there is one.
102
159
 
103
160
  ```tsx
104
- import { TextInput } from "@carbonbits/sixr/text-input";
161
+ import { EmailInput } from "@carbonbits/sixr/email-input";
105
162
  import { emailSchema } from "@carbonbits/sixr/validation";
106
163
 
107
- <TextInput
164
+ <EmailInput
108
165
  label="Work email"
109
- type="email"
110
166
  placeholder="you@company.com"
111
167
  schema={emailSchema}
112
168
  onChange={setEmail}
@@ -119,7 +175,60 @@ is shown, the input gets `aria-invalid`, and the message is announced via `role=
119
175
  and wired up with `aria-describedby`.
120
176
 
121
177
  `schema` is structurally typed, so any zod schema satisfies it without zod appearing
122
- in this package's types — you only need zod installed if you use it.
178
+ in this package's types — you only need zod installed if you use it. The inputs never
179
+ default to a schema for the same reason; pair them with the ready-made ones under
180
+ "Validation schemas".
181
+
182
+ ### `Form` / `FormError` / `SubmitButton`
183
+
184
+ ```tsx
185
+ import { Form, FormError, SubmitButton } from "@carbonbits/sixr/form";
186
+ ```
187
+
188
+ The shared skeleton behind the login/signup screens: a `<form>` that disables its
189
+ fields for the duration of `onSubmit`, plus a submit button that swaps to a spinner
190
+ for the same duration. Interactive, so it ships its own `"use client"` directive and
191
+ is reachable only at this subpath.
192
+
193
+ | Component | Prop | Type | Description |
194
+ | ------------- | -------------- | -------------------------- | --------------------------------------------------------- |
195
+ | `Form` | `onSubmit` | `() => void \| Promise<void>` | Called after `preventDefault`. A returned promise drives the disabled/spinner state until it settles. |
196
+ | `Form` | `children` | `ReactNode` | Fields, `FormError`, `SubmitButton` — whatever the design calls for, in order. |
197
+ | `FormError` | `children` | `ReactNode` | Form-level error (a rejected credential, a failed request). Renders nothing when empty. |
198
+ | `SubmitButton`| `loadingLabel` | `ReactNode` | Shown next to the spinner in place of `children` while submitting. |
199
+
200
+ ```tsx
201
+ import { EmailInput } from "@carbonbits/sixr/email-input";
202
+ import { Form, FormError, SubmitButton } from "@carbonbits/sixr/form";
203
+ import { PasswordInput } from "@carbonbits/sixr/password-input";
204
+
205
+ function LoginForm() {
206
+ const [email, setEmail] = useState("");
207
+ const [password, setPassword] = useState("");
208
+ const [error, setError] = useState<string>();
209
+
210
+ const handleSubmit = async () => {
211
+ setError(undefined);
212
+ const ok = await signIn(email, password);
213
+ if (!ok) setError("That email and password don't match.");
214
+ };
215
+
216
+ return (
217
+ <Form onSubmit={handleSubmit}>
218
+ <EmailInput label="Work email" onChange={setEmail} />
219
+ <PasswordInput label="Password" onChange={setPassword} />
220
+ <FormError>{error}</FormError>
221
+ <SubmitButton loadingLabel="Signing in…">Sign in</SubmitButton>
222
+ </Form>
223
+ );
224
+ }
225
+ ```
226
+
227
+ A field never needs its own `disabled={isLoading}` — `Form` groups its children in a
228
+ fieldset that disables for the duration of `onSubmit`, so `SubmitButton` (and any other
229
+ control inside `Form`) picks that up for free. Fields, OAuth rows, dividers — anything
230
+ the design puts between the top of the form and the submit button — are just children;
231
+ `Form` doesn't assume a shape beyond that.
123
232
 
124
233
  ## Validation schemas
125
234
 
@@ -0,0 +1,118 @@
1
+ import { Slot } from '@radix-ui/react-slot';
2
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
+
4
+ // src/components/typography/index.tsx
5
+ var DEFAULT_ELEMENT = {
6
+ display: "h1",
7
+ heading: "h2",
8
+ subheading: "h3",
9
+ body: "p",
10
+ label: "span",
11
+ hint: "p",
12
+ code: "code"
13
+ };
14
+ var DEFAULT_WEIGHT = {
15
+ display: "semibold",
16
+ heading: "semibold",
17
+ subheading: "medium",
18
+ body: "regular",
19
+ label: "semibold",
20
+ hint: "regular",
21
+ code: "regular"
22
+ };
23
+ var VARIANT_CLASSES = {
24
+ display: "text-display leading-tight tracking-tight",
25
+ heading: "text-heading leading-tight tracking-tight",
26
+ subheading: "text-subheading leading-snug",
27
+ body: "text-field leading-normal",
28
+ label: "text-label leading-normal",
29
+ hint: "text-hint leading-normal",
30
+ code: "font-code text-field leading-normal"
31
+ };
32
+ var TONE_CLASSES = {
33
+ default: "text-fg",
34
+ muted: "text-fg-muted",
35
+ subtle: "text-fg-subtle",
36
+ accent: "text-accent",
37
+ "on-accent": "text-on-accent",
38
+ success: "text-success",
39
+ warning: "text-warning",
40
+ info: "text-info",
41
+ danger: "text-danger"
42
+ };
43
+ var WEIGHT_CLASSES = {
44
+ regular: "font-normal",
45
+ medium: "font-medium",
46
+ semibold: "font-semibold",
47
+ bold: "font-bold"
48
+ };
49
+ var ALIGN_CLASSES = {
50
+ start: "text-start",
51
+ center: "text-center",
52
+ end: "text-end"
53
+ };
54
+ var ICON_CLASSES = "inline-flex shrink-0 items-center [&>svg]:h-[1em] [&>svg]:w-[1em]";
55
+ function typographyClassName({
56
+ variant,
57
+ tone,
58
+ weight,
59
+ align,
60
+ truncate,
61
+ hasIcon,
62
+ className
63
+ }) {
64
+ return [
65
+ VARIANT_CLASSES[variant],
66
+ TONE_CLASSES[tone],
67
+ WEIGHT_CLASSES[weight],
68
+ align ? ALIGN_CLASSES[align] : "",
69
+ hasIcon ? "inline-flex items-center gap-1.5" : "",
70
+ // With an icon the root is a flex container, and text-overflow only
71
+ // clips block text, so the text span carries the truncation instead.
72
+ truncate && !hasIcon ? "truncate" : "",
73
+ className ?? ""
74
+ ].filter(Boolean).join(" ");
75
+ }
76
+ function Typography({
77
+ variant = "body",
78
+ tone = "default",
79
+ weight,
80
+ align,
81
+ truncate = false,
82
+ icon,
83
+ iconPosition = "leading",
84
+ as,
85
+ asChild = false,
86
+ className,
87
+ children,
88
+ ...rest
89
+ }) {
90
+ const hasIcon = icon !== void 0 && icon !== null;
91
+ const Component = asChild ? Slot : as ?? DEFAULT_ELEMENT[variant];
92
+ const iconNode = hasIcon ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: ICON_CLASSES, children: icon }) : null;
93
+ const content = hasIcon ? /* @__PURE__ */ jsxs(Fragment, { children: [
94
+ iconPosition === "leading" ? iconNode : null,
95
+ /* @__PURE__ */ jsx("span", { className: truncate ? "min-w-0 truncate" : void 0, children }),
96
+ iconPosition === "trailing" ? iconNode : null
97
+ ] }) : children;
98
+ return /* @__PURE__ */ jsx(
99
+ Component,
100
+ {
101
+ ...rest,
102
+ className: typographyClassName({
103
+ variant,
104
+ tone,
105
+ weight: weight ?? DEFAULT_WEIGHT[variant],
106
+ align,
107
+ truncate,
108
+ hasIcon,
109
+ className
110
+ }),
111
+ children: content
112
+ }
113
+ );
114
+ }
115
+
116
+ export { Typography };
117
+ //# sourceMappingURL=chunk-NZHHNVXZ.js.map
118
+ //# sourceMappingURL=chunk-NZHHNVXZ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/typography/index.tsx"],"names":[],"mappings":"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","file":"chunk-NZHHNVXZ.js","sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport type { ElementType, HTMLAttributes, ReactNode, Ref } from \"react\";\n\n/**\n * The library's type scale. Everything that renders text — field labels, hints,\n * form errors, headings — goes through here, so a size or colour lives in one\n * place and the tokens in `src/styles/index.css` stay the single source of\n * truth for what \"hint\" or \"muted\" means.\n *\n * Server-safe: no state, no handlers, no directive. It composes Radix `Slot`\n * for `asChild`, which is how a consumer puts these styles on their own\n * element (a `next/link`, a Radix `Label`) without an extra wrapper.\n */\n\nexport type TypographyVariant =\n | \"display\"\n | \"heading\"\n | \"subheading\"\n | \"body\"\n | \"label\"\n | \"hint\"\n | \"code\";\n\nexport type TypographyTone =\n | \"default\"\n | \"muted\"\n | \"subtle\"\n | \"accent\"\n | \"on-accent\"\n | \"success\"\n | \"warning\"\n | \"info\"\n | \"danger\";\n\nexport type TypographyWeight = \"regular\" | \"medium\" | \"semibold\" | \"bold\";\n\nexport type TypographyElement =\n | \"p\"\n | \"span\"\n | \"div\"\n | \"h1\"\n | \"h2\"\n | \"h3\"\n | \"h4\"\n | \"h5\"\n | \"h6\"\n | \"label\"\n | \"code\"\n | \"strong\"\n | \"em\"\n | \"small\";\n\nexport type TypographyProps = Omit<HTMLAttributes<HTMLElement>, \"color\"> & {\n /** Size and default element. `body` unless told otherwise. */\n variant?: TypographyVariant;\n /** Colour role, from the design tokens. */\n tone?: TypographyTone;\n /** Overrides the variant's default weight. */\n weight?: TypographyWeight;\n align?: \"start\" | \"center\" | \"end\";\n /** Clip to one line with an ellipsis. */\n truncate?: boolean;\n /**\n * An icon rendered beside the text, hidden from assistive tech. Sized to the\n * text with `1em`, so the same SVG fits every variant.\n */\n icon?: ReactNode;\n iconPosition?: \"leading\" | \"trailing\";\n /** The element to render. Defaults per variant (`h2` for a heading, and so on). */\n as?: TypographyElement;\n /**\n * Radix-style composition: render the child element itself, with these\n * styles merged onto it, instead of a wrapper. Takes precedence over `as`.\n */\n asChild?: boolean;\n ref?: Ref<HTMLElement>;\n children?: ReactNode;\n};\n\nconst DEFAULT_ELEMENT: Record<TypographyVariant, TypographyElement> = {\n display: \"h1\",\n heading: \"h2\",\n subheading: \"h3\",\n body: \"p\",\n label: \"span\",\n hint: \"p\",\n code: \"code\",\n};\n\nconst DEFAULT_WEIGHT: Record<TypographyVariant, TypographyWeight> = {\n display: \"semibold\",\n heading: \"semibold\",\n subheading: \"medium\",\n body: \"regular\",\n label: \"semibold\",\n hint: \"regular\",\n code: \"regular\",\n};\n\nconst VARIANT_CLASSES: Record<TypographyVariant, string> = {\n display: \"text-display leading-tight tracking-tight\",\n heading: \"text-heading leading-tight tracking-tight\",\n subheading: \"text-subheading leading-snug\",\n body: \"text-field leading-normal\",\n label: \"text-label leading-normal\",\n hint: \"text-hint leading-normal\",\n code: \"font-code text-field leading-normal\",\n};\n\nconst TONE_CLASSES: Record<TypographyTone, string> = {\n default: \"text-fg\",\n muted: \"text-fg-muted\",\n subtle: \"text-fg-subtle\",\n accent: \"text-accent\",\n \"on-accent\": \"text-on-accent\",\n success: \"text-success\",\n warning: \"text-warning\",\n info: \"text-info\",\n danger: \"text-danger\",\n};\n\nconst WEIGHT_CLASSES: Record<TypographyWeight, string> = {\n regular: \"font-normal\",\n medium: \"font-medium\",\n semibold: \"font-semibold\",\n bold: \"font-bold\",\n};\n\nconst ALIGN_CLASSES = {\n start: \"text-start\",\n center: \"text-center\",\n end: \"text-end\",\n} as const;\n\nconst ICON_CLASSES =\n \"inline-flex shrink-0 items-center [&>svg]:h-[1em] [&>svg]:w-[1em]\";\n\nfunction typographyClassName({\n variant,\n tone,\n weight,\n align,\n truncate,\n hasIcon,\n className,\n}: {\n variant: TypographyVariant;\n tone: TypographyTone;\n weight: TypographyWeight;\n align: \"start\" | \"center\" | \"end\" | undefined;\n truncate: boolean;\n hasIcon: boolean;\n className: string | undefined;\n}): string {\n return [\n VARIANT_CLASSES[variant],\n TONE_CLASSES[tone],\n WEIGHT_CLASSES[weight],\n align ? ALIGN_CLASSES[align] : \"\",\n hasIcon ? \"inline-flex items-center gap-1.5\" : \"\",\n // With an icon the root is a flex container, and text-overflow only\n // clips block text, so the text span carries the truncation instead.\n truncate && !hasIcon ? \"truncate\" : \"\",\n className ?? \"\",\n ]\n .filter(Boolean)\n .join(\" \");\n}\n\nexport function Typography({\n variant = \"body\",\n tone = \"default\",\n weight,\n align,\n truncate = false,\n icon,\n iconPosition = \"leading\",\n as,\n asChild = false,\n className,\n children,\n ...rest\n}: TypographyProps) {\n const hasIcon = icon !== undefined && icon !== null;\n // Widened to `ElementType`: the union of intrinsic tags would otherwise\n // demand a `ref` typed to one specific element.\n const Component: ElementType = asChild\n ? Slot\n : (as ?? DEFAULT_ELEMENT[variant]);\n\n const iconNode = hasIcon ? (\n <span aria-hidden=\"true\" className={ICON_CLASSES}>\n {icon}\n </span>\n ) : null;\n\n const content = hasIcon ? (\n <>\n {iconPosition === \"leading\" ? iconNode : null}\n <span className={truncate ? \"min-w-0 truncate\" : undefined}>\n {children}\n </span>\n {iconPosition === \"trailing\" ? iconNode : null}\n </>\n ) : (\n children\n );\n\n return (\n <Component\n {...rest}\n className={typographyClassName({\n variant,\n tone,\n weight: weight ?? DEFAULT_WEIGHT[variant],\n align,\n truncate,\n hasIcon,\n className,\n })}\n >\n {content}\n </Component>\n );\n}\n"]}