@astryxdesign/cli 0.4.2 → 0.4.3-canary.ac850d9
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/CHANGELOG.md +23 -0
- package/README.md +44 -43
- package/api/template/cdn/cdn.d.mts +23 -0
- package/api/template/cdn/cdn.mjs +86 -0
- package/api/template/cdn/cdn.test.mjs +108 -0
- package/api/template/template.d.mts +2 -0
- package/api/template/template.doc.mjs +21 -3
- package/api/template/template.mjs +16 -2
- package/api/template/template.type.d.mts +19 -0
- package/api/template/template.type.mjs +12 -0
- package/api/theme/build/build.font-warning.test.mjs +23 -14
- package/api/theme/build/build.icons-specifier.test.mjs +149 -0
- package/api/theme/build/build.mjs +88 -16
- package/api/theme/build/build.test.mjs +185 -14
- package/api/theme/theme.type.d.mts +6 -0
- package/api/theme/theme.type.mjs +4 -1
- package/assets/cdn.template.html +124 -0
- package/assets/docs/theme.doc.dense.mjs +1 -1
- package/assets/docs/theme.doc.mjs +11 -5
- package/assets/docs/theme.doc.zh.mjs +1 -1
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs +20 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.tsx +87 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.doc.mjs +15 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.tsx +199 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.doc.mjs +14 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.tsx +188 -0
- package/assets/templates/themes/neutral/neutralTheme.ts +6 -4
- package/assets/theme.template.ts +4 -3
- package/clients/cli/commands/build-theme.font-warning.test.mjs +9 -6
- package/clients/cli/commands/build-theme.icons-specifier.test.mjs +132 -7
- package/clients/cli/commands/template-cdn.behavior.test.mjs +113 -0
- package/clients/cli/commands/template.doc.mjs +12 -2
- package/clients/cli/commands/template.mjs +24 -3
- package/clients/cli/lib/manifest.mjs +2 -1
- package/foundation/fs/path-safety.mjs +2 -2
- package/foundation/fs/path-safety.test.mjs +7 -0
- package/foundation/response/response-types.doc.mjs +6 -0
- package/foundation/text/copyright-header.mjs +11 -4
- package/package.json +9 -9
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<!-- Copyright (c) Meta Platforms, Inc. and affiliates. -->
|
|
2
|
+
<!doctype html>
|
|
3
|
+
<!--
|
|
4
|
+
Astryx from a CDN — no build step, no bundler, no install.
|
|
5
|
+
|
|
6
|
+
Open this file in a browser (double-click works) or serve it from anywhere.
|
|
7
|
+
Everything is pinned to the Astryx version you had installed when this was
|
|
8
|
+
written; nothing here resolves to "latest", because an unpinned CDN URL
|
|
9
|
+
silently changes under you.
|
|
10
|
+
|
|
11
|
+
React 19 removed its UMD builds, so there is no global-script path any more:
|
|
12
|
+
an import map plus `type="module"` is how you load Astryx without a bundler.
|
|
13
|
+
|
|
14
|
+
Two things worth doing next:
|
|
15
|
+
- `astryx theme template` writes an annotated theme you can point this page
|
|
16
|
+
at. Its `color` scale generates a whole ramp from one seed colour and
|
|
17
|
+
guarantees >= 4.5:1 for the text it generates against its surface.
|
|
18
|
+
- `astryx component <Name>` prints any component's real prop surface.
|
|
19
|
+
-->
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8" />
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
24
|
+
<!--
|
|
25
|
+
The theme names a font family; it never loads one. Without this link the
|
|
26
|
+
whole page silently falls back to the next family in the stack, which is
|
|
27
|
+
not the design you chose. `neutral` asks for Figtree — swap this for
|
|
28
|
+
whatever your theme names, or self-host with @font-face.
|
|
29
|
+
-->
|
|
30
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
31
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
32
|
+
<link
|
|
33
|
+
rel="stylesheet"
|
|
34
|
+
href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700&display=swap" />
|
|
35
|
+
<!-- reset.css and astryx.css are the component styles; theme.css is the
|
|
36
|
+
theme. Note /dist/theme.css — the package root has no theme.css. -->
|
|
37
|
+
<link
|
|
38
|
+
rel="stylesheet"
|
|
39
|
+
href="https://cdn.jsdelivr.net/npm/@astryxdesign/core@__ASTRYX_VERSION__/src/reset.css" />
|
|
40
|
+
<link
|
|
41
|
+
rel="stylesheet"
|
|
42
|
+
href="https://cdn.jsdelivr.net/npm/@astryxdesign/core@__ASTRYX_VERSION__/dist/astryx.css" />
|
|
43
|
+
<link
|
|
44
|
+
rel="stylesheet"
|
|
45
|
+
href="https://cdn.jsdelivr.net/npm/@astryxdesign/theme-neutral@__ASTRYX_VERSION__/dist/theme.css" />
|
|
46
|
+
<!--
|
|
47
|
+
react/jsx-runtime is not decoration: the published bundle imports it, and
|
|
48
|
+
leaving it out fails the page with "Failed to resolve module specifier".
|
|
49
|
+
|
|
50
|
+
?external=react,react-dom makes esm.sh reuse the React above instead of
|
|
51
|
+
bundling its own. Two React copies means every hook throws
|
|
52
|
+
"Cannot read properties of null (reading 'useState')".
|
|
53
|
+
|
|
54
|
+
htm is optional — delete it and the `h` binding below if you would rather
|
|
55
|
+
call createElement directly. It costs ~1KB and buys JSX-shaped markup,
|
|
56
|
+
which is worth it as soon as the tree is more than a few levels deep.
|
|
57
|
+
-->
|
|
58
|
+
<script type="importmap">
|
|
59
|
+
{
|
|
60
|
+
"imports": {
|
|
61
|
+
"react": "https://esm.sh/react@19.2.0",
|
|
62
|
+
"react/jsx-runtime": "https://esm.sh/react@19.2.0/jsx-runtime",
|
|
63
|
+
"react-dom": "https://esm.sh/react-dom@19.2.0",
|
|
64
|
+
"react-dom/client": "https://esm.sh/react-dom@19.2.0/client",
|
|
65
|
+
"htm": "https://esm.sh/htm@3.1.1",
|
|
66
|
+
"@astryxdesign/core": "https://esm.sh/@astryxdesign/core@__ASTRYX_VERSION__?external=react,react-dom",
|
|
67
|
+
"@astryxdesign/theme-neutral": "https://esm.sh/@astryxdesign/theme-neutral@__ASTRYX_VERSION__?external=react,react-dom"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
<!-- Nothing in the stylesheets sets a document font: the theme styles
|
|
72
|
+
prose elements and each component styles itself, and Button is
|
|
73
|
+
`font: inherit` by design. Without this line its label falls back to
|
|
74
|
+
the browser default (Times). -->
|
|
75
|
+
<style>
|
|
76
|
+
body {
|
|
77
|
+
font-family: var(--font-family-body);
|
|
78
|
+
}
|
|
79
|
+
/* Modules load over the network, so the page is empty for a moment.
|
|
80
|
+
Say so, rather than showing a blank screen that reads as broken. */
|
|
81
|
+
#root:empty::after {
|
|
82
|
+
content: 'Loading…';
|
|
83
|
+
display: block;
|
|
84
|
+
padding: 2rem;
|
|
85
|
+
color: var(--color-text-secondary);
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
88
|
+
</head>
|
|
89
|
+
<body>
|
|
90
|
+
<div id="root"></div>
|
|
91
|
+
<script type="module">
|
|
92
|
+
import * as React from 'react';
|
|
93
|
+
import {createRoot} from 'react-dom/client';
|
|
94
|
+
import htm from 'htm';
|
|
95
|
+
import {Theme, Card, Stack, Heading, Text, Button} from '@astryxdesign/core';
|
|
96
|
+
import {neutralTheme} from '@astryxdesign/theme-neutral';
|
|
97
|
+
|
|
98
|
+
// Nothing is compiling this file, so there is no JSX. htm gives the same
|
|
99
|
+
// shape from a tagged template; `React.createElement` directly is the
|
|
100
|
+
// dependency-free alternative.
|
|
101
|
+
const h = htm.bind(React.createElement);
|
|
102
|
+
|
|
103
|
+
// Passing the theme OBJECT is what makes `mode` work: 'system' follows
|
|
104
|
+
// the OS, and light/dark switch at runtime. The `data-astryx-theme`
|
|
105
|
+
// attribute alone only scopes the stylesheet — it cannot switch modes.
|
|
106
|
+
createRoot(document.getElementById('root')).render(h`
|
|
107
|
+
<${Theme} theme=${neutralTheme} mode="system">
|
|
108
|
+
<${Stack} padding=${6} align="start">
|
|
109
|
+
<${Card} maxWidth=${480} elevation="low">
|
|
110
|
+
<${Stack} gap=${3} align="start">
|
|
111
|
+
<${Heading} level=${1}>Hello from a CDN</${Heading}>
|
|
112
|
+
<${Text}>No bundler, no install, no build step.</${Text}>
|
|
113
|
+
<!-- Button's text comes from \`label\`, which is required — it is
|
|
114
|
+
the accessible name. Children are optional and only change
|
|
115
|
+
the visible text; the accessible name stays \`label\`. -->
|
|
116
|
+
<${Button} variant="primary" label="Try me" />
|
|
117
|
+
</${Stack}>
|
|
118
|
+
</${Card}>
|
|
119
|
+
</${Stack}>
|
|
120
|
+
</${Theme}>
|
|
121
|
+
`);
|
|
122
|
+
</script>
|
|
123
|
+
</body>
|
|
124
|
+
</html>
|
|
@@ -9,7 +9,7 @@ export const docsDense = {
|
|
|
9
9
|
{ section: 'Available Themes', title: 'Themes', content: [null, null, { type: 'prose', text: 'published: neutral (start here), butter, chocolate, gothic (dark-only), matcha, stone, y2k. @astryxdesign/theme-{name} = source (runtime). @astryxdesign/theme-{name}/built = optimized (+ theme.css).' }] },
|
|
10
10
|
{ section: 'Theme Props', title: 'Props', content: [null] },
|
|
11
11
|
{ section: 'Creating a Custom Theme', title: 'Custom Theme', content: [{ type: 'prose', text: '`theme list` + `theme add <slug>` to start from a shipped theme, or defineTheme from scratch. only override tokens that differ.' }, null, { type: 'prose', text: '`astryx theme template` writes theme.template.ts: every defineTheme field + token families + override syntax, annotated, with the CLI command that prints each reference.' }] },
|
|
12
|
-
{ section: 'defineTheme', title: 'defineTheme', content: [{ type: 'prose', text: 'scale configs (color, typography, radius, motion) + explicit token overrides + component overrides. color derives full palette from accent hex
|
|
12
|
+
{ section: 'defineTheme', title: 'defineTheme', content: [{ type: 'prose', text: 'scale configs (color, typography, radius, motion) + explicit token overrides + component overrides. color derives full palette from accent via HCT; accent = hex or [light, dark] tuple (per-scheme palettes). tokens overrides win token-by-token; --color-on-accent stays baked from color.accent, so prefer a tuple accent over overriding --color-accent.' }, null, null] },
|
|
13
13
|
{ section: 'Component Style Overrides', title: 'Component Overrides', content: [{ type: 'prose', text: 'components field uses semantic component keys + style keys (base, variant:value, stateName), not raw selectors. for external CSS, prefer data-* selectors from `astryx docs styling`. write standard CSS (borderRadius, padding) — pipeline expands to internal vars. public vars (--button-focus-offset etc) set directly. private vars (--_*) cannot be set — use CSS properties. run `astryx component <Name>` for details.' }, null, null, null, null] },
|
|
14
14
|
{ section: 'Custom Variants', title: 'Custom Variants', content: [{ type: 'prose', text: 'any unknown prop:value in components becomes a new variant. astryx theme build generates TS augmentations. works on any extensible prop axis (variant, status, etc).' }, null, null, null, null] },
|
|
15
15
|
{ section: 'Building Themes for Production', title: 'Build for Production', content: [{ type: 'prose', text: 'astryx theme build compiles defineTheme to static CSS. outputs .css + .js (__built:true) + .d.ts.' }, null, null, null, null] },
|
|
@@ -162,7 +162,7 @@ function App() {
|
|
|
162
162
|
content: [
|
|
163
163
|
{
|
|
164
164
|
type: 'prose',
|
|
165
|
-
text: 'defineTheme creates a theme from token overrides and optional scale configs. Scale configs generate tokens from parameters. Explicit token overrides always take precedence over scale-generated values.',
|
|
165
|
+
text: 'defineTheme creates a theme from token overrides and optional scale configs. Scale configs generate tokens from parameters. Explicit token overrides always take precedence over scale-generated values, token by token. One caveat for the accent: overriding --color-accent in tokens re-points the reference tokens (--color-accent-muted, --color-text-accent, --color-icon-accent) but NOT --color-on-accent, which stays baked from the color.accent seed. To give each scheme its own accent with a consistent derived palette, pass a [light, dark] tuple to color.accent instead of overriding the token.',
|
|
166
166
|
},
|
|
167
167
|
{
|
|
168
168
|
type: 'code',
|
|
@@ -172,7 +172,8 @@ function App() {
|
|
|
172
172
|
|
|
173
173
|
const myTheme = defineTheme({
|
|
174
174
|
name: 'my-theme',
|
|
175
|
-
|
|
175
|
+
// accent: single hex, or [light, dark] tuple to seed each scheme separately
|
|
176
|
+
color: { accent: ['#7B61FF', '#9B85FF'], neutralStyle: 'cool' },
|
|
176
177
|
typography: {
|
|
177
178
|
scale: { base: 14, ratio: 1.2 },
|
|
178
179
|
body: { family: 'Inter', fallbacks: '-apple-system, sans-serif' },
|
|
@@ -181,7 +182,7 @@ const myTheme = defineTheme({
|
|
|
181
182
|
motion: { fast: 175, medium: 410, ratio: 0.75 },
|
|
182
183
|
tokens: {
|
|
183
184
|
// Explicit overrides take precedence over scale-generated values
|
|
184
|
-
'--color-
|
|
185
|
+
'--color-background-body': ['#FFFFFF', '#0A0A0A'],
|
|
185
186
|
},
|
|
186
187
|
components: {
|
|
187
188
|
button: { 'variant:primary': { color: 'white' } },
|
|
@@ -195,7 +196,7 @@ const myTheme = defineTheme({
|
|
|
195
196
|
[
|
|
196
197
|
'color',
|
|
197
198
|
'--color-accent, --color-background-*, --color-text-*, --color-border, etc.',
|
|
198
|
-
'accent? (hex; omit for neutral-only), neutralStyle? (warm|cool|neutral), contrast? (standard|high)',
|
|
199
|
+
'accent? (hex or [light, dark] tuple; omit for neutral-only), neutralStyle? (warm|cool|neutral), contrast? (standard|high)',
|
|
199
200
|
],
|
|
200
201
|
[
|
|
201
202
|
'typography.scale',
|
|
@@ -253,10 +254,15 @@ const brandTheme = defineTheme({
|
|
|
253
254
|
['tokens', 'Base tokens are copied first, then child tokens override on top.'],
|
|
254
255
|
['components', 'Deep-merged: child component rules override matching keys from the base.'],
|
|
255
256
|
['icons', 'Shallow-merged: child icons override matching names from the base.'],
|
|
256
|
-
['
|
|
257
|
+
['indicators', 'Shallow-merged: child indicators override matching names from the base.'],
|
|
258
|
+
['onDark, onLight', "Deep-merged per surface: the base's resolved surface first, then the child's overrides."],
|
|
257
259
|
['typography, motion, radius, color', 'Child config replaces base entirely (these are scale inputs, not additive).'],
|
|
258
260
|
],
|
|
259
261
|
},
|
|
262
|
+
{
|
|
263
|
+
type: 'prose',
|
|
264
|
+
text: 'Inheritance is resolved when the theme is defined, so an extended theme is flat: `astryx theme build` emits one self-contained stylesheet holding everything the child inherited, and the base theme\'s CSS does not need to be loaded next to it. A base that is not a theme — most often an import that missed — is a build error rather than a theme that silently inherits nothing.',
|
|
265
|
+
},
|
|
260
266
|
],
|
|
261
267
|
},
|
|
262
268
|
{
|
|
@@ -9,7 +9,7 @@ export const docsZh = {
|
|
|
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
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
|
-
{ section: 'defineTheme', title: 'defineTheme', content: [{ type: 'prose', text: '支持比例配置(typography、radius、motion)+ 显式令牌覆盖 + 组件覆盖。' }, null, null] },
|
|
12
|
+
{ section: 'defineTheme', title: 'defineTheme', content: [{ type: 'prose', text: '支持比例配置(color、typography、radius、motion)+ 显式令牌覆盖 + 组件覆盖。color 通过 HCT 从 accent 派生完整调色板;accent 接受单个十六进制值或 [light, dark] 元组(每个模式使用各自的种子色)。tokens 覆盖按令牌逐个生效;--color-on-accent 始终由 color.accent 计算得出,因此优先使用元组 accent 而不是覆盖 --color-accent。' }, 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] },
|
|
15
15
|
{ section: 'Light/Dark Mode', title: '亮/暗模式', content: [{ type: 'prose', text: "令牌值使用 [light, dark] 元组实现自动模式切换。Theme 上 mode='system'(默认)跟随系统偏好。" }, null, null] },
|
package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs
ADDED
|
@@ -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
|
+
};
|