astralyx-ui 0.7.0 → 0.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "description": "309 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "homepage": "https://ui.astralyx.dev",
5
5
  "items": [
6
6
  {
@@ -24,7 +24,7 @@
24
24
  {
25
25
  "path": "components/ui/composer.tsx",
26
26
  "type": "registry:ui",
27
- "content": "import { useId, useState, type ComponentProps, type ReactNode } from 'react'\nimport { RotateCcw } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardBody, CardHeader } from '@/components/ui/card'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { ColorPicker } from '@/components/ui/color-picker'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { NumberInput } from '@/components/ui/number-input'\nimport { Select } from '@/components/ui/select'\nimport { Switch } from '@/components/ui/switch'\nimport type { Language } from '@/lib/highlighter'\nimport { cn } from '@/lib/utils'\n\n/**\n * A live playground: drive a set of props and watch generated source follow.\n *\n * Assembled entirely from the kit's own controls, which is the point rather\n * than a flourish — this is the densest form on the site, so anything awkward\n * about Select, Switch or NumberInput surfaces here before it reaches anyone\n * else's form.\n *\n * It owns no knowledge of the documentation registry. `controls` describes the\n * inputs, `render` draws the result and `code` writes the snippet; a composer\n * works the same in a README page, a design review or a prop explorer.\n *\n * State can be controlled. The uncontrolled default is what a docs page wants,\n * but a page that needs two composers in sync — a light and a dark preview of\n * one configuration — has to be able to lift it.\n */\nexport type ComposerValue = string | boolean | number\n\nexport type ComposerControl =\n | {\n type: 'select'\n prop: string\n label: string\n options: readonly string[]\n default: string\n }\n | { type: 'boolean'; prop: string; label: string; default: boolean }\n | {\n type: 'text'\n prop: string\n label: string\n default: string\n placeholder?: string\n }\n | {\n type: 'number'\n prop: string\n label: string\n default: number\n min?: number\n max?: number\n step?: number\n }\n | { type: 'color'; prop: string; label: string; default: string }\n\nexport type ComposerState = Record<string, ComposerValue>\n\nfunction initialState(controls: ComposerControl[]): ComposerState {\n return Object.fromEntries(controls.map((control) => [control.prop, control.default]))\n}\n\ntype ComposerProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n controls: ComposerControl[]\n /** Draws the live result. */\n render: (state: ComposerState) => ReactNode\n /** Source for the current state, shown beneath the preview. */\n code?: (state: ComposerState) => string\n language?: Language\n /** Centre the preview in a taller box, for anything with real height. */\n tall?: boolean\n panelLabel?: ReactNode\n state?: ComposerState\n onStateChange?: (state: ComposerState) => void\n resetLabel?: ReactNode\n}\n\nfunction Composer({\n controls,\n render,\n code,\n language = 'tsx',\n tall = false,\n panelLabel = 'Props',\n state: stateProp,\n onStateChange,\n resetLabel = 'Reset',\n className,\n ...props\n}: ComposerProps) {\n const controlled = stateProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(() => initialState(controls))\n const state = controlled ? stateProp : uncontrolled\n\n const dirty = controls.some((control) => state[control.prop] !== control.default)\n // Two composers on one page is the ordinary docs case, so field ids are\n // namespaced per instance rather than derived from the prop name alone.\n const fieldScope = useId()\n\n function set(next: ComposerState) {\n if (!controlled) setUncontrolled(next)\n onStateChange?.(next)\n }\n\n return (\n <Card data-slot=\"composer\" className={cn('overflow-hidden', className)} {...props}>\n {/* Panel beside the preview on wide screens, beneath it on narrow ones —\n a 280px control column leaves nothing for the preview on a phone. */}\n <div className=\"grid lg:grid-cols-[minmax(0,1fr)_280px]\">\n <CardBody\n className={cn(\n 'flex items-center justify-center',\n tall ? 'min-h-80' : 'min-h-48',\n )}\n >\n {render(state)}\n </CardBody>\n\n <div className=\"border-border flex flex-col border-t lg:border-t-0 lg:border-s\">\n {/* A real CardHeader, not a label with a rule under it: the panel\n gets the same header band as every other card in the kit, and the\n band's own border replaces the Separator. */}\n <CardHeader className=\"flex-row items-center justify-between gap-2\">\n <span className=\"text-muted-foreground text-[11px] font-medium tracking-wide uppercase\">\n {panelLabel}\n </span>\n {dirty && (\n <Button\n variant=\"ghost\"\n size=\"xs\"\n className=\"-me-2\"\n onClick={() => set(initialState(controls))}\n >\n <RotateCcw />\n {resetLabel}\n </Button>\n )}\n </CardHeader>\n\n <div className=\"bg-secondary/40 flex flex-1 flex-col gap-3.5 p-4.5\">\n {controls.map((control) => (\n <ComposerField\n key={control.prop}\n scope={fieldScope}\n control={control}\n value={state[control.prop]}\n onChange={(value) => set({ ...state, [control.prop]: value })}\n />\n ))}\n\n {controls.length === 0 && (\n <p className=\"text-muted-foreground text-xs\">\n No props to configure.\n </p>\n )}\n </div>\n </div>\n </div>\n\n {code && (\n <div className=\"border-border border-t p-3\">\n <CodeBlock code={code(state)} language={language} />\n </div>\n )}\n </Card>\n )\n}\n\n/** One row of the control panel. */\nfunction ComposerField({\n control,\n value,\n onChange,\n scope,\n}: {\n control: ComposerControl\n value: ComposerValue\n onChange: (value: ComposerValue) => void\n scope: string\n resetLabel?: ReactNode\n}) {\n const id = `${scope}-${control.prop}`\n\n // A switch carries its own label, so it needs no Label above it.\n if (control.type === 'boolean') {\n return (\n <Switch\n id={id}\n size=\"sm\"\n checked={Boolean(value)}\n onChange={(event) => onChange(event.target.checked)}\n label={<span className=\"font-mono text-xs\">{control.label}</span>}\n labelPosition=\"start\"\n containerClassName=\"justify-between w-full\"\n />\n )\n }\n\n return (\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor={id} className=\"font-mono text-xs\">\n {control.label}\n </Label>\n\n {control.type === 'select' && (\n <Select\n size=\"sm\"\n value={String(value)}\n onValueChange={onChange}\n options={control.options.map((option) => ({\n value: option,\n label: option,\n }))}\n />\n )}\n\n {control.type === 'text' && (\n <Input\n id={id}\n size=\"sm\"\n value={String(value)}\n placeholder={control.placeholder}\n onChange={(event) => onChange(event.target.value)}\n />\n )}\n\n {control.type === 'number' && (\n <NumberInput\n id={id}\n size=\"sm\"\n value={Number(value)}\n min={control.min}\n max={control.max}\n step={control.step}\n onValueChange={(next) => onChange(next ?? 0)}\n />\n )}\n\n {control.type === 'color' && (\n <ColorPicker\n size=\"sm\"\n clearable\n value={String(value)}\n onValueChange={onChange}\n />\n )}\n </div>\n )\n}\n\nexport { Composer, initialState as composerInitialState }\nexport type { ComposerProps }\n"
27
+ "content": "import {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Maximize2, Minimize2, RotateCcw } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardBody, CardHeader } from '@/components/ui/card'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { ColorPicker } from '@/components/ui/color-picker'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { NumberInput } from '@/components/ui/number-input'\nimport { Select } from '@/components/ui/select'\nimport { Switch } from '@/components/ui/switch'\nimport type { Language } from '@/lib/highlighter'\nimport { cn } from '@/lib/utils'\n\n/**\n * A live playground: drive a set of props and watch generated source follow.\n *\n * Assembled entirely from the kit's own controls, which is the point rather\n * than a flourish — this is the densest form on the site, so anything awkward\n * about Select, Switch or NumberInput surfaces here before it reaches anyone\n * else's form.\n *\n * It owns no knowledge of the documentation registry. `controls` describes the\n * inputs, `render` draws the result and `code` writes the snippet; a composer\n * works the same in a README page, a design review or a prop explorer.\n *\n * State can be controlled. The uncontrolled default is what a docs page wants,\n * but a page that needs two composers in sync — a light and a dark preview of\n * one configuration — has to be able to lift it.\n */\nexport type ComposerValue = string | boolean | number\n\nexport type ComposerControl =\n | {\n type: 'select'\n prop: string\n label: string\n options: readonly string[]\n default: string\n }\n | { type: 'boolean'; prop: string; label: string; default: boolean }\n | {\n type: 'text'\n prop: string\n label: string\n default: string\n placeholder?: string\n }\n | {\n type: 'number'\n prop: string\n label: string\n default: number\n min?: number\n max?: number\n step?: number\n }\n | { type: 'color'; prop: string; label: string; default: string }\n\nexport type ComposerState = Record<string, ComposerValue>\n\nfunction initialState(controls: ComposerControl[]): ComposerState {\n return Object.fromEntries(controls.map((control) => [control.prop, control.default]))\n}\n\ntype ComposerProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n controls: ComposerControl[]\n /** Draws the live result. */\n render: (state: ComposerState) => ReactNode\n /** Source for the current state, shown beneath the preview. */\n code?: (state: ComposerState) => string\n language?: Language\n /** Centre the preview in a taller box, for anything with real height. */\n tall?: boolean\n panelLabel?: ReactNode\n state?: ComposerState\n onStateChange?: (state: ComposerState) => void\n resetLabel?: ReactNode\n /**\n * Offer a button that gives the playground the whole screen.\n *\n * Worth turning off for a composer driving something small, where the room is\n * not the constraint and the button is just another thing in the header.\n */\n fullscreen?: boolean\n fullscreenLabel?: string\n exitFullscreenLabel?: string\n}\n\nfunction Composer({\n controls,\n render,\n code,\n language = 'tsx',\n tall = false,\n panelLabel = 'Props',\n state: stateProp,\n onStateChange,\n resetLabel = 'Reset',\n fullscreen = true,\n fullscreenLabel = 'Expand to full screen',\n exitFullscreenLabel = 'Exit full screen',\n className,\n ...props\n}: ComposerProps) {\n const controlled = stateProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(() => initialState(controls))\n const state = controlled ? stateProp : uncontrolled\n\n const dirty = controls.some((control) => state[control.prop] !== control.default)\n // Two composers on one page is the ordinary docs case, so field ids are\n // namespaced per instance rather than derived from the prop name alone.\n const fieldScope = useId()\n\n function set(next: ComposerState) {\n if (!controlled) setUncontrolled(next)\n onStateChange?.(next)\n }\n\n const shellRef = useRef<HTMLDivElement>(null)\n const { expanded, toggle } = useExpand(shellRef)\n\n return (\n <Card\n ref={shellRef}\n data-slot=\"composer\"\n data-expanded={expanded || undefined}\n className={cn(\n 'overflow-hidden',\n // Filling the screen is the same shape either way: the browser sizes a\n // fullscreen element itself, and these are what the fallback needs.\n expanded && 'fixed inset-0 z-50 rounded-none',\n className,\n )}\n {...props}\n >\n {/* Panel beside the preview on wide screens, beneath it on narrow ones —\n a 280px control column leaves nothing for the preview on a phone. */}\n <div\n className={cn(\n 'grid lg:grid-cols-[minmax(0,1fr)_280px]',\n // `min-h-0` or the preview refuses to shrink and pushes the code\n // block off the bottom of the screen instead of scrolling.\n expanded && 'min-h-0 flex-1',\n )}\n >\n <CardBody\n className={cn(\n 'flex items-center justify-center',\n expanded ? 'min-h-0 overflow-auto' : tall ? 'min-h-80' : 'min-h-48',\n )}\n >\n {render(state)}\n </CardBody>\n\n <div\n className={cn(\n 'border-border flex flex-col border-t lg:border-t-0 lg:border-s',\n expanded && 'min-h-0',\n )}\n >\n {/* A real CardHeader, not a label with a rule under it: the panel\n gets the same header band as every other card in the kit, and the\n band's own border replaces the Separator. */}\n <CardHeader className=\"flex-row items-center justify-between gap-2\">\n <span className=\"text-muted-foreground text-[11px] font-medium tracking-wide uppercase\">\n {panelLabel}\n </span>\n <span className=\"-me-2 flex items-center gap-1\">\n {dirty && (\n <Button\n variant=\"ghost\"\n size=\"xs\"\n onClick={() => set(initialState(controls))}\n >\n <RotateCcw />\n {resetLabel}\n </Button>\n )}\n {fullscreen && (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={expanded ? exitFullscreenLabel : fullscreenLabel}\n onClick={toggle}\n >\n {expanded ? <Minimize2 /> : <Maximize2 />}\n </Button>\n )}\n </span>\n </CardHeader>\n\n <div\n className={cn(\n 'bg-secondary/40 flex flex-1 flex-col gap-3.5 p-4.5',\n expanded && 'overflow-y-auto',\n )}\n >\n {controls.map((control) => (\n <ComposerField\n key={control.prop}\n scope={fieldScope}\n control={control}\n value={state[control.prop]}\n onChange={(value) => set({ ...state, [control.prop]: value })}\n />\n ))}\n\n {controls.length === 0 && (\n <p className=\"text-muted-foreground text-xs\">\n No props to configure.\n </p>\n )}\n </div>\n </div>\n </div>\n\n {code && (\n <div\n className={cn(\n 'border-border border-t p-3',\n // Capped rather than free, so the source never crowds out the\n // preview the screen was given over to.\n expanded && 'max-h-[40vh] shrink-0 overflow-auto',\n )}\n >\n <CodeBlock code={code(state)} language={language} />\n </div>\n )}\n </Card>\n )\n}\n\n/**\n * Fill the screen, by whichever route the browser allows.\n *\n * The Fullscreen API is the one that means it — the page chrome goes too, and\n * Escape is handled for us. It is not everywhere: iPhone Safari exposes no\n * element fullscreen at all, and a request can be refused outright. So a\n * refusal falls back to covering the viewport instead, which is the same thing\n * minus the browser's own furniture, and never leaves a button that does\n * nothing.\n *\n * Fullscreen can also be left without asking us — Escape, or the browser's own\n * control — so the state is read back from `fullscreenchange` rather than\n * assumed from the click that started it.\n */\nfunction useExpand(ref: React.RefObject<HTMLElement | null>) {\n const [native, setNative] = useState(false)\n const [overlay, setOverlay] = useState(false)\n const expanded = native || overlay\n\n useEffect(() => {\n const onChange = () => setNative(document.fullscreenElement === ref.current)\n document.addEventListener('fullscreenchange', onChange)\n return () => document.removeEventListener('fullscreenchange', onChange)\n }, [ref])\n\n // Only the fallback needs these: in real fullscreen the browser already owns\n // Escape, and the page behind is not being scrolled past.\n useEffect(() => {\n if (!overlay) return\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') setOverlay(false)\n }\n window.addEventListener('keydown', onKeyDown)\n\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n\n return () => {\n window.removeEventListener('keydown', onKeyDown)\n document.body.style.overflow = previous\n }\n }, [overlay])\n\n const toggle = useCallback(() => {\n if (document.fullscreenElement) {\n void document.exitFullscreen()\n return\n }\n if (overlay) {\n setOverlay(false)\n return\n }\n\n const element = ref.current\n if (element?.requestFullscreen && document.fullscreenEnabled) {\n // A rejected request resolves nothing and throws asynchronously, which is\n // why the fallback is chained rather than decided up front.\n element.requestFullscreen().catch(() => setOverlay(true))\n return\n }\n setOverlay(true)\n }, [overlay, ref])\n\n return { expanded, toggle }\n}\n\n/** One row of the control panel. */\nfunction ComposerField({\n control,\n value,\n onChange,\n scope,\n}: {\n control: ComposerControl\n value: ComposerValue\n onChange: (value: ComposerValue) => void\n scope: string\n resetLabel?: ReactNode\n}) {\n const id = `${scope}-${control.prop}`\n\n // A switch carries its own label, so it needs no Label above it.\n if (control.type === 'boolean') {\n return (\n <Switch\n id={id}\n size=\"sm\"\n checked={Boolean(value)}\n onChange={(event) => onChange(event.target.checked)}\n label={<span className=\"font-mono text-xs\">{control.label}</span>}\n labelPosition=\"start\"\n containerClassName=\"justify-between w-full\"\n />\n )\n }\n\n return (\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor={id} className=\"font-mono text-xs\">\n {control.label}\n </Label>\n\n {control.type === 'select' && (\n <Select\n size=\"sm\"\n value={String(value)}\n onValueChange={onChange}\n options={control.options.map((option) => ({\n value: option,\n label: option,\n }))}\n />\n )}\n\n {control.type === 'text' && (\n <Input\n id={id}\n size=\"sm\"\n value={String(value)}\n placeholder={control.placeholder}\n onChange={(event) => onChange(event.target.value)}\n />\n )}\n\n {control.type === 'number' && (\n <NumberInput\n id={id}\n size=\"sm\"\n value={Number(value)}\n min={control.min}\n max={control.max}\n step={control.step}\n onValueChange={(next) => onChange(next ?? 0)}\n />\n )}\n\n {control.type === 'color' && (\n <ColorPicker\n size=\"sm\"\n clearable\n value={String(value)}\n onValueChange={onChange}\n />\n )}\n </div>\n )\n}\n\nexport { Composer, initialState as composerInitialState }\nexport type { ComposerProps }\n"
28
28
  }
29
29
  ]
30
30
  }