@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.
Files changed (96) hide show
  1. package/README.md +59 -3
  2. package/astro/AdBox/index.astro +28 -0
  3. package/astro/Alert/index.astro +257 -0
  4. package/astro/BadgeInfo/index.astro +55 -0
  5. package/astro/BadgeState/index.astro +79 -0
  6. package/astro/Button/index.astro +11 -3
  7. package/astro/Card/Container/index.astro +44 -0
  8. package/astro/Card/Emphasis/index.astro +39 -0
  9. package/astro/Card/Image/index.astro +26 -0
  10. package/astro/Card/Root/index.astro +53 -0
  11. package/astro/Card/Tag/index.astro +26 -0
  12. package/astro/Chip/index.astro +48 -0
  13. package/astro/ChipBanner/index.astro +160 -0
  14. package/astro/Container/index.astro +16 -0
  15. package/astro/Divider/index.astro +27 -0
  16. package/astro/Image/index.astro +26 -0
  17. package/astro/LazyImage/index.astro +55 -0
  18. package/astro/LinkButton/index.astro +54 -0
  19. package/astro/Logo/ac/BadgeFullWhite.astro +20 -0
  20. package/astro/Logo/ac/BadgeWhite.astro +23 -0
  21. package/astro/Logo/ac/BadgeWhiteStroke.astro +27 -0
  22. package/astro/Logo/ac/BadgeWhiteStrokeDefault.astro +28 -0
  23. package/astro/Logo/ac/Badget.astro +24 -0
  24. package/astro/Logo/ac/Primary.astro +28 -0
  25. package/astro/Logo/ac/PrimaryFillWhite.astro +28 -0
  26. package/astro/Logo/ac/PrimaryFullWhite.astro +20 -0
  27. package/astro/Logo/ac/PrimaryNegative.astro +20 -0
  28. package/astro/Logo/ac/PrimaryWhite.astro +20 -0
  29. package/astro/Logo/ac/TertiaryWhite.astro +72 -0
  30. package/astro/Logo/cp/BadgeWhiteStroke.astro +10 -0
  31. package/astro/Logo/cp/Badget.astro +14 -0
  32. package/astro/Logo/cp/PrimaryFillWhite.astro +63 -0
  33. package/astro/Logo/cp/PrimaryFullWhite.astro +27 -0
  34. package/astro/Logo/cp/PrimaryLogoWhite.astro +67 -0
  35. package/astro/Logo/cp/PrimaryNegative.astro +27 -0
  36. package/astro/Logo/cp/PrimaryWhite.astro +27 -0
  37. package/astro/Modal/index.astro +213 -0
  38. package/astro/NotificationsBar/Link/index.astro +70 -0
  39. package/astro/NotificationsBar/List/index.astro +38 -0
  40. package/astro/NotificationsBar/index.astro +24 -0
  41. package/astro/PartnerBanner/index.astro +52 -0
  42. package/astro/ProfileNav/index.astro +34 -0
  43. package/astro/ProgressBar/index.astro +70 -0
  44. package/astro/Skeleton/index.astro +50 -0
  45. package/astro/SpecialButton/index.astro +331 -0
  46. package/astro/Spinner/index.astro +75 -0
  47. package/astro/SubHeader/index.astro +57 -0
  48. package/astro/Switch/Card/index.astro +69 -0
  49. package/astro/Switch/Pure/index.astro +47 -0
  50. package/astro/Tooltip/index.astro +38 -0
  51. package/astro/form/Checkbox/Field/index.astro +68 -0
  52. package/astro/form/Checkbox/Group/index.astro +79 -0
  53. package/astro/form/Datepicker/Calendar/index.astro +53 -0
  54. package/astro/form/Datepicker/CalendarHeader/index.astro +57 -0
  55. package/astro/form/Datepicker/Segment/index.astro +53 -0
  56. package/astro/form/Datepicker/index.astro +756 -0
  57. package/astro/form/EmailField/index.astro +237 -0
  58. package/astro/form/Field/ErrorMessage/index.astro +20 -0
  59. package/astro/form/Field/Input/index.astro +15 -0
  60. package/astro/form/Field/InputHolder/index.astro +28 -0
  61. package/astro/form/Field/Label/index.astro +80 -0
  62. package/astro/form/Field/Message/index.astro +29 -0
  63. package/astro/form/Field/Root/index.astro +37 -0
  64. package/astro/form/Field/TextArea/index.astro +69 -0
  65. package/astro/form/InputField/index.astro +96 -0
  66. package/astro/form/PasswordField/index.astro +123 -0
  67. package/astro/form/Radio/Field/index.astro +52 -0
  68. package/astro/form/Radio/Group/index.astro +78 -0
  69. package/astro/form/SelectField/index.astro +561 -0
  70. package/astro/form/TextareaField/index.astro +81 -0
  71. package/astro/form/TokenField/index.astro +211 -0
  72. package/astro/tokens/package.json +6 -0
  73. package/dist/{tokens-C4ypB4bY.js → astro/tokens/index.es.js} +101 -101
  74. package/dist/astro/tokens/index.es.js.map +1 -0
  75. package/dist/components/Alert/index.es.js +1 -1
  76. package/dist/components/BadgeInfo/index.es.js +1 -1
  77. package/dist/components/BadgeState/index.es.js +1 -1
  78. package/dist/components/Image/index.es.js +1 -1
  79. package/dist/components/Spinner/index.es.js +1 -1
  80. package/dist/components/form/Checkbox/Field/index.es.js +1 -1
  81. package/dist/components/form/Datepicker/Calendar/index.es.js +2 -2
  82. package/dist/components/form/Datepicker/CalendarHeader/index.es.js +2 -2
  83. package/dist/components/form/Datepicker/PortalHolder/index.es.js +1 -1
  84. package/dist/components/form/Datepicker/Segment/index.es.js +1 -1
  85. package/dist/components/form/Datepicker/index.es.js +3 -3
  86. package/dist/components/form/Field/Label/index.es.js +1 -1
  87. package/dist/components/form/SelectField/index.es.js +1 -1
  88. package/dist/{index-BcC9liUl.js → index-Bkl5OBIY.js} +3 -3
  89. package/dist/{index-BcC9liUl.js.map → index-Bkl5OBIY.js.map} +1 -1
  90. package/dist/{index-CFYmtkje.js → index-CZfMkAoF.js} +2 -2
  91. package/dist/{index-CFYmtkje.js.map → index-CZfMkAoF.js.map} +1 -1
  92. package/dist/{index-CeClo_WS.js → index-Cw1KtRQr.js} +2 -2
  93. package/dist/{index-CeClo_WS.js.map → index-Cw1KtRQr.js.map} +1 -1
  94. package/dist/main.es.js +101 -101
  95. package/package.json +7 -1
  96. 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>