@santi020k/lumen-astro 4.0.0 → 4.1.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 CHANGED
@@ -161,6 +161,9 @@ Set `data-lumen-preset="studio"` and an optional `data-lumen-scheme="light|dark"
161
161
 
162
162
  ## Compound interactive components
163
163
 
164
+ DatePicker, Calendar, and InputOTP enhancement skips markup marked `data-ui-owner`, such as the
165
+ matching React controls, so those islands keep their ids, labels, and state before and after hydration.
166
+
164
167
  Popover, dropdown menu, tabs, and tooltip parts expose the DOM and ARIA contracts consumed by the
165
168
  shared progressive-enhancement runtime. Prefer these parts over recreating roles and `data-ui-*`
166
169
  attributes by hand.
@@ -336,6 +339,24 @@ import { Anchor, CopyButton, ScrollProgress } from '@santi020k/lumen-astro'
336
339
  ]} />
337
340
  ```
338
341
 
342
+ Enhanced `DatePicker` controls mirror native input `aria-invalid`, `aria-describedby`, and
343
+ `aria-errormessage` attributes onto the visible trigger, including later attribute changes.
344
+ Valid updates and uncanceled resets clear generated validation feedback while preserving explicit
345
+ application errors.
346
+
347
+ `Skeleton` accepts `shape` (`rectangle`, `text`, `circle`), `width`, `height`, and `animation`
348
+ (`shimmer`, `pulse`, `none`). Numeric sizes are pixels; CSS lengths, percentages, and math or
349
+ custom-property expressions also work. Explicit styles take precedence. Empty placeholders default
350
+ to 1rem high, while text and element content can grow naturally. Reduced motion stops animations.
351
+ Unnamed Skeletons and Spinners are decorative. Give a standalone indicator an accessible name, or
352
+ wrap a group in one named status region with `VisuallyHidden` text.
353
+
354
+ ```astro
355
+ <Skeleton shape="circle" width={40} />
356
+ <Skeleton shape="text" width="80%" />
357
+ <Skeleton animation="pulse" height={8} width="60%" />
358
+ ```
359
+
339
360
  `Progress` can be updated after hydration by dispatching `ui:progress-change` from its root with
340
361
  `{ value, max? }`. Use `CopyButton` to copy arbitrary text or a referenced element without giving
341
362
  non-code content `Code` semantics.
@@ -63,7 +63,8 @@ if (Array.isArray(value)) initialValues = value
63
63
  else if (value !== undefined) initialValues = [value]
64
64
 
65
65
  const selectedValues = new Set(initialValues)
66
- const controlId = id ?? `ui-list-box-${name ?? 'field'}`
66
+ // Named lists keep their stable id; unnamed lists need a unique one so labels and options never collide.
67
+ const controlId = id ?? (name ? `ui-list-box-${name}` : `ui-list-box-${Math.random().toString(36).slice(2)}`)
67
68
  const listId = `${controlId}-list`
68
69
  const { classList, passthrough } = resolveLumenAstroProps(rest, ['ui-list-box'], classProp, className)
69
70
  ---
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import { resolveLumenAstroProps } from '@santi020k/lumen-core'
4
+ import { normalizeLumenProgress, resolveLumenAstroProps } from '@santi020k/lumen-core'
5
5
 
6
6
  interface Props extends HTMLAttributes<'div'> {
7
7
  class?: string | null
@@ -11,9 +11,7 @@ interface Props extends HTMLAttributes<'div'> {
11
11
  }
12
12
 
13
13
  const { class: classProp = '', className = '', max = 100, value = 0, ...rest } = Astro.props
14
- const safeMax = max > 0 ? max : 100
15
- const safeValue = Math.min(safeMax, Math.max(0, value))
16
- const percentage = (safeValue / safeMax) * 100
14
+ const { max: safeMax, percentage, value: safeValue } = normalizeLumenProgress(max, value)
17
15
 
18
16
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
19
17
  'ui-progress'
@@ -1,36 +1,77 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import { resolveLumenAstroProps } from '@santi020k/lumen-core'
4
+ import {
5
+ type LumenSkeletonAnimation,
6
+ type LumenSkeletonShape,
7
+ type LumenSkeletonSize,
8
+ resolveLumenAstroProps,
9
+ resolveLumenLoadingSemantics,
10
+ resolveLumenSkeletonAnimation,
11
+ resolveLumenSkeletonShape,
12
+ resolveLumenSkeletonSize
13
+ } from '@santi020k/lumen-core'
5
14
 
6
15
  interface Props extends HTMLAttributes<'div'> {
16
+ /** `shimmer` (default) keeps the existing sweep; `pulse` fades; `none` is static. */
17
+ animation?: LumenSkeletonAnimation
7
18
  class?: string | null
8
19
  className?: string
20
+ /** Pixels for numbers, or a CSS length such as `0.75rem` or `60%`. */
21
+ height?: LumenSkeletonSize
22
+ shape?: LumenSkeletonShape
23
+ width?: LumenSkeletonSize
9
24
  }
10
25
 
11
26
  const {
27
+ animation,
12
28
  'aria-hidden': ariaHidden,
13
29
  'aria-label': ariaLabel,
14
30
  'aria-labelledby': ariaLabelledby,
15
31
  class: classProp = '',
16
32
  className = '',
33
+ height,
17
34
  role,
35
+ shape,
36
+ style,
37
+ width,
18
38
  ...rest
19
39
  } = Astro.props
20
40
 
21
- const isAccessible = Boolean(ariaLabel || ariaLabelledby)
41
+ const semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })
42
+ const resolvedShape = resolveLumenSkeletonShape(shape)
43
+ const resolvedAnimation = resolveLumenSkeletonAnimation(animation)
44
+ const sizeVariables: Record<string, string> = {}
45
+ const resolvedHeight = resolveLumenSkeletonSize(height)
46
+ const resolvedWidth = resolveLumenSkeletonSize(width)
47
+
48
+ if (resolvedHeight) sizeVariables['--ui-skeleton-height'] = resolvedHeight
49
+
50
+ if (resolvedWidth) sizeVariables['--ui-skeleton-width'] = resolvedWidth
51
+
52
+ // Consumer styles follow the size variables so explicit declarations still win.
53
+ const skeletonStyle = typeof style === 'object' && style !== null ?
54
+ { ...sizeVariables, ...style } :
55
+ [
56
+ ...Object.entries(sizeVariables).map(([name, value]) => `${name}:${value}`),
57
+ typeof style === 'string' ? style : ''
58
+ ].filter(Boolean).join(';')
22
59
 
23
60
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
24
- 'ui-skeleton'
61
+ 'ui-skeleton',
62
+ resolvedShape === 'rectangle' ? '' : `ui-skeleton--${resolvedShape}`,
63
+ resolvedAnimation === 'pulse' ? 'ui-skeleton--pulse' : '',
64
+ resolvedAnimation === 'none' ? 'ui-skeleton--static' : ''
25
65
  ], classProp, className)
26
66
  ---
27
67
 
28
68
  <div
29
- aria-hidden={ariaHidden ?? (isAccessible ? undefined : 'true')}
69
+ aria-hidden={ariaHidden ?? semantics.ariaHidden}
30
70
  aria-label={ariaLabel}
31
71
  aria-labelledby={ariaLabelledby}
32
72
  class:list={classList}
33
- role={role ?? (isAccessible ? 'status' : undefined)}
73
+ role={role ?? semantics.role}
74
+ style={skeletonStyle || undefined}
34
75
  {...passthrough}
35
76
  >
36
77
  <slot />
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import { resolveLumenAstroProps } from '@santi020k/lumen-core'
4
+ import { resolveLumenAstroProps, resolveLumenLoadingSemantics } from '@santi020k/lumen-core'
5
5
 
6
6
  interface Props extends HTMLAttributes<'span'> {
7
7
  class?: string | null
@@ -18,7 +18,7 @@ const {
18
18
  ...rest
19
19
  } = Astro.props
20
20
 
21
- const isAccessible = Boolean(ariaLabel || ariaLabelledby)
21
+ const semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })
22
22
 
23
23
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
24
24
  'ui-spinner'
@@ -26,11 +26,11 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
26
26
  ---
27
27
 
28
28
  <span
29
- aria-hidden={ariaHidden ?? (isAccessible ? undefined : 'true')}
29
+ aria-hidden={ariaHidden ?? semantics.ariaHidden}
30
30
  aria-label={ariaLabel}
31
31
  aria-labelledby={ariaLabelledby}
32
32
  class:list={classList}
33
- role={role ?? (isAccessible ? 'status' : undefined)}
33
+ role={role ?? semantics.role}
34
34
  {...passthrough}
35
35
  >
36
36
  <slot />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@santi020k/lumen-astro",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -43,8 +43,8 @@
43
43
  "lumen": "./bin/lumen.mjs"
44
44
  },
45
45
  "dependencies": {
46
- "@santi020k/lumen": "^4.0.0",
47
- "@santi020k/lumen-core": "^4.0.0",
46
+ "@santi020k/lumen": "^4.1.0",
47
+ "@santi020k/lumen-core": "^4.1.0",
48
48
  "uqr": "^0.1.3"
49
49
  },
50
50
  "devDependencies": {
@@ -9,7 +9,7 @@
9
9
  ---
10
10
 
11
11
  <script>
12
- import { isLumenDateBoundsValid, parseLumenDate as parseCalendarDate, resolveLumenDateLabels, resolveLumenDateLocale } from '@santi020k/lumen-core'
12
+ import { connectLumenFloatingPanel, isLumenDateBoundsValid, parseLumenDate as parseCalendarDate, resolveLumenDateLabels, resolveLumenDateLocale } from '@santi020k/lumen-core'
13
13
  import {
14
14
  coerceThemePreset,
15
15
  createLumenKanbanMoveDetail,
@@ -36,8 +36,10 @@ import {
36
36
  import { initAmountFields } from './controllers/amount-field'
37
37
  import { clearComboboxes, closeCombobox, initComboboxes } from './controllers/combobox'
38
38
  import { initDataTableSorting } from './controllers/data-table'
39
+ import { bindListBoxProxy } from './controllers/list-box-proxy'
39
40
  import { initMessageFeeds } from './controllers/message-scroller'
40
41
  import { initOptionalMediaControllers } from './controllers/optional-media'
42
+ import { initProgress } from './controllers/progress'
41
43
 
42
44
  /* eslint-disable complexity */
43
45
  /* Stateful DOM handlers stay local so Astro consumers do not need a framework runtime. */
@@ -773,9 +775,15 @@ const getListBoxSelectedValues = (select: HTMLSelectElement): string[] => [
773
775
  ...select.selectedOptions
774
776
  ].map(option => option.value)
775
777
 
778
+ const isOwnedByAnotherAdapter = (root: Element): boolean => root.hasAttribute('data-ui-owner')
779
+ // A bound root that was removed and inserted again follows its external labels once reinitialized.
780
+ const listBoxResyncs = new WeakMap<HTMLElement, () => void>()
781
+
776
782
  const initListBoxes = (scope: ParentNode): void => {
777
783
  for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-list-box]')) {
778
- if (root.dataset.uiListBoxBound === 'true') continue
784
+ if (root.dataset.uiListBoxBound === 'true') listBoxResyncs.get(root)?.()
785
+
786
+ if (root.dataset.uiListBoxBound === 'true' || isOwnedByAnotherAdapter(root)) continue
779
787
 
780
788
  const select = root.querySelector<HTMLSelectElement>('[data-ui-list-box-native]')
781
789
  const list = root.querySelector<HTMLElement>('[data-ui-list-box-list]')
@@ -789,6 +797,8 @@ const initListBoxes = (scope: ParentNode): void => {
789
797
 
790
798
  list.hidden = false
791
799
 
800
+ const optionIdPrefix = select.id || nextRuntimeId('ui-list-box', root.ownerDocument)
801
+
792
802
  let activeIndex = Math.max(
793
803
  0, options.findIndex(option => option.getAttribute('aria-disabled') !== 'true')
794
804
  )
@@ -798,7 +808,7 @@ const initListBoxes = (scope: ParentNode): void => {
798
808
 
799
809
  const syncActive = (): void => {
800
810
  options.forEach((option, index) => {
801
- option.id ||= `${select.id || 'ui-list-box'}-option-${index}`
811
+ option.id ||= `${optionIdPrefix}-option-${index}`
802
812
 
803
813
  option.dataset.active = index === activeIndex ? 'true' : 'false'
804
814
  })
@@ -929,19 +939,17 @@ const initListBoxes = (scope: ParentNode): void => {
929
939
  })
930
940
  })
931
941
 
932
- select.addEventListener('change', syncSelected)
942
+ listBoxResyncs.set(root, bindListBoxProxy(root, select, list, () => nextRuntimeId('ui-list-box-label', root.ownerDocument), () => {
943
+ if (!options.length) return
933
944
 
934
- select.form?.addEventListener('reset', () => {
935
- window.setTimeout(() => {
936
- syncSelected()
945
+ syncSelected()
937
946
 
938
- activeIndex = Math.max(
939
- 0, options.findIndex(option => option.getAttribute('aria-selected') === 'true')
940
- )
947
+ activeIndex = Math.max(0, options.findIndex(option => option.getAttribute('aria-selected') === 'true'))
941
948
 
942
- syncActive()
943
- })
944
- })
949
+ syncActive()
950
+ }))
951
+
952
+ select.addEventListener('change', syncSelected)
945
953
 
946
954
  syncSelected()
947
955
 
@@ -1618,50 +1626,6 @@ const initCopyButtons = (scope: ParentNode): void => {
1618
1626
  }
1619
1627
  }
1620
1628
 
1621
- interface ProgressChangeDetail {
1622
- max?: number
1623
- value?: number
1624
- }
1625
-
1626
- const updateProgress = (
1627
- root: HTMLElement,
1628
- { max, value }: ProgressChangeDetail
1629
- ): void => {
1630
- const currentMax = Number(root.getAttribute('aria-valuemax')) || 100
1631
- const safeMax = max && max > 0 ? max : currentMax
1632
- const currentValue = Number(root.getAttribute('aria-valuenow')) || 0
1633
-
1634
- const safeValue = Math.min(
1635
- safeMax, Math.max(0, Number.isFinite(value) ? (value ?? 0) : currentValue)
1636
- )
1637
-
1638
- const indicator = root.querySelector<HTMLElement>(
1639
- '[data-slot="progress-indicator"]'
1640
- )
1641
-
1642
- root.setAttribute('aria-valuemax', String(safeMax))
1643
-
1644
- root.setAttribute('aria-valuenow', String(safeValue))
1645
-
1646
- if (indicator) indicator.style.width = `${(safeValue / safeMax) * 100}%`
1647
- }
1648
-
1649
- const initProgress = (scope: ParentNode): void => {
1650
- for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-progress]')) {
1651
- if (root.dataset.uiProgressBound === 'true') continue
1652
-
1653
- root.dataset.uiProgressBound = 'true'
1654
-
1655
- root.addEventListener('ui:progress-change', event => {
1656
- const detail = event instanceof CustomEvent ?
1657
- (event.detail as ProgressChangeDetail) :
1658
- {}
1659
-
1660
- updateProgress(root, detail)
1661
- })
1662
- }
1663
- }
1664
-
1665
1629
  const filterItems = (input: HTMLInputElement, items: HTMLElement[]): void => {
1666
1630
  const query = input.value.trim().toLowerCase()
1667
1631
 
@@ -2248,9 +2212,10 @@ const sanitizeOtpValue = (input: HTMLInputElement, value: string, length: number
2248
2212
  return normalized.slice(0, length)
2249
2213
  }
2250
2214
 
2215
+ // Markup whose behavior another adapter owns, such as a React island, is never enhanced here.
2251
2216
  const initInputOtpFields = (scope: ParentNode): void => {
2252
2217
  for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-input-otp]')) {
2253
- if (root.dataset.uiBound === 'true') continue
2218
+ if (root.dataset.uiBound === 'true' || isOwnedByAnotherAdapter(root)) continue
2254
2219
 
2255
2220
  const input = root.querySelector<HTMLInputElement>('[data-ui-input-otp-native]')
2256
2221
  const segments = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-input-otp-segment]')]
@@ -2867,7 +2832,7 @@ const moveCalendarFocus = (root: HTMLElement, currentDate: Date, key: string): v
2867
2832
 
2868
2833
  const initCalendars = (scope: ParentNode): void => {
2869
2834
  for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-calendar]')) {
2870
- if (root.dataset.uiBound === 'true') continue
2835
+ if (root.dataset.uiBound === 'true' || isOwnedByAnotherAdapter(root)) continue
2871
2836
 
2872
2837
  const input = root.querySelector<HTMLInputElement>('[data-ui-calendar-input]')
2873
2838
 
@@ -3011,12 +2976,22 @@ const formatDatePickerValue = (value: string, placeholder: string, locale: strin
3011
2976
  }).format(date)
3012
2977
  }
3013
2978
 
2979
+ // Each open picker's shared floating-panel connection; closing disconnects it.
2980
+ const datePickerPanels = new WeakMap<HTMLElement, () => void>()
2981
+
3014
2982
  const closeDatePicker = (root: HTMLElement, restoreFocus = false): void => {
2983
+ if (isOwnedByAnotherAdapter(root)) return
2984
+
3015
2985
  const trigger = root.querySelector<HTMLButtonElement>('[data-ui-date-picker-trigger]')
3016
2986
  const popover = root.querySelector<HTMLElement>('[data-ui-date-picker-popover]')
3017
2987
 
3018
2988
  if (!trigger || !popover) return
3019
2989
 
2990
+ // Leave the top layer and restore the popover's inline styles before hiding it.
2991
+ datePickerPanels.get(root)?.()
2992
+
2993
+ datePickerPanels.delete(root)
2994
+
3020
2995
  popover.hidden = true
3021
2996
 
3022
2997
  popover.dataset.state = 'closed'
@@ -3026,6 +3001,24 @@ const closeDatePicker = (root: HTMLElement, restoreFocus = false): void => {
3026
3001
  if (restoreFocus) trigger.focus()
3027
3002
  }
3028
3003
 
3004
+ const invalidDatePickerInputs = new WeakSet<HTMLInputElement>()
3005
+
3006
+ const syncDatePickerValidity = (native: HTMLInputElement, trigger: HTMLElement): void => {
3007
+ if (native.validity.valid) invalidDatePickerInputs.delete(native)
3008
+
3009
+ const invalid = native.getAttribute('aria-invalid') ?? (invalidDatePickerInputs.has(native) ? 'true' : null)
3010
+
3011
+ if (invalid === null) trigger.removeAttribute('aria-invalid')
3012
+ else trigger.setAttribute('aria-invalid', invalid)
3013
+
3014
+ for (const attribute of ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-errormessage']) {
3015
+ const value = native.getAttribute(attribute)
3016
+
3017
+ if (value === null) trigger.removeAttribute(attribute)
3018
+ else trigger.setAttribute(attribute, value)
3019
+ }
3020
+ }
3021
+
3029
3022
  const syncDatePickerValue = (root: HTMLElement): void => {
3030
3023
  const native = root.querySelector<HTMLInputElement>('[data-ui-date-picker-native]')
3031
3024
  const value = root.querySelector<HTMLElement>('[data-ui-date-picker-value]')
@@ -3043,9 +3036,9 @@ const syncDatePickerValue = (root: HTMLElement): void => {
3043
3036
  root.dataset.placeholder = 'true'
3044
3037
  }
3045
3038
 
3046
- if (native.validity.valid) {
3047
- root.querySelector<HTMLElement>('[data-ui-date-picker-trigger]')?.removeAttribute('aria-invalid')
3048
- }
3039
+ const trigger = root.querySelector<HTMLElement>('[data-ui-date-picker-trigger]')
3040
+
3041
+ if (trigger) syncDatePickerValidity(native, trigger)
3049
3042
  }
3050
3043
 
3051
3044
  const openDatePicker = (root: HTMLElement): void => {
@@ -3081,6 +3074,14 @@ const openDatePicker = (root: HTMLElement): void => {
3081
3074
 
3082
3075
  popover.dataset.state = 'open'
3083
3076
 
3077
+ datePickerPanels.get(root)?.()
3078
+
3079
+ datePickerPanels.set(root, connectLumenFloatingPanel(popover, trigger, {
3080
+ onDismiss: () => {
3081
+ closeDatePicker(root)
3082
+ }
3083
+ }))
3084
+
3084
3085
  trigger.setAttribute('aria-expanded', 'true')
3085
3086
 
3086
3087
  window.requestAnimationFrame(() => {
@@ -3095,7 +3096,7 @@ const openDatePicker = (root: HTMLElement): void => {
3095
3096
 
3096
3097
  const initDatePickers = (scope: ParentNode): void => {
3097
3098
  for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-date-picker]')) {
3098
- if (root.dataset.uiDatePickerBound === 'true') continue
3099
+ if (root.dataset.uiDatePickerBound === 'true' || isOwnedByAnotherAdapter(root)) continue
3099
3100
 
3100
3101
  const native = root.querySelector<HTMLInputElement>('[data-ui-date-picker-native]')
3101
3102
  const control = root.querySelector<HTMLElement>('[data-ui-date-picker-control]')
@@ -3147,13 +3148,13 @@ const initDatePickers = (scope: ParentNode): void => {
3147
3148
 
3148
3149
  syncState()
3149
3150
 
3150
- const observer = new MutationObserver(() => {
3151
- closeDatePicker(root)
3151
+ const observer = new MutationObserver(mutations => {
3152
+ if (mutations.some(mutation => !mutation.attributeName?.startsWith('aria-'))) closeDatePicker(root)
3152
3153
 
3153
3154
  syncState()
3154
3155
  })
3155
3156
 
3156
- observer.observe(native, { attributes: true, attributeFilter: ['disabled', 'readonly', 'required', 'value', 'min', 'max', 'placeholder'] })
3157
+ observer.observe(native, { attributes: true, attributeFilter: ['disabled', 'readonly', 'required', 'value', 'min', 'max', 'placeholder', 'aria-invalid', 'aria-errormessage', 'aria-describedby', 'aria-label', 'aria-labelledby'] })
3157
3158
 
3158
3159
  observer.observe(root, { attributes: true, attributeFilter: ['locale', 'lang', 'data-locale', 'data-choose-date-label'] })
3159
3160
 
@@ -3207,7 +3208,9 @@ const initDatePickers = (scope: ParentNode): void => {
3207
3208
  native.addEventListener('invalid', event => {
3208
3209
  event.preventDefault()
3209
3210
 
3210
- trigger.setAttribute('aria-invalid', 'true')
3211
+ invalidDatePickerInputs.add(native)
3212
+
3213
+ syncDatePickerValidity(native, trigger)
3211
3214
 
3212
3215
  trigger.focus()
3213
3216
  })
@@ -3216,6 +3219,8 @@ const initDatePickers = (scope: ParentNode): void => {
3216
3219
  globalThis.setTimeout(() => {
3217
3220
  if (event.defaultPrevented || !root.isConnected) return
3218
3221
 
3222
+ invalidDatePickerInputs.delete(native)
3223
+
3219
3224
  syncDatePickerValue(root)
3220
3225
 
3221
3226
  closeDatePicker(root)
@@ -0,0 +1,209 @@
1
+ const forwardedAttributes = ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-errormessage'] as const
2
+ // A new select id changes which labels name it.
3
+ const observedAttributes = [...forwardedAttributes, 'aria-invalid', 'disabled', 'form', 'id', 'required']
4
+
5
+ // The first invalid control owns focus so several invalid fields do not race for it.
6
+ const isFirstInvalidControl = (select: HTMLSelectElement): boolean => {
7
+ const form = select.form
8
+
9
+ if (!form) return true
10
+
11
+ for (const element of form.elements) {
12
+ if (element.matches(':invalid') && element.tagName !== 'FIELDSET') return element === select
13
+ }
14
+
15
+ return true
16
+ }
17
+
18
+ const setOptionalAttribute = (element: HTMLElement, name: string, value: string | null): void => {
19
+ if (value === null) element.removeAttribute(name)
20
+ else element.setAttribute(name, value)
21
+ }
22
+
23
+ const syncListBoxName = (
24
+ root: HTMLElement,
25
+ select: HTMLSelectElement,
26
+ list: HTMLElement,
27
+ listOwned: ReadonlySet<string>,
28
+ createLabelId: () => string
29
+ ): void => {
30
+ for (const name of forwardedAttributes) {
31
+ if (listOwned.has(name)) continue
32
+
33
+ setOptionalAttribute(list, name, select.getAttribute(name) ?? (name === 'aria-label' ? root.getAttribute('aria-label') : null))
34
+ }
35
+
36
+ if (listOwned.has('aria-labelledby') || list.hasAttribute('aria-label') || list.hasAttribute('aria-labelledby')) return
37
+
38
+ const labelIds = [...select.labels].map(label => {
39
+ label.id ||= createLabelId()
40
+
41
+ return label.id
42
+ })
43
+
44
+ if (labelIds.length) list.setAttribute('aria-labelledby', labelIds.join(' '))
45
+ }
46
+
47
+ const mayHoldListBoxLabel = (node: Node, root: HTMLElement): boolean => {
48
+ // Adoption preserves a node's original realm, so instanceof the destination Element is insufficient.
49
+ if (node.nodeType !== 1) return false
50
+
51
+ const element = node as Element
52
+
53
+ return ['LABEL', 'FORM'].includes(element.nodeName) || element.contains(root) || element.querySelector('label, form') !== null
54
+ }
55
+
56
+ // Only label insertions, removals, `for` or `id` changes, or moving the list itself can change its labels.
57
+ const affectsListBoxLabels = (
58
+ records: readonly MutationRecord[],
59
+ root: HTMLElement
60
+ ): boolean => (
61
+ records.some(record => record.type === 'attributes' ?
62
+ ['LABEL', 'FORM', 'SELECT'].includes(record.target.nodeName) :
63
+ [...record.addedNodes, ...record.removedNodes].some(node => mayHoldListBoxLabel(node, root)))
64
+ )
65
+
66
+ /**
67
+ * Once a list box is enhanced the visible list is its only focus stop and accessible control, while the
68
+ * native select keeps form submission, validation, and reset. Naming, description, error, required, and
69
+ * disabled semantics follow the select as they change, focus that reaches the select moves to the list,
70
+ * and native invalid events mark the list invalid until a valid change or an accepted reset. Consumer
71
+ * `aria-invalid` on the select always wins over generated state. External labels are followed while the root
72
+ * is connected; the returned function resynchronizes a root that was removed and inserted again.
73
+ */
74
+ export const bindListBoxProxy = (
75
+ root: HTMLElement,
76
+ select: HTMLSelectElement,
77
+ list: HTMLElement,
78
+ createLabelId: () => string,
79
+ onResetAccepted?: () => void
80
+ ): (() => void) => {
81
+ // Attributes authored on the list itself stay under the author's control.
82
+ const listOwned = new Set<string>(forwardedAttributes.filter(name => list.hasAttribute(name)))
83
+ let generatedInvalid = false
84
+ let resetForm: HTMLFormElement | null = null
85
+
86
+ const syncState = (): void => {
87
+ select.tabIndex = -1
88
+
89
+ select.setAttribute('aria-hidden', 'true')
90
+
91
+ syncListBoxName(root, select, list, listOwned, createLabelId)
92
+
93
+ setOptionalAttribute(list, 'aria-required', select.required ? 'true' : null)
94
+
95
+ // A select that is valid again or barred from validation, such as disabled or no longer required, is not invalid.
96
+ if (!select.willValidate || select.validity.valid) generatedInvalid = false
97
+
98
+ setOptionalAttribute(list, 'aria-invalid', select.getAttribute('aria-invalid') ?? (generatedInvalid ? 'true' : null))
99
+
100
+ list.tabIndex = select.disabled ? -1 : 0
101
+
102
+ setOptionalAttribute(list, 'aria-disabled', select.disabled ? 'true' : null)
103
+ }
104
+
105
+ const onReset = (event: Event): void => {
106
+ const form = event.currentTarget
107
+
108
+ globalThis.setTimeout(() => {
109
+ if (event.defaultPrevented || select.form !== form) return
110
+
111
+ generatedInvalid = false
112
+
113
+ syncState()
114
+
115
+ onResetAccepted?.()
116
+ })
117
+ }
118
+
119
+ const followForm = (): void => {
120
+ const next = root.isConnected ? select.form : null
121
+
122
+ if (next === resetForm) return
123
+
124
+ resetForm?.removeEventListener('reset', onReset)
125
+
126
+ resetForm = next
127
+
128
+ resetForm?.addEventListener('reset', onReset)
129
+ }
130
+
131
+ const sync = (): void => {
132
+ followForm()
133
+
134
+ syncState()
135
+ }
136
+
137
+ let labelObserver: MutationObserver | undefined
138
+ let observedDocument: Document | undefined
139
+
140
+ // The document observer stops once the root leaves, so a removed list box is not kept alive by it.
141
+ const followLabels = (): void => {
142
+ if (observedDocument !== root.ownerDocument) {
143
+ labelObserver?.disconnect()
144
+
145
+ labelObserver = undefined
146
+ }
147
+
148
+ const view = root.ownerDocument.defaultView
149
+
150
+ if (labelObserver || !view || !root.isConnected) return
151
+
152
+ observedDocument = root.ownerDocument
153
+
154
+ labelObserver = new view.MutationObserver(records => {
155
+ if (observedDocument !== root.ownerDocument) {
156
+ followLabels()
157
+
158
+ sync()
159
+ } else if (!root.isConnected) {
160
+ followForm()
161
+
162
+ labelObserver?.disconnect()
163
+
164
+ labelObserver = undefined
165
+ } else if (affectsListBoxLabels(records, root)) {
166
+ sync()
167
+ }
168
+ })
169
+
170
+ labelObserver.observe(root.ownerDocument, {
171
+ attributeFilter: ['for', 'form', 'id'], attributes: true, childList: true, subtree: true
172
+ })
173
+ }
174
+
175
+ sync()
176
+
177
+ followLabels()
178
+
179
+ new MutationObserver(sync).observe(select, { attributeFilter: observedAttributes, attributes: true })
180
+
181
+ select.addEventListener('focus', () => {
182
+ // Only real select focus, such as a label click, moves to the list; focus moved elsewhere stays put.
183
+ if (select.ownerDocument.activeElement === select && !select.disabled) list.focus({ preventScroll: true })
184
+ })
185
+
186
+ select.addEventListener('invalid', event => {
187
+ event.preventDefault()
188
+
189
+ generatedInvalid = true
190
+
191
+ sync()
192
+
193
+ if (isFirstInvalidControl(select) && !select.disabled) list.focus()
194
+ })
195
+
196
+ select.addEventListener('change', () => {
197
+ if (!select.validity.valid) return
198
+
199
+ generatedInvalid = false
200
+
201
+ sync()
202
+ })
203
+
204
+ return () => {
205
+ followLabels()
206
+
207
+ sync()
208
+ }
209
+ }
@@ -0,0 +1,52 @@
1
+ import { normalizeLumenProgress } from '@santi020k/lumen-core'
2
+
3
+ interface ProgressChangeDetail {
4
+ max?: number | undefined
5
+ value?: number | undefined
6
+ }
7
+
8
+ export const readProgressChangeDetail = (detail: unknown): ProgressChangeDetail => {
9
+ if (typeof detail !== 'object' || detail === null) return {}
10
+
11
+ const max = 'max' in detail && typeof detail.max === 'number' ? detail.max : undefined
12
+ const value = 'value' in detail && typeof detail.value === 'number' ? detail.value : undefined
13
+
14
+ return { max, value }
15
+ }
16
+
17
+ const updateProgress = (
18
+ root: HTMLElement,
19
+ { max, value }: ProgressChangeDetail
20
+ ): void => {
21
+ const current = normalizeLumenProgress(
22
+ root.getAttribute('aria-valuemax'), root.getAttribute('aria-valuenow')
23
+ )
24
+
25
+ // Omitted or invalid fields keep the current state instead of resetting it.
26
+ const next = normalizeLumenProgress(
27
+ max !== undefined && Number.isFinite(max) && max > 0 ? max : current.max,
28
+ value !== undefined && Number.isFinite(value) ? value : current.value
29
+ )
30
+
31
+ const indicator = root.querySelector<HTMLElement>(
32
+ '[data-slot="progress-indicator"]'
33
+ )
34
+
35
+ root.setAttribute('aria-valuemax', String(next.max))
36
+
37
+ root.setAttribute('aria-valuenow', String(next.value))
38
+
39
+ if (indicator) indicator.style.width = `${next.percentage}%`
40
+ }
41
+
42
+ export const initProgress = (scope: ParentNode): void => {
43
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-progress]')) {
44
+ if (root.dataset.uiProgressBound === 'true') continue
45
+
46
+ root.dataset.uiProgressBound = 'true'
47
+
48
+ root.addEventListener('ui:progress-change', event => {
49
+ updateProgress(root, readProgressChangeDetail(event instanceof CustomEvent ? event.detail : undefined))
50
+ })
51
+ }
52
+ }
@@ -172,6 +172,17 @@
172
172
  }
173
173
  }
174
174
 
175
+ @keyframes ui-skeleton-pulse {
176
+ 0%,
177
+ 100% {
178
+ opacity: 1;
179
+ }
180
+
181
+ 50% {
182
+ opacity: 0.55;
183
+ }
184
+ }
185
+
175
186
  @keyframes ui-spin {
176
187
  to {
177
188
  transform: rotate(360deg);
@@ -790,15 +801,21 @@
790
801
  background-image:
791
802
  linear-gradient(45deg, transparent 50%, hsl(var(--ink-muted)) 50%),
792
803
  linear-gradient(135deg, hsl(var(--ink-muted)) 50%, transparent 50%);
793
- background-position:
794
- calc(100% - 1rem) 50%,
795
- calc(100% - 0.72rem) 50%;
804
+ background-position: var(--_ui-select-chevron, calc(100% - 1rem) 50%, calc(100% - 0.72rem) 50%);
796
805
  background-size:
797
806
  0.32rem 0.32rem,
798
807
  0.32rem 0.32rem;
799
808
  background-repeat: no-repeat;
800
809
  }
801
810
 
811
+ [dir="rtl"] {
812
+ --_ui-select-chevron: 0.72rem 50%, 1rem 50%;
813
+ }
814
+
815
+ [dir="ltr"] {
816
+ --_ui-select-chevron: calc(100% - 1rem) 50%, calc(100% - 0.72rem) 50%;
817
+ }
818
+
802
819
  .ui-label {
803
820
  color: hsl(var(--ink));
804
821
  font-size: 0.875rem;
@@ -1007,7 +1024,9 @@
1007
1024
  }
1008
1025
 
1009
1026
  .ui-skeleton {
1010
- min-height: 1rem;
1027
+ /* A default height, not a minimum, lets explicit styles and classes size placeholders below 1rem. */
1028
+ width: var(--ui-skeleton-width, auto);
1029
+ height: var(--ui-skeleton-height, 1rem);
1011
1030
  border-radius: var(--ui-radius-sm);
1012
1031
  background: linear-gradient(
1013
1032
  90deg,
@@ -1019,6 +1038,42 @@
1019
1038
  animation: ui-shimmer 2.2s linear infinite;
1020
1039
  }
1021
1040
 
1041
+ .ui-skeleton:where(:not(:empty)) {
1042
+ height: var(--ui-skeleton-height, auto);
1043
+ min-height: 1rem;
1044
+ }
1045
+
1046
+ lumen-skeleton.ui-skeleton,
1047
+ lumen-progress.ui-progress {
1048
+ display: block;
1049
+ }
1050
+
1051
+ .ui-skeleton--text {
1052
+ height: var(--ui-skeleton-height, 0.75rem);
1053
+ }
1054
+
1055
+ .ui-skeleton--circle {
1056
+ flex: 0 0 auto;
1057
+ width: var(--ui-skeleton-width, var(--ui-skeleton-height, 2.5rem));
1058
+ height: var(--ui-skeleton-height, var(--ui-skeleton-width, 2.5rem));
1059
+ border-radius: 50%;
1060
+ }
1061
+
1062
+ .ui-skeleton--pulse {
1063
+ background: hsl(var(--surface-strong));
1064
+ animation: ui-skeleton-pulse 1.6s ease-in-out infinite;
1065
+ }
1066
+
1067
+ .ui-skeleton--static {
1068
+ animation: none;
1069
+ }
1070
+
1071
+ @media (prefers-reduced-motion: reduce) {
1072
+ .ui-skeleton {
1073
+ animation: none;
1074
+ }
1075
+ }
1076
+
1022
1077
  .ui-spinner {
1023
1078
  box-sizing: border-box;
1024
1079
  display: inline-block;
@@ -1586,7 +1641,9 @@ lumen-dialog.ui-dialog:not([hidden]):has(> [data-slot="dialog-body"]) {
1586
1641
 
1587
1642
  .ui-input-otp__native[data-ui-enhanced="true"] {
1588
1643
  position: absolute;
1589
- width: 1px;
1644
+ inset-block-start: 0;
1645
+ inset-inline-start: 0;
1646
+ width: 100%;
1590
1647
  height: 1px;
1591
1648
  overflow: hidden;
1592
1649
  clip-path: inset(50%);