@tangible/ui 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Card/Card.d.ts +1 -0
- package/components/Card/Card.js +17 -20
- package/components/Checkbox/Checkbox.d.ts +9 -0
- package/components/Checkbox/Checkbox.js +92 -0
- package/components/Checkbox/index.d.ts +2 -0
- package/components/Checkbox/index.js +1 -0
- package/components/Checkbox/types.d.ts +10 -0
- package/components/Checkbox/types.js +1 -0
- package/components/Chip/Chip.d.ts +4 -1
- package/components/Chip/Chip.js +32 -7
- package/components/ChipGroup/ChipGroup.d.ts +5 -0
- package/components/ChipGroup/ChipGroup.js +68 -0
- package/components/ChipGroup/ChipGroupContext.d.ts +3 -0
- package/components/ChipGroup/ChipGroupContext.js +5 -0
- package/components/ChipGroup/index.d.ts +3 -0
- package/components/ChipGroup/index.js +2 -0
- package/components/ChipGroup/types.d.ts +36 -0
- package/components/ChipGroup/types.js +1 -0
- package/components/Chips/Chips.d.ts +2 -0
- package/components/Chips/Chips.js +1 -1
- package/components/Combobox/Combobox.d.ts +33 -0
- package/components/Combobox/Combobox.js +466 -0
- package/components/Combobox/ComboboxContext.d.ts +8 -0
- package/components/Combobox/ComboboxContext.js +36 -0
- package/components/Combobox/index.d.ts +2 -0
- package/components/Combobox/index.js +1 -0
- package/components/Combobox/types.d.ts +204 -0
- package/components/Combobox/types.js +1 -0
- package/components/Dropdown/Dropdown.js +2 -1
- package/components/Field/Field.d.ts +39 -0
- package/components/Field/Field.js +92 -0
- package/components/Field/FieldContext.d.ts +16 -0
- package/components/Field/FieldContext.js +10 -0
- package/components/Field/index.d.ts +2 -0
- package/components/Field/index.js +1 -0
- package/components/Modal/Modal.d.ts +4 -4
- package/components/Modal/Modal.js +14 -12
- package/components/MoveHandle/MoveHandle.d.ts +2 -0
- package/components/MoveHandle/MoveHandle.js +84 -0
- package/components/MoveHandle/index.d.ts +2 -0
- package/components/MoveHandle/index.js +1 -0
- package/components/MoveHandle/types.d.ts +43 -0
- package/components/MoveHandle/types.js +1 -0
- package/components/MultiSelect/MultiSelect.d.ts +39 -0
- package/components/MultiSelect/MultiSelect.js +623 -0
- package/components/MultiSelect/MultiSelectContext.d.ts +20 -0
- package/components/MultiSelect/MultiSelectContext.js +56 -0
- package/components/MultiSelect/index.d.ts +2 -0
- package/components/MultiSelect/index.js +1 -0
- package/components/MultiSelect/types.d.ts +218 -0
- package/components/MultiSelect/types.js +3 -0
- package/components/Notice/Notice.d.ts +1 -1
- package/components/Notice/Notice.js +1 -1
- package/components/Progress/Progress.js +1 -1
- package/components/Progress/types.d.ts +7 -7
- package/components/Radio/Radio.d.ts +2 -0
- package/components/Radio/Radio.js +50 -0
- package/components/Radio/RadioGroup.d.ts +2 -0
- package/components/Radio/RadioGroup.js +54 -0
- package/components/Radio/RadioGroupContext.d.ts +3 -0
- package/components/Radio/RadioGroupContext.js +9 -0
- package/components/Radio/index.d.ts +8 -0
- package/components/Radio/index.js +6 -0
- package/components/Radio/types.d.ts +32 -0
- package/components/Radio/types.js +1 -0
- package/components/Rating/Rating.d.ts +5 -5
- package/components/Rating/Rating.js +2 -2
- package/components/SegmentedControl/SegmentedControl.js +20 -104
- package/components/SegmentedControl/types.d.ts +4 -8
- package/components/Select/Select.d.ts +39 -0
- package/components/Select/Select.js +497 -0
- package/components/Select/SelectContext.d.ts +20 -0
- package/components/Select/SelectContext.js +56 -0
- package/components/Select/index.d.ts +3 -0
- package/components/Select/index.js +1 -0
- package/components/Select/types.d.ts +216 -0
- package/components/Select/types.js +11 -0
- package/components/Sidebar/Sidebar.js +12 -12
- package/components/Sidebar/types.d.ts +5 -5
- package/components/StepIndicator/StepIndicator.js +1 -1
- package/components/StepList/StepList.js +2 -2
- package/components/StepList/types.d.ts +4 -4
- package/components/Switch/Switch.d.ts +9 -0
- package/components/Switch/Switch.js +91 -0
- package/components/Switch/index.d.ts +2 -0
- package/components/Switch/index.js +1 -0
- package/components/Switch/types.d.ts +11 -0
- package/components/Switch/types.js +1 -0
- package/components/TextInput/TextInput.d.ts +8 -0
- package/components/TextInput/TextInput.js +25 -0
- package/components/TextInput/index.d.ts +2 -0
- package/components/TextInput/index.js +1 -0
- package/components/TextInput/types.d.ts +32 -0
- package/components/TextInput/types.js +1 -0
- package/components/Textarea/Textarea.d.ts +6 -0
- package/components/Textarea/Textarea.js +49 -0
- package/components/Textarea/index.d.ts +2 -0
- package/components/Textarea/index.js +1 -0
- package/components/Textarea/types.d.ts +25 -0
- package/components/Textarea/types.js +1 -0
- package/components/index.d.ts +22 -0
- package/components/index.js +11 -0
- package/icons/icons.svg +2 -0
- package/icons/manifest.json +16 -0
- package/icons/registry.d.ts +4 -0
- package/icons/registry.js +2 -0
- package/icons/system/index.d.ts +4 -0
- package/icons/system/index.js +22 -0
- package/package.json +1 -1
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +1838 -136
- package/styles/all.expanded.unlayered.css +1838 -136
- package/styles/all.unlayered.css +1 -1
- package/styles/components/_bundle.scss +22 -0
- package/styles/components/input/index.scss +5 -20
- package/styles/index.scss +21 -0
- package/styles/system/_control.scss +49 -0
- package/styles/system/_tokens.scss +124 -2
- package/styles/system/index.scss +2 -1
- package/styles/utilities/_index.scss +50 -0
- package/tui-manifest.json +907 -112
- package/utils/compose-events.d.ts +15 -0
- package/utils/compose-events.js +27 -0
- package/utils/hash.d.ts +15 -0
- package/utils/hash.js +32 -0
- package/utils/index.d.ts +3 -0
- package/utils/index.js +6 -0
- package/utils/is-dev.d.ts +5 -0
- package/utils/is-dev.js +7 -0
- package/utils/use-controllable-state.d.ts +19 -0
- package/utils/use-controllable-state.js +59 -0
- package/utils/use-roving-group.d.ts +33 -0
- package/utils/use-roving-group.js +123 -0
- package/utils/value-key.d.ts +16 -0
- package/utils/value-key.js +14 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { FloatingContext, ReferenceType } from '@floating-ui/react';
|
|
2
|
+
import type { RefObject, CSSProperties, MutableRefObject } from 'react';
|
|
3
|
+
import type { SizeStandard } from '../../types/sizes';
|
|
4
|
+
import type { OptionValue } from '../../utils/value-key';
|
|
5
|
+
/**
|
|
6
|
+
* Controls when filtering should be active.
|
|
7
|
+
* - 'always': Consumer filters whenever inputValue changes (default)
|
|
8
|
+
* - 'when-searching': Query becomes empty when not actively searching,
|
|
9
|
+
* allowing consumers to show all options when browsing a selection
|
|
10
|
+
*/
|
|
11
|
+
export type FilterMode = 'always' | 'when-searching';
|
|
12
|
+
export type ComboboxProps = {
|
|
13
|
+
/**
|
|
14
|
+
* Control size.
|
|
15
|
+
* @default 'md'
|
|
16
|
+
*/
|
|
17
|
+
size?: SizeStandard;
|
|
18
|
+
/**
|
|
19
|
+
* Controlled selected value.
|
|
20
|
+
*/
|
|
21
|
+
value?: OptionValue;
|
|
22
|
+
/**
|
|
23
|
+
* Default value for uncontrolled usage.
|
|
24
|
+
*/
|
|
25
|
+
defaultValue?: OptionValue;
|
|
26
|
+
/**
|
|
27
|
+
* Callback when selection changes.
|
|
28
|
+
* Called with the option's value when user selects, or undefined when cleared.
|
|
29
|
+
* Does NOT mutate inputValue — consumer should update inputValue in handler if desired.
|
|
30
|
+
*/
|
|
31
|
+
onValueChange?: (value: OptionValue | undefined) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Controlled input value for search/filter.
|
|
34
|
+
*/
|
|
35
|
+
inputValue?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Callback when input value changes (typing, clear).
|
|
38
|
+
*/
|
|
39
|
+
onInputChange?: (value: string) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Controls filtering behavior.
|
|
42
|
+
* - 'always': Filter on inputValue at all times (default)
|
|
43
|
+
* - 'when-searching': Query is empty when not actively typing,
|
|
44
|
+
* so consumers can show all options when user is browsing a selection
|
|
45
|
+
* @default 'always'
|
|
46
|
+
*/
|
|
47
|
+
filterMode?: FilterMode;
|
|
48
|
+
/**
|
|
49
|
+
* Callback when the effective query changes.
|
|
50
|
+
* Query differs from inputValue when filterMode='when-searching':
|
|
51
|
+
* it becomes empty after selection to show all options.
|
|
52
|
+
*/
|
|
53
|
+
onQueryChange?: (query: string) => void;
|
|
54
|
+
/**
|
|
55
|
+
* Controlled open state.
|
|
56
|
+
*/
|
|
57
|
+
open?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Default open state for uncontrolled usage.
|
|
60
|
+
* @default false
|
|
61
|
+
*/
|
|
62
|
+
defaultOpen?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Callback when open state changes.
|
|
65
|
+
*/
|
|
66
|
+
onOpenChange?: (open: boolean) => void;
|
|
67
|
+
/**
|
|
68
|
+
* Whether the combobox is disabled.
|
|
69
|
+
* @default false
|
|
70
|
+
*/
|
|
71
|
+
disabled?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Placeholder text for the input.
|
|
74
|
+
*/
|
|
75
|
+
placeholder?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Whether to open the listbox when the input receives focus.
|
|
78
|
+
* @default true
|
|
79
|
+
*/
|
|
80
|
+
openOnFocus?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Accessible name for the combobox.
|
|
83
|
+
*/
|
|
84
|
+
'aria-label'?: string;
|
|
85
|
+
/**
|
|
86
|
+
* ID of element that labels this combobox.
|
|
87
|
+
*/
|
|
88
|
+
'aria-labelledby'?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Class name applied directly to the `<input>` element.
|
|
91
|
+
* Use for utilities like `tui-input-reset` that must target the input itself.
|
|
92
|
+
*/
|
|
93
|
+
inputClassName?: string;
|
|
94
|
+
children: React.ReactNode;
|
|
95
|
+
};
|
|
96
|
+
export type ComboboxContentProps = {
|
|
97
|
+
/**
|
|
98
|
+
* Additional CSS class names.
|
|
99
|
+
*/
|
|
100
|
+
className?: string;
|
|
101
|
+
children: React.ReactNode;
|
|
102
|
+
};
|
|
103
|
+
export type ComboboxOptionProps = {
|
|
104
|
+
/**
|
|
105
|
+
* The value for this option. Required and must be unique.
|
|
106
|
+
* Can be string or number.
|
|
107
|
+
*/
|
|
108
|
+
value: OptionValue;
|
|
109
|
+
/**
|
|
110
|
+
* Whether this option is disabled.
|
|
111
|
+
* @default false
|
|
112
|
+
*/
|
|
113
|
+
disabled?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Text value used for display when selected.
|
|
116
|
+
* Required when children is not a string.
|
|
117
|
+
* If children is a string, defaults to that.
|
|
118
|
+
*/
|
|
119
|
+
textValue?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Additional CSS class names.
|
|
122
|
+
*/
|
|
123
|
+
className?: string;
|
|
124
|
+
children: React.ReactNode;
|
|
125
|
+
};
|
|
126
|
+
export type ComboboxGroupProps = {
|
|
127
|
+
/**
|
|
128
|
+
* Additional CSS class names.
|
|
129
|
+
*/
|
|
130
|
+
className?: string;
|
|
131
|
+
children: React.ReactNode;
|
|
132
|
+
};
|
|
133
|
+
export type ComboboxLabelProps = {
|
|
134
|
+
/**
|
|
135
|
+
* Additional CSS class names.
|
|
136
|
+
*/
|
|
137
|
+
className?: string;
|
|
138
|
+
children: React.ReactNode;
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Registration record for an option.
|
|
142
|
+
*/
|
|
143
|
+
export type RegisteredOption = {
|
|
144
|
+
value: OptionValue;
|
|
145
|
+
ref: RefObject<HTMLElement | null>;
|
|
146
|
+
disabled: boolean;
|
|
147
|
+
textValue: string;
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Stable context: config, IDs, refs, and stable callbacks.
|
|
151
|
+
*/
|
|
152
|
+
export type ComboboxActionsContextValue = {
|
|
153
|
+
disabled: boolean;
|
|
154
|
+
placeholder: string;
|
|
155
|
+
openOnFocus: boolean;
|
|
156
|
+
inputId: string;
|
|
157
|
+
listboxId: string;
|
|
158
|
+
ariaLabel?: string;
|
|
159
|
+
ariaLabelledBy?: string;
|
|
160
|
+
setOpen: (open: boolean) => void;
|
|
161
|
+
setInputValue: (value: string) => void;
|
|
162
|
+
selectOption: (value: OptionValue) => void;
|
|
163
|
+
registerOption: (option: RegisteredOption) => void;
|
|
164
|
+
unregisterOption: (value: OptionValue) => void;
|
|
165
|
+
refs: {
|
|
166
|
+
reference: React.RefObject<ReferenceType | null>;
|
|
167
|
+
floating: React.RefObject<HTMLElement | null>;
|
|
168
|
+
setReference: (node: ReferenceType | null) => void;
|
|
169
|
+
setFloating: (node: HTMLElement | null) => void;
|
|
170
|
+
};
|
|
171
|
+
inputRef: RefObject<HTMLInputElement | null>;
|
|
172
|
+
listRef: MutableRefObject<(HTMLElement | null)[]>;
|
|
173
|
+
getReferenceProps: (userProps?: React.HTMLProps<Element>) => Record<string, unknown>;
|
|
174
|
+
getFloatingProps: (userProps?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
|
|
175
|
+
getItemProps: (userProps?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* State context: values that change during interaction.
|
|
179
|
+
*/
|
|
180
|
+
export type ComboboxStateContextValue = {
|
|
181
|
+
open: boolean;
|
|
182
|
+
value: OptionValue | undefined;
|
|
183
|
+
inputValue: string;
|
|
184
|
+
/**
|
|
185
|
+
* The effective query to filter on.
|
|
186
|
+
* When filterMode='when-searching', this is empty after selection
|
|
187
|
+
* (allowing all options to show). Otherwise equals inputValue.
|
|
188
|
+
*/
|
|
189
|
+
query: string;
|
|
190
|
+
activeIndex: number;
|
|
191
|
+
activeOptionId: string | undefined;
|
|
192
|
+
orderedOptions: RegisteredOption[];
|
|
193
|
+
floatingStyles: CSSProperties;
|
|
194
|
+
floatingContext: FloatingContext;
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Combined context value.
|
|
198
|
+
*/
|
|
199
|
+
export type ComboboxContextValue = ComboboxActionsContextValue & ComboboxStateContextValue;
|
|
200
|
+
export type ComboboxContentContextValue = {
|
|
201
|
+
listRef: MutableRefObject<(HTMLElement | null)[]>;
|
|
202
|
+
activeIndex: number;
|
|
203
|
+
orderedOptions: RegisteredOption[];
|
|
204
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -204,7 +204,7 @@ function DropdownItemComponent({ onSelect, href, target = '_self', disabled = fa
|
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
}, [disabled, onSelect, keepOpen, setOpen, triggerRef]);
|
|
207
|
-
return (_jsx(Button, { variant: "ghost", href: disabled ? undefined : href, target: href ? target : undefined, disabled: disabled, onClick: handleClick, className: cx('tui-dropdown__item', className),
|
|
207
|
+
return (_jsx(Button, { ...props, variant: "ghost", href: disabled ? undefined : href, target: href ? target : undefined, disabled: disabled, onClick: handleClick, className: cx('tui-dropdown__item', className), children: children }));
|
|
208
208
|
}
|
|
209
209
|
DropdownItemComponent.displayName = 'Dropdown.Item';
|
|
210
210
|
export const Dropdown = DropdownRoot;
|
|
@@ -216,4 +216,5 @@ export const DropdownTrigger = DropdownTriggerComponent;
|
|
|
216
216
|
export const DropdownContent = DropdownContentComponent;
|
|
217
217
|
export const DropdownItem = DropdownItemComponent;
|
|
218
218
|
// Hook for advanced use cases (custom items that need to close the dropdown)
|
|
219
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
219
220
|
export { useDropdownContext as useDropdown } from './DropdownContext.js';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export type FieldProps = {
|
|
3
|
+
/** Whether the field has an error state */
|
|
4
|
+
error?: boolean;
|
|
5
|
+
/** Whether the field is required */
|
|
6
|
+
required?: boolean;
|
|
7
|
+
/** Whether the field is disabled */
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
/** Inline layout: label and control on same row */
|
|
10
|
+
inline?: boolean;
|
|
11
|
+
/** Additional class name for the field wrapper */
|
|
12
|
+
className?: string;
|
|
13
|
+
children?: React.ReactNode;
|
|
14
|
+
};
|
|
15
|
+
type LabelProps = React.LabelHTMLAttributes<HTMLLabelElement> & {
|
|
16
|
+
/** Visually hide the label while keeping it accessible */
|
|
17
|
+
hidden?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
children?: React.ReactNode;
|
|
20
|
+
};
|
|
21
|
+
type ControlProps = {
|
|
22
|
+
children: React.ReactElement;
|
|
23
|
+
};
|
|
24
|
+
type HelperTextProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
25
|
+
className?: string;
|
|
26
|
+
children?: React.ReactNode;
|
|
27
|
+
};
|
|
28
|
+
type ErrorProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
29
|
+
className?: string;
|
|
30
|
+
children?: React.ReactNode;
|
|
31
|
+
};
|
|
32
|
+
type FieldCompound = React.ForwardRefExoticComponent<FieldProps & React.RefAttributes<HTMLDivElement>> & {
|
|
33
|
+
Label: React.FC<LabelProps>;
|
|
34
|
+
Control: React.FC<ControlProps>;
|
|
35
|
+
HelperText: React.FC<HelperTextProps>;
|
|
36
|
+
Error: React.FC<ErrorProps>;
|
|
37
|
+
};
|
|
38
|
+
export declare const Field: FieldCompound;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { forwardRef, useId, useMemo, cloneElement, isValidElement, Children, } from 'react';
|
|
3
|
+
import { cx } from '../../utils/cx.js';
|
|
4
|
+
import { FieldContext, useFieldContext } from './FieldContext.js';
|
|
5
|
+
export const Field = forwardRef(function Field({ error = false, required = false, disabled = false, inline = false, className, children, }, ref) {
|
|
6
|
+
const baseId = useId();
|
|
7
|
+
const controlId = `${baseId}-control`;
|
|
8
|
+
const helperTextId = `${baseId}-helper`;
|
|
9
|
+
const errorId = `${baseId}-error`;
|
|
10
|
+
const contextValue = useMemo(() => ({
|
|
11
|
+
controlId,
|
|
12
|
+
helperTextId,
|
|
13
|
+
errorId,
|
|
14
|
+
hasError: error,
|
|
15
|
+
required,
|
|
16
|
+
disabled,
|
|
17
|
+
}), [controlId, helperTextId, errorId, error, required, disabled]);
|
|
18
|
+
const classes = cx('tui-field', error && 'is-error', disabled && 'is-disabled', inline && 'is-layout-inline', className);
|
|
19
|
+
return (_jsx(FieldContext.Provider, { value: contextValue, children: _jsx("div", { ref: ref, className: classes, children: children }) }));
|
|
20
|
+
});
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Field.Label
|
|
23
|
+
// =============================================================================
|
|
24
|
+
function FieldLabel({ hidden = false, className, children, ...rest }) {
|
|
25
|
+
const { controlId, required } = useFieldContext();
|
|
26
|
+
const classes = cx('tui-field__label', hidden && 'tui-visually-hidden', className);
|
|
27
|
+
return (_jsxs("label", { htmlFor: controlId, className: classes, ...rest, children: [children, required && (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-field__required", "aria-hidden": "true", children: "*" }), _jsx("span", { className: "tui-visually-hidden", children: "required" })] }))] }));
|
|
28
|
+
}
|
|
29
|
+
FieldLabel.displayName = 'Field.Label';
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Field.Control
|
|
32
|
+
// =============================================================================
|
|
33
|
+
function FieldControl({ children }) {
|
|
34
|
+
const { controlId, helperTextId, errorId, hasError, required, disabled, } = useFieldContext();
|
|
35
|
+
const child = Children.only(children);
|
|
36
|
+
if (!isValidElement(child)) {
|
|
37
|
+
throw new Error('Field.Control expects a single React element as its child');
|
|
38
|
+
}
|
|
39
|
+
const childProps = child.props;
|
|
40
|
+
// Build aria-describedby, merging with any existing value from child
|
|
41
|
+
// Per ARIA spec, referencing non-existent IDs is valid (AT ignores them)
|
|
42
|
+
const describedByParts = [];
|
|
43
|
+
if (typeof childProps['aria-describedby'] === 'string') {
|
|
44
|
+
describedByParts.push(childProps['aria-describedby']);
|
|
45
|
+
}
|
|
46
|
+
describedByParts.push(helperTextId);
|
|
47
|
+
if (hasError) {
|
|
48
|
+
describedByParts.push(errorId);
|
|
49
|
+
}
|
|
50
|
+
const describedBy = describedByParts.join(' ');
|
|
51
|
+
// Clone child with a11y props
|
|
52
|
+
// Note: aria-invalid and aria-required must be string "true", not boolean
|
|
53
|
+
return cloneElement(child, {
|
|
54
|
+
id: controlId,
|
|
55
|
+
'aria-describedby': describedBy,
|
|
56
|
+
'aria-invalid': hasError ? 'true' : undefined,
|
|
57
|
+
'aria-required': required ? 'true' : undefined,
|
|
58
|
+
disabled: disabled || childProps.disabled,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
FieldControl.displayName = 'Field.Control';
|
|
62
|
+
// =============================================================================
|
|
63
|
+
// Field.HelperText
|
|
64
|
+
// =============================================================================
|
|
65
|
+
function FieldHelperText({ className, children, ...rest }) {
|
|
66
|
+
const { helperTextId } = useFieldContext();
|
|
67
|
+
if (!children)
|
|
68
|
+
return null;
|
|
69
|
+
const classes = cx('tui-field__helper', className);
|
|
70
|
+
return (_jsx("div", { id: helperTextId, className: classes, ...rest, children: children }));
|
|
71
|
+
}
|
|
72
|
+
FieldHelperText.displayName = 'Field.HelperText';
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// Field.Error
|
|
75
|
+
// =============================================================================
|
|
76
|
+
function FieldError({ className, children, ...rest }) {
|
|
77
|
+
const { errorId, hasError } = useFieldContext();
|
|
78
|
+
// Only render if there's an error and content
|
|
79
|
+
if (!hasError || !children)
|
|
80
|
+
return null;
|
|
81
|
+
const classes = cx('tui-field__error', className);
|
|
82
|
+
// role="alert" implies aria-live="assertive" - no need to add aria-live
|
|
83
|
+
return (_jsx("div", { id: errorId, className: classes, role: "alert", ...rest, children: children }));
|
|
84
|
+
}
|
|
85
|
+
FieldError.displayName = 'Field.Error';
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// Compound Export
|
|
88
|
+
// =============================================================================
|
|
89
|
+
Field.Label = FieldLabel;
|
|
90
|
+
Field.Control = FieldControl;
|
|
91
|
+
Field.HelperText = FieldHelperText;
|
|
92
|
+
Field.Error = FieldError;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type FieldContextValue = {
|
|
2
|
+
/** ID for the form control element */
|
|
3
|
+
controlId: string;
|
|
4
|
+
/** ID for helper text (for aria-describedby) */
|
|
5
|
+
helperTextId: string;
|
|
6
|
+
/** ID for error message (for aria-describedby) */
|
|
7
|
+
errorId: string;
|
|
8
|
+
/** Whether the field has an error */
|
|
9
|
+
hasError: boolean;
|
|
10
|
+
/** Whether the field is required */
|
|
11
|
+
required: boolean;
|
|
12
|
+
/** Whether the field is disabled */
|
|
13
|
+
disabled: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare const FieldContext: import("react").Context<FieldContextValue | null>;
|
|
16
|
+
export declare function useFieldContext(): FieldContextValue;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
export const FieldContext = createContext(null);
|
|
3
|
+
export function useFieldContext() {
|
|
4
|
+
const context = useContext(FieldContext);
|
|
5
|
+
if (!context) {
|
|
6
|
+
throw new Error('Field compound components must be used within a Field. ' +
|
|
7
|
+
'Wrap your Field.Label, Field.Control, Field.HelperText, and Field.Error in a <Field> component.');
|
|
8
|
+
}
|
|
9
|
+
return context;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Field } from './Field.js';
|
|
@@ -2,13 +2,13 @@ import React from 'react';
|
|
|
2
2
|
import type { SizeStandard } from '../../types';
|
|
3
3
|
type Size = SizeStandard;
|
|
4
4
|
export type ModalProps = {
|
|
5
|
-
|
|
5
|
+
open: boolean;
|
|
6
6
|
onClose: () => void;
|
|
7
7
|
size?: Size;
|
|
8
8
|
stickyHead?: boolean;
|
|
9
9
|
stickyFoot?: boolean;
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
'aria-labelledby'?: string;
|
|
11
|
+
'aria-describedby'?: string;
|
|
12
12
|
initialFocusSelector?: string;
|
|
13
13
|
container?: Element | null;
|
|
14
14
|
showCloseButton?: boolean;
|
|
@@ -17,7 +17,7 @@ export type ModalProps = {
|
|
|
17
17
|
closeOnEscape?: boolean;
|
|
18
18
|
children?: React.ReactNode;
|
|
19
19
|
};
|
|
20
|
-
declare function ModalRoot({
|
|
20
|
+
declare function ModalRoot({ open, onClose, size, stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel, closeOnBackdropClick, closeOnEscape, children, }: ModalProps): React.ReactPortal | null;
|
|
21
21
|
type ModalCloseProps = {
|
|
22
22
|
label?: string;
|
|
23
23
|
className?: string;
|
|
@@ -8,7 +8,7 @@ import { useFocusTrap, getInitialFocus } from '../../utils/focus-trap.js';
|
|
|
8
8
|
import { ModalContext, useModalContext } from './context.js';
|
|
9
9
|
import { IconButton } from '../IconButton/index.js';
|
|
10
10
|
const isBrowser = typeof document !== 'undefined';
|
|
11
|
-
function ModalRoot({
|
|
11
|
+
function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel = 'Close', closeOnBackdropClick = true, closeOnEscape = true, children, }) {
|
|
12
12
|
const dialogRef = useRef(null);
|
|
13
13
|
const restoreRef = useRef(null);
|
|
14
14
|
const [mount, setMount] = useState(null);
|
|
@@ -21,7 +21,7 @@ function ModalRoot({ isOpen, onClose, size = 'md', stickyHead, stickyFoot, label
|
|
|
21
21
|
// the captured element may not be meaningful — focus restore still works,
|
|
22
22
|
// portal scoping may fall back to global interface.
|
|
23
23
|
useLayoutEffect(() => {
|
|
24
|
-
if (!
|
|
24
|
+
if (!open || !isBrowser) {
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
if (mount)
|
|
@@ -29,10 +29,10 @@ function ModalRoot({ isOpen, onClose, size = 'md', stickyHead, stickyFoot, label
|
|
|
29
29
|
const trigger = document.activeElement;
|
|
30
30
|
restoreRef.current = trigger;
|
|
31
31
|
setMount(container ?? getPortalRootFor(trigger));
|
|
32
|
-
}, [
|
|
32
|
+
}, [open, container, mount]);
|
|
33
33
|
// Reset state when modal closes, restore focus to trigger.
|
|
34
34
|
useEffect(() => {
|
|
35
|
-
if (
|
|
35
|
+
if (open)
|
|
36
36
|
return;
|
|
37
37
|
const el = restoreRef.current;
|
|
38
38
|
if (el && typeof el.focus === 'function') {
|
|
@@ -40,25 +40,27 @@ function ModalRoot({ isOpen, onClose, size = 'md', stickyHead, stickyFoot, label
|
|
|
40
40
|
}
|
|
41
41
|
restoreRef.current = null;
|
|
42
42
|
setMount(null);
|
|
43
|
-
}, [
|
|
43
|
+
}, [open]);
|
|
44
44
|
// Body scroll lock.
|
|
45
45
|
useEffect(() => {
|
|
46
|
-
if (!
|
|
46
|
+
if (!open)
|
|
47
47
|
return;
|
|
48
48
|
document.body.classList.add('tui-modal-open');
|
|
49
49
|
return () => {
|
|
50
50
|
document.body.classList.remove('tui-modal-open');
|
|
51
51
|
};
|
|
52
|
-
}, [
|
|
52
|
+
}, [open]);
|
|
53
53
|
// Focus trap (handles Tab cycling and ESC to close).
|
|
54
54
|
useFocusTrap(dialogRef, {
|
|
55
|
-
|
|
55
|
+
// Modal mount is two-phase (capture portal root, then render portal).
|
|
56
|
+
// Activate trap only once dialog is actually mounted.
|
|
57
|
+
isActive: open && !!mount,
|
|
56
58
|
onEscape: onClose,
|
|
57
59
|
escapeDeactivates: closeOnEscape,
|
|
58
60
|
});
|
|
59
61
|
// Make scrollable region focusable; set initial focus.
|
|
60
62
|
useEffect(() => {
|
|
61
|
-
if (!
|
|
63
|
+
if (!open)
|
|
62
64
|
return;
|
|
63
65
|
const dialog = dialogRef.current;
|
|
64
66
|
if (!dialog)
|
|
@@ -96,12 +98,12 @@ function ModalRoot({ isOpen, onClose, size = 'md', stickyHead, stickyFoot, label
|
|
|
96
98
|
`Ensure an element with id="${labelledBy}" exists inside the modal.`);
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
|
-
}, [
|
|
101
|
+
}, [open, mount, initialFocusSelector, labelledBy]);
|
|
100
102
|
// Memoize context value to prevent unnecessary re-renders
|
|
101
103
|
const contextValue = useMemo(() => ({ onClose }), [onClose]);
|
|
102
|
-
if (!
|
|
104
|
+
if (!open || !mount)
|
|
103
105
|
return null;
|
|
104
|
-
return createPortal(_jsx(ModalContext.Provider, { value: contextValue, children: _jsxs("div", { className: "tui-modal", "data-state": "open", children: [_jsx("div", { className: "tui-modal__backdrop", onClick: closeOnBackdropClick ? onClose : undefined }), _jsxs("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-labelledby": labelledBy, "aria-describedby": describedBy, className: cx('tui-modal__dialog', `is-size-${size}`, stickyHead && 'is-sticky-head', stickyFoot && 'is-sticky-foot'), tabIndex: -1, children: [showCloseButton && (_jsx(IconButton, { icon: "system/close", label: closeLabel, variant: "ghost", size: "sm", onClick: onClose, className: "tui-modal__close", showTooltip: true })), children] })] }) }), mount);
|
|
106
|
+
return createPortal(_jsx(ModalContext.Provider, { value: contextValue, children: _jsxs("div", { className: "tui-modal", "data-state": "open", style: { pointerEvents: 'auto' }, children: [_jsx("div", { className: "tui-modal__backdrop", onClick: closeOnBackdropClick ? onClose : undefined }), _jsxs("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-labelledby": labelledBy, "aria-describedby": describedBy, className: cx('tui-modal__dialog', `is-size-${size}`, stickyHead && 'is-sticky-head', stickyFoot && 'is-sticky-foot'), tabIndex: -1, children: [showCloseButton && (_jsx(IconButton, { icon: "system/close", label: closeLabel, variant: "ghost", size: "sm", onClick: onClose, className: "tui-modal__close", showTooltip: true })), children] })] }) }), mount);
|
|
105
107
|
}
|
|
106
108
|
function ModalClose({ label = 'Close', className }) {
|
|
107
109
|
const { onClose } = useModalContext();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
|
|
3
|
+
import { cx } from '../../utils/cx.js';
|
|
4
|
+
import { isDev } from '../../utils/is-dev.js';
|
|
5
|
+
import { Icon } from '../Icon/index.js';
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// MoveHandle Component
|
|
8
|
+
// =============================================================================
|
|
9
|
+
//
|
|
10
|
+
// Compact reorder control for sortable lists. Shows a drag handle with optional
|
|
11
|
+
// up/down chevron buttons and a position index badge.
|
|
12
|
+
//
|
|
13
|
+
// When `index` is provided, shows the number at rest and swaps to the drag
|
|
14
|
+
// handle icon on hover/focus-within (CSS-driven, no JS state).
|
|
15
|
+
//
|
|
16
|
+
// Forwards ref to the root element (div for full, button for handle).
|
|
17
|
+
//
|
|
18
|
+
// Modes:
|
|
19
|
+
// full — Background panel with arrows, index, lock (default)
|
|
20
|
+
// handle — Bare drag icon button, no chrome
|
|
21
|
+
//
|
|
22
|
+
// CSS token API (never defined, read via fallback):
|
|
23
|
+
// --tui-move-handle-size Override container size
|
|
24
|
+
// --tui-move-handle-icon-size Override icon size
|
|
25
|
+
//
|
|
26
|
+
// =============================================================================
|
|
27
|
+
export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size = 'md', index, locked = false, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true, labels, dragHandleProps, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, }, ref) {
|
|
28
|
+
// All hooks must be called unconditionally (rules of hooks)
|
|
29
|
+
const innerRef = useRef(null);
|
|
30
|
+
const mergedRef = useCallback((node) => {
|
|
31
|
+
innerRef.current = node;
|
|
32
|
+
if (typeof ref === 'function')
|
|
33
|
+
ref(node);
|
|
34
|
+
else if (ref)
|
|
35
|
+
ref.current = node;
|
|
36
|
+
}, [ref]);
|
|
37
|
+
const hasWarnedRef = useRef(false);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (mode === 'handle')
|
|
40
|
+
return;
|
|
41
|
+
if (hasWarnedRef.current)
|
|
42
|
+
return;
|
|
43
|
+
if (isDev() && !ariaLabel && !ariaLabelledBy) {
|
|
44
|
+
console.warn('MoveHandle: Missing accessible name. Provide aria-label or aria-labelledby.');
|
|
45
|
+
hasWarnedRef.current = true;
|
|
46
|
+
}
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, []);
|
|
49
|
+
const lockedDescId = useId();
|
|
50
|
+
// Focus recovery: when a move button becomes disabled after a reorder,
|
|
51
|
+
// redirect focus to the opposite button or drag handle
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (mode === 'handle')
|
|
54
|
+
return;
|
|
55
|
+
const group = innerRef.current;
|
|
56
|
+
if (!group)
|
|
57
|
+
return;
|
|
58
|
+
const active = document.activeElement;
|
|
59
|
+
if (active instanceof HTMLButtonElement &&
|
|
60
|
+
active.disabled &&
|
|
61
|
+
group.contains(active)) {
|
|
62
|
+
const fallback = group.querySelector('.tui-move-handle__up:not(:disabled), .tui-move-handle__down:not(:disabled)') ??
|
|
63
|
+
group.querySelector('.tui-move-handle__handle');
|
|
64
|
+
fallback?.focus();
|
|
65
|
+
}
|
|
66
|
+
}, [mode, canMoveUp, canMoveDown]);
|
|
67
|
+
// Drag handle label precedence: dragHandleProps > labels.drag > default
|
|
68
|
+
const resolvedDragLabel = dragHandleProps?.['aria-label'] ?? labels?.drag ?? 'Drag to reorder';
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
70
|
+
const { 'aria-label': _dhLabel, ...restDragProps } = dragHandleProps ?? {};
|
|
71
|
+
// ----- Handle mode: just the drag icon button -----
|
|
72
|
+
// Uses ref directly (not mergedRef) — innerRef is unused for this path.
|
|
73
|
+
// Focus recovery and dev warning effects early-return for handle mode.
|
|
74
|
+
if (mode === 'handle') {
|
|
75
|
+
return (_jsx("button", { ref: ref, type: "button", className: cx('tui-move-handle', 'is-handle', className), "aria-label": resolvedDragLabel, ...restDragProps, children: _jsx(Icon, { name: "system/drag" }) }));
|
|
76
|
+
}
|
|
77
|
+
// ----- Full mode -----
|
|
78
|
+
const hasIndex = index != null;
|
|
79
|
+
const hasArrows = !!(onMoveUp || onMoveDown);
|
|
80
|
+
const resolvedLockedDesc = locked
|
|
81
|
+
? (labels?.locked ?? 'This item is locked and cannot be reordered')
|
|
82
|
+
: undefined;
|
|
83
|
+
return (_jsxs("div", { ref: mergedRef, role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": locked ? lockedDescId : undefined, "aria-disabled": locked || undefined, className: cx('tui-move-handle', `is-size-${size}`, locked && 'is-locked', hasIndex && 'has-index', className), children: [locked && (_jsx("span", { id: lockedDescId, className: "tui-visually-hidden", children: resolvedLockedDesc })), onMoveUp && (_jsx("button", { type: "button", className: "tui-move-handle__up", "aria-label": labels?.moveUp ?? 'Move up', disabled: locked || !canMoveUp, onClick: onMoveUp, children: _jsx(Icon, { name: "system/chevron-up" }) })), _jsx("div", { className: "tui-move-handle__center", children: locked ? (_jsx("span", { className: "tui-move-handle__lock", "aria-hidden": "true", children: _jsx(Icon, { name: "system/lock" }) })) : (_jsxs(_Fragment, { children: [hasIndex && (_jsx("span", { className: "tui-move-handle__index", "aria-hidden": "true", children: index })), _jsx("button", { type: "button", className: "tui-move-handle__handle", "aria-label": resolvedDragLabel, tabIndex: hasArrows ? -1 : 0, ...restDragProps, children: _jsx(Icon, { name: "system/handle-alt" }) })] })) }), onMoveDown && (_jsx("button", { type: "button", className: "tui-move-handle__down", "aria-label": labels?.moveDown ?? 'Move down', disabled: locked || !canMoveDown, onClick: onMoveDown, children: _jsx(Icon, { name: "system/chevron-down" }) }))] }));
|
|
84
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MoveHandle } from './MoveHandle.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react';
|
|
2
|
+
export type MoveHandleSize = 'sm' | 'md';
|
|
3
|
+
export type MoveHandleMode = 'full' | 'handle';
|
|
4
|
+
export interface MoveHandleLabels {
|
|
5
|
+
/** Label for the move-up button. Include item name for clear AT context, e.g. "Move Introduction up". Default: "Move up" */
|
|
6
|
+
moveUp?: string;
|
|
7
|
+
/** Label for the move-down button. Include item name for clear AT context, e.g. "Move Introduction down". Default: "Move down" */
|
|
8
|
+
moveDown?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Label for the drag handle button. Default: "Drag to reorder".
|
|
11
|
+
* Consumers add position info if needed. dragHandleProps['aria-label'] takes precedence.
|
|
12
|
+
*/
|
|
13
|
+
drag?: string;
|
|
14
|
+
/** Descriptive text announced when locked (via aria-describedby). Default: "This item is locked and cannot be reordered". */
|
|
15
|
+
locked?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface MoveHandleProps {
|
|
18
|
+
/** Structural mode. 'full' (default) shows background panel with arrows/index. 'handle' shows only the bare drag icon button. */
|
|
19
|
+
mode?: MoveHandleMode;
|
|
20
|
+
/** Component scale. sm = 32px, md = 40px. Ignored when mode is 'handle'. */
|
|
21
|
+
size?: MoveHandleSize;
|
|
22
|
+
/** Position index. When provided, shows number at rest, drag handle on hover. */
|
|
23
|
+
index?: number;
|
|
24
|
+
/** When true, shows lock icon and disables all interaction. */
|
|
25
|
+
locked?: boolean;
|
|
26
|
+
/** Called when the "move up" button is clicked. Button not rendered when omitted. */
|
|
27
|
+
onMoveUp?: () => void;
|
|
28
|
+
/** Called when the "move down" button is clicked. Button not rendered when omitted. */
|
|
29
|
+
onMoveDown?: () => void;
|
|
30
|
+
/** When false, disables the move-up button without hiding it. Default: true. */
|
|
31
|
+
canMoveUp?: boolean;
|
|
32
|
+
/** When false, disables the move-down button without hiding it. Default: true. */
|
|
33
|
+
canMoveDown?: boolean;
|
|
34
|
+
/** Override internal button labels for i18n. */
|
|
35
|
+
labels?: MoveHandleLabels;
|
|
36
|
+
/** Props to spread on the drag handle button (e.g., from dnd-kit useSortable). */
|
|
37
|
+
dragHandleProps?: HTMLAttributes<HTMLButtonElement>;
|
|
38
|
+
/** Accessible label for the control group. Required if aria-labelledby is not set. */
|
|
39
|
+
'aria-label'?: string;
|
|
40
|
+
/** ID of element labelling this group. Required if aria-label is not set. */
|
|
41
|
+
'aria-labelledby'?: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|