@proyecto-viviana/solidaria 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/button/createButton.ts +135 -0
- package/src/button/createToggleButton.ts +101 -0
- package/src/button/index.ts +4 -0
- package/src/button/types.ts +67 -0
- package/src/checkbox/createCheckbox.ts +135 -0
- package/src/checkbox/createCheckboxGroup.ts +137 -0
- package/src/checkbox/createCheckboxGroupItem.ts +117 -0
- package/src/checkbox/createCheckboxGroupState.ts +193 -0
- package/src/checkbox/index.ts +13 -0
- package/src/index.ts +128 -0
- package/src/interactions/FocusableProvider.tsx +44 -0
- package/src/interactions/PressEvent.ts +112 -0
- package/src/interactions/createFocus.ts +157 -0
- package/src/interactions/createFocusRing.ts +142 -0
- package/src/interactions/createFocusWithin.ts +141 -0
- package/src/interactions/createFocusable.ts +168 -0
- package/src/interactions/createHover.ts +214 -0
- package/src/interactions/createKeyboard.ts +82 -0
- package/src/interactions/createPress.ts +758 -0
- package/src/interactions/index.ts +45 -0
- package/src/label/createField.ts +145 -0
- package/src/label/createLabel.ts +116 -0
- package/src/label/createLabels.ts +50 -0
- package/src/label/index.ts +19 -0
- package/src/link/createLink.ts +176 -0
- package/src/link/index.ts +1 -0
- package/src/progress/createProgressBar.ts +128 -0
- package/src/progress/index.ts +5 -0
- package/src/radio/createRadio.ts +286 -0
- package/src/radio/createRadioGroup.ts +189 -0
- package/src/radio/createRadioGroupState.ts +201 -0
- package/src/radio/index.ts +23 -0
- package/src/separator/createSeparator.ts +82 -0
- package/src/separator/index.ts +6 -0
- package/src/ssr/index.ts +36 -0
- package/src/switch/createSwitch.ts +70 -0
- package/src/switch/index.ts +1 -0
- package/src/textfield/createTextField.ts +198 -0
- package/src/textfield/index.ts +5 -0
- package/src/toggle/createToggle.ts +222 -0
- package/src/toggle/createToggleState.ts +94 -0
- package/src/toggle/index.ts +7 -0
- package/src/utils/dom.ts +244 -0
- package/src/utils/events.ts +119 -0
- package/src/utils/filterDOMProps.ts +116 -0
- package/src/utils/focus.ts +151 -0
- package/src/utils/geometry.ts +115 -0
- package/src/utils/globalListeners.ts +142 -0
- package/src/utils/index.ts +66 -0
- package/src/utils/mergeProps.ts +49 -0
- package/src/utils/platform.ts +52 -0
- package/src/utils/reactivity.ts +36 -0
- package/src/utils/textSelection.ts +114 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createSeparator - SolidJS implementation of React Aria's useSeparator
|
|
3
|
+
*
|
|
4
|
+
* A separator is a visual divider between two groups of content,
|
|
5
|
+
* e.g. groups of menu items or sections of a page.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { JSX } from 'solid-js';
|
|
9
|
+
import { access, type MaybeAccessor } from '../utils';
|
|
10
|
+
import { filterDOMProps } from '../utils';
|
|
11
|
+
|
|
12
|
+
// ============================================
|
|
13
|
+
// TYPES
|
|
14
|
+
// ============================================
|
|
15
|
+
|
|
16
|
+
export type Orientation = 'horizontal' | 'vertical';
|
|
17
|
+
|
|
18
|
+
export interface AriaSeparatorProps {
|
|
19
|
+
/**
|
|
20
|
+
* The orientation of the separator.
|
|
21
|
+
* @default 'horizontal'
|
|
22
|
+
*/
|
|
23
|
+
orientation?: Orientation;
|
|
24
|
+
/**
|
|
25
|
+
* The HTML element type that will be used to render the separator.
|
|
26
|
+
* @default 'hr'
|
|
27
|
+
*/
|
|
28
|
+
elementType?: string;
|
|
29
|
+
/** An accessibility label for the separator. */
|
|
30
|
+
'aria-label'?: string;
|
|
31
|
+
/** Identifies the element(s) that labels the separator. */
|
|
32
|
+
'aria-labelledby'?: string;
|
|
33
|
+
/** The element's unique identifier. */
|
|
34
|
+
id?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SeparatorAria {
|
|
38
|
+
/** Props for the separator element. */
|
|
39
|
+
separatorProps: JSX.HTMLAttributes<HTMLElement>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================
|
|
43
|
+
// CREATE SEPARATOR
|
|
44
|
+
// ============================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Provides the accessibility implementation for a separator.
|
|
48
|
+
* A separator is a visual divider between two groups of content,
|
|
49
|
+
* e.g. groups of menu items or sections of a page.
|
|
50
|
+
*/
|
|
51
|
+
export function createSeparator(
|
|
52
|
+
props: MaybeAccessor<AriaSeparatorProps> = {}
|
|
53
|
+
): SeparatorAria {
|
|
54
|
+
const getSeparatorProps = (): JSX.HTMLAttributes<HTMLElement> => {
|
|
55
|
+
const p = access(props);
|
|
56
|
+
const domProps = filterDOMProps(p as Record<string, unknown>, { labelable: true });
|
|
57
|
+
|
|
58
|
+
// if orientation is horizontal, aria-orientation default is horizontal, so we leave it undefined
|
|
59
|
+
// if it's vertical, we need to specify it
|
|
60
|
+
let ariaOrientation: 'vertical' | undefined;
|
|
61
|
+
if (p.orientation === 'vertical') {
|
|
62
|
+
ariaOrientation = 'vertical';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// hr elements implicitly have role = separator and a horizontal orientation
|
|
66
|
+
if (p.elementType !== 'hr') {
|
|
67
|
+
return {
|
|
68
|
+
...domProps,
|
|
69
|
+
role: 'separator',
|
|
70
|
+
'aria-orientation': ariaOrientation,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return domProps;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
get separatorProps() {
|
|
79
|
+
return getSeparatorProps();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/ssr/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR utilities for Solidaria
|
|
3
|
+
*
|
|
4
|
+
* SolidJS has built-in SSR support with `isServer` and `createUniqueId()`.
|
|
5
|
+
* These utilities provide a consistent API matching React-Aria's patterns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createUniqueId } from 'solid-js';
|
|
9
|
+
import { isServer } from 'solid-js/web';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns whether the component is currently being server side rendered.
|
|
13
|
+
* Can be used to delay browser-specific rendering until after hydration.
|
|
14
|
+
*/
|
|
15
|
+
export function createIsSSR(): boolean {
|
|
16
|
+
return isServer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate a unique ID that is stable across server and client.
|
|
21
|
+
* Uses SolidJS's built-in createUniqueId which handles SSR correctly.
|
|
22
|
+
*
|
|
23
|
+
* @param defaultId - Optional default ID to use instead of generating one.
|
|
24
|
+
*/
|
|
25
|
+
export function createId(defaultId?: string): string {
|
|
26
|
+
if (defaultId) {
|
|
27
|
+
return defaultId;
|
|
28
|
+
}
|
|
29
|
+
return `solidaria-${createUniqueId()}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if we can use DOM APIs.
|
|
34
|
+
* This is useful for code that needs to run only in the browser.
|
|
35
|
+
*/
|
|
36
|
+
export const canUseDOM = !isServer;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Switch hook for Solidaria
|
|
3
|
+
*
|
|
4
|
+
* Provides the behavior and accessibility implementation for a switch component.
|
|
5
|
+
* A switch is similar to a checkbox, but represents on/off values as opposed to selection.
|
|
6
|
+
*
|
|
7
|
+
* This is a 1:1 port of @react-aria/switch's useSwitch hook.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { JSX, Accessor } from 'solid-js';
|
|
11
|
+
import { createToggle, type AriaToggleProps } from '../toggle/createToggle';
|
|
12
|
+
import { type ToggleState } from '@proyecto-viviana/solid-stately';
|
|
13
|
+
import { type MaybeAccessor } from '../utils/reactivity';
|
|
14
|
+
|
|
15
|
+
// ============================================
|
|
16
|
+
// TYPES
|
|
17
|
+
// ============================================
|
|
18
|
+
|
|
19
|
+
export interface AriaSwitchProps extends AriaToggleProps {
|
|
20
|
+
// Switch uses the same props as toggle
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SwitchAria {
|
|
24
|
+
/** Props for the label wrapper element. */
|
|
25
|
+
labelProps: JSX.LabelHTMLAttributes<HTMLLabelElement>;
|
|
26
|
+
/** Props for the input element. */
|
|
27
|
+
inputProps: JSX.InputHTMLAttributes<HTMLInputElement>;
|
|
28
|
+
/** Whether the switch is selected. */
|
|
29
|
+
isSelected: Accessor<boolean>;
|
|
30
|
+
/** Whether the switch is in a pressed state. */
|
|
31
|
+
isPressed: Accessor<boolean>;
|
|
32
|
+
/** Whether the switch is disabled. */
|
|
33
|
+
isDisabled: boolean;
|
|
34
|
+
/** Whether the switch is read only. */
|
|
35
|
+
isReadOnly: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================
|
|
39
|
+
// IMPLEMENTATION
|
|
40
|
+
// ============================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Provides the behavior and accessibility implementation for a switch component.
|
|
44
|
+
* A switch is similar to a checkbox, but represents on/off values as opposed to selection.
|
|
45
|
+
*/
|
|
46
|
+
export function createSwitch(
|
|
47
|
+
props: MaybeAccessor<AriaSwitchProps>,
|
|
48
|
+
state: ToggleState,
|
|
49
|
+
ref: () => HTMLInputElement | null
|
|
50
|
+
): SwitchAria {
|
|
51
|
+
// Don't destructure inputProps - it's a getter that needs to be evaluated each time
|
|
52
|
+
const toggle = createToggle(props, state, ref);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
labelProps: toggle.labelProps,
|
|
56
|
+
get inputProps() {
|
|
57
|
+
// Access toggle.inputProps (the getter) each time to get fresh values
|
|
58
|
+
const baseProps = toggle.inputProps;
|
|
59
|
+
return {
|
|
60
|
+
...baseProps,
|
|
61
|
+
role: 'switch' as const,
|
|
62
|
+
checked: toggle.isSelected(),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
isSelected: toggle.isSelected,
|
|
66
|
+
isPressed: toggle.isPressed,
|
|
67
|
+
isDisabled: toggle.isDisabled,
|
|
68
|
+
isReadOnly: toggle.isReadOnly,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createSwitch, type AriaSwitchProps, type SwitchAria } from './createSwitch';
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TextField hook for Solidaria
|
|
3
|
+
*
|
|
4
|
+
* Provides the behavior and accessibility implementation for a text field.
|
|
5
|
+
*
|
|
6
|
+
* This is a 1:1 port of @react-aria/textfield's useTextField hook.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { JSX } from 'solid-js';
|
|
10
|
+
import { createField, type AriaFieldProps, type FieldAria } from '../label';
|
|
11
|
+
import { createFocusable, type FocusableProps } from '../interactions';
|
|
12
|
+
import { mergeProps, filterDOMProps } from '../utils';
|
|
13
|
+
import { type MaybeAccessor, access } from '../utils/reactivity';
|
|
14
|
+
|
|
15
|
+
// ============================================
|
|
16
|
+
// TYPES
|
|
17
|
+
// ============================================
|
|
18
|
+
|
|
19
|
+
export interface AriaTextFieldProps extends AriaFieldProps, FocusableProps {
|
|
20
|
+
/** The current value (controlled). */
|
|
21
|
+
value?: string;
|
|
22
|
+
/** The default value (uncontrolled). */
|
|
23
|
+
defaultValue?: string;
|
|
24
|
+
/** Handler that is called when the value changes. */
|
|
25
|
+
onChange?: (value: string) => void;
|
|
26
|
+
/** Whether the input is disabled. */
|
|
27
|
+
isDisabled?: boolean;
|
|
28
|
+
/** Whether the input is read only. */
|
|
29
|
+
isReadOnly?: boolean;
|
|
30
|
+
/** Whether the input is required. */
|
|
31
|
+
isRequired?: boolean;
|
|
32
|
+
/** The type of input to render. */
|
|
33
|
+
type?: 'text' | 'search' | 'url' | 'tel' | 'email' | 'password' | string;
|
|
34
|
+
/** The input mode hint for virtual keyboards. */
|
|
35
|
+
inputMode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
|
|
36
|
+
/** The name of the input element, used when submitting an HTML form. */
|
|
37
|
+
name?: string;
|
|
38
|
+
/** Regex pattern to validate the input value. */
|
|
39
|
+
pattern?: string;
|
|
40
|
+
/** The maximum number of characters supported by the input. */
|
|
41
|
+
maxLength?: number;
|
|
42
|
+
/** The minimum number of characters required by the input. */
|
|
43
|
+
minLength?: number;
|
|
44
|
+
/** Placeholder text for the input. */
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
/** Whether to enable auto complete. */
|
|
47
|
+
autoComplete?: string;
|
|
48
|
+
/** Whether to enable auto correct. */
|
|
49
|
+
autoCorrect?: string;
|
|
50
|
+
/** Whether to enable spell check. */
|
|
51
|
+
spellCheck?: 'true' | 'false';
|
|
52
|
+
/** Controls whether and how text input is automatically capitalized. */
|
|
53
|
+
autoCapitalize?: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters';
|
|
54
|
+
/** The element type to use for the input. Defaults to 'input'. */
|
|
55
|
+
inputElementType?: 'input' | 'textarea';
|
|
56
|
+
|
|
57
|
+
// Clipboard events
|
|
58
|
+
onCopy?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, ClipboardEvent>;
|
|
59
|
+
onCut?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, ClipboardEvent>;
|
|
60
|
+
onPaste?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, ClipboardEvent>;
|
|
61
|
+
|
|
62
|
+
// Composition events
|
|
63
|
+
onCompositionStart?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, CompositionEvent>;
|
|
64
|
+
onCompositionEnd?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, CompositionEvent>;
|
|
65
|
+
onCompositionUpdate?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, CompositionEvent>;
|
|
66
|
+
|
|
67
|
+
// Selection events
|
|
68
|
+
onSelect?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, Event>;
|
|
69
|
+
|
|
70
|
+
// Input events
|
|
71
|
+
onBeforeInput?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, InputEvent>;
|
|
72
|
+
onInput?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, InputEvent>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TextFieldAria<T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement> extends Omit<FieldAria, 'fieldProps'> {
|
|
76
|
+
/** Props for the input element. */
|
|
77
|
+
inputProps: JSX.InputHTMLAttributes<T>;
|
|
78
|
+
/** Whether the text field is invalid. */
|
|
79
|
+
isInvalid: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================
|
|
83
|
+
// IMPLEMENTATION
|
|
84
|
+
// ============================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Provides the behavior and accessibility implementation for a text field.
|
|
88
|
+
* Text fields allow users to input text with a keyboard.
|
|
89
|
+
*
|
|
90
|
+
* @param props - Props for the text field.
|
|
91
|
+
* @param ref - Optional ref callback for the input element.
|
|
92
|
+
*/
|
|
93
|
+
export function createTextField<T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement>(
|
|
94
|
+
props: MaybeAccessor<AriaTextFieldProps>,
|
|
95
|
+
ref?: (el: T) => void
|
|
96
|
+
): TextFieldAria<T> {
|
|
97
|
+
const getProps = () => access(props);
|
|
98
|
+
|
|
99
|
+
// Get field accessibility props (label, description, error message)
|
|
100
|
+
const { labelProps, fieldProps, descriptionProps, errorMessageProps } = createField(props);
|
|
101
|
+
|
|
102
|
+
// Get focusable props
|
|
103
|
+
const { focusableProps } = createFocusable(
|
|
104
|
+
{
|
|
105
|
+
get isDisabled() {
|
|
106
|
+
return getProps().isDisabled;
|
|
107
|
+
},
|
|
108
|
+
get autoFocus() {
|
|
109
|
+
return getProps().autoFocus;
|
|
110
|
+
},
|
|
111
|
+
onFocus: getProps().onFocus,
|
|
112
|
+
onBlur: getProps().onBlur,
|
|
113
|
+
onFocusChange: getProps().onFocusChange,
|
|
114
|
+
onKeyDown: getProps().onKeyDown,
|
|
115
|
+
onKeyUp: getProps().onKeyUp,
|
|
116
|
+
},
|
|
117
|
+
ref as ((el: HTMLElement) => void) | undefined
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Filter DOM props
|
|
121
|
+
const getDomProps = () => filterDOMProps(getProps() as Record<string, unknown>, { labelable: true });
|
|
122
|
+
|
|
123
|
+
// Build input props
|
|
124
|
+
const getInputProps = (): JSX.InputHTMLAttributes<T> => {
|
|
125
|
+
const p = getProps();
|
|
126
|
+
const isInvalid = p.isInvalid ?? false;
|
|
127
|
+
const isTextarea = p.inputElementType === 'textarea';
|
|
128
|
+
|
|
129
|
+
return mergeProps(
|
|
130
|
+
getDomProps(),
|
|
131
|
+
{
|
|
132
|
+
disabled: p.isDisabled,
|
|
133
|
+
readOnly: p.isReadOnly,
|
|
134
|
+
required: p.isRequired,
|
|
135
|
+
'aria-required': p.isRequired || undefined,
|
|
136
|
+
'aria-invalid': isInvalid || undefined,
|
|
137
|
+
value: p.value ?? p.defaultValue ?? '',
|
|
138
|
+
onChange: (e: Event) => {
|
|
139
|
+
const target = e.target as HTMLInputElement | HTMLTextAreaElement;
|
|
140
|
+
p.onChange?.(target.value);
|
|
141
|
+
},
|
|
142
|
+
// Don't include type and pattern for textarea elements
|
|
143
|
+
type: isTextarea ? undefined : (p.type ?? 'text'),
|
|
144
|
+
inputMode: p.inputMode,
|
|
145
|
+
name: p.name,
|
|
146
|
+
pattern: isTextarea ? undefined : p.pattern,
|
|
147
|
+
maxLength: p.maxLength,
|
|
148
|
+
minLength: p.minLength,
|
|
149
|
+
placeholder: p.placeholder,
|
|
150
|
+
autoComplete: p.autoComplete,
|
|
151
|
+
autoCorrect: p.autoCorrect,
|
|
152
|
+
autoCapitalize: p.autoCapitalize,
|
|
153
|
+
spellCheck: p.spellCheck,
|
|
154
|
+
|
|
155
|
+
// Clipboard events
|
|
156
|
+
onCopy: p.onCopy,
|
|
157
|
+
onCut: p.onCut,
|
|
158
|
+
onPaste: p.onPaste,
|
|
159
|
+
|
|
160
|
+
// Composition events
|
|
161
|
+
onCompositionStart: p.onCompositionStart,
|
|
162
|
+
onCompositionEnd: p.onCompositionEnd,
|
|
163
|
+
onCompositionUpdate: p.onCompositionUpdate,
|
|
164
|
+
|
|
165
|
+
// Selection events
|
|
166
|
+
onSelect: p.onSelect,
|
|
167
|
+
|
|
168
|
+
// Input events
|
|
169
|
+
onBeforeInput: p.onBeforeInput,
|
|
170
|
+
onInput: p.onInput,
|
|
171
|
+
},
|
|
172
|
+
focusableProps as Record<string, unknown>,
|
|
173
|
+
fieldProps as Record<string, unknown>
|
|
174
|
+
) as JSX.InputHTMLAttributes<T>;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const getIsInvalid = () => {
|
|
178
|
+
return getProps().isInvalid ?? false;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
get labelProps() {
|
|
183
|
+
return labelProps;
|
|
184
|
+
},
|
|
185
|
+
get inputProps() {
|
|
186
|
+
return getInputProps();
|
|
187
|
+
},
|
|
188
|
+
get descriptionProps() {
|
|
189
|
+
return descriptionProps;
|
|
190
|
+
},
|
|
191
|
+
get errorMessageProps() {
|
|
192
|
+
return errorMessageProps;
|
|
193
|
+
},
|
|
194
|
+
get isInvalid() {
|
|
195
|
+
return getIsInvalid();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toggle hook for Solidaria
|
|
3
|
+
*
|
|
4
|
+
* Handles interactions for toggle elements, e.g. Checkboxes and Switches.
|
|
5
|
+
*
|
|
6
|
+
* This is a 1:1 port of @react-aria/toggle's useToggle hook.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { JSX, Accessor, createEffect } from 'solid-js';
|
|
10
|
+
import { createPress } from '../interactions/createPress';
|
|
11
|
+
import { createFocusable } from '../interactions/createFocusable';
|
|
12
|
+
import { mergeProps } from '../utils/mergeProps';
|
|
13
|
+
import { filterDOMProps } from '../utils/filterDOMProps';
|
|
14
|
+
import { type MaybeAccessor, access } from '../utils/reactivity';
|
|
15
|
+
import { type ToggleState } from '@proyecto-viviana/solid-stately';
|
|
16
|
+
import { type PressEvent } from '../interactions/PressEvent';
|
|
17
|
+
|
|
18
|
+
// ============================================
|
|
19
|
+
// TYPES
|
|
20
|
+
// ============================================
|
|
21
|
+
|
|
22
|
+
export interface AriaToggleProps {
|
|
23
|
+
/** Whether the element should be selected (controlled). */
|
|
24
|
+
isSelected?: boolean;
|
|
25
|
+
/** Whether the element should be selected by default (uncontrolled). */
|
|
26
|
+
defaultSelected?: boolean;
|
|
27
|
+
/** Handler that is called when the element's selection state changes. */
|
|
28
|
+
onChange?: (isSelected: boolean) => void;
|
|
29
|
+
/** The value of the input element, used when submitting an HTML form. */
|
|
30
|
+
value?: string;
|
|
31
|
+
/** The name of the input element, used when submitting an HTML form. */
|
|
32
|
+
name?: string;
|
|
33
|
+
/** The form to associate the input with. */
|
|
34
|
+
form?: string;
|
|
35
|
+
/** Whether the element is disabled. */
|
|
36
|
+
isDisabled?: boolean;
|
|
37
|
+
/** Whether the element is read only. */
|
|
38
|
+
isReadOnly?: boolean;
|
|
39
|
+
/** Whether the element is required. */
|
|
40
|
+
isRequired?: boolean;
|
|
41
|
+
/** Whether the element is invalid. */
|
|
42
|
+
isInvalid?: boolean;
|
|
43
|
+
/** The element's children. */
|
|
44
|
+
children?: JSX.Element;
|
|
45
|
+
/** Defines a string value that labels the current element. */
|
|
46
|
+
'aria-label'?: string;
|
|
47
|
+
/** Identifies the element (or elements) that labels the current element. */
|
|
48
|
+
'aria-labelledby'?: string;
|
|
49
|
+
/** Identifies the element (or elements) that describes the object. */
|
|
50
|
+
'aria-describedby'?: string;
|
|
51
|
+
/** Identifies the element (or elements) that provide an error message for the object. */
|
|
52
|
+
'aria-errormessage'?: string;
|
|
53
|
+
/** Identifies the element (or elements) whose contents or presence are controlled by the current element. */
|
|
54
|
+
'aria-controls'?: string;
|
|
55
|
+
/** The element's unique identifier. */
|
|
56
|
+
id?: string;
|
|
57
|
+
/** Handler that is called when the press is released over the target. */
|
|
58
|
+
onPress?: (e: PressEvent) => void;
|
|
59
|
+
/** Handler that is called when a press interaction starts. */
|
|
60
|
+
onPressStart?: (e: PressEvent) => void;
|
|
61
|
+
/** Handler that is called when a press interaction ends, either over the target or when the pointer leaves the target. */
|
|
62
|
+
onPressEnd?: (e: PressEvent) => void;
|
|
63
|
+
/** Handler that is called when the press state changes. */
|
|
64
|
+
onPressChange?: (isPressed: boolean) => void;
|
|
65
|
+
/** Handler that is called when a press is released over the target, regardless of whether it started on the target or not. */
|
|
66
|
+
onPressUp?: (e: PressEvent) => void;
|
|
67
|
+
/** Handler that is called when the element is clicked. */
|
|
68
|
+
onClick?: (e: MouseEvent) => void;
|
|
69
|
+
/** Handler that is called when the element receives focus. */
|
|
70
|
+
onFocus?: (e: FocusEvent) => void;
|
|
71
|
+
/** Handler that is called when the element loses focus. */
|
|
72
|
+
onBlur?: (e: FocusEvent) => void;
|
|
73
|
+
/** Handler that is called when the element's focus status changes. */
|
|
74
|
+
onFocusChange?: (isFocused: boolean) => void;
|
|
75
|
+
/** Handler that is called when a key is pressed. */
|
|
76
|
+
onKeyDown?: (e: KeyboardEvent) => void;
|
|
77
|
+
/** Handler that is called when a key is released. */
|
|
78
|
+
onKeyUp?: (e: KeyboardEvent) => void;
|
|
79
|
+
/** Whether to exclude the element from the tab order. */
|
|
80
|
+
excludeFromTabOrder?: boolean;
|
|
81
|
+
/** Whether to autofocus the element. */
|
|
82
|
+
autoFocus?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ToggleAria {
|
|
86
|
+
/** Props to be spread on the label element. */
|
|
87
|
+
labelProps: JSX.LabelHTMLAttributes<HTMLLabelElement>;
|
|
88
|
+
/** Props to be spread on the input element. */
|
|
89
|
+
inputProps: JSX.InputHTMLAttributes<HTMLInputElement>;
|
|
90
|
+
/** Whether the toggle is selected. */
|
|
91
|
+
isSelected: Accessor<boolean>;
|
|
92
|
+
/** Whether the toggle is in a pressed state. */
|
|
93
|
+
isPressed: Accessor<boolean>;
|
|
94
|
+
/** Whether the toggle is disabled. */
|
|
95
|
+
isDisabled: boolean;
|
|
96
|
+
/** Whether the toggle is read only. */
|
|
97
|
+
isReadOnly: boolean;
|
|
98
|
+
/** Whether the toggle is invalid. */
|
|
99
|
+
isInvalid: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============================================
|
|
103
|
+
// IMPLEMENTATION
|
|
104
|
+
// ============================================
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handles interactions for toggle elements, e.g. Checkboxes and Switches.
|
|
108
|
+
*/
|
|
109
|
+
export function createToggle(
|
|
110
|
+
props: MaybeAccessor<AriaToggleProps>,
|
|
111
|
+
state: ToggleState,
|
|
112
|
+
ref: () => HTMLInputElement | null
|
|
113
|
+
): ToggleAria {
|
|
114
|
+
const getProps = () => access(props);
|
|
115
|
+
|
|
116
|
+
const isDisabled = () => getProps().isDisabled ?? false;
|
|
117
|
+
const isReadOnly = () => getProps().isReadOnly ?? false;
|
|
118
|
+
const isInvalid = () => {
|
|
119
|
+
return getProps().isInvalid ?? false;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Handle press state for keyboard interactions and cases where labelProps is not used.
|
|
123
|
+
const { pressProps, isPressed } = createPress({
|
|
124
|
+
get onPressStart() { return getProps().onPressStart; },
|
|
125
|
+
get onPressEnd() { return getProps().onPressEnd; },
|
|
126
|
+
get onPressChange() { return getProps().onPressChange; },
|
|
127
|
+
get onPress() { return getProps().onPress; },
|
|
128
|
+
get onPressUp() { return getProps().onPressUp; },
|
|
129
|
+
get isDisabled() { return isDisabled(); },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Handle press state on the label.
|
|
133
|
+
const { pressProps: labelPressProps, isPressed: isLabelPressed } = createPress({
|
|
134
|
+
get onPressStart() { return getProps().onPressStart; },
|
|
135
|
+
get onPressEnd() { return getProps().onPressEnd; },
|
|
136
|
+
get onPressChange() { return getProps().onPressChange; },
|
|
137
|
+
get onPressUp() { return getProps().onPressUp; },
|
|
138
|
+
onPress(e: PressEvent) {
|
|
139
|
+
getProps().onPress?.(e);
|
|
140
|
+
state.toggle();
|
|
141
|
+
ref()?.focus();
|
|
142
|
+
},
|
|
143
|
+
get isDisabled() { return isDisabled() || isReadOnly(); },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Handle focusable - extract the relevant props for createFocusable
|
|
147
|
+
const { focusableProps } = createFocusable({
|
|
148
|
+
get isDisabled() { return isDisabled(); },
|
|
149
|
+
get autoFocus() { return getProps().autoFocus; },
|
|
150
|
+
get onFocus() { return getProps().onFocus; },
|
|
151
|
+
get onBlur() { return getProps().onBlur; },
|
|
152
|
+
get onFocusChange() { return getProps().onFocusChange; },
|
|
153
|
+
get onKeyDown() { return getProps().onKeyDown; },
|
|
154
|
+
get onKeyUp() { return getProps().onKeyUp; },
|
|
155
|
+
get excludeFromTabOrder() { return getProps().excludeFromTabOrder; },
|
|
156
|
+
}, ref as unknown as (el: HTMLElement) => void);
|
|
157
|
+
|
|
158
|
+
// Combine press and focusable props for input
|
|
159
|
+
const interactions = mergeProps(pressProps, focusableProps);
|
|
160
|
+
|
|
161
|
+
// Filter DOM props
|
|
162
|
+
const domProps = () => filterDOMProps(getProps() as unknown as Record<string, unknown>, { labelable: true });
|
|
163
|
+
|
|
164
|
+
// Handle input change
|
|
165
|
+
const onChange: JSX.EventHandler<HTMLInputElement, Event> = (e) => {
|
|
166
|
+
// Since we spread props on label, onChange will end up there as well as in here.
|
|
167
|
+
// So we have to stop propagation at the lowest level that we care about
|
|
168
|
+
e.stopPropagation();
|
|
169
|
+
|
|
170
|
+
// Don't update state if readonly
|
|
171
|
+
if (isReadOnly()) {
|
|
172
|
+
// Reset the checkbox to its previous state since the browser already toggled it
|
|
173
|
+
e.currentTarget.checked = state.isSelected();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
state.setSelected(e.currentTarget.checked);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Warn if no accessible label
|
|
181
|
+
createEffect(() => {
|
|
182
|
+
const p = getProps();
|
|
183
|
+
const hasChildren = p.children != null;
|
|
184
|
+
const hasAriaLabel = p['aria-label'] != null || p['aria-labelledby'] != null;
|
|
185
|
+
if (!hasChildren && !hasAriaLabel && process.env.NODE_ENV !== 'production') {
|
|
186
|
+
console.warn('If you do not provide children, you must specify an aria-label for accessibility');
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Combined pressed state
|
|
191
|
+
const combinedIsPressed: Accessor<boolean> = () => isPressed() || isLabelPressed();
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
labelProps: mergeProps(labelPressProps, {
|
|
195
|
+
onClick: (e: MouseEvent) => e.preventDefault(),
|
|
196
|
+
}),
|
|
197
|
+
get inputProps() {
|
|
198
|
+
const p = getProps();
|
|
199
|
+
return mergeProps(domProps(), {
|
|
200
|
+
'aria-invalid': isInvalid() || undefined,
|
|
201
|
+
'aria-errormessage': p['aria-errormessage'],
|
|
202
|
+
'aria-controls': p['aria-controls'],
|
|
203
|
+
'aria-readonly': isReadOnly() || undefined,
|
|
204
|
+
onChange,
|
|
205
|
+
disabled: isDisabled(),
|
|
206
|
+
...(p.value == null ? {} : { value: p.value }),
|
|
207
|
+
name: p.name,
|
|
208
|
+
form: p.form,
|
|
209
|
+
type: 'checkbox' as const,
|
|
210
|
+
...interactions,
|
|
211
|
+
// Stop click propagation to prevent labelProps.onClick from calling preventDefault
|
|
212
|
+
// which would prevent the checkbox from toggling in JSDOM/testing-library environments
|
|
213
|
+
onClick: (e: MouseEvent) => e.stopPropagation(),
|
|
214
|
+
}) as JSX.InputHTMLAttributes<HTMLInputElement>;
|
|
215
|
+
},
|
|
216
|
+
isSelected: state.isSelected,
|
|
217
|
+
isPressed: combinedIsPressed,
|
|
218
|
+
isDisabled: isDisabled(),
|
|
219
|
+
isReadOnly: isReadOnly(),
|
|
220
|
+
isInvalid: isInvalid(),
|
|
221
|
+
};
|
|
222
|
+
}
|