Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/petite-coins-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

---

Add Slider to the private A2UI ReactLynx catalog.
Comment thread
MoonfaceX marked this conversation as resolved.
3 changes: 3 additions & 0 deletions packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Modal,
RadioGroup,
Row,
Slider,
Tabs,
Text,
TextField,
Expand All @@ -40,6 +41,7 @@ import listManifest from '@lynx-js/a2ui-reactlynx/catalog/List/catalog.json';
import modalManifest from '@lynx-js/a2ui-reactlynx/catalog/Modal/catalog.json';
import radioGroupManifest from '@lynx-js/a2ui-reactlynx/catalog/RadioGroup/catalog.json';
import rowManifest from '@lynx-js/a2ui-reactlynx/catalog/Row/catalog.json';
import sliderManifest from '@lynx-js/a2ui-reactlynx/catalog/Slider/catalog.json';
import tabsManifest from '@lynx-js/a2ui-reactlynx/catalog/Tabs/catalog.json';
import textManifest from '@lynx-js/a2ui-reactlynx/catalog/Text/catalog.json';
import textFieldManifest from '@lynx-js/a2ui-reactlynx/catalog/TextField/catalog.json';
Expand Down Expand Up @@ -89,6 +91,7 @@ const ALL_BUILTINS: readonly CatalogInput[] = [
manifestEntry(Icon, iconManifest),
manifestEntry(CheckBox, checkBoxManifest),
manifestEntry(RadioGroup, radioGroupManifest),
manifestEntry(Slider, sliderManifest),
manifestEntry(TextField, textFieldManifest),
manifestEntry(Tabs, tabsManifest),
...basicFunctions,
Expand Down
45 changes: 45 additions & 0 deletions packages/genui/a2ui-playground/src/catalog/a2ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import listManifest from '@lynx-js/a2ui-reactlynx/catalog/List/catalog.json';
import modalManifest from '@lynx-js/a2ui-reactlynx/catalog/Modal/catalog.json';
import radioGroupManifest from '@lynx-js/a2ui-reactlynx/catalog/RadioGroup/catalog.json';
import rowManifest from '@lynx-js/a2ui-reactlynx/catalog/Row/catalog.json';
import sliderManifest from '@lynx-js/a2ui-reactlynx/catalog/Slider/catalog.json';
import tabsManifest from '@lynx-js/a2ui-reactlynx/catalog/Tabs/catalog.json';
import textManifest from '@lynx-js/a2ui-reactlynx/catalog/Text/catalog.json';
import textFieldManifest from '@lynx-js/a2ui-reactlynx/catalog/TextField/catalog.json';
Expand Down Expand Up @@ -1233,6 +1234,50 @@ export const COMPONENT_CATALOG: ComponentDoc[] = [
openui: [],
},
},
{
name: 'Slider',
category: 'Input',
description: 'A numeric range input backed by lynx-ui slider primitives.',
props: schemaToProps(sliderManifest),
usage: {
a2ui: {
id: 'volume-slider',
component: 'Slider',
label: 'Volume',
value: 40,
min: 0,
max: 100,
},
openui: {},
},
usageExamples: {
a2ui: [
{
label: 'Percent',
value: {
id: 'volume-slider',
component: 'Slider',
label: 'Volume',
value: 40,
min: 0,
max: 100,
},
},
{
label: 'Progress',
value: {
id: 'progress-slider',
component: 'Slider',
label: 'Progress',
value: 0.35,
min: 0,
max: 1,
},
},
],
openui: [],
},
},
{
name: 'Tabs',
category: 'Layout',
Expand Down
4 changes: 2 additions & 2 deletions packages/genui/a2ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ This package includes:
catalog API. No global registry — every consumer composes the set of
components they want available.
- `catalog/<Name>`: built-in component renderers (`Text`, `Button`,
`Card`, `Column`, `Row`, `List`, `CheckBox`, `RadioGroup`, `Image`,
`Divider`, `Icon`, `Modal`, `Tabs`).
`Card`, `Column`, `Row`, `List`, `CheckBox`, `RadioGroup`, `Slider`,
`Image`, `Divider`, `Icon`, `Modal`, `Tabs`).
- `catalog/<Name>/catalog.json`: per-component JSON-Schema manifests
for the agent handshake.

Expand Down
10 changes: 7 additions & 3 deletions packages/genui/a2ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@
"default": "./dist/catalog/RadioGroup/index.js"
},
"./catalog/RadioGroup/catalog.json": "./dist/catalog/RadioGroup/catalog.json",
"./catalog/Slider": {
"types": "./dist/catalog/Slider/index.d.ts",
"default": "./dist/catalog/Slider/index.js"
},
"./catalog/Slider/catalog.json": "./dist/catalog/Slider/catalog.json",
"./catalog/Tabs": {
"types": "./dist/catalog/Tabs/index.d.ts",
"default": "./dist/catalog/Tabs/index.js"
Expand All @@ -112,15 +117,14 @@
},
"devDependencies": {
"@lynx-js/a2ui-catalog-extractor": "workspace:*",
"@lynx-js/lynx-ui": "^3.130.0",
"@lynx-js/lynx-ui-input": "^3.130.0",
"@lynx-js/lynx-ui": "^3.133.0",
"@lynx-js/react": "workspace:*",
"@lynx-js/types": "3.7.0",
"@rstest/core": "catalog:rstest",
"@types/react": "^18.3.28"
},
"peerDependencies": {
"@lynx-js/lynx-ui": "^3.130.0",
"@lynx-js/lynx-ui": "^3.133.0",
"@lynx-js/react": "workspace:^"
},
"peerDependenciesMeta": {
Expand Down
5 changes: 5 additions & 0 deletions packages/genui/a2ui/src/catalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
Modal,
RadioGroup,
Row,
Slider,
Tabs,
Text,
TextField,
Expand Down Expand Up @@ -115,6 +116,9 @@ import radioGroupManifest from '@lynx-js/a2ui-reactlynx/catalog/RadioGroup/catal
import rowManifest from '@lynx-js/a2ui-reactlynx/catalog/Row/catalog.json' with {
type: 'json',
};
import sliderManifest from '@lynx-js/a2ui-reactlynx/catalog/Slider/catalog.json' with {
type: 'json',
};
import tabsManifest from '@lynx-js/a2ui-reactlynx/catalog/Tabs/catalog.json' with {
type: 'json',
};
Expand All @@ -139,6 +143,7 @@ export const allBuiltins = defineCatalog([
[CheckBox, checkBoxManifest],
[Icon, iconManifest],
[RadioGroup, radioGroupManifest],
[Slider, sliderManifest],
[Tabs, tabsManifest],
]);
```
Expand Down
151 changes: 151 additions & 0 deletions packages/genui/a2ui/src/catalog/Slider/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import {
SliderIndicator,
SliderRoot,
SliderThumb,
SliderTrack,
} from '@lynx-js/lynx-ui';
import { useState } from '@lynx-js/react';

import {
fromSliderRatio,
normalizeSliderLabel,
normalizeSliderNumber,
normalizeSliderRange,
toSliderRatio,
toSliderStepRatio,
} from './utils.js';
import { useChecks } from '../../react/useChecks.js';
import type { CheckLike } from '../../react/useChecks.js';
import type { GenericComponentProps } from '../../store/types.js';

import '../../../styles/catalog/Slider.css';

/**
* @a2uiCatalog Slider
*/
export interface SliderProps extends GenericComponentProps {
/** The label for the slider. */
label?: string | { path: string } | {
call: string;
args: Record<string, unknown>;
returnType?:
| 'string'
| 'number'
| 'boolean'
| 'array'
| 'object'
| 'any'
| 'void';
};
/** The minimum value of the slider. */
min?: number;
/** The maximum value of the slider. */
max: number;
/** The current value of the slider. */
value: number | { path: string } | {
call: string;
args: Record<string, unknown>;
returnType?:
| 'string'
| 'number'
| 'boolean'
| 'array'
| 'object'
| 'any'
| 'void';
};
/** A list of checks to perform. */
checks?: Array<{
/** The condition that indicates whether the check passes. */
condition: boolean | { path: string } | {
call: string;
args: Record<string, unknown>;
returnType?:
| 'string'
| 'number'
| 'boolean'
| 'array'
| 'object'
| 'any'
| 'void';
};
/** The error message to display if the check fails. */
message: string;
}>;
}

export function Slider(
props: SliderProps,
): import('@lynx-js/react').ReactNode {
const {
id,
label,
max,
min,
setValue,
surface,
dataContextPath,
} = props;
const minValue = min ?? props['minValue'];
const maxValue = max ?? props['maxValue'];
const range = normalizeSliderRange(minValue, maxValue);
const step = normalizeSliderNumber(props['step'], Number.NaN);
const stepRatio = toSliderStepRatio(step, range);
const stepProps = stepRatio === undefined ? {} : { step: stepRatio };
const ratio = toSliderRatio(props.value, range);
const [displayValue, setDisplayValue] = useState<number>(
Math.round(fromSliderRatio(ratio, range, step)),
);
const labelText = normalizeSliderLabel(label);
const checks = props.checks as CheckLike[] | undefined;

const { ok, firstFailureMessage } = useChecks({
checks,
componentId: id ?? '',
surface,
dataContextPath,
});

const handleValueChange = (nextRatio: number) => {
const nextValue = fromSliderRatio(nextRatio, range, step);
setValue?.('value', nextValue);
setDisplayValue(Math.round(nextValue));
};
Comment thread
MoonfaceX marked this conversation as resolved.

return (
<view
key={id}
className={`slider${ok ? '' : ' slider-invalid'}`}
>
{labelText
? (
<view className='slider-header'>
<text className='slider-label'>{labelText}</text>
<text className='slider-value'>{String(displayValue)}</text>
</view>
)
: null}
<view className='slider-control'>
<SliderRoot
{...stepProps}
className='slider-root'
value={ratio}
onValueChange={handleValueChange}
>
<SliderTrack className='slider-track'>
<SliderIndicator className='slider-indicator' />
<SliderThumb className='slider-thumb'>
<view className='slider-thumb-dot' />
</SliderThumb>
</SliderTrack>
</SliderRoot>
</view>
{!ok && firstFailureMessage
? <text className='slider-error'>{firstFailureMessage}</text>
: null}
</view>
);
}
91 changes: 91 additions & 0 deletions packages/genui/a2ui/src/catalog/Slider/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

const DEFAULT_MIN = 0;
const DEFAULT_MAX = 100;

export interface SliderRange {
min: number;
max: number;
}

export function normalizeSliderNumber(
value: unknown,
fallback: number,
): number {
const numberValue = typeof value === 'number'
? value
: (typeof value === 'string'
? Number(value)
: Number.NaN);

return Number.isFinite(numberValue) ? numberValue : fallback;
}

export function normalizeSliderRange(
minValue: unknown,
maxValue: unknown,
): SliderRange {
const min = normalizeSliderNumber(minValue, DEFAULT_MIN);
const max = normalizeSliderNumber(maxValue, DEFAULT_MAX);

if (max > min) {
return { min, max };
}

return { min: DEFAULT_MIN, max: DEFAULT_MAX };
}

export function clampSliderValue(value: number, range: SliderRange): number {
return Math.min(Math.max(value, range.min), range.max);
}

export function toSliderRatio(value: unknown, range: SliderRange): number {
const numericValue = normalizeSliderNumber(value, range.min);
return (clampSliderValue(numericValue, range) - range.min)
/ (range.max - range.min);
}

export function fromSliderRatio(
ratio: number,
range: SliderRange,
step?: number,
): number {
const value = clampSliderValue(
range.min + ratio * (range.max - range.min),
range,
);
if (!step || step <= 0) {
return trimFloatingPoint(value);
}
const stepped = range.min + Math.round((value - range.min) / step) * step;
return trimFloatingPoint(clampSliderValue(stepped, range));
}

export function toSliderStepRatio(
step: unknown,
range: SliderRange,
): number | undefined {
const stepValue = normalizeSliderNumber(step, Number.NaN);
if (!Number.isFinite(stepValue) || stepValue <= 0) {
return undefined;
}
return Math.min(stepValue / (range.max - range.min), 1);
}

export function normalizeSliderLabel(value: unknown): string {
if (value === null || value === undefined) return '';
if (
typeof value === 'string'
|| typeof value === 'number'
|| typeof value === 'boolean'
) {
return String(value);
}
return '';
}

function trimFloatingPoint(value: number): number {
return Number(value.toFixed(12));
}
Loading
Loading