@astryxdesign/cli 0.4.1 → 0.4.2-canary.01592f7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -0
  3. package/api/init/init.doc.mjs +2 -2
  4. package/api/init/init.test.mjs +19 -2
  5. package/api/init/init.type.d.mts +9 -1
  6. package/api/init/init.type.mjs +3 -1
  7. package/api/init/run/run.mjs +36 -6
  8. package/api/theme/add/add.mjs +2 -13
  9. package/api/theme/build/build.font-warning.test.mjs +161 -0
  10. package/api/theme/build/build.mjs +95 -26
  11. package/api/theme/build/build.test.mjs +227 -0
  12. package/api/theme/build/font-warning.d.mts +26 -0
  13. package/api/theme/build/font-warning.mjs +214 -0
  14. package/api/theme/build/font-warning.test.mjs +242 -0
  15. package/api/theme/template/template.d.mts +21 -0
  16. package/api/theme/template/template.mjs +69 -0
  17. package/api/theme/template/template.test.mjs +82 -0
  18. package/api/theme/theme.d.mts +1 -0
  19. package/api/theme/theme.mjs +1 -0
  20. package/api/theme/theme.type.d.mts +13 -0
  21. package/api/theme/theme.type.mjs +11 -1
  22. package/api/theme/themeTemplate.doc.d.mts +11 -0
  23. package/api/theme/themeTemplate.doc.mjs +64 -0
  24. package/assets/docs/getting-started.doc.mjs +11 -1
  25. package/assets/docs/theme.doc.dense.mjs +2 -2
  26. package/assets/docs/theme.doc.mjs +26 -10
  27. package/assets/docs/theme.doc.zh.mjs +1 -1
  28. package/assets/docs/typography.doc.mjs +38 -0
  29. package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.doc.mjs +13 -0
  30. package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.tsx +55 -0
  31. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs +20 -0
  32. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.tsx +87 -0
  33. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.doc.mjs +15 -0
  34. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.tsx +199 -0
  35. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.doc.mjs +14 -0
  36. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.tsx +188 -0
  37. package/assets/templates/pages/ai-chat/page.tsx +18 -13
  38. package/assets/templates/themes/butter/butterTheme.ts +4 -1
  39. package/assets/templates/themes/chocolate/chocolateTheme.ts +4 -1
  40. package/assets/templates/themes/gothic/gothicTheme.ts +4 -1
  41. package/assets/templates/themes/stone/stoneTheme.ts +4 -1
  42. package/assets/theme.template.ts +322 -0
  43. package/authoring/doctypes/base/type.ts +9 -1
  44. package/authoring/doctypes/component/component.doc.mjs +1 -1
  45. package/clients/cli/commands/build-theme.font-warning.test.mjs +138 -0
  46. package/clients/cli/commands/build-theme.mjs +50 -0
  47. package/clients/cli/commands/init.behavior.test.mjs +13 -2
  48. package/clients/cli/commands/theme-template.behavior.test.mjs +85 -0
  49. package/clients/cli/commands/theme-template.doc.mjs +42 -0
  50. package/clients/cli/commands/theme.doc.mjs +2 -2
  51. package/clients/cli/index.mjs +1 -0
  52. package/clients/cli/lib/manifest.mjs +2 -0
  53. package/foundation/agent-docs/agent-docs.mjs +1 -1
  54. package/foundation/fs/path-safety.mjs +2 -2
  55. package/foundation/fs/path-safety.test.mjs +7 -0
  56. package/foundation/response/response-types.doc.mjs +5 -0
  57. package/foundation/text/copyright-header.d.mts +11 -0
  58. package/foundation/text/copyright-header.mjs +35 -0
  59. package/foundation/text/copyright-header.test.mjs +58 -0
  60. package/package.json +9 -9
@@ -8,7 +8,7 @@ export const docsZh = {
8
8
  { section: 'Quick Start', title: '快速开始', content: [null, null, null, null, { type: 'prose', text: '默认导入使用运行时样式注入。/built 导入使用预编译 CSS(需配合 theme.css)。' }] },
9
9
  { section: 'Available Themes', title: '可用主题', content: [null, null, { type: 'prose', text: '已发布主题:neutral(推荐起点)、butter、chocolate、gothic(仅暗色)、matcha、stone、y2k。@astryxdesign/theme-{name} = 源码版(运行时注入)。@astryxdesign/theme-{name}/built = 优化版(配合 theme.css)。' }] },
10
10
  { section: 'Theme Props', title: 'Theme 属性', content: [null] },
11
- { section: 'Creating a Custom Theme', title: '创建自定义主题', content: [{ type: 'prose', text: '使用 CLI 向导(推荐)或手动 defineTheme。只覆盖与默认值不同的令牌。' }, null] },
11
+ { section: 'Creating a Custom Theme', title: '创建自定义主题', content: [{ type: 'prose', text: ' `theme list` + `theme add <slug>` 从内置主题开始,或用 defineTheme 从零编写。只覆盖与默认值不同的令牌。' }, null, { type: 'prose', text: '`astryx theme template` 会写入 theme.template.ts:带注释的完整参考,涵盖每个 defineTheme 字段、令牌族和覆盖语法,并标明打印各自参考的 CLI 命令。' }] },
12
12
  { section: 'defineTheme', title: 'defineTheme', content: [{ type: 'prose', text: '支持比例配置(typography、radius、motion)+ 显式令牌覆盖 + 组件覆盖。' }, null, null] },
13
13
  { section: 'Building Themes for Production', title: '生产构建', content: [{ type: 'prose', text: 'astryx theme build 将 defineTheme 编译为静态 CSS。输出 .css + .js(__built:true)+ .d.ts。' }, null, null, null, null] },
14
14
  { section: 'Runtime vs Built Themes', title: '运行时 vs 构建', content: [{ type: 'prose', text: '运行时:useInsertionEffect 在客户端注入样式。构建:静态 CSS 在首次渲染时就存在。SSR 应用请使用 /built + theme.css。' }, null, null, null] },
@@ -44,6 +44,44 @@ export const docs = {
44
44
  ],
45
45
  },
46
46
 
47
+ // ── Loading Custom Fonts ────────────────────────────────────────────────
48
+ {
49
+ title: 'Loading Custom Fonts',
50
+ category: 'foundations',
51
+ content: [
52
+ {
53
+ type: 'prose',
54
+ text: 'Astryx never loads font files. defineTheme and the built CSS only set font-family — naming a webfont (Fraunces, JetBrains Mono, …) makes every browser look for it, and quietly fall back when the app has not loaded it. `astryx theme build` warns when a theme names families that are neither CSS generics nor common system fonts and prints the snippet to add; loading the font is always the app\'s job.',
55
+ },
56
+ {
57
+ type: 'code',
58
+ lang: 'html',
59
+ label: 'Google Fonts — add to your document <head>',
60
+ code: `<link rel="preconnect" href="https://fonts.googleapis.com" />
61
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
62
+ <link
63
+ rel="stylesheet"
64
+ href="https://fonts.googleapis.com/css2?family=Fraunces:wght@400;600;700&family=JetBrains+Mono&display=swap"
65
+ />`,
66
+ },
67
+ {
68
+ type: 'code',
69
+ lang: 'css',
70
+ label: 'Self-hosted — one @font-face per family and weight',
71
+ code: `@font-face {
72
+ font-family: 'Fraunces';
73
+ src: url('/fonts/fraunces.woff2') format('woff2');
74
+ font-weight: 400 700; /* variable-font range, or one weight per file */
75
+ font-display: swap; /* fallback text stays visible while the font loads */
76
+ }`,
77
+ },
78
+ {
79
+ type: 'prose',
80
+ text: "Always pair a webfont with a real fallback stack — metric-similar system fonts plus a generic — so text stays readable before the font loads and wherever it never does: defineTheme({typography: {heading: {family: 'Fraunces', fallbacks: 'Georgia, serif'}}}).",
81
+ },
82
+ ],
83
+ },
84
+
47
85
  // ── Font Sizes ──────────────────────────────────────────────────────────
48
86
  {
49
87
  title: 'Font Sizes',
@@ -0,0 +1,13 @@
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: 'ChatMessageBubble',
7
+ name: 'ChatMessageBubble — Custom Content',
8
+ displayName: 'ChatMessageBubble — Custom Content',
9
+ description: 'Custom in-message content aligned to the bubble text column. An artifact card is wrapped in a ghost bubble with width="100%", so its left edge matches the bubble text and it spans the full message column instead of the default bubble width cap; the timestamp rides the bubble metadata slot.',
10
+ isReady: true,
11
+ aspectRatio: 4 / 3,
12
+ componentsUsed: ['Chat', 'ChatMessage', 'ChatMessageBubble', 'ChatMessageMetadata', 'ClickableCard', 'Text', 'Timestamp'],
13
+ };
@@ -0,0 +1,55 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {
6
+ ChatMessageList,
7
+ ChatMessage,
8
+ ChatMessageBubble,
9
+ ChatMessageMetadata,
10
+ } from '@astryxdesign/core/Chat';
11
+ import {ClickableCard} from '@astryxdesign/core/ClickableCard';
12
+ import {Text} from '@astryxdesign/core/Text';
13
+ import {Timestamp} from '@astryxdesign/core/Timestamp';
14
+
15
+ export default function ChatMessageBubbleCustomContent() {
16
+ return (
17
+ <ChatMessageList style={{maxWidth: 520}}>
18
+ <ChatMessage sender="user">
19
+ <ChatMessageBubble>Can you pull up the Q3 report?</ChatMessageBubble>
20
+ </ChatMessage>
21
+ <ChatMessage sender="assistant">
22
+ <ChatMessageBubble name="Navi">
23
+ Sure, here is the artifact from last quarter.
24
+ </ChatMessageBubble>
25
+ {/* A ghost bubble aligns custom content with the bubble text column;
26
+ width="100%" spans the full message column instead of the default
27
+ bubble width cap. Metadata rides the bubble's metadata slot. */}
28
+ <ChatMessageBubble
29
+ variant="ghost"
30
+ width="100%"
31
+ metadata={
32
+ <ChatMessageMetadata
33
+ timestamp={
34
+ <Timestamp value="2026-04-28T09:45:00" format="time" />
35
+ }
36
+ footer={
37
+ <Text type="supporting" color="secondary">
38
+ Claude Opus 4.6
39
+ </Text>
40
+ }
41
+ />
42
+ }>
43
+ <ClickableCard label="Open Q3 performance report" onClick={() => {}}>
44
+ <Text type="body" weight="semibold" display="block">
45
+ Q3 Performance Report
46
+ </Text>
47
+ <Text type="supporting" color="secondary" display="block">
48
+ 12 pages · updated 2 days ago
49
+ </Text>
50
+ </ClickableCard>
51
+ </ChatMessageBubble>
52
+ </ChatMessage>
53
+ </ChatMessageList>
54
+ );
55
+ }
@@ -0,0 +1,20 @@
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: 'ComplexSelector',
7
+ name: 'ComplexSelector — Deadline Picker',
8
+ displayName: 'Complex Selector — Deadline Picker',
9
+ description:
10
+ 'A multi-step deadline field: pick a preset like Today or Next week, or switch to a custom date and time before applying. The popup stays open until the user commits, so the content owns the Apply action.',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: [
14
+ 'ComplexSelector',
15
+ 'RadioList',
16
+ 'DateInput',
17
+ 'TimeInput',
18
+ 'Button',
19
+ ],
20
+ };
@@ -0,0 +1,87 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useState} from 'react';
6
+ import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
7
+ import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
8
+ import {DateInput} from '@astryxdesign/core/DateInput';
9
+ import {TimeInput} from '@astryxdesign/core/TimeInput';
10
+ import {Button} from '@astryxdesign/core/Button';
11
+ import {VStack} from '@astryxdesign/core/Layout';
12
+
13
+ type ISODate =
14
+ `${number}${number}${number}${number}-${number}${number}-${number}${number}`;
15
+ type ISOTime = string & {readonly __brand: 'ISOTimeString'};
16
+
17
+ interface Deadline {
18
+ preset: 'today' | 'next-week' | 'custom';
19
+ date: ISODate;
20
+ time: ISOTime;
21
+ }
22
+
23
+ const presetLabels: Record<Deadline['preset'], string> = {
24
+ today: 'Today',
25
+ 'next-week': 'Next week',
26
+ custom: 'Custom date',
27
+ };
28
+
29
+ function formatDeadline(value: Deadline) {
30
+ if (value.preset === 'custom') {
31
+ return `${value.date} at ${value.time}`;
32
+ }
33
+ return presetLabels[value.preset];
34
+ }
35
+
36
+ export default function ComplexSelectorDeadlinePicker() {
37
+ const [value, setValue] = useState<Deadline>({
38
+ preset: 'today',
39
+ date: '2026-04-06' as ISODate,
40
+ time: '17:00' as ISOTime,
41
+ });
42
+
43
+ return (
44
+ <ComplexSelector<Deadline>
45
+ label="Deadline"
46
+ description="Choose a preset or set a custom date and time."
47
+ value={value}
48
+ onChange={setValue}
49
+ triggerLabel={formatDeadline(value)}
50
+ style={{width: 320}}>
51
+ {(selectedValue, onChange, close) => {
52
+ const set = (patch: Partial<Deadline>) =>
53
+ onChange({...selectedValue, ...patch});
54
+
55
+ return (
56
+ <VStack gap={4} style={{width: 320}}>
57
+ <RadioList
58
+ label="When is it due?"
59
+ value={selectedValue.preset}
60
+ onChange={preset => set({preset: preset as Deadline['preset']})}>
61
+ <RadioListItem label="Today" value="today" />
62
+ <RadioListItem label="Next week" value="next-week" />
63
+ <RadioListItem label="Custom date" value="custom" />
64
+ </RadioList>
65
+
66
+ {selectedValue.preset === 'custom' && (
67
+ <VStack gap={3}>
68
+ <DateInput
69
+ label="Date"
70
+ value={selectedValue.date}
71
+ onChange={date => date && set({date})}
72
+ />
73
+ <TimeInput
74
+ label="Time"
75
+ value={selectedValue.time}
76
+ onChange={time => time && set({time})}
77
+ />
78
+ </VStack>
79
+ )}
80
+
81
+ <Button label="Apply" variant="primary" onClick={close} />
82
+ </VStack>
83
+ );
84
+ }}
85
+ </ComplexSelector>
86
+ );
87
+ }
@@ -0,0 +1,15 @@
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: 'ComplexSelector',
7
+ name: 'ComplexSelector',
8
+ displayName: 'Complex Selector',
9
+ description:
10
+ 'A two-axis picker: choose a fruit and a ripeness level from one control. ComplexSelector owns the trigger, popover, and focus restore while the custom grid owns its keyboard semantics.',
11
+ isReady: true,
12
+ aspectRatio: 1,
13
+ isShowcase: true,
14
+ componentsUsed: ['ComplexSelector', 'Text'],
15
+ };
@@ -0,0 +1,199 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useEffect, useState} from 'react';
6
+ import * as stylex from '@stylexjs/stylex';
7
+ import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
8
+ import {Text} from '@astryxdesign/core/Text';
9
+ import {useGridFocus} from '@astryxdesign/core/hooks';
10
+ import {
11
+ borderVars,
12
+ colorVars,
13
+ radiusVars,
14
+ spacingVars,
15
+ } from '@astryxdesign/core/theme/tokens.stylex';
16
+
17
+ type Fruit = 'Apple' | 'Pear' | 'Peach' | 'Plum';
18
+ type Ripeness = 'Crisp' | 'Tender' | 'Juicy' | 'Peak';
19
+
20
+ interface FruitValue {
21
+ fruit: Fruit;
22
+ ripeness: Ripeness;
23
+ }
24
+
25
+ const fruits: Array<{id: Fruit; emoji: string; description: string}> = [
26
+ {id: 'Apple', emoji: '🍎', description: 'Bright and balanced'},
27
+ {id: 'Pear', emoji: '🍐', description: 'Soft floral sweetness'},
28
+ {id: 'Peach', emoji: '🍑', description: 'Round summer flavor'},
29
+ {id: 'Plum', emoji: '🟣', description: 'Jammy and tart'},
30
+ ];
31
+
32
+ const ripenessLevels: Array<{
33
+ id: Ripeness;
34
+ shortLabel: string;
35
+ description: string;
36
+ }> = [
37
+ {id: 'Crisp', shortLabel: 'C', description: 'Snappy bite'},
38
+ {id: 'Tender', shortLabel: 'T', description: 'Easy bite'},
39
+ {id: 'Juicy', shortLabel: 'J', description: 'Full juice'},
40
+ {id: 'Peak', shortLabel: 'P', description: 'Most intense'},
41
+ ];
42
+
43
+ const GRID_CELL_SELECTOR = '[role="gridcell"]';
44
+
45
+ const styles = stylex.create({
46
+ grid: {
47
+ display: 'flex',
48
+ flexDirection: 'column',
49
+ gap: spacingVars['--spacing-1'],
50
+ minWidth: 280,
51
+ },
52
+ row: {
53
+ display: 'grid',
54
+ gridTemplateColumns: 'minmax(150px, 1fr) repeat(4, 44px)',
55
+ alignItems: 'center',
56
+ columnGap: spacingVars['--spacing-1'],
57
+ },
58
+ rowHeader: {
59
+ display: 'flex',
60
+ alignItems: 'center',
61
+ gap: spacingVars['--spacing-2'],
62
+ textAlign: 'start',
63
+ minWidth: 0,
64
+ },
65
+ emoji: {
66
+ fontSize: 18,
67
+ flexShrink: 0,
68
+ },
69
+ fruitText: {
70
+ display: 'flex',
71
+ flexDirection: 'column',
72
+ minWidth: 0,
73
+ },
74
+ cell: {
75
+ display: 'flex',
76
+ alignItems: 'center',
77
+ justifyContent: 'center',
78
+ height: 36,
79
+ borderWidth: borderVars['--border-width'],
80
+ borderStyle: 'solid',
81
+ borderColor: colorVars['--color-border'],
82
+ borderRadius: radiusVars['--radius-container'],
83
+ backgroundColor: colorVars['--color-background-card'],
84
+ color: colorVars['--color-text-secondary'],
85
+ fontFamily: 'inherit',
86
+ cursor: 'pointer',
87
+ ':hover': {
88
+ '@media (hover: hover)': {
89
+ borderColor: colorVars['--color-border-emphasized'],
90
+ color: colorVars['--color-text-primary'],
91
+ },
92
+ },
93
+ },
94
+ cellSelected: {
95
+ borderColor: colorVars['--color-accent'],
96
+ backgroundColor: colorVars['--color-accent'],
97
+ color: colorVars['--color-on-accent'],
98
+ },
99
+ });
100
+
101
+ function FruitRipenessGrid({
102
+ value,
103
+ onChange,
104
+ }: {
105
+ value: FruitValue;
106
+ onChange: (value: FruitValue) => void;
107
+ }) {
108
+ const {gridRef, handleKeyDown, handleFocus, focusCell} =
109
+ useGridFocus<HTMLDivElement>({
110
+ columns: ripenessLevels.length,
111
+ cellSelector: GRID_CELL_SELECTOR,
112
+ hasRovingTabIndex: true,
113
+ });
114
+
115
+ useEffect(() => {
116
+ const rowIndex = fruits.findIndex(f => f.id === value.fruit);
117
+ const columnIndex = ripenessLevels.findIndex(l => l.id === value.ripeness);
118
+ requestAnimationFrame(() => {
119
+ focusCell(
120
+ rowIndex >= 0 && columnIndex >= 0
121
+ ? rowIndex * ripenessLevels.length + columnIndex
122
+ : 0,
123
+ );
124
+ });
125
+ }, [focusCell, value]);
126
+
127
+ return (
128
+ <div
129
+ ref={gridRef}
130
+ role="grid"
131
+ aria-label="Fruit ripeness choices"
132
+ onKeyDown={handleKeyDown}
133
+ onFocus={handleFocus}
134
+ {...stylex.props(styles.grid)}>
135
+ {fruits.map(fruit => (
136
+ <div key={fruit.id} role="row" {...stylex.props(styles.row)}>
137
+ <div role="rowheader" {...stylex.props(styles.rowHeader)}>
138
+ <span aria-hidden="true" {...stylex.props(styles.emoji)}>
139
+ {fruit.emoji}
140
+ </span>
141
+ <span {...stylex.props(styles.fruitText)}>
142
+ <Text type="body">{fruit.id}</Text>
143
+ <Text type="supporting" color="secondary">
144
+ {fruit.description}
145
+ </Text>
146
+ </span>
147
+ </div>
148
+ {ripenessLevels.map(level => {
149
+ const isSelected =
150
+ value.fruit === fruit.id && value.ripeness === level.id;
151
+ return (
152
+ <button
153
+ key={`${fruit.id}-${level.id}`}
154
+ type="button"
155
+ role="gridcell"
156
+ aria-label={`${fruit.id}, ${level.id}: ${level.description}`}
157
+ aria-selected={isSelected || undefined}
158
+ tabIndex={isSelected ? 0 : -1}
159
+ onClick={() => onChange({fruit: fruit.id, ripeness: level.id})}
160
+ {...stylex.props(
161
+ styles.cell,
162
+ isSelected && styles.cellSelected,
163
+ )}>
164
+ {level.shortLabel}
165
+ </button>
166
+ );
167
+ })}
168
+ </div>
169
+ ))}
170
+ </div>
171
+ );
172
+ }
173
+
174
+ export default function ComplexSelectorShowcase() {
175
+ const [value, setValue] = useState<FruitValue>({
176
+ fruit: 'Apple',
177
+ ripeness: 'Juicy',
178
+ });
179
+
180
+ return (
181
+ <ComplexSelector<FruitValue>
182
+ label="Fruit blend"
183
+ description="Choose a fruit and ripeness in one control. Arrow keys move across the grid."
184
+ value={value}
185
+ onChange={setValue}
186
+ triggerLabel={`${value.fruit} · ${value.ripeness}`}
187
+ style={{width: 280}}>
188
+ {(selectedValue, onChange, close) => (
189
+ <FruitRipenessGrid
190
+ value={selectedValue}
191
+ onChange={nextValue => {
192
+ onChange(nextValue);
193
+ close();
194
+ }}
195
+ />
196
+ )}
197
+ </ComplexSelector>
198
+ );
199
+ }
@@ -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: 'ComplexSelector',
7
+ name: 'ComplexSelector — Tree Search',
8
+ displayName: 'Complex Selector — Tree Search',
9
+ description:
10
+ 'A destination picker that combines a search field with a TreeList hierarchy. TreeList owns tree keyboard navigation; ComplexSelector owns the trigger, popover, and focus restore. Selecting a folder closes the popup.',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: ['ComplexSelector', 'TextInput', 'TreeList'],
14
+ };
@@ -0,0 +1,188 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useMemo, useState} from 'react';
6
+ import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
7
+ import {TextInput} from '@astryxdesign/core/TextInput';
8
+ import {TreeList, type TreeListItemData} from '@astryxdesign/core/TreeList';
9
+ import {Text} from '@astryxdesign/core/Text';
10
+ import {VStack} from '@astryxdesign/core/Layout';
11
+
12
+ interface DestinationNode {
13
+ id: string;
14
+ label: string;
15
+ path: string;
16
+ children?: DestinationNode[];
17
+ }
18
+
19
+ interface Destination {
20
+ id: string;
21
+ label: string;
22
+ path: string;
23
+ }
24
+
25
+ const destinationTree: DestinationNode[] = [
26
+ {
27
+ id: 'workspace',
28
+ label: 'Workspace',
29
+ path: '/Workspace',
30
+ children: [
31
+ {
32
+ id: 'research',
33
+ label: 'Research',
34
+ path: '/Workspace/Research',
35
+ children: [
36
+ {
37
+ id: 'field-notes',
38
+ label: 'Field notes',
39
+ path: '/Workspace/Research/Field notes',
40
+ },
41
+ {
42
+ id: 'interviews',
43
+ label: 'Interviews',
44
+ path: '/Workspace/Research/Interviews',
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ id: 'roadmap',
50
+ label: 'Roadmap',
51
+ path: '/Workspace/Roadmap',
52
+ },
53
+ ],
54
+ },
55
+ {
56
+ id: 'teams',
57
+ label: 'Teams',
58
+ path: '/Teams',
59
+ children: [
60
+ {
61
+ id: 'design-systems',
62
+ label: 'Design systems',
63
+ path: '/Teams/Design systems',
64
+ children: [
65
+ {
66
+ id: 'accessibility',
67
+ label: 'Accessibility',
68
+ path: '/Teams/Design systems/Accessibility',
69
+ },
70
+ ],
71
+ },
72
+ ],
73
+ },
74
+ ];
75
+
76
+ function matches(node: DestinationNode, query: string): boolean {
77
+ if (node.label.toLowerCase().includes(query)) {
78
+ return true;
79
+ }
80
+ return (node.children ?? []).some(child => matches(child, query));
81
+ }
82
+
83
+ function filterTree(
84
+ nodes: DestinationNode[],
85
+ query: string,
86
+ ): DestinationNode[] {
87
+ if (!query) {
88
+ return nodes;
89
+ }
90
+ return nodes
91
+ .filter(node => matches(node, query))
92
+ .map(node => ({
93
+ ...node,
94
+ children: node.children ? filterTree(node.children, query) : undefined,
95
+ }));
96
+ }
97
+
98
+ function toItems(
99
+ nodes: DestinationNode[],
100
+ selectedId: string,
101
+ onSelect: (value: Destination) => void,
102
+ ): TreeListItemData[] {
103
+ return nodes.map(node => {
104
+ const hasChildren = (node.children ?? []).length > 0;
105
+ return {
106
+ id: node.id,
107
+ label: node.label,
108
+ isSelected: node.id === selectedId,
109
+ isExpanded: true,
110
+ onClick: hasChildren
111
+ ? undefined
112
+ : () => onSelect({id: node.id, label: node.label, path: node.path}),
113
+ children: hasChildren
114
+ ? toItems(node.children ?? [], selectedId, onSelect)
115
+ : undefined,
116
+ };
117
+ });
118
+ }
119
+
120
+ function DestinationSearch({
121
+ value,
122
+ onChange,
123
+ close,
124
+ }: {
125
+ value: Destination;
126
+ onChange: (value: Destination) => void;
127
+ close: () => void;
128
+ }) {
129
+ const [query, setQuery] = useState('');
130
+ const items = useMemo(
131
+ () =>
132
+ toItems(
133
+ filterTree(destinationTree, query.toLowerCase()),
134
+ value.id,
135
+ next => {
136
+ onChange(next);
137
+ close();
138
+ },
139
+ ),
140
+ [query, value.id, onChange, close],
141
+ );
142
+
143
+ return (
144
+ <VStack gap={3} style={{width: 360}}>
145
+ <TextInput
146
+ label="Search destinations"
147
+ isLabelHidden
148
+ value={query}
149
+ onChange={setQuery}
150
+ hasClear
151
+ placeholder="Search folders or teams"
152
+ />
153
+ {items.length > 0 ? (
154
+ <TreeList items={items} density="compact" />
155
+ ) : (
156
+ <Text type="supporting" color="secondary">
157
+ No matching destinations.
158
+ </Text>
159
+ )}
160
+ </VStack>
161
+ );
162
+ }
163
+
164
+ export default function ComplexSelectorTreeSearch() {
165
+ const [value, setValue] = useState<Destination>({
166
+ id: 'accessibility',
167
+ label: 'Accessibility',
168
+ path: '/Teams/Design systems/Accessibility',
169
+ });
170
+
171
+ return (
172
+ <ComplexSelector<Destination>
173
+ label="Destination"
174
+ description="Search and browse nested folders."
175
+ value={value}
176
+ onChange={setValue}
177
+ triggerLabel={value.path}
178
+ style={{width: 360}}>
179
+ {(selectedValue, onChange, close) => (
180
+ <DestinationSearch
181
+ value={selectedValue}
182
+ onChange={onChange}
183
+ close={close}
184
+ />
185
+ )}
186
+ </ComplexSelector>
187
+ );
188
+ }