Skip to content
Open
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
1 change: 1 addition & 0 deletions @theme/markdoc/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export { Quiz } from '@redocly/marketing-pages/components/Quiz/Quiz.js';
export { TagBadge } from './components/TagBadge/TagBadge';
export { SplitView, LeftView, RightView } from './components/SplitView/SplitView';
export { GroupElements } from './components/GroupElements/GroupElements';
export { Demo } from './components/Demo/Demo';
export * from '../../docs/realm/@theme/markdoc/components';
106 changes: 106 additions & 0 deletions @theme/markdoc/components/ColorControl/ColorControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useId } from 'react';
import styled from 'styled-components';

import { CheckmarkIcon } from '@redocly/theme/icons/CheckmarkIcon/CheckmarkIcon';

export type ColorControlProps = {
options: readonly string[];
value?: string;
onChange: (value: string) => void;
/** Groups the inputs. Generated when the caller passes nothing. */
name?: string;
ariaLabel?: string;
className?: string;
};

/**
* Picks one of the palette color names. Each swatch carries the theme's own
* `.tag-{name}` class, so it takes the color from `--tag-color` and also covers
* the custom names a project defines in its stylesheet.
*/
export function ColorControl({
options,
value,
onChange,
name,
ariaLabel,
className,
}: ColorControlProps) {
const generatedName = useId();

return (
<ColorControlWrapper
data-component-name="ColorControl/ColorControl"
className={className}
role="radiogroup"
aria-label={ariaLabel}
>
{options.map((option) => (
<Swatch key={option} className={`tag-${option}`} title={option}>
<Radio
type="radio"
name={name ?? generatedName}
value={option}
checked={value === option}
onChange={() => onChange(option)}
/>
<SwatchFill>
{value === option && (
<CheckMark>
<CheckmarkIcon width="12" height="12" color="--color-static-white" />
</CheckMark>
)}
</SwatchFill>
</Swatch>
))}
</ColorControlWrapper>
);
}

const ColorControlWrapper = styled.div`
display: flex;
align-items: center;
gap: var(--spacing-xxs);
min-height: var(--demo-control-height, 32px);
`;

/* Shares the row with the other swatches, so a long palette never wraps. */
const Swatch = styled.label`
position: relative;
display: flex;
flex: 1 1 auto;
min-width: 0;
max-width: 20px;
cursor: pointer;
`;

/* Kept in the layout so it stays focusable with the keyboard. */
const Radio = styled.input`
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
`;

const SwatchFill = styled.span`
position: relative;
display: block;
width: 100%;
aspect-ratio: 1;
border-radius: 50%;
background: var(--tag-color, var(--text-color-secondary));

${Radio}:focus-visible + & {
outline: 2px solid var(--color-primary-base);
outline-offset: 2px;
}
`;

const CheckMark = styled.span`
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
`;
242 changes: 242 additions & 0 deletions @theme/markdoc/components/Demo/AttributeControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import React, { useId, useMemo } from 'react';
import styled from 'styled-components';

import { Select } from '@redocly/theme/components/Select/Select';
import { Switch } from '@redocly/theme/components/Switch/Switch';
import { Tooltip } from '@redocly/theme/components/Tooltip/Tooltip';
import { InformationIcon } from '@redocly/theme/icons/InformationIcon/InformationIcon';

import { ColorControl } from '../ColorControl/ColorControl';
import { RadioGroup } from '../RadioGroup/RadioGroup';
import { getControlKind, getEnumOptions } from './properties';

import type { AttributeControlKind, AttributeDescriptor, AttributeValue } from './properties';

export type AttributeControlProps = {
name: string;
/** Shown instead of the name, exactly as given. */
label?: string;
descriptor: AttributeDescriptor;
value: AttributeValue;
onChange: (value: AttributeValue) => void;
className?: string;
};

export function AttributeControl({
name,
label: labelText,
descriptor,
value,
onChange,
className,
}: AttributeControlProps) {
const controlId = useId();
const kind = getControlKind(descriptor);
// Select and ColorControl run effects on every new options identity, so keep the array stable.
const options = useMemo(() => getEnumOptions(descriptor), [descriptor]);
const selectOptions = useMemo(
() =>
options?.map((option) => ({
value: option,
label: String(option),
element: String(option),
})),
[options],
);
const colorOptions = useMemo(() => options?.map((option) => String(option)), [options]);

const label = (
<LabelRow>
<Label htmlFor={controlId}>
{labelText ?? formatLabel(name)}
{descriptor.required && <Required>*</Required>}
</Label>
{kind === 'color' && value !== undefined && <ValueName>{formatLabel(String(value))}</ValueName>}
{descriptor.description && (
<Tooltip tip={descriptor.description} placement="top" width="240px">
<DescriptionTrigger type="button" aria-label={`About ${name}`}>
<InformationIcon width="14" height="14" color="--icon-color-secondary" />
</DescriptionTrigger>
</Tooltip>
)}
</LabelRow>
);

// A switch keeps its label on one row, with the control at the end.
if (kind === 'switch') {
return (
<SwitchFieldWrapper data-component-name="Demo/AttributeControl" className={className}>
{label}
<Switch value={Boolean(value)} onChange={onChange} />
</SwitchFieldWrapper>
);
}

return (
<AttributeControlWrapper data-component-name="Demo/AttributeControl" className={className}>
{label}
{kind === 'color' && colorOptions ? (
<ColorControl
value={value === undefined ? undefined : String(value)}
options={colorOptions}
onChange={onChange}
ariaLabel={name}
/>
) : kind === 'radio' && selectOptions ? (
<RadioGroup
value={value}
options={selectOptions}
onChange={onChange}
ariaLabel={name}
stretch
/>
) : kind === 'select' && selectOptions ? (
<FullWidthSelect
value={value}
options={selectOptions}
placeholder="Not set"
clearable={!descriptor.required}
onChange={(next) => onChange(next as AttributeValue)}
/>
) : kind === 'textarea' ? (
<TextArea
id={controlId}
rows={2}
value={value === undefined ? '' : String(value)}
onChange={(event) => onChange(event.target.value)}
/>
) : (
<Input
id={controlId}
type={kind === 'number' ? 'number' : 'text'}
value={value === undefined ? '' : String(value)}
placeholder={descriptor.default === undefined ? 'Not set' : String(descriptor.default)}
onChange={(event) => onChange(readInputValue(event.target.value, kind))}
/>
)}
</AttributeControlWrapper>
);
}

/** Turns a name such as "badgeColor" or "persian-green" into "Badge color" / "Persian green". */
function formatLabel(name: string): string {
const words = name
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[-_]+/g, ' ')
.toLowerCase();

return words.charAt(0).toUpperCase() + words.slice(1);
}

function readInputValue(raw: string, kind: AttributeControlKind): AttributeValue {
if (kind !== 'number') {
return raw;
}

return raw === '' ? undefined : Number(raw);
}

const AttributeControlWrapper = styled.div`
display: flex;
flex-direction: column;
gap: var(--spacing-xxs);
min-width: 0;
`;

const SwitchFieldWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
min-width: 0;
`;

const LabelRow = styled.div`
display: flex;
align-items: center;
gap: var(--spacing-xs);
min-height: 22px;
`;

const Label = styled.label`
color: var(--text-color-secondary);
font-family: var(--font-family-base);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-base);
`;

const ValueName = styled.span`
color: var(--text-color-helper);
font-family: var(--font-family-base);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
`;

const Required = styled.span`
color: var(--color-red-6, #d64545);
margin-left: 2px;
`;

const DescriptionTrigger = styled.button`
display: flex;
align-items: center;
padding: 0;
border: none;
background: none;
cursor: help;

&:focus-visible {
outline: 2px solid var(--color-primary-base);
outline-offset: 2px;
border-radius: var(--border-radius);
}
`;

const controlTypography = `
color: var(--text-color-primary);
font-family: var(--font-family-base);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
`;

const controlSurface = `
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-lg);
background: var(--input-bg-color);

&:hover {
border-color: var(--color-warm-grey-4);
}

&:focus {
border-color: var(--color-primary-base);
outline: none;
}
`;

const FullWidthSelect = styled(Select)`
width: 100%;
min-height: var(--demo-control-height);
`;

const Input = styled.input`
${controlTypography}
${controlSurface}
width: 100%;
min-height: var(--demo-control-height);
padding: 0 var(--spacing-sm);
`;

const TextArea = styled.textarea`
${controlTypography}
${controlSurface}
width: 100%;
padding: var(--spacing-xs) var(--spacing-sm);
font-family: var(--font-family-monospaced);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
resize: vertical;
`;
41 changes: 41 additions & 0 deletions @theme/markdoc/components/Demo/Demo-markdoc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import markdoc from '@markdoc/markdoc';

import type { Config, Node } from '@markdoc/markdoc';
import type { MarkdocTagSchema } from '@redocly/theme/markdoc/tags/types';

export const DemoTag: MarkdocTagSchema = {
render: 'Demo',
attributes: {
tag: {
type: String,
required: true,
description: 'Name of the built-in Markdoc tag to demonstrate, such as "admonition".',
},
properties: {
type: Object,
description:
'Attribute descriptors of the demonstrated tag, either as a flat map or as "groups", "content", and "attributes". Each descriptor holds a "type", and optionally "default", "required", "description", "enum", and "group".',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Low

The description for properties lists "groups", "content", and "attributes", but omits "separators", which is supported and used in cards.md. The attribute descriptor list also omits supported fields like hidden and matches.

},
layout: {
type: String,
default: 'horizontal',
matches: ['horizontal', 'vertical'],
description:
'Places the form beside the preview ("horizontal", the default) or under it ("vertical").',
},
},
transform(node: Node, config: Config) {
const attributes = node.transformAttributes(config);
// The body doubles as text, so the form can edit it and the snippet can show it.
const body = node.children
.map((child) => markdoc.format(child))
.join('\n')
.trim();

return new markdoc.Tag(
'Demo',
{ ...attributes, initialChildren: body },
node.transformChildren(config),
);
},
};
Loading
Loading