@stonedogcode/style 0.9.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 (75) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +699 -0
  4. package/package.json +95 -0
  5. package/src/components/DictationControls.tsx +141 -0
  6. package/src/components/DictationPrompt.tsx +78 -0
  7. package/src/components/StyledBox.tsx +174 -0
  8. package/src/components/StyledButton.tsx +144 -0
  9. package/src/components/StyledCollapsible.tsx +127 -0
  10. package/src/components/StyledDefinitionList.tsx +134 -0
  11. package/src/components/StyledFieldset.tsx +157 -0
  12. package/src/components/StyledFlex.tsx +13 -0
  13. package/src/components/StyledFooter.tsx +399 -0
  14. package/src/components/StyledFormLabel.tsx +141 -0
  15. package/src/components/StyledGrid.tsx +109 -0
  16. package/src/components/StyledGridItem.tsx +19 -0
  17. package/src/components/StyledHStack.tsx +145 -0
  18. package/src/components/StyledHeading.tsx +79 -0
  19. package/src/components/StyledHrRule.tsx +33 -0
  20. package/src/components/StyledIcon.tsx +172 -0
  21. package/src/components/StyledIconButton.tsx +135 -0
  22. package/src/components/StyledInputBool.tsx +81 -0
  23. package/src/components/StyledInputRadio.tsx +141 -0
  24. package/src/components/StyledInputSelect.tsx +115 -0
  25. package/src/components/StyledInputSlider.tsx +83 -0
  26. package/src/components/StyledInputText.tsx +146 -0
  27. package/src/components/StyledInputTextArea.tsx +119 -0
  28. package/src/components/StyledInputToggle.tsx +224 -0
  29. package/src/components/StyledList.tsx +188 -0
  30. package/src/components/StyledScrollbar.tsx +53 -0
  31. package/src/components/StyledSearch.tsx +78 -0
  32. package/src/components/StyledSeparator.tsx +38 -0
  33. package/src/components/StyledSidebar.tsx +555 -0
  34. package/src/components/StyledSimpleGrid.tsx +99 -0
  35. package/src/components/StyledSparkLine.tsx +119 -0
  36. package/src/components/StyledSpinner.tsx +91 -0
  37. package/src/components/StyledStack.tsx +62 -0
  38. package/src/components/StyledText.tsx +99 -0
  39. package/src/components/StyledTooltip.tsx +398 -0
  40. package/src/components/StyledVStack.tsx +143 -0
  41. package/src/components/TitleLogo.tsx +223 -0
  42. package/src/components/create-icon.tsx +66 -0
  43. package/src/components/create-intent-button.tsx +134 -0
  44. package/src/components/dictation.ts +71 -0
  45. package/src/components/intent-buttons.ts +154 -0
  46. package/src/config/can-hover.ts +75 -0
  47. package/src/config/density.ts +138 -0
  48. package/src/config/font-size.ts +113 -0
  49. package/src/config/intent-icons.tsx +116 -0
  50. package/src/config/logger.ts +60 -0
  51. package/src/config/style-config.tsx +263 -0
  52. package/src/config/types.ts +137 -0
  53. package/src/index.ts +259 -0
  54. package/src/preset/index.ts +243 -0
  55. package/src/preset/recipes/arrows.ts +29 -0
  56. package/src/preset/recipes/box.ts +122 -0
  57. package/src/preset/recipes/button.ts +161 -0
  58. package/src/preset/recipes/dl-list.ts +109 -0
  59. package/src/preset/recipes/drawer.ts +125 -0
  60. package/src/preset/recipes/form.ts +95 -0
  61. package/src/preset/recipes/icon-button.ts +161 -0
  62. package/src/preset/recipes/icon.ts +34 -0
  63. package/src/preset/recipes/input-bool.ts +184 -0
  64. package/src/preset/recipes/input-dropdown.ts +93 -0
  65. package/src/preset/recipes/input-radio.ts +158 -0
  66. package/src/preset/recipes/input-surface.ts +152 -0
  67. package/src/preset/recipes/input-text.ts +17 -0
  68. package/src/preset/recipes/list.ts +196 -0
  69. package/src/preset/recipes/menu.ts +28 -0
  70. package/src/preset/recipes/separator.ts +89 -0
  71. package/src/preset/recipes/stack.ts +89 -0
  72. package/src/preset/recipes/striped.ts +34 -0
  73. package/src/preset/recipes/text.ts +41 -0
  74. package/src/preset/recipes/tooltip.ts +77 -0
  75. package/src/preset/semantic-variables.ts +283 -0
@@ -0,0 +1,555 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useState } from "react";
4
+ import { styled } from "styled-system/jsx";
5
+ import StyledBox from "./StyledBox";
6
+ import StyledText from "./StyledText";
7
+ import StyledVStack from "./StyledVStack";
8
+ import StyledHStack from "./StyledHStack";
9
+ import StyledFlex from "./StyledFlex";
10
+ import StyledScrollbar from "./StyledScrollbar";
11
+ import StyledTooltip from "./StyledTooltip";
12
+
13
+ /**
14
+ * Tool navigation for applications whose readers are often elderly and
15
+ * sometimes cognitively impaired. See `docs/prd/PRD-0001-styled-sidebar.md`
16
+ * for the reasoning behind each of these choices — this file implements it.
17
+ *
18
+ * The short version of the contract:
19
+ *
20
+ * **This component renders what it is given.** It does not sort, does not
21
+ * filter, and owns no search field. `items` arrive already ordered and
22
+ * already filtered, so ordering is a host preference and search is a host
23
+ * concern — which is also what lets a host supply a dictation-capable input
24
+ * this package could never depend on.
25
+ *
26
+ * Every item is an icon *and* a name; there is no icon-only rendering, not
27
+ * even collapsed. Help opens on click, never hover. Nothing anywhere in here
28
+ * changes state on hover.
29
+ *
30
+ * **`iconOnlyWhenCollapsed` is the one documented exception** (PRD §20a), and
31
+ * it is opt-in for exactly that reason. A host that sets it accepts an
32
+ * icon-only rail — the thing §20 rejects — in exchange for the horizontal
33
+ * space. The mitigations that make it defensible are not optional and are
34
+ * enforced below: the button keeps the tool's name as its accessible name, so
35
+ * nothing is lost to assistive technology, and the name and description are
36
+ * reachable as a tooltip. Read `collapsedTooltipTrigger` before assuming a
37
+ * touch reader can see either.
38
+ */
39
+
40
+ /**
41
+ * Minimum interactive size, stated as a `minHeight` on the element's own base
42
+ * rather than left to emerge from padding — so no density step, font profile
43
+ * or zoom level can erode it (PRD §A4).
44
+ *
45
+ * Two floors, not one. 48 is the hard minimum the whole package holds itself
46
+ * to; the PRD asks for 60 "where layout allows", and a full-width item row is
47
+ * exactly where it allows. The narrow rail controls (pager, collapse) sit in a
48
+ * strip beside the list, where 60 would crowd the tools themselves, so they
49
+ * keep the 48 floor.
50
+ */
51
+ const ITEM_MIN_TARGET = "60px";
52
+ const CONTROL_MIN_TARGET = "48px";
53
+
54
+ export interface SidebarItem {
55
+ id: string;
56
+ /** Supplied by the host — this package ships no icons. */
57
+ icon?: React.ReactNode;
58
+ /** The tool's name. Always rendered. */
59
+ label: string;
60
+ /** One line, rendered beneath the name. */
61
+ description?: string;
62
+ /** Revealed by the item's help control, on click. */
63
+ help?: React.ReactNode;
64
+ }
65
+
66
+ export interface StyledSidebarProps {
67
+ /** Already ordered and already filtered by the host. */
68
+ items: SidebarItem[];
69
+ selectedId?: string;
70
+ onSelect: (id: string) => void;
71
+ /** How to handle more items than fit. Default `"scroll"`. */
72
+ overflow?: "scroll" | "paging";
73
+ /** Items per page when `overflow="paging"`. */
74
+ itemsPerPage?: number;
75
+ /** Controlled. Omit to let the component own the state via `defaultCollapsed`. */
76
+ collapsed?: boolean;
77
+ /**
78
+ * Initial collapsed state when uncontrolled. Default `false`.
79
+ *
80
+ * Separate from `collapsed` so a host can say "start collapsed" without
81
+ * taking on the state itself. Supplying `collapsed` wins.
82
+ */
83
+ defaultCollapsed?: boolean;
84
+ onCollapsedChange?: (next: boolean) => void;
85
+ /**
86
+ * Render collapsed items as an icon-only rail, with the name and description
87
+ * moved into a tooltip. Default `false`.
88
+ *
89
+ * **This is the PRD §20a exception and it is off by default deliberately.**
90
+ * §20 rejects an icon-only rail because it reinstates the guess-the-glyph
91
+ * problem the component exists to remove. A host opting in is trading that
92
+ * away for horizontal space, which is a decision only a host can make — the
93
+ * package will not make it for them, and the two other consumers of this
94
+ * component must not inherit it silently.
95
+ *
96
+ * The name is *never* actually lost: collapsed buttons carry it as their
97
+ * accessible name, so a screen reader announces the tool regardless.
98
+ *
99
+ * **On its own this recovers NO horizontal space, and that surprises people.**
100
+ * The sidebar fills whatever width its container gives it; it does not narrow
101
+ * itself, because layout is the host's to own. A host that sets this flag and
102
+ * leaves its rail at its old width has traded away the visible names for
103
+ * nothing — with no build error and nothing to notice. **Narrow the container
104
+ * when collapsed; that is the other half of the bargain.** A component test
105
+ * pins the part this component does owe: it must survive being narrowed.
106
+ */
107
+ iconOnlyWhenCollapsed?: boolean;
108
+ /**
109
+ * How the collapsed item's name/description tooltip opens. Default `"hover"`.
110
+ *
111
+ * `"click"` renders an explicit control instead, and is what a host should
112
+ * pass when its reader has asked for help-on-press — HopperGuard drives this
113
+ * from its `accessibility.clickForTooltips` setting.
114
+ *
115
+ * **It is also the only mode a touch reader can use.** `StyledTooltip` has no
116
+ * touch trigger (hover and focus only), so on a tablet the hover mode reveals
117
+ * nothing: tapping an icon-only item activates it rather than explaining it.
118
+ * A host shipping `iconOnlyWhenCollapsed` to touch devices wants `"click"`.
119
+ */
120
+ collapsedTooltipTrigger?: "hover" | "click";
121
+ /** Rendered when `items` is empty — e.g. "No tools match that search." */
122
+ emptyState?: React.ReactNode;
123
+ /** e.g. "TOOLS". */
124
+ heading?: React.ReactNode;
125
+ /** Names the navigation landmark. */
126
+ "aria-label"?: string;
127
+ }
128
+
129
+ const ItemButton = styled("button", {
130
+ base: {
131
+ display: "flex",
132
+ alignItems: "center",
133
+ gap: "3",
134
+ // `flex: 1` + `minWidth: 0`, not `width: 100%`. The row also holds the help
135
+ // control, and a child that insists on the full width pushes that control
136
+ // off the edge — which at 375px is the whole reason the help exists.
137
+ flex: "1 1 auto",
138
+ minWidth: 0,
139
+ minHeight: ITEM_MIN_TARGET,
140
+ px: "3",
141
+ py: "2",
142
+ textAlign: "left",
143
+ borderRadius: "md",
144
+ // The width is constant across states, so selecting an item cannot change
145
+ // its size and shuffle everything below it. Only the colour moves — and
146
+ // colour is never the only signal (see the label's weight below).
147
+ borderWidth: "2px",
148
+ borderStyle: "solid",
149
+ cursor: "pointer",
150
+ },
151
+ });
152
+
153
+ /**
154
+ * The label column.
155
+ *
156
+ * A plain styled span rather than `StyledVStack`, purely so these two
157
+ * declarations are statically extractable: `minWidth: 0` is what lets a long
158
+ * tool name wrap instead of overflowing the rail (a flex child's default
159
+ * `min-width: auto` refuses to shrink below its longest word), and
160
+ * `overflowWrap` handles the single word longer than the rail (PRD §A3).
161
+ */
162
+ const ItemLabels = styled("span", {
163
+ base: {
164
+ display: "flex",
165
+ flexDirection: "column",
166
+ alignItems: "flex-start",
167
+ minWidth: 0,
168
+ overflowWrap: "anywhere",
169
+ },
170
+ });
171
+
172
+ /** Keeps a host's icon from being squashed by a long label. */
173
+ const ItemIcon = styled("span", {
174
+ base: { display: "inline-flex", flexShrink: 0 },
175
+ });
176
+
177
+ /** Keeps the help control at its full size when the label is long. */
178
+ const HelpSlot = styled("span", {
179
+ base: { display: "inline-flex", flexShrink: 0 },
180
+ });
181
+
182
+ const PagerButton = styled("button", {
183
+ base: {
184
+ minWidth: CONTROL_MIN_TARGET,
185
+ minHeight: CONTROL_MIN_TARGET,
186
+ px: "3",
187
+ borderRadius: "md",
188
+ borderWidth: "1px",
189
+ borderStyle: "solid",
190
+ borderColor: "borderBgPrimary",
191
+ color: "textPrimary",
192
+ cursor: "pointer",
193
+ _disabled: { opacity: 0.5, cursor: "not-allowed" },
194
+ },
195
+ });
196
+
197
+ const CollapseButton = styled("button", {
198
+ base: {
199
+ minWidth: CONTROL_MIN_TARGET,
200
+ minHeight: CONTROL_MIN_TARGET,
201
+ px: "2",
202
+ borderRadius: "md",
203
+ borderWidth: "1px",
204
+ borderStyle: "solid",
205
+ borderColor: "borderBgPrimary",
206
+ color: "textPrimary",
207
+ cursor: "pointer",
208
+ },
209
+ });
210
+
211
+ const StyledSidebar: React.FC<StyledSidebarProps> = ({
212
+ items,
213
+ selectedId,
214
+ onSelect,
215
+ overflow = "scroll",
216
+ itemsPerPage = 8,
217
+ collapsed,
218
+ defaultCollapsed,
219
+ onCollapsedChange,
220
+ iconOnlyWhenCollapsed = false,
221
+ collapsedTooltipTrigger = "hover",
222
+ emptyState,
223
+ heading,
224
+ "aria-label": ariaLabel = "Tools",
225
+ }) => {
226
+ const [page, setPage] = useState(0);
227
+
228
+ // Controlled when `collapsed` is supplied, uncontrolled otherwise. The
229
+ // internal state moves either way — see the toggle below.
230
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(defaultCollapsed ?? false);
231
+ const isCollapsed = collapsed !== undefined ? collapsed : uncontrolledCollapsed;
232
+
233
+ // Whether this sidebar can collapse AT ALL, which is a different question
234
+ // from whether it currently is.
235
+ //
236
+ // `defaultCollapsed` is read undefined-vs-absent rather than defaulted to
237
+ // `false`, because those two mean different things here: a host that says
238
+ // nothing must keep the sidebar it already has. Treating "uncontrolled" as
239
+ // "collapsible" would grow a collapse control on optima-filings and
240
+ // optima-cloud-saas, neither of which asked for one and neither of which
241
+ // would see a build error — the silent-default hazard CLAUDE.md describes.
242
+ const canCollapse = onCollapsedChange !== undefined || defaultCollapsed !== undefined;
243
+
244
+ const toggleCollapsed = () => {
245
+ const next = !isCollapsed;
246
+ if (collapsed === undefined) setUncontrolledCollapsed(next);
247
+ onCollapsedChange?.(next);
248
+ };
249
+
250
+ // The rail is icon-only only when BOTH are true. Keeping this one derived
251
+ // value rather than testing the pair at each site is what stops a later edit
252
+ // dropping the icon while leaving the label suppressed, which would render an
253
+ // item with nothing in it at all.
254
+ const iconOnly = isCollapsed && iconOnlyWhenCollapsed;
255
+
256
+ // Paging resets whenever the item set changes. Without this a host that
257
+ // narrows `items` (a search) leaves the reader on a page that no longer
258
+ // exists — the single most likely bug in this component (PRD §D16).
259
+ const itemsKey = items.map((i) => i.id).join("|");
260
+ const lastKey = useRef(itemsKey);
261
+ useEffect(() => {
262
+ if (lastKey.current !== itemsKey) {
263
+ lastKey.current = itemsKey;
264
+ setPage(0);
265
+ }
266
+ }, [itemsKey]);
267
+
268
+ const paging = overflow === "paging";
269
+ const pageCount = paging ? Math.max(1, Math.ceil(items.length / itemsPerPage)) : 1;
270
+ const safePage = Math.min(page, pageCount - 1);
271
+ const visible = paging
272
+ ? items.slice(safePage * itemsPerPage, safePage * itemsPerPage + itemsPerPage)
273
+ : items;
274
+
275
+ const list = (
276
+ <StyledVStack gap={1} alignItems="stretch" role="list" data-testid="sidebar-items">
277
+ {visible.map((item) => {
278
+ const isSelected = item.id === selectedId;
279
+
280
+ const button = (
281
+ <ItemButton
282
+ type="button"
283
+ onClick={() => onSelect(item.id)}
284
+ // Selection is announced, not just drawn.
285
+ aria-current={isSelected ? "true" : undefined}
286
+ // The name survives the icon-only rail. Without this the button's
287
+ // accessible name is whatever the host's icon happens to expose —
288
+ // usually nothing — so a screen reader announces "button" and the
289
+ // rail becomes unusable rather than merely terse. This is the
290
+ // mitigation that makes PRD §20a defensible; it is not optional.
291
+ aria-label={iconOnly ? item.label : undefined}
292
+ data-testid={`sidebar-item-${item.id}`}
293
+ data-icon-only={iconOnly ? "true" : undefined}
294
+ data-selected={isSelected ? "true" : undefined}
295
+ // Tokens, as ternaries on Panda style props — never an inline
296
+ // `style={{ background: "var(--colors-…)" }}`. A literal custom
297
+ // property in a component bypasses the token layer, which is the
298
+ // only thing that re-points under a consumer's `cssVarPrefix`,
299
+ // and it also never reaches the stylesheet so nothing can grep
300
+ // for it. This is the package's oldest defect class (CLAUDE.md,
301
+ // "Token compliance").
302
+ borderColor={isSelected ? "borderBgAccent" : "transparent"}
303
+ background={isSelected ? "boxBgAccent" : "transparent"}
304
+ // Icon-only: centre the glyph and hold the target size.
305
+ //
306
+ // `minWidth: 0` on the base is what lets a long label wrap
307
+ // instead of overflowing — correct when there IS a label, and
308
+ // exactly wrong without one, because the button then shrinks to
309
+ // the glyph and the tap target quietly falls under the floor.
310
+ // The horizontal padding goes too: at a 72px rail, 12px either
311
+ // side is a fifth of the width spent on nothing.
312
+ minWidth={iconOnly ? CONTROL_MIN_TARGET : 0}
313
+ justifyContent={iconOnly ? "center" : "flex-start"}
314
+ px={iconOnly ? "1" : "3"}
315
+ >
316
+ {item.icon !== undefined && item.icon !== null && <ItemIcon>{item.icon}</ItemIcon>}
317
+ {/* The labels are dropped entirely only under the §20a opt-in.
318
+ Plain `collapsed` still renders the name and drops just the
319
+ description, which is what §20 asks for and remains the
320
+ default for every host that says nothing. */}
321
+ {!iconOnly && (
322
+ <ItemLabels>
323
+ <StyledText
324
+ // Weight, not just colour. Selection must survive greyscale,
325
+ // a high-contrast theme and colour blindness (PRD §C10) —
326
+ // and `aria-current` carries it to assistive technology.
327
+ fontWeight={isSelected ? "bold" : "normal"}
328
+ color={isSelected ? "textAccent" : "textPrimary"}
329
+ >
330
+ {item.label}
331
+ </StyledText>
332
+ {!isCollapsed && item.description && (
333
+ // `size`, not `fontSize`: StyledText writes its resolved size
334
+ // into an inline `style`, which beats any class a `fontSize`
335
+ // prop would generate. The prop looked right and did nothing.
336
+ <StyledText size="sm" color={isSelected ? "textAccent" : "textSecondary"}>
337
+ {item.description}
338
+ </StyledText>
339
+ )}
340
+ </ItemLabels>
341
+ )}
342
+ </ItemButton>
343
+ );
344
+
345
+ return (
346
+ <StyledHStack key={item.id} gap={1} alignItems="center" role="listitem">
347
+ {iconOnly ? (
348
+ // The name and description are what the rail just took away, so
349
+ // this tooltip is not decoration — it is the only place a sighted
350
+ // reader can recover them without expanding.
351
+ //
352
+ // `tooltip` carries both, and the description is omitted rather
353
+ // than rendered empty when the host did not supply one: a panel
354
+ // containing a name the button already announces is worse than no
355
+ // panel, because it teaches the reader that pressing help wastes
356
+ // their time.
357
+ <StyledTooltip
358
+ tooltip={
359
+ item.description ? (
360
+ <>
361
+ <StyledText fontWeight="bold">{item.label}</StyledText>
362
+ <StyledText size="sm">{item.description}</StyledText>
363
+ </>
364
+ ) : (
365
+ <StyledText fontWeight="bold">{item.label}</StyledText>
366
+ )
367
+ }
368
+ trigger={collapsedTooltipTrigger}
369
+ helpLabel={`What does ${item.label} do?`}
370
+ // `sidebar-tooltip-`, NOT `sidebar-item-tooltip-`: the latter
371
+ // prefix-matches the `/^sidebar-item-/` selector the component
372
+ // tests already use to enumerate rows, silently doubling the
373
+ // count. A testid that shadows another is a trap for whoever
374
+ // writes the next query.
375
+ data-testid={`sidebar-tooltip-${item.id}`}
376
+ >
377
+ {button}
378
+ </StyledTooltip>
379
+ ) : (
380
+ button
381
+ )}
382
+
383
+ {item.help && (
384
+ // trigger="click" — a drifting pointer must not spawn this, nor
385
+ // dismiss one being read. Unconditionally click even when the
386
+ // name/description tooltip above is on hover: this one is the
387
+ // longer explanation, and it is the one a reader dwells on.
388
+ <HelpSlot>
389
+ <StyledTooltip tooltip={item.help} trigger="click" helpLabel={`What does ${item.label} do?`}>
390
+ <span />
391
+ </StyledTooltip>
392
+ </HelpSlot>
393
+ )}
394
+ </StyledHStack>
395
+ );
396
+ })}
397
+ </StyledVStack>
398
+ );
399
+
400
+ return (
401
+ // `role="navigation"` rather than `as="nav"`: StyledBox accepts an `as`
402
+ // prop, but it does not survive Panda's styled factory here, so `as="nav"`
403
+ // rendered a plain div and the landmark silently never existed — an
404
+ // aria-label on an unroled div names nothing. Caught by a rendering test
405
+ // in the consuming app, which is exactly the kind of gap a behaviour test
406
+ // sails past.
407
+ <StyledBox
408
+ role="navigation"
409
+ aria-label={ariaLabel}
410
+ data-testid="styled-sidebar"
411
+ borderRightWidth="1px"
412
+ borderColor="borderBgPrimary"
413
+ // Tighter when icon-only. The arithmetic is the whole reason this exists:
414
+ // a 48px target + a ~15px scroll gutter + 2x8px of padding needs 79px,
415
+ // which does not fit the 72px rail the opt-in is for. At 2x4px it needs
416
+ // 71px and does. A host still has to give the rail enough width — this
417
+ // just stops the component spending a fifth of it on its own margins.
418
+ p={iconOnly ? 1 : 2}
419
+ // `height: 100%` + `minHeight: 0` is what makes scroll mode work at all.
420
+ // StyledScrollbar is `flex: 1; min-height: 0; overflow: auto`, which can
421
+ // only produce a scrollbar inside a column whose height is constrained.
422
+ // Against an auto-height parent `height: 100%` computes to auto, so this
423
+ // costs a host that does not constrain the sidebar nothing.
424
+ height="100%"
425
+ minHeight="0"
426
+ noWrap
427
+ >
428
+ <StyledVStack gap={2} alignItems="stretch" height="100%" minHeight="0">
429
+ {/*
430
+ Stacked when icon-only, side-by-side otherwise.
431
+
432
+ Reported from production: at a 72px rail the heading and a 48px
433
+ control cannot share a row, so the control was laid out past the
434
+ rail's right edge and clipped — it measured 118px in a 72px rail.
435
+ `space-between` does not shrink a child below its min-width; it
436
+ overflows, silently and off-screen.
437
+
438
+ Only under the icon-only opt-in, because a 280px rail has room for the
439
+ row and stacking there would cost vertical space for nothing.
440
+ */}
441
+ <StyledFlex
442
+ flexDirection={iconOnly ? "column" : "row"}
443
+ justifyContent={iconOnly ? "flex-start" : "space-between"}
444
+ alignItems={iconOnly ? "stretch" : "center"}
445
+ gap={iconOnly ? 1 : 0}
446
+ >
447
+ {heading && (
448
+ <StyledText fontWeight="bold" size={iconOnly ? "sm" : undefined}>
449
+ {heading}
450
+ </StyledText>
451
+ )}
452
+ {/* Gating on `onCollapsedChange` alone would leave a host that only
453
+ set `defaultCollapsed` with a permanently collapsed rail and no way
454
+ out of it — see `canCollapse` for why it is not simply
455
+ "uncontrolled". */}
456
+ {canCollapse && (
457
+ <CollapseButton
458
+ type="button"
459
+ onClick={toggleCollapsed}
460
+ aria-expanded={!isCollapsed}
461
+ // The name says what pressing it will do, not what state it is in.
462
+ // Under the icon-only opt-in it will reveal the names themselves,
463
+ // not merely the descriptions, so it says so — a reader who cannot
464
+ // identify the glyphs is precisely the one reaching for this.
465
+ aria-label={
466
+ iconOnlyWhenCollapsed
467
+ ? isCollapsed
468
+ ? "Show tool names"
469
+ : "Hide tool names"
470
+ : isCollapsed
471
+ ? "Show tool descriptions"
472
+ : "Hide tool descriptions"
473
+ }
474
+ data-testid="sidebar-collapse"
475
+ >
476
+ {isCollapsed ? "»" : "«"}
477
+ </CollapseButton>
478
+ )}
479
+ </StyledFlex>
480
+
481
+ {items.length === 0 ? (
482
+ // A live region: someone filtering with a screen reader has to learn
483
+ // that nothing matched (PRD §F22).
484
+ <StyledBox role="status" data-testid="sidebar-empty">
485
+ {emptyState}
486
+ </StyledBox>
487
+ ) : paging ? (
488
+ <>
489
+ {list}
490
+ {pageCount > 1 && (
491
+ <StyledHStack gap={2} alignItems="center" justifyContent="space-between">
492
+ <PagerButton
493
+ type="button"
494
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
495
+ disabled={safePage === 0}
496
+ aria-label="Previous page of tools"
497
+ data-testid="sidebar-prev"
498
+ >
499
+
500
+ </PagerButton>
501
+ {/* Position is stated, not implied by a row of dots. */}
502
+ <StyledText size="sm" data-testid="sidebar-page-status">
503
+ Page {safePage + 1} of {pageCount}
504
+ </StyledText>
505
+ <PagerButton
506
+ type="button"
507
+ onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
508
+ disabled={safePage >= pageCount - 1}
509
+ aria-label="Next page of tools"
510
+ data-testid="sidebar-next"
511
+ >
512
+
513
+ </PagerButton>
514
+ </StyledHStack>
515
+ )}
516
+ </>
517
+ ) : (
518
+ <StyledScrollbar
519
+ data-testid="sidebar-scroll"
520
+ // Reserve the scrollbar's width even before it appears, so content
521
+ // is never laid out underneath it and then clipped.
522
+ //
523
+ // This is the reported production defect. `StyledScrollbar` is
524
+ // `scrollbar-width: thick` with 0.5rem of padding, which is not
525
+ // enough clearance on a narrow rail — a desktop scrollbar is
526
+ // ~15-17px. `stable` takes the gutter out of the content box up
527
+ // front, so the layout is the same whether or not the list happens
528
+ // to overflow. Without it a rail is correct until one more tool is
529
+ // added, which is the worst kind of correct.
530
+ //
531
+ // NOT reproducible in this repo's own component tests: headless
532
+ // Chromium uses OVERLAY scrollbars, which occupy zero width, so the
533
+ // clipping cannot occur here at all. The guard asserts the reserved
534
+ // gutter rather than the symptom.
535
+ //
536
+ // INLINE STYLE, not a Panda prop, and both halves of that matter.
537
+ // Panda has no `scrollbar-gutter` utility, so the prop emitted a
538
+ // class (`scr-bar-g_stable`) with no rule behind it — this
539
+ // package's oldest defect class, and it computed as `auto`. Adding
540
+ // a utility to THIS package's Panda config would not help either:
541
+ // every consumer generates its own CSS from its own config, so the
542
+ // rule would exist here and nowhere a consumer could use it. An
543
+ // inline style is the only form that reaches all three products.
544
+ style={{ scrollbarGutter: "stable" }}
545
+ >
546
+ {list}
547
+ </StyledScrollbar>
548
+ )}
549
+ </StyledVStack>
550
+ </StyledBox>
551
+ );
552
+ };
553
+
554
+ export default StyledSidebar;
555
+ export { StyledSidebar };
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect, useCallback } from "react";
4
+ import { styled } from "styled-system/jsx";
5
+ import type { HTMLStyledProps } from "styled-system/types";
6
+
7
+ // Panda CSS breakpoints (in px)
8
+ const BREAKPOINTS: Record<string, number> = {
9
+ base: 0,
10
+ sm: 640,
11
+ md: 768,
12
+ lg: 1024,
13
+ xl: 1280,
14
+ };
15
+
16
+ interface StyledSimpleGridProps extends Omit<HTMLStyledProps<"div">, "columns"> {
17
+ columns?: number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
18
+ gridTemplateRows?: string;
19
+ gap?: string | number;
20
+ }
21
+
22
+ const PandaSimpleGrid = styled("div", {
23
+ base: {
24
+ display: "grid",
25
+ },
26
+ });
27
+
28
+ /**
29
+ * Resolve a responsive columns object to the correct value for the current window width.
30
+ * Walks breakpoints from largest to smallest, returning the first match.
31
+ */
32
+ function resolveResponsiveColumns(
33
+ columns: { base?: number; sm?: number; md?: number; lg?: number; xl?: number },
34
+ windowWidth: number,
35
+ ): number {
36
+ const ordered = ["xl", "lg", "md", "sm", "base"] as const;
37
+ for (const bp of ordered) {
38
+ // Read once and narrow, rather than testing then re-indexing with `!`. The
39
+ // non-null assertion was hiding the fact that BREAKPOINTS[bp] is also an
40
+ // indexed read and equally unchecked.
41
+ const columnsAtBreakpoint = columns[bp];
42
+ const minimumWidth = BREAKPOINTS[bp];
43
+ if (columnsAtBreakpoint !== undefined && minimumWidth !== undefined) {
44
+ if (windowWidth >= minimumWidth) return columnsAtBreakpoint;
45
+ }
46
+ }
47
+ return columns.base ?? 1;
48
+ }
49
+
50
+ const StyledSimpleGrid: React.FC<StyledSimpleGridProps> = ({
51
+ columns,
52
+ gridTemplateRows,
53
+ gap,
54
+ style,
55
+ children,
56
+ ...rest
57
+ }) => {
58
+ const [resolvedCols, setResolvedCols] = useState<number>(() => {
59
+ if (typeof columns === "number") return columns;
60
+ if (typeof columns === "object" && columns !== null) {
61
+ return columns.base ?? 1;
62
+ }
63
+ return 1;
64
+ });
65
+
66
+ const recalculate = useCallback(() => {
67
+ if (typeof columns === "number") {
68
+ setResolvedCols(columns);
69
+ } else if (typeof columns === "object" && columns !== null) {
70
+ setResolvedCols(resolveResponsiveColumns(columns, window.innerWidth));
71
+ }
72
+ }, [columns]);
73
+
74
+ useEffect(() => {
75
+ recalculate();
76
+ window.addEventListener("resize", recalculate);
77
+ return () => window.removeEventListener("resize", recalculate);
78
+ }, [recalculate]);
79
+
80
+ // Runtime-computed grid values MUST use inline style — Panda CSS drops them at build time
81
+ const gridStyles: React.CSSProperties = {
82
+ ...style,
83
+ gridTemplateColumns: `repeat(${resolvedCols}, 1fr)`,
84
+ gridTemplateRows,
85
+ gap,
86
+ };
87
+
88
+ return (
89
+ <PandaSimpleGrid
90
+ {...rest}
91
+ style={gridStyles}
92
+ >
93
+ {children}
94
+ </PandaSimpleGrid>
95
+ );
96
+ };
97
+
98
+ export default StyledSimpleGrid;
99
+ export { StyledSimpleGrid };