@charcuterie/ui 2.7.0 → 2.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/README.md +11 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.d.ts +94 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.d.ts.map +1 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.js +73 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.js.map +1 -0
- package/dist/AdaptiveGrid/chooseColumns.d.ts +206 -0
- package/dist/AdaptiveGrid/chooseColumns.d.ts.map +1 -0
- package/dist/AdaptiveGrid/chooseColumns.js +156 -0
- package/dist/AdaptiveGrid/chooseColumns.js.map +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +155 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.js +153 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -0
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +11 -6
- package/dist/Button/Button.js.map +1 -1
- package/dist/ButtonLink/ButtonLink.d.ts +59 -0
- package/dist/ButtonLink/ButtonLink.d.ts.map +1 -0
- package/dist/ButtonLink/ButtonLink.js +69 -0
- package/dist/ButtonLink/ButtonLink.js.map +1 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +1 -1
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -1
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +1 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/Field/FieldGroup.d.ts.map +1 -1
- package/dist/Field/FieldGroup.js +1 -1
- package/dist/Field/FieldGroup.js.map +1 -1
- package/dist/LogViewer/LogViewer.d.ts.map +1 -1
- package/dist/LogViewer/LogViewer.js +33 -1
- package/dist/LogViewer/LogViewer.js.map +1 -1
- package/dist/RouterLink/AnchorLink.d.ts +17 -0
- package/dist/RouterLink/AnchorLink.d.ts.map +1 -0
- package/dist/RouterLink/AnchorLink.js +16 -0
- package/dist/RouterLink/AnchorLink.js.map +1 -0
- package/dist/RouterLink/RouterLinkProvider.d.ts +13 -0
- package/dist/RouterLink/RouterLinkProvider.d.ts.map +1 -0
- package/dist/RouterLink/RouterLinkProvider.js +47 -0
- package/dist/RouterLink/RouterLinkProvider.js.map +1 -0
- package/dist/RouterLink/routerLink.d.ts +45 -0
- package/dist/RouterLink/routerLink.d.ts.map +1 -0
- package/dist/RouterLink/routerLink.js +31 -0
- package/dist/RouterLink/routerLink.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
- package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
- package/dist/TextLink/TextLink.d.ts +69 -0
- package/dist/TextLink/TextLink.d.ts.map +1 -0
- package/dist/TextLink/TextLink.js +79 -0
- package/dist/TextLink/TextLink.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +5 -1
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/controlStyles.d.ts +36 -1
- package/dist/controlStyles.d.ts.map +1 -1
- package/dist/controlStyles.js +30 -0
- package/dist/controlStyles.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/intentStyles.d.ts +17 -0
- package/dist/intentStyles.d.ts.map +1 -1
- package/dist/intentStyles.js +17 -0
- package/dist/intentStyles.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +56 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/dist/reactRouter/ReactRouterLink.d.ts +23 -0
- package/dist/reactRouter/ReactRouterLink.d.ts.map +1 -0
- package/dist/reactRouter/ReactRouterLink.js +23 -0
- package/dist/reactRouter/ReactRouterLink.js.map +1 -0
- package/dist/reactRouter/index.d.ts +29 -0
- package/dist/reactRouter/index.d.ts.map +1 -0
- package/dist/reactRouter/index.js +29 -0
- package/dist/reactRouter/index.js.map +1 -0
- package/package.json +15 -3
- package/src/AdaptiveGrid/AdaptiveGrid.mdx +169 -0
- package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +240 -0
- package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +243 -0
- package/src/AdaptiveGrid/AdaptiveGrid.tsx +150 -0
- package/src/AdaptiveGrid/chooseColumns.test.ts +253 -0
- package/src/AdaptiveGrid/chooseColumns.ts +270 -0
- package/src/AdaptiveGrid/useAdaptiveColumns.ts +355 -0
- package/src/Button/Button.tsx +10 -22
- package/src/ButtonLink/ButtonLink.mdx +75 -0
- package/src/ButtonLink/ButtonLink.stories.tsx +281 -0
- package/src/ButtonLink/ButtonLink.test.tsx +203 -0
- package/src/ButtonLink/ButtonLink.tsx +149 -0
- package/src/Combobox/Combobox.tsx +4 -1
- package/src/Dialog/Dialog.tsx +18 -2
- package/src/Field/Field.tsx +10 -2
- package/src/Field/FieldGroup.tsx +4 -2
- package/src/LogViewer/LogViewer.test.tsx +47 -0
- package/src/LogViewer/LogViewer.tsx +35 -1
- package/src/RouterLink/AnchorLink.tsx +23 -0
- package/src/RouterLink/RouterLink.mdx +178 -0
- package/src/RouterLink/RouterLinkProvider.tsx +68 -0
- package/src/RouterLink/routerLink.ts +56 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +6 -1
- package/src/TextLink/TextLink.mdx +93 -0
- package/src/TextLink/TextLink.stories.tsx +260 -0
- package/src/TextLink/TextLink.test.tsx +230 -0
- package/src/TextLink/TextLink.tsx +194 -0
- package/src/Tooltip/Tooltip.tsx +5 -1
- package/src/controlStyles.ts +69 -1
- package/src/icons.storyHelpers.tsx +32 -0
- package/src/index.ts +41 -0
- package/src/intentStyles.ts +19 -0
- package/src/mountStory.testHelpers.ts +63 -0
- package/src/reactRouter/ReactRouterLink.tsx +30 -0
- package/src/reactRouter/index.ts +29 -0
- package/src/sourceRules.test.ts +41 -6
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { RefObject } from "react"
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
useSyncExternalStore,
|
|
8
|
+
} from "react"
|
|
9
|
+
|
|
10
|
+
import type { ColumnChoice } from "./chooseColumns.ts"
|
|
11
|
+
import {
|
|
12
|
+
chooseColumns,
|
|
13
|
+
DEFAULT_MAX_AUTO_COLUMNS,
|
|
14
|
+
DEFAULT_MAX_MANUAL_COLUMNS,
|
|
15
|
+
DEFAULT_MIN_COLUMN_INLINE_SIZE_PX,
|
|
16
|
+
getContentMaxInlineSize,
|
|
17
|
+
} from "./chooseColumns.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The height-first column rule, wired to a real element.
|
|
21
|
+
*
|
|
22
|
+
* `chooseColumns` is the decision; this is the measuring. The split
|
|
23
|
+
* is the point — the rule is a table of numbers anyone can check in
|
|
24
|
+
* Node, and everything that needs a browser is quarantined here
|
|
25
|
+
* behind seams.
|
|
26
|
+
*
|
|
27
|
+
* ## What it measures, and from where
|
|
28
|
+
*
|
|
29
|
+
* Two different sources, on purpose:
|
|
30
|
+
*
|
|
31
|
+
* - **Inline size comes from a container**, observed with a
|
|
32
|
+
* `ResizeObserver`. A grid sitting beside a rail has less room
|
|
33
|
+
* than `window.innerWidth` claims, and this library is built
|
|
34
|
+
* around the case where the component's container is what
|
|
35
|
+
* changed and the window never moved. The source app read
|
|
36
|
+
* `window.innerWidth` with a bare `resize` listener; that is the
|
|
37
|
+
* one part of the port that is not a lift.
|
|
38
|
+
* - **Block size comes from the viewport**, behind an injected
|
|
39
|
+
* resolver. It has to: the question is "will this scroll", and a
|
|
40
|
+
* grid in normal flow is exactly as tall as its contents, so
|
|
41
|
+
* measuring the element always answers "it fits."
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ **The observed element must not itself be capped by the
|
|
44
|
+
* answer.** `contentMaxInlineSize` narrows the content when the
|
|
45
|
+
* count is low; if the ref sits on the element that cap is applied
|
|
46
|
+
* to, the fold eats its own output and can never widen again — one
|
|
47
|
+
* column forever, with no error anywhere. `AdaptiveGrid` prevents
|
|
48
|
+
* this structurally by measuring an uncapped outer box and capping
|
|
49
|
+
* an inner one, and a caller wiring the hook by hand owes itself
|
|
50
|
+
* the same shape.
|
|
51
|
+
*
|
|
52
|
+
* ## Composing with a shell
|
|
53
|
+
*
|
|
54
|
+
* `contentMaxInlineSize` is a CSS length meant to be handed
|
|
55
|
+
* straight to a shell's main region as its max inline size, so the
|
|
56
|
+
* page chrome and the grid widen together rather than the grid
|
|
57
|
+
* rattling around inside a fixed column.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where the available block size comes from, and how to hear about
|
|
62
|
+
* it changing. The shape `useSyncExternalStore` wants, and the same
|
|
63
|
+
* `get`/`subscribe` seam `useColorScheme` takes its resolver in.
|
|
64
|
+
*
|
|
65
|
+
* Injected rather than hardcoded for the reason every seam in this
|
|
66
|
+
* library is: an Electron window, a fixed-height kiosk panel, and a
|
|
67
|
+
* test all have a block size, and none of them is
|
|
68
|
+
* `window.innerHeight`.
|
|
69
|
+
*/
|
|
70
|
+
export type BlockSizeResolver = {
|
|
71
|
+
get: () => number
|
|
72
|
+
subscribe: (listener: () => void) => () => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where a manual choice is remembered between visits.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately not a storage *key* on its own: the source app
|
|
79
|
+
* hardcoded `"rip-deck.layout-columns"`, which is exactly the kind
|
|
80
|
+
* of app-namespaced string a shared library must never invent for
|
|
81
|
+
* its consumers.
|
|
82
|
+
*/
|
|
83
|
+
export type ColumnPersistence = {
|
|
84
|
+
read: () => string | null
|
|
85
|
+
write: (value: string) => void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `window.innerHeight`, as a resolver.
|
|
90
|
+
*
|
|
91
|
+
* Guards `typeof window`, so importing this in a server render is
|
|
92
|
+
* safe: it degrades to an inert zero rather than throwing on a
|
|
93
|
+
* missing global. A zero block size resolves to one column, which
|
|
94
|
+
* is the right thing to send down the wire — it is the layout that
|
|
95
|
+
* is correct everywhere, and it widens on the client.
|
|
96
|
+
*/
|
|
97
|
+
export const viewportBlockSizeResolver =
|
|
98
|
+
(): BlockSizeResolver => ({
|
|
99
|
+
get: () =>
|
|
100
|
+
typeof window === "undefined"
|
|
101
|
+
? 0
|
|
102
|
+
: window.innerHeight,
|
|
103
|
+
subscribe: (listener) => {
|
|
104
|
+
if (typeof window === "undefined") {
|
|
105
|
+
return () => {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
window.addEventListener("resize", listener)
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
window.removeEventListener("resize", listener)
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `localStorage` under a caller-supplied key.
|
|
118
|
+
*
|
|
119
|
+
* Reads and writes are wrapped: `localStorage` throws in a
|
|
120
|
+
* sandboxed iframe and in some privacy modes, and a grid that
|
|
121
|
+
* cannot remember a choice should still honour the one just made.
|
|
122
|
+
*/
|
|
123
|
+
export const localStorageColumnPersistence = (
|
|
124
|
+
key: string,
|
|
125
|
+
): ColumnPersistence => ({
|
|
126
|
+
read: () => {
|
|
127
|
+
try {
|
|
128
|
+
return window.localStorage.getItem(key)
|
|
129
|
+
} catch {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
write: (value) => {
|
|
134
|
+
try {
|
|
135
|
+
window.localStorage.setItem(key, value)
|
|
136
|
+
} catch {
|
|
137
|
+
// Private-mode Safari and friends. Nothing to do.
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A stored choice, or `"auto"` for anything unreadable.
|
|
144
|
+
*
|
|
145
|
+
* Every failure lands on `"auto"` on purpose: a corrupt key, a
|
|
146
|
+
* browser that refuses storage, a number somebody typed into
|
|
147
|
+
* devtools. The default is the mode that works everywhere, so a bad
|
|
148
|
+
* value costs a preference rather than a page.
|
|
149
|
+
*/
|
|
150
|
+
export const readStoredChoice = ({
|
|
151
|
+
maxColumns = DEFAULT_MAX_MANUAL_COLUMNS,
|
|
152
|
+
persistence,
|
|
153
|
+
}: {
|
|
154
|
+
maxColumns?: number
|
|
155
|
+
persistence: ColumnPersistence | undefined
|
|
156
|
+
}): ColumnChoice => {
|
|
157
|
+
const stored = persistence?.read() ?? null
|
|
158
|
+
|
|
159
|
+
if (stored === null || stored === "auto") {
|
|
160
|
+
return "auto"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const parsed = Number(stored)
|
|
164
|
+
|
|
165
|
+
if (!Number.isInteger(parsed)) {
|
|
166
|
+
return "auto"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (parsed < 1 || parsed > maxColumns) {
|
|
170
|
+
return "auto"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return parsed
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const useAdaptiveColumns = <
|
|
177
|
+
ContainerElement extends HTMLElement = HTMLDivElement,
|
|
178
|
+
>({
|
|
179
|
+
blockSizeResolver,
|
|
180
|
+
chromeBlockSize,
|
|
181
|
+
columnInlineSize,
|
|
182
|
+
gutterInlineSize,
|
|
183
|
+
itemBlockSize,
|
|
184
|
+
itemCount,
|
|
185
|
+
maxAutoColumns = DEFAULT_MAX_AUTO_COLUMNS,
|
|
186
|
+
maxManualColumns = DEFAULT_MAX_MANUAL_COLUMNS,
|
|
187
|
+
minColumnInlineSize = DEFAULT_MIN_COLUMN_INLINE_SIZE_PX,
|
|
188
|
+
persistence,
|
|
189
|
+
singleInlineSize,
|
|
190
|
+
storageKey,
|
|
191
|
+
}: {
|
|
192
|
+
/** @see BlockSizeResolver. Defaults to the viewport. */
|
|
193
|
+
blockSizeResolver?: BlockSizeResolver
|
|
194
|
+
/** Everything down the page that is not this grid, in CSS px. */
|
|
195
|
+
chromeBlockSize?: number
|
|
196
|
+
/** Override `contentInlineSize.column`. */
|
|
197
|
+
columnInlineSize?: string
|
|
198
|
+
/** Override `contentInlineSize.gutter`. */
|
|
199
|
+
gutterInlineSize?: string
|
|
200
|
+
/** One item's block size in CSS px. Measure it; err high. */
|
|
201
|
+
itemBlockSize: number
|
|
202
|
+
/** How many items the grid is being asked to fit. */
|
|
203
|
+
itemCount: number
|
|
204
|
+
/** The most columns the automatic answer will take. */
|
|
205
|
+
maxAutoColumns?: number
|
|
206
|
+
/** The most a person may ask for. One above the auto cap. */
|
|
207
|
+
maxManualColumns?: number
|
|
208
|
+
/** The narrowest a column may be, in CSS px. */
|
|
209
|
+
minColumnInlineSize?: number
|
|
210
|
+
/**
|
|
211
|
+
* Where a manual choice is remembered. Defaults to
|
|
212
|
+
* `localStorage` when `storageKey` is given, and to nothing at
|
|
213
|
+
* all when it is not — a grid with no key keeps a choice for the
|
|
214
|
+
* session and writes nowhere.
|
|
215
|
+
*/
|
|
216
|
+
persistence?: ColumnPersistence
|
|
217
|
+
/** Override `contentInlineSize.single`. */
|
|
218
|
+
singleInlineSize?: string
|
|
219
|
+
/**
|
|
220
|
+
* The `localStorage` key a manual choice is remembered under.
|
|
221
|
+
*
|
|
222
|
+
* **Caller-supplied and namespaced by the app** — one origin
|
|
223
|
+
* often serves several things that would each like a `columns`
|
|
224
|
+
* key, and a shared library has no business picking that name.
|
|
225
|
+
*/
|
|
226
|
+
storageKey?: string
|
|
227
|
+
}): {
|
|
228
|
+
/** What the automatic rule wants right now. */
|
|
229
|
+
autoColumns: number
|
|
230
|
+
/** What to draw. */
|
|
231
|
+
columns: number
|
|
232
|
+
/**
|
|
233
|
+
* The cap for the content around the grid, as a CSS length. Hand
|
|
234
|
+
* it to a shell's main region.
|
|
235
|
+
*/
|
|
236
|
+
contentMaxInlineSize: string
|
|
237
|
+
/**
|
|
238
|
+
* Attach to the element whose inline size the grid may spend.
|
|
239
|
+
*
|
|
240
|
+
* ⚠️ Never the element `contentMaxInlineSize` is applied to.
|
|
241
|
+
*/
|
|
242
|
+
containerRef: RefObject<ContainerElement | null>
|
|
243
|
+
/** What was picked, `"auto"` included. */
|
|
244
|
+
choice: ColumnChoice
|
|
245
|
+
setChoice: (choice: ColumnChoice) => void
|
|
246
|
+
} => {
|
|
247
|
+
const containerRef = useRef<ContainerElement>(null)
|
|
248
|
+
|
|
249
|
+
// Built once, so the `resize` subscription and the storage
|
|
250
|
+
// handle survive re-renders and StrictMode's double mount — the
|
|
251
|
+
// same lazy-`useState` seam construction `ColorSchemeSwitcher`
|
|
252
|
+
// uses.
|
|
253
|
+
const [seams] = useState(() => ({
|
|
254
|
+
blockSize:
|
|
255
|
+
blockSizeResolver ?? viewportBlockSizeResolver(),
|
|
256
|
+
persistence:
|
|
257
|
+
persistence ??
|
|
258
|
+
(storageKey === undefined
|
|
259
|
+
? undefined
|
|
260
|
+
: localStorageColumnPersistence(storageKey)),
|
|
261
|
+
}))
|
|
262
|
+
|
|
263
|
+
const availableBlockSize = useSyncExternalStore(
|
|
264
|
+
seams.blockSize.subscribe,
|
|
265
|
+
seams.blockSize.get,
|
|
266
|
+
// Server-side there is no window and no scrolling to predict.
|
|
267
|
+
() => 0,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Zero until the container has been measured, which resolves to
|
|
272
|
+
* one column.
|
|
273
|
+
*
|
|
274
|
+
* The safe direction to be wrong in for one frame: starting
|
|
275
|
+
* narrow and widening moves content down the page, where
|
|
276
|
+
* starting wide and narrowing snatches it sideways. In practice
|
|
277
|
+
* `ResizeObserver` delivers its first record before paint, so
|
|
278
|
+
* there is usually nothing to see.
|
|
279
|
+
*/
|
|
280
|
+
const [availableInlineSize, setAvailableInlineSize] =
|
|
281
|
+
useState(0)
|
|
282
|
+
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
const container = containerRef.current
|
|
285
|
+
|
|
286
|
+
if (!container) {
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
291
|
+
const next =
|
|
292
|
+
entry?.contentRect.width ??
|
|
293
|
+
container.getBoundingClientRect().width
|
|
294
|
+
|
|
295
|
+
// Same size, same state — a drag fires this many times a
|
|
296
|
+
// second and every one of them would otherwise re-render
|
|
297
|
+
// every item in the grid.
|
|
298
|
+
setAvailableInlineSize((current) =>
|
|
299
|
+
current === next ? current : next,
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
observer.observe(container)
|
|
304
|
+
|
|
305
|
+
return () => {
|
|
306
|
+
observer.disconnect()
|
|
307
|
+
}
|
|
308
|
+
}, [])
|
|
309
|
+
|
|
310
|
+
const [choice, setStoredChoice] = useState<ColumnChoice>(
|
|
311
|
+
() =>
|
|
312
|
+
readStoredChoice({
|
|
313
|
+
maxColumns: maxManualColumns,
|
|
314
|
+
persistence: seams.persistence,
|
|
315
|
+
}),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
const setChoice = useCallback(
|
|
319
|
+
(next: ColumnChoice) => {
|
|
320
|
+
setStoredChoice(next)
|
|
321
|
+
|
|
322
|
+
// A storage failure must not swallow the click. The
|
|
323
|
+
// preference is a nicety; the column count just asked for is
|
|
324
|
+
// not.
|
|
325
|
+
seams.persistence?.write(String(next))
|
|
326
|
+
},
|
|
327
|
+
[seams],
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
const autoColumns = chooseColumns({
|
|
331
|
+
availableBlockSize,
|
|
332
|
+
availableInlineSize,
|
|
333
|
+
chromeBlockSize,
|
|
334
|
+
itemBlockSize,
|
|
335
|
+
itemCount,
|
|
336
|
+
maxColumns: maxAutoColumns,
|
|
337
|
+
minColumnInlineSize,
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
const columns = choice === "auto" ? autoColumns : choice
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
autoColumns,
|
|
344
|
+
choice,
|
|
345
|
+
columns,
|
|
346
|
+
containerRef,
|
|
347
|
+
contentMaxInlineSize: getContentMaxInlineSize({
|
|
348
|
+
columnInlineSize,
|
|
349
|
+
columns,
|
|
350
|
+
gutterInlineSize,
|
|
351
|
+
singleInlineSize,
|
|
352
|
+
}),
|
|
353
|
+
setChoice,
|
|
354
|
+
}
|
|
355
|
+
}
|
package/src/Button/Button.tsx
CHANGED
|
@@ -7,19 +7,10 @@ import type {
|
|
|
7
7
|
ReactNode,
|
|
8
8
|
} from "react"
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
CONTROL_SIZE_CLASS,
|
|
12
|
-
ICON_CONTROL_SIZE_CLASS,
|
|
13
|
-
} from "../controlStyles.ts"
|
|
10
|
+
import { getControlClassName } from "../controlStyles.ts"
|
|
14
11
|
import type { IntentAppearance } from "../intentStyles.ts"
|
|
15
|
-
import {
|
|
16
|
-
DISABLED_CLASS,
|
|
17
|
-
FOCUS_RING_CLASS,
|
|
18
|
-
INTENT_APPEARANCE_CLASS,
|
|
19
|
-
INTENT_HOVER_CLASS,
|
|
20
|
-
} from "../intentStyles.ts"
|
|
12
|
+
import { DISABLED_CLASS } from "../intentStyles.ts"
|
|
21
13
|
import { Spinner } from "../Spinner/Spinner.tsx"
|
|
22
|
-
import { toClassName } from "../toClassName.ts"
|
|
23
14
|
|
|
24
15
|
export type ButtonProps = Omit<
|
|
25
16
|
ComponentPropsWithRef<"button">,
|
|
@@ -97,18 +88,15 @@ export const Button = ({
|
|
|
97
88
|
<button
|
|
98
89
|
{...buttonProps}
|
|
99
90
|
aria-busy={isLoading || undefined}
|
|
100
|
-
className={
|
|
101
|
-
|
|
102
|
-
sizing === "icon"
|
|
103
|
-
? ICON_CONTROL_SIZE_CLASS[size]
|
|
104
|
-
: CONTROL_SIZE_CLASS[size],
|
|
105
|
-
INTENT_APPEARANCE_CLASS[intent][appearance],
|
|
106
|
-
INTENT_HOVER_CLASS[intent][appearance],
|
|
107
|
-
FOCUS_RING_CLASS,
|
|
108
|
-
DISABLED_CLASS,
|
|
109
|
-
isFullWidth && "w-full",
|
|
91
|
+
className={getControlClassName({
|
|
92
|
+
appearance,
|
|
110
93
|
className,
|
|
111
|
-
|
|
94
|
+
disabledClass: DISABLED_CLASS,
|
|
95
|
+
intent,
|
|
96
|
+
isFullWidth,
|
|
97
|
+
size,
|
|
98
|
+
sizing,
|
|
99
|
+
})}
|
|
112
100
|
disabled={isDisabled || isLoading}
|
|
113
101
|
type={type}
|
|
114
102
|
>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as ButtonLinkStories from "./ButtonLink.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={ButtonLinkStories} />
|
|
6
|
+
|
|
7
|
+
# ButtonLink
|
|
8
|
+
|
|
9
|
+
**Navigation that looks like a button.** It takes `Button`'s visual props and paints
|
|
10
|
+
identically to it — and it is an `<a href>`.
|
|
11
|
+
|
|
12
|
+
That combination is the whole component. Plex Channels' "Configure" reads as the primary
|
|
13
|
+
action on its card, so it has to look like a solid button; it goes to another page, so it
|
|
14
|
+
has to be a link. Today it is a `<button onClick={() => navigate("#/settings")}>`, which
|
|
15
|
+
cannot be middle-clicked, ctrl-clicked, opened in a new tab, or copied — and mux-magic
|
|
16
|
+
solves the same problem in the other direction, with 14 raw `<a href="/…">` that force a
|
|
17
|
+
full page reload through its own SPA router.
|
|
18
|
+
|
|
19
|
+
<Canvas of={ButtonLinkStories.Default} />
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
|
|
23
|
+
<Controls />
|
|
24
|
+
|
|
25
|
+
## It is the same paint, not a copy of it
|
|
26
|
+
|
|
27
|
+
`Button` and `ButtonLink` both assemble their class list through `getControlClassName`, so
|
|
28
|
+
`INTENT_APPEARANCE_CLASS`, `CONTROL_SIZE_CLASS`, and `FOCUS_RING_CLASS` are read once
|
|
29
|
+
rather than duplicated. `ButtonLink.test.tsx` then compares the two elements' **computed**
|
|
30
|
+
styles in the real browser, which is the version of that claim a refactor cannot quietly
|
|
31
|
+
break.
|
|
32
|
+
|
|
33
|
+
<Canvas of={ButtonLinkStories.BesideAButton} />
|
|
34
|
+
|
|
35
|
+
<Canvas of={ButtonLinkStories.AllVariants} />
|
|
36
|
+
|
|
37
|
+
<Canvas of={ButtonLinkStories.AllSizes} />
|
|
38
|
+
|
|
39
|
+
## Three things `Button` has that this does not
|
|
40
|
+
|
|
41
|
+
| Missing | Why |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `isLoading` | A navigation has no pending state **this component** owns. The wait belongs to the destination — a router transition, a `Suspense` boundary, a `ProgressBar` on the page being entered. A spinner here would be the component lying about work it is not doing. |
|
|
44
|
+
| `type` | There is no form to submit. |
|
|
45
|
+
| `sizing="icon"` | An icon-only navigation is an `IconButton` problem — a glyph is not an accessible name — and solving it here would mean a second name-enforcing type. |
|
|
46
|
+
|
|
47
|
+
## Accessibility contract
|
|
48
|
+
|
|
49
|
+
- The accessible name is the children. `iconStart`/`iconEnd` are decoration inside the same
|
|
50
|
+
anchor.
|
|
51
|
+
- `isDisabled` **drops `href`** and sets `aria-disabled` with an explicit `role="link"`,
|
|
52
|
+
rather than shipping a focusable `<a>` that silently ignores clicks. It is painted
|
|
53
|
+
through `ARIA_DISABLED_CLASS`, which also switches off pointer events — without a real
|
|
54
|
+
`:disabled`, CSS `:hover` still matches, so the hover fill has to be taken off the table
|
|
55
|
+
rather than merely overridden.
|
|
56
|
+
- `isExternal` sets `target="_blank"`, `rel="noopener noreferrer"`, and a visually-hidden
|
|
57
|
+
*"(opens in a new tab)"*.
|
|
58
|
+
- Contrast on the disabled treatment leans on WCAG 1.4.3's exemption for *inactive*
|
|
59
|
+
controls, exactly as `Button`'s does.
|
|
60
|
+
|
|
61
|
+
<Canvas of={ButtonLinkStories.AllStates} />
|
|
62
|
+
|
|
63
|
+
<Canvas of={ButtonLinkStories.ConfigureCard} />
|
|
64
|
+
|
|
65
|
+
<Canvas of={ButtonLinkStories.External} />
|
|
66
|
+
|
|
67
|
+
## Routing
|
|
68
|
+
|
|
69
|
+
`ButtonLink` renders through whatever link component the app injected at its root, and a
|
|
70
|
+
plain `<a>` when nothing is injected — or when the destination is external or a fragment,
|
|
71
|
+
which no router should intercept. See **Guides/Routing**.
|
|
72
|
+
|
|
73
|
+
<Canvas of={ButtonLinkStories.Routed} />
|
|
74
|
+
|
|
75
|
+
<Canvas of={ButtonLinkStories.Responsive} />
|