@hackcove/ui 0.2.2 → 0.2.4

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 (47) hide show
  1. package/README.md +183 -4
  2. package/dist/AppShell.d.ts +37 -0
  3. package/dist/AppShell.js +61 -0
  4. package/dist/Avatar.d.ts +9 -4
  5. package/dist/Avatar.js +9 -3
  6. package/dist/BulletList.d.ts +3 -0
  7. package/dist/BulletList.js +6 -0
  8. package/dist/Code.d.ts +3 -0
  9. package/dist/Code.js +6 -0
  10. package/dist/ColourSchemeToggle.d.ts +7 -0
  11. package/dist/ColourSchemeToggle.js +25 -0
  12. package/dist/Container.d.ts +12 -0
  13. package/dist/Container.js +9 -0
  14. package/dist/DateCalendar.d.ts +4 -0
  15. package/dist/DateCalendar.js +40 -0
  16. package/dist/DateField.d.ts +49 -0
  17. package/dist/DateField.js +77 -0
  18. package/dist/DatePicker.d.ts +8 -0
  19. package/dist/DatePicker.js +27 -0
  20. package/dist/Divider.d.ts +3 -0
  21. package/dist/Divider.js +6 -0
  22. package/dist/InlineLinks.d.ts +11 -0
  23. package/dist/InlineLinks.js +11 -0
  24. package/dist/Markdown.js +1 -1
  25. package/dist/PageHeader.d.ts +19 -0
  26. package/dist/PageHeader.js +11 -0
  27. package/dist/PageLayouts.d.ts +73 -0
  28. package/dist/PageLayouts.js +63 -0
  29. package/dist/Stack.d.ts +30 -0
  30. package/dist/Stack.js +28 -0
  31. package/dist/TextLink.d.ts +12 -0
  32. package/dist/TextLink.js +8 -0
  33. package/dist/Video.d.ts +10 -0
  34. package/dist/Video.js +10 -0
  35. package/dist/colourScheme.d.ts +12 -0
  36. package/dist/colourScheme.js +40 -0
  37. package/dist/index.d.ts +18 -1
  38. package/dist/index.js +16 -0
  39. package/dist/isoDate.d.ts +5 -0
  40. package/dist/isoDate.js +16 -0
  41. package/dist/shownDate.d.ts +45 -0
  42. package/dist/shownDate.js +88 -0
  43. package/dist/typography.d.ts +32 -24
  44. package/dist/typography.js +39 -9
  45. package/dist/ui.d.ts +1 -0
  46. package/dist/ui.js +3 -1
  47. package/package.json +24 -1
@@ -0,0 +1,45 @@
1
+ import type { CalendarDate } from "@internationalized/date";
2
+ import { type FocusEvent, type ReactElement } from "react";
3
+ import type { DateFieldProps, Read } from "./DateField.js";
4
+ type ValueKeys = "value" | "defaultValue" | "onChange" | "minValue" | "maxValue" | "isRequired";
5
+ /** The value props, each of which may be passed as `undefined`. */
6
+ type ValueProps = {
7
+ [K in ValueKeys]?: DateFieldProps[K] | undefined;
8
+ };
9
+ /**
10
+ * The value the field reports is always what it shows: the typed date when it is complete, exists
11
+ * and is within min/max, else `null`. Two parts of React Aria disagree with that, so this owns both:
12
+ *
13
+ * - **The value.** React Aria keeps its last valid date behind an impossible or partly cleared
14
+ * display, and reports an out-of-range one. So the consumer's `onChange` is fired from what the
15
+ * segments read, once per change: a valid date turning invalid reports `null`. React Aria itself
16
+ * is controlled by `inner`, so a parent holding that `null` does not wipe the typed segments; a
17
+ * `value` the parent sets that differs from the last report is passed through, and not reported
18
+ * back. Because React Aria's own value goes stale, `validate` checks `isRequired` against the
19
+ * segments: a partly filled required field blocks the form.
20
+ * - **Keeping an impossible date.** When focus leaves a complete field, React Aria moves an
21
+ * impossible day to the month's last and reports that date (`confirmPlaceholder`); it skips this
22
+ * for a read-only field. So while the typed date is impossible, the field is read-only for exactly
23
+ * the blur that leaves it: `onBlurCapture` on a wrapper of the input turns it on before React
24
+ * Aria's blur handler runs, and the field's own `onBlur`, which runs after it, turns it off again.
25
+ *
26
+ * `shown` (`YYYY-MM-DD` or `""`) is what the hidden `name` input posts.
27
+ */
28
+ export declare function useShownDate(props: ValueProps): {
29
+ inner: CalendarDate | null;
30
+ setInner: import("react").Dispatch<import("react").SetStateAction<CalendarDate | null>>;
31
+ shown: string;
32
+ impossible: string | null;
33
+ onRead: (next: Read) => void;
34
+ validate: () => "Enter the whole date." | null;
35
+ leaving: boolean;
36
+ onBlurCapture: (event: FocusEvent<HTMLElement>) => void;
37
+ onLeft: () => void;
38
+ };
39
+ /** The hidden input a form posts under `name`: the shown date as `YYYY-MM-DD`, or `""`. */
40
+ export declare function ShownInput({ name, shown, isDisabled, }: {
41
+ name: string | undefined;
42
+ shown: string;
43
+ isDisabled: boolean | undefined;
44
+ }): ReactElement | null;
45
+ export {};
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState, } from "react";
3
+ import { flushSync } from "react-dom";
4
+ import { fromISODate, toISODate } from "./isoDate.js";
5
+ /** `iso` when it is a date within min/max, else `""`. */
6
+ function withinRange(iso, { minValue, maxValue }) {
7
+ const date = fromISODate(iso);
8
+ if (!date)
9
+ return "";
10
+ if (minValue && date.compare(minValue) < 0)
11
+ return "";
12
+ if (maxValue && date.compare(maxValue) > 0)
13
+ return "";
14
+ return iso;
15
+ }
16
+ /**
17
+ * The value the field reports is always what it shows: the typed date when it is complete, exists
18
+ * and is within min/max, else `null`. Two parts of React Aria disagree with that, so this owns both:
19
+ *
20
+ * - **The value.** React Aria keeps its last valid date behind an impossible or partly cleared
21
+ * display, and reports an out-of-range one. So the consumer's `onChange` is fired from what the
22
+ * segments read, once per change: a valid date turning invalid reports `null`. React Aria itself
23
+ * is controlled by `inner`, so a parent holding that `null` does not wipe the typed segments; a
24
+ * `value` the parent sets that differs from the last report is passed through, and not reported
25
+ * back. Because React Aria's own value goes stale, `validate` checks `isRequired` against the
26
+ * segments: a partly filled required field blocks the form.
27
+ * - **Keeping an impossible date.** When focus leaves a complete field, React Aria moves an
28
+ * impossible day to the month's last and reports that date (`confirmPlaceholder`); it skips this
29
+ * for a read-only field. So while the typed date is impossible, the field is read-only for exactly
30
+ * the blur that leaves it: `onBlurCapture` on a wrapper of the input turns it on before React
31
+ * Aria's blur handler runs, and the field's own `onBlur`, which runs after it, turns it off again.
32
+ *
33
+ * `shown` (`YYYY-MM-DD` or `""`) is what the hidden `name` input posts.
34
+ */
35
+ export function useShownDate(props) {
36
+ const { value, defaultValue, onChange, isRequired } = props;
37
+ const [inner, setInner] = useState(value ?? defaultValue ?? null);
38
+ const [read, setRead] = useState({
39
+ typed: toISODate(inner),
40
+ impossible: null,
41
+ partial: false,
42
+ });
43
+ const [leaving, setLeaving] = useState(false);
44
+ const shown = withinRange(read.typed, props);
45
+ const reported = useRef(shown);
46
+ const report = useRef(onChange);
47
+ useEffect(() => {
48
+ report.current = onChange;
49
+ });
50
+ const given = value === undefined ? undefined : toISODate(value);
51
+ const givenShown = withinRange(given ?? "", props);
52
+ const lastGiven = useRef(given);
53
+ useEffect(() => {
54
+ if (given === lastGiven.current)
55
+ return;
56
+ lastGiven.current = given;
57
+ if (given === undefined || given === reported.current)
58
+ return;
59
+ reported.current = givenShown;
60
+ setInner(fromISODate(given));
61
+ }, [given, givenShown]);
62
+ const onRead = useCallback((next) => setRead(next), []);
63
+ useEffect(() => {
64
+ if (shown === reported.current)
65
+ return;
66
+ reported.current = shown;
67
+ report.current?.(fromISODate(shown));
68
+ }, [shown]);
69
+ return {
70
+ inner,
71
+ setInner,
72
+ shown,
73
+ impossible: read.impossible,
74
+ onRead,
75
+ validate: () => (isRequired && read.partial ? "Enter the whole date." : null),
76
+ leaving,
77
+ onBlurCapture: (event) => {
78
+ if (read.impossible && !event.currentTarget.contains(event.relatedTarget)) {
79
+ flushSync(() => setLeaving(true));
80
+ }
81
+ },
82
+ onLeft: () => setLeaving(false),
83
+ };
84
+ }
85
+ /** The hidden input a form posts under `name`: the shown date as `YYYY-MM-DD`, or `""`. */
86
+ export function ShownInput({ name, shown, isDisabled, }) {
87
+ return name ? _jsx("input", { type: "hidden", name: name, value: shown, disabled: isDisabled }) : null;
88
+ }
@@ -1,4 +1,4 @@
1
- import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
1
+ import type { ComponentPropsWithoutRef, ElementType, ReactElement, ReactNode } from "react";
2
2
  export type TypographyColor = "default" | "secondary" | "tertiary" | "muted" | "brand";
3
3
  /** Own props shared by every typography component; the rest follow the rendered element. */
4
4
  type OwnProps = {
@@ -19,57 +19,65 @@ export type TypographyProps<E extends ElementType> = OwnProps & {
19
19
  * overriding PageHeading's size.
20
20
  */
21
21
  export declare const Display: {
22
- <E extends ElementType = "h1">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
22
+ <E extends ElementType = "h1">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
23
23
  displayName: string;
24
24
  };
25
25
  /** The supporting sentence under a `Display`. `p`, text-lg/7 normal, secondary. */
26
26
  export declare const Lead: {
27
- <E extends ElementType = "p">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
27
+ <E extends ElementType = "p">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
28
28
  displayName: string;
29
29
  };
30
30
  /** Page title. `h1`, text-2xl/8 semibold, primary text. */
31
31
  export declare const PageHeading: {
32
- <E extends ElementType = "h1">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
33
- displayName: string;
34
- };
35
- /** Top-level section header. `h2`, text-base/6 semibold, primary text. */
36
- export declare const SectionHeading: {
37
- <E extends ElementType = "h2">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
38
- displayName: string;
39
- };
40
- /** Nested header. `h3`, text-sm/5 semibold, primary text. */
41
- export declare const SubsectionHeading: {
42
- <E extends ElementType = "h3">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
32
+ <E extends ElementType = "h1">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
43
33
  displayName: string;
44
34
  };
35
+ /**
36
+ * Top-level section header. `h2`, text-base/6 semibold, primary text. `rule` sets
37
+ * a hairline under it (the `Divider` line colour) with the space either side of
38
+ * the line built in.
39
+ */
40
+ export declare function SectionHeading<E extends ElementType = "h2">({ rule, ...props }: TypographyProps<E> & {
41
+ rule?: boolean;
42
+ }): ReactElement;
43
+ export type SubsectionHeadingSize = "sm" | "base" | "lg";
44
+ /**
45
+ * Nested header. `h3`, text-sm/5 semibold, primary text. `size` steps it up for
46
+ * a card, drawer or form-group title: `sm` (default), `base` (bold) or `lg`.
47
+ */
48
+ export declare function SubsectionHeading<E extends ElementType = "h3">({ size, ...props }: TypographyProps<E> & {
49
+ size?: SubsectionHeadingSize;
50
+ }): ReactElement;
45
51
  /** Dialog/modal title. `h2`, text-base/6 semibold, primary text. */
46
52
  export declare const ModalHeading: {
47
- <E extends ElementType = "h2">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
53
+ <E extends ElementType = "h2">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
48
54
  displayName: string;
49
55
  };
50
56
  /** Supporting line under a heading. `p`, text-sm/5 normal, secondary. */
51
57
  export declare const Subtitle: {
52
- <E extends ElementType = "p">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
58
+ <E extends ElementType = "p">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
53
59
  displayName: string;
54
60
  };
55
61
  /** Small all-caps label above a group. `span`, text-xs semibold uppercase, tertiary. */
56
62
  export declare const Overline: {
57
- <E extends ElementType = "span">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
58
- displayName: string;
59
- };
60
- /** Default running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary. */
61
- export declare const Body: {
62
- <E extends ElementType = "span">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
63
+ <E extends ElementType = "span">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
63
64
  displayName: string;
64
65
  };
66
+ /**
67
+ * Default running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary.
68
+ * `preserveNewlines` keeps the line breaks in the text (a user-typed note); runs of spaces still collapse.
69
+ */
70
+ export declare function Body<E extends ElementType = "span">({ preserveNewlines, ...props }: TypographyProps<E> & {
71
+ preserveNewlines?: boolean;
72
+ }): ReactElement;
65
73
  /** Emphasised run of body text. `span`, text-sm/5 medium, primary text. */
66
74
  export declare const BodyStrong: {
67
- <E extends ElementType = "span">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
75
+ <E extends ElementType = "span">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
68
76
  displayName: string;
69
77
  };
70
78
  /** Fine print / metadata. `span` (inline; `as="p"` for a block), text-xs/4 normal, tertiary. */
71
79
  export declare const Caption: {
72
- <E extends ElementType = "span">({ as, color, className, children, ...rest }: TypographyProps<E>): import("react").JSX.Element;
80
+ <E extends ElementType = "span">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
73
81
  displayName: string;
74
82
  };
75
83
  export {};
@@ -15,13 +15,20 @@ const COLOR = {
15
15
  * layout classes — never the pinned size/weight.
16
16
  */
17
17
  function typography(displayName, defaultAs, variantClass, defaultColor) {
18
- function Typography({ as, color, className, children, ...rest }) {
19
- const Tag = as ?? defaultAs;
20
- return (_jsx(Tag, { className: cx(variantClass, COLOR[color ?? defaultColor], className), ...rest, children: children }));
18
+ function Typography(props) {
19
+ return pinned(defaultAs, variantClass, defaultColor, props);
21
20
  }
22
21
  Typography.displayName = displayName;
23
22
  return Typography;
24
23
  }
24
+ /**
25
+ * The one place a pinned role renders. The few roles with an opt-in prop of their own are
26
+ * written by hand, not by `typography`, and call this too.
27
+ */
28
+ function pinned(defaultAs, variantClass, defaultColor, { as, color, className, children, ...rest }) {
29
+ const Tag = as ?? defaultAs;
30
+ return (_jsx(Tag, { className: cx(variantClass, COLOR[color ?? defaultColor], className), ...rest, children: children }));
31
+ }
25
32
  // --- Structural roles (block by default) -----------------------------------
26
33
  /**
27
34
  * Marketing-scale headline — the one line at the top of a public page. `h1`,
@@ -35,10 +42,27 @@ export const Display = typography("Display", "h1", "text-4xl/[1.1] font-semibold
35
42
  export const Lead = typography("Lead", "p", "text-lg/7 font-normal sm:text-xl/8", "secondary");
36
43
  /** Page title. `h1`, text-2xl/8 semibold, primary text. */
37
44
  export const PageHeading = typography("PageHeading", "h1", "text-2xl/8 font-semibold tracking-tight", "default");
38
- /** Top-level section header. `h2`, text-base/6 semibold, primary text. */
39
- export const SectionHeading = typography("SectionHeading", "h2", "text-base/6 font-semibold", "default");
40
- /** Nested header. `h3`, text-sm/5 semibold, primary text. */
41
- export const SubsectionHeading = typography("SubsectionHeading", "h3", "text-sm/5 font-semibold", "default");
45
+ /**
46
+ * Top-level section header. `h2`, text-base/6 semibold, primary text. `rule` sets
47
+ * a hairline under it (the `Divider` line colour) with the space either side of
48
+ * the line built in.
49
+ */
50
+ export function SectionHeading({ rule, ...props }) {
51
+ const variant = cx(rule && "mb-4 border-b border-slate-200/70 pb-2", "text-base/6 font-semibold");
52
+ return pinned("h2", variant, "default", props);
53
+ }
54
+ const SUBSECTION_SIZE = {
55
+ sm: "text-sm/5 font-semibold",
56
+ base: "text-base/6 font-bold",
57
+ lg: "text-lg/7 font-semibold",
58
+ };
59
+ /**
60
+ * Nested header. `h3`, text-sm/5 semibold, primary text. `size` steps it up for
61
+ * a card, drawer or form-group title: `sm` (default), `base` (bold) or `lg`.
62
+ */
63
+ export function SubsectionHeading({ size = "sm", ...props }) {
64
+ return pinned("h3", SUBSECTION_SIZE[size], "default", props);
65
+ }
42
66
  /** Dialog/modal title. `h2`, text-base/6 semibold, primary text. */
43
67
  export const ModalHeading = typography("ModalHeading", "h2", "text-base/6 font-semibold", "default");
44
68
  /** Supporting line under a heading. `p`, text-sm/5 normal, secondary. */
@@ -46,8 +70,14 @@ export const Subtitle = typography("Subtitle", "p", "text-sm/5 font-normal", "se
46
70
  // --- Inline roles (inline by default) --------------------------------------
47
71
  /** Small all-caps label above a group. `span`, text-xs semibold uppercase, tertiary. */
48
72
  export const Overline = typography("Overline", "span", "text-xs font-semibold uppercase tracking-wide", "tertiary");
49
- /** Default running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary. */
50
- export const Body = typography("Body", "span", "text-sm/5 font-normal", "secondary");
73
+ /**
74
+ * Default running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary.
75
+ * `preserveNewlines` keeps the line breaks in the text (a user-typed note); runs of spaces still collapse.
76
+ */
77
+ export function Body({ preserveNewlines, ...props }) {
78
+ const variant = cx(preserveNewlines && "whitespace-pre-line", "text-sm/5 font-normal");
79
+ return pinned("span", variant, "secondary", props);
80
+ }
51
81
  /** Emphasised run of body text. `span`, text-sm/5 medium, primary text. */
52
82
  export const BodyStrong = typography("BodyStrong", "span", "text-sm/5 font-medium", "default");
53
83
  /** Fine print / metadata. `span` (inline; `as="p"` for a block), text-xs/4 normal, tertiary. */
package/dist/ui.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ /** @deprecated use PageHeader */
2
3
  export declare function PageTitle({ title, subtitle }: {
3
4
  title: string;
4
5
  subtitle?: string;
package/dist/ui.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "./cx.js";
3
+ import { PageHeaderContent } from "./PageHeader.js";
3
4
  // Small presentational primitives written against Tailwind's slate-based class vocabulary
4
5
  // (remapped to Catppuccin Mocha in dark mode by theme.css).
6
+ /** @deprecated use PageHeader */
5
7
  export function PageTitle({ title, subtitle }) {
6
- return (_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight text-slate-900", children: title }), subtitle && _jsx("p", { className: "mt-1 text-sm text-slate-500", children: subtitle })] }));
8
+ return _jsx(PageHeaderContent, { title: title, subtitle: subtitle });
7
9
  }
8
10
  /** A bordered panel with an optional title and count; `as` picks the element (default `section`). */
9
11
  export function Card({ title, count, as: Tag = "section", className, children, }) {
package/package.json CHANGED
@@ -1,8 +1,26 @@
1
1
  {
2
2
  "name": "@hackcove/ui",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a @hackcove/theme-* package.",
5
+ "keywords": [
6
+ "components",
7
+ "design-system",
8
+ "hackcove",
9
+ "markdown",
10
+ "react",
11
+ "tailwind",
12
+ "tailwindcss",
13
+ "ui"
14
+ ],
15
+ "homepage": "https://github.com/madangryscientist/hackcove/tree/main/packages/ui#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/madangryscientist/hackcove/issues"
18
+ },
5
19
  "license": "MIT",
20
+ "author": {
21
+ "name": "Jacobus Langner",
22
+ "url": "https://github.com/madangryscientist"
23
+ },
6
24
  "repository": {
7
25
  "type": "git",
8
26
  "url": "git+https://github.com/madangryscientist/hackcove.git",
@@ -29,6 +47,10 @@
29
47
  "pack-check": "npm pack --dry-run",
30
48
  "prepack": "bun run build"
31
49
  },
50
+ "dependencies": {
51
+ "@internationalized/date": "^3.12.4",
52
+ "react-aria-components": "^1.22.1"
53
+ },
32
54
  "devDependencies": {
33
55
  "@types/bun": "1.4.3",
34
56
  "@types/react": "^19.3.0",
@@ -41,6 +63,7 @@
41
63
  },
42
64
  "peerDependencies": {
43
65
  "react": ">=18",
66
+ "react-dom": ">=18",
44
67
  "tailwindcss": "^4"
45
68
  },
46
69
  "nx": {