@gusnips/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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/dist/api-client.d.ts +139 -0
  4. package/dist/api-client.d.ts.map +1 -0
  5. package/dist/api-client.js +133 -0
  6. package/dist/api-client.js.map +1 -0
  7. package/dist/api-error.d.ts +45 -0
  8. package/dist/api-error.d.ts.map +1 -0
  9. package/dist/api-error.js +52 -0
  10. package/dist/api-error.js.map +1 -0
  11. package/dist/auth-store.d.ts +49 -0
  12. package/dist/auth-store.d.ts.map +1 -0
  13. package/dist/auth-store.js +27 -0
  14. package/dist/auth-store.js.map +1 -0
  15. package/dist/chunk-reload.d.ts +61 -0
  16. package/dist/chunk-reload.d.ts.map +1 -0
  17. package/dist/chunk-reload.js +92 -0
  18. package/dist/chunk-reload.js.map +1 -0
  19. package/dist/cn.d.ts +13 -0
  20. package/dist/cn.d.ts.map +1 -0
  21. package/dist/cn.js +16 -0
  22. package/dist/cn.js.map +1 -0
  23. package/dist/describe-error.d.ts +74 -0
  24. package/dist/describe-error.d.ts.map +1 -0
  25. package/dist/describe-error.js +70 -0
  26. package/dist/describe-error.js.map +1 -0
  27. package/dist/error-boundary.d.ts +61 -0
  28. package/dist/error-boundary.d.ts.map +1 -0
  29. package/dist/error-boundary.js +32 -0
  30. package/dist/error-boundary.js.map +1 -0
  31. package/dist/guards.d.ts +65 -0
  32. package/dist/guards.d.ts.map +1 -0
  33. package/dist/guards.js +52 -0
  34. package/dist/guards.js.map +1 -0
  35. package/dist/hydrate.d.ts +30 -0
  36. package/dist/hydrate.d.ts.map +1 -0
  37. package/dist/hydrate.js +25 -0
  38. package/dist/hydrate.js.map +1 -0
  39. package/dist/i18n.d.ts +70 -0
  40. package/dist/i18n.d.ts.map +1 -0
  41. package/dist/i18n.js +66 -0
  42. package/dist/i18n.js.map +1 -0
  43. package/dist/index.d.ts +12 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +31 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/prerender-contract.d.ts +30 -0
  48. package/dist/prerender-contract.d.ts.map +1 -0
  49. package/dist/prerender-contract.js +30 -0
  50. package/dist/prerender-contract.js.map +1 -0
  51. package/dist/query.d.ts +28 -0
  52. package/dist/query.d.ts.map +1 -0
  53. package/dist/query.js +51 -0
  54. package/dist/query.js.map +1 -0
  55. package/dist/sse.d.ts +64 -0
  56. package/dist/sse.d.ts.map +1 -0
  57. package/dist/sse.js +105 -0
  58. package/dist/sse.js.map +1 -0
  59. package/dist/states.d.ts +69 -0
  60. package/dist/states.d.ts.map +1 -0
  61. package/dist/states.js +2 -0
  62. package/dist/states.js.map +1 -0
  63. package/dist/ui/class-name.d.ts +16 -0
  64. package/dist/ui/class-name.d.ts.map +1 -0
  65. package/dist/ui/class-name.js +13 -0
  66. package/dist/ui/class-name.js.map +1 -0
  67. package/dist/ui/combobox.d.ts +81 -0
  68. package/dist/ui/combobox.d.ts.map +1 -0
  69. package/dist/ui/combobox.js +70 -0
  70. package/dist/ui/combobox.js.map +1 -0
  71. package/dist/ui/dialog.d.ts +39 -0
  72. package/dist/ui/dialog.d.ts.map +1 -0
  73. package/dist/ui/dialog.js +32 -0
  74. package/dist/ui/dialog.js.map +1 -0
  75. package/dist/ui/drawer.d.ts +63 -0
  76. package/dist/ui/drawer.d.ts.map +1 -0
  77. package/dist/ui/drawer.js +55 -0
  78. package/dist/ui/drawer.js.map +1 -0
  79. package/dist/ui/glyphs.d.ts +4 -0
  80. package/dist/ui/glyphs.d.ts.map +1 -0
  81. package/dist/ui/glyphs.js +20 -0
  82. package/dist/ui/glyphs.js.map +1 -0
  83. package/dist/ui/index.d.ts +9 -0
  84. package/dist/ui/index.d.ts.map +1 -0
  85. package/dist/ui/index.js +48 -0
  86. package/dist/ui/index.js.map +1 -0
  87. package/dist/ui/input.d.ts +45 -0
  88. package/dist/ui/input.d.ts.map +1 -0
  89. package/dist/ui/input.js +108 -0
  90. package/dist/ui/input.js.map +1 -0
  91. package/dist/ui/menu.d.ts +70 -0
  92. package/dist/ui/menu.d.ts.map +1 -0
  93. package/dist/ui/menu.js +85 -0
  94. package/dist/ui/menu.js.map +1 -0
  95. package/dist/ui/select.d.ts +69 -0
  96. package/dist/ui/select.d.ts.map +1 -0
  97. package/dist/ui/select.js +48 -0
  98. package/dist/ui/select.js.map +1 -0
  99. package/dist/ui/tabs.d.ts +52 -0
  100. package/dist/ui/tabs.d.ts.map +1 -0
  101. package/dist/ui/tabs.js +62 -0
  102. package/dist/ui/tabs.js.map +1 -0
  103. package/package.json +124 -0
  104. package/src/api-client.test.ts +256 -0
  105. package/src/api-client.ts +305 -0
  106. package/src/api-error.ts +60 -0
  107. package/src/auth-store.ts +59 -0
  108. package/src/chunk-reload.test.ts +46 -0
  109. package/src/chunk-reload.ts +94 -0
  110. package/src/cn.ts +16 -0
  111. package/src/describe-error.test.ts +102 -0
  112. package/src/describe-error.ts +140 -0
  113. package/src/error-boundary.tsx +88 -0
  114. package/src/guards.tsx +99 -0
  115. package/src/hydrate.ts +36 -0
  116. package/src/i18n.test.ts +68 -0
  117. package/src/i18n.ts +101 -0
  118. package/src/index.ts +63 -0
  119. package/src/prerender-contract.ts +31 -0
  120. package/src/query.test.ts +54 -0
  121. package/src/query.ts +79 -0
  122. package/src/sse.test.ts +50 -0
  123. package/src/sse.ts +117 -0
  124. package/src/states.ts +71 -0
  125. package/src/ui/class-name.ts +23 -0
  126. package/src/ui/combobox.test.ts +32 -0
  127. package/src/ui/combobox.tsx +242 -0
  128. package/src/ui/dialog.tsx +87 -0
  129. package/src/ui/drawer.tsx +161 -0
  130. package/src/ui/focus.test.ts +82 -0
  131. package/src/ui/glyphs.tsx +35 -0
  132. package/src/ui/index.ts +145 -0
  133. package/src/ui/input.test.ts +22 -0
  134. package/src/ui/input.tsx +181 -0
  135. package/src/ui/menu.test.ts +73 -0
  136. package/src/ui/menu.tsx +186 -0
  137. package/src/ui/select.tsx +191 -0
  138. package/src/ui/tabs.tsx +95 -0
@@ -0,0 +1,191 @@
1
+ import { Select as Primitive } from "@base-ui/react/select";
2
+ import type { ReactNode } from "react";
3
+ import { mergeClassName, type StateClassName } from "./class-name.ts";
4
+ import { CheckGlyph, ChevronGlyph } from "./glyphs.tsx";
5
+
6
+ // The parts, for a select this wrapper's option list cannot express — groups, separators,
7
+ // a scrollable list with arrows.
8
+ export const SelectRoot = Primitive.Root;
9
+ export const SelectTrigger = Primitive.Trigger;
10
+ export const SelectValue = Primitive.Value;
11
+ export const SelectIcon = Primitive.Icon;
12
+ export const SelectPortal = Primitive.Portal;
13
+ export const SelectPositioner = Primitive.Positioner;
14
+ export const SelectPopup = Primitive.Popup;
15
+ export const SelectList = Primitive.List;
16
+ export const SelectItem = Primitive.Item;
17
+ export const SelectItemIndicator = Primitive.ItemIndicator;
18
+ export const SelectItemText = Primitive.ItemText;
19
+ export const SelectGroup = Primitive.Group;
20
+ export const SelectGroupLabel = Primitive.GroupLabel;
21
+ export const SelectSeparator = Primitive.Separator;
22
+
23
+ export interface SelectOption<T extends string = string> {
24
+ value: T;
25
+ label: string;
26
+ disabled?: boolean;
27
+ icon?: ReactNode;
28
+ }
29
+
30
+ export type SelectProps<T extends string = string> = Omit<
31
+ Primitive.Root.Props<T, false>,
32
+ "children" | "items" | "defaultValue" | "multiple"
33
+ > & {
34
+ options?: readonly SelectOption<T>[];
35
+ /** Shown while nothing is selected. No default — a default would be English. */
36
+ placeholder?: ReactNode;
37
+ /**
38
+ * Shown instead of the list when `options` is empty. Required, because a popup that opens
39
+ * on nothing is a dead end: this is the line that says why it is empty.
40
+ */
41
+ emptyLabel: ReactNode;
42
+ /**
43
+ * Label of an entry that puts the field back to nothing selected — for an optional
44
+ * question, where "I'd rather not say" has to stay reachable after the first answer.
45
+ * Rendered only once there is a value to take back; picking it calls `onValueChange(null)`.
46
+ */
47
+ clearLabel?: string;
48
+ /** Accessible name for the trigger, when no visible label points at it. */
49
+ label?: string;
50
+ /** Mark that says "this opens". */
51
+ chevron?: ReactNode;
52
+ /** Mark next to the selected option. */
53
+ indicator?: ReactNode;
54
+ /** Classes for the trigger — the button you see when the popup is closed. */
55
+ className?: StateClassName<Primitive.Trigger.State>;
56
+ popupClassName?: StateClassName<Primitive.Popup.State>;
57
+ triggerProps?: Primitive.Trigger.Props;
58
+ /**
59
+ * Positioner props — `align`, `side`, `sideOffset`, `collisionPadding`, and the stacking
60
+ * order. The default `z-[60]` puts the list above the overlay layer (z-50: dialog, drawer);
61
+ * raise it with `positionerProps={{ className: "z-[80]" }}` if the app stacks something
62
+ * higher than a dialog above it.
63
+ */
64
+ positionerProps?: Primitive.Positioner.Props;
65
+ };
66
+
67
+ /**
68
+ * A single-choice field. Trigger, portal, positioner, popup and list composed once; `name`,
69
+ * `form`, `required`, `id`, `disabled` and every other root prop pass straight through to
70
+ * Base UI's hidden input, so this submits inside a `<form>` like a native `<select>`.
71
+ *
72
+ * Controlled only, and that is the point of `value ?? null`: React and Base UI both read
73
+ * `value={undefined}` as "uncontrolled", so a caller holding `T | undefined` in state would
74
+ * silently hand the field back to the primitive the moment the value cleared, and every
75
+ * later render would be ignored. `null` says "nothing selected" out loud. `defaultValue` is
76
+ * not accepted here for the same reason — use the parts for an uncontrolled select.
77
+ */
78
+ export function Select<T extends string = string>({
79
+ value,
80
+ options = [],
81
+ placeholder,
82
+ emptyLabel,
83
+ clearLabel,
84
+ label,
85
+ chevron,
86
+ indicator,
87
+ className,
88
+ popupClassName,
89
+ triggerProps,
90
+ positionerProps,
91
+ ...rest
92
+ }: SelectProps<T>) {
93
+ const selected = options.find((option) => option.value === value);
94
+ // `undefined` when nothing is selected, and it has to be exactly that. Base UI's Value part
95
+ // falls back to `placeholder` only when it is handed NO children at all — an empty fragment
96
+ // still counts as children, which is how the donor ended up with a select that showed a
97
+ // blank trigger instead of "Choose one".
98
+ const selectedLabel = selected ? (
99
+ <>
100
+ {selected.icon ? (
101
+ <span className="mr-2 inline-flex align-middle">{selected.icon}</span>
102
+ ) : null}
103
+ {selected.label}
104
+ </>
105
+ ) : undefined;
106
+
107
+ return (
108
+ <Primitive.Root<T, false> value={value ?? null} items={options} {...rest}>
109
+ <Primitive.Trigger
110
+ aria-label={label}
111
+ {...triggerProps}
112
+ className={mergeClassName(
113
+ // No focus style: the trigger keeps the app's one `:focus-visible` outline. Swapping
114
+ // it for a `ring-*` loses forced-colors mode, where a box-shadow is forced to none.
115
+ "flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-left text-foreground disabled:pointer-events-none disabled:opacity-50",
116
+ className,
117
+ )}
118
+ >
119
+ <Primitive.Value
120
+ placeholder={placeholder}
121
+ className="min-w-0 flex-1 truncate data-placeholder:text-muted-foreground"
122
+ >
123
+ {selectedLabel}
124
+ </Primitive.Value>
125
+ <Primitive.Icon className="shrink-0 text-muted-foreground">
126
+ {chevron ?? <ChevronGlyph />}
127
+ </Primitive.Icon>
128
+ </Primitive.Trigger>
129
+ <Primitive.Portal>
130
+ {/* Above the overlay layer (z-50: dialog, drawer). A select opened inside a dialog
131
+ portals to the body just like the dialog does, so nesting decides nothing and z
132
+ decides everything — one step lower and the list paints behind the panel that
133
+ anchors it. */}
134
+ <Primitive.Positioner
135
+ sideOffset={4}
136
+ {...positionerProps}
137
+ className={mergeClassName(
138
+ "z-[60] max-w-[var(--available-width)] outline-none",
139
+ positionerProps?.className,
140
+ )}
141
+ >
142
+ <Primitive.Popup
143
+ className={mergeClassName(
144
+ "min-w-[var(--anchor-width)] origin-[var(--transform-origin)] overflow-hidden border border-border bg-popover text-popover-foreground outline-none transition-[scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
145
+ popupClassName,
146
+ )}
147
+ >
148
+ <Primitive.List className="max-h-[var(--available-height)] overflow-y-auto">
149
+ {clearLabel !== undefined && value != null ? (
150
+ // An Item with `value={null}`, not a button above the list: only an item is
151
+ // reachable by arrow keys and typeahead, and only an item closes the popup
152
+ // on its own. `null` is what arrives at `onValueChange`.
153
+ <Primitive.Item
154
+ value={null}
155
+ className="flex cursor-default items-center gap-2 px-3 py-2 text-muted-foreground outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
156
+ >
157
+ <span className="size-4 shrink-0" aria-hidden="true" />
158
+ <Primitive.ItemText>{clearLabel}</Primitive.ItemText>
159
+ </Primitive.Item>
160
+ ) : null}
161
+ {options.length === 0 ? (
162
+ <div className="px-3 py-2 text-muted-foreground">{emptyLabel}</div>
163
+ ) : (
164
+ options.map((option) => (
165
+ <Primitive.Item
166
+ key={option.value}
167
+ value={option.value}
168
+ disabled={option.disabled}
169
+ className="flex cursor-default items-center gap-2 px-3 py-2 outline-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-accent data-highlighted:text-accent-foreground"
170
+ >
171
+ {/* The mark keeps a column of its own whether or not it is showing, so
172
+ the labels do not shift by 16px when the selection moves. */}
173
+ <span className="size-4 shrink-0">
174
+ <Primitive.ItemIndicator>
175
+ {indicator ?? <CheckGlyph />}
176
+ </Primitive.ItemIndicator>
177
+ </span>
178
+ {option.icon ? (
179
+ <span className="inline-flex shrink-0">{option.icon}</span>
180
+ ) : null}
181
+ <Primitive.ItemText>{option.label}</Primitive.ItemText>
182
+ </Primitive.Item>
183
+ ))
184
+ )}
185
+ </Primitive.List>
186
+ </Primitive.Popup>
187
+ </Primitive.Positioner>
188
+ </Primitive.Portal>
189
+ </Primitive.Root>
190
+ );
191
+ }
@@ -0,0 +1,95 @@
1
+ import { Tabs as Primitive } from "@base-ui/react/tabs";
2
+ import { cn } from "../cn.ts";
3
+ import { mergeClassName } from "./class-name.ts";
4
+
5
+ // Root and Panel pass straight through: a wrapper around either would add a class string and
6
+ // nothing else, and Base UI already owns arrow-key navigation, activation mode and the
7
+ // tab/panel `aria-controls` wiring.
8
+ export const Tabs = Primitive.Root;
9
+ export const TabPanel = Primitive.Panel;
10
+
11
+ type TabListLabel =
12
+ | { label: string; "aria-labelledby"?: undefined }
13
+ | { label?: undefined; "aria-labelledby": string };
14
+
15
+ export type TabListProps = Omit<Primitive.List.Props, "aria-labelledby"> &
16
+ TabListLabel & {
17
+ /** Classes for the scroll wrapper — the element that owns the sideways scroll. */
18
+ wrapperClassName?: string;
19
+ };
20
+
21
+ /**
22
+ * The rail, and the reason this file exists.
23
+ *
24
+ * It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel
25
+ * below the fold on a phone. But the scroll cannot live on the rail itself: `overflow-x: auto`
26
+ * makes the element a scroll container, a scroll container clips everything its children paint
27
+ * outside the padding box, and a computed `overflow-x` of `auto` forces `overflow-y` from
28
+ * `visible` to `auto` as well. A self-scrolling rail therefore crops the focus ring on all
29
+ * FOUR sides of whichever tab the keyboard is on — top and bottom always, the left of the
30
+ * first tab and the right of the last.
31
+ *
32
+ * So the scroll goes on a wrapper, and the wrapper's padding is the room the ring needs. The
33
+ * number is derived, not inherited: `@gusnips/tokens` draws `outline: 2px solid` at
34
+ * `outline-offset: 2px`, so the ring paints from 2px to 4px beyond a tab's border box, and
35
+ * `p-1` is exactly those 4px. An outline reaches FURTHER than the `ring-2` box-shadow the
36
+ * donor had here, so the wrapper matters more now than it did there, not less.
37
+ *
38
+ * Naming the rail is not optional and not a comment: pass `label`, or point
39
+ * `aria-labelledby` at a heading. A tablist with neither is announced as nothing at all.
40
+ */
41
+ export function TabList({ label, className, wrapperClassName, children, ...rest }: TabListProps) {
42
+ return (
43
+ <div className={cn("w-fit max-w-full overflow-x-auto p-1", wrapperClassName)}>
44
+ <Primitive.List
45
+ aria-label={label}
46
+ {...rest}
47
+ // `relative`, because TabIndicator positions itself against the rail.
48
+ className={mergeClassName("relative flex w-fit", className)}
49
+ >
50
+ {children}
51
+ </Primitive.List>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ // Styled off `aria-selected`, never a parallel `data-active`: the primitive writes both from
57
+ // one state, and styling the accessibility contract is what keeps what a screen reader
58
+ // announces and what an eye sees from ever drifting apart.
59
+ //
60
+ // `shrink-0` and `whitespace-nowrap` are what make the rail scroll instead of squashing — a
61
+ // tab that shrinks to fit is a tab nobody can read, and the scroll above exists precisely so
62
+ // it does not have to.
63
+ const tabClasses =
64
+ "shrink-0 cursor-pointer px-3 py-1.5 whitespace-nowrap text-muted-foreground select-none aria-selected:text-foreground [@media(pointer:coarse)]:min-h-11";
65
+
66
+ export function Tab({ className, ...rest }: Primitive.Tab.Props) {
67
+ return <Primitive.Tab {...rest} className={mergeClassName(tabClasses, className)} />;
68
+ }
69
+
70
+ /**
71
+ * The travelling marker under the active tab — a SEPARATE part, because the two donors
72
+ * disagreed about whether to have one at all and both were right about their own app. Render
73
+ * it or do not; the rail works either way.
74
+ *
75
+ * It must be a child of `TabList`: Base UI measures the active tab against the enclosing
76
+ * `[role="tablist"]` and writes `--active-tab-left` / `--active-tab-width` onto this element.
77
+ *
78
+ * `renderBeforeHydration` is on by default because this package's apps are prerendered — with
79
+ * it off, every prerendered page shows the rail with no marker until React arrives. It writes
80
+ * one inline script to do that; under a strict CSP without Base UI's `CspProvider` the script
81
+ * is blocked and the behaviour falls back to exactly what `false` would have done.
82
+ */
83
+ export function TabIndicator({ className, ...rest }: Primitive.Indicator.Props) {
84
+ return (
85
+ <Primitive.Indicator
86
+ aria-hidden="true"
87
+ renderBeforeHydration
88
+ {...rest}
89
+ className={mergeClassName(
90
+ "absolute bottom-0 left-0 h-0.5 w-[var(--active-tab-width)] translate-x-[var(--active-tab-left)] bg-foreground transition-[translate,width]",
91
+ className,
92
+ )}
93
+ />
94
+ );
95
+ }