@astryxdesign/cli 0.7.12-canary.b87846c → 0.7.12-canary.c5cc8f4
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/api/component/__fixtures__/resolutions/modules.txt +2 -2
- package/api/template/template.mjs +1 -0
- package/assets/discover/official-packages.json +12 -0
- package/assets/docs/tree/type-scale.doc.mjs +19 -2
- package/assets/templates/blocks/components/Hooks/useInputStatusIconHookUsage.tsx +1 -0
- package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.doc.mjs +14 -0
- package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.tsx +56 -0
- package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.doc.mjs +2 -2
- package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.tsx +66 -10
- package/assets/templates/blocks/components/Link/useLinkifyHookUsage.doc.mjs +14 -0
- package/assets/templates/blocks/components/Link/useLinkifyHookUsage.tsx +39 -0
- package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.doc.mjs +14 -0
- package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.tsx +35 -0
- package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.doc.mjs +14 -0
- package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.tsx +97 -0
- package/assets/templates/pages/table-filter/page.tsx +15 -23
- package/assets/templates/pages/table-inbox/page.tsx +3 -6
- package/assets/templates/themes/butter/butterTheme.ts +1 -1
- package/foundation/discovery/hook-discovery.mjs +31 -8
- package/foundation/doc-compiler/import.mjs +5 -4
- package/foundation/fs/import-url.d.mts +20 -0
- package/foundation/fs/import-url.mjs +22 -0
- package/foundation/fs/module-loader.d.mts +14 -3
- package/foundation/fs/module-loader.mjs +56 -47
- package/foundation/text/levenshtein.d.mts +6 -1
- package/foundation/text/levenshtein.mjs +25 -9
- package/foundation/text/suggest-order.d.mts +8 -1
- package/foundation/text/suggest-order.mjs +72 -6
- package/package.json +6 -6
|
@@ -271,7 +271,7 @@ useIsomorphicLayoutEffect useIsomorphicLayoutEffect hooks/useAnimatedLayout.doc.
|
|
|
271
271
|
useLayerDismissal useLayerDismissal Layer/Layer.doc.mjs Layer/useLayerDismissal.ts
|
|
272
272
|
useLightbox useLightbox Lightbox/useLightbox.doc.mjs Lightbox/useLightbox.tsx
|
|
273
273
|
useLinkComponent useLinkComponent Link/useLinkComponent.doc.mjs Link/useLinkComponent.ts
|
|
274
|
-
useLinkify useLinkify Link/
|
|
274
|
+
useLinkify useLinkify Link/useLinkify.doc.mjs Link/useLinkify.tsx
|
|
275
275
|
useListFormatter useListFormatter i18n/InternationalizationProvider.doc.mjs i18n/useListFormatter.ts
|
|
276
276
|
useMenuDrillIn useMenuDrillIn DropdownMenu/DropdownMenu.doc.mjs DropdownMenu/useMenuDrillIn.tsx
|
|
277
277
|
useMenuHover useMenuHover hooks/useAnimatedLayout.doc.mjs hooks/useMenuHover.ts
|
|
@@ -298,7 +298,7 @@ useSpeechRecognition useSpeechRecognition Chat/Chat.doc.mjs Chat/useSpeechRecogn
|
|
|
298
298
|
useSwipeAction useSwipeAction Item/Item.doc.mjs Item/useSwipeAction.ts
|
|
299
299
|
useSwitchTouch useSwitchTouch Switch/Switch.doc.mjs Switch/useSwitchTouch.ts
|
|
300
300
|
useTableCellStyles useTableCellStyles Table/Table.doc.mjs Table/useTableCellStyles.ts
|
|
301
|
-
useTableColumnSettingsState useTableColumnSettingsState Table/
|
|
301
|
+
useTableColumnSettingsState useTableColumnSettingsState Table/plugins/columnSettings/useTableColumnSettingsState.doc.mjs Table/plugins/columnSettings/useTableColumnSettingsState.tsx
|
|
302
302
|
useTableSortableState useTableSortableState Table/plugins/sortable/useTableSortableState.doc.mjs Table/plugins/sortable/useTableSortableState.tsx
|
|
303
303
|
useToastGesture useToastGesture Toast/Toast.doc.mjs Toast/useToastGesture.ts
|
|
304
304
|
useTouchTrigger useTouchTrigger Layer/Layer.doc.mjs Layer/useTouchTrigger.ts
|
|
@@ -193,6 +193,7 @@ export async function template(name, options = {}) {
|
|
|
193
193
|
`Unknown template "${name}"`,
|
|
194
194
|
orderByAffinity(name, templates, t => [t.dirName, t.name], {
|
|
195
195
|
closest: true,
|
|
196
|
+
typos: true,
|
|
196
197
|
}).map(t => ({
|
|
197
198
|
name: t.dirName,
|
|
198
199
|
reason: `${t.type} template`,
|
|
@@ -128,6 +128,10 @@
|
|
|
128
128
|
"kind": "component",
|
|
129
129
|
"name": "CommentThread"
|
|
130
130
|
},
|
|
131
|
+
{
|
|
132
|
+
"kind": "component",
|
|
133
|
+
"name": "DialogHeroHeader"
|
|
134
|
+
},
|
|
131
135
|
{
|
|
132
136
|
"kind": "component",
|
|
133
137
|
"name": "DiffViewer"
|
|
@@ -136,6 +140,10 @@
|
|
|
136
140
|
"kind": "component",
|
|
137
141
|
"name": "EmojiPicker"
|
|
138
142
|
},
|
|
143
|
+
{
|
|
144
|
+
"kind": "component",
|
|
145
|
+
"name": "LineClamp"
|
|
146
|
+
},
|
|
139
147
|
{
|
|
140
148
|
"kind": "component",
|
|
141
149
|
"name": "ListInput"
|
|
@@ -252,6 +260,10 @@
|
|
|
252
260
|
"kind": "template",
|
|
253
261
|
"name": "CommentThreadShowcase"
|
|
254
262
|
},
|
|
263
|
+
{
|
|
264
|
+
"kind": "template",
|
|
265
|
+
"name": "DialogHeroHeaderShowcase"
|
|
266
|
+
},
|
|
255
267
|
{
|
|
256
268
|
"kind": "template",
|
|
257
269
|
"name": "DiffViewerShowcase"
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file `astryx docs typography/type-scale`: line height, semantic type scale,
|
|
5
|
-
* display text, headings and text components,
|
|
5
|
+
* display text, headings and text components, custom type scales, and the
|
|
6
|
+
* reader's text size on iOS.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
/** @type {import('@astryxdesign/cli/authoring').ReferenceDoc} */
|
|
@@ -14,13 +15,15 @@ export const docs = {
|
|
|
14
15
|
placement: {parent: 'namespace:typography', slot: 'guides', order: 20},
|
|
15
16
|
category: 'foundations',
|
|
16
17
|
description:
|
|
17
|
-
|
|
18
|
+
"Line height, semantic type scale tokens, display text, Heading and Text components, custom type scales via defineTheme, and following the reader's text size on iOS.",
|
|
18
19
|
keywords: [
|
|
19
20
|
'line height',
|
|
20
21
|
'leading',
|
|
21
22
|
'defineTheme',
|
|
22
23
|
'base',
|
|
23
24
|
'ratio',
|
|
25
|
+
'dynamic type',
|
|
26
|
+
'text size',
|
|
24
27
|
],
|
|
25
28
|
|
|
26
29
|
sections: [
|
|
@@ -143,5 +146,19 @@ const denseTheme = defineTheme({
|
|
|
143
146
|
},
|
|
144
147
|
],
|
|
145
148
|
},
|
|
149
|
+
{
|
|
150
|
+
id: 'dynamic-type',
|
|
151
|
+
title: "Following the reader's text size on iOS",
|
|
152
|
+
content: [
|
|
153
|
+
{
|
|
154
|
+
type: 'prose',
|
|
155
|
+
text: "On iPhone, readers pick a text size in Settings (Display & Text Size, then Larger Text), and native apps grow or shrink their text with it. Every theme does the same in Safari on iPhone, which exposes that choice through `font: -apple-system-body`: each font size follows the reader's choice on the curve of the iOS text style it plays, so body text scales like Body, `heading-1` like Title 1 and display text like Large Title, and every role keeps its order. A size the theme fixes at a length, such as a 12px supporting size, scales on the curve of the step it reads by default; a size in `em` or `%` follows its parent, and one that reads a variable follows that variable. At the default size every font size is the theme's own. The family, spacing, radius and the root font size never change, line heights keep their ratios, text inputs keep their 16px floor on iOS, and on Android the system font scale grows every font size through `env(preferred-text-scale)` where the browser reports it; browsers with neither see no change. There is nothing to turn on: no theme option, prop or document attribute.",
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
type: 'prose',
|
|
159
|
+
text: 'Fixed-height controls such as the text inputs keep their height, so their text can overflow at the accessibility sizes. A labelled `Button` grows with its label.',
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
146
163
|
],
|
|
147
164
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'useInteractiveRole',
|
|
7
|
+
name: 'useInteractiveRole — One Tile, Four Roles',
|
|
8
|
+
displayName: 'useInteractiveRole — One Tile, Four Roles',
|
|
9
|
+
description:
|
|
10
|
+
'A polymorphic Tile used four ways: with an href it renders as a link, with an onClick as a button, with neither as plain content, and placed as a Popover trigger it becomes the trigger button through context — the resolution order in one place.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 4 / 3,
|
|
13
|
+
componentsUsed: ['Popover', 'Card', 'Text', 'Layout', 'Link'],
|
|
14
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {useInteractiveRole} from '@astryxdesign/core/hooks';
|
|
6
|
+
import {Link} from '@astryxdesign/core/Link';
|
|
7
|
+
import {Popover} from '@astryxdesign/core/Popover';
|
|
8
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
9
|
+
import {Text} from '@astryxdesign/core/Text';
|
|
10
|
+
import {VStack} from '@astryxdesign/core/Layout';
|
|
11
|
+
|
|
12
|
+
// A polymorphic Tile: the hook alone decides what it renders as — href wins,
|
|
13
|
+
// then onClick, then a trigger context (Popover), then inert.
|
|
14
|
+
function Tile({
|
|
15
|
+
href,
|
|
16
|
+
onClick,
|
|
17
|
+
children,
|
|
18
|
+
}: {
|
|
19
|
+
href?: string;
|
|
20
|
+
onClick?: () => void;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
const role = useInteractiveRole({href, onClick});
|
|
24
|
+
const body = (
|
|
25
|
+
<Card padding={3} width={200}>
|
|
26
|
+
<Text type="body">{children}</Text>
|
|
27
|
+
</Card>
|
|
28
|
+
);
|
|
29
|
+
switch (role) {
|
|
30
|
+
case 'link':
|
|
31
|
+
return <Link href={href}>{body}</Link>;
|
|
32
|
+
case 'button':
|
|
33
|
+
return <button onClick={onClick}>{body}</button>;
|
|
34
|
+
default:
|
|
35
|
+
return body;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function UseInteractiveRoleHookUsage() {
|
|
40
|
+
return (
|
|
41
|
+
<VStack gap={3} hAlign="center" style={{width: '100%'}}>
|
|
42
|
+
<Tile href="https://example.com/docs">Renders as a link</Tile>
|
|
43
|
+
<Tile onClick={() => {}}>Renders as a button</Tile>
|
|
44
|
+
<Popover
|
|
45
|
+
label="Tile details"
|
|
46
|
+
content={
|
|
47
|
+
<Card padding={3}>
|
|
48
|
+
<Text type="body">Opened through the tile's context role</Text>
|
|
49
|
+
</Card>
|
|
50
|
+
}>
|
|
51
|
+
<Tile>Trigger via context</Tile>
|
|
52
|
+
</Popover>
|
|
53
|
+
<Tile>Plain content</Tile>
|
|
54
|
+
</VStack>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -7,8 +7,8 @@ export const doc = {
|
|
|
7
7
|
name: 'useStreamingText — Streaming Response',
|
|
8
8
|
displayName: 'useStreamingText — Streaming Response',
|
|
9
9
|
description:
|
|
10
|
-
'
|
|
10
|
+
'A chat-style reply card: while the response streams in bursts, useStreamingText reveals it steadily character by character; when streaming ends the full text snaps into place. Regenerate replays the stream.',
|
|
11
11
|
isReady: true,
|
|
12
12
|
aspectRatio: 4 / 3,
|
|
13
|
-
componentsUsed: ['Card', 'Text', 'Layout'],
|
|
13
|
+
componentsUsed: ['Button', 'Card', 'Text', 'Layout'],
|
|
14
14
|
};
|
|
@@ -2,29 +2,85 @@
|
|
|
2
2
|
|
|
3
3
|
'use client';
|
|
4
4
|
|
|
5
|
+
import {useCallback, useEffect, useRef, useState} from 'react';
|
|
5
6
|
import {useStreamingText} from '@astryxdesign/core/hooks';
|
|
7
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
6
8
|
import {Card} from '@astryxdesign/core/Card';
|
|
7
|
-
import {VStack} from '@astryxdesign/core/Layout';
|
|
8
9
|
import {Text} from '@astryxdesign/core/Text';
|
|
10
|
+
import {VStack} from '@astryxdesign/core/Layout';
|
|
11
|
+
|
|
12
|
+
const CHUNKS = [
|
|
13
|
+
'Astryx hooks keep behavior reusable ',
|
|
14
|
+
'while components keep visuals consistent. ',
|
|
15
|
+
'Streaming text arrives in bursts; ',
|
|
16
|
+
'the hook smooths it into a steady reveal.',
|
|
17
|
+
];
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
// A fake stream: chunks land in bursts, the way a model streams tokens.
|
|
20
|
+
// Starting again stops the stream in progress, and the last chunk ends it.
|
|
21
|
+
function useFakeStream(onChunk: (chunk: string) => void, onEnd: () => void) {
|
|
22
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
23
|
+
const start = useCallback(() => {
|
|
24
|
+
if (timer.current) {
|
|
25
|
+
clearTimeout(timer.current);
|
|
26
|
+
}
|
|
27
|
+
let index = 0;
|
|
28
|
+
const tick = () => {
|
|
29
|
+
onChunk(CHUNKS[index]);
|
|
30
|
+
index += 1;
|
|
31
|
+
if (index < CHUNKS.length) {
|
|
32
|
+
timer.current = setTimeout(tick, 350);
|
|
33
|
+
} else {
|
|
34
|
+
timer.current = null;
|
|
35
|
+
onEnd();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
timer.current = setTimeout(tick, 350);
|
|
39
|
+
}, [onChunk, onEnd]);
|
|
40
|
+
useEffect(
|
|
41
|
+
() => () => {
|
|
42
|
+
if (timer.current) {
|
|
43
|
+
clearTimeout(timer.current);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[],
|
|
47
|
+
);
|
|
48
|
+
return start;
|
|
49
|
+
}
|
|
12
50
|
|
|
13
51
|
export default function UseStreamingTextHookUsage() {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
52
|
+
const [target, setTarget] = useState('');
|
|
53
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
54
|
+
|
|
55
|
+
const appendChunk = useCallback((chunk: string) => {
|
|
56
|
+
setTarget(previous => previous + chunk);
|
|
57
|
+
}, []);
|
|
58
|
+
// When the last chunk has landed, end the stream so the hook snaps to the
|
|
59
|
+
// full text.
|
|
60
|
+
const endStream = useCallback(() => setIsStreaming(false), []);
|
|
61
|
+
const startStream = useFakeStream(appendChunk, endStream);
|
|
62
|
+
|
|
63
|
+
const regenerate = useCallback(() => {
|
|
64
|
+
setTarget('');
|
|
65
|
+
setIsStreaming(true);
|
|
66
|
+
startStream();
|
|
67
|
+
}, [startStream]);
|
|
68
|
+
|
|
69
|
+
const displayedText = useStreamingText(target, isStreaming, {speed: 'fast'});
|
|
17
70
|
|
|
18
71
|
return (
|
|
19
72
|
<Card width={420} padding={4}>
|
|
20
|
-
<VStack gap={
|
|
73
|
+
<VStack gap={3}>
|
|
21
74
|
<Text type="body" weight="bold">
|
|
22
75
|
Assistant response
|
|
23
76
|
</Text>
|
|
24
77
|
<Text type="body">{displayedText}</Text>
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
78
|
+
<Button
|
|
79
|
+
label={isStreaming ? 'Streaming…' : 'Regenerate'}
|
|
80
|
+
variant="secondary"
|
|
81
|
+
size="sm"
|
|
82
|
+
onClick={regenerate}
|
|
83
|
+
/>
|
|
28
84
|
</VStack>
|
|
29
85
|
</Card>
|
|
30
86
|
);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'useLinkify',
|
|
7
|
+
name: 'useLinkify — Links in a Comment Thread',
|
|
8
|
+
displayName: 'useLinkify — Links in a Comment Thread',
|
|
9
|
+
description:
|
|
10
|
+
'Plain-text comments where useLinkify turns the URL and the email address into Links, and a custom pattern turns issue references like #123 into tracker links — the patterns option in action. The comment without matches stays plain text.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 4 / 3,
|
|
13
|
+
componentsUsed: ['Link', 'Layout', 'Text'],
|
|
14
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {useLinkify, type LinkifyPattern} from '@astryxdesign/core/Link';
|
|
6
|
+
import {VStack} from '@astryxdesign/core/Layout';
|
|
7
|
+
import {Text} from '@astryxdesign/core/Text';
|
|
8
|
+
|
|
9
|
+
const COMMENTS = [
|
|
10
|
+
'See https://example.com/spec or email support@example.com.',
|
|
11
|
+
'Fixed by #123, tracking the regression in #456.',
|
|
12
|
+
'No links here, just a plain note.',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Issue references like #123 link into the tracker; the capture group maps
|
|
16
|
+
// onto its URL scheme, showing the patterns option.
|
|
17
|
+
const ISSUE_PATTERN: LinkifyPattern = {
|
|
18
|
+
pattern: /#(\d+)/g,
|
|
19
|
+
href: match => `https://example.com/issues/${match[1]}`,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function Comment({children}: {children: string}) {
|
|
23
|
+
const nodes = useLinkify(children, {patterns: [ISSUE_PATTERN]});
|
|
24
|
+
return (
|
|
25
|
+
<Text type="body" color="secondary">
|
|
26
|
+
{nodes}
|
|
27
|
+
</Text>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function UseLinkifyHookUsage() {
|
|
32
|
+
return (
|
|
33
|
+
<VStack gap={3} style={{width: '100%', maxWidth: 360}}>
|
|
34
|
+
{COMMENTS.map(comment => (
|
|
35
|
+
<Comment key={comment}>{comment}</Comment>
|
|
36
|
+
))}
|
|
37
|
+
</VStack>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'useTableRowsVirtualization',
|
|
7
|
+
name: 'useTableRowsVirtualization - Fixed Height Rows',
|
|
8
|
+
displayName: 'useTableRowsVirtualization - Fixed Height Rows',
|
|
9
|
+
description:
|
|
10
|
+
'A Table with 1,000 product rows in a fixed-height viewport. Scroll vertically to mount the visible rows. The caller keeps all data and columns; this hook does not virtualize columns.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 16 / 9,
|
|
13
|
+
componentsUsed: ['Table'],
|
|
14
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Table,
|
|
7
|
+
useTableRowsVirtualization,
|
|
8
|
+
type TableColumn,
|
|
9
|
+
} from '@astryxdesign/core/Table';
|
|
10
|
+
|
|
11
|
+
interface Product extends Record<string, unknown> {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const products: Product[] = Array.from({length: 1000}, (_, index) => ({
|
|
17
|
+
id: `product-${index}`,
|
|
18
|
+
name: `Product ${index + 1}`,
|
|
19
|
+
}));
|
|
20
|
+
const columns: TableColumn<Product>[] = [{key: 'name', header: 'Product'}];
|
|
21
|
+
|
|
22
|
+
export default function RowsVirtualizationHookUsage() {
|
|
23
|
+
const virtualization = useTableRowsVirtualization({
|
|
24
|
+
rowHeight: 56,
|
|
25
|
+
viewportHeight: 320,
|
|
26
|
+
});
|
|
27
|
+
return (
|
|
28
|
+
<Table
|
|
29
|
+
data={products}
|
|
30
|
+
columns={columns}
|
|
31
|
+
idKey="id"
|
|
32
|
+
virtualization={virtualization}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'useTableColumnSettingsState',
|
|
7
|
+
name: 'useTableColumnSettingsState — Column Visibility Picker',
|
|
8
|
+
displayName: 'useTableColumnSettingsState — Column Visibility Picker',
|
|
9
|
+
description:
|
|
10
|
+
'A Table toolbar with a Columns menu of checkboxes: toggling Email updates active columns, Reset restores defaults, and the config is passed directly to useTableColumnSettings.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 16 / 9,
|
|
13
|
+
componentsUsed: ['Button', 'CheckboxInput', 'Popover', 'Table', 'Layout'],
|
|
14
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {useState} from 'react';
|
|
6
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
7
|
+
import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
|
|
8
|
+
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
|
9
|
+
import {Popover} from '@astryxdesign/core/Popover';
|
|
10
|
+
import {
|
|
11
|
+
Table,
|
|
12
|
+
useTableColumnSettings,
|
|
13
|
+
useTableColumnSettingsState,
|
|
14
|
+
} from '@astryxdesign/core/Table';
|
|
15
|
+
import type {TableColumn} from '@astryxdesign/core/Table';
|
|
16
|
+
|
|
17
|
+
interface Member extends Record<string, unknown> {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
email: string;
|
|
21
|
+
role: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const members: Member[] = [
|
|
25
|
+
{id: '1', name: 'Alice', email: 'alice@example.com', role: 'Engineer'},
|
|
26
|
+
{id: '2', name: 'Bob', email: 'bob@example.com', role: 'Designer'},
|
|
27
|
+
{id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'Manager'},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const allColumns: TableColumn<Member>[] = [
|
|
31
|
+
{key: 'name', header: 'Name'},
|
|
32
|
+
{key: 'email', header: 'Email'},
|
|
33
|
+
{key: 'role', header: 'Role'},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const columnOptions = [
|
|
37
|
+
{key: 'name' as const, label: 'Name', isAlwaysVisible: true},
|
|
38
|
+
{key: 'email' as const, label: 'Email'},
|
|
39
|
+
{key: 'role' as const, label: 'Role'},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const defaultKeys = ['name', 'email', 'role'];
|
|
43
|
+
|
|
44
|
+
export default function UseTableColumnSettingsStateHookUsage() {
|
|
45
|
+
const [activeKeys, setActiveKeys] =
|
|
46
|
+
useState<ReadonlyArray<string>>(defaultKeys);
|
|
47
|
+
|
|
48
|
+
const state = useTableColumnSettingsState({
|
|
49
|
+
columns: columnOptions,
|
|
50
|
+
activeColumnKeys: activeKeys,
|
|
51
|
+
onChangeActiveColumnKeys: setActiveKeys,
|
|
52
|
+
defaultColumnKeys: defaultKeys,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const plugin = useTableColumnSettings<Member>(state.columnSettingsConfig);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<VStack gap={3}>
|
|
59
|
+
<HStack hAlign="start">
|
|
60
|
+
<Popover
|
|
61
|
+
placement="below"
|
|
62
|
+
label="Column settings"
|
|
63
|
+
width={200}
|
|
64
|
+
content={
|
|
65
|
+
<VStack gap={2}>
|
|
66
|
+
{columnOptions.map(col => (
|
|
67
|
+
<CheckboxInput
|
|
68
|
+
key={col.key}
|
|
69
|
+
label={col.label}
|
|
70
|
+
value={state.isColumnActive(col.key)}
|
|
71
|
+
isDisabled={!state.isColumnToggleable(col.key)}
|
|
72
|
+
onChange={() => state.toggleColumn(col.key)}
|
|
73
|
+
/>
|
|
74
|
+
))}
|
|
75
|
+
<Button
|
|
76
|
+
label="Reset"
|
|
77
|
+
size="sm"
|
|
78
|
+
variant="secondary"
|
|
79
|
+
onClick={state.resetToDefault}>
|
|
80
|
+
Reset
|
|
81
|
+
</Button>
|
|
82
|
+
</VStack>
|
|
83
|
+
}>
|
|
84
|
+
<Button label="Columns" size="sm">
|
|
85
|
+
Columns
|
|
86
|
+
</Button>
|
|
87
|
+
</Popover>
|
|
88
|
+
</HStack>
|
|
89
|
+
<Table
|
|
90
|
+
data={members}
|
|
91
|
+
columns={allColumns}
|
|
92
|
+
idKey="id"
|
|
93
|
+
plugins={{columnSettings: plugin}}
|
|
94
|
+
/>
|
|
95
|
+
</VStack>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -1129,18 +1129,6 @@ const INITIAL_SAVED_VIEWS: SavedView[] = [
|
|
|
1129
1129
|
const COMPACT_SURFACE_QUERY =
|
|
1130
1130
|
'(max-width: 768px), (max-width: 1024px) and (pointer: coarse) and (hover: none)';
|
|
1131
1131
|
|
|
1132
|
-
/**
|
|
1133
|
-
* Top padding for whatever opens a BottomSheet, on the spacing scale.
|
|
1134
|
-
*
|
|
1135
|
-
* The panel's grab pill floats over the content instead of reserving layout
|
|
1136
|
-
* space for itself: it is 4px centered in a 24px band, so it covers the top
|
|
1137
|
-
* 10-14px of the sheet. Content that starts at the usual 16px gutter clears it
|
|
1138
|
-
* by 2px, which does not read as a gap — it reads as the sheet being cut off.
|
|
1139
|
-
* Opening at the full 24px band puts real space under the pill, and every
|
|
1140
|
-
* sheet here uses this one value so they all start on the same line.
|
|
1141
|
-
*/
|
|
1142
|
-
const SHEET_TOP_INSET = 6;
|
|
1143
|
-
|
|
1144
1132
|
/**
|
|
1145
1133
|
* Width the filter row holds back, in px, for the readout that trails the
|
|
1146
1134
|
* clauses — the density toggle, the result count and Clear all.
|
|
@@ -1698,6 +1686,7 @@ const styles = stylex.create({
|
|
|
1698
1686
|
fontSize: typeScaleVars['--text-body-size'],
|
|
1699
1687
|
fontWeight: typeScaleVars['--text-body-weight'],
|
|
1700
1688
|
height: 'auto',
|
|
1689
|
+
minHeight: 0,
|
|
1701
1690
|
lineHeight: typeScaleVars['--text-body-leading'],
|
|
1702
1691
|
paddingBlock: 0,
|
|
1703
1692
|
paddingInline: 0,
|
|
@@ -2052,7 +2041,8 @@ function FilterOverflow({
|
|
|
2052
2041
|
<VStack
|
|
2053
2042
|
gap={2}
|
|
2054
2043
|
padding={isCompactSurface ? 4 : 0}
|
|
2055
|
-
|
|
2044
|
+
// In a sheet the sheet's band under its grab bar is the top inset.
|
|
2045
|
+
paddingBlockStart={0}>
|
|
2056
2046
|
{items.map(({child, index}) => (
|
|
2057
2047
|
<StackItem key={index} xstyle={styles.overflowItem}>
|
|
2058
2048
|
{child}
|
|
@@ -2145,7 +2135,8 @@ function AdaptiveSavedViewForm({
|
|
|
2145
2135
|
<Section
|
|
2146
2136
|
variant="transparent"
|
|
2147
2137
|
padding={4}
|
|
2148
|
-
|
|
2138
|
+
// The sheet's band under its grab bar is the top inset.
|
|
2139
|
+
paddingBlockStart={0}>
|
|
2149
2140
|
<VStack gap={4}>
|
|
2150
2141
|
<HStack gap={2}>
|
|
2151
2142
|
<StackItem size="fill">
|
|
@@ -4090,11 +4081,11 @@ export default function TableFilterTemplate() {
|
|
|
4090
4081
|
label="View options"
|
|
4091
4082
|
height="tall"
|
|
4092
4083
|
// The tab strip compensates against a container inset, so the sheet
|
|
4093
|
-
// publishes none
|
|
4084
|
+
// publishes none; its band under the grab bar is the only top inset,
|
|
4085
|
+
// so the strip's own box starts at the tab row and its scroll arrow
|
|
4086
|
+
// lands on the tabs rather than above them.
|
|
4094
4087
|
padding={0}>
|
|
4095
|
-
{
|
|
4096
|
-
row and its scroll arrow lands on the tabs rather than above them. */}
|
|
4097
|
-
<VStack gap={0} paddingBlockStart={SHEET_TOP_INSET}>
|
|
4088
|
+
<VStack gap={0}>
|
|
4098
4089
|
{/* The heading the popover puts above the pane is dropped here: it
|
|
4099
4090
|
restates the selected tab word for word, and the strip already
|
|
4100
4091
|
marks which one that is. On a phone that is a line of vertical
|
|
@@ -4148,14 +4139,15 @@ export default function TableFilterTemplate() {
|
|
|
4148
4139
|
const detailBody =
|
|
4149
4140
|
activeJob == null ? null : (
|
|
4150
4141
|
<VStack gap={0} xstyle={styles.detailPanel}>
|
|
4151
|
-
{/* The only part of the body that knows which host it is in: the
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
Layout drops the panel entirely
|
|
4142
|
+
{/* The only part of the body that knows which host it is in: in the
|
|
4143
|
+
sheet, the sheet's band under its grab bar is the top inset; the
|
|
4144
|
+
docked panel has no pill and keeps the 16px gutter of the panels
|
|
4145
|
+
beside it. The two never coexist — Layout drops the panel entirely
|
|
4146
|
+
on a compact surface. */}
|
|
4155
4147
|
<Section
|
|
4156
4148
|
variant="transparent"
|
|
4157
4149
|
padding={4}
|
|
4158
|
-
paddingBlockStart={isCompactSurface ?
|
|
4150
|
+
paddingBlockStart={isCompactSurface ? 0 : 4}>
|
|
4159
4151
|
<VStack gap={4}>
|
|
4160
4152
|
<HStack gap={2}>
|
|
4161
4153
|
<StackItem size="fill">
|
|
@@ -1785,12 +1785,9 @@ function ComposeWindow({
|
|
|
1785
1785
|
hasScrim={false}
|
|
1786
1786
|
// The Layout below owns the inset; the sheet adds none of its own.
|
|
1787
1787
|
padding={0}>
|
|
1788
|
-
{/* The
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
sheet buys the handle its own band of space before the content
|
|
1792
|
-
starts. */}
|
|
1793
|
-
<VStack paddingBlockStart={3}>
|
|
1788
|
+
{/* The sheet keeps a band under its grab handle at rest, so the Layout
|
|
1789
|
+
starts below the pill without a band of its own. */}
|
|
1790
|
+
<VStack>
|
|
1794
1791
|
<Layout
|
|
1795
1792
|
// auto, not fill: the sheet is sized by this Layout now rather than
|
|
1796
1793
|
// the other way round. `fill` would stretch it to a height that
|
|
@@ -394,8 +394,8 @@ export const butterTheme = defineTheme({
|
|
|
394
394
|
// Radius intentionally not pinned, so buttons keep core's
|
|
395
395
|
// `var(--_button-radius, --radius-element)`: standalone buttons stay 8px,
|
|
396
396
|
// while the chat composer (which sets --_button-radius) rounds them fully.
|
|
397
|
+
// Inline padding only. Button sets its own block padding and height.
|
|
397
398
|
base: {
|
|
398
|
-
paddingBlock: 'var(--spacing-3)',
|
|
399
399
|
paddingInline: 'var(--spacing-4)',
|
|
400
400
|
},
|
|
401
401
|
'variant:secondary': SECONDARY_BUTTON,
|
|
@@ -12,6 +12,13 @@ import {levenshteinDistance} from '../text/string-utils.mjs';
|
|
|
12
12
|
|
|
13
13
|
const SKIP_DIRS = new Set(['utils', '__tests__', 'node_modules']);
|
|
14
14
|
|
|
15
|
+
// A hook's doc outside src/hooks/: a `use…` hook, or a `create…` factory
|
|
16
|
+
// documented as one (its doc typed `HookDoc`, such as createReorderable).
|
|
17
|
+
const HOOK_DOC_FILE_RE = /^(?:use|create)[A-Z]\w*\.doc\.mjs$/;
|
|
18
|
+
|
|
19
|
+
// Matches the top-level `relatedHooks: [...]` field in a hook .doc.mjs file.
|
|
20
|
+
const RELATED_HOOKS_RE = /(?:^|\n) {0,4}relatedHooks:\s*\[([^\]]*)\]/;
|
|
21
|
+
|
|
15
22
|
// Matches the top-level `category: 'categoryName'` field in a hook .doc.mjs file.
|
|
16
23
|
const CATEGORY_RE = /(?:^|\n) {0,4}category:\s*['"]([^'"]+)['"]/;
|
|
17
24
|
|
|
@@ -32,6 +39,25 @@ function readHookMeta(docPath) {
|
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Whether a `create…` factory's doc names its own `use…` hook in
|
|
44
|
+
* `relatedHooks`, as createAnimator's names useAnimator. Such a factory is
|
|
45
|
+
* listed beside that hook, not on its own.
|
|
46
|
+
* @param {string} docPath
|
|
47
|
+
* @param {string} hookName
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function isBesideOwnHook(docPath, hookName) {
|
|
51
|
+
if (!hookName.startsWith('create')) return false;
|
|
52
|
+
const ownHook = `use${hookName.slice('create'.length)}`;
|
|
53
|
+
try {
|
|
54
|
+
const related = RELATED_HOOKS_RE.exec(fs.readFileSync(docPath, 'utf-8'));
|
|
55
|
+
return related != null && related[1].includes(`'${ownHook}'`);
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
/**
|
|
36
62
|
* Extract the hook name from a doc filename.
|
|
37
63
|
* e.g. 'useFocusTrap.doc.mjs' → 'useFocusTrap'
|
|
@@ -70,7 +96,8 @@ export function discoverHooks(coreDir) {
|
|
|
70
96
|
}
|
|
71
97
|
}
|
|
72
98
|
|
|
73
|
-
// 2. Scan ALL component directories for
|
|
99
|
+
// 2. Scan ALL component directories for hook docs: use*.doc.mjs, and
|
|
100
|
+
// create*.doc.mjs for a factory documented as a hook (createReorderable)
|
|
74
101
|
if (pathExists(srcDir)) {
|
|
75
102
|
const topEntries = listDirEntries(srcDir);
|
|
76
103
|
for (const entry of topEntries) {
|
|
@@ -120,14 +147,10 @@ function scanDirForHookDocs(dirPath, hookCategories) {
|
|
|
120
147
|
for (const entry of entries) {
|
|
121
148
|
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
122
149
|
scanDirForHookDocs(path.join(dirPath, entry.name), hookCategories);
|
|
123
|
-
} else if (
|
|
124
|
-
entry.isFile() &&
|
|
125
|
-
entry.name.startsWith('use') &&
|
|
126
|
-
entry.name.endsWith('.doc.mjs')
|
|
127
|
-
) {
|
|
150
|
+
} else if (entry.isFile() && HOOK_DOC_FILE_RE.test(entry.name)) {
|
|
128
151
|
const hookName = hookNameFromFile(entry.name);
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
const docPath = path.join(dirPath, entry.name);
|
|
153
|
+
if (!hookCategories.has(hookName) && !isBesideOwnHook(docPath, hookName)) {
|
|
131
154
|
const {category} = readHookMeta(docPath);
|
|
132
155
|
hookCategories.set(hookName, category || 'Other');
|
|
133
156
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import {pathToFileURL} from 'node:url';
|
|
26
26
|
import {createJiti} from 'jiti';
|
|
27
|
+
import {importUrl} from '../fs/import-url.mjs';
|
|
27
28
|
import {importUserModule} from '../fs/module-loader.mjs';
|
|
28
29
|
import {fileStamp, recordContentRead} from '../fs/read-record.mjs';
|
|
29
30
|
|
|
@@ -105,14 +106,14 @@ export function importTemplateModule(file) {
|
|
|
105
106
|
jsxJiti ??= createJiti(import.meta.url, {jsx: true});
|
|
106
107
|
return jsxJiti.import(first);
|
|
107
108
|
}
|
|
108
|
-
return
|
|
109
|
+
return importUrl(`file://${first}`);
|
|
109
110
|
},
|
|
110
111
|
changed =>
|
|
111
112
|
changed.endsWith('.ts')
|
|
112
113
|
? createJiti(import.meta.url, {jsx: true, moduleCache: false}).import(
|
|
113
114
|
changed,
|
|
114
115
|
)
|
|
115
|
-
:
|
|
116
|
+
: importUrl(freshUrl(changed)),
|
|
116
117
|
);
|
|
117
118
|
}
|
|
118
119
|
|
|
@@ -126,7 +127,7 @@ export function importNativeModule(file) {
|
|
|
126
127
|
return importCurrent(
|
|
127
128
|
file,
|
|
128
129
|
'native',
|
|
129
|
-
first =>
|
|
130
|
-
changed =>
|
|
130
|
+
first => importUrl(pathToFileURL(first).href),
|
|
131
|
+
changed => importUrl(freshUrl(changed)),
|
|
131
132
|
);
|
|
132
133
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @file The dynamic import of an authored module by its file URL.
|
|
6
|
+
*
|
|
7
|
+
* @input An absolute `file:` URL, with any query its caller adds to name a
|
|
8
|
+
* fresh module.
|
|
9
|
+
* @output The module's namespace object, as a plain `import()` gives it.
|
|
10
|
+
* @position Called by the doc importers (../doc-compiler/import.mjs). The
|
|
11
|
+
* node test project loads this file natively, as the CLI does
|
|
12
|
+
* (vitest.config.ts). The doc files that a catalog command imports then load
|
|
13
|
+
* natively as well, without a request to the test runner for each of them.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Import the module at `url`.
|
|
17
|
+
* @param {string} url an absolute `file:` URL
|
|
18
|
+
* @returns {Promise<any>}
|
|
19
|
+
*/
|
|
20
|
+
export function importUrl(url: string): Promise<any>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file The dynamic import of an authored module by its file URL.
|
|
5
|
+
*
|
|
6
|
+
* @input An absolute `file:` URL, with any query its caller adds to name a
|
|
7
|
+
* fresh module.
|
|
8
|
+
* @output The module's namespace object, as a plain `import()` gives it.
|
|
9
|
+
* @position Called by the doc importers (../doc-compiler/import.mjs). The
|
|
10
|
+
* node test project loads this file natively, as the CLI does
|
|
11
|
+
* (vitest.config.ts). The doc files that a catalog command imports then load
|
|
12
|
+
* natively as well, without a request to the test runner for each of them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Import the module at `url`.
|
|
17
|
+
* @param {string} url an absolute `file:` URL
|
|
18
|
+
* @returns {Promise<any>}
|
|
19
|
+
*/
|
|
20
|
+
export function importUrl(url) {
|
|
21
|
+
return import(url);
|
|
22
|
+
}
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Import a user-authored module. `.ts` is loaded via jiti; `.mjs`/`.js` use
|
|
6
6
|
* native dynamic import for ordinary cached reads. A fresh `.ts` read uses a
|
|
7
|
-
* no-cache jiti instance.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* no-cache jiti instance. Any other fresh read drops the module from the
|
|
8
|
+
* require cache and imports a cache-busted file URL. Node then chooses its
|
|
9
|
+
* format as for the first read, so a fresh read gives what a first read gives.
|
|
10
10
|
* Normal loads retain module caching. `fresh` rereads a file that changed
|
|
11
11
|
* during this process: one a codemod rewrote, or an edited doc file (the doc
|
|
12
12
|
* importers in ../doc-compiler/import.mjs read each change once).
|
|
@@ -29,6 +29,17 @@ export function importUserModule(file: string, { fresh }?: {
|
|
|
29
29
|
* @returns {Promise<Record<string, unknown>>}
|
|
30
30
|
*/
|
|
31
31
|
export function importThemeArtifact(specifier: string, cwd: string): Promise<Record<string, unknown>>;
|
|
32
|
+
/**
|
|
33
|
+
* Run `read`, an import of `file`, once the imports of `file` started before
|
|
34
|
+
* it have ended. Imports of other files do not wait. A fresh read removes the
|
|
35
|
+
* file from Node's CommonJS cache. A CommonJS import still in progress needs
|
|
36
|
+
* that entry until it ends.
|
|
37
|
+
* @template T
|
|
38
|
+
* @param {string} file absolute path
|
|
39
|
+
* @param {() => Promise<T>} read
|
|
40
|
+
* @returns {Promise<T>}
|
|
41
|
+
*/
|
|
42
|
+
export function readInTurn<T>(file: string, read: () => Promise<T>): Promise<T>;
|
|
32
43
|
/**
|
|
33
44
|
* Import a user-authored module, take its default export, and validate it
|
|
34
45
|
* through an authoring parser. This is the single load/validation boundary for
|
|
@@ -40,30 +40,6 @@ function getJiti() {
|
|
|
40
40
|
return jitiInstance;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
* Node treats `.js` as ESM only inside the nearest package scope whose
|
|
45
|
-
* package.json declares `"type": "module"`; no package.json defaults to
|
|
46
|
-
* CommonJS.
|
|
47
|
-
* @param {string} file
|
|
48
|
-
* @returns {boolean}
|
|
49
|
-
*/
|
|
50
|
-
function isCommonJsFile(file) {
|
|
51
|
-
let dir = path.dirname(file);
|
|
52
|
-
for (;;) {
|
|
53
|
-
const packageJson = path.join(dir, 'package.json');
|
|
54
|
-
if (fs.existsSync(packageJson)) {
|
|
55
|
-
try {
|
|
56
|
-
return JSON.parse(fs.readFileSync(packageJson, 'utf-8')).type !== 'module';
|
|
57
|
-
} catch {
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
const parent = path.dirname(dir);
|
|
62
|
-
if (parent === dir) return true;
|
|
63
|
-
dir = parent;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
43
|
let loadsInFlight = 0;
|
|
68
44
|
/** @type {typeof process.stdout.write | undefined} */
|
|
69
45
|
let realStdoutWrite;
|
|
@@ -104,9 +80,9 @@ async function withStdoutOnStderr(load) {
|
|
|
104
80
|
/**
|
|
105
81
|
* Import a user-authored module. `.ts` is loaded via jiti; `.mjs`/`.js` use
|
|
106
82
|
* native dynamic import for ordinary cached reads. A fresh `.ts` read uses a
|
|
107
|
-
* no-cache jiti instance.
|
|
108
|
-
*
|
|
109
|
-
*
|
|
83
|
+
* no-cache jiti instance. Any other fresh read drops the module from the
|
|
84
|
+
* require cache and imports a cache-busted file URL. Node then chooses its
|
|
85
|
+
* format as for the first read, so a fresh read gives what a first read gives.
|
|
110
86
|
* Normal loads retain module caching. `fresh` rereads a file that changed
|
|
111
87
|
* during this process: one a codemod rewrote, or an edited doc file (the doc
|
|
112
88
|
* importers in ../doc-compiler/import.mjs read each change once).
|
|
@@ -251,31 +227,64 @@ async function importModule(file, fresh) {
|
|
|
251
227
|
if (fresh && file.endsWith('.ts')) {
|
|
252
228
|
return await createJiti(import.meta.url, {moduleCache: false}).import(file);
|
|
253
229
|
}
|
|
254
|
-
if (fresh && file.endsWith('.js') && isCommonJsFile(file)) {
|
|
255
|
-
const resolved = requireFromHere.resolve(file);
|
|
256
|
-
delete requireFromHere.cache[resolved];
|
|
257
|
-
const value = requireFromHere(file);
|
|
258
|
-
if (
|
|
259
|
-
value != null &&
|
|
260
|
-
typeof value === 'object' &&
|
|
261
|
-
value.__esModule === true &&
|
|
262
|
-
Object.prototype.hasOwnProperty.call(value, 'default')
|
|
263
|
-
) {
|
|
264
|
-
return value;
|
|
265
|
-
}
|
|
266
|
-
return {
|
|
267
|
-
...(value != null && typeof value === 'object' ? value : {}),
|
|
268
|
-
default: value,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
230
|
if (file.endsWith('.ts')) {
|
|
272
231
|
return await getJiti().import(file);
|
|
273
232
|
}
|
|
274
233
|
const url = pathToFileURL(file);
|
|
275
|
-
if (fresh)
|
|
276
|
-
|
|
234
|
+
if (!fresh) return await readInTurn(file, () => import(url.href));
|
|
235
|
+
// Node chooses the format again, as for the first import: a package's
|
|
236
|
+
// "type", the extension, or the source's syntax. The query names a new ES
|
|
237
|
+
// module. A CommonJS module also needs its cache entry gone.
|
|
238
|
+
url.searchParams.set('astryx-fresh', String(++freshImportNonce));
|
|
239
|
+
return await readInTurn(file, () => {
|
|
240
|
+
delete requireFromHere.cache[cacheKey(file)];
|
|
241
|
+
return import(url.href);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The name Node's CommonJS cache gives `file`: its real path, so a link and
|
|
247
|
+
* its target name one entry. A file that does not resolve keeps its path.
|
|
248
|
+
* @param {string} file absolute path
|
|
249
|
+
* @returns {string}
|
|
250
|
+
*/
|
|
251
|
+
function cacheKey(file) {
|
|
252
|
+
try {
|
|
253
|
+
return requireFromHere.resolve(file);
|
|
254
|
+
} catch {
|
|
255
|
+
return file;
|
|
277
256
|
}
|
|
278
|
-
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Each file's latest read, by its cache name, settled once that read ends.
|
|
261
|
+
* @type {Map<string, Promise<void>>}
|
|
262
|
+
*/
|
|
263
|
+
const readsInProgress = new Map();
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Run `read`, an import of `file`, once the imports of `file` started before
|
|
267
|
+
* it have ended. Imports of other files do not wait. A fresh read removes the
|
|
268
|
+
* file from Node's CommonJS cache. A CommonJS import still in progress needs
|
|
269
|
+
* that entry until it ends.
|
|
270
|
+
* @template T
|
|
271
|
+
* @param {string} file absolute path
|
|
272
|
+
* @param {() => Promise<T>} read
|
|
273
|
+
* @returns {Promise<T>}
|
|
274
|
+
*/
|
|
275
|
+
export function readInTurn(file, read) {
|
|
276
|
+
const key = cacheKey(file);
|
|
277
|
+
const before = readsInProgress.get(key) ?? Promise.resolve();
|
|
278
|
+
const result = before.then(read);
|
|
279
|
+
const ended = result.then(
|
|
280
|
+
() => {},
|
|
281
|
+
() => {},
|
|
282
|
+
);
|
|
283
|
+
readsInProgress.set(key, ended);
|
|
284
|
+
void ended.then(() => {
|
|
285
|
+
if (readsInProgress.get(key) === ended) readsInProgress.delete(key);
|
|
286
|
+
});
|
|
287
|
+
return result;
|
|
279
288
|
}
|
|
280
289
|
|
|
281
290
|
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* @param {number} [max] - The largest distance the caller can use. When
|
|
20
20
|
* given, a larger distance answers `max + 1`, and only the cells within
|
|
21
21
|
* `max` of the diagonal are computed.
|
|
22
|
+
* @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
|
|
23
|
+
* adjacent letters swapped cost one edit, not two (the optimal string
|
|
24
|
+
* alignment distance).
|
|
22
25
|
* @returns {number}
|
|
23
26
|
*/
|
|
24
|
-
export function levenshteinDistance(a: string, b: string, max?: number
|
|
27
|
+
export function levenshteinDistance(a: string, b: string, max?: number, { swaps }?: {
|
|
28
|
+
swaps?: boolean;
|
|
29
|
+
}): number;
|
|
@@ -19,11 +19,15 @@
|
|
|
19
19
|
* @param {number} [max] - The largest distance the caller can use. When
|
|
20
20
|
* given, a larger distance answers `max + 1`, and only the cells within
|
|
21
21
|
* `max` of the diagonal are computed.
|
|
22
|
+
* @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
|
|
23
|
+
* adjacent letters swapped cost one edit, not two (the optimal string
|
|
24
|
+
* alignment distance).
|
|
22
25
|
* @returns {number}
|
|
23
26
|
*/
|
|
24
|
-
export function levenshteinDistance(a, b, max) {
|
|
25
|
-
if (max !== undefined) return distanceWithin(a, b, max);
|
|
26
|
-
const m = a.length,
|
|
27
|
+
export function levenshteinDistance(a, b, max, {swaps = false} = {}) {
|
|
28
|
+
if (max !== undefined) return distanceWithin(a, b, max, swaps);
|
|
29
|
+
const m = a.length,
|
|
30
|
+
n = b.length;
|
|
27
31
|
// Early exit: the distance is always ≥ |m − n|, so once the length gap
|
|
28
32
|
// exceeds the loosest fuzzy-match threshold any caller uses (the hook
|
|
29
33
|
// suggester keeps distance ≤ 5) the pair can never match — skip the O(m·n)
|
|
@@ -36,9 +40,10 @@ export function levenshteinDistance(a, b, max) {
|
|
|
36
40
|
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
37
41
|
for (let i = 1; i <= m; i++) {
|
|
38
42
|
for (let j = 1; j <= n; j++) {
|
|
39
|
-
dp[i][j] =
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
dp[i][j] =
|
|
44
|
+
a[i - 1] === b[j - 1]
|
|
45
|
+
? dp[i - 1][j - 1]
|
|
46
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
42
47
|
}
|
|
43
48
|
}
|
|
44
49
|
return dp[m][n];
|
|
@@ -51,11 +56,13 @@ export function levenshteinDistance(a, b, max) {
|
|
|
51
56
|
* @param {string} a
|
|
52
57
|
* @param {string} b
|
|
53
58
|
* @param {number} max
|
|
59
|
+
* @param {boolean} swaps - two adjacent letters swapped cost one edit
|
|
54
60
|
* @returns {number}
|
|
55
61
|
*/
|
|
56
|
-
function distanceWithin(a, b, max) {
|
|
62
|
+
function distanceWithin(a, b, max, swaps) {
|
|
57
63
|
const over = max + 1;
|
|
58
64
|
if (Math.abs(a.length - b.length) > max) return over;
|
|
65
|
+
let before = new Array(b.length + 1).fill(over);
|
|
59
66
|
let previous = new Array(b.length + 1).fill(over);
|
|
60
67
|
let current = new Array(b.length + 1).fill(over);
|
|
61
68
|
for (let j = 0; j <= Math.min(b.length, max); j++) previous[j] = j;
|
|
@@ -67,18 +74,27 @@ function distanceWithin(a, b, max) {
|
|
|
67
74
|
let rowMin = current[0];
|
|
68
75
|
for (let j = from; j <= to; j++) {
|
|
69
76
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
70
|
-
|
|
77
|
+
let value = Math.min(
|
|
71
78
|
previous[j - 1] + cost,
|
|
72
79
|
previous[j] + 1,
|
|
73
80
|
current[j - 1] + 1,
|
|
74
81
|
over,
|
|
75
82
|
);
|
|
83
|
+
if (
|
|
84
|
+
swaps &&
|
|
85
|
+
i > 1 &&
|
|
86
|
+
j > 1 &&
|
|
87
|
+
a[i - 1] === b[j - 2] &&
|
|
88
|
+
a[i - 2] === b[j - 1]
|
|
89
|
+
) {
|
|
90
|
+
value = Math.min(value, before[j - 2] + 1);
|
|
91
|
+
}
|
|
76
92
|
current[j] = value;
|
|
77
93
|
if (value < rowMin) rowMin = value;
|
|
78
94
|
}
|
|
79
95
|
// Every later cell costs at least this row's least.
|
|
80
96
|
if (rowMin >= over) return over;
|
|
81
|
-
[previous, current] = [current,
|
|
97
|
+
[before, previous, current] = [previous, current, before];
|
|
82
98
|
}
|
|
83
99
|
return Math.min(previous[b.length], over);
|
|
84
100
|
}
|
|
@@ -18,15 +18,22 @@ export function nameWords(text: string): string[];
|
|
|
18
18
|
* when it carries some of them, the items that carry none are left out. When
|
|
19
19
|
* no item carries one, nothing is left out. The list only ever gets shorter,
|
|
20
20
|
* and its order does not change.
|
|
21
|
+
*
|
|
22
|
+
* With `typos`, a near name (one or two slips from the typed name, as
|
|
23
|
+
* `ButonGroup` or `ButtonGorup` for ButtonGroup) ranks just below the names
|
|
24
|
+
* that carry every typed word and above the rest. With `closest` too, near
|
|
25
|
+
* names stay in the list whatever the cut leaves out, and the list is never
|
|
26
|
+
* longer than it is without `typos`.
|
|
21
27
|
* @template T
|
|
22
28
|
* @param {string} typedName
|
|
23
29
|
* @param {T[]} items
|
|
24
30
|
* @param {(item: T) => Array<string | undefined>} namesOf
|
|
25
|
-
* @param {{closest?: boolean}} [options]
|
|
31
|
+
* @param {{closest?: boolean, typos?: boolean}} [options]
|
|
26
32
|
* @returns {T[]}
|
|
27
33
|
*/
|
|
28
34
|
export function orderByAffinity<T>(typedName: string, items: T[], namesOf: (item: T) => Array<string | undefined>, options?: {
|
|
29
35
|
closest?: boolean;
|
|
36
|
+
typos?: boolean;
|
|
30
37
|
}): T[];
|
|
31
38
|
/**
|
|
32
39
|
* Docs route suggestions, closest to the typed route first. When every
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
|
|
20
20
|
import {levenshteinDistance} from './levenshtein.mjs';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The score of a near name (nearNameSlips), less a little per slip: above any
|
|
24
|
+
* name that carries only some of the typed words, below one that carries all
|
|
25
|
+
* of them.
|
|
26
|
+
*/
|
|
27
|
+
const NEAR_NAME_SCORE = 19.6;
|
|
22
28
|
/** The score a synonym match gets: an exact keyword's. */
|
|
23
29
|
const SYNONYM_SCORE = 90;
|
|
24
30
|
/** The score a name with exactly the typed words gets: an exact name's. */
|
|
@@ -59,17 +65,22 @@ export function nameWords(text) {
|
|
|
59
65
|
* closer. Tiers, best first: the same words; every typed word present; some
|
|
60
66
|
* typed word present; none. Within a tier, more of the typed words and more of
|
|
61
67
|
* the candidate's own words matched rank higher, and a name that starts with
|
|
62
|
-
* the typed name (or the reverse) gets a small lift.
|
|
68
|
+
* the typed name (or the reverse) gets a small lift. With `typos`, a near name
|
|
69
|
+
* (one or two slips from the typed name) ranks just below the names that
|
|
70
|
+
* carry every typed word.
|
|
63
71
|
* @param {string[]} typed - {@link nameWords} of the typed name
|
|
64
72
|
* @param {string} name
|
|
73
|
+
* @param {{typos?: boolean}} [options]
|
|
65
74
|
* @returns {number}
|
|
66
75
|
*/
|
|
67
|
-
function affinityOf(typed, name) {
|
|
76
|
+
function affinityOf(typed, name, {typos = false} = {}) {
|
|
68
77
|
const words = nameWords(name);
|
|
69
78
|
if (typed.length === 0 || words.length === 0) return 0;
|
|
70
79
|
const typedJoined = typed.join('');
|
|
71
80
|
const joined = words.join('');
|
|
72
81
|
if (joined === typedJoined) return 40;
|
|
82
|
+
const slips = typos ? nearNameSlips(typedJoined, joined) : 0;
|
|
83
|
+
const near = slips > 0 ? NEAR_NAME_SCORE - slips * 0.2 : 0;
|
|
73
84
|
const have = new Set(words);
|
|
74
85
|
const want = new Set(typed);
|
|
75
86
|
const covered = typed.filter(w => have.has(w)).length / typed.length;
|
|
@@ -77,7 +88,29 @@ function affinityOf(typed, name) {
|
|
|
77
88
|
const prefix =
|
|
78
89
|
joined.startsWith(typedJoined) || typedJoined.startsWith(joined) ? 1 : 0;
|
|
79
90
|
const tier = covered === 1 ? 2 : covered > 0 ? 1 : 0;
|
|
80
|
-
|
|
91
|
+
// A near name keeps its word score when that is higher: a name that also
|
|
92
|
+
// carries every typed word ranks among the other full matches.
|
|
93
|
+
return Math.max(
|
|
94
|
+
near,
|
|
95
|
+
tier * 10 + covered * 4 + precise * 2 + prefix + precise * 0.01,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* How many slips separate a typed name from a candidate, both with their
|
|
101
|
+
* words joined, when it is a near name: an edit or two adjacent letters
|
|
102
|
+
* swapped count as one slip, one slip is allowed from five letters and two from
|
|
103
|
+
* eight. Zero when it is not a near name.
|
|
104
|
+
* @param {string} typed
|
|
105
|
+
* @param {string} name
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
function nearNameSlips(typed, name) {
|
|
109
|
+
const shorter = Math.min(typed.length, name.length);
|
|
110
|
+
const cap = shorter >= 8 ? 2 : shorter >= 5 ? 1 : 0;
|
|
111
|
+
if (cap === 0 || Math.abs(typed.length - name.length) > cap) return 0;
|
|
112
|
+
const slips = levenshteinDistance(typed, name, cap, {swaps: true});
|
|
113
|
+
return slips <= cap ? slips : 0;
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
/**
|
|
@@ -87,14 +120,15 @@ function affinityOf(typed, name) {
|
|
|
87
120
|
* @param {string} typedName
|
|
88
121
|
* @param {T[]} items
|
|
89
122
|
* @param {(item: T) => Array<string | undefined>} namesOf
|
|
123
|
+
* @param {{typos?: boolean}} [options]
|
|
90
124
|
* @returns {Array<{item: T, best: number}>}
|
|
91
125
|
*/
|
|
92
|
-
function scoreByAffinity(typedName, items, namesOf) {
|
|
126
|
+
function scoreByAffinity(typedName, items, namesOf, options = {}) {
|
|
93
127
|
const typed = nameWords(typedName);
|
|
94
128
|
const scored = items.map((item, index) => {
|
|
95
129
|
let best = 0;
|
|
96
130
|
for (const name of namesOf(item)) {
|
|
97
|
-
if (name) best = Math.max(best, affinityOf(typed, name));
|
|
131
|
+
if (name) best = Math.max(best, affinityOf(typed, name, options));
|
|
98
132
|
}
|
|
99
133
|
return {item, index, best};
|
|
100
134
|
});
|
|
@@ -112,14 +146,46 @@ function scoreByAffinity(typedName, items, namesOf) {
|
|
|
112
146
|
* when it carries some of them, the items that carry none are left out. When
|
|
113
147
|
* no item carries one, nothing is left out. The list only ever gets shorter,
|
|
114
148
|
* and its order does not change.
|
|
149
|
+
*
|
|
150
|
+
* With `typos`, a near name (one or two slips from the typed name, as
|
|
151
|
+
* `ButonGroup` or `ButtonGorup` for ButtonGroup) ranks just below the names
|
|
152
|
+
* that carry every typed word and above the rest. With `closest` too, near
|
|
153
|
+
* names stay in the list whatever the cut leaves out, and the list is never
|
|
154
|
+
* longer than it is without `typos`.
|
|
115
155
|
* @template T
|
|
116
156
|
* @param {string} typedName
|
|
117
157
|
* @param {T[]} items
|
|
118
158
|
* @param {(item: T) => Array<string | undefined>} namesOf
|
|
119
|
-
* @param {{closest?: boolean}} [options]
|
|
159
|
+
* @param {{closest?: boolean, typos?: boolean}} [options]
|
|
120
160
|
* @returns {T[]}
|
|
121
161
|
*/
|
|
122
162
|
export function orderByAffinity(typedName, items, namesOf, options = {}) {
|
|
163
|
+
if (!options.typos) return orderByWords(typedName, items, namesOf, options);
|
|
164
|
+
const scored = scoreByAffinity(typedName, items, namesOf, {typos: true});
|
|
165
|
+
if (!options.closest) return scored.map(s => s.item);
|
|
166
|
+
const kept = new Set(orderByWords(typedName, items, namesOf, options));
|
|
167
|
+
return scored
|
|
168
|
+
.filter(s => kept.has(s.item) || isNearScore(s.best))
|
|
169
|
+
.slice(0, kept.size)
|
|
170
|
+
.map(s => s.item);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether a score is a near name's.
|
|
175
|
+
* @param {number} score
|
|
176
|
+
*/
|
|
177
|
+
const isNearScore = score => score < 20 && score > NEAR_NAME_SCORE - 1;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* {@link orderByAffinity} without `typos`.
|
|
181
|
+
* @template T
|
|
182
|
+
* @param {string} typedName
|
|
183
|
+
* @param {T[]} items
|
|
184
|
+
* @param {(item: T) => Array<string | undefined>} namesOf
|
|
185
|
+
* @param {{closest?: boolean}} options
|
|
186
|
+
* @returns {T[]}
|
|
187
|
+
*/
|
|
188
|
+
function orderByWords(typedName, items, namesOf, options) {
|
|
123
189
|
const scored = scoreByAffinity(typedName, items, namesOf);
|
|
124
190
|
if (!options.closest) return scored.map(s => s.item);
|
|
125
191
|
// One-letter words (`a` in `a11y`, `use-a-theme`) say nothing about which
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.7.12-canary.
|
|
3
|
+
"version": "0.7.12-canary.c5cc8f4",
|
|
4
4
|
"displayName": "CLI",
|
|
5
5
|
"description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"zod": "^4.4.3"
|
|
126
126
|
},
|
|
127
127
|
"peerDependencies": {
|
|
128
|
-
"@astryxdesign/core": "0.7.12-canary.
|
|
128
|
+
"@astryxdesign/core": "0.7.12-canary.c5cc8f4"
|
|
129
129
|
},
|
|
130
130
|
"peerDependenciesMeta": {
|
|
131
131
|
"@astryxdesign/core": {
|
|
@@ -133,10 +133,10 @@
|
|
|
133
133
|
}
|
|
134
134
|
},
|
|
135
135
|
"devDependencies": {
|
|
136
|
-
"@astryxdesign/charts": "0.7.12-canary.
|
|
137
|
-
"@astryxdesign/core": "0.7.12-canary.
|
|
138
|
-
"@astryxdesign/lab": "0.7.12-canary.
|
|
139
|
-
"@astryxdesign/theme-neutral": "0.7.12-canary.
|
|
136
|
+
"@astryxdesign/charts": "0.7.12-canary.c5cc8f4",
|
|
137
|
+
"@astryxdesign/core": "0.7.12-canary.c5cc8f4",
|
|
138
|
+
"@astryxdesign/lab": "0.7.12-canary.c5cc8f4",
|
|
139
|
+
"@astryxdesign/theme-neutral": "0.7.12-canary.c5cc8f4",
|
|
140
140
|
"@heroicons/react": "^2.2.0",
|
|
141
141
|
"@stylexjs/stylex": "^0.19.0",
|
|
142
142
|
"@types/babel__core": "^7.20.5",
|