@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.
- package/LICENSE +201 -0
- package/NOTICE +18 -0
- package/README.md +699 -0
- package/package.json +95 -0
- package/src/components/DictationControls.tsx +141 -0
- package/src/components/DictationPrompt.tsx +78 -0
- package/src/components/StyledBox.tsx +174 -0
- package/src/components/StyledButton.tsx +144 -0
- package/src/components/StyledCollapsible.tsx +127 -0
- package/src/components/StyledDefinitionList.tsx +134 -0
- package/src/components/StyledFieldset.tsx +157 -0
- package/src/components/StyledFlex.tsx +13 -0
- package/src/components/StyledFooter.tsx +399 -0
- package/src/components/StyledFormLabel.tsx +141 -0
- package/src/components/StyledGrid.tsx +109 -0
- package/src/components/StyledGridItem.tsx +19 -0
- package/src/components/StyledHStack.tsx +145 -0
- package/src/components/StyledHeading.tsx +79 -0
- package/src/components/StyledHrRule.tsx +33 -0
- package/src/components/StyledIcon.tsx +172 -0
- package/src/components/StyledIconButton.tsx +135 -0
- package/src/components/StyledInputBool.tsx +81 -0
- package/src/components/StyledInputRadio.tsx +141 -0
- package/src/components/StyledInputSelect.tsx +115 -0
- package/src/components/StyledInputSlider.tsx +83 -0
- package/src/components/StyledInputText.tsx +146 -0
- package/src/components/StyledInputTextArea.tsx +119 -0
- package/src/components/StyledInputToggle.tsx +224 -0
- package/src/components/StyledList.tsx +188 -0
- package/src/components/StyledScrollbar.tsx +53 -0
- package/src/components/StyledSearch.tsx +78 -0
- package/src/components/StyledSeparator.tsx +38 -0
- package/src/components/StyledSidebar.tsx +555 -0
- package/src/components/StyledSimpleGrid.tsx +99 -0
- package/src/components/StyledSparkLine.tsx +119 -0
- package/src/components/StyledSpinner.tsx +91 -0
- package/src/components/StyledStack.tsx +62 -0
- package/src/components/StyledText.tsx +99 -0
- package/src/components/StyledTooltip.tsx +398 -0
- package/src/components/StyledVStack.tsx +143 -0
- package/src/components/TitleLogo.tsx +223 -0
- package/src/components/create-icon.tsx +66 -0
- package/src/components/create-intent-button.tsx +134 -0
- package/src/components/dictation.ts +71 -0
- package/src/components/intent-buttons.ts +154 -0
- package/src/config/can-hover.ts +75 -0
- package/src/config/density.ts +138 -0
- package/src/config/font-size.ts +113 -0
- package/src/config/intent-icons.tsx +116 -0
- package/src/config/logger.ts +60 -0
- package/src/config/style-config.tsx +263 -0
- package/src/config/types.ts +137 -0
- package/src/index.ts +259 -0
- package/src/preset/index.ts +243 -0
- package/src/preset/recipes/arrows.ts +29 -0
- package/src/preset/recipes/box.ts +122 -0
- package/src/preset/recipes/button.ts +161 -0
- package/src/preset/recipes/dl-list.ts +109 -0
- package/src/preset/recipes/drawer.ts +125 -0
- package/src/preset/recipes/form.ts +95 -0
- package/src/preset/recipes/icon-button.ts +161 -0
- package/src/preset/recipes/icon.ts +34 -0
- package/src/preset/recipes/input-bool.ts +184 -0
- package/src/preset/recipes/input-dropdown.ts +93 -0
- package/src/preset/recipes/input-radio.ts +158 -0
- package/src/preset/recipes/input-surface.ts +152 -0
- package/src/preset/recipes/input-text.ts +17 -0
- package/src/preset/recipes/list.ts +196 -0
- package/src/preset/recipes/menu.ts +28 -0
- package/src/preset/recipes/separator.ts +89 -0
- package/src/preset/recipes/stack.ts +89 -0
- package/src/preset/recipes/striped.ts +34 -0
- package/src/preset/recipes/text.ts +41 -0
- package/src/preset/recipes/tooltip.ts +77 -0
- 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 };
|