Merge pull request #3441 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 89b7a3d7fb
This commit is contained in:
@@ -11,11 +11,14 @@ module BrandingHelper
|
||||
end
|
||||
|
||||
def _logo_as_symbol_wordmark
|
||||
content_tag(:svg, tag.use(href: '#logo-symbol-wordmark'), viewBox: '0 0 261 66', class: 'logo logo--wordmark')
|
||||
tag.svg(viewBox: '0 0 261 66', class: 'logo logo--wordmark') do
|
||||
tag.title('Mastodon') +
|
||||
tag.use(href: '#logo-symbol-wordmark')
|
||||
end
|
||||
end
|
||||
|
||||
def _logo_as_symbol_icon
|
||||
content_tag(:svg, tag.use(href: '#logo-symbol-icon'), viewBox: '0 0 79 79', class: 'logo logo--icon')
|
||||
tag.svg(tag.use(href: '#logo-symbol-icon'), viewBox: '0 0 79 79', class: 'logo logo--icon')
|
||||
end
|
||||
|
||||
def render_logo
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { A11yLiveRegion } from '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/A11yLiveRegion',
|
||||
component: A11yLiveRegion,
|
||||
} satisfies Meta<typeof A11yLiveRegion>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Polite: Story = {
|
||||
args: {
|
||||
children: "This field can't be empty.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Assertive: Story = {
|
||||
args: {
|
||||
...Polite.args,
|
||||
role: 'alert',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
/**
|
||||
* A live region is a content region that announces changes of its contents
|
||||
* to users of assistive technology like screen readers.
|
||||
*
|
||||
* Dynamically added warnings, errors, or live status updates should be wrapped
|
||||
* in a live region to ensure they are not missed when they appear.
|
||||
*
|
||||
* **Important:**
|
||||
* Live regions must be present in the DOM _before_
|
||||
* the to-be announced content is rendered into it.
|
||||
*/
|
||||
|
||||
export const A11yLiveRegion = polymorphicForwardRef<'div'>(
|
||||
({ role = 'status', as: Component = 'div', children, ...props }, ref) => {
|
||||
return (
|
||||
<Component
|
||||
role={role}
|
||||
aria-live={role === 'alert' ? 'assertive' : 'polite'}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { CalloutInline } from '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/CalloutInline',
|
||||
args: {
|
||||
children: 'Contents here',
|
||||
},
|
||||
component: CalloutInline,
|
||||
} satisfies Meta<typeof CalloutInline>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
variant: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: 'warning',
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
variant: 'info',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export interface FieldStatus {
|
||||
variant: 'error' | 'warning' | 'info' | 'success';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = {
|
||||
error: ErrorIcon,
|
||||
warning: WarningIcon,
|
||||
info: InfoIcon,
|
||||
success: CheckIcon,
|
||||
};
|
||||
|
||||
export const CalloutInline: FC<
|
||||
Partial<FieldStatus> & React.ComponentPropsWithoutRef<'div'>
|
||||
> = ({ variant = 'error', message, className, children, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames(className, classes.wrapper)}
|
||||
data-variant={variant}
|
||||
>
|
||||
<Icon id={variant} icon={iconMap[variant]} className={classes.icon} />
|
||||
{message ?? children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&[data-variant='success'] {
|
||||
color: var(--color-text-success);
|
||||
}
|
||||
|
||||
&[data-variant='warning'] {
|
||||
color: var(--color-text-warning);
|
||||
}
|
||||
|
||||
&[data-variant='error'] {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
&[data-variant='info'] {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
|
||||
export const CheckboxField = forwardRef<
|
||||
HTMLInputElement,
|
||||
Props & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
|
||||
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
inputPlacement='inline-start'
|
||||
>
|
||||
|
||||
@@ -86,14 +86,14 @@ interface Props<T extends ComboboxItem>
|
||||
*/
|
||||
|
||||
export const ComboboxFieldWithRef = <T extends ComboboxItem>(
|
||||
{ id, label, hint, hasError, required, ...otherProps }: Props<T>,
|
||||
{ id, label, hint, status, required, ...otherProps }: Props<T>,
|
||||
ref: React.ForwardedRef<HTMLInputElement>,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps {
|
||||
|
||||
export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
|
||||
(
|
||||
{ id, label, hint, hasError, value, required, className, ...otherProps },
|
||||
{ id, label, hint, status, value, required, className, ...otherProps },
|
||||
ref,
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
@@ -48,7 +48,7 @@ export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
|
||||
@@ -37,7 +37,7 @@ export const EmojiTextInputField: FC<
|
||||
value,
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
status,
|
||||
maxLength,
|
||||
counterMax = maxLength,
|
||||
recommended,
|
||||
@@ -49,7 +49,7 @@ export const EmojiTextInputField: FC<
|
||||
const wrapperProps = {
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
status,
|
||||
counterMax,
|
||||
recommended,
|
||||
disabled,
|
||||
@@ -84,7 +84,7 @@ export const EmojiTextAreaField: FC<
|
||||
recommended,
|
||||
disabled,
|
||||
hint,
|
||||
hasError,
|
||||
status,
|
||||
...otherProps
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -92,7 +92,7 @@ export const EmojiTextAreaField: FC<
|
||||
const wrapperProps = {
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
status,
|
||||
counterMax,
|
||||
recommended,
|
||||
disabled,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
.fieldset {
|
||||
--container-gap: 12px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--container-gap);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
@@ -17,3 +19,11 @@
|
||||
column-gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
// If there's no content, we need to compensate for the parent's
|
||||
// flex gap to avoid extra spacing below the field.
|
||||
&:empty {
|
||||
margin-top: calc(-1 * var(--container-gap));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
import type { ReactNode, FC } from 'react';
|
||||
import { createContext, useId } from 'react';
|
||||
|
||||
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
|
||||
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
|
||||
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
|
||||
|
||||
import classes from './fieldset.module.scss';
|
||||
import { getFieldStatus } from './form_field_wrapper';
|
||||
import formFieldWrapperClasses from './form_field_wrapper.module.scss';
|
||||
|
||||
interface FieldsetProps {
|
||||
legend: ReactNode;
|
||||
hint?: ReactNode;
|
||||
name?: string;
|
||||
hasError?: boolean;
|
||||
status?: FieldStatus | FieldStatus['variant'];
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -26,22 +31,33 @@ export const Fieldset: FC<FieldsetProps> = ({
|
||||
legend,
|
||||
hint,
|
||||
name,
|
||||
hasError,
|
||||
status,
|
||||
layout,
|
||||
children,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const labelId = `${uniqueId}-label`;
|
||||
const hintId = `${uniqueId}-hint`;
|
||||
const statusId = `${uniqueId}-status`;
|
||||
const fieldsetName = name || `${uniqueId}-fieldset-name`;
|
||||
const hasHint = !!hint;
|
||||
|
||||
const fieldStatus = getFieldStatus(status);
|
||||
const hasStatusMessage = !!fieldStatus?.message;
|
||||
|
||||
const descriptionIds = [
|
||||
hasHint ? hintId : '',
|
||||
hasStatusMessage ? statusId : '',
|
||||
]
|
||||
.filter((id) => !!id)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className={classes.fieldset}
|
||||
data-has-error={hasError}
|
||||
data-has-error={status === 'error'}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={hintId}
|
||||
aria-describedby={descriptionIds}
|
||||
>
|
||||
<div className={formFieldWrapperClasses.labelWrapper}>
|
||||
<div id={labelId} className={formFieldWrapperClasses.label}>
|
||||
@@ -59,6 +75,11 @@ export const Fieldset: FC<FieldsetProps> = ({
|
||||
{children}
|
||||
</FieldsetNameContext.Provider>
|
||||
</div>
|
||||
|
||||
{/* Live region must be rendered even when empty */}
|
||||
<A11yLiveRegion className={classes.status} id={statusId}>
|
||||
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
|
||||
</A11yLiveRegion>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,14 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status {
|
||||
// If there's no content, we need to compensate for the parent's
|
||||
// flex gap to avoid extra spacing below the field.
|
||||
&:empty {
|
||||
margin-top: calc(-1 * var(--form-field-label-gap));
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
|
||||
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
|
||||
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
|
||||
|
||||
import { FieldsetNameContext } from './fieldset';
|
||||
import classes from './form_field_wrapper.module.scss';
|
||||
|
||||
@@ -20,7 +24,7 @@ interface FieldWrapperProps {
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
required?: boolean;
|
||||
hasError?: boolean;
|
||||
status?: FieldStatus['variant'] | FieldStatus;
|
||||
inputId?: string;
|
||||
describedById?: string;
|
||||
inputPlacement?: 'inline-start' | 'inline-end';
|
||||
@@ -33,7 +37,7 @@ interface FieldWrapperProps {
|
||||
*/
|
||||
export type CommonFieldWrapperProps = Pick<
|
||||
FieldWrapperProps,
|
||||
'label' | 'hint' | 'hasError'
|
||||
'label' | 'hint' | 'status'
|
||||
> & { wrapperClassName?: string };
|
||||
|
||||
/**
|
||||
@@ -48,27 +52,31 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||
hint,
|
||||
describedById,
|
||||
required,
|
||||
hasError,
|
||||
status,
|
||||
inputPlacement,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const inputId = inputIdProp || `${uniqueId}-input`;
|
||||
const statusId = `${inputIdProp || uniqueId}-status`;
|
||||
const hintId = `${inputIdProp || uniqueId}-hint`;
|
||||
const hasHint = !!hint;
|
||||
const fieldStatus = getFieldStatus(status);
|
||||
const hasStatusMessage = !!fieldStatus?.message;
|
||||
|
||||
const hasParentFieldset = !!useContext(FieldsetNameContext);
|
||||
|
||||
const descriptionIds =
|
||||
[hasHint ? hintId : '', hasStatusMessage ? statusId : '', describedById]
|
||||
.filter((id) => !!id)
|
||||
.join(' ') || undefined;
|
||||
|
||||
const inputProps: InputProps = {
|
||||
required,
|
||||
id: inputId,
|
||||
'aria-describedby': descriptionIds,
|
||||
};
|
||||
if (hasHint) {
|
||||
inputProps['aria-describedby'] = describedById
|
||||
? `${describedById} ${hintId}`
|
||||
: hintId;
|
||||
}
|
||||
|
||||
const input = (
|
||||
<div className={classes.inputWrapper}>{children(inputProps)}</div>
|
||||
@@ -77,7 +85,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.wrapper, className)}
|
||||
data-has-error={hasError}
|
||||
data-has-error={fieldStatus?.variant === 'error'}
|
||||
data-input-placement={inputPlacement}
|
||||
>
|
||||
{inputPlacement === 'inline-start' && input}
|
||||
@@ -100,6 +108,11 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||
</div>
|
||||
|
||||
{inputPlacement !== 'inline-start' && input}
|
||||
|
||||
{/* Live region must be rendered even when empty */}
|
||||
<A11yLiveRegion className={classes.status} id={statusId}>
|
||||
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
|
||||
</A11yLiveRegion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -121,3 +134,19 @@ const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
|
||||
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
|
||||
</>
|
||||
);
|
||||
|
||||
export function getFieldStatus(status: FieldWrapperProps['status']) {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof status === 'string') {
|
||||
const fieldStatus: FieldStatus = {
|
||||
variant: status,
|
||||
message: '',
|
||||
};
|
||||
return fieldStatus;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
|
||||
export const RadioButtonField = forwardRef<
|
||||
HTMLInputElement,
|
||||
Props & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => {
|
||||
>(({ id, label, hint, status, required, ...otherProps }, ref) => {
|
||||
const fieldsetName = useContext(FieldsetNameContext);
|
||||
|
||||
return (
|
||||
@@ -23,7 +23,7 @@ export const RadioButtonField = forwardRef<
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
inputPlacement='inline-start'
|
||||
>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
Inspired by:
|
||||
https://danielstern.ca/range.css
|
||||
https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/
|
||||
*/
|
||||
|
||||
.input {
|
||||
--color-bg-thumb: var(--color-bg-brand-base);
|
||||
--color-bg-thumb-hover: var(--color-bg-brand-base-hover);
|
||||
--color-bg-track: var(--color-bg-secondary);
|
||||
|
||||
width: 100%;
|
||||
margin: 6px 0;
|
||||
background-color: transparent;
|
||||
appearance: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Thumb
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
margin-top: -6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--color-bg-thumb);
|
||||
border: 0;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--color-bg-thumb);
|
||||
border: 0;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--color-bg-thumb);
|
||||
border: 0;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
margin-top: 0; // Needed to keep the Edge thumb centred
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
&::-webkit-slider-thumb {
|
||||
background: var(--color-bg-thumb-hover);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
background: var(--color-bg-thumb-hover);
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
background: var(--color-bg-thumb-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&::-webkit-slider-thumb {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Track
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
background: var(--color-bg-track);
|
||||
border: 0;
|
||||
border-radius: 1.3px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
background: var(--color-bg-track);
|
||||
border: 0;
|
||||
border-radius: 1.3px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-ms-track {
|
||||
background: var(--color-bg-track);
|
||||
border: 0;
|
||||
color: transparent;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.markers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
writing-mode: vertical-lr;
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
user-select: none;
|
||||
|
||||
option {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { RangeInputField } from './range_input_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/RangeInputField',
|
||||
component: RangeInputField,
|
||||
args: {
|
||||
label: 'Label',
|
||||
hint: 'This is a description of this form field',
|
||||
checked: false,
|
||||
disabled: false,
|
||||
},
|
||||
} satisfies Meta<typeof RangeInputField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Simple: Story = {};
|
||||
|
||||
export const Markers: Story = {
|
||||
args: {
|
||||
markers: [
|
||||
{ value: 0, label: 'None' },
|
||||
{ value: 25, label: 'Some' },
|
||||
{ value: 50, label: 'Half' },
|
||||
{ value: 75, label: 'Most' },
|
||||
{ value: 100, label: 'All' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { forwardRef, useId } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './range_input.module.scss';
|
||||
|
||||
export type RangeInputProps = Omit<
|
||||
ComponentPropsWithoutRef<'input'>,
|
||||
'type' | 'list'
|
||||
> & {
|
||||
markers?: { value: number; label: string }[] | number[];
|
||||
};
|
||||
|
||||
interface Props extends RangeInputProps, CommonFieldWrapperProps {}
|
||||
|
||||
/**
|
||||
* A simple form field for single-line text.
|
||||
*
|
||||
* Accepts an optional `hint` and can be marked as required
|
||||
* or optional (by explicitly setting `required={false}`)
|
||||
*/
|
||||
|
||||
export const RangeInputField = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
status={status}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
{(inputProps) => <RangeInput {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
RangeInputField.displayName = 'RangeInputField';
|
||||
|
||||
export const RangeInput = forwardRef<HTMLInputElement, RangeInputProps>(
|
||||
({ className, markers, id, ...otherProps }, ref) => {
|
||||
const markersId = useId();
|
||||
|
||||
if (!markers) {
|
||||
return (
|
||||
<input
|
||||
{...otherProps}
|
||||
type='range'
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
{...otherProps}
|
||||
type='range'
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
list={markersId}
|
||||
/>
|
||||
<datalist id={markersId} className={classes.markers}>
|
||||
{markers.map((marker) => {
|
||||
const value = typeof marker === 'number' ? marker : marker.value;
|
||||
return (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
label={typeof marker !== 'number' ? marker.label : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</datalist>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RangeInput.displayName = 'RangeInput';
|
||||
@@ -51,7 +51,7 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ interface Props
|
||||
*/
|
||||
|
||||
export const SelectField = forwardRef<HTMLSelectElement, Props>(
|
||||
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
|
||||
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
|
||||
@@ -38,7 +38,17 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: { variant: 'error', message: "This field can't be empty" },
|
||||
},
|
||||
};
|
||||
|
||||
export const WithWarning: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
status: {
|
||||
variant: 'warning',
|
||||
message: 'Special characters are not allowed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -26,14 +26,14 @@ export const TextAreaField = forwardRef<
|
||||
TextAreaProps & CommonFieldWrapperProps
|
||||
>(
|
||||
(
|
||||
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps },
|
||||
{ id, label, hint, required, status, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
|
||||
@@ -29,16 +29,16 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-text-brand);
|
||||
}
|
||||
|
||||
&:focus:user-invalid,
|
||||
&:required:user-invalid,
|
||||
[data-has-error='true'] & {
|
||||
outline-color: var(--color-text-error);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-text-brand);
|
||||
}
|
||||
|
||||
&:required:user-valid {
|
||||
outline-color: var(--color-text-success);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,17 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithWarning: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
status: {
|
||||
variant: 'warning',
|
||||
message: 'Special characters are not allowed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -25,14 +25,14 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {}
|
||||
|
||||
export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps },
|
||||
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
|
||||
export const ToggleField = forwardRef<
|
||||
HTMLInputElement,
|
||||
Props & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
|
||||
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
inputPlacement='inline-end'
|
||||
>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
}
|
||||
|
||||
$button-breakpoint: 420px;
|
||||
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
|
||||
$button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
|
||||
.buttonsDesktop {
|
||||
@container (width < #{$button-breakpoint}) {
|
||||
@@ -132,7 +132,7 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
|
||||
}
|
||||
|
||||
@supports (not (container-type: inline-size)) {
|
||||
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
|
||||
@media (min-width: ($button-fallback-breakpoint + 1px)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +141,11 @@ const InnerNodeModal: FC<{
|
||||
onChange={handleChange}
|
||||
label={intl.formatMessage(messages.fieldLabel)}
|
||||
className={classes.noteInput}
|
||||
hasError={state === 'error'}
|
||||
hint={errorText}
|
||||
status={
|
||||
state === 'error'
|
||||
? { variant: 'error', message: errorText }
|
||||
: undefined
|
||||
}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal.
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import { inputToHashtag } from '@/flavours/glitch/utils/hashtags';
|
||||
import {
|
||||
hasSpecialCharacters,
|
||||
inputToHashtag,
|
||||
} from '@/flavours/glitch/utils/hashtags';
|
||||
import type {
|
||||
ApiCreateCollectionPayload,
|
||||
ApiUpdateCollectionPayload,
|
||||
@@ -31,6 +34,7 @@ import classes from './styles.module.scss';
|
||||
import { WizardStepHeader } from './wizard_step_header';
|
||||
|
||||
export const CollectionDetails: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const { id, name, description, topic, discoverable, sensitive, accountIds } =
|
||||
@@ -152,6 +156,11 @@ export const CollectionDetails: React.FC = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const topicHasSpecialCharacters = useMemo(
|
||||
() => hasSpecialCharacters(topic),
|
||||
[topic],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classes.form}>
|
||||
<FormStack className={classes.formFieldStack}>
|
||||
@@ -224,6 +233,18 @@ export const CollectionDetails: React.FC = () => {
|
||||
autoCorrect='off'
|
||||
spellCheck='false'
|
||||
maxLength={40}
|
||||
status={
|
||||
topicHasSpecialCharacters
|
||||
? {
|
||||
variant: 'warning',
|
||||
message: intl.formatMessage({
|
||||
id: 'collections.topic_special_chars_hint',
|
||||
defaultMessage:
|
||||
'Special characters will be removed when saving',
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Fieldset
|
||||
|
||||
@@ -233,7 +233,7 @@ export const Profile: React.FC<{
|
||||
}
|
||||
value={displayName}
|
||||
onChange={handleDisplayNameChange}
|
||||
hasError={!!errors?.display_name}
|
||||
status={errors?.display_name ? 'error' : undefined}
|
||||
id='display_name'
|
||||
/>
|
||||
</div>
|
||||
@@ -255,7 +255,7 @@ export const Profile: React.FC<{
|
||||
}
|
||||
value={note}
|
||||
onChange={handleNoteChange}
|
||||
hasError={!!errors?.note}
|
||||
status={errors?.note ? 'error' : undefined}
|
||||
id='note'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
createAppSelector,
|
||||
createDataLoadingThunk,
|
||||
} from '@/flavours/glitch/store/typed_functions';
|
||||
import { inputToHashtag } from '@/flavours/glitch/utils/hashtags';
|
||||
|
||||
type QueryStatus = 'idle' | 'loading' | 'error';
|
||||
|
||||
@@ -82,7 +83,7 @@ const collectionSlice = createSlice({
|
||||
id: collection?.id ?? null,
|
||||
name: collection?.name ?? '',
|
||||
description: collection?.description ?? '',
|
||||
topic: collection?.tag?.name ?? '',
|
||||
topic: inputToHashtag(collection?.tag?.name ?? ''),
|
||||
language: collection?.language ?? '',
|
||||
discoverable: collection?.discoverable ?? true,
|
||||
sensitive: collection?.sensitive ?? false,
|
||||
|
||||
@@ -8763,7 +8763,7 @@ noscript {
|
||||
gap: 8px;
|
||||
|
||||
$button-breakpoint: 420px;
|
||||
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
|
||||
$button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
|
||||
&--desktop {
|
||||
margin-top: 55px;
|
||||
@@ -8787,7 +8787,7 @@ noscript {
|
||||
}
|
||||
|
||||
@supports (not (container-type: inline-size)) {
|
||||
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
|
||||
@media (min-width: ($button-fallback-breakpoint + 1px)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,28 +13,28 @@
|
||||
.logo-container {
|
||||
margin: 50px auto;
|
||||
|
||||
h1 {
|
||||
a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: min-content;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px;
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
outline: 0;
|
||||
line-height: 32px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
|
||||
.logo {
|
||||
height: 42px;
|
||||
margin-inline-end: 10px;
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
outline: 0;
|
||||
padding: 12px 16px;
|
||||
line-height: 32px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.logo {
|
||||
height: 42px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ code {
|
||||
|
||||
.input {
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -472,13 +471,19 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
.input.radio_buttons .radio label {
|
||||
margin-bottom: 5px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
display: block;
|
||||
width: auto;
|
||||
.input.radio_buttons .radio {
|
||||
label {
|
||||
margin-bottom: 5px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
accent-color: var(--color-text-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.check_boxes {
|
||||
@@ -504,6 +509,12 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
label.checkbox {
|
||||
input[type='checkbox'] {
|
||||
accent-color: var(--color-text-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.input.static .label_input__wrapper {
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
@@ -524,13 +535,20 @@ code {
|
||||
color: var(--color-text-primary);
|
||||
display: block;
|
||||
width: 100%;
|
||||
outline: 0;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 4px;
|
||||
padding: 10px 16px;
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: -2px;
|
||||
outline-color: transparent;
|
||||
transition: outline-color 0.15s ease-out;
|
||||
|
||||
&:focus {
|
||||
outline: var(--outline-focus-default);
|
||||
}
|
||||
|
||||
&:invalid {
|
||||
box-shadow: none;
|
||||
@@ -614,6 +632,11 @@ code {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
@@ -654,6 +677,11 @@ code {
|
||||
padding-inline-end: 30px;
|
||||
height: 41px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -59,3 +59,8 @@ export const inputToHashtag = (input: string): string => {
|
||||
|
||||
return `#${words.join('')}${trailingSpace}`;
|
||||
};
|
||||
|
||||
export const hasSpecialCharacters = (input: string) => {
|
||||
// Regex matches any character NOT a letter/digit, except the #
|
||||
return /[^a-zA-Z0-9# ]/.test(input);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { A11yLiveRegion } from '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/A11yLiveRegion',
|
||||
component: A11yLiveRegion,
|
||||
} satisfies Meta<typeof A11yLiveRegion>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Polite: Story = {
|
||||
args: {
|
||||
children: "This field can't be empty.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Assertive: Story = {
|
||||
args: {
|
||||
...Polite.args,
|
||||
role: 'alert',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
/**
|
||||
* A live region is a content region that announces changes of its contents
|
||||
* to users of assistive technology like screen readers.
|
||||
*
|
||||
* Dynamically added warnings, errors, or live status updates should be wrapped
|
||||
* in a live region to ensure they are not missed when they appear.
|
||||
*
|
||||
* **Important:**
|
||||
* Live regions must be present in the DOM _before_
|
||||
* the to-be announced content is rendered into it.
|
||||
*/
|
||||
|
||||
export const A11yLiveRegion = polymorphicForwardRef<'div'>(
|
||||
({ role = 'status', as: Component = 'div', children, ...props }, ref) => {
|
||||
return (
|
||||
<Component
|
||||
role={role}
|
||||
aria-live={role === 'alert' ? 'assertive' : 'polite'}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { CalloutInline } from '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/CalloutInline',
|
||||
args: {
|
||||
children: 'Contents here',
|
||||
},
|
||||
component: CalloutInline,
|
||||
} satisfies Meta<typeof CalloutInline>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
variant: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: 'warning',
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
variant: 'info',
|
||||
},
|
||||
};
|
||||
39
app/javascript/mastodon/components/callout_inline/index.tsx
Normal file
39
app/javascript/mastodon/components/callout_inline/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export interface FieldStatus {
|
||||
variant: 'error' | 'warning' | 'info' | 'success';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = {
|
||||
error: ErrorIcon,
|
||||
warning: WarningIcon,
|
||||
info: InfoIcon,
|
||||
success: CheckIcon,
|
||||
};
|
||||
|
||||
export const CalloutInline: FC<
|
||||
Partial<FieldStatus> & React.ComponentPropsWithoutRef<'div'>
|
||||
> = ({ variant = 'error', message, className, children, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames(className, classes.wrapper)}
|
||||
data-variant={variant}
|
||||
>
|
||||
<Icon id={variant} icon={iconMap[variant]} className={classes.icon} />
|
||||
{message ?? children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&[data-variant='success'] {
|
||||
color: var(--color-text-success);
|
||||
}
|
||||
|
||||
&[data-variant='warning'] {
|
||||
color: var(--color-text-warning);
|
||||
}
|
||||
|
||||
&[data-variant='error'] {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
&[data-variant='info'] {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
|
||||
export const CheckboxField = forwardRef<
|
||||
HTMLInputElement,
|
||||
Props & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
|
||||
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
inputPlacement='inline-start'
|
||||
>
|
||||
|
||||
@@ -86,14 +86,14 @@ interface Props<T extends ComboboxItem>
|
||||
*/
|
||||
|
||||
export const ComboboxFieldWithRef = <T extends ComboboxItem>(
|
||||
{ id, label, hint, hasError, required, ...otherProps }: Props<T>,
|
||||
{ id, label, hint, status, required, ...otherProps }: Props<T>,
|
||||
ref: React.ForwardedRef<HTMLInputElement>,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps {
|
||||
|
||||
export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
|
||||
(
|
||||
{ id, label, hint, hasError, value, required, className, ...otherProps },
|
||||
{ id, label, hint, status, value, required, className, ...otherProps },
|
||||
ref,
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
@@ -48,7 +48,7 @@ export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
|
||||
@@ -37,7 +37,7 @@ export const EmojiTextInputField: FC<
|
||||
value,
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
status,
|
||||
maxLength,
|
||||
counterMax = maxLength,
|
||||
recommended,
|
||||
@@ -49,7 +49,7 @@ export const EmojiTextInputField: FC<
|
||||
const wrapperProps = {
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
status,
|
||||
counterMax,
|
||||
recommended,
|
||||
disabled,
|
||||
@@ -84,7 +84,7 @@ export const EmojiTextAreaField: FC<
|
||||
recommended,
|
||||
disabled,
|
||||
hint,
|
||||
hasError,
|
||||
status,
|
||||
...otherProps
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -92,7 +92,7 @@ export const EmojiTextAreaField: FC<
|
||||
const wrapperProps = {
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
status,
|
||||
counterMax,
|
||||
recommended,
|
||||
disabled,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
.fieldset {
|
||||
--container-gap: 12px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--container-gap);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
@@ -17,3 +19,11 @@
|
||||
column-gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
// If there's no content, we need to compensate for the parent's
|
||||
// flex gap to avoid extra spacing below the field.
|
||||
&:empty {
|
||||
margin-top: calc(-1 * var(--container-gap));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
import type { ReactNode, FC } from 'react';
|
||||
import { createContext, useId } from 'react';
|
||||
|
||||
import { A11yLiveRegion } from 'mastodon/components/a11y_live_region';
|
||||
import type { FieldStatus } from 'mastodon/components/callout_inline';
|
||||
import { CalloutInline } from 'mastodon/components/callout_inline';
|
||||
|
||||
import classes from './fieldset.module.scss';
|
||||
import { getFieldStatus } from './form_field_wrapper';
|
||||
import formFieldWrapperClasses from './form_field_wrapper.module.scss';
|
||||
|
||||
interface FieldsetProps {
|
||||
legend: ReactNode;
|
||||
hint?: ReactNode;
|
||||
name?: string;
|
||||
hasError?: boolean;
|
||||
status?: FieldStatus | FieldStatus['variant'];
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -26,22 +31,33 @@ export const Fieldset: FC<FieldsetProps> = ({
|
||||
legend,
|
||||
hint,
|
||||
name,
|
||||
hasError,
|
||||
status,
|
||||
layout,
|
||||
children,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const labelId = `${uniqueId}-label`;
|
||||
const hintId = `${uniqueId}-hint`;
|
||||
const statusId = `${uniqueId}-status`;
|
||||
const fieldsetName = name || `${uniqueId}-fieldset-name`;
|
||||
const hasHint = !!hint;
|
||||
|
||||
const fieldStatus = getFieldStatus(status);
|
||||
const hasStatusMessage = !!fieldStatus?.message;
|
||||
|
||||
const descriptionIds = [
|
||||
hasHint ? hintId : '',
|
||||
hasStatusMessage ? statusId : '',
|
||||
]
|
||||
.filter((id) => !!id)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className={classes.fieldset}
|
||||
data-has-error={hasError}
|
||||
data-has-error={status === 'error'}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={hintId}
|
||||
aria-describedby={descriptionIds}
|
||||
>
|
||||
<div className={formFieldWrapperClasses.labelWrapper}>
|
||||
<div id={labelId} className={formFieldWrapperClasses.label}>
|
||||
@@ -59,6 +75,11 @@ export const Fieldset: FC<FieldsetProps> = ({
|
||||
{children}
|
||||
</FieldsetNameContext.Provider>
|
||||
</div>
|
||||
|
||||
{/* Live region must be rendered even when empty */}
|
||||
<A11yLiveRegion className={classes.status} id={statusId}>
|
||||
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
|
||||
</A11yLiveRegion>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,14 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status {
|
||||
// If there's no content, we need to compensate for the parent's
|
||||
// flex gap to avoid extra spacing below the field.
|
||||
&:empty {
|
||||
margin-top: calc(-1 * var(--form-field-label-gap));
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { A11yLiveRegion } from 'mastodon/components/a11y_live_region';
|
||||
import type { FieldStatus } from 'mastodon/components/callout_inline';
|
||||
import { CalloutInline } from 'mastodon/components/callout_inline';
|
||||
|
||||
import { FieldsetNameContext } from './fieldset';
|
||||
import classes from './form_field_wrapper.module.scss';
|
||||
|
||||
@@ -20,7 +24,7 @@ interface FieldWrapperProps {
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
required?: boolean;
|
||||
hasError?: boolean;
|
||||
status?: FieldStatus['variant'] | FieldStatus;
|
||||
inputId?: string;
|
||||
describedById?: string;
|
||||
inputPlacement?: 'inline-start' | 'inline-end';
|
||||
@@ -33,7 +37,7 @@ interface FieldWrapperProps {
|
||||
*/
|
||||
export type CommonFieldWrapperProps = Pick<
|
||||
FieldWrapperProps,
|
||||
'label' | 'hint' | 'hasError'
|
||||
'label' | 'hint' | 'status'
|
||||
> & { wrapperClassName?: string };
|
||||
|
||||
/**
|
||||
@@ -48,27 +52,31 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||
hint,
|
||||
describedById,
|
||||
required,
|
||||
hasError,
|
||||
status,
|
||||
inputPlacement,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const inputId = inputIdProp || `${uniqueId}-input`;
|
||||
const statusId = `${inputIdProp || uniqueId}-status`;
|
||||
const hintId = `${inputIdProp || uniqueId}-hint`;
|
||||
const hasHint = !!hint;
|
||||
const fieldStatus = getFieldStatus(status);
|
||||
const hasStatusMessage = !!fieldStatus?.message;
|
||||
|
||||
const hasParentFieldset = !!useContext(FieldsetNameContext);
|
||||
|
||||
const descriptionIds =
|
||||
[hasHint ? hintId : '', hasStatusMessage ? statusId : '', describedById]
|
||||
.filter((id) => !!id)
|
||||
.join(' ') || undefined;
|
||||
|
||||
const inputProps: InputProps = {
|
||||
required,
|
||||
id: inputId,
|
||||
'aria-describedby': descriptionIds,
|
||||
};
|
||||
if (hasHint) {
|
||||
inputProps['aria-describedby'] = describedById
|
||||
? `${describedById} ${hintId}`
|
||||
: hintId;
|
||||
}
|
||||
|
||||
const input = (
|
||||
<div className={classes.inputWrapper}>{children(inputProps)}</div>
|
||||
@@ -77,7 +85,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.wrapper, className)}
|
||||
data-has-error={hasError}
|
||||
data-has-error={fieldStatus?.variant === 'error'}
|
||||
data-input-placement={inputPlacement}
|
||||
>
|
||||
{inputPlacement === 'inline-start' && input}
|
||||
@@ -100,6 +108,11 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||
</div>
|
||||
|
||||
{inputPlacement !== 'inline-start' && input}
|
||||
|
||||
{/* Live region must be rendered even when empty */}
|
||||
<A11yLiveRegion className={classes.status} id={statusId}>
|
||||
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
|
||||
</A11yLiveRegion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -121,3 +134,19 @@ const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
|
||||
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
|
||||
</>
|
||||
);
|
||||
|
||||
export function getFieldStatus(status: FieldWrapperProps['status']) {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof status === 'string') {
|
||||
const fieldStatus: FieldStatus = {
|
||||
variant: status,
|
||||
message: '',
|
||||
};
|
||||
return fieldStatus;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
|
||||
export const RadioButtonField = forwardRef<
|
||||
HTMLInputElement,
|
||||
Props & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => {
|
||||
>(({ id, label, hint, status, required, ...otherProps }, ref) => {
|
||||
const fieldsetName = useContext(FieldsetNameContext);
|
||||
|
||||
return (
|
||||
@@ -23,7 +23,7 @@ export const RadioButtonField = forwardRef<
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
inputPlacement='inline-start'
|
||||
>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
Inspired by:
|
||||
https://danielstern.ca/range.css
|
||||
https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/
|
||||
*/
|
||||
|
||||
.input {
|
||||
--color-bg-thumb: var(--color-bg-brand-base);
|
||||
--color-bg-thumb-hover: var(--color-bg-brand-base-hover);
|
||||
--color-bg-track: var(--color-bg-secondary);
|
||||
|
||||
width: 100%;
|
||||
margin: 6px 0;
|
||||
background-color: transparent;
|
||||
appearance: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Thumb
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
margin-top: -6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--color-bg-thumb);
|
||||
border: 0;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--color-bg-thumb);
|
||||
border: 0;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--color-bg-thumb);
|
||||
border: 0;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
margin-top: 0; // Needed to keep the Edge thumb centred
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
&::-webkit-slider-thumb {
|
||||
background: var(--color-bg-thumb-hover);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
background: var(--color-bg-thumb-hover);
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
background: var(--color-bg-thumb-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&::-webkit-slider-thumb {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Track
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
background: var(--color-bg-track);
|
||||
border: 0;
|
||||
border-radius: 1.3px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
background: var(--color-bg-track);
|
||||
border: 0;
|
||||
border-radius: 1.3px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-ms-track {
|
||||
background: var(--color-bg-track);
|
||||
border: 0;
|
||||
color: transparent;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.markers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
writing-mode: vertical-lr;
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
user-select: none;
|
||||
|
||||
option {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { RangeInputField } from './range_input_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/RangeInputField',
|
||||
component: RangeInputField,
|
||||
args: {
|
||||
label: 'Label',
|
||||
hint: 'This is a description of this form field',
|
||||
checked: false,
|
||||
disabled: false,
|
||||
},
|
||||
} satisfies Meta<typeof RangeInputField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Simple: Story = {};
|
||||
|
||||
export const Markers: Story = {
|
||||
args: {
|
||||
markers: [
|
||||
{ value: 0, label: 'None' },
|
||||
{ value: 25, label: 'Some' },
|
||||
{ value: 50, label: 'Half' },
|
||||
{ value: 75, label: 'Most' },
|
||||
{ value: 100, label: 'All' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { forwardRef, useId } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './range_input.module.scss';
|
||||
|
||||
export type RangeInputProps = Omit<
|
||||
ComponentPropsWithoutRef<'input'>,
|
||||
'type' | 'list'
|
||||
> & {
|
||||
markers?: { value: number; label: string }[] | number[];
|
||||
};
|
||||
|
||||
interface Props extends RangeInputProps, CommonFieldWrapperProps {}
|
||||
|
||||
/**
|
||||
* A simple form field for single-line text.
|
||||
*
|
||||
* Accepts an optional `hint` and can be marked as required
|
||||
* or optional (by explicitly setting `required={false}`)
|
||||
*/
|
||||
|
||||
export const RangeInputField = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
status={status}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
{(inputProps) => <RangeInput {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
RangeInputField.displayName = 'RangeInputField';
|
||||
|
||||
export const RangeInput = forwardRef<HTMLInputElement, RangeInputProps>(
|
||||
({ className, markers, id, ...otherProps }, ref) => {
|
||||
const markersId = useId();
|
||||
|
||||
if (!markers) {
|
||||
return (
|
||||
<input
|
||||
{...otherProps}
|
||||
type='range'
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
{...otherProps}
|
||||
type='range'
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
list={markersId}
|
||||
/>
|
||||
<datalist id={markersId} className={classes.markers}>
|
||||
{markers.map((marker) => {
|
||||
const value = typeof marker === 'number' ? marker : marker.value;
|
||||
return (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
label={typeof marker !== 'number' ? marker.label : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</datalist>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RangeInput.displayName = 'RangeInput';
|
||||
@@ -51,7 +51,7 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ interface Props
|
||||
*/
|
||||
|
||||
export const SelectField = forwardRef<HTMLSelectElement, Props>(
|
||||
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
|
||||
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
|
||||
@@ -38,7 +38,17 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: { variant: 'error', message: "This field can't be empty" },
|
||||
},
|
||||
};
|
||||
|
||||
export const WithWarning: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
status: {
|
||||
variant: 'warning',
|
||||
message: 'Special characters are not allowed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -26,14 +26,14 @@ export const TextAreaField = forwardRef<
|
||||
TextAreaProps & CommonFieldWrapperProps
|
||||
>(
|
||||
(
|
||||
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps },
|
||||
{ id, label, hint, required, status, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
|
||||
@@ -29,16 +29,16 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-text-brand);
|
||||
}
|
||||
|
||||
&:focus:user-invalid,
|
||||
&:required:user-invalid,
|
||||
[data-has-error='true'] & {
|
||||
outline-color: var(--color-text-error);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-text-brand);
|
||||
}
|
||||
|
||||
&:required:user-valid {
|
||||
outline-color: var(--color-text-success);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,17 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithWarning: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
status: {
|
||||
variant: 'warning',
|
||||
message: 'Special characters are not allowed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -25,14 +25,14 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {}
|
||||
|
||||
export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps },
|
||||
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const Optional: Story = {
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
|
||||
export const ToggleField = forwardRef<
|
||||
HTMLInputElement,
|
||||
Props & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
|
||||
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
status={status}
|
||||
inputId={id}
|
||||
inputPlacement='inline-end'
|
||||
>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
}
|
||||
|
||||
$button-breakpoint: 420px;
|
||||
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
|
||||
$button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
|
||||
.buttonsDesktop {
|
||||
@container (width < #{$button-breakpoint}) {
|
||||
@@ -132,7 +132,7 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
|
||||
}
|
||||
|
||||
@supports (not (container-type: inline-size)) {
|
||||
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
|
||||
@media (min-width: ($button-fallback-breakpoint + 1px)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +141,11 @@ const InnerNodeModal: FC<{
|
||||
onChange={handleChange}
|
||||
label={intl.formatMessage(messages.fieldLabel)}
|
||||
className={classes.noteInput}
|
||||
hasError={state === 'error'}
|
||||
hint={errorText}
|
||||
status={
|
||||
state === 'error'
|
||||
? { variant: 'error', message: errorText }
|
||||
: undefined
|
||||
}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal.
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import { inputToHashtag } from '@/mastodon/utils/hashtags';
|
||||
import {
|
||||
hasSpecialCharacters,
|
||||
inputToHashtag,
|
||||
} from '@/mastodon/utils/hashtags';
|
||||
import type {
|
||||
ApiCreateCollectionPayload,
|
||||
ApiUpdateCollectionPayload,
|
||||
@@ -31,6 +34,7 @@ import classes from './styles.module.scss';
|
||||
import { WizardStepHeader } from './wizard_step_header';
|
||||
|
||||
export const CollectionDetails: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const { id, name, description, topic, discoverable, sensitive, accountIds } =
|
||||
@@ -152,6 +156,11 @@ export const CollectionDetails: React.FC = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const topicHasSpecialCharacters = useMemo(
|
||||
() => hasSpecialCharacters(topic),
|
||||
[topic],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classes.form}>
|
||||
<FormStack className={classes.formFieldStack}>
|
||||
@@ -224,6 +233,18 @@ export const CollectionDetails: React.FC = () => {
|
||||
autoCorrect='off'
|
||||
spellCheck='false'
|
||||
maxLength={40}
|
||||
status={
|
||||
topicHasSpecialCharacters
|
||||
? {
|
||||
variant: 'warning',
|
||||
message: intl.formatMessage({
|
||||
id: 'collections.topic_special_chars_hint',
|
||||
defaultMessage:
|
||||
'Special characters will be removed when saving',
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Fieldset
|
||||
|
||||
@@ -230,7 +230,7 @@ export const Profile: React.FC<{
|
||||
}
|
||||
value={displayName}
|
||||
onChange={handleDisplayNameChange}
|
||||
hasError={!!errors?.display_name}
|
||||
status={errors?.display_name ? 'error' : undefined}
|
||||
id='display_name'
|
||||
/>
|
||||
</div>
|
||||
@@ -252,7 +252,7 @@ export const Profile: React.FC<{
|
||||
}
|
||||
value={note}
|
||||
onChange={handleNoteChange}
|
||||
hasError={!!errors?.note}
|
||||
status={errors?.note ? 'error' : undefined}
|
||||
id='note'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Samlede feltet \"{item}\" op.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Træk feltet \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Omarrangér felter",
|
||||
"account_edit.image_edit.add_button": "Tilføj billede",
|
||||
"account_edit.image_edit.alt_add_button": "Tilføj alt-tekst",
|
||||
"account_edit.image_edit.alt_edit_button": "Rediger alt-tekst",
|
||||
"account_edit.image_edit.remove_button": "Fjern billede",
|
||||
"account_edit.image_edit.replace_button": "Erstat billede",
|
||||
"account_edit.name_modal.add_title": "Tilføj visningsnavn",
|
||||
"account_edit.name_modal.edit_title": "Rediger visningsnavn",
|
||||
"account_edit.profile_tab.button_label": "Tilpas",
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
"account_edit.field_edit_modal.name_hint": "z. B. „Meine Website“",
|
||||
"account_edit.field_edit_modal.name_label": "Beschriftung",
|
||||
"account_edit.field_edit_modal.url_warning": "Um einen Link hinzuzufügen, füge {protocol} an den Anfang ein.",
|
||||
"account_edit.field_edit_modal.value_hint": "z. B. „https://example.me“",
|
||||
"account_edit.field_edit_modal.value_hint": "z. B. „https://beispiel.tld“",
|
||||
"account_edit.field_edit_modal.value_label": "Inhalt",
|
||||
"account_edit.field_reorder_modal.drag_cancel": "Das Ziehen wurde abgebrochen und das Feld „{item}“ wurde abgelegt.",
|
||||
"account_edit.field_reorder_modal.drag_end": "Das Feld „{item}“ wurde abgelegt.",
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Das Feld „{item}“ wurde ausgewählt.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Das Feld „{item}“ verschieben",
|
||||
"account_edit.field_reorder_modal.title": "Felder neu anordnen",
|
||||
"account_edit.image_edit.add_button": "Bild hinzufügen",
|
||||
"account_edit.image_edit.alt_add_button": "Bildbeschreibung hinzufügen",
|
||||
"account_edit.image_edit.alt_edit_button": "Bildbeschreibung bearbeiten",
|
||||
"account_edit.image_edit.remove_button": "Bild entfernen",
|
||||
"account_edit.image_edit.replace_button": "Bild ersetzen",
|
||||
"account_edit.name_modal.add_title": "Anzeigenamen hinzufügen",
|
||||
"account_edit.name_modal.edit_title": "Anzeigenamen bearbeiten",
|
||||
"account_edit.profile_tab.button_label": "Anpassen",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Το πεδίο \"{item}\" σηκώθηκε.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Μετακίνηση πεδίου \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Αναδιάταξη πεδίων",
|
||||
"account_edit.image_edit.add_button": "Προσθήκη εικόνας",
|
||||
"account_edit.image_edit.alt_add_button": "Προσθήκη εναλλακτικού κειμένου",
|
||||
"account_edit.image_edit.alt_edit_button": "Επεξεργασία εναλλακτικού κειμένου",
|
||||
"account_edit.image_edit.remove_button": "Αφαίρεση εικόνας",
|
||||
"account_edit.image_edit.replace_button": "Αντικατάσταση εικόνας",
|
||||
"account_edit.name_modal.add_title": "Προσθήκη εμφανιζόμενου ονόματος",
|
||||
"account_edit.name_modal.edit_title": "Επεξεργασία εμφανιζόμενου ονόματος",
|
||||
"account_edit.profile_tab.button_label": "Προσαρμογή",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Picked up field \"{item}\".",
|
||||
"account_edit.field_reorder_modal.handle_label": "Drag field \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Rearrange fields",
|
||||
"account_edit.image_edit.add_button": "Add image",
|
||||
"account_edit.image_edit.alt_add_button": "Add alt text",
|
||||
"account_edit.image_edit.alt_edit_button": "Edit alt text",
|
||||
"account_edit.image_edit.remove_button": "Remove image",
|
||||
"account_edit.image_edit.replace_button": "Replace image",
|
||||
"account_edit.name_modal.add_title": "Add display name",
|
||||
"account_edit.name_modal.edit_title": "Edit display name",
|
||||
"account_edit.profile_tab.button_label": "Customise",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
|
||||
"collections.sensitive": "Sensitive",
|
||||
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
|
||||
"collections.topic_special_chars_hint": "Special characters will be removed when saving",
|
||||
"collections.view_collection": "View collection",
|
||||
"collections.view_other_collections_by_user": "View other collections by this user",
|
||||
"collections.visibility_public": "Public",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Campo elegido «{item}».",
|
||||
"account_edit.field_reorder_modal.handle_label": "Arrastrá el campo «{item}»",
|
||||
"account_edit.field_reorder_modal.title": "Reordená los campos",
|
||||
"account_edit.image_edit.add_button": "Agregar imagen",
|
||||
"account_edit.image_edit.alt_add_button": "Agregar texto alternativo",
|
||||
"account_edit.image_edit.alt_edit_button": "Editar texto alternativo",
|
||||
"account_edit.image_edit.remove_button": "Quitar imagen",
|
||||
"account_edit.image_edit.replace_button": "Reemplazar imagen",
|
||||
"account_edit.name_modal.add_title": "Agregar nombre a mostrar",
|
||||
"account_edit.name_modal.edit_title": "Editar nombre a mostrar",
|
||||
"account_edit.profile_tab.button_label": "Personalizar",
|
||||
@@ -961,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "No te pierdas nada",
|
||||
"onboarding.follows.back": "Volver",
|
||||
"onboarding.follows.empty": "Desafortunadamente, no se pueden mostrar resultados en este momento. Podés intentar usar la búsqueda o navegar por la página de exploración para encontrar cuentas a las que seguir, o intentarlo de nuevo más tarde.",
|
||||
"onboarding.follows.next": "Siguiente: Configurá tu perfil",
|
||||
"onboarding.follows.search": "Buscar",
|
||||
"onboarding.follows.title": "Para comenzar, empezá a seguir cuentas",
|
||||
"onboarding.profile.discoverable": "Hacer que mi perfil sea detectable",
|
||||
"onboarding.profile.discoverable_hint": "Cuando optás por ser detectable en Mastodon, tus mensajes pueden aparecer en los resultados de búsqueda y de tendencia, y tu perfil puede ser sugerido a personas con intereses similares a los tuyos.",
|
||||
"onboarding.profile.display_name": "Nombre para mostrar",
|
||||
"onboarding.profile.display_name_hint": "Tu nombre completo o tu pseudónimo…",
|
||||
"onboarding.profile.finish": "Finalizar",
|
||||
"onboarding.profile.note": "Biografía",
|
||||
"onboarding.profile.note_hint": "Podés @mencionar otras cuentas o usar #etiquetas…",
|
||||
"onboarding.profile.title": "Configuración del perfil",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Recogido el campo «{item}».",
|
||||
"account_edit.field_reorder_modal.handle_label": "Arrastra el campo «{item}»",
|
||||
"account_edit.field_reorder_modal.title": "Reorganizar campos",
|
||||
"account_edit.image_edit.add_button": "Añadir imagen",
|
||||
"account_edit.image_edit.alt_add_button": "Añadir texto alternativo",
|
||||
"account_edit.image_edit.alt_edit_button": "Editar texto alternativo",
|
||||
"account_edit.image_edit.remove_button": "Eliminar imagen",
|
||||
"account_edit.image_edit.replace_button": "Reemplazar imagen",
|
||||
"account_edit.name_modal.add_title": "Añadir nombre para mostrar",
|
||||
"account_edit.name_modal.edit_title": "Editar nombre para mostrar",
|
||||
"account_edit.profile_tab.button_label": "Personalizar",
|
||||
|
||||
@@ -173,6 +173,8 @@
|
||||
"account_edit.field_edit_modal.link_emoji_warning": "Recomendamos no usar emojis personalizados combinados con enlaces. Los campos personalizados que contengan ambos solo se mostrarán como texto en vez de un enlace, para evitar confusiones.",
|
||||
"account_edit.field_edit_modal.name_hint": "Ej. \"Web personal\"",
|
||||
"account_edit.field_edit_modal.name_label": "Etiqueta",
|
||||
"account_edit.field_edit_modal.url_warning": "Para añadir un enlace, incluye {protocol} al principio.",
|
||||
"account_edit.field_edit_modal.value_hint": "Ejemplo: “https://example.me”",
|
||||
"account_edit.field_edit_modal.value_label": "Valor",
|
||||
"account_edit.field_reorder_modal.drag_cancel": "El arrastre se ha cancelado. El campo \"{item}\" se ha soltado.",
|
||||
"account_edit.field_reorder_modal.drag_end": "El campo \"{item}\" se ha soltado.",
|
||||
@@ -182,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Campo \"{item}\" seleccionado.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Arrastra el campo \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Reorganizar campos",
|
||||
"account_edit.image_edit.add_button": "Añadir imagen",
|
||||
"account_edit.image_edit.alt_add_button": "Añadir texto alternativo",
|
||||
"account_edit.image_edit.alt_edit_button": "Editar texto alternativo",
|
||||
"account_edit.image_edit.remove_button": "Quitar imagen",
|
||||
"account_edit.image_edit.replace_button": "Sustituir imagen",
|
||||
"account_edit.name_modal.add_title": "Añadir nombre para mostrar",
|
||||
"account_edit.name_modal.edit_title": "Editar nombre para mostrar",
|
||||
"account_edit.profile_tab.button_label": "Personalizar",
|
||||
@@ -337,14 +344,14 @@
|
||||
"collections.create_collection": "Crear colección",
|
||||
"collections.delete_collection": "Eliminar colección",
|
||||
"collections.description_length_hint": "Limitado a 100 caracteres",
|
||||
"collections.detail.accept_inclusion": "Aceptar",
|
||||
"collections.detail.accept_inclusion": "De acuerdo",
|
||||
"collections.detail.accounts_heading": "Cuentas",
|
||||
"collections.detail.author_added_you": "{author} te añadió a esta colección",
|
||||
"collections.detail.curated_by_author": "Seleccionado por {author}",
|
||||
"collections.detail.curated_by_you": "Seleccionado por ti",
|
||||
"collections.detail.loading": "Cargando colección…",
|
||||
"collections.detail.other_accounts_in_collection": "Otros en esta colección:",
|
||||
"collections.detail.revoke_inclusion": "Eliminar",
|
||||
"collections.detail.revoke_inclusion": "Sácame de aquí",
|
||||
"collections.detail.sensitive_note": "Esta colección contiene cuentas y contenido que puede ser sensible para algunos usuarios.",
|
||||
"collections.detail.share": "Compartir esta colección",
|
||||
"collections.edit_details": "Editar detalles",
|
||||
@@ -360,9 +367,9 @@
|
||||
"collections.old_last_post_note": "Última publicación hace más de una semana",
|
||||
"collections.remove_account": "Quitar esta cuenta",
|
||||
"collections.report_collection": "Informar de esta colección",
|
||||
"collections.revoke_collection_inclusion": "Eliminarme de esta colección",
|
||||
"collections.revoke_inclusion.confirmation": "Ha sido eliminado de\"{collection}\"",
|
||||
"collections.revoke_inclusion.error": "Se ha producido un error. Inténtelo de nuevo más tarde.",
|
||||
"collections.revoke_collection_inclusion": "Sácame de esta colección",
|
||||
"collections.revoke_inclusion.confirmation": "Has salido de la \"{collection}\"",
|
||||
"collections.revoke_inclusion.error": "Se ha producido un error, inténtalo de nuevo más tarde.",
|
||||
"collections.search_accounts_label": "Buscar cuentas para añadir…",
|
||||
"collections.search_accounts_max_reached": "Has añadido el número máximo de cuentas",
|
||||
"collections.sensitive": "Sensible",
|
||||
@@ -486,9 +493,9 @@
|
||||
"confirmations.remove_from_followers.confirm": "Eliminar seguidor",
|
||||
"confirmations.remove_from_followers.message": "{name} dejará de seguirte. ¿Estás seguro de que quieres continuar?",
|
||||
"confirmations.remove_from_followers.title": "¿Eliminar seguidor?",
|
||||
"confirmations.revoke_collection_inclusion.confirm": "Eliminar",
|
||||
"confirmations.revoke_collection_inclusion.confirm": "Sácame",
|
||||
"confirmations.revoke_collection_inclusion.message": "Esta acción es permanente, y el curador no podrá volver a añadirle a la colección más adelante.",
|
||||
"confirmations.revoke_collection_inclusion.title": "¿Eliminarse de esta colección?",
|
||||
"confirmations.revoke_collection_inclusion.title": "¿Salirse de esta colección?",
|
||||
"confirmations.revoke_quote.confirm": "Eliminar publicación",
|
||||
"confirmations.revoke_quote.message": "Esta acción no tiene vuelta atrás.",
|
||||
"confirmations.revoke_quote.title": "¿Eliminar la publicación?",
|
||||
@@ -959,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "Nunca te pierdas nada",
|
||||
"onboarding.follows.back": "Atrás",
|
||||
"onboarding.follows.empty": "Desafortunadamente, no se pueden mostrar resultados en este momento. Puedes intentar usar la búsqueda o navegar por la página de exploración para encontrar personas a las que seguir, o inténtalo de nuevo más tarde.",
|
||||
"onboarding.follows.next": "Siguiente: Configura tu perfil",
|
||||
"onboarding.follows.search": "Buscar",
|
||||
"onboarding.follows.title": "Sigue personas para comenzar",
|
||||
"onboarding.profile.discoverable": "Hacer que mi perfil aparezca en búsquedas",
|
||||
"onboarding.profile.discoverable_hint": "Cuando permites que tu perfil aparezca en búsquedas en Mastodon, tus publicaciones podrán aparecer en los resultados de búsqueda y en tendencias, y tu perfil podrá recomendarse a gente con intereses similares a los tuyos.",
|
||||
"onboarding.profile.display_name": "Nombre para mostrar",
|
||||
"onboarding.profile.display_name_hint": "Tu nombre completo o tu apodo…",
|
||||
"onboarding.profile.finish": "Terminar",
|
||||
"onboarding.profile.note": "Biografía",
|
||||
"onboarding.profile.note_hint": "Puedes @mencionar a otras personas o #etiquetas…",
|
||||
"onboarding.profile.title": "Configuración del perfil",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Valittu kenttä ”{item}”.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Siirrä kenttää ”{item}”",
|
||||
"account_edit.field_reorder_modal.title": "Järjestele kenttiä",
|
||||
"account_edit.image_edit.add_button": "Lisää kuva",
|
||||
"account_edit.image_edit.alt_add_button": "Lisää tekstivastine",
|
||||
"account_edit.image_edit.alt_edit_button": "Muokkaa tekstivastinetta",
|
||||
"account_edit.image_edit.remove_button": "Poista kuva",
|
||||
"account_edit.image_edit.replace_button": "Korvaa kuva",
|
||||
"account_edit.name_modal.add_title": "Lisää näyttönimi",
|
||||
"account_edit.name_modal.edit_title": "Muokkaa näyttönimeä",
|
||||
"account_edit.profile_tab.button_label": "Mukauta",
|
||||
@@ -225,8 +230,8 @@
|
||||
"alert.rate_limited.title": "Pyyntömäärää rajoitettu",
|
||||
"alert.unexpected.message": "Tapahtui odottamaton virhe.",
|
||||
"alert.unexpected.title": "Hups!",
|
||||
"alt_text_badge.title": "Vaihtoehtoinen teksti",
|
||||
"alt_text_modal.add_alt_text": "Lisää vaihtoehtoinen teksti",
|
||||
"alt_text_badge.title": "Tekstivastine",
|
||||
"alt_text_modal.add_alt_text": "Lisää tekstivastine",
|
||||
"alt_text_modal.add_text_from_image": "Lisää teksti kuvasta",
|
||||
"alt_text_modal.cancel": "Peruuta",
|
||||
"alt_text_modal.change_thumbnail": "Vaihda pikkukuva",
|
||||
@@ -468,10 +473,10 @@
|
||||
"confirmations.logout.confirm": "Kirjaudu ulos",
|
||||
"confirmations.logout.message": "Haluatko varmasti kirjautua ulos?",
|
||||
"confirmations.logout.title": "Kirjaudutaanko ulos?",
|
||||
"confirmations.missing_alt_text.confirm": "Lisää vaihtoehtoinen teksti",
|
||||
"confirmations.missing_alt_text.message": "Julkaisussasi on mediaa ilman vaihtoehtoista tekstiä. Kuvausten lisääminen auttaa tekemään sisällöstäsi saavutettavamman useammille ihmisille.",
|
||||
"confirmations.missing_alt_text.confirm": "Lisää tekstivastine",
|
||||
"confirmations.missing_alt_text.message": "Julkaisussasi on mediaa ilman tekstivastinetta. Kuvausten lisääminen auttaa tekemään sisällöstäsi saavutettavamman useammille ihmisille.",
|
||||
"confirmations.missing_alt_text.secondary": "Julkaise silti",
|
||||
"confirmations.missing_alt_text.title": "Lisätäänkö vaihtoehtoinen teksti?",
|
||||
"confirmations.missing_alt_text.title": "Lisätäänkö tekstivastine?",
|
||||
"confirmations.mute.confirm": "Mykistä",
|
||||
"confirmations.private_quote_notify.cancel": "Takaisin muokkaukseen",
|
||||
"confirmations.private_quote_notify.confirm": "Julkaise",
|
||||
@@ -706,7 +711,7 @@
|
||||
"ignore_notifications_modal.not_following_title": "Sivuutetaanko ilmoitukset käyttäjiltä, joita et seuraa?",
|
||||
"ignore_notifications_modal.private_mentions_title": "Sivuutetaanko ilmoitukset pyytämättömistä yksityismaininnoista?",
|
||||
"info_button.label": "Ohje",
|
||||
"info_button.what_is_alt_text": "<h1>Mikä vaihtoehtoinen teksti on?</h1> <p>Vaihtoehtoinen teksti tarjoaa kuvauksen kuvista ihmisille, joilla on näkövamma tai matalan kaistanleveyden yhteys tai jotka kaipaavat lisäkontekstia.</p> <p>Voit parantaa saavutettavuutta ja ymmärrettävyyttä kaikkien näkökulmasta kirjoittamalla selkeän, tiiviin ja objektiivisen vaihtoehtoisen tekstin.</p> <ul> <li>Ota mukaan tärkeät elementit</li> <li>Tiivistä kuvissa oleva teksti</li> <li>Käytä tavallisia lauserakenteita</li> <li>Vältä turhaa tietoa</li> <li>Keskity trendeihin ja keskeisiin tuloksiin monimutkaisissa visuaalisissa esityksissä (kuten kaavioissa tai kartoissa)</li> </ul>",
|
||||
"info_button.what_is_alt_text": "<h1>Mikä tekstivastine on?</h1> <p>Tekstivastine tarjoaa kuvauksen kuvista ihmisille, joilla on näkövamma tai matalan kaistanleveyden yhteys tai jotka kaipaavat lisäkontekstia.</p> <p>Voit parantaa saavutettavuutta ja ymmärrettävyyttä kaikkien näkökulmasta kirjoittamalla selkeän, tiiviin ja objektiivisen tekstivastineen.</p> <ul> <li>Ota mukaan tärkeät elementit</li> <li>Tiivistä kuvissa oleva teksti</li> <li>Käytä tavallisia lauserakenteita</li> <li>Vältä turhaa tietoa</li> <li>Keskity trendeihin ja keskeisiin tuloksiin monimutkaisissa visuaalisissa esityksissä (kuten kaavioissa tai kartoissa)</li> </ul>",
|
||||
"interaction_modal.action": "Jotta voit olla vuorovaikutuksessa käyttäjän {name} julkaisun kanssa, sinun on kirjauduttava sisään tilillesi käyttämälläsi Mastodon-palvelimella.",
|
||||
"interaction_modal.go": "Siirry",
|
||||
"interaction_modal.no_account_yet": "Eikö sinulla ole vielä tiliä?",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Champ « {item} » sélectionné.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Faites glisser le champ « {item} »",
|
||||
"account_edit.field_reorder_modal.title": "Réorganiser les champs",
|
||||
"account_edit.image_edit.add_button": "Ajouter une image",
|
||||
"account_edit.image_edit.alt_add_button": "Ajouter un texte alternatif",
|
||||
"account_edit.image_edit.alt_edit_button": "Modifier le texte alternatif",
|
||||
"account_edit.image_edit.remove_button": "Supprimer l’image",
|
||||
"account_edit.image_edit.replace_button": "Remplacer l'image",
|
||||
"account_edit.name_modal.add_title": "Ajouter un nom public",
|
||||
"account_edit.name_modal.edit_title": "Modifier le nom public",
|
||||
"account_edit.profile_tab.button_label": "Personnaliser",
|
||||
@@ -961,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "Ne rien rater",
|
||||
"onboarding.follows.back": "Retour",
|
||||
"onboarding.follows.empty": "Malheureusement, aucun résultat ne peut être affiché pour le moment. Vous pouvez essayer de rechercher ou de parcourir la page \"Explorer\" pour trouver des personnes à suivre, ou réessayer plus tard.",
|
||||
"onboarding.follows.next": "Suivant : configurer votre profil",
|
||||
"onboarding.follows.search": "Recherche",
|
||||
"onboarding.follows.title": "Suivre des personnes pour commencer",
|
||||
"onboarding.profile.discoverable": "Permettre de découvrir mon profil",
|
||||
"onboarding.profile.discoverable_hint": "Lorsque vous acceptez d'être découvert sur Mastodon, vos messages peuvent apparaître dans les résultats de recherche et les tendances, et votre profil peut être suggéré à des personnes ayant des intérêts similaires aux vôtres.",
|
||||
"onboarding.profile.display_name": "Nom affiché",
|
||||
"onboarding.profile.display_name_hint": "Votre nom complet ou votre nom rigolo…",
|
||||
"onboarding.profile.finish": "Terminer",
|
||||
"onboarding.profile.note": "Bio",
|
||||
"onboarding.profile.note_hint": "Vous pouvez @mentionner d'autres personnes ou #hashtags…",
|
||||
"onboarding.profile.title": "Configuration du profil",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Champ « {item} » sélectionné.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Faites glisser le champ « {item} »",
|
||||
"account_edit.field_reorder_modal.title": "Réorganiser les champs",
|
||||
"account_edit.image_edit.add_button": "Ajouter une image",
|
||||
"account_edit.image_edit.alt_add_button": "Ajouter un texte alternatif",
|
||||
"account_edit.image_edit.alt_edit_button": "Modifier le texte alternatif",
|
||||
"account_edit.image_edit.remove_button": "Supprimer l’image",
|
||||
"account_edit.image_edit.replace_button": "Remplacer l'image",
|
||||
"account_edit.name_modal.add_title": "Ajouter un nom public",
|
||||
"account_edit.name_modal.edit_title": "Modifier le nom public",
|
||||
"account_edit.profile_tab.button_label": "Personnaliser",
|
||||
@@ -961,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "Toujours au courant",
|
||||
"onboarding.follows.back": "Retour",
|
||||
"onboarding.follows.empty": "Malheureusement, aucun résultat ne peut être affiché pour le moment. Vous pouvez essayer d'utiliser la recherche ou parcourir la page de découverte pour trouver des personnes à suivre, ou réessayez plus tard.",
|
||||
"onboarding.follows.next": "Suivant : configurer votre profil",
|
||||
"onboarding.follows.search": "Recherche",
|
||||
"onboarding.follows.title": "Suivre des personnes pour commencer",
|
||||
"onboarding.profile.discoverable": "Permettre de découvrir mon profil",
|
||||
"onboarding.profile.discoverable_hint": "Lorsque vous acceptez d'être découvert sur Mastodon, vos messages peuvent apparaître dans les résultats de recherche et les tendances, et votre profil peut être suggéré à des personnes ayant des intérêts similaires aux vôtres.",
|
||||
"onboarding.profile.display_name": "Nom affiché",
|
||||
"onboarding.profile.display_name_hint": "Votre nom complet ou votre nom rigolo…",
|
||||
"onboarding.profile.finish": "Terminer",
|
||||
"onboarding.profile.note": "Biographie",
|
||||
"onboarding.profile.note_hint": "Vous pouvez @mentionner d'autres personnes ou #hashtags…",
|
||||
"onboarding.profile.title": "Configuration du profil",
|
||||
|
||||
@@ -173,6 +173,8 @@
|
||||
"account_edit.field_edit_modal.link_emoji_warning": "Molaimid gan emoji saincheaptha a úsáid i gcomhar le Urlanna. Taispeánfar réimsí saincheaptha ina bhfuil an dá cheann mar théacs amháin seachas mar nasc, chun mearbhall úsáideoirí a sheachaint.",
|
||||
"account_edit.field_edit_modal.name_hint": "M.sh. “Suíomh Gréasáin pearsanta”",
|
||||
"account_edit.field_edit_modal.name_label": "Lipéad",
|
||||
"account_edit.field_edit_modal.url_warning": "Chun nasc a chur leis, cuir {protocol} ag an tús le do thoil.",
|
||||
"account_edit.field_edit_modal.value_hint": "M.sh. “https://example.me”",
|
||||
"account_edit.field_edit_modal.value_label": "Luach",
|
||||
"account_edit.field_reorder_modal.drag_cancel": "Cuireadh an tarraingt ar ceal. Baineadh an réimse \"{item}\".",
|
||||
"account_edit.field_reorder_modal.drag_end": "Baineadh an réimse \"{item}\".",
|
||||
@@ -182,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Réimse \"{item}\" bailithe.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Tarraing réimse \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Athshocraigh réimsí",
|
||||
"account_edit.image_edit.add_button": "Cuir íomhá leis",
|
||||
"account_edit.image_edit.alt_add_button": "Cuir téacs alt leis",
|
||||
"account_edit.image_edit.alt_edit_button": "Cuir téacs alt in eagar",
|
||||
"account_edit.image_edit.remove_button": "Bain íomhá",
|
||||
"account_edit.image_edit.replace_button": "Athsholáthair íomhá",
|
||||
"account_edit.name_modal.add_title": "Cuir ainm taispeána leis",
|
||||
"account_edit.name_modal.edit_title": "Cuir ainm taispeána in eagar",
|
||||
"account_edit.profile_tab.button_label": "Saincheap",
|
||||
@@ -959,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "Ná caill aon rud go deo",
|
||||
"onboarding.follows.back": "Ar ais",
|
||||
"onboarding.follows.empty": "Ar an drochuair, ní féidir aon torthaí a thaispeáint faoi láthair. Is féidir leat triail a bhaint as cuardach nó brabhsáil ar an leathanach taiscéalaíochta chun teacht ar dhaoine le leanúint, nó bain triail eile as níos déanaí.",
|
||||
"onboarding.follows.next": "Ar Aghaidh: Socraigh do phróifíl",
|
||||
"onboarding.follows.search": "Cuardach",
|
||||
"onboarding.follows.title": "Lean daoine le tosú",
|
||||
"onboarding.profile.discoverable": "Déan mo phróifíl a fháil amach",
|
||||
"onboarding.profile.discoverable_hint": "Nuair a roghnaíonn tú infhionnachtana ar Mastodon, d’fhéadfadh do phoist a bheith le feiceáil i dtorthaí cuardaigh agus treochtaí, agus d’fhéadfaí do phróifíl a mholadh do dhaoine a bhfuil na leasanna céanna acu leat.",
|
||||
"onboarding.profile.display_name": "Ainm taispeána",
|
||||
"onboarding.profile.display_name_hint": "D’ainm iomlán nó d’ainm spraíúil…",
|
||||
"onboarding.profile.finish": "Críochnaigh",
|
||||
"onboarding.profile.note": "Bith",
|
||||
"onboarding.profile.note_hint": "Is féidir leat @ daoine eile a lua nó #hashtags…",
|
||||
"onboarding.profile.title": "Socrú próifíle",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Seleccionado o campo \"{item}\".",
|
||||
"account_edit.field_reorder_modal.handle_label": "Arrastra o campo \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Ordear campos",
|
||||
"account_edit.image_edit.add_button": "Engadir imaxe",
|
||||
"account_edit.image_edit.alt_add_button": "Engadir descrición",
|
||||
"account_edit.image_edit.alt_edit_button": "Editar descrición",
|
||||
"account_edit.image_edit.remove_button": "Retirar a imaxe",
|
||||
"account_edit.image_edit.replace_button": "Substituír a imaxe",
|
||||
"account_edit.name_modal.add_title": "Engadir nome público",
|
||||
"account_edit.name_modal.edit_title": "Editar o nome público",
|
||||
"account_edit.profile_tab.button_label": "Personalizar",
|
||||
|
||||
@@ -173,6 +173,8 @@
|
||||
"account_edit.field_edit_modal.link_emoji_warning": "אנו ממליצים נגד שימוש באמוג'י ייחודיים ביחד עם URL. שדות מיוחדים שמכילים את שניהם יופיעו כמלל בלבד ולא כקישור, כדי למנוע בלבול משתמשים.",
|
||||
"account_edit.field_edit_modal.name_hint": "למשל \"אתר אישי\"",
|
||||
"account_edit.field_edit_modal.name_label": "תווית",
|
||||
"account_edit.field_edit_modal.url_warning": "כדי להוסיף קישור, אנא הכלילו {protocol} בהתחלה.",
|
||||
"account_edit.field_edit_modal.value_hint": "למשל “https://example.me”",
|
||||
"account_edit.field_edit_modal.value_label": "ערך",
|
||||
"account_edit.field_reorder_modal.drag_cancel": "הגרירה בוטלה. השדה \"{item}\" נעזב.",
|
||||
"account_edit.field_reorder_modal.drag_end": "השדה \"{item}\" נעזב.",
|
||||
@@ -182,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "השדה \"{item}\" נבחר.",
|
||||
"account_edit.field_reorder_modal.handle_label": "הזזת השדה \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "סידור שדות",
|
||||
"account_edit.image_edit.add_button": "הוספת תמונה",
|
||||
"account_edit.image_edit.alt_add_button": "הוספת מלל חלופי",
|
||||
"account_edit.image_edit.alt_edit_button": "עריכת מלל חלופי",
|
||||
"account_edit.image_edit.remove_button": "הסרת תמונה",
|
||||
"account_edit.image_edit.replace_button": "החלפת תמונה",
|
||||
"account_edit.name_modal.add_title": "הוספת שם תצוגה",
|
||||
"account_edit.name_modal.edit_title": "עריכת שם תצוגה",
|
||||
"account_edit.profile_tab.button_label": "התאמה אישית",
|
||||
@@ -959,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "לעולם אל תחמיץ דבר",
|
||||
"onboarding.follows.back": "בחזרה",
|
||||
"onboarding.follows.empty": "למצער, תוצאות לחיפושך אינן בנמצא. ניתן להשתמש בחיפוש או בדף החקירות לשם מציאת אנשים ולעקבם. אפשר גם לנסות שוב אחר כך.",
|
||||
"onboarding.follows.next": "להמשיך ליצירת הפרופיל שלך",
|
||||
"onboarding.follows.search": "חיפוש",
|
||||
"onboarding.follows.title": "כדי להתחיל, יש לעקוב אחרי אנשים",
|
||||
"onboarding.profile.discoverable": "כלול את הפרופיל שלי בעמודת התגליות",
|
||||
"onboarding.profile.discoverable_hint": "כשתבחרו להכלל ב\"תגליות\" על מסטודון, ההודעות שלכם עשויות להופיע בתוצאות חיפוש ועמודות \"נושאים חמים\", והפרופיל יוצע לאחרים עם תחומי עניין משותפים לכם.",
|
||||
"onboarding.profile.display_name": "שם להצגה",
|
||||
"onboarding.profile.display_name_hint": "שמך המלא או כינוי הכיף שלך…",
|
||||
"onboarding.profile.finish": "סיום",
|
||||
"onboarding.profile.note": "אודות",
|
||||
"onboarding.profile.note_hint": "ניתן @לאזכר משתמשים אחרים או #תגיות…",
|
||||
"onboarding.profile.title": "הגדרת פרופיל",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "A(z) „{item}” mező áthelyezéshez felvéve.",
|
||||
"account_edit.field_reorder_modal.handle_label": "A(z) „{item}” mező húzása",
|
||||
"account_edit.field_reorder_modal.title": "Mezők átrendezése",
|
||||
"account_edit.image_edit.add_button": "Kép hozzáadása",
|
||||
"account_edit.image_edit.alt_add_button": "Helyettesítő szöveg hozzáadása",
|
||||
"account_edit.image_edit.alt_edit_button": "Helyettesítő szöveg szerkesztése",
|
||||
"account_edit.image_edit.remove_button": "Kép eltávolítása",
|
||||
"account_edit.image_edit.replace_button": "Kép cseréje",
|
||||
"account_edit.name_modal.add_title": "Megjelenítendő név hozzáadása",
|
||||
"account_edit.name_modal.edit_title": "Megjelenítendő név szerkesztése",
|
||||
"account_edit.profile_tab.button_label": "Testreszabás",
|
||||
@@ -961,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "Soha ne mulassz el semmit",
|
||||
"onboarding.follows.back": "Vissza",
|
||||
"onboarding.follows.empty": "Sajnos jelenleg nem jeleníthető meg eredmény. Kipróbálhatod a keresést vagy böngészheted a felfedező oldalon a követni kívánt személyeket, vagy próbáld meg később.",
|
||||
"onboarding.follows.next": "Következik: A profil beállítása",
|
||||
"onboarding.follows.search": "Keresés",
|
||||
"onboarding.follows.title": "A kezdéshez kezdj el embereket követni",
|
||||
"onboarding.profile.discoverable": "Saját profil beállítása felfedezhetőként",
|
||||
"onboarding.profile.discoverable_hint": "A Mastodonon a felfedezhetőség választása esetén a saját bejegyzéseid megjelenhetnek a keresési eredmények és a felkapott tartalmak között, valamint a profilod a hozzád hasonló érdeklődési körrel rendelkező embereknél is ajánlásra kerülhet.",
|
||||
"onboarding.profile.display_name": "Megjelenített név",
|
||||
"onboarding.profile.display_name_hint": "Teljes neved vagy vicces neved…",
|
||||
"onboarding.profile.finish": "Befejezés",
|
||||
"onboarding.profile.note": "Bemutatkozás",
|
||||
"onboarding.profile.note_hint": "Megemlíthetsz @másokat vagy #hashtag-eket…",
|
||||
"onboarding.profile.title": "Profilbeállítás",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Náði reitnum \"{item}\".",
|
||||
"account_edit.field_reorder_modal.handle_label": "Dragðu reitinn \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Endurraða gagnasviðum",
|
||||
"account_edit.image_edit.add_button": "Bæta við mynd",
|
||||
"account_edit.image_edit.alt_add_button": "Bæta við hjálpartexta",
|
||||
"account_edit.image_edit.alt_edit_button": "Breyta hjálpartexta",
|
||||
"account_edit.image_edit.remove_button": "Fjarlægja mynd",
|
||||
"account_edit.image_edit.replace_button": "Skipta um mynd",
|
||||
"account_edit.name_modal.add_title": "Bættu við birtingarnafni",
|
||||
"account_edit.name_modal.edit_title": "Breyta birtingarnafni",
|
||||
"account_edit.profile_tab.button_label": "Sérsníða",
|
||||
@@ -961,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "Aldrei missa af neinu",
|
||||
"onboarding.follows.back": "Til baka",
|
||||
"onboarding.follows.empty": "Því miður er ekki hægt að birta neinar niðurstöður í augnablikinu. Þú getur reynt að nota leitina eða skoðað könnunarsíðuna til að finna fólk til að fylgjast með, nú eða prófað aftur síðar.",
|
||||
"onboarding.follows.next": "Næsta: Settu upp notandasniðið þitt",
|
||||
"onboarding.follows.search": "Leita",
|
||||
"onboarding.follows.title": "Þú ættir að fylgjast með fólki til að komast í gang",
|
||||
"onboarding.profile.discoverable": "Gera notandasniðið mitt uppgötvanlegt",
|
||||
"onboarding.profile.discoverable_hint": "Þegar þú velur að hægt sé að uppgötva þig á Mastodon, munu færslurnar þínar birtast í leitarniðurstöðum og vinsældalistum, auk þess sem stungið verður upp á notandasniðinu þínu við fólk sem er með svipuð áhugamál og þú.",
|
||||
"onboarding.profile.display_name": "Birtingarnafn",
|
||||
"onboarding.profile.display_name_hint": "Fullt nafn þitt eða eitthvað til gamans…",
|
||||
"onboarding.profile.finish": "Ljúka",
|
||||
"onboarding.profile.note": "Æviágrip",
|
||||
"onboarding.profile.note_hint": "Þú getur @minnst á annað fólk eða #myllumerki…",
|
||||
"onboarding.profile.title": "Uppsetning notandasniðs",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Campo \"{item}\" selezionato.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Trascina il campo \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Riorganizza i campi",
|
||||
"account_edit.image_edit.add_button": "Aggiungi un'immagine",
|
||||
"account_edit.image_edit.alt_add_button": "Aggiungi il testo alternativo",
|
||||
"account_edit.image_edit.alt_edit_button": "Modifica il testo alternativo",
|
||||
"account_edit.image_edit.remove_button": "Rimuovi l'immagine",
|
||||
"account_edit.image_edit.replace_button": "Sostituisci l'immagine",
|
||||
"account_edit.name_modal.add_title": "Aggiungi il nome mostrato",
|
||||
"account_edit.name_modal.edit_title": "Modifica il nome mostrato",
|
||||
"account_edit.profile_tab.button_label": "Personalizza",
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
"account_edit.field_edit_modal.link_emoji_warning": "Não recomendamos o uso de emojis personalizados em combinação com URLs. Campos personalizados que contenham ambos serão exibidos apenas como texto, em vez de como hiperligação, para evitar confusão aos utilizadores.",
|
||||
"account_edit.field_edit_modal.name_hint": "Ex.: \"Site pessoal\"",
|
||||
"account_edit.field_edit_modal.name_label": "Rótulo",
|
||||
"account_edit.field_edit_modal.value_hint": "Ex.: \"https://exemplo.eu\"",
|
||||
"account_edit.field_edit_modal.value_label": "Valor",
|
||||
"account_edit.field_reorder_modal.drag_cancel": "O arrastamento foi cancelado. O campo \"{item}\" foi largado.",
|
||||
"account_edit.field_reorder_modal.drag_end": "O campo \"{item}\" foi largado.",
|
||||
@@ -182,6 +183,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Apanhou o campo \"{item}\".",
|
||||
"account_edit.field_reorder_modal.handle_label": "Arrastar o campo \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Reordenar campos",
|
||||
"account_edit.image_edit.add_button": "Adicionar imagem",
|
||||
"account_edit.image_edit.alt_add_button": "Adicionar texto alternativo",
|
||||
"account_edit.image_edit.alt_edit_button": "Editar texto alternativo",
|
||||
"account_edit.image_edit.remove_button": "Remover imagem",
|
||||
"account_edit.image_edit.replace_button": "Substituir imagem",
|
||||
"account_edit.name_modal.add_title": "Adicionar nome a mostrar",
|
||||
"account_edit.name_modal.edit_title": "Editar o nome a mostrar",
|
||||
"account_edit.profile_tab.button_label": "Personalizar",
|
||||
@@ -958,12 +964,14 @@
|
||||
"notifications_permission_banner.title": "Nunca percas nada",
|
||||
"onboarding.follows.back": "Voltar",
|
||||
"onboarding.follows.empty": "Infelizmente não é possível mostrar resultados neste momento. Podes tentar pesquisar ou navegar na página \"Explorar\" para encontrares pessoas para seguires ou tentar novamente mais tarde.",
|
||||
"onboarding.follows.next": "A seguir: configure o seu perfil",
|
||||
"onboarding.follows.search": "Pesquisar",
|
||||
"onboarding.follows.title": "Segue pessoas para começar",
|
||||
"onboarding.profile.discoverable": "Permitir que o meu perfil seja descoberto",
|
||||
"onboarding.profile.discoverable_hint": "Quando opta pela possibilidade de ser descoberto no Mastodon, as suas mensagens podem aparecer nos resultados de pesquisa e nos destaques, e o seu perfil pode ser sugerido a pessoas com interesses semelhantes aos seus.",
|
||||
"onboarding.profile.display_name": "Nome a apresentar",
|
||||
"onboarding.profile.display_name_hint": "O teu nome completo ou o teu nome divertido…",
|
||||
"onboarding.profile.finish": "Terminar",
|
||||
"onboarding.profile.note": "Biografia",
|
||||
"onboarding.profile.note_hint": "Podes @mencionar outras pessoas e usar #etiquetas…",
|
||||
"onboarding.profile.title": "Configuração do perfil",
|
||||
|
||||
@@ -183,6 +183,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "U mor fusha “{item}”.",
|
||||
"account_edit.field_reorder_modal.handle_label": "Tërhiqni fushën “{item}”",
|
||||
"account_edit.field_reorder_modal.title": "Risistemoni fusha",
|
||||
"account_edit.image_edit.add_button": "Shtoni figurë",
|
||||
"account_edit.image_edit.alt_add_button": "Shtoni tekst alternativ",
|
||||
"account_edit.image_edit.alt_edit_button": "Përpunoni tekst alternativ",
|
||||
"account_edit.image_edit.remove_button": "Hiqe figurën",
|
||||
"account_edit.image_edit.replace_button": "Zëvendësoje figurën",
|
||||
"account_edit.name_modal.add_title": "Shtoni emër në ekran",
|
||||
"account_edit.name_modal.edit_title": "Përpunoni emër në ekran",
|
||||
"account_edit.profile_tab.button_label": "Përshtateni",
|
||||
@@ -955,12 +960,14 @@
|
||||
"notifications_permission_banner.title": "Mos t’ju shpëtojë gjë",
|
||||
"onboarding.follows.back": "Mbrapsht",
|
||||
"onboarding.follows.empty": "Mjerisht, s’mund të shfaqen përfundime tani. Mund të provoni të përdorni kërkimin, ose të shfletoni faqen e eksplorimit, që të gjeni persona për ndjekje, ose të riprovoni më vonë.",
|
||||
"onboarding.follows.next": "Pasuesi: Ujdisni profilin tuaj",
|
||||
"onboarding.follows.search": "Kërkoni",
|
||||
"onboarding.follows.title": "Që t’ia filloni, ndiqni persona",
|
||||
"onboarding.profile.discoverable": "Bëje profilin tim të zbulueshëm",
|
||||
"onboarding.profile.discoverable_hint": "Kur zgjidhni të jeni i zbulueshëm në Mastodon, postimet tuaja mund të shfaqen në përfundime kërkimesh dhe gjëra në modë dhe profili juaj mund t’u sugjerohet njerëzve me interesa të ngjashme me ju.",
|
||||
"onboarding.profile.display_name": "Emër në ekran",
|
||||
"onboarding.profile.display_name_hint": "Emri juaj i plotë, ose ç’të doni…",
|
||||
"onboarding.profile.finish": "Përfundoje",
|
||||
"onboarding.profile.note": "Jetëshkrim",
|
||||
"onboarding.profile.note_hint": "Mund të @përmendni persona të tjerë, ose #hashtagë…",
|
||||
"onboarding.profile.title": "Udjisje profili",
|
||||
|
||||
@@ -167,6 +167,11 @@
|
||||
"account_edit.field_edit_modal.name_label": "Etikett",
|
||||
"account_edit.field_edit_modal.url_warning": "För att lägga till en länk, vänligen inkludera {protocol} i början.",
|
||||
"account_edit.field_edit_modal.value_hint": "T.ex. \"https://example.me”",
|
||||
"account_edit.image_edit.add_button": "Lägg till bild",
|
||||
"account_edit.image_edit.alt_add_button": "Lägg till alternativtext",
|
||||
"account_edit.image_edit.alt_edit_button": "Redigera alternativtext",
|
||||
"account_edit.image_edit.remove_button": "Ta bort bild",
|
||||
"account_edit.image_edit.replace_button": "Ersätt bild",
|
||||
"account_edit.profile_tab.button_label": "Anpassa",
|
||||
"account_note.placeholder": "Klicka för att lägga till anteckning",
|
||||
"admin.dashboard.daily_retention": "Användarlojalitet per dag efter registrering",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "\"{item}\" alanı seçildi.",
|
||||
"account_edit.field_reorder_modal.handle_label": "\"{item}\" alanını sürükle",
|
||||
"account_edit.field_reorder_modal.title": "Alanları yeniden düzenle",
|
||||
"account_edit.image_edit.add_button": "Görsel ekle",
|
||||
"account_edit.image_edit.alt_add_button": "Alternatif metin ekle",
|
||||
"account_edit.image_edit.alt_edit_button": "Alternatif metni düzenle",
|
||||
"account_edit.image_edit.remove_button": "Görseli kaldır",
|
||||
"account_edit.image_edit.replace_button": "Görseli değiştir",
|
||||
"account_edit.name_modal.add_title": "Görünen ad ekle",
|
||||
"account_edit.name_modal.edit_title": "Görünen adı düzenle",
|
||||
"account_edit.profile_tab.button_label": "Özelleştir",
|
||||
@@ -961,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "Hiçbir şeyi kaçırmayın",
|
||||
"onboarding.follows.back": "Geri",
|
||||
"onboarding.follows.empty": "Maalesef şu an bir sonuç gösterilemiyor. Takip edilecek kişileri bulmak için arama veya keşfet sayfasına gözatmayı kullanabilirsiniz veya daha sonra tekrar deneyin.",
|
||||
"onboarding.follows.next": "Sonraki: Profilinizi ayarlayın",
|
||||
"onboarding.follows.search": "Ara",
|
||||
"onboarding.follows.title": "Başlamak için insanları takip edin",
|
||||
"onboarding.profile.discoverable": "Profilimi keşfedilebilir yap",
|
||||
"onboarding.profile.discoverable_hint": "Mastodon'da keşfedilebilirliği etkinleştirdiğinizde, gönderileriniz arama sonuçlarında ve trendlerde görünebilir aynı zamanda profiliniz sizinle benzer ilgi alanlarına sahip kişilere önerilebilir.",
|
||||
"onboarding.profile.display_name": "Görünen isim",
|
||||
"onboarding.profile.display_name_hint": "Tam adınız veya kullanıcı adınız…",
|
||||
"onboarding.profile.finish": "Tamamla",
|
||||
"onboarding.profile.note": "Kişisel bilgiler",
|
||||
"onboarding.profile.note_hint": "Diğer insanlara @değinebilir veya #etiketler kullanabilirsiniz…",
|
||||
"onboarding.profile.title": "Profilini ayarla",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "Đã chọn trường \"{item}\".",
|
||||
"account_edit.field_reorder_modal.handle_label": "Kéo trường \"{item}\"",
|
||||
"account_edit.field_reorder_modal.title": "Sắp xếp lại trường",
|
||||
"account_edit.image_edit.add_button": "Thêm ảnh",
|
||||
"account_edit.image_edit.alt_add_button": "Thêm văn bản thay thế",
|
||||
"account_edit.image_edit.alt_edit_button": "Sửa văn bản thay thế",
|
||||
"account_edit.image_edit.remove_button": "Gỡ ảnh",
|
||||
"account_edit.image_edit.replace_button": "Thay thế ảnh",
|
||||
"account_edit.name_modal.add_title": "Thêm tên gọi",
|
||||
"account_edit.name_modal.edit_title": "Sửa tên gọi",
|
||||
"account_edit.profile_tab.button_label": "Tùy chỉnh",
|
||||
|
||||
@@ -173,6 +173,8 @@
|
||||
"account_edit.field_edit_modal.link_emoji_warning": "我们建议不要同时使用自定义表情和网址。同时包含两者的自定义字段将会显示为纯文本而不是链接形式,以避免用户混淆。",
|
||||
"account_edit.field_edit_modal.name_hint": "例如:“个人网站”",
|
||||
"account_edit.field_edit_modal.name_label": "标签",
|
||||
"account_edit.field_edit_modal.url_warning": "要添加链接,请在开头加上 {protocol}。",
|
||||
"account_edit.field_edit_modal.value_hint": "例如:“https://example.me”",
|
||||
"account_edit.field_edit_modal.value_label": "值",
|
||||
"account_edit.field_reorder_modal.drag_cancel": "拖拽已终止。字段“{item}”已被丢弃。",
|
||||
"account_edit.field_reorder_modal.drag_end": "字段“{item}”已被丢弃。",
|
||||
@@ -182,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "已选中字段“{item}”。",
|
||||
"account_edit.field_reorder_modal.handle_label": "拖拽字段“{item}”",
|
||||
"account_edit.field_reorder_modal.title": "重新排列字段",
|
||||
"account_edit.image_edit.add_button": "添加图片",
|
||||
"account_edit.image_edit.alt_add_button": "添加替代文本",
|
||||
"account_edit.image_edit.alt_edit_button": "编辑替代文本",
|
||||
"account_edit.image_edit.remove_button": "移除图片",
|
||||
"account_edit.image_edit.replace_button": "替换图片",
|
||||
"account_edit.name_modal.add_title": "添加显示名称",
|
||||
"account_edit.name_modal.edit_title": "编辑显示名称",
|
||||
"account_edit.profile_tab.button_label": "自定义",
|
||||
@@ -959,12 +966,14 @@
|
||||
"notifications_permission_banner.title": "精彩不容错过",
|
||||
"onboarding.follows.back": "返回",
|
||||
"onboarding.follows.empty": "很抱歉,现在无法显示任何结果。你可以尝试使用搜索或浏览探索页面来查找要关注的人,或稍后再试。",
|
||||
"onboarding.follows.next": "下一步:设置你的个人资料",
|
||||
"onboarding.follows.search": "搜索",
|
||||
"onboarding.follows.title": "关注用户,玩转 Mastodon",
|
||||
"onboarding.profile.discoverable": "让我的账号可被他人发现",
|
||||
"onboarding.profile.discoverable_hint": "当你在 Mastodon 上启用发现功能时,你的嘟文可能会出现在搜索结果与热门中,你的账号可能会被推荐给与你兴趣相似的人。",
|
||||
"onboarding.profile.display_name": "昵称",
|
||||
"onboarding.profile.display_name_hint": "你的全名或昵称…",
|
||||
"onboarding.profile.finish": "完成",
|
||||
"onboarding.profile.note": "简介",
|
||||
"onboarding.profile.note_hint": "你可以提及 @其他人 或使用 #话题…",
|
||||
"onboarding.profile.title": "设置个人资料",
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
"account_edit.field_reorder_modal.drag_start": "已選取欄位「{item}」。",
|
||||
"account_edit.field_reorder_modal.handle_label": "拖放欄位「{item}」",
|
||||
"account_edit.field_reorder_modal.title": "重新整理欄位",
|
||||
"account_edit.image_edit.add_button": "新增圖片",
|
||||
"account_edit.image_edit.alt_add_button": "新增 ALT 說明文字",
|
||||
"account_edit.image_edit.alt_edit_button": "編輯 ALT 說明文字",
|
||||
"account_edit.image_edit.remove_button": "移除圖片",
|
||||
"account_edit.image_edit.replace_button": "替換圖片",
|
||||
"account_edit.name_modal.add_title": "新增顯示名稱",
|
||||
"account_edit.name_modal.edit_title": "編輯顯示名稱",
|
||||
"account_edit.profile_tab.button_label": "自訂",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
createAppSelector,
|
||||
createDataLoadingThunk,
|
||||
} from '@/mastodon/store/typed_functions';
|
||||
import { inputToHashtag } from '@/mastodon/utils/hashtags';
|
||||
|
||||
type QueryStatus = 'idle' | 'loading' | 'error';
|
||||
|
||||
@@ -82,7 +83,7 @@ const collectionSlice = createSlice({
|
||||
id: collection?.id ?? null,
|
||||
name: collection?.name ?? '',
|
||||
description: collection?.description ?? '',
|
||||
topic: collection?.tag?.name ?? '',
|
||||
topic: inputToHashtag(collection?.tag?.name ?? ''),
|
||||
language: collection?.language ?? '',
|
||||
discoverable: collection?.discoverable ?? true,
|
||||
sensitive: collection?.sensitive ?? false,
|
||||
|
||||
@@ -59,3 +59,8 @@ export const inputToHashtag = (input: string): string => {
|
||||
|
||||
return `#${words.join('')}${trailingSpace}`;
|
||||
};
|
||||
|
||||
export const hasSpecialCharacters = (input: string) => {
|
||||
// Regex matches any character NOT a letter/digit, except the #
|
||||
return /[^a-zA-Z0-9# ]/.test(input);
|
||||
};
|
||||
|
||||
@@ -8478,7 +8478,7 @@ noscript {
|
||||
gap: 8px;
|
||||
|
||||
$button-breakpoint: 420px;
|
||||
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
|
||||
$button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
|
||||
&--desktop {
|
||||
margin-top: 55px;
|
||||
@@ -8502,7 +8502,7 @@ noscript {
|
||||
}
|
||||
|
||||
@supports (not (container-type: inline-size)) {
|
||||
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
|
||||
@media (min-width: ($button-fallback-breakpoint + 1px)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,28 +13,28 @@
|
||||
.logo-container {
|
||||
margin: 50px auto;
|
||||
|
||||
h1 {
|
||||
a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: min-content;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px;
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
outline: 0;
|
||||
line-height: 32px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
|
||||
.logo {
|
||||
height: 42px;
|
||||
margin-inline-end: 10px;
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
outline: 0;
|
||||
padding: 12px 16px;
|
||||
line-height: 32px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.logo {
|
||||
height: 42px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ code {
|
||||
|
||||
.input {
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -471,13 +470,19 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
.input.radio_buttons .radio label {
|
||||
margin-bottom: 5px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
display: block;
|
||||
width: auto;
|
||||
.input.radio_buttons .radio {
|
||||
label {
|
||||
margin-bottom: 5px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
accent-color: var(--color-text-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.check_boxes {
|
||||
@@ -503,6 +508,12 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
label.checkbox {
|
||||
input[type='checkbox'] {
|
||||
accent-color: var(--color-text-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.input.static .label_input__wrapper {
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
@@ -523,13 +534,20 @@ code {
|
||||
color: var(--color-text-primary);
|
||||
display: block;
|
||||
width: 100%;
|
||||
outline: 0;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 4px;
|
||||
padding: 10px 16px;
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: -2px;
|
||||
outline-color: transparent;
|
||||
transition: outline-color 0.15s ease-out;
|
||||
|
||||
&:focus {
|
||||
outline: var(--outline-focus-default);
|
||||
}
|
||||
|
||||
&:invalid {
|
||||
box-shadow: none;
|
||||
@@ -613,6 +631,11 @@ code {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
@@ -653,6 +676,11 @@ code {
|
||||
padding-inline-end: 30px;
|
||||
height: 41px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ class AccountMigration < ApplicationRecord
|
||||
before_validation :set_target_account
|
||||
before_validation :set_followers_count
|
||||
|
||||
attribute :current_username, :string
|
||||
|
||||
normalizes :acct, with: ->(acct) { acct.strip.delete_prefix('@') }
|
||||
normalizes :current_username, with: ->(value) { value.strip.delete_prefix('@') }
|
||||
|
||||
validates :acct, presence: true, domain: { acct: true }
|
||||
validate :validate_migration_cooldown
|
||||
@@ -33,7 +36,7 @@ class AccountMigration < ApplicationRecord
|
||||
|
||||
scope :within_cooldown, -> { where(created_at: cooldown_duration_ago..) }
|
||||
|
||||
attr_accessor :current_password, :current_username
|
||||
attr_accessor :current_password
|
||||
|
||||
def self.cooldown_duration_ago
|
||||
Time.current - COOLDOWN_PERIOD
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user