@consumidor-positivo/aurora 1.2.0 → 1.3.0
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/README.md +59 -3
- package/astro/AdBox/index.astro +28 -0
- package/astro/Alert/index.astro +257 -0
- package/astro/BadgeInfo/index.astro +55 -0
- package/astro/BadgeState/index.astro +79 -0
- package/astro/Button/index.astro +11 -3
- package/astro/Card/Container/index.astro +44 -0
- package/astro/Card/Emphasis/index.astro +39 -0
- package/astro/Card/Image/index.astro +26 -0
- package/astro/Card/Root/index.astro +53 -0
- package/astro/Card/Tag/index.astro +26 -0
- package/astro/Chip/index.astro +48 -0
- package/astro/ChipBanner/index.astro +160 -0
- package/astro/Container/index.astro +16 -0
- package/astro/Divider/index.astro +27 -0
- package/astro/Image/index.astro +26 -0
- package/astro/LazyImage/index.astro +55 -0
- package/astro/LinkButton/index.astro +54 -0
- package/astro/Logo/ac/BadgeFullWhite.astro +20 -0
- package/astro/Logo/ac/BadgeWhite.astro +23 -0
- package/astro/Logo/ac/BadgeWhiteStroke.astro +27 -0
- package/astro/Logo/ac/BadgeWhiteStrokeDefault.astro +28 -0
- package/astro/Logo/ac/Badget.astro +24 -0
- package/astro/Logo/ac/Primary.astro +28 -0
- package/astro/Logo/ac/PrimaryFillWhite.astro +28 -0
- package/astro/Logo/ac/PrimaryFullWhite.astro +20 -0
- package/astro/Logo/ac/PrimaryNegative.astro +20 -0
- package/astro/Logo/ac/PrimaryWhite.astro +20 -0
- package/astro/Logo/ac/TertiaryWhite.astro +72 -0
- package/astro/Logo/cp/BadgeWhiteStroke.astro +10 -0
- package/astro/Logo/cp/Badget.astro +14 -0
- package/astro/Logo/cp/PrimaryFillWhite.astro +63 -0
- package/astro/Logo/cp/PrimaryFullWhite.astro +27 -0
- package/astro/Logo/cp/PrimaryLogoWhite.astro +67 -0
- package/astro/Logo/cp/PrimaryNegative.astro +27 -0
- package/astro/Logo/cp/PrimaryWhite.astro +27 -0
- package/astro/Modal/index.astro +213 -0
- package/astro/NotificationsBar/Link/index.astro +70 -0
- package/astro/NotificationsBar/List/index.astro +38 -0
- package/astro/NotificationsBar/index.astro +24 -0
- package/astro/PartnerBanner/index.astro +52 -0
- package/astro/ProfileNav/index.astro +34 -0
- package/astro/ProgressBar/index.astro +70 -0
- package/astro/Skeleton/index.astro +50 -0
- package/astro/SpecialButton/index.astro +331 -0
- package/astro/Spinner/index.astro +75 -0
- package/astro/SubHeader/index.astro +57 -0
- package/astro/Switch/Card/index.astro +69 -0
- package/astro/Switch/Pure/index.astro +47 -0
- package/astro/Tooltip/index.astro +38 -0
- package/astro/form/Checkbox/Field/index.astro +68 -0
- package/astro/form/Checkbox/Group/index.astro +79 -0
- package/astro/form/Datepicker/Calendar/index.astro +53 -0
- package/astro/form/Datepicker/CalendarHeader/index.astro +57 -0
- package/astro/form/Datepicker/Segment/index.astro +53 -0
- package/astro/form/Datepicker/index.astro +756 -0
- package/astro/form/EmailField/index.astro +237 -0
- package/astro/form/Field/ErrorMessage/index.astro +20 -0
- package/astro/form/Field/Input/index.astro +15 -0
- package/astro/form/Field/InputHolder/index.astro +28 -0
- package/astro/form/Field/Label/index.astro +80 -0
- package/astro/form/Field/Message/index.astro +29 -0
- package/astro/form/Field/Root/index.astro +37 -0
- package/astro/form/Field/TextArea/index.astro +69 -0
- package/astro/form/InputField/index.astro +96 -0
- package/astro/form/PasswordField/index.astro +123 -0
- package/astro/form/Radio/Field/index.astro +52 -0
- package/astro/form/Radio/Group/index.astro +78 -0
- package/astro/form/SelectField/index.astro +561 -0
- package/astro/form/TextareaField/index.astro +81 -0
- package/astro/form/TokenField/index.astro +211 -0
- package/astro/tokens/package.json +6 -0
- package/dist/{tokens-C4ypB4bY.js → astro/tokens/index.es.js} +101 -101
- package/dist/astro/tokens/index.es.js.map +1 -0
- package/dist/components/Alert/index.es.js +1 -1
- package/dist/components/BadgeInfo/index.es.js +1 -1
- package/dist/components/BadgeState/index.es.js +1 -1
- package/dist/components/Image/index.es.js +1 -1
- package/dist/components/Spinner/index.es.js +1 -1
- package/dist/components/form/Checkbox/Field/index.es.js +1 -1
- package/dist/components/form/Datepicker/Calendar/index.es.js +2 -2
- package/dist/components/form/Datepicker/CalendarHeader/index.es.js +2 -2
- package/dist/components/form/Datepicker/PortalHolder/index.es.js +1 -1
- package/dist/components/form/Datepicker/Segment/index.es.js +1 -1
- package/dist/components/form/Datepicker/index.es.js +3 -3
- package/dist/components/form/Field/Label/index.es.js +1 -1
- package/dist/components/form/SelectField/index.es.js +1 -1
- package/dist/{index-BcC9liUl.js → index-Bkl5OBIY.js} +3 -3
- package/dist/{index-BcC9liUl.js.map → index-Bkl5OBIY.js.map} +1 -1
- package/dist/{index-CFYmtkje.js → index-CZfMkAoF.js} +2 -2
- package/dist/{index-CFYmtkje.js.map → index-CZfMkAoF.js.map} +1 -1
- package/dist/{index-CeClo_WS.js → index-Cw1KtRQr.js} +2 -2
- package/dist/{index-CeClo_WS.js.map → index-Cw1KtRQr.js.map} +1 -1
- package/dist/main.es.js +101 -101
- package/package.json +7 -1
- package/dist/tokens-C4ypB4bY.js.map +0 -1
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types'
|
|
3
|
+
import FieldRoot from '../Field/Root/index.astro'
|
|
4
|
+
import FieldLabel from '../Field/Label/index.astro'
|
|
5
|
+
import FieldMessage from '../Field/Message/index.astro'
|
|
6
|
+
import Modal from '../../Modal/index.astro'
|
|
7
|
+
import Text from '../../Text/index.astro'
|
|
8
|
+
import IconCheck from '../../icons/default/IconCheck.astro'
|
|
9
|
+
import IconChevronDown from '../../icons/default/IconChevronDown.astro'
|
|
10
|
+
import IconSearch from '../../icons/default/IconSearch.astro'
|
|
11
|
+
import IconSlash from '../../icons/default/IconSlash.astro'
|
|
12
|
+
import { COLOR_NEUTRAL_40 } from '@consumidor-positivo/aurora/astro/tokens'
|
|
13
|
+
import '../../../dist/components/SelectField/styles.css'
|
|
14
|
+
|
|
15
|
+
export type SelectOption = {
|
|
16
|
+
value: string
|
|
17
|
+
label: string
|
|
18
|
+
disabled?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Same props and markup as the React `SelectField`, which is hand-written
|
|
23
|
+
* (combobox wrapper, input, listbox, hidden `<select>` for the form). The
|
|
24
|
+
* inline controller follows the React hook step by step: toggle on click,
|
|
25
|
+
* ArrowUp/ArrowDown skipping disabled options, Enter, Escape, the label filter
|
|
26
|
+
* with `autocomplete`, the highlighted option that follows the mouse, the
|
|
27
|
+
* dropdown height computed from the space below, the outside click that closes
|
|
28
|
+
* the list, and the 500ms delay before closing after a pick.
|
|
29
|
+
*
|
|
30
|
+
* Differences from React: `value` is the initial selection (change it through
|
|
31
|
+
* the hidden `<select>` or a pick); `onChange` becomes `au:selectchange`
|
|
32
|
+
* (`detail.value`, fired on a pick and on every keystroke with `autocomplete`,
|
|
33
|
+
* like the React callback) and `onBlur` becomes `au:selectblur`
|
|
34
|
+
* (`detail.target`, the option React hands back), both bubbling from the
|
|
35
|
+
* `au-field` root 200ms after the blur like React. `register` has no
|
|
36
|
+
* counterpart: the `<select hidden name>` is in the form either way. With
|
|
37
|
+
* `fullScreenOptions`, the options list opens in a `Modal` on mobile
|
|
38
|
+
* (`isMobile()`, max-width 767px, decided when the controller runs, as React
|
|
39
|
+
* decides on render); the modal rides at the end of the markup and moves
|
|
40
|
+
* itself to `document.body`, like the React portal. `class` and `style` go on
|
|
41
|
+
* the `au-field` root (and `class` also on `au-field__select`, as React does);
|
|
42
|
+
* `data-testid` goes on the combobox wrapper; other HTML attributes land on
|
|
43
|
+
* the root.
|
|
44
|
+
*/
|
|
45
|
+
export type Props = Omit<HTMLAttributes<'div'>, 'style' | 'placeholder'> & {
|
|
46
|
+
label?: string
|
|
47
|
+
options: SelectOption[]
|
|
48
|
+
showOptionalLabel?: boolean
|
|
49
|
+
disabled?: boolean
|
|
50
|
+
required?: boolean
|
|
51
|
+
error?: boolean
|
|
52
|
+
errorMessage?: string
|
|
53
|
+
helpMessage?: string
|
|
54
|
+
placeholder?: string
|
|
55
|
+
/** Initial selected value. */
|
|
56
|
+
value?: string
|
|
57
|
+
/** Name of the hidden `<select>` submitted with the form. */
|
|
58
|
+
name?: string
|
|
59
|
+
/** Lets the user type to filter the options by label. */
|
|
60
|
+
autocomplete?: boolean
|
|
61
|
+
/** Text of the single option shown when the filter matches nothing. */
|
|
62
|
+
EmptyText?: string
|
|
63
|
+
/** On mobile, opens the options list in a full-screen modal. Not mapped in Figma. */
|
|
64
|
+
fullScreenOptions?: boolean
|
|
65
|
+
htmlType?: 'text' | 'number'
|
|
66
|
+
style?: HTMLAttributes<'div'>['style']
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const uid = crypto.randomUUID().slice(0, 8)
|
|
70
|
+
|
|
71
|
+
const {
|
|
72
|
+
label,
|
|
73
|
+
options,
|
|
74
|
+
showOptionalLabel,
|
|
75
|
+
error = false,
|
|
76
|
+
errorMessage,
|
|
77
|
+
helpMessage,
|
|
78
|
+
disabled = false,
|
|
79
|
+
required = false,
|
|
80
|
+
value,
|
|
81
|
+
style,
|
|
82
|
+
class: className,
|
|
83
|
+
placeholder,
|
|
84
|
+
name,
|
|
85
|
+
autocomplete = false,
|
|
86
|
+
EmptyText = 'Nada encontrado',
|
|
87
|
+
fullScreenOptions = false,
|
|
88
|
+
htmlType = 'text',
|
|
89
|
+
'data-testid': dataTestId,
|
|
90
|
+
...rest
|
|
91
|
+
} = Astro.props
|
|
92
|
+
|
|
93
|
+
// Stand in for the React `useId()` calls.
|
|
94
|
+
const labelId = `au-select-${uid}-label`
|
|
95
|
+
const errorId = error && errorMessage ? `au-select-${uid}-error` : undefined
|
|
96
|
+
const modalId = `au-select-${uid}-modal`
|
|
97
|
+
|
|
98
|
+
// Same initial state as the React hook.
|
|
99
|
+
const selected = {
|
|
100
|
+
value: value || '',
|
|
101
|
+
label: value ? options.find((option) => option.value === value)?.label || '' : '',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const optionClasses = (option: SelectOption, prefix: string) => [
|
|
105
|
+
prefix,
|
|
106
|
+
option.value === selected.value && `${prefix}--selected`,
|
|
107
|
+
option.disabled && `${prefix}--disabled`,
|
|
108
|
+
]
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
<FieldRoot
|
|
112
|
+
style={style}
|
|
113
|
+
class={className}
|
|
114
|
+
error={error}
|
|
115
|
+
disabled={disabled}
|
|
116
|
+
data-element="au-select-field"
|
|
117
|
+
data-au-select-autocomplete={autocomplete ? '' : undefined}
|
|
118
|
+
data-au-select-fullscreen={fullScreenOptions ? modalId : undefined}
|
|
119
|
+
data-au-select-empty-text={EmptyText}
|
|
120
|
+
{...rest}>
|
|
121
|
+
<FieldLabel
|
|
122
|
+
text={label}
|
|
123
|
+
labelId={labelId}
|
|
124
|
+
showOptionalLabel={showOptionalLabel}
|
|
125
|
+
required={required}
|
|
126
|
+
error={error}
|
|
127
|
+
disabled={disabled}
|
|
128
|
+
/>
|
|
129
|
+
<div
|
|
130
|
+
class:list={[
|
|
131
|
+
'au-field__select',
|
|
132
|
+
disabled && 'au-field__select--disabled',
|
|
133
|
+
required && 'au-field__select--required',
|
|
134
|
+
error && 'au-field__select--error',
|
|
135
|
+
className,
|
|
136
|
+
]}>
|
|
137
|
+
<div
|
|
138
|
+
class="au-field__select-wrapper"
|
|
139
|
+
data-testid={dataTestId}
|
|
140
|
+
tabindex={disabled ? -1 : 0}
|
|
141
|
+
role="combobox"
|
|
142
|
+
aria-haspopup="listbox"
|
|
143
|
+
aria-expanded="false"
|
|
144
|
+
aria-labelledby={labelId}
|
|
145
|
+
aria-describedby={errorId}>
|
|
146
|
+
<input
|
|
147
|
+
class="au-field__select-input"
|
|
148
|
+
value={selected.label}
|
|
149
|
+
placeholder={placeholder || 'Selecionar...'}
|
|
150
|
+
readonly={!autocomplete}
|
|
151
|
+
disabled={disabled}
|
|
152
|
+
type={htmlType}
|
|
153
|
+
aria-labelledby={labelId}
|
|
154
|
+
/>
|
|
155
|
+
<div class="au-field__select-icon" aria-hidden="true">
|
|
156
|
+
<IconChevronDown />
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<ul
|
|
160
|
+
class="au-field__select-options"
|
|
161
|
+
role="listbox"
|
|
162
|
+
aria-live="polite"
|
|
163
|
+
tabindex="-1"
|
|
164
|
+
style="max-height: 0px; overflow-y: auto">
|
|
165
|
+
{
|
|
166
|
+
options.length === 0 ? (
|
|
167
|
+
<li class="au-field__select-option au-field__select-option--empty">{EmptyText}</li>
|
|
168
|
+
) : (
|
|
169
|
+
options.map((option) => (
|
|
170
|
+
<li
|
|
171
|
+
class:list={optionClasses(option, 'au-field__select-option')}
|
|
172
|
+
role="option"
|
|
173
|
+
aria-selected={option.value === selected.value ? 'true' : 'false'}
|
|
174
|
+
aria-disabled={option.disabled ? 'true' : undefined}>
|
|
175
|
+
{option.label}
|
|
176
|
+
{option.disabled ? <IconSlash /> : option.value === selected.value ? <IconCheck /> : null}
|
|
177
|
+
</li>
|
|
178
|
+
))
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
</ul>
|
|
182
|
+
<select hidden disabled={disabled} name={name}>
|
|
183
|
+
{options.map((option) => (
|
|
184
|
+
<option value={option.value} selected={option.value === selected.value}>
|
|
185
|
+
{option.label}
|
|
186
|
+
</option>
|
|
187
|
+
))}
|
|
188
|
+
</select>
|
|
189
|
+
</div>
|
|
190
|
+
<FieldMessage
|
|
191
|
+
hasError={error}
|
|
192
|
+
errorMessage={errorMessage}
|
|
193
|
+
helpMessage={helpMessage}
|
|
194
|
+
errorId={errorId}
|
|
195
|
+
/>
|
|
196
|
+
<template data-au-select-icons><IconSlash /><IconCheck /></template>
|
|
197
|
+
</FieldRoot>
|
|
198
|
+
{
|
|
199
|
+
fullScreenOptions && (
|
|
200
|
+
<Modal id={modalId} portal>
|
|
201
|
+
<Fragment slot="header">
|
|
202
|
+
<div>
|
|
203
|
+
<Text as="label" variant="heading-small" weight="bold">
|
|
204
|
+
{label}
|
|
205
|
+
</Text>
|
|
206
|
+
<div
|
|
207
|
+
class="au-field__fullscreen-options-select-search-bar"
|
|
208
|
+
tabindex={disabled ? -1 : 0}
|
|
209
|
+
role="combobox"
|
|
210
|
+
aria-haspopup="listbox"
|
|
211
|
+
aria-expanded="false"
|
|
212
|
+
aria-labelledby={labelId}>
|
|
213
|
+
<input
|
|
214
|
+
class="au-field__select-input"
|
|
215
|
+
value={selected.label}
|
|
216
|
+
placeholder="Buscar"
|
|
217
|
+
disabled={disabled}
|
|
218
|
+
type={htmlType}
|
|
219
|
+
aria-label="Buscar"
|
|
220
|
+
/>
|
|
221
|
+
<div class="au-field__select-icon" aria-hidden="true">
|
|
222
|
+
<IconSearch rawColor={COLOR_NEUTRAL_40} />
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</Fragment>
|
|
227
|
+
<div class="au-field__fullscreen-options-select-options" role="listbox">
|
|
228
|
+
{options.map((option) => (
|
|
229
|
+
<li
|
|
230
|
+
class:list={optionClasses(option, 'au-field__fullscreen-options-select-option')}
|
|
231
|
+
role="option"
|
|
232
|
+
aria-selected={option.value === selected.value ? 'true' : 'false'}
|
|
233
|
+
aria-disabled={option.disabled ? 'true' : undefined}>
|
|
234
|
+
{option.label}
|
|
235
|
+
{option.disabled ? <IconSlash /> : option.value === selected.value ? <IconCheck /> : null}
|
|
236
|
+
</li>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
</Modal>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
<script>
|
|
244
|
+
import { elementController } from '@consumidor-positivo/aurora/astro/runtime'
|
|
245
|
+
|
|
246
|
+
type Option = { value: string; label: string; disabled?: boolean }
|
|
247
|
+
|
|
248
|
+
const CLOSE_DELAY_MS = 500
|
|
249
|
+
const BLUR_DELAY_MS = 200
|
|
250
|
+
const MIN_DROPDOWN_HEIGHT = 212
|
|
251
|
+
// Same rule as `isMobile()` (lib/core/utils/isMobile.ts).
|
|
252
|
+
const MOBILE_QUERY = 'screen and (max-width:767px)'
|
|
253
|
+
const OPTION_CLASS = 'au-field__select-option'
|
|
254
|
+
const FULLSCREEN_OPTION_CLASS = 'au-field__fullscreen-options-select-option'
|
|
255
|
+
|
|
256
|
+
elementController('au-select-field', ({ root, query, on, emit }) => {
|
|
257
|
+
const selectBox = query<HTMLDivElement>('.au-field__select')
|
|
258
|
+
const wrapperBox = query<HTMLDivElement>('.au-field__select-wrapper')
|
|
259
|
+
const inputEl = query<HTMLInputElement>('.au-field__select-wrapper > .au-field__select-input')
|
|
260
|
+
const listboxEl = query<HTMLUListElement>('.au-field__select-options')
|
|
261
|
+
const nativeEl = query<HTMLSelectElement>('select[hidden]')
|
|
262
|
+
const template = query<HTMLTemplateElement>('template[data-au-select-icons]')
|
|
263
|
+
if (!selectBox || !wrapperBox || !inputEl || !listboxEl || !nativeEl || !template) return
|
|
264
|
+
const select = selectBox as HTMLDivElement
|
|
265
|
+
const wrapper = wrapperBox as HTMLDivElement
|
|
266
|
+
const input = inputEl as HTMLInputElement
|
|
267
|
+
const listbox = listboxEl as HTMLUListElement
|
|
268
|
+
const nativeSelect = nativeEl as HTMLSelectElement
|
|
269
|
+
|
|
270
|
+
// The icons React renders inside an option ride in a template, taken out
|
|
271
|
+
// of the page here.
|
|
272
|
+
const [slashIcon, checkIcon] = Array.from(template.content.children)
|
|
273
|
+
template.remove()
|
|
274
|
+
|
|
275
|
+
// Controller settings only; React's root carries no such attributes.
|
|
276
|
+
const autocomplete = root.hasAttribute('data-au-select-autocomplete')
|
|
277
|
+
const modalId = root.dataset.auSelectFullscreen
|
|
278
|
+
const emptyText = root.dataset.auSelectEmptyText ?? 'Nada encontrado'
|
|
279
|
+
root.removeAttribute('data-au-select-autocomplete')
|
|
280
|
+
root.removeAttribute('data-au-select-fullscreen')
|
|
281
|
+
root.removeAttribute('data-au-select-empty-text')
|
|
282
|
+
|
|
283
|
+
const fullScreenOptions = !!modalId
|
|
284
|
+
// React reads `isMobile()` on render; the same media query, once, here.
|
|
285
|
+
const fullScreenEnabled = fullScreenOptions && window.matchMedia(MOBILE_QUERY).matches
|
|
286
|
+
const disabled = input.disabled
|
|
287
|
+
|
|
288
|
+
// The options come from the markup: value and label from the hidden
|
|
289
|
+
// `<select>`, `disabled` from the listbox items (both share the order).
|
|
290
|
+
const items = Array.from(listbox.querySelectorAll<HTMLLIElement>(`.${OPTION_CLASS}`))
|
|
291
|
+
const options: Option[] = Array.from(nativeSelect.options).map((option, index) => ({
|
|
292
|
+
value: option.value,
|
|
293
|
+
label: option.label,
|
|
294
|
+
disabled: items[index]?.getAttribute('aria-disabled') === 'true' || undefined,
|
|
295
|
+
}))
|
|
296
|
+
|
|
297
|
+
// React state. The initial selection comes from the markup (the item
|
|
298
|
+
// rendered with `--selected`): a `<select>` with no `selected` option
|
|
299
|
+
// reports its first option as the value, which React does not treat as a
|
|
300
|
+
// selection either.
|
|
301
|
+
const selectedIndex = items.findIndex((item) => item.classList.contains(`${OPTION_CLASS}--selected`))
|
|
302
|
+
let isOpen = false
|
|
303
|
+
let selected: Option =
|
|
304
|
+
selectedIndex === -1
|
|
305
|
+
? { value: '', label: '' }
|
|
306
|
+
: { value: options[selectedIndex].value, label: options[selectedIndex].label }
|
|
307
|
+
let activeIndex: number | null = null
|
|
308
|
+
let searchValue = ''
|
|
309
|
+
let lastActiveIndex: number | null = null
|
|
310
|
+
|
|
311
|
+
// Full-screen modal parts, resolved once the modal is in the page.
|
|
312
|
+
let modalRoot: HTMLElement | null = null
|
|
313
|
+
let searchBar: HTMLDivElement | null = null
|
|
314
|
+
let searchInput: HTMLInputElement | null = null
|
|
315
|
+
let fullscreenList: HTMLElement | null = null
|
|
316
|
+
|
|
317
|
+
if (fullScreenEnabled) {
|
|
318
|
+
// React renders no `<ul>` in this mode and keeps the input read-only.
|
|
319
|
+
listbox.remove()
|
|
320
|
+
input.readOnly = true
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const filteredOptions = () =>
|
|
324
|
+
autocomplete || fullScreenOptions
|
|
325
|
+
? options.filter((option) => option.label.toLowerCase().includes(searchValue.toLowerCase()))
|
|
326
|
+
: options
|
|
327
|
+
|
|
328
|
+
const comboboxes = () => (searchBar ? [wrapper, searchBar] : [wrapper])
|
|
329
|
+
const reference = () => (fullScreenEnabled && searchBar ? searchBar : wrapper)
|
|
330
|
+
|
|
331
|
+
// React keys the items by index and updates them in place, so a mouse
|
|
332
|
+
// resting over the list does not get a new `mouseenter`. Same here: the
|
|
333
|
+
// `li` elements are reused and only their attributes change.
|
|
334
|
+
const itemIndex = new WeakMap<HTMLLIElement, number>()
|
|
335
|
+
function syncItems(list: HTMLElement, filtered: Option[], prefix: string) {
|
|
336
|
+
const current = Array.from(list.children) as HTMLLIElement[]
|
|
337
|
+
while (current.length > filtered.length) current.pop()!.remove()
|
|
338
|
+
filtered.forEach((option, index) => {
|
|
339
|
+
let item = current[index]
|
|
340
|
+
if (!item || !itemIndex.has(item)) {
|
|
341
|
+
const created = document.createElement('li')
|
|
342
|
+
created.addEventListener('pointerup', () => {
|
|
343
|
+
const target = filteredOptions()[itemIndex.get(created) ?? -1]
|
|
344
|
+
if (target) selectOption(target.value, target.disabled)
|
|
345
|
+
})
|
|
346
|
+
created.addEventListener('mouseenter', () => {
|
|
347
|
+
activeIndex = itemIndex.get(created) ?? null
|
|
348
|
+
render()
|
|
349
|
+
})
|
|
350
|
+
if (item) item.replaceWith(created)
|
|
351
|
+
else list.appendChild(created)
|
|
352
|
+
item = created
|
|
353
|
+
current[index] = item
|
|
354
|
+
}
|
|
355
|
+
itemIndex.set(item, index)
|
|
356
|
+
item.className = [
|
|
357
|
+
prefix,
|
|
358
|
+
activeIndex === index && `${prefix}--highlighted`,
|
|
359
|
+
option.value === selected.value && `${prefix}--selected`,
|
|
360
|
+
option.disabled && `${prefix}--disabled`,
|
|
361
|
+
]
|
|
362
|
+
.filter(Boolean)
|
|
363
|
+
.join(' ')
|
|
364
|
+
item.setAttribute('role', 'option')
|
|
365
|
+
item.setAttribute('aria-selected', String(option.value === selected.value))
|
|
366
|
+
if (option.disabled) item.setAttribute('aria-disabled', 'true')
|
|
367
|
+
else item.removeAttribute('aria-disabled')
|
|
368
|
+
const icon = option.disabled ? slashIcon : option.value === selected.value ? checkIcon : null
|
|
369
|
+
const iconName = icon?.className ?? ''
|
|
370
|
+
const currentIcon = item.querySelector<HTMLElement>('.au-icon')
|
|
371
|
+
if (item.firstChild?.nodeType !== 3) item.prepend(option.label)
|
|
372
|
+
else if (item.firstChild.nodeValue !== option.label) item.firstChild.nodeValue = option.label
|
|
373
|
+
if (currentIcon && currentIcon.className !== iconName) currentIcon.remove()
|
|
374
|
+
if (icon && item.querySelector('.au-icon') === null) item.appendChild(icon.cloneNode(true))
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function render() {
|
|
379
|
+
const filtered = filteredOptions()
|
|
380
|
+
const active = activeIndex !== null ? filtered[activeIndex] : undefined
|
|
381
|
+
|
|
382
|
+
select.classList.toggle('au-field__select--open', isOpen)
|
|
383
|
+
listbox.classList.toggle('au-field__select-options--open', isOpen)
|
|
384
|
+
comboboxes().forEach((box) => {
|
|
385
|
+
box.setAttribute('aria-expanded', String(isOpen))
|
|
386
|
+
if (active) box.setAttribute('aria-activedescendant', active.value)
|
|
387
|
+
else box.removeAttribute('aria-activedescendant')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// React: `value={(!fullScreenOptionsEnabled ? searchValue : null) || selectedOption.label}`
|
|
391
|
+
const inputValue = (!fullScreenEnabled ? searchValue : '') || selected.label
|
|
392
|
+
if (input.value !== inputValue) input.value = inputValue
|
|
393
|
+
if (searchInput) {
|
|
394
|
+
const searchInputValue = searchValue || selected.label
|
|
395
|
+
if (searchInput.value !== searchInputValue) searchInput.value = searchInputValue
|
|
396
|
+
}
|
|
397
|
+
// React's controlled `<select value>` leaves the selection alone when no
|
|
398
|
+
// option matches (an empty value included).
|
|
399
|
+
if (selected.value && nativeSelect.value !== selected.value) nativeSelect.value = selected.value
|
|
400
|
+
|
|
401
|
+
if (listbox.isConnected) {
|
|
402
|
+
if (filtered.length === 0) {
|
|
403
|
+
const empty = document.createElement('li')
|
|
404
|
+
empty.className = `${OPTION_CLASS} ${OPTION_CLASS}--empty`
|
|
405
|
+
empty.textContent = emptyText
|
|
406
|
+
listbox.replaceChildren(empty)
|
|
407
|
+
} else {
|
|
408
|
+
syncItems(listbox, filtered, OPTION_CLASS)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (fullscreenList) syncItems(fullscreenList, filtered, FULLSCREEN_OPTION_CLASS)
|
|
412
|
+
|
|
413
|
+
// The `[activeOptionIndex]` effect: scroll the highlighted option into view.
|
|
414
|
+
if (activeIndex !== lastActiveIndex) {
|
|
415
|
+
lastActiveIndex = activeIndex
|
|
416
|
+
const list: HTMLElement = fullscreenList ?? listbox
|
|
417
|
+
list.querySelector<HTMLElement>('[class*="--highlighted"]')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function setOpen(open: boolean) {
|
|
422
|
+
isOpen = open
|
|
423
|
+
if (fullScreenEnabled && modalId) {
|
|
424
|
+
document.dispatchEvent(
|
|
425
|
+
new CustomEvent('au:modalcontrol', { detail: { id: modalId, open }, bubbles: true }),
|
|
426
|
+
)
|
|
427
|
+
if (open) bindModal()
|
|
428
|
+
}
|
|
429
|
+
render()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function toggleDropdown() {
|
|
433
|
+
if (!isOpen) {
|
|
434
|
+
const box = reference()
|
|
435
|
+
const { bottom } = box.getBoundingClientRect()
|
|
436
|
+
let totalPadding = 0
|
|
437
|
+
let parent: HTMLElement | null = box.parentElement
|
|
438
|
+
while (parent) {
|
|
439
|
+
totalPadding += parseFloat(window.getComputedStyle(parent).paddingBottom || '0')
|
|
440
|
+
parent = parent.parentElement
|
|
441
|
+
}
|
|
442
|
+
const availableSpaceBelow = window.innerHeight - bottom - totalPadding
|
|
443
|
+
listbox.style.maxHeight = `${Math.max(availableSpaceBelow, MIN_DROPDOWN_HEIGHT)}px`
|
|
444
|
+
|
|
445
|
+
const selectedIndex = options.findIndex((option) => option.value === selected.value)
|
|
446
|
+
activeIndex = selectedIndex !== -1 ? selectedIndex : null
|
|
447
|
+
}
|
|
448
|
+
setOpen(!isOpen)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function findNextAvailableIndex(currentIndex: number, direction: 'down' | 'up') {
|
|
452
|
+
const filtered = filteredOptions()
|
|
453
|
+
const step = direction === 'down' ? 1 : -1
|
|
454
|
+
let nextIndex = (currentIndex + step + filtered.length) % filtered.length
|
|
455
|
+
while (filtered[nextIndex]?.disabled) {
|
|
456
|
+
nextIndex = (nextIndex + step + filtered.length) % filtered.length
|
|
457
|
+
}
|
|
458
|
+
return nextIndex
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
462
|
+
if (disabled) return
|
|
463
|
+
const filtered = filteredOptions()
|
|
464
|
+
const actions: Record<string, () => void> = {
|
|
465
|
+
Enter: () => {
|
|
466
|
+
if (!isOpen) {
|
|
467
|
+
toggleDropdown()
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
if (activeIndex !== null && !filtered[activeIndex].disabled) {
|
|
471
|
+
event.preventDefault()
|
|
472
|
+
selectOption(filtered[activeIndex].value, filtered[activeIndex].disabled)
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
ArrowDown: () => {
|
|
476
|
+
event.preventDefault()
|
|
477
|
+
activeIndex = findNextAvailableIndex(activeIndex !== null ? activeIndex : -1, 'down')
|
|
478
|
+
if (!isOpen) toggleDropdown()
|
|
479
|
+
else render()
|
|
480
|
+
},
|
|
481
|
+
ArrowUp: () => {
|
|
482
|
+
event.preventDefault()
|
|
483
|
+
activeIndex = findNextAvailableIndex(activeIndex !== null ? activeIndex : filtered.length, 'up')
|
|
484
|
+
if (!isOpen) toggleDropdown()
|
|
485
|
+
else render()
|
|
486
|
+
},
|
|
487
|
+
Escape: () => setOpen(false),
|
|
488
|
+
}
|
|
489
|
+
actions[event.key]?.()
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function selectOption(optionValue: string, optionDisabled?: boolean) {
|
|
493
|
+
if (optionDisabled) return
|
|
494
|
+
const option = options.find((candidate) => candidate.value === optionValue)
|
|
495
|
+
if (option) selected = { value: option.value, label: option.label }
|
|
496
|
+
activeIndex = null
|
|
497
|
+
searchValue = ''
|
|
498
|
+
render()
|
|
499
|
+
window.setTimeout(() => setOpen(false), CLOSE_DELAY_MS)
|
|
500
|
+
emit('au:selectchange', { value: optionValue })
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function onInput(event: Event) {
|
|
504
|
+
const value = (event.target as HTMLInputElement).value
|
|
505
|
+
searchValue = value
|
|
506
|
+
emit('au:selectchange', { value })
|
|
507
|
+
if (value === '') selected = { value: '', label: '' }
|
|
508
|
+
if (!isOpen) setOpen(true)
|
|
509
|
+
else render()
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function onBlur(event: FocusEvent) {
|
|
513
|
+
const target = event.target as HTMLInputElement | null
|
|
514
|
+
window.setTimeout(() => {
|
|
515
|
+
const elementValue = target?.value ?? ''
|
|
516
|
+
const fromLabel = options.find((option) => option.label === elementValue)
|
|
517
|
+
const customValue = fullScreenEnabled ? { value: '', label: '' } : { value: elementValue, label: '' }
|
|
518
|
+
emit('au:selectblur', { target: selected.value ? selected : fromLabel || customValue })
|
|
519
|
+
}, BLUR_DELAY_MS)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function bindModal() {
|
|
523
|
+
if (modalRoot || !modalId) return
|
|
524
|
+
modalRoot = document.getElementById(modalId)
|
|
525
|
+
if (!modalRoot) return
|
|
526
|
+
searchBar = modalRoot.querySelector<HTMLDivElement>('.au-field__fullscreen-options-select-search-bar')
|
|
527
|
+
searchInput = searchBar?.querySelector<HTMLInputElement>('.au-field__select-input') ?? null
|
|
528
|
+
fullscreenList = modalRoot.querySelector<HTMLElement>('.au-field__fullscreen-options-select-options')
|
|
529
|
+
searchBar?.addEventListener('keydown', onKeyDown)
|
|
530
|
+
searchBar?.addEventListener('focusout', onBlur)
|
|
531
|
+
searchInput?.addEventListener('input', onInput)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
on('click', (event) => {
|
|
535
|
+
if ((event.target as Element | null)?.closest('.au-field__select-wrapper')) toggleDropdown()
|
|
536
|
+
})
|
|
537
|
+
wrapper.addEventListener('keydown', onKeyDown)
|
|
538
|
+
wrapper.addEventListener('focusout', onBlur)
|
|
539
|
+
input.addEventListener('input', onInput)
|
|
540
|
+
nativeSelect.addEventListener('change', () => selectOption(nativeSelect.value))
|
|
541
|
+
|
|
542
|
+
// React: `document.addEventListener('pointerup', ...)` while open, closing
|
|
543
|
+
// only when an option is highlighted.
|
|
544
|
+
on('global:pointerup', (event) => {
|
|
545
|
+
if (!isOpen) return
|
|
546
|
+
const clickedOutside = !reference().contains(event.target as Node) && activeIndex !== null
|
|
547
|
+
if (clickedOutside) setOpen(false)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
// The modal's own X (React `onClose={onCloseOptions}`).
|
|
551
|
+
on('global:au:modalclose', (event) => {
|
|
552
|
+
const detail = (event as CustomEvent<{ id?: string }>).detail
|
|
553
|
+
if (fullScreenEnabled && detail?.id === modalId && isOpen) {
|
|
554
|
+
isOpen = false
|
|
555
|
+
render()
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
render()
|
|
560
|
+
})
|
|
561
|
+
</script>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types'
|
|
3
|
+
import FieldRoot from '../Field/Root/index.astro'
|
|
4
|
+
import FieldLabel from '../Field/Label/index.astro'
|
|
5
|
+
import FieldTextArea from '../Field/TextArea/index.astro'
|
|
6
|
+
import FieldMessage from '../Field/Message/index.astro'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Same props as the React `TextAreaField`, and the same split: `class` and
|
|
10
|
+
* `style` go on the `au-field` root, every other HTML attribute (`name`,
|
|
11
|
+
* `value`, `rows`, `placeholder`, `data-*`...) lands on the `<textarea>`.
|
|
12
|
+
*
|
|
13
|
+
* Differences from React: `textareaRef` and the `on*` callbacks have no
|
|
14
|
+
* counterpart. As in React, `required` only marks the label (it does not
|
|
15
|
+
* reach the `<textarea>`), and the message is not tied to the textarea.
|
|
16
|
+
*/
|
|
17
|
+
export type Props = Omit<HTMLAttributes<'textarea'>, 'disabled' | 'maxlength'> & {
|
|
18
|
+
showOptionalLabel?: boolean
|
|
19
|
+
success?: boolean
|
|
20
|
+
error?: boolean
|
|
21
|
+
errorMessage?: string
|
|
22
|
+
helpMessage?: string
|
|
23
|
+
label?: string
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
required?: boolean
|
|
26
|
+
maxLength?: number
|
|
27
|
+
/** Inline style of the `<textarea>` (`style` goes on the root). */
|
|
28
|
+
textAreaStyle?: HTMLAttributes<'textarea'>['style']
|
|
29
|
+
/** Allows the user to resize the textarea horizontally. Not mapped in Figma. */
|
|
30
|
+
horizontalResize?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
showOptionalLabel,
|
|
35
|
+
required = false,
|
|
36
|
+
success = false,
|
|
37
|
+
error = false,
|
|
38
|
+
errorMessage,
|
|
39
|
+
helpMessage,
|
|
40
|
+
label,
|
|
41
|
+
id,
|
|
42
|
+
disabled = false,
|
|
43
|
+
style,
|
|
44
|
+
class: className,
|
|
45
|
+
textAreaStyle,
|
|
46
|
+
maxLength,
|
|
47
|
+
horizontalResize = false,
|
|
48
|
+
...rest
|
|
49
|
+
} = Astro.props
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
<FieldRoot
|
|
54
|
+
style={style}
|
|
55
|
+
class={className}
|
|
56
|
+
success={success}
|
|
57
|
+
error={error}
|
|
58
|
+
disabled={disabled}>
|
|
59
|
+
<FieldLabel
|
|
60
|
+
text={label}
|
|
61
|
+
id={id}
|
|
62
|
+
required={required}
|
|
63
|
+
showOptionalLabel={showOptionalLabel}
|
|
64
|
+
success={success}
|
|
65
|
+
error={error}
|
|
66
|
+
disabled={disabled}
|
|
67
|
+
/>
|
|
68
|
+
<FieldTextArea
|
|
69
|
+
id={id}
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
style={textAreaStyle}
|
|
72
|
+
maxLength={maxLength}
|
|
73
|
+
horizontalResize={horizontalResize}
|
|
74
|
+
{...rest}
|
|
75
|
+
/>
|
|
76
|
+
<FieldMessage
|
|
77
|
+
hasError={error}
|
|
78
|
+
errorMessage={errorMessage}
|
|
79
|
+
helpMessage={helpMessage}
|
|
80
|
+
/>
|
|
81
|
+
</FieldRoot>
|