@santi020k/lumen-mcp 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/data/lumen-data.json +162 -75
- package/package.json +1 -1
package/data/lumen-data.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"BarChart": "8ebdc0421409ac0da721c5c618c4b59a2998bb895370ec4de64c914612d935ce",
|
|
18
18
|
"Breadcrumb": "700d5860a964387089f8fc8918145bb8e82fca04f0e6c6d8fb2e20bd90cdb152",
|
|
19
19
|
"Bubble": "61d1f46dd7b7695e8c1906faf487b5b8dc12c769b170af87269db24f0b91d916",
|
|
20
|
-
"Button": "
|
|
20
|
+
"Button": "acfa846c121335fc3f7a67c8657eb5035aeef1b2fddfb4a1e1d65d7dde84ee56",
|
|
21
21
|
"ButtonGroup": "fc3d52bc7eae343546394308b910f8f2d708f618c08eb0c1fc75c0ca93a58e48",
|
|
22
22
|
"Calendar": "e886a642f9bd9cb8d8fca14ccb17adbdb63441e09d850474907c99319e8ba24a",
|
|
23
23
|
"Callout": "86c2031542acbfd5cefb6447b32c4385ceb1936b6430d82bd8dcb158f5f90741",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"ContextMenu": "8b9b5b009deb7c99fb367bf68216d9fe144163b8cb759c46154e3d0bbaed5048",
|
|
43
43
|
"ColorPicker": "c5c777fd5aa2c9d91d8eff7ef6386af457807ab01a1400cebd7204c40fdedaec",
|
|
44
44
|
"DataTable": "a220a4eabab7bcd2c4a35987419db761e19ce11f120adfb523b44243daab5149",
|
|
45
|
-
"DatePicker": "
|
|
45
|
+
"DatePicker": "0097e68947d480d00cf378846694d7165fb1e30492c0fc744bb61b3406f251c1",
|
|
46
46
|
"DateRangePicker": "b9ca3d0d56e7a26710bfdedea2ab34d5e334b1bb31a9abbee7478252ba9b8123",
|
|
47
47
|
"Dialog": "2173bc40116da149c25d01f68b1d9cef7df3d1f0784dfae6775979bd420ea3a2",
|
|
48
48
|
"DialogHeader": "80d453ce7eed0a7953386108ca9f93ff2392af6c7d0df51d0ad84c07f5cef0f9",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"Label": "10cb7069a9a24205bda0fd87e74d79bb9066164870dd5959b52fbdea6cd7b242",
|
|
83
83
|
"Link": "d939a1c5f221f60c5dac42dc148434326728305fa0eb0e6b10094a538f9a7b04",
|
|
84
84
|
"LineChart": "74bce4665da745771ae12d37c427ea7815763b119d38c26a387519712750a1d5",
|
|
85
|
-
"ListBox": "
|
|
85
|
+
"ListBox": "b44218ebd86088871293c8a9b8f9858b90f8596c28ea4048b388759678831c26",
|
|
86
86
|
"Marker": "b2c3679b841cee6cc4ce6322d6dd7758f85a9371bc4817a750f6de4e7c0cc3d2",
|
|
87
87
|
"Menubar": "ceaa113420c1a99cc1d7e856c04b5a75d8569473ebd8a24a83c947ec3b6608e5",
|
|
88
88
|
"Message": "26c4a46c2090e9834c52dc56bfd2d84f7d70d9fcff6885270df9839eb895ab8e",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"PieChart": "27ae45992174becb95be0c7bf9a6b5d9b445f0dd82f916ee8beb1f453538ce05",
|
|
98
98
|
"Pill": "a0dffa43c0e089ae68d8dd11df5ccca3b83eb0acb5d777752f29475b0bb064a1",
|
|
99
99
|
"Popover": "91a1fc3fdc5b14b7c01fa5c9393d2b2257ed6da9b97977089f01e8643c0c1080",
|
|
100
|
-
"Progress": "
|
|
100
|
+
"Progress": "42a0a7e700806ff8c31dc793cf4e79fa62b59b6dd310571bcd775d034680081c",
|
|
101
101
|
"Prose": "36e61f7f8b4a6c74a2a716a73dbf22cb4f86fae092b1518aa0a0e68765cea68d",
|
|
102
102
|
"RadioGroup": "10423caa00dba5ef159036ba11d3845371e859656dd7c8cb86490f0e63da821f",
|
|
103
103
|
"Resizable": "a6246108a484cf509fb27385e60b7bddd07ce77af02518e2fa89a4c2f2ebaed7",
|
|
@@ -106,15 +106,15 @@
|
|
|
106
106
|
"ScrollProgress": "f2b2fcb0234a7eaf1488381037ecc69e93ec55989b1ae6f55d3b8749f5ba93a4",
|
|
107
107
|
"Schedule": "20cad55139cfc812c3bb221cd17d22fd9ce36af7dfde1c6af444bead018b0d63",
|
|
108
108
|
"SearchField": "1addd653896a728c84bfa9d39a7a05a01de8a9e7da56ef550c7594de72587a8f",
|
|
109
|
-
"Select": "
|
|
109
|
+
"Select": "861b3fa84249f5ffd9131b3daad461cd53a70d069efe682fccab00a8fa2b823e",
|
|
110
110
|
"Separator": "552aaaa93c8b96569f471c6f5de52e9951cfa3660f36c8b19202ffd36c009c0b",
|
|
111
111
|
"Sheet": "1745ea3173d28c270ff9f882e019b162457a31d732e452055d082a5442bc84ea",
|
|
112
112
|
"Sidebar": "48e299d7419f0172470bf4f12bbe83a191bb6af736478486882850d3bccf0c13",
|
|
113
|
-
"Skeleton": "
|
|
113
|
+
"Skeleton": "f01d4d32ba03d9cad69792a7ce12f1e6989defbe936cb10a11847e307890ac52",
|
|
114
114
|
"SkipLink": "7dda000ca2f4a06bc12862315543272aaff1dad6065a6283cb81593ee7b00e66",
|
|
115
115
|
"Slider": "a262873c940a39d0c6fec8815a97562f0b90a24e039e1f80ca896af06bccea16",
|
|
116
116
|
"Sparkline": "962ac9166de4fae7c6715603151b8db39b008888a2ff9ec52364f8bc328bb233",
|
|
117
|
-
"Spinner": "
|
|
117
|
+
"Spinner": "e81ebf2f4bf864e042834b8dba7cf8891d3dfe6c635e19402f811857f9cd9367",
|
|
118
118
|
"Switch": "78e684f4ba8e616c4a7f2518e9011af16a842b76fd9ff4218cffe60e040b4ee0",
|
|
119
119
|
"Table": "953879cf84ec1f7e05e6da4f0b690be5bb71a5249e93032895a52ab409dc94c7",
|
|
120
120
|
"Tabs": "dd4dc58ac56a0ee0435bace62c97e6502b8380e9287bcf1b7651d4aa499259e1",
|
|
@@ -216,43 +216,43 @@
|
|
|
216
216
|
"Range chart": "8ea75e50ab85881f9ba5d8919e2ab173aaf37c1202e8865b95dc4af289a67327",
|
|
217
217
|
"Combo chart": "65043d6894c8abcd3be2296fde4c3c2bc1d6b2cf72e2116e37193f924a3f4260",
|
|
218
218
|
"Theme": "679fe9e0d809e2cc52ca1d15274aee3537faf690666c9eb06a94e56e0c1386ef",
|
|
219
|
-
"Text": "
|
|
220
|
-
"Surface": "
|
|
219
|
+
"Text": "9c38c1261814e037a58ac93d1b5c2d6008a4409bc404383a14ba96aa46ff8602",
|
|
220
|
+
"Surface": "15e5e51f77b26a3880b4c8bd04df1a596c09f1e5b8f0163803458c473027c243",
|
|
221
221
|
"Icon": "279318eeae9955028e5fe7ea20080d671bf51d3c090fac80b66f77a540cfd114",
|
|
222
222
|
"Icon button": "de9debec2cbca588d9bdc05d81a6987db1f56d14684b4c05f147e3950ede9472",
|
|
223
|
-
"Button": "
|
|
224
|
-
"Text field": "
|
|
225
|
-
"Badge": "
|
|
226
|
-
"Divider": "
|
|
227
|
-
"Spinner": "
|
|
223
|
+
"Button": "3329aab89e51b786bb854636b8b9fdac531559ef072ed2475cd82c92f2496f49",
|
|
224
|
+
"Text field": "138f62d36ec29181ef7fcfc6f91c89da1f97a6f067ed42e16e26a826a0399555",
|
|
225
|
+
"Badge": "a741860205e8aa6bbaae1e35df61b5ed388447e79f68b8d67fe590753f2a772e",
|
|
226
|
+
"Divider": "9a6b2b8994c6793f732cdc9ae4bb967f422a07479c942c5ce2cfe6b00cc39bc0",
|
|
227
|
+
"Spinner": "eb6a4bf6e4737dfd170a575f1e99b5c9acd2337fb73b604a861db0f2b0879c50",
|
|
228
228
|
"Card": "251bcef892b82f9b9f55497966ebc80136b9b52785ab67eeb3e764e75aa86f72",
|
|
229
229
|
"Alert": "9a822c4778f64ae81d0dd10c3bd20bbba27858b07026c487c41cbb0eb8df6c18",
|
|
230
230
|
"Progress": "bd9809fc782b6ec9d2e75d3fdc56231df69127d8c41cd357ecbc608fc4f40d7a",
|
|
231
231
|
"Skeleton": "67bd48d90b5ee6456da8bdee9a6d6fbb2d010cbe137712f13fed0d26585607db",
|
|
232
232
|
"Disclosure": "419d032e9d995f9a0a5f4e8156a5c7f16b7c71642a7af4cd72e3f72b49c70628",
|
|
233
233
|
"Avatar": "eee8db476fd7078ff76126a186d545b930898c11159c89ebfce7f15b3f7b1243",
|
|
234
|
-
"Textarea": "
|
|
235
|
-
"Field group": "
|
|
236
|
-
"Chip": "
|
|
237
|
-
"Button group": "
|
|
238
|
-
"Toast": "
|
|
234
|
+
"Textarea": "27503a8403070323e49d88ddff0d86251a8804e30053119c41a7be7039b86c23",
|
|
235
|
+
"Field group": "b63191d08ffa40bc86c291a220a0309d315bf78368c15978659944f5a3a151f8",
|
|
236
|
+
"Chip": "3d533ab7de764317e5ebd204b7e2552648642c98891aa1a4ec2b59fa19be2385",
|
|
237
|
+
"Button group": "64493e805809ba9a20441f8a90a382e4bee1fb36d4499003bf0cab48db6bc6cb",
|
|
238
|
+
"Toast": "8c78b8b7a690b86ba74f9ba57a478b64992a48810ee1f9fe2cf0cda7868c37de",
|
|
239
239
|
"Toggle": "b09bb3c67fb3875e6b5f414d6286b10d45351d5235f0333bec36393431a3a5c6",
|
|
240
240
|
"Settings row": "371dcb32ec68b57373313f53c57e147fc3bea1298bb6b9c4466fe2b24860a129",
|
|
241
|
-
"Picker": "
|
|
242
|
-
"Slider": "
|
|
241
|
+
"Picker": "594233e68b35afc33eb251dccf2023e752359584b840109895dc56c8885a76bb",
|
|
242
|
+
"Slider": "cd56539eab6423965415a0d6cbd189de72e0f7904249b516f4a94aeff654c02a",
|
|
243
243
|
"Search field": "95d38f9bc17a33ed877ae4efc96d7ea75268d6a9cf2b20ceca5e6d94155eb60d",
|
|
244
244
|
"Phone input": "68e405abc8a6868b057d92d30445801b057ab7b831c92e76deb60c4415df6d95",
|
|
245
|
-
"Checkbox": "
|
|
246
|
-
"Radio group": "
|
|
247
|
-
"Segmented control": "
|
|
248
|
-
"Tabs": "
|
|
245
|
+
"Checkbox": "ba62a1796d67f5f4851b070816e04dbda76c0912d5bb4beec554271677f8ef47",
|
|
246
|
+
"Radio group": "eeef71b881b5a84031a4b2f8a3b202fe30cafcefdd9d5936787825b1016f93bb",
|
|
247
|
+
"Segmented control": "c8a6c7453ded870a3b587e82d48a5907cd7a8337e300026d2bbb09308988429f",
|
|
248
|
+
"Tabs": "7c2fe9fbb8e48d2e74c38efb7b83658dfa04d15f4fddb06c0a619e78c2755879",
|
|
249
249
|
"Navigation bar": "4ebb7b649cb490d2db19cd4ddee0e2ebef96e2b8513c7ebabe1750f5bdf630f6",
|
|
250
250
|
"Empty state": "3aa9d6d38bf30e1b0542eccb743be8ca8305628a70ac548f4d7ba06f506fdc16",
|
|
251
251
|
"Error state": "4fb2059980bf0aa245be77b6d748056e621643fd1aa3c6013a282818dd0af357",
|
|
252
252
|
"List row": "858e3c35181b8f8c29f12e2aac8a4c0df44e0f7e10b6ea20b96f476de395fa83",
|
|
253
253
|
"Banner": "e689f8db9772326e03bf8a69a47751d4d1892033b8c8d37b833206d867ffb145",
|
|
254
254
|
"Stat": "990c8aad85721625c94e924a1cb0d1611b727dbd33e6738c334f5f60e4b3ecc6",
|
|
255
|
-
"Gauge": "
|
|
255
|
+
"Gauge": "8fe85dcf9666d1107877e101499421b2b80b0de19a7983eb52b2fc991b632f22",
|
|
256
256
|
"Section header": "9fa81aac8b69c95dc974a4bf03d766dbadef032df92e535b556117e1e88ae257",
|
|
257
257
|
"Status bar": "e1666b25f04cfe88cfabe664f88184fd83442e588f953da9765ebbc1ba588579",
|
|
258
258
|
"Collapsible navigation bar": "7348ace3e82a85e039e3ef0e955b456bbbaf776031547a7c6b7082f9fb455084",
|
|
@@ -300,7 +300,7 @@
|
|
|
300
300
|
"Top app bar": "873805490b3ef07516a879843d785593d6b80120da88ca9514a1856a519e9d0a",
|
|
301
301
|
"Swipe actions": "8cff687b8a4a9ed528f4bff2f84f88d7f34ae03e51f60854117a40301c40fa6a",
|
|
302
302
|
"Multi select": "822c0d9b6abdf9638a254aa0c1f28a424fa40250f4edd9ecd62ad6526297a056",
|
|
303
|
-
"Range slider": "
|
|
303
|
+
"Range slider": "3f0b38a51f7f047a39e3def2fbd8d03fc8b2474978e4200f495dd2aa57db81b1",
|
|
304
304
|
"Adaptive list detail scaffold": "dad38baad36e690df3c353beb6c91162c120f2342d3f1d4b0f77f58ac22aff06",
|
|
305
305
|
"Mentions": "15cb940ee9d41b3077072b9b80ff8cd0959bc463f56d6b827c0f25243fb68c2a",
|
|
306
306
|
"Tour": "1a381c56d08e8a556e9bfbe928d92561a8491ee47bd1fabe04d426b86a634509",
|
|
@@ -3905,6 +3905,8 @@
|
|
|
3905
3905
|
},
|
|
3906
3906
|
"elements": {
|
|
3907
3907
|
"attributes": [
|
|
3908
|
+
"aria-busy",
|
|
3909
|
+
"aria-disabled",
|
|
3908
3910
|
"data-slot",
|
|
3909
3911
|
"disabled",
|
|
3910
3912
|
"loading",
|
|
@@ -3928,7 +3930,7 @@
|
|
|
3928
3930
|
"props": [],
|
|
3929
3931
|
"propsExtends": "HTMLElement attributes",
|
|
3930
3932
|
"registration": "defineLumenElements()",
|
|
3931
|
-
"source": "lumenButtonElementConfig = {\n attributeClasses: {\n variant: {\n default: 'ui-button--default',\n destructive: 'ui-button--destructive',\n ghost: 'ui-button--ghost',\n link: 'ui-button--link',\n outline: 'ui-button--outline',\n secondary: 'ui-button--secondary'\n },\n size: {\n default: 'ui-button--default-size',\n icon: 'ui-button--icon',\n lg: 'ui-button--lg',\n sm: 'ui-button--sm'\n },\n disabled: { true: 'ui-button--disabled' },\n loading: { true: 'ui-button--loading' }\n },\n baseClassName: 'ui-button',\n defaults: {\n 'data-slot': 'button',\n role: 'button',\n size: 'default',\n tabindex: '0',\n variant: 'default'\n },\n role: 'button',\n tagName: 'lumen-button'\n} as const satisfies LumenElementConfig",
|
|
3933
|
+
"source": "lumenButtonElementConfig = {\n attributeClasses: {\n variant: {\n default: 'ui-button--default',\n destructive: 'ui-button--destructive',\n ghost: 'ui-button--ghost',\n link: 'ui-button--link',\n outline: 'ui-button--outline',\n secondary: 'ui-button--secondary'\n },\n size: {\n default: 'ui-button--default-size',\n icon: 'ui-button--icon',\n lg: 'ui-button--lg',\n sm: 'ui-button--sm'\n },\n disabled: { true: 'ui-button--disabled' },\n loading: { true: 'ui-button--loading' }\n },\n baseClassName: 'ui-button',\n defaults: {\n 'data-slot': 'button',\n role: 'button',\n size: 'default',\n tabindex: '0',\n variant: 'default'\n },\n observedAttributes: ['variant', 'size', 'disabled', 'loading', 'aria-busy', 'aria-disabled'],\n role: 'button',\n tagName: 'lumen-button'\n} as const satisfies LumenElementConfig",
|
|
3932
3934
|
"styleImport": "import '@santi020k/lumen-elements/styles.css'",
|
|
3933
3935
|
"tagName": "lumen-button"
|
|
3934
3936
|
},
|
|
@@ -9907,7 +9909,7 @@
|
|
|
9907
9909
|
}
|
|
9908
9910
|
],
|
|
9909
9911
|
"propsExtends": "Omit<\n ComponentPropsWithoutRef<'input'>,\n 'defaultValue' | 'type' | 'value'\n> & SurfaceProps",
|
|
9910
|
-
"source": "export type DatePickerProps = Omit<\n ComponentPropsWithoutRef<'input'>,\n 'defaultValue' | 'type' | 'value'\n> &\nSurfaceProps & {\n defaultValue?: string\n formatDate?: (value: string) => string\n inputRef?: Ref<HTMLInputElement>\n labels?: { chooseDate?: string, previousMonth?: string, nextMonth?: string }\n locale?: string\n onValueChange?: (value: string) => void\n placeholder?: string\n value?: string\n}\n\nexport const DatePicker = ({\n className,\n defaultValue,\n disabled,\n formatDate,\n glass = false,\n id,\n inputRef,\n labels,\n locale,\n max,\n min,\n name,\n onChange,\n onInvalid,\n onValueChange,\n placeholder,\n readOnly,\n required,\n value,\n ...props\n}: DatePickerProps) => {\n const generatedId = useId()\n const datePickerId = id ?? generatedId\n const nativeInputId = `${datePickerId}-native`\n const popoverId = `${datePickerId}-popover`\n const rootRef = useRef<HTMLDivElement | null>(null)\n const nativeInputRef = useRef<HTMLInputElement | null>(null)\n const triggerRef = useRef<HTMLButtonElement | null>(null)\n const [internalValue, setInternalValue] = useState(defaultValue ?? '')\n const { open: isOpen, setOpen } = useDatePickerDisclosure(disabled === true || readOnly === true)\n const selectedValue = value ?? internalValue\n const hasSelectedValue = selectedValue !== ''\n const dateLabels = { ...resolveDateControlLabels(locale || 'en'), ...labels }\n const placeholderText = placeholder ?? dateLabels.chooseDate\n const maxStr = stringifyDatePickerConstraint(max)\n const minStr = stringifyDatePickerConstraint(min)\n const accessibleLabel = props['aria-label']\n\n useEffect(() => {\n const owner = nativeInputRef.current?.form\n let active = true\n let resetTimer: ReturnType<typeof globalThis.setTimeout> | undefined\n\n const reset = (event: Event) => {\n globalThis.clearTimeout(resetTimer)\n\n resetTimer = globalThis.setTimeout(() => {\n if (!active || event.defaultPrevented || !nativeInputRef.current?.isConnected) return\n\n if (value === undefined) setInternalValue(defaultValue ?? '')\n\n setOpen(false)\n })\n }\n\n owner?.addEventListener('reset', reset)\n\n return () => {\n active = false\n\n globalThis.clearTimeout(resetTimer)\n\n owner?.removeEventListener('reset', reset)\n }\n }, [defaultValue, props.form, value, setOpen])\n\n useEffect(() => {\n if (!isOpen) return\n\n const root = rootRef.current\n\n if (!root) return\n\n const owner = root.ownerDocument\n\n const handlePointerDown = (event: MouseEvent) => {\n if (!event.composedPath().includes(root)) setOpen(false)\n }\n\n owner.addEventListener('mousedown', handlePointerDown)\n\n rootRef.current?.querySelector<HTMLElement>('[role=\"gridcell\"][tabindex=\"0\"]')?.focus({ preventScroll: true })\n\n return () => {\n owner.removeEventListener('mousedown', handlePointerDown)\n }\n }, [isOpen, setOpen])\n\n const selectDate = (nextValue: string) => {\n if (disabled || readOnly) return\n\n if (value === undefined) setInternalValue(nextValue)\n\n onValueChange?.(nextValue)\n\n if (nativeInputRef.current) {\n // Use the native setter so React's change event observes the selected value.\n const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')\n\n descriptor?.set?.call(nativeInputRef.current, nextValue)\n\n nativeInputRef.current.dispatchEvent(\n new Event('input', { bubbles: true })\n )\n\n nativeInputRef.current.dispatchEvent(\n new Event('change', { bubbles: true })\n )\n }\n\n setOpen(false)\n\n globalThis.setTimeout(() => triggerRef.current?.focus())\n }\n\n return (\n <div\n className={composeClassName(\n 'ui-date-picker-field', glass && 'ui-date-picker-field--glass', glass === 'subtle' && 'ui-glass-subtle', glass === 'strong' && 'ui-glass-strong', className\n )}\n data-placeholder={hasSelectedValue ? undefined : 'true'}\n data-ui-date-picker\n data-ui-glass-track={glass ? true : undefined}\n ref={rootRef}\n onKeyDown={event => {\n if (!isOpen || event.key !== 'Escape') return\n\n event.preventDefault()\n\n event.stopPropagation()\n\n setOpen(false)\n\n triggerRef.current?.focus({ preventScroll: true })\n }}\n >\n <input\n aria-hidden=\"true\"\n className=\"ui-date-picker ui-date-picker__native\"\n data-ui-date-picker-native\n data-ui-enhanced=\"true\"\n disabled={disabled}\n id={nativeInputId}\n max={max}\n min={min}\n name={name}\n onChange={event => {\n if (value === undefined) setInternalValue(event.currentTarget.value)\n\n onChange?.(event)\n }}\n onInvalid={event => {\n event.preventDefault()\n\n
|
|
9912
|
+
"source": "export type DatePickerProps = Omit<\n ComponentPropsWithoutRef<'input'>,\n 'defaultValue' | 'type' | 'value'\n> &\nSurfaceProps & {\n defaultValue?: string\n formatDate?: (value: string) => string\n inputRef?: Ref<HTMLInputElement>\n labels?: { chooseDate?: string, previousMonth?: string, nextMonth?: string }\n locale?: string\n onValueChange?: (value: string) => void\n placeholder?: string\n value?: string\n}\n\nexport const DatePicker = ({\n className,\n defaultValue,\n disabled,\n formatDate,\n glass = false,\n id,\n inputRef,\n labels,\n locale,\n max,\n min,\n name,\n onChange,\n onInvalid,\n onValueChange,\n placeholder,\n readOnly,\n required,\n value,\n ...props\n}: DatePickerProps) => {\n const generatedId = useId()\n const datePickerId = id ?? generatedId\n const nativeInputId = `${datePickerId}-native`\n const popoverId = `${datePickerId}-popover`\n const rootRef = useRef<HTMLDivElement | null>(null)\n const nativeInputRef = useRef<HTMLInputElement | null>(null)\n const triggerRef = useRef<HTMLButtonElement | null>(null)\n const popoverRef = useRef<HTMLDivElement | null>(null)\n const [internalValue, setInternalValue] = useState(defaultValue ?? '')\n const { open: isOpen, setOpen } = useDatePickerDisclosure(disabled === true || readOnly === true)\n const selectedValue = value ?? internalValue\n const hasSelectedValue = selectedValue !== ''\n const dateLabels = { ...resolveDateControlLabels(locale || 'en'), ...labels }\n const placeholderText = placeholder ?? dateLabels.chooseDate\n const maxStr = stringifyDatePickerConstraint(max)\n const minStr = stringifyDatePickerConstraint(min)\n const accessibleLabel = props['aria-label']\n const consumerInvalid = props['aria-invalid']\n const invalidAttemptedRef = useRef(false)\n\n const closePicker = useCallback(() => {\n setOpen(false)\n }, [setOpen])\n\n // Lift the calendar into the top layer so scrolling dialogs cannot clip it.\n useFloatingPanel(isOpen, triggerRef, popoverRef, closePicker, {})\n\n const syncValidity = useCallback(() => {\n const native = nativeInputRef.current\n const trigger = triggerRef.current\n\n if (!native || !trigger) return\n\n if (native.validity.valid) invalidAttemptedRef.current = false\n\n const invalid = consumerInvalid ?? (invalidAttemptedRef.current ? true : undefined)\n\n if (invalid === undefined) trigger.removeAttribute('aria-invalid')\n else trigger.setAttribute('aria-invalid', String(invalid))\n }, [consumerInvalid])\n\n useLayoutEffect(syncValidity, [disabled, max, min, readOnly, required, selectedValue, syncValidity])\n\n useEffect(() => {\n const owner = nativeInputRef.current?.form\n let active = true\n let resetTimer: ReturnType<typeof globalThis.setTimeout> | undefined\n\n const reset = (event: Event) => {\n globalThis.clearTimeout(resetTimer)\n\n resetTimer = globalThis.setTimeout(() => {\n if (!active || event.defaultPrevented || !nativeInputRef.current?.isConnected) return\n\n invalidAttemptedRef.current = false\n\n syncValidity()\n\n if (value === undefined) setInternalValue(defaultValue ?? '')\n\n setOpen(false)\n })\n }\n\n owner?.addEventListener('reset', reset)\n\n return () => {\n active = false\n\n globalThis.clearTimeout(resetTimer)\n\n owner?.removeEventListener('reset', reset)\n }\n }, [defaultValue, props.form, value, setOpen, syncValidity])\n\n useEffect(() => {\n if (!isOpen) return\n\n const root = rootRef.current\n\n if (!root) return\n\n const owner = root.ownerDocument\n\n const handlePointerDown = (event: MouseEvent) => {\n if (!event.composedPath().includes(root)) setOpen(false)\n }\n\n const handleKeyDown = (event: globalThis.KeyboardEvent) => {\n if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing) return\n\n event.preventDefault()\n\n setOpen(false)\n\n triggerRef.current?.focus({ preventScroll: true })\n }\n\n owner.addEventListener('keydown', handleKeyDown)\n\n owner.addEventListener('mousedown', handlePointerDown)\n\n rootRef.current?.querySelector<HTMLElement>('[role=\"gridcell\"][tabindex=\"0\"]')?.focus({ preventScroll: true })\n\n return () => {\n owner.removeEventListener('mousedown', handlePointerDown)\n\n owner.removeEventListener('keydown', handleKeyDown)\n }\n }, [isOpen, setOpen])\n\n const selectDate = (nextValue: string) => {\n if (disabled || readOnly) return\n\n if (value === undefined) setInternalValue(nextValue)\n\n onValueChange?.(nextValue)\n\n if (nativeInputRef.current) {\n // Use the native setter so React's change event observes the selected value.\n const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')\n\n descriptor?.set?.call(nativeInputRef.current, nextValue)\n\n nativeInputRef.current.dispatchEvent(\n new Event('input', { bubbles: true })\n )\n\n nativeInputRef.current.dispatchEvent(\n new Event('change', { bubbles: true })\n )\n }\n\n setOpen(false)\n\n globalThis.setTimeout(() => triggerRef.current?.focus())\n }\n\n return (\n <div\n className={composeClassName(\n 'ui-date-picker-field', glass && 'ui-date-picker-field--glass', glass === 'subtle' && 'ui-glass-subtle', glass === 'strong' && 'ui-glass-strong', className\n )}\n data-placeholder={hasSelectedValue ? undefined : 'true'}\n data-ui-date-picker\n data-ui-owner=\"react\"\n data-ui-glass-track={glass ? true : undefined}\n ref={rootRef}\n onKeyDown={event => {\n if (!isOpen || event.key !== 'Escape' || event.defaultPrevented || event.nativeEvent.isComposing) return\n\n event.preventDefault()\n\n event.stopPropagation()\n\n setOpen(false)\n\n triggerRef.current?.focus({ preventScroll: true })\n }}\n >\n <input\n aria-hidden=\"true\"\n className=\"ui-date-picker ui-date-picker__native\"\n data-ui-date-picker-native\n data-ui-enhanced=\"true\"\n disabled={disabled}\n id={nativeInputId}\n max={max}\n min={min}\n name={name}\n onChange={event => {\n if (value === undefined) setInternalValue(event.currentTarget.value)\n\n syncValidity()\n\n onChange?.(event)\n }}\n onInvalid={event => {\n event.preventDefault()\n\n invalidAttemptedRef.current = true\n\n syncValidity()\n\n triggerRef.current?.focus()\n\n onInvalid?.(event)\n }}\n ref={node => {\n nativeInputRef.current = node\n\n setRefValue(inputRef, node)\n }}\n required={required}\n readOnly={readOnly}\n tabIndex={-1}\n type=\"date\"\n value={selectedValue}\n {...props}\n />\n <div className=\"ui-date-picker__control\" data-ui-date-picker-control>\n <button\n aria-controls={popoverId}\n aria-expanded={isOpen}\n aria-haspopup=\"dialog\"\n aria-label={accessibleLabel}\n aria-labelledby={props['aria-labelledby']}\n aria-describedby={props['aria-describedby']}\n aria-errormessage={props['aria-errormessage']}\n aria-invalid={consumerInvalid}\n aria-disabled={readOnly ? true : undefined}\n aria-required={required ?? undefined}\n className=\"ui-input ui-date-picker ui-date-picker__trigger\"\n data-ui-date-picker-trigger\n disabled={disabled}\n id={datePickerId}\n onClick={() => {\n if (disabled || readOnly) return\n\n setOpen(!isOpen)\n }}\n onKeyDown={event => {\n if (event.key !== 'ArrowDown' || disabled || readOnly) return\n\n event.preventDefault()\n\n setOpen(true)\n }}\n ref={triggerRef}\n type=\"button\"\n >\n <span data-ui-date-picker-value>\n {formatDatePickerDisplayValue(selectedValue, placeholderText, locale, formatDate)}\n </span>\n <svg\n aria-hidden=\"true\"\n className=\"ui-date-picker__icon\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M7 2v3M17 2v3M3.5 9h17M5 4h14a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z\" />\n </svg>\n </button>\n <div\n aria-label={dateLabels.chooseDate}\n className=\"ui-date-picker__popover\"\n data-state={isOpen ? 'open' : 'closed'}\n data-ui-date-picker-popover\n hidden={!isOpen}\n id={popoverId}\n ref={popoverRef}\n role=\"dialog\"\n >\n <Calendar\n key={isOpen ? 'open' : 'closed'}\n disabled={disabled}\n readOnly={readOnly}\n labels={dateLabels}\n locale={locale}\n max={maxStr}\n min={minStr}\n onValueChange={selectDate}\n value={selectedValue}\n />\n </div>\n </div>\n </div>\n )\n}",
|
|
9911
9913
|
"styleImport": "import '@santi020k/lumen-react/styles.css'"
|
|
9912
9914
|
}
|
|
9913
9915
|
},
|
|
@@ -18868,7 +18870,7 @@
|
|
|
18868
18870
|
"values": "HTML attributes"
|
|
18869
18871
|
}
|
|
18870
18872
|
],
|
|
18871
|
-
"astroSource": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface ListBoxOption {\n disabled?: boolean\n label: string\n section?: string\n value: string\n}\n\ninterface Props extends Omit<HTMLAttributes<'select'>, 'size' | 'value'> {\n class?: string | null\n className?: string\n emptyMessage?: string\n label?: string\n loading?: boolean\n loadingMessage?: string\n multiple?: boolean\n options?: (ListBoxOption | string)[]\n selectionFollowsFocus?: boolean\n value?: string | string[]\n}\n\nconst {\n class: classProp = '',\n className = '',\n disabled = false,\n emptyMessage = 'No options available',\n id,\n label,\n loading = false,\n loadingMessage = 'Loading options',\n multiple = false,\n name,\n options = [],\n required = false,\n selectionFollowsFocus = false,\n value,\n ...rest\n} = Astro.props\n\nconst normalizedOptions = options.map(option => typeof option === 'string' ? { label: option, value: option } : option)\nconst optionGroups: { label?: string, options: ListBoxOption[] }[] = []\n\nfor (const option of normalizedOptions) {\n const currentGroup = optionGroups.at(-1)\n\n if (!currentGroup || currentGroup.label !== option.section) {\n optionGroups.push({\n ...(option.section ? { label: option.section } : {}),\n options: [option]\n })\n } else {\n currentGroup.options.push(option)\n }\n}\n\nlet initialValues: string[] = []\n\nif (Array.isArray(value)) initialValues = value\nelse if (value !== undefined) initialValues = [value]\n\nconst selectedValues = new Set(initialValues)\nconst controlId = id ?? `ui-list-box-${name
|
|
18873
|
+
"astroSource": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface ListBoxOption {\n disabled?: boolean\n label: string\n section?: string\n value: string\n}\n\ninterface Props extends Omit<HTMLAttributes<'select'>, 'size' | 'value'> {\n class?: string | null\n className?: string\n emptyMessage?: string\n label?: string\n loading?: boolean\n loadingMessage?: string\n multiple?: boolean\n options?: (ListBoxOption | string)[]\n selectionFollowsFocus?: boolean\n value?: string | string[]\n}\n\nconst {\n class: classProp = '',\n className = '',\n disabled = false,\n emptyMessage = 'No options available',\n id,\n label,\n loading = false,\n loadingMessage = 'Loading options',\n multiple = false,\n name,\n options = [],\n required = false,\n selectionFollowsFocus = false,\n value,\n ...rest\n} = Astro.props\n\nconst normalizedOptions = options.map(option => typeof option === 'string' ? { label: option, value: option } : option)\nconst optionGroups: { label?: string, options: ListBoxOption[] }[] = []\n\nfor (const option of normalizedOptions) {\n const currentGroup = optionGroups.at(-1)\n\n if (!currentGroup || currentGroup.label !== option.section) {\n optionGroups.push({\n ...(option.section ? { label: option.section } : {}),\n options: [option]\n })\n } else {\n currentGroup.options.push(option)\n }\n}\n\nlet initialValues: string[] = []\n\nif (Array.isArray(value)) initialValues = value\nelse if (value !== undefined) initialValues = [value]\n\nconst selectedValues = new Set(initialValues)\n// Named lists keep their stable id; unnamed lists need a unique one so labels and options never collide.\nconst controlId = id ?? (name ? `ui-list-box-${name}` : `ui-list-box-${Math.random().toString(36).slice(2)}`)\nconst listId = `${controlId}-list`\nconst { classList, passthrough } = resolveLumenAstroProps(rest, ['ui-list-box'], classProp, className)\n---\n\n<div\n class:list={classList}\n data-loading={loading ? 'true' : undefined}\n data-multiple={multiple ? 'true' : undefined}\n data-selection-follows-focus={selectionFollowsFocus ? 'true' : undefined}\n data-ui-list-box\n>\n {label && <label class=\"ui-label\" for={controlId}>{label}</label>}\n <select\n class=\"ui-list-box__native\"\n data-ui-list-box-native\n disabled={disabled || loading}\n id={controlId}\n multiple={multiple}\n name={name}\n required={required}\n size={Math.min(Math.max(normalizedOptions.length, 2), 8)}\n {...passthrough}\n >\n {optionGroups.map(group => group.label ?\n (\n <optgroup label={group.label}>\n {group.options.map(option => (\n <option\n disabled={option.disabled}\n selected={selectedValues.has(option.value)}\n value={option.value}\n >\n {option.label}\n </option>\n ))}\n </optgroup>\n ) :\n group.options.map(option => (\n <option\n disabled={option.disabled}\n selected={selectedValues.has(option.value)}\n value={option.value}\n >\n {option.label}\n </option>\n )))}\n </select>\n <div\n aria-busy={loading ? 'true' : undefined}\n aria-disabled={disabled || loading ? 'true' : undefined}\n aria-multiselectable={multiple ? 'true' : undefined}\n class=\"ui-list-box__list\"\n data-ui-list-box-list\n hidden\n id={listId}\n role=\"listbox\"\n tabindex={disabled || loading ? undefined : '0'}\n >\n {!loading && !normalizedOptions.length && (\n <div aria-disabled=\"true\" class=\"ui-list-box__empty\" role=\"option\">{emptyMessage}</div>\n )}\n {!loading && optionGroups.map((group, groupIndex) => {\n const groupLabelId = group.label ? `${controlId}-group-${groupIndex}` : undefined\n\n const renderedOptions = group.options.map(option => (\n <div\n aria-disabled={option.disabled ? 'true' : undefined}\n aria-selected={selectedValues.has(option.value) ? 'true' : 'false'}\n data-ui-list-box-option\n data-value={option.value}\n role=\"option\"\n >\n {option.label}\n </div>\n ))\n\n return group.label ?\n (\n <div aria-labelledby={groupLabelId} role=\"group\">\n <div class=\"ui-list-box__section\" id={groupLabelId}>{group.label}</div>\n {renderedOptions}\n </div>\n ) :\n renderedOptions\n })}\n </div>\n {loading && <p class=\"ui-list-box__status\" role=\"status\">{loadingMessage}</p>}\n</div>\n",
|
|
18872
18874
|
"category": "Forms",
|
|
18873
18875
|
"collections": [],
|
|
18874
18876
|
"contract": null,
|
|
@@ -18951,7 +18953,7 @@
|
|
|
18951
18953
|
}
|
|
18952
18954
|
],
|
|
18953
18955
|
"propsExtends": "Omit<HTMLAttributes<'select'>, 'size' | 'value'>",
|
|
18954
|
-
"source": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface ListBoxOption {\n disabled?: boolean\n label: string\n section?: string\n value: string\n}\n\ninterface Props extends Omit<HTMLAttributes<'select'>, 'size' | 'value'> {\n class?: string | null\n className?: string\n emptyMessage?: string\n label?: string\n loading?: boolean\n loadingMessage?: string\n multiple?: boolean\n options?: (ListBoxOption | string)[]\n selectionFollowsFocus?: boolean\n value?: string | string[]\n}\n\nconst {\n class: classProp = '',\n className = '',\n disabled = false,\n emptyMessage = 'No options available',\n id,\n label,\n loading = false,\n loadingMessage = 'Loading options',\n multiple = false,\n name,\n options = [],\n required = false,\n selectionFollowsFocus = false,\n value,\n ...rest\n} = Astro.props\n\nconst normalizedOptions = options.map(option => typeof option === 'string' ? { label: option, value: option } : option)\nconst optionGroups: { label?: string, options: ListBoxOption[] }[] = []\n\nfor (const option of normalizedOptions) {\n const currentGroup = optionGroups.at(-1)\n\n if (!currentGroup || currentGroup.label !== option.section) {\n optionGroups.push({\n ...(option.section ? { label: option.section } : {}),\n options: [option]\n })\n } else {\n currentGroup.options.push(option)\n }\n}\n\nlet initialValues: string[] = []\n\nif (Array.isArray(value)) initialValues = value\nelse if (value !== undefined) initialValues = [value]\n\nconst selectedValues = new Set(initialValues)\nconst controlId = id ?? `ui-list-box-${name
|
|
18956
|
+
"source": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface ListBoxOption {\n disabled?: boolean\n label: string\n section?: string\n value: string\n}\n\ninterface Props extends Omit<HTMLAttributes<'select'>, 'size' | 'value'> {\n class?: string | null\n className?: string\n emptyMessage?: string\n label?: string\n loading?: boolean\n loadingMessage?: string\n multiple?: boolean\n options?: (ListBoxOption | string)[]\n selectionFollowsFocus?: boolean\n value?: string | string[]\n}\n\nconst {\n class: classProp = '',\n className = '',\n disabled = false,\n emptyMessage = 'No options available',\n id,\n label,\n loading = false,\n loadingMessage = 'Loading options',\n multiple = false,\n name,\n options = [],\n required = false,\n selectionFollowsFocus = false,\n value,\n ...rest\n} = Astro.props\n\nconst normalizedOptions = options.map(option => typeof option === 'string' ? { label: option, value: option } : option)\nconst optionGroups: { label?: string, options: ListBoxOption[] }[] = []\n\nfor (const option of normalizedOptions) {\n const currentGroup = optionGroups.at(-1)\n\n if (!currentGroup || currentGroup.label !== option.section) {\n optionGroups.push({\n ...(option.section ? { label: option.section } : {}),\n options: [option]\n })\n } else {\n currentGroup.options.push(option)\n }\n}\n\nlet initialValues: string[] = []\n\nif (Array.isArray(value)) initialValues = value\nelse if (value !== undefined) initialValues = [value]\n\nconst selectedValues = new Set(initialValues)\n// Named lists keep their stable id; unnamed lists need a unique one so labels and options never collide.\nconst controlId = id ?? (name ? `ui-list-box-${name}` : `ui-list-box-${Math.random().toString(36).slice(2)}`)\nconst listId = `${controlId}-list`\nconst { classList, passthrough } = resolveLumenAstroProps(rest, ['ui-list-box'], classProp, className)\n---\n\n<div\n class:list={classList}\n data-loading={loading ? 'true' : undefined}\n data-multiple={multiple ? 'true' : undefined}\n data-selection-follows-focus={selectionFollowsFocus ? 'true' : undefined}\n data-ui-list-box\n>\n {label && <label class=\"ui-label\" for={controlId}>{label}</label>}\n <select\n class=\"ui-list-box__native\"\n data-ui-list-box-native\n disabled={disabled || loading}\n id={controlId}\n multiple={multiple}\n name={name}\n required={required}\n size={Math.min(Math.max(normalizedOptions.length, 2), 8)}\n {...passthrough}\n >\n {optionGroups.map(group => group.label ?\n (\n <optgroup label={group.label}>\n {group.options.map(option => (\n <option\n disabled={option.disabled}\n selected={selectedValues.has(option.value)}\n value={option.value}\n >\n {option.label}\n </option>\n ))}\n </optgroup>\n ) :\n group.options.map(option => (\n <option\n disabled={option.disabled}\n selected={selectedValues.has(option.value)}\n value={option.value}\n >\n {option.label}\n </option>\n )))}\n </select>\n <div\n aria-busy={loading ? 'true' : undefined}\n aria-disabled={disabled || loading ? 'true' : undefined}\n aria-multiselectable={multiple ? 'true' : undefined}\n class=\"ui-list-box__list\"\n data-ui-list-box-list\n hidden\n id={listId}\n role=\"listbox\"\n tabindex={disabled || loading ? undefined : '0'}\n >\n {!loading && !normalizedOptions.length && (\n <div aria-disabled=\"true\" class=\"ui-list-box__empty\" role=\"option\">{emptyMessage}</div>\n )}\n {!loading && optionGroups.map((group, groupIndex) => {\n const groupLabelId = group.label ? `${controlId}-group-${groupIndex}` : undefined\n\n const renderedOptions = group.options.map(option => (\n <div\n aria-disabled={option.disabled ? 'true' : undefined}\n aria-selected={selectedValues.has(option.value) ? 'true' : 'false'}\n data-ui-list-box-option\n data-value={option.value}\n role=\"option\"\n >\n {option.label}\n </div>\n ))\n\n return group.label ?\n (\n <div aria-labelledby={groupLabelId} role=\"group\">\n <div class=\"ui-list-box__section\" id={groupLabelId}>{group.label}</div>\n {renderedOptions}\n </div>\n ) :\n renderedOptions\n })}\n </div>\n {loading && <p class=\"ui-list-box__status\" role=\"status\">{loadingMessage}</p>}\n</div>\n",
|
|
18955
18957
|
"styleImport": "import '@santi020k/lumen-astro/styles.css'"
|
|
18956
18958
|
},
|
|
18957
18959
|
"elements": {
|
|
@@ -19040,7 +19042,7 @@
|
|
|
19040
19042
|
}
|
|
19041
19043
|
],
|
|
19042
19044
|
"propsExtends": "Omit<\n ComponentPropsWithRef<'select'>,\n 'children' | 'defaultValue' | 'onChange' | 'size' | 'value'\n>",
|
|
19043
|
-
"source": "export interface ListBoxProps extends Omit<\n ComponentPropsWithRef<'select'>,\n 'children' | 'defaultValue' | 'onChange' | 'size' | 'value'\n> {\n defaultValue?: string | string[]\n emptyMessage?: ReactNode\n label?: ReactNode\n loading?: boolean\n loadingMessage?: ReactNode\n onValueChange?: (values: string[]) => void\n options?: SelectOption[]\n selectionFollowsFocus?: boolean\n value?: string | string[]\n}\n\nexport const ListBox = (inputProps: ListBoxProps) => {\n const {\n className,\n defaultValue,\n disabled,\n emptyMessage,\n id,\n label,\n loading,\n loadingMessage,\n multiple,\n name,\n onValueChange,\n options,\n ref,\n required,\n selectionFollowsFocus,\n value,\n ...props\n } = normalizeListBoxProps(inputProps)\n\n const generatedId = useId()\n const controlId = id ?? `ui-list-box-${generatedId}`\n\n const normalizedOptions = options.map(\n option => typeof option === 'string' ? { label: option, value: option } : option\n )\n\n const optionGroups = groupListBoxOptions(normalizedOptions)\n const [internalValues, setInternalValues] = useState(() => normalizeListBoxValues(defaultValue))\n const selectedValues = getListBoxSelectedValues(value, internalValues)\n const selectedSet = new Set(selectedValues)\n\n const firstEnabledIndex = Math.max(\n 0, normalizedOptions.findIndex(option => !option.disabled)\n )\n\n const [activeIndex, setActiveIndex] = useState(firstEnabledIndex)\n const selectRef = useRef<HTMLSelectElement | null>(null)\n const typeaheadRef = useRef('')\n\n const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(\n undefined\n )\n\n const commitValues = (nextValues: string[]) => {\n if (value === undefined) {\n setInternalValues(nextValues)\n }\n\n onValueChange?.(nextValues)\n\n const select = selectRef.current\n\n if (!select) return\n\n for (const option of select.options) {\n option.selected = nextValues.includes(option.value)\n }\n\n select.dispatchEvent(new Event('input', { bubbles: true }))\n\n select.dispatchEvent(new Event('change', { bubbles: true }))\n }\n\n const selectOption = (index: number) => {\n const option = normalizedOptions[index]\n\n if (!option || option.disabled || disabled || loading) return\n\n const optionValue = option.value\n let nextValues: string[]\n\n if (!multiple) {\n nextValues = [optionValue]\n } else if (selectedSet.has(optionValue)) {\n nextValues = selectedValues.filter(\n selectedValue => selectedValue !== optionValue\n )\n } else {\n nextValues = [...selectedValues, optionValue]\n }\n\n commitValues(nextValues)\n }\n\n const moveActive = (direction: 1 | -1) => {\n if (!normalizedOptions.length || loading) return\n\n let nextIndex = activeIndex\n\n for (const _option of normalizedOptions) {\n nextIndex =\n (nextIndex + direction + normalizedOptions.length) %\n normalizedOptions.length\n\n if (!normalizedOptions[nextIndex]?.disabled) {\n setActiveIndex(nextIndex)\n\n if (selectionFollowsFocus) selectOption(nextIndex)\n\n return\n }\n }\n }\n\n useEffect(\n () => () => {\n if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current)\n }, []\n )\n\n useEffect(() => {\n const select = selectRef.current\n const form = select?.form\n\n if (!form || value !== undefined) return\n\n const handleReset = () => {\n setInternalValues(normalizeListBoxValues(defaultValue))\n\n setActiveIndex(firstEnabledIndex)\n }\n\n form.addEventListener('reset', handleReset)\n\n return () => {\n form.removeEventListener('reset', handleReset)\n }\n }, [defaultValue, firstEnabledIndex, value])\n\n const handleNavigationKey = (key: string): boolean => {\n if (key === 'ArrowDown') moveActive(1)\n else if (key === 'ArrowUp') moveActive(-1)\n else if (key === 'Home') {\n setActiveIndex(firstEnabledIndex)\n\n if (selectionFollowsFocus) selectOption(firstEnabledIndex)\n } else if (key === 'End') {\n const lastEnabledIndex = findLastEnabledOptionIndex(normalizedOptions)\n\n if (lastEnabledIndex >= 0) {\n setActiveIndex(lastEnabledIndex)\n\n if (selectionFollowsFocus) selectOption(lastEnabledIndex)\n }\n } else if (key === 'Enter' || key === ' ') selectOption(activeIndex)\n else return false\n\n return true\n }\n\n const handleTypeaheadKey = (event: KeyboardEvent<HTMLDivElement>): boolean => {\n if (\n event.key.length !== 1 ||\n event.ctrlKey ||\n event.metaKey ||\n event.altKey\n ) return false\n\n typeaheadRef.current += event.key.toLowerCase()\n\n if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current)\n\n typeaheadTimerRef.current = setTimeout(() => {\n typeaheadRef.current = ''\n }, 700)\n\n const nextIndex = normalizedOptions.findIndex(\n option => !option.disabled &&\n option.label.toLowerCase().startsWith(typeaheadRef.current)\n )\n\n if (nextIndex >= 0) {\n setActiveIndex(nextIndex)\n\n if (selectionFollowsFocus) selectOption(nextIndex)\n }\n\n return true\n }\n\n const handleListKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (handleNavigationKey(event.key) || handleTypeaheadKey(event)) event.preventDefault()\n }\n\n return (\n <div\n className={composeClassName('ui-list-box', className)}\n data-multiple={multiple ? 'true' : undefined}\n data-ui-list-box\n >\n <ListBoxLabel controlId={controlId} label={label} />\n <select\n {...props}\n className=\"ui-list-box__native\"\n data-ui-enhanced\n data-ui-list-box-native\n disabled={disabled || loading}\n id={controlId}\n multiple={multiple}\n name={name}\n ref={node => {\n selectRef.current = node\n\n setRefValue(ref, node)\n }}\n required={required}\n value={getListBoxSelectValue(multiple, selectedValues)}\n >\n <ListBoxNativeOptions groups={optionGroups} />\n </select>\n <div\n aria-busy={optionalTrue(loading)}\n aria-disabled={optionalTrue(disabled || loading)}\n aria-multiselectable={optionalTrue(multiple)}\n className=\"ui-list-box__list\"\n data-ui-list-box-list\n onKeyDown={handleListKeyDown}\n role=\"listbox\"\n tabIndex={disabled || loading ? undefined : 0}\n >\n <ListBoxVisibleOptions\n activeIndex={activeIndex}\n controlId={controlId}\n emptyMessage={emptyMessage}\n groups={optionGroups}\n loading={loading}\n onSelect={index => {\n setActiveIndex(index)\n\n selectOption(index)\n }}\n selectedValues={selectedSet}\n />\n </div>\n {loading ?\n (\n <p className=\"ui-list-box__status\" role=\"status\">\n {loadingMessage}\n </p>\n ) :\n null}\n </div>\n )\n}",
|
|
19045
|
+
"source": "export interface ListBoxProps extends Omit<\n ComponentPropsWithRef<'select'>,\n 'children' | 'defaultValue' | 'onChange' | 'size' | 'value'\n> {\n defaultValue?: string | string[]\n emptyMessage?: ReactNode\n label?: ReactNode\n loading?: boolean\n loadingMessage?: ReactNode\n onValueChange?: (values: string[]) => void\n options?: SelectOption[]\n selectionFollowsFocus?: boolean\n value?: string | string[]\n}\n\nexport const ListBox = (inputProps: ListBoxProps) => {\n const {\n className,\n defaultValue,\n disabled,\n emptyMessage,\n id,\n label,\n loading,\n loadingMessage,\n multiple,\n name,\n onValueChange,\n options,\n ref,\n required,\n selectionFollowsFocus,\n value,\n ...props\n } = normalizeListBoxProps(inputProps)\n\n const generatedId = useId()\n const controlId = id ?? `ui-list-box-${generatedId}`\n\n const normalizedOptions = options.map(\n option => typeof option === 'string' ? { label: option, value: option } : option\n )\n\n const optionGroups = groupListBoxOptions(normalizedOptions)\n const [internalValues, setInternalValues] = useState(() => normalizeListBoxValues(defaultValue))\n const selectedValues = getListBoxSelectedValues(value, internalValues)\n const selectedSet = new Set(selectedValues)\n const firstEnabledIndex = normalizedOptions.findIndex(option => !option.disabled)\n const isEnabledOption = (index: number): boolean => isEnabledListBoxOption(normalizedOptions, index)\n // -1 means \"no explicit active option\"; see resolveListBoxActiveIndex.\n const [activeIndex, setActiveIndex] = useState(-1)\n const resolvedActiveIndex = resolveListBoxActiveIndex(normalizedOptions, activeIndex, selectedSet)\n const selectRef = useRef<HTMLSelectElement | null>(null)\n const listRef = useRef<HTMLDivElement | null>(null)\n const rootRef = useRef<HTMLDivElement | null>(null)\n // Labels that point at the select by id name the visible list, since the select is hidden.\n const [externalLabelledBy, setExternalLabelledBy] = useState<string | undefined>(undefined)\n\n useLayoutEffect(() => {\n const root = rootRef.current\n\n if (!root) return\n\n return observeExternalListBoxLabels(root, controlId, setExternalLabelledBy)\n }, [controlId])\n\n // Consumer focus callbacks describe the whole control, once per entry and exit.\n const focusWithinRef = useRef(false)\n // Only invalid state Lumen generated from native validation; consumer aria-invalid always wins.\n const [generatedInvalid, setGeneratedInvalid] = useState(false)\n const selectedKey = JSON.stringify(selectedValues)\n const typeaheadRef = useRef('')\n\n const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(\n undefined\n )\n\n const commitValues = (nextValues: string[]) => {\n if (value === undefined) {\n setInternalValues(nextValues)\n }\n\n onValueChange?.(nextValues)\n\n const select = selectRef.current\n\n if (!select) return\n\n for (const option of select.options) {\n option.selected = nextValues.includes(option.value)\n }\n\n select.dispatchEvent(new Event('input', { bubbles: true }))\n\n select.dispatchEvent(new Event('change', { bubbles: true }))\n }\n\n const selectOption = (index: number) => {\n const option = normalizedOptions[index]\n\n if (!option || option.disabled || disabled || loading) return\n\n const optionValue = option.value\n let nextValues: string[]\n\n if (!multiple) {\n nextValues = [optionValue]\n } else if (selectedSet.has(optionValue)) {\n nextValues = selectedValues.filter(\n selectedValue => selectedValue !== optionValue\n )\n } else {\n nextValues = [...selectedValues, optionValue]\n }\n\n commitValues(nextValues)\n }\n\n const moveActive = (direction: 1 | -1) => {\n if (!normalizedOptions.length || loading) return\n\n let nextIndex = Math.max(0, resolvedActiveIndex)\n\n for (const _option of normalizedOptions) {\n nextIndex =\n (nextIndex + direction + normalizedOptions.length) %\n normalizedOptions.length\n\n if (!normalizedOptions[nextIndex]?.disabled) {\n setActiveIndex(nextIndex)\n\n if (selectionFollowsFocus) selectOption(nextIndex)\n\n return\n }\n }\n }\n\n useEffect(\n () => () => {\n if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current)\n }, []\n )\n\n useEffect(() => {\n const select = selectRef.current\n\n if (generatedInvalid && select && (!select.willValidate || select.validity.valid)) setGeneratedInvalid(false)\n }, [disabled, generatedInvalid, loading, required, selectedKey])\n\n const resetSourceRef = useRef({ defaultValue, value })\n\n useLayoutEffect(() => {\n resetSourceRef.current = { defaultValue, value }\n })\n\n useEffect(() => {\n const select = selectRef.current\n const form = select?.form\n\n if (!select || !form) return\n\n let resetTimer: ReturnType<typeof setTimeout> | undefined\n\n // Wait for reset listeners to run so a canceled reset keeps the current selection.\n const handleReset = (event: Event) => {\n clearTimeout(resetTimer)\n\n resetTimer = setTimeout(() => {\n if (event.defaultPrevented) return\n\n const source = resetSourceRef.current\n const defaults = normalizeListBoxValues(source.value ?? source.defaultValue)\n\n if (source.value === undefined) setInternalValues(defaults)\n\n for (const option of select.options) option.selected = defaults.includes(option.value)\n\n setGeneratedInvalid(false)\n\n setActiveIndex(-1)\n })\n }\n\n form.addEventListener('reset', handleReset)\n\n return () => {\n clearTimeout(resetTimer)\n\n form.removeEventListener('reset', handleReset)\n }\n }, [props.form])\n\n const activateIndex = (index: number) => {\n if (index < 0) return\n\n setActiveIndex(index)\n\n if (selectionFollowsFocus) selectOption(index)\n }\n\n const handleNavigationKey = (key: string): boolean => {\n if (key === 'ArrowDown') moveActive(1)\n else if (key === 'ArrowUp') moveActive(-1)\n else if (key === 'Home') activateIndex(firstEnabledIndex)\n else if (key === 'End') activateIndex(findLastEnabledOptionIndex(normalizedOptions))\n else if (key === 'Enter' || key === ' ') selectOption(resolvedActiveIndex)\n else return false\n\n return true\n }\n\n const handleTypeaheadKey = (event: KeyboardEvent<HTMLDivElement>): boolean => {\n if (\n event.key.length !== 1 ||\n event.ctrlKey ||\n event.metaKey ||\n event.altKey\n ) return false\n\n typeaheadRef.current += event.key.toLowerCase()\n\n if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current)\n\n typeaheadTimerRef.current = setTimeout(() => {\n typeaheadRef.current = ''\n }, 700)\n\n const nextIndex = normalizedOptions.findIndex(\n option => !option.disabled &&\n option.label.toLowerCase().startsWith(typeaheadRef.current)\n )\n\n if (nextIndex >= 0) {\n setActiveIndex(nextIndex)\n\n if (selectionFollowsFocus) selectOption(nextIndex)\n }\n\n return true\n }\n\n const handleListKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (handleNavigationKey(event.key) || handleTypeaheadKey(event)) event.preventDefault()\n }\n\n return (\n <div\n className={composeClassName('ui-list-box', className)}\n data-multiple={multiple ? 'true' : undefined}\n data-ui-list-box\n data-ui-owner=\"react\"\n ref={rootRef}\n >\n <ListBoxLabel controlId={controlId} label={label} />\n <select\n {...props}\n // The visible list is the only focus stop and accessible control; the select keeps form semantics.\n aria-hidden=\"true\"\n className=\"ui-list-box__native\"\n data-ui-enhanced\n data-ui-list-box-native\n disabled={disabled || loading}\n id={controlId}\n multiple={multiple}\n name={name}\n onChange={() => {\n // The visible list owns selection; this controlled proxy only carries native form events.\n }}\n onBlur={event => {\n if (!focusWithinRef.current || isInsideListBox(rootRef.current, event.relatedTarget)) return\n\n focusWithinRef.current = false\n\n props.onBlur?.(event)\n }}\n onFocus={event => {\n if (!focusWithinRef.current) {\n focusWithinRef.current = true\n\n props.onFocus?.(event)\n }\n\n if (event.currentTarget.ownerDocument.activeElement === event.currentTarget) {\n listRef.current?.focus({ preventScroll: true })\n }\n }}\n onInvalid={event => {\n props.onInvalid?.(event)\n\n event.preventDefault()\n\n setGeneratedInvalid(true)\n\n if (isFirstInvalidControl(event.currentTarget)) listRef.current?.focus()\n }}\n ref={node => {\n selectRef.current = node\n\n setRefValue(ref, node)\n }}\n required={required}\n tabIndex={-1}\n value={getListBoxSelectValue(multiple, selectedValues)}\n >\n {/* An empty choice keeps single selection unselected until the user picks an option. */}\n {multiple ? null : <option value=\"\" />}\n <ListBoxNativeOptions groups={optionGroups} />\n </select>\n <div\n {...getListBoxListAria(props, {\n activeOptionId: resolvedActiveIndex >= 0 ? `${controlId}-option-${resolvedActiveIndex}` : undefined,\n controlId,\n externalLabelledBy,\n disabled,\n generatedInvalid,\n label,\n loading,\n multiple,\n required\n })}\n className=\"ui-list-box__list\"\n data-ui-list-box-list\n onBlur={event => {\n if (isInsideListBox(rootRef.current, event.relatedTarget)) return\n\n selectRef.current?.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: event.relatedTarget }))\n }}\n onFocus={event => {\n if (focusWithinRef.current) return\n\n selectRef.current?.dispatchEvent(new FocusEvent('focusin', { bubbles: true, relatedTarget: event.relatedTarget }))\n }}\n onKeyDown={handleListKeyDown}\n ref={listRef}\n role=\"listbox\"\n tabIndex={disabled || loading ? undefined : 0}\n >\n <ListBoxVisibleOptions\n activeIndex={resolvedActiveIndex}\n controlId={controlId}\n emptyMessage={emptyMessage}\n groups={optionGroups}\n loading={loading}\n onSelect={index => {\n if (!isEnabledOption(index)) return\n\n setActiveIndex(index)\n\n selectOption(index)\n }}\n selectedValues={selectedSet}\n />\n </div>\n {loading ?\n (\n <p className=\"ui-list-box__status\" role=\"status\">\n {loadingMessage}\n </p>\n ) :\n null}\n </div>\n )\n}",
|
|
19044
19046
|
"styleImport": "import '@santi020k/lumen-react/styles.css'"
|
|
19045
19047
|
}
|
|
19046
19048
|
},
|
|
@@ -22636,7 +22638,7 @@
|
|
|
22636
22638
|
"values": "HTML attributes"
|
|
22637
22639
|
}
|
|
22638
22640
|
],
|
|
22639
|
-
"astroSource": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'div'> {\n class?: string | null\n className?: string\n max?: number\n value?: number\n}\n\nconst { class: classProp = '', className = '', max = 100, value = 0, ...rest } = Astro.props\nconst
|
|
22641
|
+
"astroSource": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { normalizeLumenProgress, resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'div'> {\n class?: string | null\n className?: string\n max?: number\n value?: number\n}\n\nconst { class: classProp = '', className = '', max = 100, value = 0, ...rest } = Astro.props\nconst { max: safeMax, percentage, value: safeValue } = normalizeLumenProgress(max, value)\n\nconst { classList, passthrough } = resolveLumenAstroProps(rest, [\n 'ui-progress'\n], classProp, className)\n---\n\n<div\n aria-valuemax={safeMax}\n aria-valuemin=\"0\"\n aria-valuenow={safeValue}\n class:list={classList}\n data-ui-progress\n role=\"progressbar\"\n {...passthrough}\n>\n <span\n class=\"ui-progress__bar\"\n data-slot=\"progress-indicator\"\n style={`width: ${percentage}%`}\n >\n </span>\n</div>\n",
|
|
22640
22642
|
"category": "Feedback",
|
|
22641
22643
|
"collections": [],
|
|
22642
22644
|
"contract": null,
|
|
@@ -22689,7 +22691,7 @@
|
|
|
22689
22691
|
}
|
|
22690
22692
|
],
|
|
22691
22693
|
"propsExtends": "HTMLAttributes<'div'>",
|
|
22692
|
-
"source": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'div'> {\n class?: string | null\n className?: string\n max?: number\n value?: number\n}\n\nconst { class: classProp = '', className = '', max = 100, value = 0, ...rest } = Astro.props\nconst
|
|
22694
|
+
"source": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { normalizeLumenProgress, resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'div'> {\n class?: string | null\n className?: string\n max?: number\n value?: number\n}\n\nconst { class: classProp = '', className = '', max = 100, value = 0, ...rest } = Astro.props\nconst { max: safeMax, percentage, value: safeValue } = normalizeLumenProgress(max, value)\n\nconst { classList, passthrough } = resolveLumenAstroProps(rest, [\n 'ui-progress'\n], classProp, className)\n---\n\n<div\n aria-valuemax={safeMax}\n aria-valuemin=\"0\"\n aria-valuenow={safeValue}\n class:list={classList}\n data-ui-progress\n role=\"progressbar\"\n {...passthrough}\n>\n <span\n class=\"ui-progress__bar\"\n data-slot=\"progress-indicator\"\n style={`width: ${percentage}%`}\n >\n </span>\n</div>\n",
|
|
22693
22695
|
"styleImport": "import '@santi020k/lumen-astro/styles.css'"
|
|
22694
22696
|
},
|
|
22695
22697
|
"elements": {
|
|
@@ -22744,7 +22746,7 @@
|
|
|
22744
22746
|
}
|
|
22745
22747
|
],
|
|
22746
22748
|
"propsExtends": "ComponentPropsWithoutRef<'div'>",
|
|
22747
|
-
"source": "export interface ProgressProps extends ComponentPropsWithoutRef<'div'> {\n max?: number\n value?: number\n}\n\nexport const Progress = ({\n className,\n max = 100,\n value = 0,\n ...props\n}: ProgressProps) => {\n const
|
|
22749
|
+
"source": "export interface ProgressProps extends ComponentPropsWithoutRef<'div'> {\n max?: number\n value?: number\n}\n\nexport const Progress = ({\n className,\n max = 100,\n value = 0,\n ...props\n}: ProgressProps) => {\n const { max: safeMax, percentage, value: safeValue } = normalizeLumenProgress(max, value)\n\n return (\n <div\n aria-valuemax={safeMax}\n aria-valuemin={0}\n aria-valuenow={safeValue}\n className={composeClassName('ui-progress', className)}\n role=\"progressbar\"\n {...props}\n >\n <span\n className=\"ui-progress__bar\"\n data-slot=\"progress-indicator\"\n style={{ width: `${percentage}%` }}\n />\n </div>\n )\n}",
|
|
22748
22750
|
"styleImport": "import '@santi020k/lumen-react/styles.css'"
|
|
22749
22751
|
}
|
|
22750
22752
|
},
|
|
@@ -24881,7 +24883,7 @@
|
|
|
24881
24883
|
}
|
|
24882
24884
|
],
|
|
24883
24885
|
"propsExtends": "Omit<\n NativeSelectProps,\n | 'defaultValue' |\n 'disabled' |\n 'id' |\n 'name' |\n 'onChange' |\n 'options' |\n 'placeholder' |\n 'required' |\n 'value'\n >, SelectOptions",
|
|
24884
|
-
"source": "export interface SelectProps\n extends\n Omit<\n NativeSelectProps,\n | 'defaultValue' |\n 'disabled' |\n 'id' |\n 'name' |\n 'onChange' |\n 'options' |\n 'placeholder' |\n 'required' |\n 'value'\n >,\n SelectOptions {\n glass?: LumenGlassProp\n inputRef?: Ref<HTMLSelectElement>\n onChange?: ComponentPropsWithoutRef<'select'>['onChange']\n visualSize?: LumenControlVisualSize\n}\n\nexport const Select = ({\n children,\n className,\n defaultValue,\n disabled = false,\n glass = false,\n id,\n inputRef,\n name,\n onChange,\n onValueChange,\n options = emptyOptions,\n placeholder,\n required = false,\n visualSize = 'default',\n value,\n ...props\n}: SelectProps) => {\n const select = useSelect({\n defaultValue,\n disabled,\n id,\n name,\n onValueChange,\n options,\n placeholder,\n required,\n value\n })\n\n const sizeClass = getSelectSizeClass(visualSize)\n\n return (\n <div\n {...select.rootProps}\n className={composeClassName(\n 'ui-select-field', glassClass('ui-select-field', glass), className\n )}\n data-ui-glass-track={glass ? true : undefined}\n >\n <select\n {...select.nativeSelectProps}\n {...props}\n className={composeClassName('ui-select ui-select__native', sizeClass)}\n onChange={composeHandlers(onChange, select.nativeSelectProps.onChange)}\n ref={inputRef}\n >\n {placeholder && (\n <option data-ui-select-placeholder disabled value=\"\">\n {placeholder}\n </option>\n )}\n {children}\n {select.options.map(option => (\n <option\n disabled={option.disabled}\n key={option.value}\n value={option.value}\n >\n {option.label}\n </option>\n ))}\n </select>\n\n <div {...select.controlProps} className=\"ui-select__control\">\n <button\n {...select.triggerProps}\n aria-label={props['aria-label']}\n aria-labelledby={props['aria-labelledby']}\n aria-describedby={props['aria-describedby']}\n aria-invalid={props['aria-invalid']}\n aria-errormessage={props['aria-errormessage']}\n className={composeClassName(\n 'ui-select ui-select__trigger', sizeClass\n )}\n type={select.triggerProps.type ?? 'button'}\n >\n <span data-ui-select-value>{select.triggerText}</span>\n </button>\n\n <div
|
|
24886
|
+
"source": "export interface SelectProps\n extends\n Omit<\n NativeSelectProps,\n | 'defaultValue' |\n 'disabled' |\n 'id' |\n 'name' |\n 'onChange' |\n 'options' |\n 'placeholder' |\n 'required' |\n 'value'\n >,\n SelectOptions {\n glass?: LumenGlassProp\n inputRef?: Ref<HTMLSelectElement>\n onChange?: ComponentPropsWithoutRef<'select'>['onChange']\n visualSize?: LumenControlVisualSize\n}\n\nexport const Select = ({\n children,\n className,\n defaultValue,\n disabled = false,\n glass = false,\n id,\n inputRef,\n name,\n onChange,\n onValueChange,\n options = emptyOptions,\n placeholder,\n required = false,\n visualSize = 'default',\n value,\n ...props\n}: SelectProps) => {\n const select = useSelect({\n defaultValue,\n disabled,\n id,\n name,\n onValueChange,\n options,\n placeholder,\n required,\n value\n })\n\n const sizeClass = getSelectSizeClass(visualSize)\n\n return (\n <div\n {...select.rootProps}\n className={composeClassName(\n 'ui-select-field', glassClass('ui-select-field', glass), className\n )}\n data-ui-glass-track={glass ? true : undefined}\n >\n <select\n {...select.nativeSelectProps}\n {...props}\n className={composeClassName('ui-select ui-select__native', sizeClass)}\n onChange={composeHandlers(onChange, select.nativeSelectProps.onChange)}\n ref={inputRef}\n >\n {placeholder && (\n <option data-ui-select-placeholder disabled value=\"\">\n {placeholder}\n </option>\n )}\n {children}\n {select.options.map(option => (\n <option\n disabled={option.disabled}\n key={option.value}\n value={option.value}\n >\n {option.label}\n </option>\n ))}\n </select>\n\n <div {...select.controlProps} className=\"ui-select__control\">\n <button\n {...select.triggerProps}\n aria-label={props['aria-label']}\n aria-labelledby={props['aria-labelledby']}\n aria-describedby={props['aria-describedby']}\n aria-invalid={props['aria-invalid']}\n aria-errormessage={props['aria-errormessage']}\n className={composeClassName(\n 'ui-select ui-select__trigger', sizeClass\n )}\n type={select.triggerProps.type ?? 'button'}\n >\n <span data-ui-select-value>{select.triggerText}</span>\n </button>\n\n <div\n {...select.listProps}\n aria-label={props['aria-label']}\n aria-labelledby={props['aria-labelledby']}\n className=\"ui-select__list\"\n >\n {select.options.map(option => {\n const optionProps = select.getOptionProps(option)\n\n return (\n <button\n key={option.value}\n {...optionProps}\n type={optionProps.type ?? 'button'}\n >\n {option.label}\n </button>\n )\n })}\n </div>\n </div>\n </div>\n )\n}",
|
|
24885
24887
|
"styleImport": "import '@santi020k/lumen-react/styles.css'"
|
|
24886
24888
|
}
|
|
24887
24889
|
},
|
|
@@ -25640,6 +25642,24 @@
|
|
|
25640
25642
|
},
|
|
25641
25643
|
{
|
|
25642
25644
|
"apiReference": [
|
|
25645
|
+
{
|
|
25646
|
+
"attribute": "shape",
|
|
25647
|
+
"defaultValue": "\"rectangle\"",
|
|
25648
|
+
"description": "Text lines default to 0.75rem high; circles stay square from one width or height.",
|
|
25649
|
+
"values": "\"rectangle\" | \"text\" | \"circle\""
|
|
25650
|
+
},
|
|
25651
|
+
{
|
|
25652
|
+
"attribute": "width, height",
|
|
25653
|
+
"defaultValue": "-",
|
|
25654
|
+
"description": "Numbers are pixels. Strings accept lengths, percentages, and calc() or var() expressions; heights below the 1rem default are allowed.",
|
|
25655
|
+
"values": "number | CSS length"
|
|
25656
|
+
},
|
|
25657
|
+
{
|
|
25658
|
+
"attribute": "animation",
|
|
25659
|
+
"defaultValue": "\"shimmer\"",
|
|
25660
|
+
"description": "Pulse fades instead of sweeping; none is static. Reduced motion stops either animation.",
|
|
25661
|
+
"values": "\"shimmer\" | \"pulse\" | \"none\""
|
|
25662
|
+
},
|
|
25643
25663
|
{
|
|
25644
25664
|
"attribute": "aria-label, aria-labelledby",
|
|
25645
25665
|
"defaultValue": "-",
|
|
@@ -25671,7 +25691,7 @@
|
|
|
25671
25691
|
"values": "HTML attributes"
|
|
25672
25692
|
}
|
|
25673
25693
|
],
|
|
25674
|
-
"astroSource": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps
|
|
25694
|
+
"astroSource": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport {\n type LumenSkeletonAnimation,\n type LumenSkeletonShape,\n type LumenSkeletonSize,\n resolveLumenAstroProps,\n resolveLumenLoadingSemantics,\n resolveLumenSkeletonAnimation,\n resolveLumenSkeletonShape,\n resolveLumenSkeletonSize\n} from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'div'> {\n /** `shimmer` (default) keeps the existing sweep; `pulse` fades; `none` is static. */\n animation?: LumenSkeletonAnimation\n class?: string | null\n className?: string\n /** Pixels for numbers, or a CSS length such as `0.75rem` or `60%`. */\n height?: LumenSkeletonSize\n shape?: LumenSkeletonShape\n width?: LumenSkeletonSize\n}\n\nconst {\n animation,\n 'aria-hidden': ariaHidden,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n class: classProp = '',\n className = '',\n height,\n role,\n shape,\n style,\n width,\n ...rest\n} = Astro.props\n\nconst semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })\nconst resolvedShape = resolveLumenSkeletonShape(shape)\nconst resolvedAnimation = resolveLumenSkeletonAnimation(animation)\nconst sizeVariables: Record<string, string> = {}\nconst resolvedHeight = resolveLumenSkeletonSize(height)\nconst resolvedWidth = resolveLumenSkeletonSize(width)\n\nif (resolvedHeight) sizeVariables['--ui-skeleton-height'] = resolvedHeight\n\nif (resolvedWidth) sizeVariables['--ui-skeleton-width'] = resolvedWidth\n\n// Consumer styles follow the size variables so explicit declarations still win.\nconst skeletonStyle = typeof style === 'object' && style !== null ?\n { ...sizeVariables, ...style } :\n [\n ...Object.entries(sizeVariables).map(([name, value]) => `${name}:${value}`),\n typeof style === 'string' ? style : ''\n ].filter(Boolean).join(';')\n\nconst { classList, passthrough } = resolveLumenAstroProps(rest, [\n 'ui-skeleton',\n resolvedShape === 'rectangle' ? '' : `ui-skeleton--${resolvedShape}`,\n resolvedAnimation === 'pulse' ? 'ui-skeleton--pulse' : '',\n resolvedAnimation === 'none' ? 'ui-skeleton--static' : ''\n], classProp, className)\n---\n\n<div\n aria-hidden={ariaHidden ?? semantics.ariaHidden}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n class:list={classList}\n role={role ?? semantics.role}\n style={skeletonStyle || undefined}\n {...passthrough}\n>\n <slot />\n</div>\n",
|
|
25675
25695
|
"category": "Feedback",
|
|
25676
25696
|
"collections": [],
|
|
25677
25697
|
"contract": null,
|
|
@@ -25696,6 +25716,11 @@
|
|
|
25696
25716
|
"language": "astro",
|
|
25697
25717
|
"packageName": "@santi020k/lumen-astro",
|
|
25698
25718
|
"props": [
|
|
25719
|
+
{
|
|
25720
|
+
"name": "animation",
|
|
25721
|
+
"optional": true,
|
|
25722
|
+
"type": "LumenSkeletonAnimation"
|
|
25723
|
+
},
|
|
25699
25724
|
{
|
|
25700
25725
|
"name": "class",
|
|
25701
25726
|
"optional": true,
|
|
@@ -25705,14 +25730,34 @@
|
|
|
25705
25730
|
"name": "className",
|
|
25706
25731
|
"optional": true,
|
|
25707
25732
|
"type": "string"
|
|
25733
|
+
},
|
|
25734
|
+
{
|
|
25735
|
+
"name": "height",
|
|
25736
|
+
"optional": true,
|
|
25737
|
+
"type": "LumenSkeletonSize"
|
|
25738
|
+
},
|
|
25739
|
+
{
|
|
25740
|
+
"name": "shape",
|
|
25741
|
+
"optional": true,
|
|
25742
|
+
"type": "LumenSkeletonShape"
|
|
25743
|
+
},
|
|
25744
|
+
{
|
|
25745
|
+
"name": "width",
|
|
25746
|
+
"optional": true,
|
|
25747
|
+
"type": "LumenSkeletonSize"
|
|
25708
25748
|
}
|
|
25709
25749
|
],
|
|
25710
25750
|
"propsExtends": "HTMLAttributes<'div'>",
|
|
25711
|
-
"source": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps
|
|
25751
|
+
"source": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport {\n type LumenSkeletonAnimation,\n type LumenSkeletonShape,\n type LumenSkeletonSize,\n resolveLumenAstroProps,\n resolveLumenLoadingSemantics,\n resolveLumenSkeletonAnimation,\n resolveLumenSkeletonShape,\n resolveLumenSkeletonSize\n} from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'div'> {\n /** `shimmer` (default) keeps the existing sweep; `pulse` fades; `none` is static. */\n animation?: LumenSkeletonAnimation\n class?: string | null\n className?: string\n /** Pixels for numbers, or a CSS length such as `0.75rem` or `60%`. */\n height?: LumenSkeletonSize\n shape?: LumenSkeletonShape\n width?: LumenSkeletonSize\n}\n\nconst {\n animation,\n 'aria-hidden': ariaHidden,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n class: classProp = '',\n className = '',\n height,\n role,\n shape,\n style,\n width,\n ...rest\n} = Astro.props\n\nconst semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })\nconst resolvedShape = resolveLumenSkeletonShape(shape)\nconst resolvedAnimation = resolveLumenSkeletonAnimation(animation)\nconst sizeVariables: Record<string, string> = {}\nconst resolvedHeight = resolveLumenSkeletonSize(height)\nconst resolvedWidth = resolveLumenSkeletonSize(width)\n\nif (resolvedHeight) sizeVariables['--ui-skeleton-height'] = resolvedHeight\n\nif (resolvedWidth) sizeVariables['--ui-skeleton-width'] = resolvedWidth\n\n// Consumer styles follow the size variables so explicit declarations still win.\nconst skeletonStyle = typeof style === 'object' && style !== null ?\n { ...sizeVariables, ...style } :\n [\n ...Object.entries(sizeVariables).map(([name, value]) => `${name}:${value}`),\n typeof style === 'string' ? style : ''\n ].filter(Boolean).join(';')\n\nconst { classList, passthrough } = resolveLumenAstroProps(rest, [\n 'ui-skeleton',\n resolvedShape === 'rectangle' ? '' : `ui-skeleton--${resolvedShape}`,\n resolvedAnimation === 'pulse' ? 'ui-skeleton--pulse' : '',\n resolvedAnimation === 'none' ? 'ui-skeleton--static' : ''\n], classProp, className)\n---\n\n<div\n aria-hidden={ariaHidden ?? semantics.ariaHidden}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n class:list={classList}\n role={role ?? semantics.role}\n style={skeletonStyle || undefined}\n {...passthrough}\n>\n <slot />\n</div>\n",
|
|
25712
25752
|
"styleImport": "import '@santi020k/lumen-astro/styles.css'"
|
|
25713
25753
|
},
|
|
25714
25754
|
"elements": {
|
|
25715
25755
|
"attributes": [
|
|
25756
|
+
"animation",
|
|
25757
|
+
"height",
|
|
25758
|
+
"shape",
|
|
25759
|
+
"width",
|
|
25760
|
+
"width, height",
|
|
25716
25761
|
"aria-label, aria-labelledby",
|
|
25717
25762
|
"aria-hidden",
|
|
25718
25763
|
"role",
|
|
@@ -25731,7 +25776,7 @@
|
|
|
25731
25776
|
"props": [],
|
|
25732
25777
|
"propsExtends": "HTMLElement attributes",
|
|
25733
25778
|
"registration": "defineLumenElements()",
|
|
25734
|
-
"source": "lumenSkeletonElementConfig = {\n baseClassName: 'ui-skeleton',\n tagName: 'lumen-skeleton'\n} as const satisfies LumenElementConfig",
|
|
25779
|
+
"source": "lumenSkeletonElementConfig = {\n attributeClasses: {\n animation: { none: 'ui-skeleton--static', pulse: 'ui-skeleton--pulse' },\n shape: { circle: 'ui-skeleton--circle', text: 'ui-skeleton--text' }\n },\n baseClassName: 'ui-skeleton',\n observedAttributes: ['animation', 'height', 'shape', 'width', ...loadingSemanticAttributes],\n tagName: 'lumen-skeleton'\n} as const satisfies LumenElementConfig",
|
|
25735
25780
|
"styleImport": "import '@santi020k/lumen-elements/styles.css'",
|
|
25736
25781
|
"tagName": "lumen-skeleton"
|
|
25737
25782
|
},
|
|
@@ -25741,9 +25786,30 @@
|
|
|
25741
25786
|
"importStatement": "import { Skeleton } from '@santi020k/lumen-react'",
|
|
25742
25787
|
"language": "tsx",
|
|
25743
25788
|
"packageName": "@santi020k/lumen-react",
|
|
25744
|
-
"props": [
|
|
25789
|
+
"props": [
|
|
25790
|
+
{
|
|
25791
|
+
"name": "animation",
|
|
25792
|
+
"optional": true,
|
|
25793
|
+
"type": "LumenSkeletonAnimation"
|
|
25794
|
+
},
|
|
25795
|
+
{
|
|
25796
|
+
"name": "height",
|
|
25797
|
+
"optional": true,
|
|
25798
|
+
"type": "LumenSkeletonSize"
|
|
25799
|
+
},
|
|
25800
|
+
{
|
|
25801
|
+
"name": "shape",
|
|
25802
|
+
"optional": true,
|
|
25803
|
+
"type": "LumenSkeletonShape"
|
|
25804
|
+
},
|
|
25805
|
+
{
|
|
25806
|
+
"name": "width",
|
|
25807
|
+
"optional": true,
|
|
25808
|
+
"type": "LumenSkeletonSize"
|
|
25809
|
+
}
|
|
25810
|
+
],
|
|
25745
25811
|
"propsExtends": "ComponentPropsWithRef<'div'>",
|
|
25746
|
-
"source": "export
|
|
25812
|
+
"source": "export interface SkeletonProps extends ComponentPropsWithRef<'div'> {\n /** `shimmer` (default) keeps the existing sweep; `pulse` fades; `none` is static. */\n animation?: LumenSkeletonAnimation\n /** Pixels for numbers, or a CSS length such as `0.75rem` or `60%`. */\n height?: LumenSkeletonSize\n shape?: LumenSkeletonShape\n width?: LumenSkeletonSize\n}\n\nexport const Skeleton = ({\n animation,\n 'aria-hidden': ariaHidden,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n className,\n height,\n role,\n shape,\n style,\n width,\n ...props\n}: SkeletonProps) => {\n const semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })\n const resolvedShape = resolveLumenSkeletonShape(shape)\n const resolvedAnimation = resolveLumenSkeletonAnimation(animation)\n\n const skeletonStyle: LumenCSSProperties = {\n '--ui-skeleton-height': resolveLumenSkeletonSize(height),\n '--ui-skeleton-width': resolveLumenSkeletonSize(width),\n ...style\n }\n\n return (\n <div\n aria-hidden={ariaHidden ?? (semantics.ariaHidden ? true : undefined)}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n className={composeClassName(\n 'ui-skeleton',\n resolvedShape !== 'rectangle' && `ui-skeleton--${resolvedShape}`,\n resolvedAnimation === 'pulse' && 'ui-skeleton--pulse',\n resolvedAnimation === 'none' && 'ui-skeleton--static',\n className\n )}\n role={role ?? semantics.role}\n style={skeletonStyle}\n {...props}\n />\n )\n}",
|
|
25747
25813
|
"styleImport": "import '@santi020k/lumen-react/styles.css'"
|
|
25748
25814
|
}
|
|
25749
25815
|
},
|
|
@@ -25766,6 +25832,11 @@
|
|
|
25766
25832
|
],
|
|
25767
25833
|
"name": "Skeleton",
|
|
25768
25834
|
"props": [
|
|
25835
|
+
{
|
|
25836
|
+
"name": "animation",
|
|
25837
|
+
"optional": true,
|
|
25838
|
+
"type": "LumenSkeletonAnimation"
|
|
25839
|
+
},
|
|
25769
25840
|
{
|
|
25770
25841
|
"name": "class",
|
|
25771
25842
|
"optional": true,
|
|
@@ -25775,6 +25846,21 @@
|
|
|
25775
25846
|
"name": "className",
|
|
25776
25847
|
"optional": true,
|
|
25777
25848
|
"type": "string"
|
|
25849
|
+
},
|
|
25850
|
+
{
|
|
25851
|
+
"name": "height",
|
|
25852
|
+
"optional": true,
|
|
25853
|
+
"type": "LumenSkeletonSize"
|
|
25854
|
+
},
|
|
25855
|
+
{
|
|
25856
|
+
"name": "shape",
|
|
25857
|
+
"optional": true,
|
|
25858
|
+
"type": "LumenSkeletonShape"
|
|
25859
|
+
},
|
|
25860
|
+
{
|
|
25861
|
+
"name": "width",
|
|
25862
|
+
"optional": true,
|
|
25863
|
+
"type": "LumenSkeletonSize"
|
|
25778
25864
|
}
|
|
25779
25865
|
],
|
|
25780
25866
|
"propsExtends": "HTMLAttributes<'div'>",
|
|
@@ -26431,7 +26517,7 @@
|
|
|
26431
26517
|
"values": "HTML attributes"
|
|
26432
26518
|
}
|
|
26433
26519
|
],
|
|
26434
|
-
"astroSource": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'span'> {\n class?: string | null\n className?: string\n}\n\nconst {\n 'aria-hidden': ariaHidden,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n class: classProp = '',\n className = '',\n role,\n ...rest\n} = Astro.props\n\nconst
|
|
26520
|
+
"astroSource": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps, resolveLumenLoadingSemantics } from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'span'> {\n class?: string | null\n className?: string\n}\n\nconst {\n 'aria-hidden': ariaHidden,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n class: classProp = '',\n className = '',\n role,\n ...rest\n} = Astro.props\n\nconst semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })\n\nconst { classList, passthrough } = resolveLumenAstroProps(rest, [\n 'ui-spinner'\n], classProp, className)\n---\n\n<span\n aria-hidden={ariaHidden ?? semantics.ariaHidden}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n class:list={classList}\n role={role ?? semantics.role}\n {...passthrough}\n>\n <slot />\n</span>\n",
|
|
26435
26521
|
"category": "Feedback",
|
|
26436
26522
|
"collections": [],
|
|
26437
26523
|
"contract": null,
|
|
@@ -26468,7 +26554,7 @@
|
|
|
26468
26554
|
}
|
|
26469
26555
|
],
|
|
26470
26556
|
"propsExtends": "HTMLAttributes<'span'>",
|
|
26471
|
-
"source": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps } from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'span'> {\n class?: string | null\n className?: string\n}\n\nconst {\n 'aria-hidden': ariaHidden,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n class: classProp = '',\n className = '',\n role,\n ...rest\n} = Astro.props\n\nconst
|
|
26557
|
+
"source": "---\nimport type { HTMLAttributes } from 'astro/types'\n\nimport { resolveLumenAstroProps, resolveLumenLoadingSemantics } from '@santi020k/lumen-core'\n\ninterface Props extends HTMLAttributes<'span'> {\n class?: string | null\n className?: string\n}\n\nconst {\n 'aria-hidden': ariaHidden,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n class: classProp = '',\n className = '',\n role,\n ...rest\n} = Astro.props\n\nconst semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })\n\nconst { classList, passthrough } = resolveLumenAstroProps(rest, [\n 'ui-spinner'\n], classProp, className)\n---\n\n<span\n aria-hidden={ariaHidden ?? semantics.ariaHidden}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n class:list={classList}\n role={role ?? semantics.role}\n {...passthrough}\n>\n <slot />\n</span>\n",
|
|
26472
26558
|
"styleImport": "import '@santi020k/lumen-astro/styles.css'"
|
|
26473
26559
|
},
|
|
26474
26560
|
"elements": {
|
|
@@ -26491,7 +26577,7 @@
|
|
|
26491
26577
|
"props": [],
|
|
26492
26578
|
"propsExtends": "HTMLElement attributes",
|
|
26493
26579
|
"registration": "defineLumenElements()",
|
|
26494
|
-
"source": "lumenSpinnerElementConfig = {\n baseClassName: 'ui-spinner',\n tagName: 'lumen-spinner'\n} as const satisfies LumenElementConfig",
|
|
26580
|
+
"source": "lumenSpinnerElementConfig = {\n baseClassName: 'ui-spinner',\n observedAttributes: loadingSemanticAttributes,\n tagName: 'lumen-spinner'\n} as const satisfies LumenElementConfig",
|
|
26495
26581
|
"styleImport": "import '@santi020k/lumen-elements/styles.css'",
|
|
26496
26582
|
"tagName": "lumen-spinner"
|
|
26497
26583
|
},
|
|
@@ -26503,7 +26589,7 @@
|
|
|
26503
26589
|
"packageName": "@santi020k/lumen-react",
|
|
26504
26590
|
"props": [],
|
|
26505
26591
|
"propsExtends": "ComponentPropsWithoutRef<'span'>",
|
|
26506
|
-
"source": "export type SpinnerProps = ComponentPropsWithoutRef<'span'>\n\nexport const Spinner = ({\n 'aria-hidden': ariaHidden,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n className,\n role,\n ...props\n}: SpinnerProps) => {\n const
|
|
26592
|
+
"source": "export type SpinnerProps = ComponentPropsWithoutRef<'span'>\n\nexport const Spinner = ({\n 'aria-hidden': ariaHidden,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n className,\n role,\n ...props\n}: SpinnerProps) => {\n const semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })\n\n return (\n <span\n aria-hidden={ariaHidden ?? (semantics.ariaHidden ? true : undefined)}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n className={composeClassName('ui-spinner', className)}\n role={role ?? semantics.role}\n {...props}\n />\n )\n}",
|
|
26507
26593
|
"styleImport": "import '@santi020k/lumen-react/styles.css'"
|
|
26508
26594
|
}
|
|
26509
26595
|
},
|
|
@@ -45873,11 +45959,11 @@
|
|
|
45873
45959
|
}
|
|
45874
45960
|
],
|
|
45875
45961
|
"docs": {
|
|
45876
|
-
"aiUsage": "# AI Usage Guide\n\nUse this guide when an AI agent is building an app with Lumen or updating examples that teach\nanother agent how to use the library.\n\nThis branch documents the locally prepared Lumen 4 candidate. Version 4 install examples become\nusable after publication; the public plugin and hosted MCP still describe their deployed version.\nUse the workspace packages and generated local MCP snapshot to evaluate this candidate.\n\n## Install in ChatGPT or Codex\n\nThe [Lumen UI plugin](https://chatgpt.com/plugins/plugin_asdk_app_6a8f6c526c5481918eb8a48806fa112b)\nis published in the shared Plugins Directory for ChatGPT and Codex.\n\n1. Open the listing and select **Install plugin**. Sign in to ChatGPT if prompted.\n2. Start a request with **@Lumen UI**, or choose one of the listing's starter prompts.\n3. Describe the interface and target framework, for example: “Find the right Lumen components for\n an accessible React settings screen.”\n\nThe plugin combines the Lumen workflow skill with the hosted public MCP catalog. It requires no\nseparate Lumen account, API key, or local server. Its tools only read public Lumen documentation;\nthey do not access private repositories or modify files and services. Your coding agent performs\nimplementation work using the retrieved contracts and its own permissions.\n\nSee the [publication record](./openai-plugin-submission.md) for version and verification details.\n\n## Install the Lumen Agent Skill\n\nLumen ships a portable Agent Skill for AI coding tools that support the open `SKILL.md` format:\n\n```bash\nnpx skills add santi020k/lumen --skill lumen-ui\n```\n\nThe source lives in [`skills/lumen-ui`](../skills/lumen-ui). The skill provides the durable\nframework-selection, component-composition, theming, accessibility, and verification workflow.\nUse it by naming `$lumen-ui` in a prompt, or let a compatible agent trigger it from Lumen-specific\nrequests.\n\nThe skill and MCP server complement each other: the skill explains how to work, while\n`@santi020k/lumen-mcp` supplies structured catalog search, framework-specific contracts, recipes,\nsource, tokens, resources, and rules.\n\n## Pick the Right Package\n\nUse the framework requested by the user. Every adapter shares the same Lumen foundation.\n\n| App Target | Install | Import Components From | Load Styles From |\n| -------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | -------------------------------------- |\n| Astro | `@santi020k/lumen-astro` | `@santi020k/lumen-astro` | `@santi020k/lumen-astro/styles.css` |\n| React | `@santi020k/lumen-react` | `@santi020k/lumen-react` | `@santi020k/lumen-react/styles.css` |\n| React Hook Form composites | `@santi020k/lumen-react-hook-form` | `@santi020k/lumen-react-hook-form` | Uses React styles |\n| Web Components | `@santi020k/lumen-elements` | `@santi020k/lumen-elements/define` | `@santi020k/lumen-elements/styles.css` |\n| React Native / Expo | `@santi020k/lumen-react-native` | `@santi020k/lumen-react-native` | Not applicable |\n| Apple / SwiftUI | Swift Package `https://github.com/santi020k/lumen`, pinned to `4.0.0` | `LumenUI` | Not applicable |\n| Android / Compose | Maven Central `com.santi020k:lumen-compose:4.0.0` | `com.santi020k.lumen` | Not applicable |\n| Wear OS / Compose | Maven Central `com.santi020k:lumen-compose-wear:4.0.0` | `com.santi020k.lumen` | Not applicable |\n| Package metadata | `@santi020k/lumen-core` | `@santi020k/lumen-core` | Not applicable |\n| Optional web brand icons | `@santi020k/lumen-icons-brand` | Register once, then use the framework `Icon` | Uses framework styles |\n\nEach framework package includes the shared foundation. Install `@santi020k/lumen` separately only\nwhen you need its framework-neutral CLI or registry metadata.\n\nFor React Native, mount one `LumenProvider` near the app root. For SwiftUI, attach the `LumenUI`\nSwift Package product to the application target, use an exact or compatible release-version rule,\nand apply `.lumenTheme(...)` near the root. For Compose, install\n`com.santi020k:lumen-compose:4.0.0` from Maven Central and wrap content in `LumenTheme`. Native\nadapters do not load CSS or the Astro runtime.\n\nReact Native can use ordinary React hooks and platform-neutral application hooks. Import native\nLumen controllers such as `useDialog`, `useTabs`, `useSelect`, `useLanguageToggle`,\n`useThemeToggle`, and `useToast` from `@santi020k/lumen-react-native`; their state semantics track\nthe reusable React contracts while their returned props target native Lumen components. Never\nimport DOM-dependent hooks from `@santi020k/lumen-react` into a native bundle. Share business state\nin an application workspace package and keep the Lumen rendering adapter at each app boundary.\n\nFor watchOS, use the focused `LumenWatch*` contracts from the same Swift package. For Wear OS,\ninstall `com.santi020k:lumen-compose-wear:4.0.0` and wrap wearable content in `LumenWearTheme`\ninside the application-owned Wear Material theme. Keep complications, tiles, rotary input,\nAlways On behavior, haptics, synchronization, navigation, and health or safety logic app-owned.\n\nBrand marks are intentionally excluded from the default web Lucide bundle. In Astro, React, or Web\nComponents, install and register the optional pack once before rendering a namespaced icon:\n\n```ts\nimport { registerLumenBrandIcons } from \"@santi020k/lumen-icons-brand\";\n\nregisterLumenBrandIcons();\n```\n\nThen use any canonical Font Awesome Free brand name, such as `brand:github`, `brand:discord`,\n`brand:figma`, `brand:linkedin`, `brand:whatsapp`, or `brand:youtube`, through the existing Astro,\nReact, or Web Component `Icon` API. The package also provides `brand:x` as an alias for\n`brand:x-twitter`.\n\nBrand marks keep their recognizable filled geometry instead of being redrawn as Lucide outlines.\nThey still inherit the same Lumen size, alignment, `currentColor`, and accessible-label behavior as\nthe default icon catalog. React Native, SwiftUI, and Compose already include the corresponding\ncomplete brand catalog and accept `brand:github`, `.brandGithub`, and\n`LumenIconName.BrandGithub`, respectively, without a separate package.\n\n## Consumer composition in v4\n\n- For reporting filters, use React `DateRangeInput` with controlled `value`/`onValueChange`, localized\n `labels`, bounds and explicit presets. Keep draft changes inside the picker until Apply. See the\n [reporting example](https://lumen.santi020k.com/docs/web/reporting) and [migration guide](migrating-to-lumen.md).\n- Keep date-only values as validated `YYYY-MM-DD` strings. Do not round-trip a local date through\n UTC timestamps. Use the native adapter's existing date field and application-owned calendar/time-zone rules.\n- Compose a metric card from `Card`, `Stat` and a chart with `presentation=\"bare\"`; do not invent\n MetricCard or Status components. Products own status labels, aggregation, currency units and rounding.\n- Use unique chart X identities, abbreviated `xLabel` text and full `formatCategory` labels.\n Preserve absent observations explicitly; never turn missing data into a fabricated zero.\n- Compare two media treatments with `ImageComparison` and matching source dimensions. Supply a\n visible localized control label and useful image alternatives. See [image comparison](image-comparison.md).\n- Use `Prose` for ordinary Markdown, `Code`/`CodeTabs` for examples, and `CopyButton` for dynamic\n command output. Localize clipboard success/error feedback; do not swallow rejected writes.\n- Use public `asChild` composition when a semantic link needs button styling. Disable nested form\n controls separately. On Input, native numeric `size` and Lumen `visualSize` have different meanings.\n- Map the complete semantic theme in light and dark modes, including `brand-solid`/`on-brand` and\n `danger`/`on-danger`. Text-safe colors matter more than matching a decorative swatch.\n- NavigationMenu, Sidebar and Sheet compose a documentation shell. Keep ordinary links in normal\n Tab order and application routing in the app. Do not rebuild primitives with `ui-*` classes.\n\n### Theme bootstrap and generated command output\n\nWhen the application owns theme state, use `ThemeToggle controlled` and its own click handler.\nInitialize the document in the head before paint; unavailable storage must not prevent rendering:\n\n```html\n<script>\n let theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'\n try {\n const saved = localStorage.getItem('application-theme')\n if (saved === 'light' || saved === 'dark') theme = saved\n } catch {\n // Private browsing or storage policy can deny access.\n }\n document.documentElement.dataset.theme = theme\n document.documentElement.classList.toggle('dark', theme === 'dark')\n</script>\n```\n\nThe owner's click handler updates the document and the toggle's `aria-pressed` state, and persists\ninside a guarded storage write. Use a server-rendered theme when the application already has one.\nA controlled Lumen toggle does not read storage or change that state itself.\n\nFor commands generated from a form, point `CopyButton` at the current output element. It reads the\nlatest text when clicked, so the application only updates the output rather than managing clipboard\npromises or feedback timers:\n\n```astro\n---\nimport { CopyButton } from '@santi020k/lumen-astro'\n---\n\n<output id=\"generated-command\">pnpm run check</output>\n<CopyButton target=\"#generated-command\" label=\"Copy command\" copiedLabel=\"Command copied\" errorLabel=\"Copy failed; select the command and copy it manually\">\n Copy command\n</CopyButton>\n```\n\n## MCP-Assisted Workflow\n\nConnect `@santi020k/lumen-mcp` when the agent supports Model Context Protocol. The server returns\nvalidated structured content as well as readable text, so agents can select components without\nscraping documentation.\n\nThe MCP per-component catalog covers Astro, React, Web Components, React Native, SwiftUI, and\nCompose. Use `lumen_list_native_components` to discover platform availability and\n`lumen_get_native_component` for installation, setup, API, examples, accessibility guidance, and\noptional reference source. The skill still verifies installed packages when local versions differ.\n\n```bash\ncodex mcp add lumen -- npx -y @santi020k/lumen-mcp\n```\n\nFor Codex project configuration:\n\n```toml\n[mcp_servers.lumen]\nenabled = true\ncommand = \"npx\"\nargs = [\"-y\", \"@santi020k/lumen-mcp\"]\n```\n\nUse this sequence:\n\n1. Read `lumen://meta` and `lumen://diagnostics` to identify and verify the snapshot.\n2. Read `lumen://rules`.\n3. Search for the use case with `lumen_search`, passing the target `framework` when known.\n4. Inspect the selected framework with `lumen_get_component` and `detail: \"usage\"`.\n5. Follow its framework behavior setup and hook/controller contract.\n6. Read related multi-component guidance with `lumen_get_recipe`.\n7. Request `detail: \"source\"` only when the usage contract is insufficient.\n8. Read `lumen://tokens` before introducing custom styling.\n9. Retain `lumen://catalog-manifest` and use `lumen_diff_catalog` after upgrades to identify contracts that need refreshing.\n\nExample framework-aware component request:\n\n```json\n{\n \"name\": \"lumen_get_component\",\n \"arguments\": {\n \"name\": \"DateRangePicker\",\n \"framework\": \"react\",\n \"detail\": \"usage\"\n }\n}\n```\n\nStable resources are available at `lumen://rules`, `lumen://tokens`, `lumen://components`,\n`lumen://meta`, `lumen://catalog-manifest`, `lumen://diagnostics`,\n`lumen://components/{name}`, and `lumen://recipes/{name}`. See\n[`packages/mcp/README.md`](../packages/mcp/README.md) for Claude Desktop, Cursor, generic stdio,\nlocal repository, and programmatic examples.\n\n## Minimal Setup\n\nImport the stylesheet once in the app's global CSS, root layout, or app entry.\n\nThis section applies to web targets. Native adapters use their provider or theme APIs instead of a\nstylesheet.\n\n```css\n@import \"@santi020k/lumen-react/styles.css\";\n```\n\nIf Tailwind is present, keep both imports in the same shared CSS entry.\n\n```css\n@import \"@santi020k/lumen-react/layers.css\";\n@import \"tailwindcss\";\n@import \"@santi020k/lumen-react/styles.css\";\n```\n\nKeep the layer prelude first. It places Tailwind base styles below Lumen components and Tailwind\nutilities above them, so consumer spacing, display, radius, width, and responsive visibility\nclasses override Lumen defaults predictably.\n\nAstro apps should mount `UIPrimitives` once in the root layout when using interactive primitives\nsuch as dialogs, menus, popovers, tabs, carousels, command lists, toggles, validated forms, or\ntoasts. Do not place `UIPrimitives` in each component example or next to each primitive instance;\none root include enhances all matching `data-ui-*` markup on the page.\n\n```astro\n---\nimport UIPrimitives from '@santi020k/lumen-astro/runtime'\n---\n\n<UIPrimitives />\n```\n\n## Astro\n\nUse direct named imports from `@santi020k/lumen-astro`.\n\n```astro\n---\nimport { Button, Card, Input, Label } from '@santi020k/lumen-astro'\n---\n\n<Card>\n <Label for=\"email\">Email</Label>\n <Input id=\"email\" type=\"email\" placeholder=\"you@example.com\" />\n <Button type=\"submit\">Subscribe</Button>\n</Card>\n```\n\n`Stat` defaults to a `div`. Choose `as=\"article\"` for a self-contained metric with supporting\ncontent, or `as=\"section\"` for a labeled region. Do not use a semantic root solely for styling.\nIts visual variants are `default`, `accent`, and `glass`; reserve `accent` for featured metrics and\nkeep glass selective.\n\nFor long reading surfaces, use `Anchor` for local section navigation and pass each item’s optional\nheading `depth`, `index`, and `description` when hierarchy matters. Use `activationOffset` for fixed\napplication headers. Use `ScrollProgress` for document position; keep `Progress` for\napplication-controlled task completion. In Astro, dispatch `ui:progress-change` with\n`{ value, max? }` from the `Progress` root when browser state changes after hydration. `Pill`\naccepts `neutral`, `brand`, and `outline`\nvariants, plus native link and count composition.\n\n## React\n\nReact components share the same visual classes and data attributes as Astro components. For\nbehavior-heavy primitives, use the React behavior hooks from `@santi020k/lumen-react` (`useDialog`,\n`usePopover`, `useDropdownMenu`, `useTabs`, `useSelect`, `useToast`, and `useTooltip`), which track\nthe Astro runtime's ARIA, keyboard, Escape, dismissal, and toast controller semantics.\n\nThe React package entry is a client boundary. In Next.js, it can be imported by a Server Component\nwithout evaluating Lumen's contexts and hooks in the React server runtime; the rendered Lumen\nsubtree becomes a Client Component boundary. Vite and other client-rendered React applications use\nthe same entry without additional setup.\nFor stateless Badge, Card composition, layout, form-label, progress, loading, typography, and\nvisually-hidden usage in a React Server Component, import from\n`@santi020k/lumen-react/server`. That entrypoint contains the implementations directly and avoids\ncreating a client boundary. Keep event handlers, hooks, and interactive primitives on the package\nroot inside a Client Component.\n`Button`, `Link`, `ButtonLink`, and `Input` accept React 19 DOM refs directly as props.\n`Button asChild` composes the button contract onto one child element. Across adapters, native\nnumeric `size` remains available on `Input` and `NativeSelect`; use `visualSize` in Astro and React,\nor `visual-size` in Elements, for presentation.\n`Stat as` and `variant` follow the same semantic-root and visual guidance as the Astro component.\n\n```tsx\nimport \"@santi020k/lumen-react/styles.css\";\nimport { Button, Card, Input, Label } from \"@santi020k/lumen-react\";\n\nexport function SubscribeForm() {\n return (\n <Card>\n <Label htmlFor=\"email\">Email</Label>\n <Input id=\"email\" type=\"email\" placeholder=\"you@example.com\" />\n <Button type=\"submit\">Subscribe</Button>\n </Card>\n );\n}\n```\n\nFor React Hook Form, spread `register()` directly onto native-backed controls. Install\n`@santi020k/lumen-react-hook-form` only for controlled composites such as `Select`, `DatePicker`,\n`InputOTP`, and `ListBox`. Do not combine React Hook Form and `useFormValidation` unless the\napplication intentionally coordinates two validation sources. For schema validation, use\n`zodResolver` or `yupResolver` from `@hookform/resolvers`; keep Zod or Yup in the application and\nrender the resulting React Hook Form errors through Lumen's `FieldError` contract.\n\n## Web Components\n\nRegister custom elements once, then use `lumen-*` tags in HTML. The elements adapter includes\ninteractive behavior parity for DataTable, Dialog, Popover, DropdownMenu, ContextMenu, Tabs,\nSelect, ThemeBuilder, Toast, Tooltip, forms, RichTextEditor, Schedule, and VirtualList: selection,\nvalidation, rich text command, context menu, schedule drag/drop, theme, and range events, ARIA\nstate, roving/listbox keyboard paths, Escape/outside dismissal, focus return/trapping, native\nselect form participation, and the same toast controller events as the Astro runtime.\n\n```html\n<script type=\"module\">\n import { defineLumenElements } from \"@santi020k/lumen-elements/define\";\n\n defineLumenElements();\n</script>\n\n<lumen-card>\n <label for=\"email\">Email</label>\n <lumen-input\n id=\"email\"\n type=\"email\"\n placeholder=\"you@example.com\"\n ></lumen-input>\n <lumen-button type=\"submit\">Subscribe</lumen-button>\n</lumen-card>\n```\n\nFor a surface that uses only Badge, Button, Card, and Combobox, import\n`@santi020k/lumen-elements/components/badge`, `/button`, `/card`, and `/combobox` and call their\ncorresponding `defineLumenBadge`, `defineLumenButton`, `defineLumenCard`, and `defineLumenCombobox`\nfunctions. Those implementation-level entrypoints avoid the complete catalog bundle; the Combobox\nentrypoint includes its filtering and keyboard behavior. Continue using `defineLumenElements` for\nany component without a granular entrypoint.\n\nFor a foundation-only custom-element surface, import `defineLumenFoundations` from\n`@santi020k/lumen-elements/components/foundations`. It registers Card compound parts, Container,\nDirection, Grid, Label, Separator, Skeleton, Spinner, Stack, Typography, and VisuallyHidden without\npulling the complete catalog implementation.\n\nPut input elements inside a native `<form data-ui-form>`. Scalar Lumen controls participate in\n`FormData`, validity, disabled state, focus, and reset through form-associated custom-element\nbehavior with a native-control fallback. Do not generate `<lumen-form>` as the submission\ncontainer.\n\n## Component Selection\n\nThe shared catalog includes:\n\n`Accordion`, `Alert`, `AlertDialog`, `Agenda`, `AspectRatio`, `Attachment`, `Autocomplete`,\n`Avatar`, `Badge`, `BarChart`, `Breadcrumb`, `Bubble`, `Button`, `ButtonGroup`, `Calendar`, `Card`,\n`Carousel`, `Chart`, `Checkbox`, `CheckboxGroup`, `Collapsible`, `Code`, `Combobox`, `Command`,\n`Container`, `ColorPicker`,\n`ContextMenu`, `DataTable`, `DatePicker`, `DateRangePicker`, `Dialog`, `Direction`, `Drawer`,\n`DropdownMenu`, `Empty`, `ErrorState`, `ErrorSummary`, `Field`, `FieldError`, `Form`, `Grid`, `HoverCard`, `Icon`,\n`Graphic`, `Backdrop`, `Illustration`, `Input`, `InputGroup`, `InputOTP`, `Item`, `KanbanBoard`, `KanbanColumn`, `Kbd`, `Label`, `LineChart`, `ListBox`, `Marker`, `Menubar`,\n`Message`, `MessageScroller`, `NativeSelect`, `ContextNavigation`, `NavigationMenu`, `NumberField`, `Pagination`,\n`PasswordField`, `PieChart`, `Popover`, `Progress`, `CopyButton`, `RadioGroup`, `Resizable`, `RichTextEditor`,\n`ScrollArea`, `Schedule`, `SearchField`, `Select`, `Separator`, `Sheet`, `Sidebar`, `Skeleton`,\n`Slider`, `Sparkline`, `Spinner`, `Stack`, `Switch`, `Table`, `Tabs`, `TagGroup`, `Textarea`, `ThemeBuilder`,\n`TimeField`, `Toast`, `Toggle`, `ToggleGroup`, `Tooltip`, `Tree`, `TreeGrid`, `Typography`, and\n`VirtualList`, and `VisuallyHidden`.\n\n## Product Recipes\n\n- Follow [the error-handling guide](./error-handling.md) when a generated interface needs failure\n feedback. Use `FieldError` and `ErrorSummary` for validation, `Alert` for persistent inline\n problems, `Toast` for brief non-blocking failures, and `ErrorState` only when a page or region\n cannot show its primary content. Keep exception capture, logging, retry policy, and safe message\n mapping in application code. For React Native, SwiftUI, and Compose, follow the guide's native API\n mapping and announcement rules; do not invent a cross-platform error manager inside the component\n layer.\n\n- Start full product surfaces from the bundled `analytics-dashboard`, `saas-admin`,\n `commerce-dashboard`, `project-workspace`, or `auth-onboarding` recipe. Install with\n `lumen add <recipe>` for Astro, or add `--target react` / `--target elements`. Keep the generated\n shared template stylesheet, then replace sample data and routes while preserving public Lumen\n components, semantic tokens, accessible names, focus paths, and responsive navigation.\n- Use `Sparkline` for a compact trend beside a labelled metric, `BarChart` for categorical\n comparison, `LineChart` for ordered or time-series data, `PieChart` for a small\n part-to-whole breakdown. Pass already-aggregated `LumenChartSeries` data; keep statistics,\n resampling, model evaluation, and domain calculations in application code. Keep `showTable`\n enabled unless the same values are already available in a nearby semantic table. Prefer a bar\n chart when precise slice comparison matters or the pie would exceed roughly seven categories.\n Use `ScatterChart` for numeric relationships, `Heatmap` for a labeled matrix, `RangeChart` for\n low-to-high intervals, and `ComboChart` only when mixed marks share a meaningful domain.\n Use `WaterfallChart` for signed balance changes, `LollipopChart` for ranking, `DumbbellChart`\n for reference/current comparison, and `BulletChart` for actual/target comparison. Use `Histogram`\n for supplied bins, `BoxPlot` for supplied distribution summaries, `CalendarHeatmap` for daily\n activity, and `FunnelChart` for ordered cohort stages. Retrieve each specialized data contract;\n these components do not all accept `LumenChartSeries`. Keep statistical and conversion policy\n in the application, preserve missing days, and use density for unequal histogram bin widths. Use\n `Chart` as the custom SVG/canvas escape hatch. Preserve `null` gaps and the default accessible\n summary and fallback data. See `docs/data-visualization.md` for the full contract.\n Pass `labels` to replace every library-owned visible and assistive phrase. Finite zero is valid\n data, while an empty chart has no finite values; do not silently reinterpret an all-zero series\n as missing. Set `showTable={false}` (`showData={false}` on native adapters) only when an\n equivalent accessible table or scoreboard is adjacent.\n Keep stable identity in `datum.x` and put display text in `datum.xLabel`. Use `formatCategory`\n and `formatValue` for localized axes, SVG titles, and data tables. Choose\n `presentation=\"bare\"` inside an existing Card, `categoryWidth` for long horizontal labels,\n `markers=\"auto\"` or a numeric marker step for dense lines, and `emptyLabel` for actionable\n source-specific guidance.\n- Use `Icon name=\"search\"` or any Lucide icon name for standard interface pictograms. Kebab-case\n names and Lucide aliases work. Pair decorative icons with `decorative`, and pass `label` only\n when the icon carries meaning on its own. On the web, install and register\n `@santi020k/lumen-icons-brand` for supported brand marks; native adapters include those names.\n Then use `brand:github` or the adapter's generated equivalent. Render project-specific or\n unsupported brand SVGs through the default `Icon` slot. Astro reports unknown named icons during\n development instead of failing silently.\n- Use `Schedule` with `Agenda`, `Calendar`, and `DatePicker` for booking, CRM, content calendar, and\n event planning screens. Mark slots with `data-ui-schedule-slot` and events with\n `data-ui-schedule-event`; the runtime emits `ui:schedule-change` when a draggable event is moved.\n `Calendar` renders a hidden input plus selectable date grid; Astro `UIPrimitives` and registered\n Web Components wire month navigation and roving keyboard focus directly, while React apps can use\n the `Calendar` component or `useCalendar` for the same date selection contract.\n Astro `UIPrimitives` and registered Web Components bind schedule drag/drop directly; React apps\n can use `useSchedule` for `rootProps`, `getEventProps`, `getSlotProps`, and `ui:schedule-change`\n parity. Use `createScheduleSlots`, `moveScheduleEvent`, and `expandRecurringScheduleEvent` from\n `@santi020k/lumen-core` for reusable schedule state; use `getScheduleConflicts` and\n `canPlaceScheduleEvent` before committing drag/drop changes. Persist local schedule edits with\n `createScheduleStorageKey`, `saveScheduleEvents`, and `loadScheduleEvents` against any\n `localStorage`-compatible adapter. Use `resizeScheduleEvent` and `resizeScheduleEvents` for\n snap-to-grid resize handles.\n- Use `KanbanBoard` and `KanbanColumn` for status-based workspaces while continuing to use `Card` for\n items. Mark items with `data-ui-kanban-item` and put `data-ui-kanban-handle` on a dedicated button;\n do not make the whole card draggable. Astro and Elements emit the cancellable, controlled\n `ui:kanban-move-request` event, and React provides `useKanban`. The event reports `itemId`,\n `fromColumn`, `toColumn`, optional `beforeId`, and keyboard or pointer input, but never moves DOM or\n persists application data. The host owns validation, `aria-busy`, optimistic state, rollback,\n status names, and APIs. Use compact `Empty` states inside columns, preserve every column with\n `Skeleton` blocks while loading, and paginate large columns. See [Kanban composition and movement](kanban.md).\n- React applications can compose [TanStack-powered table views](data-table-views.md) through\n `DataTableView`, preserving application-owned cells and server queries. Numeric/date ranges and\n page-scoped selection are opt-in. Use `DataTableSavedViews` for explicit host-owned preference\n persistence, and `useLumenReviewWorkflow` for reviewed commands with stale-revision and unknown-outcome\n guards. Install `review-workflow`, `import-review` or `record-workspace` for executable React compositions.\n- Use `DataTable`, `Tree`, `TreeGrid`, `VirtualList`, `Pagination`, and `Command` for dense data\n collection workflows. Astro and Elements wire selectable/sortable `DataTable` behavior and\n `VirtualList` range events; React also provides built-in fixed-height list windowing. `Tree`\n and `TreeGrid` use roving keyboard focus, and `VirtualList` emits `ui:virtual-list-range` as the\n rendered range changes. Use `createDataViewState`,\n `serializeDataViewState`, `parseDataViewState`, `applyDataViewState`, and `getVirtualRange` for\n saved views, filtering, sorting, pagination, and virtualization math. Use\n `createDataViewStorageKey`, `saveDataViewState`, and `loadDataViewState` when saved views should\n survive navigation or reloads. Use `pinDataViewColumn`, `unpinDataViewColumn`,\n `toggleDataViewSelection`, `createDataViewSearchParams`, and `createDataViewServerRequest` when\n connecting dense views to router state or server-backed data.\n- Use `Resizable` for split-pane editors, dashboards, inspectors, and side-by-side review layouts.\n Astro `UIPrimitives` and registered Web Components generate separator handles and pane sizing\n directly; React apps can use the `Resizable` component or `useResizable` for pointer, keyboard,\n min/max, and double-click reset parity.\n- Use `ThemeBuilder`, `ColorPicker`, `Card`, `Button`, and `Input` to build token preview and export\n tools. Add `data-ui-theme-brand-hue`, `data-ui-theme-accent-hue`, `data-ui-theme-scheme`,\n `data-ui-theme-mode`, manual color inputs such as `data-ui-theme-primary-hex`, and\n `data-ui-theme-target` for scoped live previews. Add `data-ui-theme-export-format`,\n `data-ui-theme-export`, and `data-ui-theme-output` to copy CSS, Figma variable JSON, or design\n token JSON. Astro `UIPrimitives` and registered Web Components wire those controls directly;\n React apps can use `useThemeBuilder` for the same tokens, exports, and control props. Use\n `createThemePalette`, `createThemeBuilderTokens`, `exportThemeBuilderValue`, `exportThemeCss`,\n `parseThemeCss`, and `scoreThemeContrast`\n for theme import/export and accessibility checks. Use `suggestReadableInk`, `mergeThemeTokens`,\n and `tuneThemeContrast` when generated palettes need readable foreground repair.\n- Use `RichTextEditor` with `ButtonGroup`, `ToggleGroup`, and `Textarea` for editor compositions.\n Controls with `data-ui-editor-command` request formatting, block, history, link or list commands;\n use `data-ui-editor-value` for value-bearing commands. Before the browser executes a command,\n Lumen emits the bubbling, cancelable `ui:editor-command-request` event. External engines must\n call `preventDefault()` and set `event.detail.executed` to their synchronous result. A failed\n external command never falls back to the browser. The existing `ui:editor-command` event reports\n completion; use it for notifications, not executing the command again.\n Editable surfaces support formatting shortcuts and emit `ui:editor-change` with HTML and text.\n Set `data-ui-editor-native-state=\"false\"` when an external engine owns toolbar state.\n React apps can use `useRichTextEditor({ commandHandler: ({ command, value }) => boolean })`;\n providing a handler disables native toolbar state syncing by default. Use `nativeState` to\n explicitly control that policy. Engine commands are synchronous; engines with asynchronous\n work should manage completion themselves. Keep Tiptap, ProseMirror, Lexical and other engines\n in the application.\n- Use `Autocomplete`, `SearchField`, `NumberField`, `TimeField`, `DateRangePicker`, `ColorPicker`,\n and `TagGroup` when forms need more than plain text inputs. Add `data-ui-form` to forms that\n should reflect native Constraint Validation API state into `Field` error slots on blur and submit.\n For `InputOTP`, Astro `UIPrimitives` and registered Web Components render/sync a real native input\n with visual OTP segments; React apps can use the `InputOTP` component or `useInputOTP` for the\n same sanitization, paste handling, and caret navigation.\n For `DateRangePicker`, Astro `UIPrimitives` and registered Web Components sync the paired native\n date input `min`/`max` constraints directly; React apps can use `useDateRangePicker` for the same\n start/end syncing props.\n Use native attributes such as `required`, `pattern`, `min`, `max`, and `type`; add\n `data-error-required`, `data-error-pattern`, `data-error-min`, `data-error-max`, or\n `data-error-custom` when a control needs custom copy. Listen for `ui:validate` to call\n `control.setCustomValidity(message)` before the runtime reads validity, and listen for\n `ui:invalid` or `ui:valid` on the form for submission state. Astro `UIPrimitives` and registered\n Web Components bind `data-ui-form` directly; React apps can use `useFormValidation` with `Field`\n to get the same control props, CustomEvents, and error slot updates.\n- Use `Toast` for transient feedback. Static `<Toast>` markup works without\n JavaScript. With Astro `UIPrimitives` or registered Web Components, create toasts with\n `window.LumenToast.create(detail)`, `LumenToast.create(detail)` from `@santi020k/lumen-elements`,\n or `document.dispatchEvent(new CustomEvent('ui:toast', { detail }))`; update them with\n `.update(id, detail)` or `ui:toast-update`; and dismiss them with `.dismiss(id?)` or\n `ui:toast-dismiss`. `detail` supports `title`, `description`, `variant`, `duration`, `placement`,\n `max`, and `action: { label, value }`. Destructive toasts use `role=\"alert\"`; other variants use\n `role=\"status\"`. Runtime toasts pause their timeout on hover or focus, and Escape dismisses the\n focused toast.\n\n## Runtime CustomEvents\n\nWhen `UIPrimitives` is mounted, or when Web Components are registered for matching behavior, Lumen\nuses these CustomEvents:\n\n| Event | Target | Detail | Fires When |\n| -------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |\n| `ui:data-table-selection-change` | `DataTable` root `[data-ui-datatable]` | `{ values: string[] }` | Selectable row checkboxes, the select-all checkbox, or form reset changes selected row values. |\n| `ui:schedule-change` | `Schedule` root `[data-ui-schedule]` | `{ eventId?: string, slot?: string }` | A draggable schedule event is dropped on a `[data-ui-schedule-slot]`. |\n| `ui:virtual-list-range` | `VirtualList` root `[data-ui-virtual-list]` | `{ startIndex: number, endIndex: number }` | The virtual list calculates its visible range on init or scroll. |\n| `ui:kanban-move-request` | `KanbanBoard` root `[data-ui-kanban]` | `{ itemId: string, fromColumn: string, toColumn: string, beforeId?: string, input: 'keyboard' \\| 'pointer' }` | A handle requests a controlled move; the event is cancellable and never moves data or DOM. |\n| `ui:tag-remove` | `TagGroup` root `.ui-tag-group` or `[data-ui-tag-group]` | `{ value?: string }` | A `[data-ui-tag-remove]` control removes its closest tag or list item. |\n| `ui:editor-command-request` | `RichTextEditor` root `[data-ui-rich-text-editor]` | `{ command: string, executed: boolean, value?: string }` | Cancel before execution and set executed for external engine ownership. |\n| `ui:editor-command` | `RichTextEditor` root `[data-ui-rich-text-editor]` | `{ command: string, executed: boolean, value?: string }` | A toolbar control or keyboard shortcut runs an editor command. |\n| `ui:editor-change` | `RichTextEditor` root `[data-ui-rich-text-editor]` | `{ html: string, text: string }` | Editable content changes or an editor command runs. |\n| `ui:theme-change` | `ThemeBuilder` root `[data-ui-theme-builder]` | `{ hue: number, accentHue: number, mode: 'generated' \\| 'manual', scheme: 'dark' \\| 'light', tokens: Record<string, string> }` | Hue, manual color, mode, or scheme controls update generated tokens. |\n| `ui:theme-export` | `ThemeBuilder` root `[data-ui-theme-builder]` | `{ css?: string, format: 'css' \\| 'figma' \\| 'tokens', tokens: Record<string, string> \\| null, value: string }` | A `[data-ui-theme-export]` control writes the selected export to the output and clipboard when available. |\n| `ui:language-change` | `LanguageToggle` control | `{ previousValue: string, value: string }` | Requests or applies the next configured locale. |\n| `ui:validate` | Form `[data-ui-form]` | `{ control, form, value }` | Before validation state is read, so custom code can call `control.setCustomValidity(message)`. |\n| `ui:invalid` | Form `[data-ui-form]` | `{ control?, controls?, form }` | Blur or submit validation finds invalid controls. Failed submits focus the first invalid control. |\n| `ui:valid` | Form `[data-ui-form]` | `{ control?, controls?, form }` | Blur or submit validation finds the checked control or form valid. |\n| `ui:toast` | `document` | `{ id?, title?, description?, variant?, duration?, placement?, action? }` | Creates a runtime toast in the matching notification viewport. |\n| `ui:toast-update` | `document` | `{ id, title?, description?, variant?, duration? }` | Updates a runtime toast. |\n| `ui:toast-dismiss` | `document` | `{ id? }` | Dismisses one runtime toast, or all runtime toasts when `id` is omitted. |\n| `ui:toast-action` | Toast `[data-ui-toast]` | `{ id, value? }` | A runtime toast action button is pressed, unless the action specifies a custom event name. |\n\n## Registry Helper\n\n`@santi020k/lumen` exports typed component, recipe, and documentation metadata from\n`@santi020k/lumen/registry`. The umbrella package and every web adapter expose the same small\n`lumen` binary, so projects that install Astro, React, or Elements can use discovery and diagnostics\nwithout another direct dependency. Individual components are addressable by name, including\nkebab-case aliases such as `data-table`:\n\n```bash\nlumen list\nlumen show Button\nlumen show data-table\nlumen add Button\nlumen add Button --target react\nlumen add Button --target elements\nlumen add data-table\nlumen show scheduler\nlumen add scheduler\nlumen add scheduler --target react\nlumen add scheduler --target elements\nlumen add scheduler --dry-run\nlumen add scheduler --fail-on-conflict\nlumen add scheduler --force\nlumen add scheduler --merge\nlumen list --registry ./registry/lumen.registry.json\nlumen add custom-recipe --registry ./registry/custom.registry.json\nlumen add private-recipe --registry https://example.com/lumen.registry.json --registry-token \"$TOKEN\"\nlumen install\nlumen doctor --json\nlumen doctor-native --json\nlumen init --framework astro --tailwind\n```\n\n`lumen doctor-native` emits the same adoption inventory and Android toolchain preflight in readable\ntext or JSON. Treat direct-primitive matches as review suggestions, not rewrite instructions: first\nconfirm whether a surface is product-owned and whether the public Lumen contract preserves its\nbehavior. A failed JDK, SDK, platform-package, or required-NDK preflight should be fixed before\nstarting Gradle so native compilation does not obscure the setup problem.\n\n`lumen add <component>` defaults to an Astro wrapper. Use `--target react` for a local React wrapper\nor `--target elements` for a custom-elements starter. Bundled recipes also accept `--target astro`,\n`--target react`, and `--target elements`; external registry inline files keep their authored paths\nand sources. Those paths must be unique, relative forward-slash paths: the installer rejects\nabsolute paths, parent traversal, and symbolic-link path segments before writing any file. Registry\nmanifests are limited to 5 MiB by default; programmatic `loadLumenRegistry` callers can lower the\nlimit with `maxBytes` and control remote request time with `timeoutMs`.\n\nBefore inventing a wrapper or custom class pattern, check the component source in\n`packages/astro/components`. Astro is the reference implementation for props, class names, data\nattributes, and accessible markup.\n\n## Styling Rules for Generated Code\n\n- Read the [content flow contract](content-flow.md). Stack and Grid own sibling gaps; surfaces own\n padding; Field owns label/control/feedback spacing. Do not add another child margin for the same\n relationship. Use `gap=\"related\"` for closely related controls, `gap=\"group\"` for separate groups\n and `gap=\"section\"` for major sections. Retrieve spacing tokens through MCP.\n- Use Card parts for header/body/actions and a nested Stack inside complex CardContent. Card\n handles missing or hidden parts and wraps footer actions. Choose `density=\"compact\"`,\n `\"comfortable\"` (default) or `\"spacious\"` instead of scattering padding overrides.\n- Keep text rhythm inside Prose or Typography. Verify long and translated text, enlarged text,\n validation states, optional sections and mobile wrapping before calling a composition finished.\n- Use the `page-header` and `section-header` recipes for dashboard identity, optional breadcrumbs,\n status/count badges and independent actions. Keep headings in document order, pass translated\n navigation/action labels and unique heading ids, and connect buttons to application-owned handlers.\n- Retrieve complete `content-flow-header`, `content-flow-settings`, `content-flow-list` and\n `content-flow-actions` examples with `lumen_get_recipe`; the CLI installs the same compositions.\n Keep one outer Container for fluid gutters. Card permits interactive overflow; use AspectRatio\n to clip media. Reading blocks trim their outer margins, so avoid compensating offsets.\n\n- Do not require consumers to configure Tailwind for Lumen components.\n- Prefer component props and composition before adding custom CSS.\n- Assign each visible outer edge to one container. For flush content inside a rounded frame,\n let the frame own the border and corner clipping; remove child rounding with supported props.\n For example, use `<Image radius=\"none\" ... />` below a framed header: `Image` defaults to\n `radius=\"lg\"`, which otherwise leaves rounded gaps at the shared edge. Keep standalone image\n rounding when appropriate.\n- Use a single divider between header and body rather than overlapping full borders. Inset\n surfaces with padding may have their own radius; account for the inset so their curves align.\n Clip artwork at its frame, not an entire interactive surface where focus rings or overlays\n could be cut off. Verify shared corners, borders, and keyboard focus at phone and desktop\n widths in both light and dark themes.\n- Use the shared token names for custom surfaces: `canvas`, `surface`, `surface-muted`,\n `surface-strong`, `line`, `ink`, `ink-soft`, `ink-muted`, `brand`, `brand-solid`, `brand-soft`,\n `accent`, `success`, `warning`, and `danger`.\n- Use `glass` as a boolean prop or attribute for glass surfaces when supported.\n- Preserve semantic labels, keyboard paths, focus states, and native form attributes.\n\n## Figma Handoff\n\nUse `exportThemeFigmaVariables` from `@santi020k/lumen-core/figma` when a design workflow needs\nFigma variable-friendly color values, and `exportThemeDesignTokens` when exporting standard design\ntoken JSON for token importers. See [docs/figma.md](figma.md) for the recommended Figma workflow.\n\n## When Editing This Repo\n\n- Shared contracts belong in `packages/core`.\n- Astro implementation and standalone CSS belong in `packages/astro`.\n- React-specific primitives belong in `packages/react`.\n- Web Component-specific primitives belong in `packages/elements`.\n- Update the relevant package README when public usage changes.\n- Add tests beside package code as `*.test.ts` when behavior, exported metadata, or component\n contracts change.\n\n### React inline range calendar\n\nFor a range editor with both endpoint calendars visible, use the React-only\n`DateRangeCalendar` export. Pass `value={{ start, end }}`, `onValueChange`, required\n`labels={{ start, end, presets }}`, `locale`, optional `min`/`max`, and optional\n`presets={[{ label, value: { start, end } }]}`. `formatDate` customizes the displayed\nendpoint summaries. Keep a draft in the consuming application and apply it only\nwhen confirmed. ISO endpoints must be valid and ascending. Crossing a boundary\nmoves the other endpoint; presets outside the bounds are disabled. The component\nshares `useCalendar` keyboard behavior and stacks its calendars on narrow screens.\n\nFor React input-attached range selection, import `DateRangeInput` from\n`@santi020k/lumen-react`. Pass controlled ISO `value`/`onValueChange`, an accessible\n`label`, and localized `labels` containing `start`, `end`, `presets`, `apply`, and\n`cancel`. It owns draft state and confirmation, anchors a non-modal popover to its\ntrigger, and supports keyboard dismissal and responsive scrolling. Use `validate`\nfor domain restrictions and `renderSummary` for draft details. Optional\n`name={{ start: 'from', end: 'to' }}` submits applied ISO values through hidden inputs.\nSee the React README for the complete usage and browser fallback contract.\n\n## Fixed-height virtual lists\n\n`VirtualList` uses the same DOM windowing controller in Astro, React and Elements. All rows remain\nmounted; offscreen rows are hidden, and inert spacers preserve the complete scroll height. This\nreduces displayed rows, not the cost of creating the initial DOM. Use pagination or an application\nrenderer when retaining the entire dataset in the DOM is too expensive.\n\nUse `itemSize` (Astro/React) or `item-size` (Elements) for a fixed row height in pixels, defaulting\nto 44. `overscan` defaults to four rows on each side. Every direct element child is a row; put\ncontrols inside it, and keep headers or empty-state actions outside the list. Rows must fit the\nconfigured height; variable-height measurement is not supported. Do not add external row margins\nor gaps. Initially hidden rows remain hidden and are excluded from the window.\n\nThe controller refreshes on scrolling, resizing, direct child changes and sizing attribute changes.\nA focused row and its neighboring rows remain available for native Tab navigation, which can extend\nthe displayed window until focus moves. Name the scroll container using `aria-label` or\n`aria-labelledby`; it is keyboard-focusable by default. `ui:virtual-list-range` reports inclusive\n`startIndex` and `endIndex` when the displayed range or item count changes. Empty lists use\n`{ startIndex: 0, endIndex: -1 }`. `data-ui-range-start` and `data-ui-range-end` mirror the current\nwindow. Elements disconnect and React unmount restore original row styles and hidden state.\n\n## NumberField boundary\n\n`NumberField` remains a native number-input wrapper. The v4 consumer audits do not establish a\nshared requirement for a locale-aware draft parser. Applications needing decimal-comma entry,\nlossless monetary drafts or currency rules should retain their explicit parsing and validation\npolicy; Lumen must not infer currency units or precision from locale.\n\n\n## Combobox keyboard behavior in v4\n\nCombobox keeps DOM focus in its input while Arrow Up/Down changes the active option through\n`aria-activedescendant`. Enter commits the active option only while the list is open; without an\nactive option, normal form submission remains available. Home, End, Left and Right retain native\ntext editing. Composition input and canceled keyboard events do not trigger selection or dismissal.\nEscape closes the innermost open control first, preserving an enclosing popup or dialog until the\nnext Escape. Tab dismisses the list when focus leaves the component.\n\nAstro and Elements observe added, removed, relabeled and disabled options, and delegate pointer\nselection to the current list. React derives options from its current props and reports committed\nselections through `onChange`, including controlled inputs. Disabled and read-only inputs stay closed.\nApplications should keep focus on the input and observe `aria-activedescendant` instead of calling\nfocus on option buttons. The DOM adapters match both option labels and values.\n\n## Appearance presets\n\nChoose `default`, `studio` or `glass` at the theme boundary. See [appearance presets](appearance-presets.md) for exact framework APIs. Studio follows the neutral PostLens look. Glass remains explicit per supporting surface; React Native and Compose retain solid fallbacks without adding blur dependencies. Customize semantic colors, typography and surface dimensions through public theme APIs.\n\n## Version-aware build, review, and migration\n\nThe portable skills are `lumen-ui` for implementation, `lumen-review` for a read-only audit, and\n`lumen-migrate` for an explicitly requested upgrade. Their canonical sources live under `skills/`;\n`pnpm run generate:plugin-package` produces the client snapshots. Both client manifests use plugin\n1.1.0 and pin the v4 catalog. This candidate is not proof of publication or directory approval.\n\nBefore applying MCP contracts, call `lumen_check_compatibility` with resolved versions from installed\nmetadata or native lock files. A manifest range is not an installed version. When versions differ,\ninspect installed public types and README or use a matching MCP package. Do not silently upgrade.\nSearch supports English and Spanish queries with framework and platform filters.\n\nFor a v4 upgrade, read `lumen_get_migration` and preview `lumen migrate v4 --dry-run --json` locally.\n`--apply` rewrites only known static SDK import paths in JavaScript and TypeScript. It preserves\ncomments and examples and reports UI/native review triggers. Dependency updates and application-owned\nworkarounds require review; the CLI does not infer that a workaround can be removed. JSX, Astro,\nnative source, dynamic imports, and dependency manifests are not automatically rewritten by the\nSDK import transform.\n\nSee [v4 AI verification](lumen-4-ai.md) for actual agent benchmark coverage and release evidence.\n\nThe integrated `lumen migrate v4` command also retains the release's web spacing migrations and\noptional coordinated dependency workflow. SDK edits compose into that command's source transform,\nso its apply ledger fingerprints the final source and repeat runs do not rewrite spacing twice.\nSDK import edits remain limited to `.ts`, `.js`, and `.mjs`; this restriction does not disable the\nseparate documented JSX/Astro spacing migration. The JSON report includes installed package\nversions and explicit SDK dependency-review findings.\n\n\n## Login examples and Auth\n\nUse the `auth-onboarding` recipe for visual email-code sign-in, code verification, and passkey\nrecovery states in Astro, React, or Elements. The preview controls are disabled and do not authenticate.\nLumen supplies the UI; the optional `@santi020k/auth-client` supplies protocol operations.\nSee the [login integration reference](../packages/templates/README.md#login-examples-and-auth)\nfor helper calls, safe result handling, and the application-owned server and session requirements.\n## Complex React forms\n\nUse the optional `@santi020k/lumen-react/forms` hooks with public form primitives for dependent\nvalidation, unsaved edits, stable repeatable rows, step navigation, and cancellation-safe checks.\nRead [composable form workflows](powerful-forms.md) before implementation. Choose one validation\nowner; keep schemas, financial policy, requests, authorization, and draft storage in the consumer.\n\nFor photo and media editors, install the `media-workspace` recipe and follow\n[Studio media workspaces](studio-media-workspaces.md). Reuse public media controls; keep\nimage processing, storage and export in the consumer application.\n",
|
|
45962
|
+
"aiUsage": "# AI Usage Guide\n\nUse this guide when an AI agent is building an app with Lumen or updating examples that teach\nanother agent how to use the library.\n\nThis branch documents the locally prepared Lumen 4 candidate. Version 4 install examples become\nusable after publication; the public plugin and hosted MCP still describe their deployed version.\nUse the workspace packages and generated local MCP snapshot to evaluate this candidate.\n\n## Install in ChatGPT or Codex\n\nThe [Lumen UI plugin](https://chatgpt.com/plugins/plugin_asdk_app_6a8f6c526c5481918eb8a48806fa112b)\nis published in the shared Plugins Directory for ChatGPT and Codex.\n\n1. Open the listing and select **Install plugin**. Sign in to ChatGPT if prompted.\n2. Start a request with **@Lumen UI**, or choose one of the listing's starter prompts.\n3. Describe the interface and target framework, for example: “Find the right Lumen components for\n an accessible React settings screen.”\n\nThe plugin combines the Lumen workflow skill with the hosted public MCP catalog. It requires no\nseparate Lumen account, API key, or local server. Its tools only read public Lumen documentation;\nthey do not access private repositories or modify files and services. Your coding agent performs\nimplementation work using the retrieved contracts and its own permissions.\n\nSee the [publication record](./openai-plugin-submission.md) for version and verification details.\n\n## Install the Lumen Agent Skill\n\nLumen ships a portable Agent Skill for AI coding tools that support the open `SKILL.md` format:\n\n```bash\nnpx skills add santi020k/lumen --skill lumen-ui\n```\n\nThe source lives in [`skills/lumen-ui`](../skills/lumen-ui). The skill provides the durable\nframework-selection, component-composition, theming, accessibility, and verification workflow.\nUse it by naming `$lumen-ui` in a prompt, or let a compatible agent trigger it from Lumen-specific\nrequests.\n\nThe skill and MCP server complement each other: the skill explains how to work, while\n`@santi020k/lumen-mcp` supplies structured catalog search, framework-specific contracts, recipes,\nsource, tokens, resources, and rules.\n\n## Pick the Right Package\n\nUse the framework requested by the user. Every adapter shares the same Lumen foundation.\n\n| App Target | Install | Import Components From | Load Styles From |\n| -------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | -------------------------------------- |\n| Astro | `@santi020k/lumen-astro` | `@santi020k/lumen-astro` | `@santi020k/lumen-astro/styles.css` |\n| React | `@santi020k/lumen-react` | `@santi020k/lumen-react` | `@santi020k/lumen-react/styles.css` |\n| React Hook Form composites | `@santi020k/lumen-react-hook-form` | `@santi020k/lumen-react-hook-form` | Uses React styles |\n| Web Components | `@santi020k/lumen-elements` | `@santi020k/lumen-elements/define` | `@santi020k/lumen-elements/styles.css` |\n| React Native / Expo | `@santi020k/lumen-react-native` | `@santi020k/lumen-react-native` | Not applicable |\n| Apple / SwiftUI | Swift Package `https://github.com/santi020k/lumen`, pinned to `4.1.0` | `LumenUI` | Not applicable |\n| Android / Compose | Maven Central `com.santi020k:lumen-compose:4.0.0` | `com.santi020k.lumen` | Not applicable |\n| Wear OS / Compose | Maven Central `com.santi020k:lumen-compose-wear:4.0.0` | `com.santi020k.lumen` | Not applicable |\n| Package metadata | `@santi020k/lumen-core` | `@santi020k/lumen-core` | Not applicable |\n| Optional web brand icons | `@santi020k/lumen-icons-brand` | Register once, then use the framework `Icon` | Uses framework styles |\n\nEach framework package includes the shared foundation. Install `@santi020k/lumen` separately only\nwhen you need its framework-neutral CLI or registry metadata.\n\nFor React Native, mount one `LumenProvider` near the app root. For SwiftUI, attach the `LumenUI`\nSwift Package product to the application target, use an exact or compatible release-version rule,\nand apply `.lumenTheme(...)` near the root. For Compose, install\n`com.santi020k:lumen-compose:4.0.0` from Maven Central and wrap content in `LumenTheme`. Native\nadapters do not load CSS or the Astro runtime.\n\nReact Native can use ordinary React hooks and platform-neutral application hooks. Import native\nLumen controllers such as `useDialog`, `useTabs`, `useSelect`, `useLanguageToggle`,\n`useThemeToggle`, and `useToast` from `@santi020k/lumen-react-native`; their state semantics track\nthe reusable React contracts while their returned props target native Lumen components. Never\nimport DOM-dependent hooks from `@santi020k/lumen-react` into a native bundle. Share business state\nin an application workspace package and keep the Lumen rendering adapter at each app boundary.\n\nFor watchOS, use the focused `LumenWatch*` contracts from the same Swift package. For Wear OS,\ninstall `com.santi020k:lumen-compose-wear:4.0.0` and wrap wearable content in `LumenWearTheme`\ninside the application-owned Wear Material theme. Keep complications, tiles, rotary input,\nAlways On behavior, haptics, synchronization, navigation, and health or safety logic app-owned.\n\nBrand marks are intentionally excluded from the default web Lucide bundle. In Astro, React, or Web\nComponents, install and register the optional pack once before rendering a namespaced icon:\n\n```ts\nimport { registerLumenBrandIcons } from \"@santi020k/lumen-icons-brand\";\n\nregisterLumenBrandIcons();\n```\n\nThen use any canonical Font Awesome Free brand name, such as `brand:github`, `brand:discord`,\n`brand:figma`, `brand:linkedin`, `brand:whatsapp`, or `brand:youtube`, through the existing Astro,\nReact, or Web Component `Icon` API. The package also provides `brand:x` as an alias for\n`brand:x-twitter`.\n\nBrand marks keep their recognizable filled geometry instead of being redrawn as Lucide outlines.\nThey still inherit the same Lumen size, alignment, `currentColor`, and accessible-label behavior as\nthe default icon catalog. React Native, SwiftUI, and Compose already include the corresponding\ncomplete brand catalog and accept `brand:github`, `.brandGithub`, and\n`LumenIconName.BrandGithub`, respectively, without a separate package.\n\n## Consumer composition in v4\n\n- For reporting filters, use React `DateRangeInput` with controlled `value`/`onValueChange`, localized\n `labels`, bounds and explicit presets. Keep draft changes inside the picker until Apply. See the\n [reporting example](https://lumen.santi020k.com/docs/web/reporting) and [migration guide](migrating-to-lumen.md).\n- Keep date-only values as validated `YYYY-MM-DD` strings. Do not round-trip a local date through\n UTC timestamps. Use the native adapter's existing date field and application-owned calendar/time-zone rules.\n- Compose a metric card from `Card`, `Stat` and a chart with `presentation=\"bare\"`; do not invent\n MetricCard or Status components. Products own status labels, aggregation, currency units and rounding.\n- Use unique chart X identities, abbreviated `xLabel` text and full `formatCategory` labels.\n Preserve absent observations explicitly; never turn missing data into a fabricated zero.\n- Compare two media treatments with `ImageComparison` and matching source dimensions. Supply a\n visible localized control label and useful image alternatives. See [image comparison](image-comparison.md).\n- Use `Prose` for ordinary Markdown, `Code`/`CodeTabs` for examples, and `CopyButton` for dynamic\n command output. Localize clipboard success/error feedback; do not swallow rejected writes.\n- Use public `asChild` composition when a semantic link needs button styling. Disable nested form\n controls separately. On Input, native numeric `size` and Lumen `visualSize` have different meanings.\n- Map the complete semantic theme in light and dark modes, including `brand-solid`/`on-brand` and\n `danger`/`on-danger`. Text-safe colors matter more than matching a decorative swatch.\n- NavigationMenu, Sidebar and Sheet compose a documentation shell. Keep ordinary links in normal\n Tab order and application routing in the app. Do not rebuild primitives with `ui-*` classes.\n\n### Theme bootstrap and generated command output\n\nWhen the application owns theme state, use `ThemeToggle controlled` and its own click handler.\nInitialize the document in the head before paint; unavailable storage must not prevent rendering:\n\n```html\n<script>\n let theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'\n try {\n const saved = localStorage.getItem('application-theme')\n if (saved === 'light' || saved === 'dark') theme = saved\n } catch {\n // Private browsing or storage policy can deny access.\n }\n document.documentElement.dataset.theme = theme\n document.documentElement.classList.toggle('dark', theme === 'dark')\n</script>\n```\n\nThe owner's click handler updates the document and the toggle's `aria-pressed` state, and persists\ninside a guarded storage write. Use a server-rendered theme when the application already has one.\nA controlled Lumen toggle does not read storage or change that state itself.\n\nFor commands generated from a form, point `CopyButton` at the current output element. It reads the\nlatest text when clicked, so the application only updates the output rather than managing clipboard\npromises or feedback timers:\n\n```astro\n---\nimport { CopyButton } from '@santi020k/lumen-astro'\n---\n\n<output id=\"generated-command\">pnpm run check</output>\n<CopyButton target=\"#generated-command\" label=\"Copy command\" copiedLabel=\"Command copied\" errorLabel=\"Copy failed; select the command and copy it manually\">\n Copy command\n</CopyButton>\n```\n\n## MCP-Assisted Workflow\n\nConnect `@santi020k/lumen-mcp` when the agent supports Model Context Protocol. The server returns\nvalidated structured content as well as readable text, so agents can select components without\nscraping documentation.\n\nThe MCP per-component catalog covers Astro, React, Web Components, React Native, SwiftUI, and\nCompose. Use `lumen_list_native_components` to discover platform availability and\n`lumen_get_native_component` for installation, setup, API, examples, accessibility guidance, and\noptional reference source. The skill still verifies installed packages when local versions differ.\n\n```bash\ncodex mcp add lumen -- npx -y @santi020k/lumen-mcp\n```\n\nFor Codex project configuration:\n\n```toml\n[mcp_servers.lumen]\nenabled = true\ncommand = \"npx\"\nargs = [\"-y\", \"@santi020k/lumen-mcp\"]\n```\n\nUse this sequence:\n\n1. Read `lumen://meta` and `lumen://diagnostics` to identify and verify the snapshot.\n2. Read `lumen://rules`.\n3. Search for the use case with `lumen_search`, passing the target `framework` when known.\n4. Inspect the selected framework with `lumen_get_component` and `detail: \"usage\"`.\n5. Follow its framework behavior setup and hook/controller contract.\n6. Read related multi-component guidance with `lumen_get_recipe`.\n7. Request `detail: \"source\"` only when the usage contract is insufficient.\n8. Read `lumen://tokens` before introducing custom styling.\n9. Retain `lumen://catalog-manifest` and use `lumen_diff_catalog` after upgrades to identify contracts that need refreshing.\n\nExample framework-aware component request:\n\n```json\n{\n \"name\": \"lumen_get_component\",\n \"arguments\": {\n \"name\": \"DateRangePicker\",\n \"framework\": \"react\",\n \"detail\": \"usage\"\n }\n}\n```\n\nStable resources are available at `lumen://rules`, `lumen://tokens`, `lumen://components`,\n`lumen://meta`, `lumen://catalog-manifest`, `lumen://diagnostics`,\n`lumen://components/{name}`, and `lumen://recipes/{name}`. See\n[`packages/mcp/README.md`](../packages/mcp/README.md) for Claude Desktop, Cursor, generic stdio,\nlocal repository, and programmatic examples.\n\n## Minimal Setup\n\nImport the stylesheet once in the app's global CSS, root layout, or app entry.\n\nThis section applies to web targets. Native adapters use their provider or theme APIs instead of a\nstylesheet.\n\n```css\n@import \"@santi020k/lumen-react/styles.css\";\n```\n\nIf Tailwind is present, keep both imports in the same shared CSS entry.\n\n```css\n@import \"@santi020k/lumen-react/layers.css\";\n@import \"tailwindcss\";\n@import \"@santi020k/lumen-react/styles.css\";\n```\n\nKeep the layer prelude first. It places Tailwind base styles below Lumen components and Tailwind\nutilities above them, so consumer spacing, display, radius, width, and responsive visibility\nclasses override Lumen defaults predictably.\n\nAstro apps should mount `UIPrimitives` once in the root layout when using interactive primitives\nsuch as dialogs, menus, popovers, tabs, carousels, command lists, toggles, validated forms, or\ntoasts. Do not place `UIPrimitives` in each component example or next to each primitive instance;\none root include enhances all matching `data-ui-*` markup on the page.\n\n```astro\n---\nimport UIPrimitives from '@santi020k/lumen-astro/runtime'\n---\n\n<UIPrimitives />\n```\n\n## Astro\n\nUse direct named imports from `@santi020k/lumen-astro`.\n\n```astro\n---\nimport { Button, Card, Input, Label } from '@santi020k/lumen-astro'\n---\n\n<Card>\n <Label for=\"email\">Email</Label>\n <Input id=\"email\" type=\"email\" placeholder=\"you@example.com\" />\n <Button type=\"submit\">Subscribe</Button>\n</Card>\n```\n\n`Stat` defaults to a `div`. Choose `as=\"article\"` for a self-contained metric with supporting\ncontent, or `as=\"section\"` for a labeled region. Do not use a semantic root solely for styling.\nIts visual variants are `default`, `accent`, and `glass`; reserve `accent` for featured metrics and\nkeep glass selective.\n\nFor long reading surfaces, use `Anchor` for local section navigation and pass each item’s optional\nheading `depth`, `index`, and `description` when hierarchy matters. Use `activationOffset` for fixed\napplication headers. Use `ScrollProgress` for document position; keep `Progress` for\napplication-controlled task completion. In Astro, dispatch `ui:progress-change` with\n`{ value, max? }` from the `Progress` root when browser state changes after hydration. Non-finite or\nmissing fields keep the current state, and every adapter clamps to finite ARIA values. Use `Spinner`\nfor unknown-duration waits.\n\nSkeleton and Spinner are decorative (`aria-hidden`) unless named. For a group of placeholders,\nannounce loading once: wrap them in one `role=\"status\"` region with `aria-busy=\"true\"` and a\n`VisuallyHidden` label, and keep the Skeletons unnamed. Size placeholders with `shape`\n(`rectangle`, `text`, `circle`), `width`, and `height`, and use deterministic widths rather than\nrandom values so server and client markup match. Data fetching and when to show placeholders stay\nin the application. `Pill`\naccepts `neutral`, `brand`, and `outline`\nvariants, plus native link and count composition.\n\n## React\n\nReact components share the same visual classes and data attributes as Astro components. For\nbehavior-heavy primitives, use the React behavior hooks from `@santi020k/lumen-react` (`useDialog`,\n`usePopover`, `useDropdownMenu`, `useTabs`, `useSelect`, `useToast`, and `useTooltip`), which track\nthe Astro runtime's ARIA, keyboard, Escape, dismissal, and toast controller semantics.\n\nThe React package entry is a client boundary. In Next.js, it can be imported by a Server Component\nwithout evaluating Lumen's contexts and hooks in the React server runtime; the rendered Lumen\nsubtree becomes a Client Component boundary. Vite and other client-rendered React applications use\nthe same entry without additional setup.\nFor stateless Badge, Card composition, layout, form-label, progress, loading, typography, and\nvisually-hidden usage in a React Server Component, import from\n`@santi020k/lumen-react/server`. That entrypoint contains the implementations directly and avoids\ncreating a client boundary. Keep event handlers, hooks, and interactive primitives on the package\nroot inside a Client Component.\n`Button`, `Link`, `ButtonLink`, and `Input` accept React 19 DOM refs directly as props.\n`Button asChild` composes the button contract onto one child element. Across adapters, native\nnumeric `size` remains available on `Input` and `NativeSelect`; use `visualSize` in Astro and React,\nor `visual-size` in Elements, for presentation.\n`Stat as` and `variant` follow the same semantic-root and visual guidance as the Astro component.\n\n```tsx\nimport \"@santi020k/lumen-react/styles.css\";\nimport { Button, Card, Input, Label } from \"@santi020k/lumen-react\";\n\nexport function SubscribeForm() {\n return (\n <Card>\n <Label htmlFor=\"email\">Email</Label>\n <Input id=\"email\" type=\"email\" placeholder=\"you@example.com\" />\n <Button type=\"submit\">Subscribe</Button>\n </Card>\n );\n}\n```\n\nFor React Hook Form, spread `register()` directly onto native-backed controls. Install\n`@santi020k/lumen-react-hook-form` only for controlled composites such as `Select`, `DatePicker`,\n`InputOTP`, and `ListBox`. Do not combine React Hook Form and `useFormValidation` unless the\napplication intentionally coordinates two validation sources. For schema validation, use\n`zodResolver` or `yupResolver` from `@hookform/resolvers`; keep Zod or Yup in the application and\nrender the resulting React Hook Form errors through Lumen's `FieldError` contract.\n\n## Web Components\n\nRegister custom elements once, then use `lumen-*` tags in HTML. The elements adapter includes\ninteractive behavior parity for DataTable, Dialog, Popover, DropdownMenu, ContextMenu, Tabs,\nSelect, ThemeBuilder, Toast, Tooltip, forms, RichTextEditor, Schedule, and VirtualList: selection,\nvalidation, rich text command, context menu, schedule drag/drop, theme, and range events, ARIA\nstate, roving/listbox keyboard paths, Escape/outside dismissal, focus return/trapping, native\nselect form participation, and the same toast controller events as the Astro runtime.\n\n```html\n<script type=\"module\">\n import { defineLumenElements } from \"@santi020k/lumen-elements/define\";\n\n defineLumenElements();\n</script>\n\n<lumen-card>\n <label for=\"email\">Email</label>\n <lumen-input\n id=\"email\"\n type=\"email\"\n placeholder=\"you@example.com\"\n ></lumen-input>\n <lumen-button type=\"submit\">Subscribe</lumen-button>\n</lumen-card>\n```\n\nFor a surface that uses only Badge, Button, Card, and Combobox, import\n`@santi020k/lumen-elements/components/badge`, `/button`, `/card`, and `/combobox` and call their\ncorresponding `defineLumenBadge`, `defineLumenButton`, `defineLumenCard`, and `defineLumenCombobox`\nfunctions. Those implementation-level entrypoints avoid the complete catalog bundle; the Combobox\nentrypoint includes its filtering and keyboard behavior. Continue using `defineLumenElements` for\nany component without a granular entrypoint.\n\nFor a foundation-only custom-element surface, import `defineLumenFoundations` from\n`@santi020k/lumen-elements/components/foundations`. It registers Card compound parts, Container,\nDirection, Grid, Label, Separator, Skeleton, Spinner, Stack, Typography, and VisuallyHidden without\npulling the complete catalog implementation.\n\nPut input elements inside a native `<form data-ui-form>`. Scalar Lumen controls participate in\n`FormData`, validity, disabled state, focus, and reset through form-associated custom-element\nbehavior with a native-control fallback. Do not generate `<lumen-form>` as the submission\ncontainer.\n\n## Component Selection\n\nThe shared catalog includes:\n\n`Accordion`, `Alert`, `AlertDialog`, `Agenda`, `AspectRatio`, `Attachment`, `Autocomplete`,\n`Avatar`, `Badge`, `BarChart`, `Breadcrumb`, `Bubble`, `Button`, `ButtonGroup`, `Calendar`, `Card`,\n`Carousel`, `Chart`, `Checkbox`, `CheckboxGroup`, `Collapsible`, `Code`, `Combobox`, `Command`,\n`Container`, `ColorPicker`,\n`ContextMenu`, `DataTable`, `DatePicker`, `DateRangePicker`, `Dialog`, `Direction`, `Drawer`,\n`DropdownMenu`, `Empty`, `ErrorState`, `ErrorSummary`, `Field`, `FieldError`, `Form`, `Grid`, `HoverCard`, `Icon`,\n`Graphic`, `Backdrop`, `Illustration`, `Input`, `InputGroup`, `InputOTP`, `Item`, `KanbanBoard`, `KanbanColumn`, `Kbd`, `Label`, `LineChart`, `ListBox`, `Marker`, `Menubar`,\n`Message`, `MessageScroller`, `NativeSelect`, `ContextNavigation`, `NavigationMenu`, `NumberField`, `Pagination`,\n`PasswordField`, `PieChart`, `Popover`, `Progress`, `CopyButton`, `RadioGroup`, `Resizable`, `RichTextEditor`,\n`ScrollArea`, `Schedule`, `SearchField`, `Select`, `Separator`, `Sheet`, `Sidebar`, `Skeleton`,\n`Slider`, `Sparkline`, `Spinner`, `Stack`, `Switch`, `Table`, `Tabs`, `TagGroup`, `Textarea`, `ThemeBuilder`,\n`TimeField`, `Toast`, `Toggle`, `ToggleGroup`, `Tooltip`, `Tree`, `TreeGrid`, `Typography`, and\n`VirtualList`, and `VisuallyHidden`.\n\n## Product Recipes\n\n- Follow [the error-handling guide](./error-handling.md) when a generated interface needs failure\n feedback. Use `FieldError` and `ErrorSummary` for validation, `Alert` for persistent inline\n problems, `Toast` for brief non-blocking failures, and `ErrorState` only when a page or region\n cannot show its primary content. Keep exception capture, logging, retry policy, and safe message\n mapping in application code. For React Native, SwiftUI, and Compose, follow the guide's native API\n mapping and announcement rules; do not invent a cross-platform error manager inside the component\n layer.\n\n- Start full product surfaces from the bundled `analytics-dashboard`, `saas-admin`,\n `commerce-dashboard`, `project-workspace`, or `auth-onboarding` recipe. Install with\n `lumen add <recipe>` for Astro, or add `--target react` / `--target elements`. Keep the generated\n shared template stylesheet, then replace sample data and routes while preserving public Lumen\n components, semantic tokens, accessible names, focus paths, and responsive navigation.\n- Use `Sparkline` for a compact trend beside a labelled metric, `BarChart` for categorical\n comparison, `LineChart` for ordered or time-series data, `PieChart` for a small\n part-to-whole breakdown. Pass already-aggregated `LumenChartSeries` data; keep statistics,\n resampling, model evaluation, and domain calculations in application code. Keep `showTable`\n enabled unless the same values are already available in a nearby semantic table. Prefer a bar\n chart when precise slice comparison matters or the pie would exceed roughly seven categories.\n Use `ScatterChart` for numeric relationships, `Heatmap` for a labeled matrix, `RangeChart` for\n low-to-high intervals, and `ComboChart` only when mixed marks share a meaningful domain.\n Use `WaterfallChart` for signed balance changes, `LollipopChart` for ranking, `DumbbellChart`\n for reference/current comparison, and `BulletChart` for actual/target comparison. Use `Histogram`\n for supplied bins, `BoxPlot` for supplied distribution summaries, `CalendarHeatmap` for daily\n activity, and `FunnelChart` for ordered cohort stages. Retrieve each specialized data contract;\n these components do not all accept `LumenChartSeries`. Keep statistical and conversion policy\n in the application, preserve missing days, and use density for unequal histogram bin widths. Use\n `Chart` as the custom SVG/canvas escape hatch. Preserve `null` gaps and the default accessible\n summary and fallback data. See `docs/data-visualization.md` for the full contract.\n Pass `labels` to replace every library-owned visible and assistive phrase. Finite zero is valid\n data, while an empty chart has no finite values; do not silently reinterpret an all-zero series\n as missing. Set `showTable={false}` (`showData={false}` on native adapters) only when an\n equivalent accessible table or scoreboard is adjacent.\n Keep stable identity in `datum.x` and put display text in `datum.xLabel`. Use `formatCategory`\n and `formatValue` for localized axes, SVG titles, and data tables. Choose\n `presentation=\"bare\"` inside an existing Card, `categoryWidth` for long horizontal labels,\n `markers=\"auto\"` or a numeric marker step for dense lines, and `emptyLabel` for actionable\n source-specific guidance.\n- Use `Icon name=\"search\"` or any Lucide icon name for standard interface pictograms. Kebab-case\n names and Lucide aliases work. Pair decorative icons with `decorative`, and pass `label` only\n when the icon carries meaning on its own. On the web, install and register\n `@santi020k/lumen-icons-brand` for supported brand marks; native adapters include those names.\n Then use `brand:github` or the adapter's generated equivalent. Render project-specific or\n unsupported brand SVGs through the default `Icon` slot. Astro reports unknown named icons during\n development instead of failing silently.\n- Use `Schedule` with `Agenda`, `Calendar`, and `DatePicker` for booking, CRM, content calendar, and\n event planning screens. Mark slots with `data-ui-schedule-slot` and events with\n `data-ui-schedule-event`; the runtime emits `ui:schedule-change` when a draggable event is moved.\n `Calendar` renders a hidden input plus selectable date grid; Astro `UIPrimitives` and registered\n Web Components wire month navigation and roving keyboard focus directly, while React apps can use\n the `Calendar` component or `useCalendar` for the same date selection contract.\n Astro `UIPrimitives` and registered Web Components bind schedule drag/drop directly; React apps\n can use `useSchedule` for `rootProps`, `getEventProps`, `getSlotProps`, and `ui:schedule-change`\n parity. Use `createScheduleSlots`, `moveScheduleEvent`, and `expandRecurringScheduleEvent` from\n `@santi020k/lumen-core` for reusable schedule state; use `getScheduleConflicts` and\n `canPlaceScheduleEvent` before committing drag/drop changes. Persist local schedule edits with\n `createScheduleStorageKey`, `saveScheduleEvents`, and `loadScheduleEvents` against any\n `localStorage`-compatible adapter. Use `resizeScheduleEvent` and `resizeScheduleEvents` for\n snap-to-grid resize handles.\n- Use `KanbanBoard` and `KanbanColumn` for status-based workspaces while continuing to use `Card` for\n items. Mark items with `data-ui-kanban-item` and put `data-ui-kanban-handle` on a dedicated button;\n do not make the whole card draggable. Astro and Elements emit the cancellable, controlled\n `ui:kanban-move-request` event, and React provides `useKanban`. The event reports `itemId`,\n `fromColumn`, `toColumn`, optional `beforeId`, and keyboard or pointer input, but never moves DOM or\n persists application data. The host owns validation, `aria-busy`, optimistic state, rollback,\n status names, and APIs. Use compact `Empty` states inside columns, preserve every column with\n `Skeleton` blocks while loading, and paginate large columns. See [Kanban composition and movement](kanban.md).\n- React applications can compose [TanStack-powered table views](data-table-views.md) through\n `DataTableView`, preserving application-owned cells and server queries. Numeric/date ranges and\n page-scoped selection are opt-in. Use `DataTableSavedViews` for explicit host-owned preference\n persistence, and `useLumenReviewWorkflow` for reviewed commands with stale-revision and unknown-outcome\n guards. Install `review-workflow`, `import-review` or `record-workspace` for executable React compositions.\n- Use `DataTable`, `Tree`, `TreeGrid`, `VirtualList`, `Pagination`, and `Command` for dense data\n collection workflows. Astro and Elements wire selectable/sortable `DataTable` behavior and\n `VirtualList` range events; React also provides built-in fixed-height list windowing. `Tree`\n and `TreeGrid` use roving keyboard focus, and `VirtualList` emits `ui:virtual-list-range` as the\n rendered range changes. Use `createDataViewState`,\n `serializeDataViewState`, `parseDataViewState`, `applyDataViewState`, and `getVirtualRange` for\n saved views, filtering, sorting, pagination, and virtualization math. Use\n `createDataViewStorageKey`, `saveDataViewState`, and `loadDataViewState` when saved views should\n survive navigation or reloads. Use `pinDataViewColumn`, `unpinDataViewColumn`,\n `toggleDataViewSelection`, `createDataViewSearchParams`, and `createDataViewServerRequest` when\n connecting dense views to router state or server-backed data.\n- Use `Resizable` for split-pane editors, dashboards, inspectors, and side-by-side review layouts.\n Astro `UIPrimitives` and registered Web Components generate separator handles and pane sizing\n directly; React apps can use the `Resizable` component or `useResizable` for pointer, keyboard,\n min/max, and double-click reset parity.\n- Use `ThemeBuilder`, `ColorPicker`, `Card`, `Button`, and `Input` to build token preview and export\n tools. Add `data-ui-theme-brand-hue`, `data-ui-theme-accent-hue`, `data-ui-theme-scheme`,\n `data-ui-theme-mode`, manual color inputs such as `data-ui-theme-primary-hex`, and\n `data-ui-theme-target` for scoped live previews. Add `data-ui-theme-export-format`,\n `data-ui-theme-export`, and `data-ui-theme-output` to copy CSS, Figma variable JSON, or design\n token JSON. Astro `UIPrimitives` and registered Web Components wire those controls directly;\n React apps can use `useThemeBuilder` for the same tokens, exports, and control props. Use\n `createThemePalette`, `createThemeBuilderTokens`, `exportThemeBuilderValue`, `exportThemeCss`,\n `parseThemeCss`, and `scoreThemeContrast`\n for theme import/export and accessibility checks. Use `suggestReadableInk`, `mergeThemeTokens`,\n and `tuneThemeContrast` when generated palettes need readable foreground repair.\n- Use `RichTextEditor` with `ButtonGroup`, `ToggleGroup`, and `Textarea` for editor compositions.\n Controls with `data-ui-editor-command` request formatting, block, history, link or list commands;\n use `data-ui-editor-value` for value-bearing commands. Before the browser executes a command,\n Lumen emits the bubbling, cancelable `ui:editor-command-request` event. External engines must\n call `preventDefault()` and set `event.detail.executed` to their synchronous result. A failed\n external command never falls back to the browser. The existing `ui:editor-command` event reports\n completion; use it for notifications, not executing the command again.\n Editable surfaces support formatting shortcuts and emit `ui:editor-change` with HTML and text.\n Set `data-ui-editor-native-state=\"false\"` when an external engine owns toolbar state.\n React apps can use `useRichTextEditor({ commandHandler: ({ command, value }) => boolean })`;\n providing a handler disables native toolbar state syncing by default. Use `nativeState` to\n explicitly control that policy. Engine commands are synchronous; engines with asynchronous\n work should manage completion themselves. Keep Tiptap, ProseMirror, Lexical and other engines\n in the application.\n- Use `Autocomplete`, `SearchField`, `NumberField`, `TimeField`, `DateRangePicker`, `ColorPicker`,\n and `TagGroup` when forms need more than plain text inputs. Add `data-ui-form` to forms that\n should reflect native Constraint Validation API state into `Field` error slots on blur and submit.\n For `InputOTP`, Astro `UIPrimitives` and registered Web Components render/sync a real native input\n with visual OTP segments; React apps can use the `InputOTP` component or `useInputOTP` for the\n same sanitization, paste handling, and caret navigation.\n For `DateRangePicker`, Astro `UIPrimitives` and registered Web Components sync the paired native\n date input `min`/`max` constraints directly; React apps can use `useDateRangePicker` for the same\n start/end syncing props.\n Use native attributes such as `required`, `pattern`, `min`, `max`, and `type`; add\n `data-error-required`, `data-error-pattern`, `data-error-min`, `data-error-max`, or\n `data-error-custom` when a control needs custom copy. Listen for `ui:validate` to call\n `control.setCustomValidity(message)` before the runtime reads validity, and listen for\n `ui:invalid` or `ui:valid` on the form for submission state. Astro `UIPrimitives` and registered\n Web Components bind `data-ui-form` directly; React apps can use `useFormValidation` with `Field`\n to get the same control props, CustomEvents, and error slot updates.\n- Use `Toast` for transient feedback. Static `<Toast>` markup works without\n JavaScript. With Astro `UIPrimitives` or registered Web Components, create toasts with\n `window.LumenToast.create(detail)`, `LumenToast.create(detail)` from `@santi020k/lumen-elements`,\n or `document.dispatchEvent(new CustomEvent('ui:toast', { detail }))`; update them with\n `.update(id, detail)` or `ui:toast-update`; and dismiss them with `.dismiss(id?)` or\n `ui:toast-dismiss`. `detail` supports `title`, `description`, `variant`, `duration`, `placement`,\n `max`, and `action: { label, value }`. Destructive toasts use `role=\"alert\"`; other variants use\n `role=\"status\"`. Runtime toasts pause their timeout on hover or focus, and Escape dismisses the\n focused toast.\n\n## Runtime CustomEvents\n\nWhen `UIPrimitives` is mounted, or when Web Components are registered for matching behavior, Lumen\nuses these CustomEvents:\n\n| Event | Target | Detail | Fires When |\n| -------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |\n| `ui:data-table-selection-change` | `DataTable` root `[data-ui-datatable]` | `{ values: string[] }` | Selectable row checkboxes, the select-all checkbox, or form reset changes selected row values. |\n| `ui:schedule-change` | `Schedule` root `[data-ui-schedule]` | `{ eventId?: string, slot?: string }` | A draggable schedule event is dropped on a `[data-ui-schedule-slot]`. |\n| `ui:virtual-list-range` | `VirtualList` root `[data-ui-virtual-list]` | `{ startIndex: number, endIndex: number }` | The virtual list calculates its visible range on init or scroll. |\n| `ui:kanban-move-request` | `KanbanBoard` root `[data-ui-kanban]` | `{ itemId: string, fromColumn: string, toColumn: string, beforeId?: string, input: 'keyboard' \\| 'pointer' }` | A handle requests a controlled move; the event is cancellable and never moves data or DOM. |\n| `ui:tag-remove` | `TagGroup` root `.ui-tag-group` or `[data-ui-tag-group]` | `{ value?: string }` | A `[data-ui-tag-remove]` control removes its closest tag or list item. |\n| `ui:editor-command-request` | `RichTextEditor` root `[data-ui-rich-text-editor]` | `{ command: string, executed: boolean, value?: string }` | Cancel before execution and set executed for external engine ownership. |\n| `ui:editor-command` | `RichTextEditor` root `[data-ui-rich-text-editor]` | `{ command: string, executed: boolean, value?: string }` | A toolbar control or keyboard shortcut runs an editor command. |\n| `ui:editor-change` | `RichTextEditor` root `[data-ui-rich-text-editor]` | `{ html: string, text: string }` | Editable content changes or an editor command runs. |\n| `ui:theme-change` | `ThemeBuilder` root `[data-ui-theme-builder]` | `{ hue: number, accentHue: number, mode: 'generated' \\| 'manual', scheme: 'dark' \\| 'light', tokens: Record<string, string> }` | Hue, manual color, mode, or scheme controls update generated tokens. |\n| `ui:theme-export` | `ThemeBuilder` root `[data-ui-theme-builder]` | `{ css?: string, format: 'css' \\| 'figma' \\| 'tokens', tokens: Record<string, string> \\| null, value: string }` | A `[data-ui-theme-export]` control writes the selected export to the output and clipboard when available. |\n| `ui:language-change` | `LanguageToggle` control | `{ previousValue: string, value: string }` | Requests or applies the next configured locale. |\n| `ui:validate` | Form `[data-ui-form]` | `{ control, form, value }` | Before validation state is read, so custom code can call `control.setCustomValidity(message)`. |\n| `ui:invalid` | Form `[data-ui-form]` | `{ control?, controls?, form }` | Blur or submit validation finds invalid controls. Failed submits focus the first invalid control. |\n| `ui:valid` | Form `[data-ui-form]` | `{ control?, controls?, form }` | Blur or submit validation finds the checked control or form valid. |\n| `ui:toast` | `document` | `{ id?, title?, description?, variant?, duration?, placement?, action? }` | Creates a runtime toast in the matching notification viewport. |\n| `ui:toast-update` | `document` | `{ id, title?, description?, variant?, duration? }` | Updates a runtime toast. |\n| `ui:toast-dismiss` | `document` | `{ id? }` | Dismisses one runtime toast, or all runtime toasts when `id` is omitted. |\n| `ui:toast-action` | Toast `[data-ui-toast]` | `{ id, value? }` | A runtime toast action button is pressed, unless the action specifies a custom event name. |\n\n## Registry Helper\n\n`@santi020k/lumen` exports typed component, recipe, and documentation metadata from\n`@santi020k/lumen/registry`. The umbrella package and every web adapter expose the same small\n`lumen` binary, so projects that install Astro, React, or Elements can use discovery and diagnostics\nwithout another direct dependency. Individual components are addressable by name, including\nkebab-case aliases such as `data-table`:\n\n```bash\nlumen list\nlumen show Button\nlumen show data-table\nlumen add Button\nlumen add Button --target react\nlumen add Button --target elements\nlumen add data-table\nlumen show scheduler\nlumen add scheduler\nlumen add scheduler --target react\nlumen add scheduler --target elements\nlumen add scheduler --dry-run\nlumen add scheduler --fail-on-conflict\nlumen add scheduler --force\nlumen add scheduler --merge\nlumen list --registry ./registry/lumen.registry.json\nlumen add custom-recipe --registry ./registry/custom.registry.json\nlumen add private-recipe --registry https://example.com/lumen.registry.json --registry-token \"$TOKEN\"\nlumen install\nlumen doctor --json\nlumen doctor-native --json\nlumen init --framework astro --tailwind\n```\n\n`lumen doctor-native` emits the same adoption inventory and Android toolchain preflight in readable\ntext or JSON. Treat direct-primitive matches as review suggestions, not rewrite instructions: first\nconfirm whether a surface is product-owned and whether the public Lumen contract preserves its\nbehavior. A failed JDK, SDK, platform-package, or required-NDK preflight should be fixed before\nstarting Gradle so native compilation does not obscure the setup problem.\n\n`lumen add <component>` defaults to an Astro wrapper. Use `--target react` for a local React wrapper\nor `--target elements` for a custom-elements starter. Bundled recipes also accept `--target astro`,\n`--target react`, and `--target elements`; external registry inline files keep their authored paths\nand sources. Those paths must be unique, relative forward-slash paths: the installer rejects\nabsolute paths, parent traversal, and symbolic-link path segments before writing any file. Registry\nmanifests are limited to 5 MiB by default; programmatic `loadLumenRegistry` callers can lower the\nlimit with `maxBytes` and control remote request time with `timeoutMs`.\n\nBefore inventing a wrapper or custom class pattern, check the component source in\n`packages/astro/components`. Astro is the reference implementation for props, class names, data\nattributes, and accessible markup.\n\n## Styling Rules for Generated Code\n\n- Read the [content flow contract](content-flow.md). Stack and Grid own sibling gaps; surfaces own\n padding; Field owns label/control/feedback spacing. Do not add another child margin for the same\n relationship. Use `gap=\"related\"` for closely related controls, `gap=\"group\"` for separate groups\n and `gap=\"section\"` for major sections. Retrieve spacing tokens through MCP.\n- Use Card parts for header/body/actions and a nested Stack inside complex CardContent. Card\n handles missing or hidden parts and wraps footer actions. Choose `density=\"compact\"`,\n `\"comfortable\"` (default) or `\"spacious\"` instead of scattering padding overrides.\n- Keep text rhythm inside Prose or Typography. Verify long and translated text, enlarged text,\n validation states, optional sections and mobile wrapping before calling a composition finished.\n- Use the `page-header` and `section-header` recipes for dashboard identity, optional breadcrumbs,\n status/count badges and independent actions. Keep headings in document order, pass translated\n navigation/action labels and unique heading ids, and connect buttons to application-owned handlers.\n- Retrieve complete `content-flow-header`, `content-flow-settings`, `content-flow-list` and\n `content-flow-actions` examples with `lumen_get_recipe`; the CLI installs the same compositions.\n Keep one outer Container for fluid gutters. Card permits interactive overflow; use AspectRatio\n to clip media. Reading blocks trim their outer margins, so avoid compensating offsets.\n\n- Do not require consumers to configure Tailwind for Lumen components.\n- Prefer component props and composition before adding custom CSS.\n- Assign each visible outer edge to one container. For flush content inside a rounded frame,\n let the frame own the border and corner clipping; remove child rounding with supported props.\n For example, use `<Image radius=\"none\" ... />` below a framed header: `Image` defaults to\n `radius=\"lg\"`, which otherwise leaves rounded gaps at the shared edge. Keep standalone image\n rounding when appropriate.\n- Use a single divider between header and body rather than overlapping full borders. Inset\n surfaces with padding may have their own radius; account for the inset so their curves align.\n Clip artwork at its frame, not an entire interactive surface where focus rings or overlays\n could be cut off. Verify shared corners, borders, and keyboard focus at phone and desktop\n widths in both light and dark themes.\n- Use the shared token names for custom surfaces: `canvas`, `surface`, `surface-muted`,\n `surface-strong`, `line`, `ink`, `ink-soft`, `ink-muted`, `brand`, `brand-solid`, `brand-soft`,\n `accent`, `success`, `warning`, and `danger`.\n- Use `glass` as a boolean prop or attribute for glass surfaces when supported.\n- Preserve semantic labels, keyboard paths, focus states, and native form attributes.\n\n## Figma Handoff\n\nUse `exportThemeFigmaVariables` from `@santi020k/lumen-core/figma` when a design workflow needs\nFigma variable-friendly color values, and `exportThemeDesignTokens` when exporting standard design\ntoken JSON for token importers. See [docs/figma.md](figma.md) for the recommended Figma workflow.\n\n## When Editing This Repo\n\n- Shared contracts belong in `packages/core`.\n- Astro implementation and standalone CSS belong in `packages/astro`.\n- React-specific primitives belong in `packages/react`.\n- Web Component-specific primitives belong in `packages/elements`.\n- Update the relevant package README when public usage changes.\n- Add tests beside package code as `*.test.ts` when behavior, exported metadata, or component\n contracts change.\n\n### React inline range calendar\n\nFor a range editor with both endpoint calendars visible, use the React-only\n`DateRangeCalendar` export. Pass `value={{ start, end }}`, `onValueChange`, required\n`labels={{ start, end, presets }}`, `locale`, optional `min`/`max`, and optional\n`presets={[{ label, value: { start, end } }]}`. `formatDate` customizes the displayed\nendpoint summaries. Keep a draft in the consuming application and apply it only\nwhen confirmed. ISO endpoints must be valid and ascending. Crossing a boundary\nmoves the other endpoint; presets outside the bounds are disabled. The component\nshares `useCalendar` keyboard behavior and stacks its calendars on narrow screens.\n\nFor React input-attached range selection, import `DateRangeInput` from\n`@santi020k/lumen-react`. Pass controlled ISO `value`/`onValueChange`, an accessible\n`label`, and localized `labels` containing `start`, `end`, `presets`, `apply`, and\n`cancel`. It owns draft state and confirmation, anchors a non-modal popover to its\ntrigger, and supports keyboard dismissal and responsive scrolling. Use `validate`\nfor domain restrictions and `renderSummary` for draft details. Optional\n`name={{ start: 'from', end: 'to' }}` submits applied ISO values through hidden inputs.\nSee the React README for the complete usage and browser fallback contract.\n\n## Fixed-height virtual lists\n\n`VirtualList` uses the same DOM windowing controller in Astro, React and Elements. All rows remain\nmounted; offscreen rows are hidden, and inert spacers preserve the complete scroll height. This\nreduces displayed rows, not the cost of creating the initial DOM. Use pagination or an application\nrenderer when retaining the entire dataset in the DOM is too expensive.\n\nUse `itemSize` (Astro/React) or `item-size` (Elements) for a fixed row height in pixels, defaulting\nto 44. `overscan` defaults to four rows on each side. Every direct element child is a row; put\ncontrols inside it, and keep headers or empty-state actions outside the list. Rows must fit the\nconfigured height; variable-height measurement is not supported. Do not add external row margins\nor gaps. Initially hidden rows remain hidden and are excluded from the window.\n\nThe controller refreshes on scrolling, resizing, direct child changes and sizing attribute changes.\nA focused row and its neighboring rows remain available for native Tab navigation, which can extend\nthe displayed window until focus moves. Name the scroll container using `aria-label` or\n`aria-labelledby`; it is keyboard-focusable by default. `ui:virtual-list-range` reports inclusive\n`startIndex` and `endIndex` when the displayed range or item count changes. Empty lists use\n`{ startIndex: 0, endIndex: -1 }`. `data-ui-range-start` and `data-ui-range-end` mirror the current\nwindow. Elements disconnect and React unmount restore original row styles and hidden state.\n\n## NumberField boundary\n\n`NumberField` remains a native number-input wrapper. The v4 consumer audits do not establish a\nshared requirement for a locale-aware draft parser. Applications needing decimal-comma entry,\nlossless monetary drafts or currency rules should retain their explicit parsing and validation\npolicy; Lumen must not infer currency units or precision from locale.\n\n\n## Combobox keyboard behavior in v4\n\nCombobox keeps DOM focus in its input while Arrow Up/Down changes the active option through\n`aria-activedescendant`. Enter commits the active option only while the list is open; without an\nactive option, normal form submission remains available. Home, End, Left and Right retain native\ntext editing. Composition input and canceled keyboard events do not trigger selection or dismissal.\nEscape closes the innermost open control first, preserving an enclosing popup or dialog until the\nnext Escape. Tab dismisses the list when focus leaves the component.\n\nAstro and Elements observe added, removed, relabeled and disabled options, and delegate pointer\nselection to the current list. React derives options from its current props and reports committed\nselections through `onChange`, including controlled inputs. Disabled and read-only inputs stay closed.\nApplications should keep focus on the input and observe `aria-activedescendant` instead of calling\nfocus on option buttons. The DOM adapters match both option labels and values.\n\n## Appearance presets\n\nChoose `default`, `studio` or `glass` at the theme boundary. See [appearance presets](appearance-presets.md) for exact framework APIs. Studio follows the neutral PostLens look. Glass remains explicit per supporting surface; React Native and Compose retain solid fallbacks without adding blur dependencies. Customize semantic colors, typography and surface dimensions through public theme APIs.\n\n## Version-aware build, review, and migration\n\nThe portable skills are `lumen-ui` for implementation, `lumen-review` for a read-only audit, and\n`lumen-migrate` for an explicitly requested upgrade. Their canonical sources live under `skills/`;\n`pnpm run generate:plugin-package` produces the client snapshots. Both client manifests use plugin\n1.1.0 and pin the v4 catalog. This candidate is not proof of publication or directory approval.\n\nBefore applying MCP contracts, call `lumen_check_compatibility` with resolved versions from installed\nmetadata or native lock files. A manifest range is not an installed version. When versions differ,\ninspect installed public types and README or use a matching MCP package. Do not silently upgrade.\nSearch supports English and Spanish queries with framework and platform filters.\n\nFor a v4 upgrade, read `lumen_get_migration` and preview `lumen migrate v4 --dry-run --json` locally.\n`--apply` rewrites only known static SDK import paths in JavaScript and TypeScript. It preserves\ncomments and examples and reports UI/native review triggers. Dependency updates and application-owned\nworkarounds require review; the CLI does not infer that a workaround can be removed. JSX, Astro,\nnative source, dynamic imports, and dependency manifests are not automatically rewritten by the\nSDK import transform.\n\nSee [v4 AI verification](lumen-4-ai.md) for actual agent benchmark coverage and release evidence.\n\nThe integrated `lumen migrate v4` command also retains the release's web spacing migrations and\noptional coordinated dependency workflow. SDK edits compose into that command's source transform,\nso its apply ledger fingerprints the final source and repeat runs do not rewrite spacing twice.\nSDK import edits remain limited to `.ts`, `.js`, and `.mjs`; this restriction does not disable the\nseparate documented JSX/Astro spacing migration. The JSON report includes installed package\nversions and explicit SDK dependency-review findings.\n\n\n## Login examples and Auth\n\nUse the `auth-onboarding` recipe for visual email-code sign-in, code verification, and passkey\nrecovery states in Astro, React, or Elements. The preview controls are disabled and do not authenticate.\nLumen supplies the UI; the optional `@santi020k/auth-client` supplies protocol operations.\nSee the [login integration reference](../packages/templates/README.md#login-examples-and-auth)\nfor helper calls, safe result handling, and the application-owned server and session requirements.\n## Complex React forms\n\nUse the optional `@santi020k/lumen-react/forms` hooks with public form primitives for dependent\nvalidation, unsaved edits, stable repeatable rows, step navigation, and cancellation-safe checks.\nRead [composable form workflows](powerful-forms.md) before implementation. Choose one validation\nowner; keep schemas, financial policy, requests, authorization, and draft storage in the consumer.\n\nFor photo and media editors, install the `media-workspace` recipe and follow\n[Studio media workspaces](studio-media-workspaces.md). Reuse public media controls; keep\nimage processing, storage and export in the consumer application.\n",
|
|
45877
45963
|
"readme": "<p align=\"center\">\n <a href=\"https://lumen.santi020k.com\">\n <picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"./docs/assets/readme/hero-dark.svg\">\n <img src=\"./docs/assets/readme/hero-light.svg\" alt=\"Lumen UI — A shared language. Every interface.\" width=\"1200\" height=\"340\">\n </picture>\n </a>\n</p>\n\n<h1 align=\"center\">Lumen UI</h1>\n\n<p align=\"center\">\n <strong>Accessible primitives. Native authoring. Shared foundations.</strong><br>\n An open-source UI system for Astro, React, Web Components, React Native, SwiftUI, and Jetpack Compose.\n</p>\n\n<p align=\"center\">\n <a href=\"https://lumen.santi020k.com\">Documentation</a> ·\n <a href=\"https://lumen.santi020k.com/docs/components\">Components</a> ·\n <a href=\"https://lumen.santi020k.com/#playgrounds\">Playgrounds</a> ·\n <a href=\"https://www.figma.com/community/file/1662337342676541513\">Figma library</a> ·\n <a href=\"https://lumen.santi020k.com/support\">Support</a>\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/santi020k/lumen/actions/workflows/ci.yml\"><img alt=\"CI status on main\" src=\"https://img.shields.io/github/actions/workflow/status/santi020k/lumen/ci.yml?branch=main&style=flat-square&label=CI\"></a>\n <a href=\"https://github.com/santi020k/lumen/actions/workflows/codeql.yml\"><img alt=\"CodeQL status\" src=\"https://github.com/santi020k/lumen/actions/workflows/codeql.yml/badge.svg\"></a>\n <a href=\"https://www.npmjs.com/package/@santi020k/lumen-astro\"><img alt=\"Published Astro package version\" src=\"https://img.shields.io/npm/v/@santi020k/lumen-astro?style=flat-square&label=npm\"></a>\n <a href=\"./LICENSE\"><img alt=\"MIT license\" src=\"https://img.shields.io/badge/license-MIT-036b8c?style=flat-square\"></a>\n</p>\n\nLumen gives your product a consistent visual language while preserving each framework's native\nway of building. Compose accessible controls, forms, charts, and complete product layouts from\nshared tokens and explicit component contracts.\n\n**Explore:** [Quick start](#web-quick-start) · [Native apps](#native-playgrounds) · [Templates](#dashboards-and-templates) · [AI and Figma](#ai-and-design-workflows) · [Packages](#packages) · [Repository apps](#repository-apps) · [Contributing](#contributing)\n\n> **Lumen 4 candidate:** This branch documents the upcoming major version. The npm badge reports\n> the published version; local examples and store builds may differ. See the\n> [migration and readiness notes](#lumen-4-candidate) before upgrading.\n\n## See Lumen in action\n\n<p>\n <a href=\"https://lumen.santi020k.com/docs/web/playground\">\n <img src=\"./apps/docs/src/assets/platforms/web.webp\" alt=\"A Lumen web workspace with navigation, metric cards, an interactive chart, and a task form\" width=\"1184\" height=\"802\">\n </a>\n</p>\n\n*Real Lumen components with illustrative data. [Open the web playground →](https://lumen.santi020k.com/docs/web/playground)*\n\n| Built for | What you get |\n| --- | --- |\n| **Web products** | Astro, React, and custom elements with standalone CSS and optional Tailwind integration. |\n| **Native interfaces** | React Native, SwiftUI, Compose, WidgetKit, and Wear OS foundations with platform conventions. |\n| **Accessible interaction** | Semantic structure, keyboard paths, focus management, and reduced-motion support. |\n| **Design and AI workflows** | Figma resources, a portable agent skill, MCP discovery, and machine-readable contracts. |\n\n## Web quick start\n\nChoose one adapter. Astro requires Astro 5+, React requires React 19+, and the web packages\nrequire Node.js 22.12+ for their tooling. Install in your existing application:\n\n```bash\n# Astro\npnpm add @santi020k/lumen-astro\n\n# React\npnpm add @santi020k/lumen-react\n\n# Web Components\npnpm add @santi020k/lumen-elements\n```\n\n### Astro\n\nImport the stylesheet and mount `UIPrimitives` once in your root layout. The runtime enhances all\ninteractive Lumen markup on the page.\n\n```astro\n---\nimport '@santi020k/lumen-astro/styles.css'\nimport UIPrimitives from '@santi020k/lumen-astro/runtime'\n---\n\n<html lang=\"en\">\n <body>\n <slot />\n <UIPrimitives />\n </body>\n</html>\n```\n\n```astro\n---\nimport { Button, Card, Input, Label } from '@santi020k/lumen-astro'\n---\n\n<Card>\n <Label for=\"email\">Email</Label>\n <Input id=\"email\" name=\"email\" type=\"email\" placeholder=\"you@example.com\" />\n <Button>Subscribe</Button>\n</Card>\n```\n\n### React\n\nLoad the stylesheet once from your app entry or global CSS:\n\n```tsx\nimport '@santi020k/lumen-react/styles.css'\nimport { Button, Card, Input, Label } from '@santi020k/lumen-react'\n\nexport function SubscribeForm() {\n return (\n <Card>\n <Label htmlFor=\"email\">Email</Label>\n <Input id=\"email\" name=\"email\" type=\"email\" placeholder=\"you@example.com\" />\n <Button>Subscribe</Button>\n </Card>\n )\n}\n```\n\n### Web Components\n\nImport the styles and register the elements once:\n\n```html\n<style>\n @import \"@santi020k/lumen-elements/styles.css\";\n</style>\n\n<script type=\"module\">\n import { defineLumenElements } from '@santi020k/lumen-elements/define'\n\n defineLumenElements()\n</script>\n\n<lumen-card>\n <lumen-label id=\"email-label\">Email</lumen-label>\n <lumen-input aria-labelledby=\"email-label\" id=\"email\" name=\"email\" type=\"email\" placeholder=\"you@example.com\"></lumen-input>\n <lumen-button>Subscribe</lumen-button>\n</lumen-card>\n```\n\nSee the [Web documentation](https://lumen.santi020k.com/docs/web) for installation, theming,\ncomponent examples, and API details.\n\nBrowse the [package directory](https://lumen.santi020k.com/docs/packages) for framework adapters,\nshared foundations, and optional integrations, with links to focused examples and usage guides.\n\nFor failure states, use the [error-handling guide](./docs/error-handling.md) to choose between field\nfeedback, summaries, persistent alerts, transient toasts, and the `ErrorState` recovery surface.\nLumen owns presentation and accessibility; applications retain logging, retry, and exception policy.\n\nFor native applications, choose the [React Native](https://lumen.santi020k.com/docs/react-native),\n[Apple / SwiftUI](https://lumen.santi020k.com/docs/apple), or\n[Android / Compose](https://lumen.santi020k.com/docs/android) guide. The\n[shared foundations](https://lumen.santi020k.com/docs/foundations) explain the cross-platform token\nand component contract; repository contributors can also use the\n[cross-platform architecture](./docs/cross-platform.md) and\n[native component reference](./docs/native-components.md).\n\n> **Native qualification:** Check the [compatibility matrix](docs/native-compatibility.md) and\n> [device evidence](docs/native-device-validation.md) for your target. Local builds, published\n> artifacts, and physical-device accessibility are distinct verification steps.\n\n## Tailwind CSS\n\nKeep the layer prelude, Tailwind import, and Lumen stylesheet in the same shared CSS entry. Replace\n`lumen-astro` with the package for your framework.\n\n```css\n@import \"@santi020k/lumen-astro/layers.css\";\n@import \"tailwindcss\";\n@import \"@santi020k/lumen-astro/styles.css\";\n```\n\nThis order places Tailwind base styles before Lumen components and Tailwind utilities above Lumen\ncomponent defaults.\n\n## Native playgrounds\n\n<table>\n <tr>\n <th>SwiftUI</th>\n <th>Jetpack Compose</th>\n <th>React Native</th>\n </tr>\n <tr>\n <td><a href=\"https://lumen.santi020k.com/docs/apple/playground\"><img src=\"./apps/docs/src/assets/platforms/apple.webp\" alt=\"Native SwiftUI release checklist example\" width=\"220\"></a></td>\n <td><a href=\"https://lumen.santi020k.com/docs/android/playground\"><img src=\"./apps/docs/src/assets/platforms/android.webp\" alt=\"Native Compose release checklist example\" width=\"220\"></a></td>\n <td><a href=\"https://lumen.santi020k.com/docs/react-native/playground\"><img src=\"./apps/docs/src/assets/platforms/react-native.webp\" alt=\"Expo browser preview of the contributor profile example\" width=\"220\"></a></td>\n </tr>\n <tr>\n <td>Native app capture</td>\n <td>Native app capture</td>\n <td>Expo browser preview</td>\n </tr>\n</table>\n\nSee [capture provenance](apps/docs/src/assets/platforms/README.md) for sources and verification scope.\n\nTry Lumen before adding it to your project. The [playground section](https://lumen.santi020k.com/#playgrounds)\nbrings together the browser demos and native apps. Explore component states and themes, then use\nthe open-source apps as implementation references. Start with the\n[web playground](https://lumen.santi020k.com/docs/web/playground) for Astro, React, and Web Components,\nor choose a native gallery:\n\n- [`apps/playground-react-native`](./apps/playground-react-native) runs through Expo on the web,\n iOS, and Android. [Try the browser preview](https://lumen.santi020k.com/docs/react-native/playground#preview)\n or use the local setup guide for native evaluation. EAS profiles cover TestFlight, Android App\n Bundles, and APKs; this gallery has no separate public store listing.\n- [`apps/playground-apple`](./apps/playground-apple) is available on the\n [App Store](https://apps.apple.com/app/id6805250815) for iPhone, iPad, and Mac. It also builds as\n an iOS Xcode app or macOS Swift Package executable for local exploration.\n- [`apps/playground-android`](./apps/playground-android) builds a native Compose application and a\n directly installable debug APK. It is also available on\n [Google Play](https://play.google.com/store/apps/details?id=com.santi020k.lumen.playground.compose)\n for Android phones and tablets.\n\nSee the [playground workflow](./docs/playgrounds.md) for a platform chooser, reference-app source,\nand run, capture, and distribution commands. Store builds follow their own release schedule and\nmay differ from the current repository candidate.\n\n## Appearance presets\n\nChoose an appearance preset or customize the existing themes with the [appearance guide](docs/appearance-presets.md). The Studio preset uses PostLens as its visual reference.\n\n## Dashboards and templates\n\nExplore the [template gallery](https://lumen.santi020k.com/templates) for complete analytics,\nSaaS admin, commerce, project workspace, and authentication/onboarding experiences. Each family\nuses public Lumen primitives and semantic tokens, includes responsive and accessibility coverage,\nand ships through the CLI for all three framework targets:\n\n```bash\nlumen add analytics-dashboard\nlumen add commerce-dashboard --target react\nlumen add auth-onboarding --target elements\n```\n\n## AI and design workflows\n\nInstall the portable Lumen skill in Codex, Claude Code, Cursor, Windsurf, and other compatible\ncoding agents:\n\n```bash\nnpx skills add santi020k/lumen --skill lumen-ui\n```\n\nThe skill teaches agents how to select, compose, theme, and verify Lumen primitives. Pair it with\n[`@santi020k/lumen-mcp`](./packages/mcp) when an agent needs to search the live catalog or retrieve\ncurrent source, props, tokens, and usage rules.\n\nFor ChatGPT and Codex, install the published\n[Lumen UI plugin](https://chatgpt.com/plugins/plugin_asdk_app_6a8f6c526c5481918eb8a48806fa112b)\nfrom the Plugins Directory. Select **Install plugin**, then mention **@Lumen UI** in a request such\nas “Find the right Lumen components for an accessible React settings screen.” The plugin bundles\nthe skill with the hosted, read-only catalog; no separate Lumen account or local MCP setup is needed.\n\nLumen is designed to reduce repetitive UI code and unnecessary agent context. Actual token usage\ndepends on the model, task, and corrections; see the [measurement method](docs/ai-efficiency.md).\n\n<details>\n<summary>Guides, registries, and integration references</summary>\n\nAdditional machine-readable surfaces include:\n\n- [`llms.txt`](./llms.txt) for a concise project map.\n- [`docs/ai-usage.md`](./docs/ai-usage.md) for downstream generation examples.\n- [`docs/styling-contract.md`](./docs/styling-contract.md) for stable parts and component variables.\n- [`docs/import-and-icon-performance.md`](./docs/import-and-icon-performance.md) for repeatable import and icon evidence.\n- [`docs/graphics.md`](./docs/graphics.md) for portable decorative artwork across web and native platforms.\n- [`docs/consumer-regression-fixtures.md`](./docs/consumer-regression-fixtures.md) for production-shaped local coverage.\n- [`docs/project-adoption.md`](./docs/project-adoption.md) for the sibling-project audit and staged\n web and native migration strategy.\n- [`registry/lumen.registry.json`](./registry/lumen.registry.json) for recipes and installable file\n groups.\n- [`docs/figma.md`](./docs/figma.md) for Figma variables and Code Connect workflows.\n- [`apps/figma-plugin/README.md`](./apps/figma-plugin/README.md) for the **beta** Figma plugin,\n Astro starters, and AI handoff.\n- [`plugins/lumen-ui`](./plugins/lumen-ui) for the packaged Codex and ChatGPT plugin, with its\n [submission and review record](./docs/openai-plugin-submission.md).\n- [`.claude-plugin/marketplace.json`](./.claude-plugin/marketplace.json) for the Claude Code\n marketplace, with its [installation and submission guide](./docs/claude-code-plugin.md).\n- [`docs/cross-platform.md`](./docs/cross-platform.md) for the native architecture, support tiers,\n and component-parity policy.\n- [`docs/native-patterns.md`](./docs/native-patterns.md) for developed native compositions and the\n boundary between Lumen presentation and platform-owned application structure.\n\n</details>\n\n## Packages\n\n| Package | Purpose |\n| --- | --- |\n| [`@santi020k/lumen-astro`](./packages/astro) | Reference implementation, Astro components, and progressive-enhancement runtime |\n| [`@santi020k/lumen-react`](./packages/react) | React components and behavior hooks |\n| [`@santi020k/lumen-react-hook-form`](./packages/react-hook-form) | Optional React Hook Form adapters for composite controls |\n| [`@santi020k/lumen-elements`](./packages/elements) | Standards-based Web Components |\n| [`@santi020k/lumen-core`](./packages/core) | Shared tokens, metadata, class helpers, and behavior contracts |\n| [`@santi020k/lumen-tokens`](./packages/tokens) | Canonical platform-neutral design token source |\n| [`@santi020k/lumen-react-native`](./packages/react-native) | React Native foundations and native primitives |\n| [`LumenUI`](./packages/swift) | Swift Package for iOS, iPadOS, macOS, tvOS, visionOS, and watchOS foundations and native primitives |\n| [`LumenWidgetUI`](./packages/swift-widget) | Focused WidgetKit-safe semantic text, icon, badge, and compact-stat foundations |\n| [`lumen-compose`](./packages/compose) | Android library for Jetpack Compose foundations and native primitives |\n| [`lumen-compose-wear`](./packages/compose/wear) | Focused Wear OS primitives for at-a-glance round-screen actions, progress, status, metrics, and rows |\n| [`@santi020k/lumen-icons-brand`](./packages/icons-brand) | Optional namespaced brand icons for web `Icon` adapters |\n| [`@santi020k/lumen`](./packages/lumen) | Umbrella package, CLI, registry metadata, and public package map |\n| [`@santi020k/lumen-mcp`](./packages/mcp) | MCP server for component discovery and source retrieval |\n\nThe private [`@santi020k/lumen-templates`](packages/templates/README.md) workspace supplies the\ngallery and preview renderer. Consumer recipes are installed through the public Lumen CLI.\n\n## Repository apps\n\nEach app has its own setup, source map, and verification guidance:\n\n| App | Purpose |\n| --- | --- |\n| [Documentation](apps/docs/README.md) | Public documentation, live examples, search, and native previews |\n| [Template showcase](apps/templates/README.md) | Standalone gallery of five product-template families |\n| [Next.js smoke app](apps/next-smoke/README.md) | Server/client boundaries and rendered consumer regression fixtures |\n| [Apple playground](apps/playground-apple/README.md) | iPhone, iPad, Mac, watchOS, and WidgetKit reference hosts |\n| [Android playground](apps/playground-android/README.md) | Compose phone/tablet app and separate Wear OS host |\n| [React Native playground](apps/playground-react-native/README.md) | Expo reference app and embedded browser gallery |\n| [Figma plugin](apps/figma-plugin/README.md) | Development beta for Astro starters and structured agent handoff |\n| [Motion studio](apps/motion/README.md) | Reusable product films and local video exports |\n| [Store artwork](apps/store-assets/README.md) | Canonical SVG sources for the playground app icons |\n\n## Lumen 4 candidate\n\nThe v4 release branch brings consumer-driven date selection, chart readability, clipboard feedback,\nloading states, native layout improvements and a new ImageComparison primitive. Start with the\n[v3 → v4 migration guide](docs/migrating-v3-to-v4.md), [consumer audit](docs/lumen-4-consumer-audit.md),\nand [local readiness record](docs/lumen-4-readiness.md). The refreshed community gallery includes\npublished consumer screenshots; it does not imply those applications already run this candidate.\n\nFor existing applications, start with [v1 → v2](docs/migrating-v1-to-v2.md) when needed, then\n[v2 → v3](docs/migrating-v2-to-v3.md) before\n[v3 → v4](docs/migrating-v3-to-v4.md). For initial adoption, use\n[Migrating to Lumen](docs/migrating-to-lumen.md).\n\n## Contributing\n\nLumen is a pnpm workspace. Astro is the reference implementation; shared contracts belong in\n`packages/core`, while framework-specific behavior stays in its adapter package.\n\nUse Node.js 22.22.2+, 24.15.0+, or 26+ and pnpm 12.8.1, as declared in the root manifest.\n\n```bash\npnpm install --frozen-lockfile\npnpm run dev\npnpm run validate\n```\n\nRead [`CONTRIBUTING.md`](./CONTRIBUTING.md) before opening a pull request. User-visible package\nchanges require a changeset.\n\n## Community\n\nCreate reusable product animations and social videos locally with the\n[Lumen motion studio](./apps/motion/README.md). Its first pilot previews one interface in four\nappearances and exports portrait, square, and landscape MP4s through HyperFrames.\n\n[Feedback & support](https://lumen.santi020k.com/support) connects ideas, questions, bug reports,\nand the [public roadmap](https://lumen.santi020k.com/support#roadmap). Discuss improvements on GitHub\nand follow accepted work through implementation and release.\n\nLumen is free and MIT licensed while the project focuses on adoption, public examples, and\nreal-world feedback.\n\n- Explore task-oriented [guides](https://lumen.santi020k.com/guides).\n- Share a public project in the [community showcase](https://lumen.santi020k.com/community).\n- Evaluate the system with the open [team adoption path](https://lumen.santi020k.com/teams).\n- Tell us what would improve your next build through the\n [adoption survey](https://github.com/santi020k/lumen/issues/new?template=adoption-feedback.yml).\n\nMaintainers can use the [exposure playbook](./docs/exposure-playbook.md) for publishing cadence,\ncampaign measurement, video scripts, and launch copy, and the\n[marketing package](./docs/marketing/README.md) for execution-ready copy, channel decisions, a\nfour-week calendar, asset briefs, an approval queue, and a community-response library.\n\n## Find your next step\n\n| Resource | Use it for |\n| --- | --- |\n| [Cross-platform architecture](docs/cross-platform.md) | Shared foundations and adapter responsibilities. |\n| [Native compatibility](docs/native-compatibility.md) | Supported toolchains and platform baselines. |\n| [Native release runbook](docs/native-release-runbook.md) | Artifact qualification and distribution gates. |\n\n## License\n\nLumen's original code and artwork are available under the [MIT License](./LICENSE). Generated\ninterface icons retain the Lucide ISC or Feather MIT terms, and generated brand icons retain Font\nAwesome Free's CC BY 4.0 terms. The native packages include the required attribution and license\nnotices; see [`icons/THIRD_PARTY_NOTICES.md`](./icons/THIRD_PARTY_NOTICES.md).\n\nCreated and maintained by [Santiago Molina](https://santi020k.com).\n"
|
|
45878
45964
|
},
|
|
45879
45965
|
"meta": {
|
|
45880
|
-
"catalogHash": "
|
|
45966
|
+
"catalogHash": "b4a56c598bbbdfcba0de814cdea3fb936c66e9152d6ae8a15adb0e64b3f41de8",
|
|
45881
45967
|
"componentCount": 196,
|
|
45882
45968
|
"nativeComponentCount": 125,
|
|
45883
45969
|
"packages": [
|
|
@@ -45891,21 +45977,21 @@
|
|
|
45891
45977
|
"com.santi020k:lumen-compose"
|
|
45892
45978
|
],
|
|
45893
45979
|
"packageVersions": {
|
|
45894
|
-
"@santi020k/lumen-astro": "4.
|
|
45895
|
-
"@santi020k/lumen-core": "4.
|
|
45896
|
-
"@santi020k/lumen-elements": "4.
|
|
45897
|
-
"@santi020k/lumen-mcp": "4.
|
|
45898
|
-
"@santi020k/lumen-react": "4.
|
|
45899
|
-
"@santi020k/lumen-react-native": "4.0.
|
|
45980
|
+
"@santi020k/lumen-astro": "4.1.0",
|
|
45981
|
+
"@santi020k/lumen-core": "4.1.0",
|
|
45982
|
+
"@santi020k/lumen-elements": "4.1.0",
|
|
45983
|
+
"@santi020k/lumen-mcp": "4.1.0",
|
|
45984
|
+
"@santi020k/lumen-react": "4.1.0",
|
|
45985
|
+
"@santi020k/lumen-react-native": "4.0.1",
|
|
45900
45986
|
"@santi020k/lumen-tokens": "4.0.0",
|
|
45901
|
-
"LumenUI": "4.
|
|
45987
|
+
"LumenUI": "4.1.0",
|
|
45902
45988
|
"com.santi020k:lumen-compose": "4.0.0",
|
|
45903
45989
|
"com.santi020k:lumen-compose-wear": "4.0.0"
|
|
45904
45990
|
},
|
|
45905
45991
|
"registryName": "lumen",
|
|
45906
45992
|
"registryVersion": 1,
|
|
45907
45993
|
"schemaVersion": 8,
|
|
45908
|
-
"serverVersion": "4.
|
|
45994
|
+
"serverVersion": "4.1.0"
|
|
45909
45995
|
},
|
|
45910
45996
|
"migration": {
|
|
45911
45997
|
"schemaVersion": 1,
|
|
@@ -46346,11 +46432,12 @@
|
|
|
46346
46432
|
"approval": {
|
|
46347
46433
|
"approver": "Santiago Molina (santi020k)",
|
|
46348
46434
|
"date": "2026-10-07",
|
|
46349
|
-
"decision": "
|
|
46350
|
-
"reviewedRevision": "
|
|
46435
|
+
"decision": "Approve the independently reviewed Mac certificate extraction correction and real macOS tool regression under the standing authorization recorded by santi020k in PR #95 comment 6027959606. Delivery remains conditional on all required hosted checks and current Codex review passing before protected merge. Preserve immutable v4.0.0 artifacts and approved iOS and Android submissions and rollout holds; deliver the corrected Mac build from merged main.",
|
|
46436
|
+
"reviewedRevision": "eea78c5a5d49f9f6610fbe23b75a8e721db03f7e",
|
|
46351
46437
|
"evidence": [
|
|
46352
|
-
"https://github.com/santi020k/lumen/commit/
|
|
46353
|
-
"https://github.com/santi020k/lumen/pull/95"
|
|
46438
|
+
"https://github.com/santi020k/lumen/commit/eea78c5a5d49f9f6610fbe23b75a8e721db03f7e",
|
|
46439
|
+
"https://github.com/santi020k/lumen/pull/95",
|
|
46440
|
+
"https://github.com/santi020k/lumen/pull/101"
|
|
46354
46441
|
]
|
|
46355
46442
|
}
|
|
46356
46443
|
},
|
|
@@ -60162,7 +60249,7 @@
|
|
|
60162
60249
|
},
|
|
60163
60250
|
"react-native": {
|
|
60164
60251
|
"packages/react-native/src/accessibility-announcement.ts": "import { useEffect, useRef } from 'react'\nimport { AccessibilityInfo, Platform } from 'react-native'\n\nimport type { LumenErrorStateAnnouncement } from './structured-recipes.js'\n\n/** Android and web use live regions; iOS requires an explicit announcement. */\nexport const useLumenAccessibilityAnnouncement = (\n message: string,\n announcement: LumenErrorStateAnnouncement\n): void => {\n const previousRef = useRef<{ message: string, announcement: LumenErrorStateAnnouncement } | null>(null)\n\n useEffect(() => {\n if (Platform.OS !== 'ios' || announcement === 'off' || !message.trim()) {\n previousRef.current = null\n\n return\n }\n\n if (previousRef.current?.message === message && previousRef.current.announcement === announcement) return\n\n previousRef.current = { announcement, message }\n\n AccessibilityInfo.announceForAccessibilityWithOptions(message, { queue: announcement === 'polite' })\n }, [announcement, message])\n}\n",
|
|
60165
|
-
"packages/react-native/src/additional-components.tsx": "import {\n type ReactElement,\n type ReactNode,\n useId\n} from 'react'\nimport {\n Pressable,\n type PressableProps,\n Text,\n TextInput,\n type TextInputProps,\n type TextStyle,\n useWindowDimensions,\n View,\n type ViewProps } from 'react-native'\n\nimport { useLumenAccessibilityAnnouncement } from './accessibility-announcement.js'\nimport { LumenFieldContext } from './field-context.js'\nimport type { LumenTextInputRef, LumenViewRef } from './native-ref-types.js'\nimport {\n resolveLumenAriaInvalid,\n resolveLumenButtonOpacity,\n resolveLumenValidationHint\n} from './recipes.js'\nimport { type LumenAlertVariant, resolveLumenAlertColors } from './shared-recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nconst resolveOptional = <Value,>(value: Value | undefined, fallback: Value): Value => value ?? fallback\n\nconst resolveTextareaBorderColor = (\n errorMessage: string | undefined,\n dangerColor: string,\n lineColor: string\n): string => errorMessage ? dangerColor : lineColor\n\nconst resolveTextareaOpacity = (editable: boolean): number => editable ? 1 : 0.52\n\nconst LumenTextareaMessage = ({\n color,\n fontSize,\n message\n}: {\n color: string\n fontSize: number\n message: string | undefined\n}): ReactElement | null => message ? <Text style={{ color, fontSize }}>{message}</Text> : null\n\nexport interface LumenToastProps extends ViewProps {\n action?: ReactNode\n description?: string\n dismissLabel?: string\n onDismiss?: () => void\n ref?: LumenViewRef\n title: string\n variant?: LumenAlertVariant\n}\n\nexport const LumenToast = ({\n action,\n description,\n dismissLabel = 'Dismiss',\n onDismiss,\n ref,\n style,\n title,\n variant = 'default',\n ...props\n}: LumenToastProps): ReactElement => {\n const theme = useLumenTheme()\n const colors = resolveLumenAlertColors(theme.colors, variant)\n\n useLumenAccessibilityAnnouncement([title, description].filter(Boolean).join('. '), 'polite')\n\n return (\n <View\n ref={ref}\n {...props}\n accessibilityLiveRegion=\"polite\"\n accessibilityRole=\"alert\"\n style={[\n {\n alignItems: 'center',\n backgroundColor: colors.backgroundColor,\n borderColor: colors.borderColor,\n borderRadius: theme.radii.md,\n borderWidth: 1,\n flexDirection: 'row',\n gap: theme.spacing.md,\n paddingHorizontal: theme.spacing.lg,\n paddingVertical: theme.spacing.md\n },\n style\n ]}\n >\n <View style={{ flex: 1, gap: theme.spacing.xs }}>\n <Text\n style={{\n color: colors.color,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {title}\n </Text>\n {description ?\n <Text style={{ color: theme.colors.inkSoft, fontSize: theme.fontSizes.xs }}>{description}</Text> :\n null}\n </View>\n {action}\n {onDismiss ?\n (\n <Pressable\n accessibilityLabel={dismissLabel}\n accessibilityRole=\"button\"\n onPress={onDismiss}\n style={({ pressed }) => ({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 44,\n minWidth: 44,\n opacity: resolveLumenButtonOpacity(false, pressed),\n padding: theme.spacing.sm\n })}\n >\n <Text style={{ color: colors.color, fontSize: theme.fontSizes.xs }}>×</Text>\n </Pressable>\n ) :\n null}\n </View>\n )\n}\n\nexport interface LumenTextareaProps extends Omit<\n TextInputProps,\n 'multiline' | 'onChangeText' | 'value'\n> {\n description?: string\n errorMessage?: string\n label: string\n onChangeText: (value: string) => void\n ref?: LumenTextInputRef\n value: string\n}\n\nexport const LumenTextarea = ({\n accessibilityHint,\n accessibilityLabel,\n accessibilityState,\n description,\n editable = true,\n errorMessage,\n label,\n numberOfLines = 4,\n onChangeText,\n placeholder,\n placeholderTextColor,\n ref,\n style,\n value,\n ...props\n}: LumenTextareaProps): ReactElement => {\n const theme = useLumenTheme()\n\n const supportingColor = resolveTextareaBorderColor(\n errorMessage,\n theme.colors.danger,\n theme.colors.inkMuted\n )\n\n const supportingMessage = resolveOptional(errorMessage, description)\n\n return (\n <View style={{ gap: theme.spacing.xs }}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n <TextInput\n ref={ref}\n {...props}\n accessibilityHint={resolveLumenValidationHint(errorMessage, description, accessibilityHint)}\n accessibilityLabel={accessibilityLabel ?? label}\n accessibilityState={{ ...accessibilityState, disabled: !editable }}\n aria-invalid={resolveLumenAriaInvalid(errorMessage)}\n editable={editable}\n multiline\n numberOfLines={numberOfLines}\n onChangeText={onChangeText}\n placeholder={placeholder}\n placeholderTextColor={resolveOptional(placeholderTextColor, theme.colors.inkMuted)}\n style={[\n {\n backgroundColor: theme.colors.surface,\n borderColor: resolveTextareaBorderColor(\n errorMessage,\n theme.colors.danger,\n theme.colors.line\n ),\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n minHeight: 112,\n opacity: resolveTextareaOpacity(editable),\n padding: theme.spacing.md,\n textAlignVertical: 'top'\n },\n style\n ]}\n value={value}\n />\n <LumenTextareaMessage\n color={supportingColor}\n fontSize={theme.fontSizes.xs}\n message={supportingMessage}\n />\n </View>\n )\n}\n\nexport interface LumenChipProps extends Omit<PressableProps, 'children'> {\n label: string\n onRemove?: () => void\n ref?: LumenViewRef\n removeLabel?: string\n selected?: boolean\n}\n\nexport const LumenChip = ({\n disabled = false,\n label,\n onPress,\n onRemove,\n ref,\n removeLabel = `Remove ${label}`,\n selected = false,\n style,\n ...props\n}: LumenChipProps): ReactElement => {\n const theme = useLumenTheme()\n const isDisabled = disabled === true\n\n const content = (\n <>\n <Text\n style={{\n color: selected ? theme.colors.brand : theme.colors.inkSoft,\n fontSize: theme.fontSizes.xs,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n {onRemove ?\n (\n <Pressable\n accessibilityLabel={removeLabel}\n accessibilityRole=\"button\"\n accessibilityState={{ disabled: isDisabled }}\n disabled={isDisabled}\n hitSlop={6}\n onPress={event => {\n event.stopPropagation()\n\n onRemove()\n }}\n style={{\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 24,\n minWidth: 24\n }}\n >\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>×</Text>\n </Pressable>\n ) :\n null}\n </>\n )\n\n const baseStyle = {\n alignItems: 'center' as const,\n alignSelf: 'flex-start' as const,\n backgroundColor: selected ? theme.colors.brandSoft : theme.colors.surfaceMuted,\n borderColor: selected ? theme.colors.brand : theme.colors.line,\n borderRadius: theme.radii.full,\n borderWidth: 1,\n flexDirection: 'row' as const,\n gap: theme.spacing.xs,\n minHeight: 32,\n opacity: isDisabled ? 0.52 : 1,\n paddingHorizontal: theme.spacing.md\n }\n\n if (!onPress) {\n return <View ref={ref} {...props} style={[baseStyle, style as ViewProps['style']]}>{content}</View>\n }\n\n return (\n <Pressable\n ref={ref}\n {...props}\n accessibilityRole=\"button\"\n accessibilityState={{ ...props.accessibilityState, disabled: isDisabled, selected }}\n disabled={isDisabled}\n onPress={onPress}\n style={state => [\n baseStyle,\n { opacity: resolveLumenButtonOpacity(isDisabled, state.pressed) },\n typeof style === 'function' ? style(state) : style\n ]}\n >\n {content}\n </Pressable>\n )\n}\n\nexport interface LumenFieldGroupProps extends ViewProps {\n children: ReactNode\n description?: string\n errorMessage?: string\n label: string\n ref?: LumenViewRef\n required?: boolean\n /** Localized spoken description of a required field. */\n requiredLabel?: string\n}\n\nconst requiredFieldLabel = (label: string, required: boolean, requiredLabel: string): string | undefined => required ? `${label}, ${requiredLabel}` : undefined\n\nexport const LumenFieldGroup = ({\n children,\n description,\n errorMessage,\n label,\n ref,\n required = false,\n requiredLabel = 'required',\n style,\n ...props\n}: LumenFieldGroupProps): ReactElement => {\n const theme = useLumenTheme()\n const fieldId = useId().replaceAll(':', '')\n const labelId = `${fieldId}-label`\n const descriptionId = description ? `${fieldId}-description` : undefined\n const errorId = errorMessage ? `${fieldId}-error` : undefined\n\n return (\n <LumenFieldContext value={{\n ...(descriptionId ? { descriptionId } : {}),\n error: Boolean(errorMessage),\n ...(errorId ? { errorId } : {}),\n label,\n labelId,\n required\n }}\n >\n <View ref={ref} {...props} style={[{ gap: theme.spacing.sm }, style]}>\n <Text\n accessibilityLabel={requiredFieldLabel(label, required, requiredLabel)}\n nativeID={labelId}\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n {required ? ' *' : ''}\n </Text>\n {description ?\n (\n <Text nativeID={descriptionId} style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>\n {description}\n </Text>\n ) :\n null}\n {children}\n {errorMessage ?\n (\n <Text\n accessibilityLiveRegion=\"polite\"\n accessibilityRole=\"alert\"\n nativeID={errorId}\n style={{ color: theme.colors.danger, fontSize: theme.fontSizes.xs }}\n >\n {errorMessage}\n </Text>\n ) :\n null}\n </View>\n </LumenFieldContext>\n )\n}\n\nexport type LumenButtonGroupOrientation = 'horizontal' | 'vertical'\n\nexport interface LumenButtonGroupProps extends ViewProps {\n children: ReactNode\n orientation?: LumenButtonGroupOrientation\n ref?: LumenViewRef\n}\n\nexport const LumenButtonGroup = ({\n children,\n orientation = 'horizontal',\n ref,\n style,\n ...props\n}: LumenButtonGroupProps): ReactElement => {\n const theme = useLumenTheme()\n const { fontScale } = useWindowDimensions()\n const horizontal = orientation === 'horizontal' && fontScale < 2\n\n return (\n <View\n ref={ref}\n {...props}\n style={[\n {\n alignItems: horizontal ? 'center' : 'stretch',\n flexDirection: horizontal ? 'row' : 'column',\n flexWrap: horizontal ? 'wrap' : 'nowrap',\n gap: theme.spacing.sm\n },\n style\n ]}\n >\n {children}\n </View>\n )\n}\n",
|
|
60252
|
+
"packages/react-native/src/additional-components.tsx": "import {\n type ReactElement,\n type ReactNode,\n useId\n} from 'react'\nimport {\n Pressable,\n type PressableProps,\n Text,\n TextInput,\n type TextInputProps,\n type TextStyle,\n useWindowDimensions,\n View,\n type ViewProps } from 'react-native'\n\nimport { useLumenAccessibilityAnnouncement } from './accessibility-announcement.js'\nimport { LumenFieldContext, type LumenFieldContextValue } from './field-context.js'\nimport type { LumenTextInputRef, LumenViewRef } from './native-ref-types.js'\nimport {\n resolveLumenAriaInvalid,\n resolveLumenButtonOpacity,\n resolveLumenValidationHint\n} from './recipes.js'\nimport { type LumenAlertVariant, resolveLumenAlertColors } from './shared-recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nconst resolveOptional = <Value,>(value: Value | undefined, fallback: Value): Value => value ?? fallback\n\nconst resolveTextareaBorderColor = (\n errorMessage: string | undefined,\n dangerColor: string,\n lineColor: string\n): string => errorMessage ? dangerColor : lineColor\n\nconst resolveTextareaOpacity = (editable: boolean): number => editable ? 1 : 0.52\n\nconst LumenTextareaMessage = ({\n color,\n fontSize,\n message\n}: {\n color: string\n fontSize: number\n message: string | undefined\n}): ReactElement | null => message ? <Text style={{ color, fontSize }}>{message}</Text> : null\n\nexport interface LumenToastProps extends ViewProps {\n action?: ReactNode\n description?: string\n dismissLabel?: string\n onDismiss?: () => void\n ref?: LumenViewRef\n title: string\n variant?: LumenAlertVariant\n}\n\nexport const LumenToast = ({\n action,\n description,\n dismissLabel = 'Dismiss',\n onDismiss,\n ref,\n style,\n title,\n variant = 'default',\n ...props\n}: LumenToastProps): ReactElement => {\n const theme = useLumenTheme()\n const colors = resolveLumenAlertColors(theme.colors, variant)\n\n useLumenAccessibilityAnnouncement([title, description].filter(Boolean).join('. '), 'polite')\n\n return (\n <View\n ref={ref}\n {...props}\n accessibilityLiveRegion=\"polite\"\n accessibilityRole=\"alert\"\n style={[\n {\n alignItems: 'center',\n backgroundColor: colors.backgroundColor,\n borderColor: colors.borderColor,\n borderRadius: theme.radii.md,\n borderWidth: 1,\n flexDirection: 'row',\n gap: theme.spacing.md,\n paddingHorizontal: theme.spacing.lg,\n paddingVertical: theme.spacing.md\n },\n style\n ]}\n >\n <View style={{ flex: 1, gap: theme.spacing.xs }}>\n <Text\n style={{\n color: colors.color,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {title}\n </Text>\n {description ?\n <Text style={{ color: theme.colors.inkSoft, fontSize: theme.fontSizes.xs }}>{description}</Text> :\n null}\n </View>\n {action}\n {onDismiss ?\n (\n <Pressable\n accessibilityLabel={dismissLabel}\n accessibilityRole=\"button\"\n onPress={onDismiss}\n style={({ pressed }) => ({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 44,\n minWidth: 44,\n opacity: resolveLumenButtonOpacity(false, pressed),\n padding: theme.spacing.sm\n })}\n >\n <Text style={{ color: colors.color, fontSize: theme.fontSizes.xs }}>×</Text>\n </Pressable>\n ) :\n null}\n </View>\n )\n}\n\nexport interface LumenTextareaProps extends Omit<\n TextInputProps,\n 'multiline' | 'onChangeText' | 'value'\n> {\n description?: string\n errorMessage?: string\n label: string\n onChangeText: (value: string) => void\n ref?: LumenTextInputRef\n value: string\n}\n\nexport const LumenTextarea = ({\n accessibilityHint,\n accessibilityLabel,\n accessibilityState,\n description,\n editable = true,\n errorMessage,\n label,\n numberOfLines = 4,\n onChangeText,\n placeholder,\n placeholderTextColor,\n ref,\n style,\n value,\n ...props\n}: LumenTextareaProps): ReactElement => {\n const theme = useLumenTheme()\n\n const supportingColor = resolveTextareaBorderColor(\n errorMessage,\n theme.colors.danger,\n theme.colors.inkMuted\n )\n\n const supportingMessage = resolveOptional(errorMessage, description)\n\n return (\n <View style={{ gap: theme.spacing.xs }}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n <TextInput\n ref={ref}\n {...props}\n accessibilityHint={resolveLumenValidationHint(errorMessage, description, accessibilityHint)}\n accessibilityLabel={accessibilityLabel ?? label}\n accessibilityState={{ ...accessibilityState, disabled: !editable }}\n aria-invalid={resolveLumenAriaInvalid(errorMessage)}\n editable={editable}\n multiline\n numberOfLines={numberOfLines}\n onChangeText={onChangeText}\n placeholder={placeholder}\n placeholderTextColor={resolveOptional(placeholderTextColor, theme.colors.inkMuted)}\n style={[\n {\n backgroundColor: theme.colors.surface,\n borderColor: resolveTextareaBorderColor(\n errorMessage,\n theme.colors.danger,\n theme.colors.line\n ),\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n minHeight: 112,\n opacity: resolveTextareaOpacity(editable),\n padding: theme.spacing.md,\n textAlignVertical: 'top'\n },\n style\n ]}\n value={value}\n />\n <LumenTextareaMessage\n color={supportingColor}\n fontSize={theme.fontSizes.xs}\n message={supportingMessage}\n />\n </View>\n )\n}\n\nexport interface LumenChipProps extends Omit<PressableProps, 'children'> {\n label: string\n onRemove?: () => void\n ref?: LumenViewRef\n removeLabel?: string\n selected?: boolean\n}\n\nexport const LumenChip = ({\n disabled = false,\n label,\n onPress,\n onRemove,\n ref,\n removeLabel = `Remove ${label}`,\n selected = false,\n style,\n ...props\n}: LumenChipProps): ReactElement => {\n const theme = useLumenTheme()\n const isDisabled = disabled === true\n\n const content = (\n <>\n <Text\n style={{\n color: selected ? theme.colors.brand : theme.colors.inkSoft,\n fontSize: theme.fontSizes.xs,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n {onRemove ?\n (\n <Pressable\n accessibilityLabel={removeLabel}\n accessibilityRole=\"button\"\n accessibilityState={{ disabled: isDisabled }}\n disabled={isDisabled}\n hitSlop={6}\n onPress={event => {\n event.stopPropagation()\n\n onRemove()\n }}\n style={{\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 24,\n minWidth: 24\n }}\n >\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>×</Text>\n </Pressable>\n ) :\n null}\n </>\n )\n\n const baseStyle = {\n alignItems: 'center' as const,\n alignSelf: 'flex-start' as const,\n backgroundColor: selected ? theme.colors.brandSoft : theme.colors.surfaceMuted,\n borderColor: selected ? theme.colors.brand : theme.colors.line,\n borderRadius: theme.radii.full,\n borderWidth: 1,\n flexDirection: 'row' as const,\n gap: theme.spacing.xs,\n minHeight: 32,\n opacity: isDisabled ? 0.52 : 1,\n paddingHorizontal: theme.spacing.md\n }\n\n if (!onPress) {\n return <View ref={ref} {...props} style={[baseStyle, style as ViewProps['style']]}>{content}</View>\n }\n\n return (\n <Pressable\n ref={ref}\n {...props}\n accessibilityRole=\"button\"\n accessibilityState={{ ...props.accessibilityState, disabled: isDisabled, selected }}\n disabled={isDisabled}\n onPress={onPress}\n style={state => [\n baseStyle,\n { opacity: resolveLumenButtonOpacity(isDisabled, state.pressed) },\n typeof style === 'function' ? style(state) : style\n ]}\n >\n {content}\n </Pressable>\n )\n}\n\nexport interface LumenFieldGroupProps extends ViewProps {\n children: ReactNode\n description?: string\n errorMessage?: string\n label: string\n ref?: LumenViewRef\n required?: boolean\n /** Localized spoken description of a required field. */\n requiredLabel?: string\n}\n\ninterface LumenFieldContextSource {\n description: string | undefined\n errorMessage: string | undefined\n fieldId: string\n label: string\n required: boolean\n}\n\nconst createLumenFieldContextValue = ({\n description, errorMessage, fieldId, label, required\n}: LumenFieldContextSource): LumenFieldContextValue => ({\n ...(description ? { description, descriptionId: `${fieldId}-description` } : {}),\n error: Boolean(errorMessage),\n ...(errorMessage ? { errorId: `${fieldId}-error`, errorMessage } : {}),\n label,\n labelId: `${fieldId}-label`,\n required\n})\n\nconst requiredFieldLabel = (label: string, required: boolean, requiredLabel: string): string | undefined => required ? `${label}, ${requiredLabel}` : undefined\n\nexport const LumenFieldGroup = ({\n children,\n description,\n errorMessage,\n label,\n ref,\n required = false,\n requiredLabel = 'required',\n style,\n ...props\n}: LumenFieldGroupProps): ReactElement => {\n const theme = useLumenTheme()\n const fieldId = useId().replaceAll(':', '')\n const labelId = `${fieldId}-label`\n const descriptionId = description ? `${fieldId}-description` : undefined\n const errorId = errorMessage ? `${fieldId}-error` : undefined\n\n return (\n <LumenFieldContext value={createLumenFieldContextValue({ description, errorMessage, fieldId, label, required })}>\n <View ref={ref} {...props} style={[{ gap: theme.spacing.sm }, style]}>\n <Text\n accessibilityLabel={requiredFieldLabel(label, required, requiredLabel)}\n nativeID={labelId}\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n {required ? ' *' : ''}\n </Text>\n {description ?\n (\n <Text nativeID={descriptionId} style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>\n {description}\n </Text>\n ) :\n null}\n {children}\n {errorMessage ?\n (\n <Text\n accessibilityLiveRegion=\"polite\"\n accessibilityRole=\"alert\"\n nativeID={errorId}\n style={{ color: theme.colors.danger, fontSize: theme.fontSizes.xs }}\n >\n {errorMessage}\n </Text>\n ) :\n null}\n </View>\n </LumenFieldContext>\n )\n}\n\nexport type LumenButtonGroupOrientation = 'horizontal' | 'vertical'\n\nexport interface LumenButtonGroupProps extends ViewProps {\n children: ReactNode\n orientation?: LumenButtonGroupOrientation\n ref?: LumenViewRef\n}\n\nexport const LumenButtonGroup = ({\n children,\n orientation = 'horizontal',\n ref,\n style,\n ...props\n}: LumenButtonGroupProps): ReactElement => {\n const theme = useLumenTheme()\n const { fontScale } = useWindowDimensions()\n const horizontal = orientation === 'horizontal' && fontScale < 2\n\n return (\n <View\n ref={ref}\n {...props}\n style={[\n {\n alignItems: horizontal ? 'center' : 'stretch',\n flexDirection: horizontal ? 'row' : 'column',\n flexWrap: horizontal ? 'wrap' : 'nowrap',\n gap: theme.spacing.sm\n },\n style\n ]}\n >\n {children}\n </View>\n )\n}\n",
|
|
60166
60253
|
"packages/react-native/src/advanced-form-components.tsx": "import { type ReactElement, useState } from 'react'\nimport { ScrollView, type TextInputProps, View } from 'react-native'\n\nimport { isLumenDecimalInBounds, type LumenDecimalDraft, type LumenDecimalOptions, normalizeLumenNumericOTP, parseLumenDecimalDraft, stepLumenDecimalDraft } from '@santi020k/lumen-core'\n\nimport { LumenFieldGroup } from './additional-components.js'\nimport { LumenButton, LumenText, LumenTextField } from './foundation-primitives.js'\nimport type { LumenTextInputRef } from './native-ref-types.js'\nimport { useLumenTheme } from './theme-context.js'\n\ninterface ControlledFieldProps extends Omit<TextInputProps, 'editable' | 'onChangeText' | 'value'> {\n description?: string\n enabled?: boolean\n errorMessage?: string\n label: string\n onValueChange: (value: string) => void\n readOnly?: boolean\n ref?: LumenTextInputRef\n value: string\n}\n\nconst isEditable = (enabled?: boolean, readOnly?: boolean): boolean => (enabled ?? true) && !(readOnly ?? false)\n\nconst fieldGroupProps = (label: string, description?: string, errorMessage?: string) => ({\n ...(description === undefined ? {} : { description }),\n ...(errorMessage === undefined ? {} : { errorMessage }),\n label\n})\n\nexport interface LumenPasswordFieldProps extends Omit<ControlledFieldProps, 'secureTextEntry' | 'textContentType' | 'autoComplete'> {\n hideLabel?: string\n newPassword?: boolean\n showLabel?: string\n}\n\nconst LumenPasswordFieldControl = ({\n description, enabled, errorMessage, hideLabel = 'Hide password', label,\n newPassword = false, onBlur, onValueChange, readOnly, ref,\n showLabel = 'Show password', value, ...props\n}: LumenPasswordFieldProps): ReactElement => {\n const [revealed, setRevealed] = useState(false)\n const editable = isEditable(enabled, readOnly)\n const visible = editable && revealed\n\n return (\n <LumenFieldGroup {...fieldGroupProps(label, description, errorMessage)}>\n <LumenTextField\n {...(ref ? { ref } : {})}\n {...props}\n autoCapitalize=\"none\"\n autoComplete={newPassword ? 'new-password' : 'current-password'}\n autoCorrect={false}\n editable={editable}\n onBlur={event => {\n setRevealed(false)\n\n onBlur?.(event)\n }}\n onChangeText={proposal => {\n if (editable) onValueChange(proposal)\n }}\n secureTextEntry={!visible}\n textContentType={newPassword ? 'newPassword' : 'password'}\n value={value}\n />\n <LumenButton\n disabled={!editable}\n intent=\"quiet\"\n accessibilityLabel={visible ? hideLabel : showLabel}\n onPress={() => {\n if (editable) setRevealed(current => !current)\n }}\n >\n {visible ? hideLabel : showLabel}\n </LumenButton>\n </LumenFieldGroup>\n )\n}\n\nexport const LumenPasswordField = (props: LumenPasswordFieldProps): ReactElement => (\n <LumenPasswordFieldControl key={`${props.enabled ?? true}-${props.readOnly ?? false}`} {...props} />\n)\n\nexport interface LumenInputOTPProps extends Omit<ControlledFieldProps, 'autoComplete' | 'keyboardType' | 'maxLength' | 'secureTextEntry' | 'textContentType'> {\n length?: number\n masked?: boolean\n onComplete?: (value: string) => void\n}\n\nexport const LumenInputOTP = ({\n description, enabled, errorMessage, label, length = 6, masked = false,\n onComplete, onValueChange, readOnly, ref, value, ...props\n}: LumenInputOTPProps): ReactElement => {\n if (normalizeLumenNumericOTP(value, length) !== value) throw new RangeError('OTP value must contain at most length ASCII digits.')\n\n const editable = isEditable(enabled, readOnly)\n\n return (\n <LumenFieldGroup {...fieldGroupProps(label, description, errorMessage)}>\n <LumenTextField\n {...(ref ? { ref } : {})}\n {...props}\n autoComplete=\"one-time-code\"\n autoCorrect={false}\n editable={editable}\n keyboardType=\"number-pad\"\n onChangeText={proposal => {\n if (!editable) return\n\n const normalized = normalizeLumenNumericOTP(proposal, length)\n\n if (normalized === null || normalized === value) return\n\n onValueChange(normalized)\n\n if (normalized.length === length) onComplete?.(normalized)\n }}\n secureTextEntry={masked}\n textContentType=\"oneTimeCode\"\n value={value}\n />\n </LumenFieldGroup>\n )\n}\n\nexport interface LumenNumberFieldProps extends ControlledFieldProps {\n decrementLabel?: string\n incrementLabel?: string\n invalidNumberLabel?: string\n locale?: string\n max?: string\n min?: string\n outOfRangeLabel?: string\n showStepper?: boolean\n step?: string\n}\n\nconst numberLabels = (\n decrementLabel?: string, incrementLabel?: string, invalidNumberLabel?: string, outOfRangeLabel?: string\n) => ({\n decrement: decrementLabel ?? 'Decrease value',\n increment: incrementLabel ?? 'Increase value',\n invalid: invalidNumberLabel ?? 'Enter a valid number',\n range: outOfRangeLabel ?? 'Enter a number within the allowed range'\n})\n\nconst numberOptions = (locale?: string, max?: string, min?: string, step?: string): LumenDecimalOptions => ({\n ...(locale === undefined ? {} : { locale }),\n ...(max === undefined ? {} : { max }),\n ...(min === undefined ? {} : { min }),\n ...(step === undefined ? {} : { step })\n})\n\nconst numberValidation = (\n draft: LumenDecimalDraft, inBounds: boolean, labels: ReturnType<typeof numberLabels>\n): string | undefined => {\n if (draft.kind === 'empty') return undefined\n\n if (draft.kind !== 'valid') return labels.invalid\n\n return inBounds ? undefined : labels.range\n}\n\nexport const LumenNumberField = ({\n decrementLabel, description, enabled, errorMessage,\n incrementLabel, invalidNumberLabel,\n label, locale, max, min, onValueChange, outOfRangeLabel,\n readOnly, ref, showStepper = true, step, value, ...props\n}: LumenNumberFieldProps): ReactElement => {\n const theme = useLumenTheme()\n const options = numberOptions(locale, max, min, step)\n const labels = numberLabels(decrementLabel, incrementLabel, invalidNumberLabel, outOfRangeLabel)\n const draft = parseLumenDecimalDraft(value, locale)\n const inBounds = isLumenDecimalInBounds(value, options)\n const validation = numberValidation(draft, inBounds, labels)\n const editable = isEditable(enabled, readOnly)\n const message = errorMessage ?? validation\n const decrease = stepLumenDecimalDraft(value, -1, options)\n const increase = stepLumenDecimalDraft(value, 1, options)\n\n const canStep = (proposal: string | null): boolean => {\n if (proposal === null) return false\n\n if (draft.kind !== 'valid') return true\n\n const next = parseLumenDecimalDraft(proposal, locale)\n\n return next.kind === 'valid' &&\n next.coefficient * 10n ** BigInt(draft.scale) !== draft.coefficient * 10n ** BigInt(next.scale)\n }\n\n return (\n <LumenFieldGroup {...fieldGroupProps(label, description, message)}>\n <LumenTextField\n {...(ref ? { ref } : {})}\n {...props}\n editable={editable}\n keyboardType=\"decimal-pad\"\n onChangeText={proposal => {\n if (editable) onValueChange(proposal)\n }}\n value={value}\n />\n {showStepper ?\n (\n <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.sm }}>\n <LumenButton\n disabled={!editable || !canStep(decrease)}\n intent=\"quiet\"\n accessibilityLabel={labels.decrement}\n onPress={() => {\n if (editable && decrease !== null) onValueChange(decrease)\n }}\n >\n {labels.decrement}\n </LumenButton>\n <LumenButton\n disabled={!editable || !canStep(increase)}\n intent=\"quiet\"\n accessibilityLabel={labels.increment}\n onPress={() => {\n if (editable && increase !== null) onValueChange(increase)\n }}\n >\n {labels.increment}\n </LumenButton>\n </View>\n ) :\n null}\n </LumenFieldGroup>\n )\n}\n\nexport interface LumenAutocompleteOption {\n description?: string\n disabled?: boolean\n label: string\n value: string\n}\n\nexport interface LumenAutocompleteProps extends Omit<ControlledFieldProps, 'onValueChange' | 'value'> {\n dismissLabel?: string\n emptyLabel?: string\n loading?: boolean\n loadingLabel?: string\n onQueryChange: (query: string) => void\n onRetry?: () => void\n onValueChange: (value: string) => void\n options: readonly LumenAutocompleteOption[]\n query: string\n resultsErrorMessage?: string\n retryLabel?: string\n value?: string\n}\n\nconst defaultCopy = (value: string | undefined, fallback: string): string => value ?? fallback\n\ninterface AutocompleteLabels { dismiss: string, empty: string, loading: string, retry: string }\n\nconst autocompleteStatus = (loading: boolean | undefined, error: string | undefined, count: number,\n labels: AutocompleteLabels): string | undefined => {\n if (loading) return labels.loading\n\n if (error !== undefined) return error\n\n return count === 0 ? labels.empty : undefined\n}\n\nconst LumenAutocompleteControl = ({\n description, dismissLabel, emptyLabel, enabled,\n errorMessage, label, loading, loadingLabel, onFocus,\n onQueryChange, onRetry, onValueChange, options, query, readOnly, ref,\n resultsErrorMessage, retryLabel, value, ...props\n}: LumenAutocompleteProps): ReactElement => {\n const theme = useLumenTheme()\n const [expanded, setExpanded] = useState(false)\n const editable = isEditable(enabled, readOnly)\n\n if (new Set(options.map(option => option.value)).size !== options.length) throw new RangeError('Autocomplete option values must be unique.')\n\n const labels = {\n dismiss: defaultCopy(dismissLabel, 'Close results'),\n empty: defaultCopy(emptyLabel, 'No results'),\n loading: defaultCopy(loadingLabel, 'Loading results'),\n retry: defaultCopy(retryLabel, 'Retry')\n }\n\n const status = autocompleteStatus(loading, resultsErrorMessage, options.length, labels)\n\n return (\n <LumenFieldGroup {...fieldGroupProps(label, description, errorMessage)}>\n <LumenTextField\n {...(ref ? { ref } : {})}\n {...props}\n accessibilityRole=\"combobox\"\n accessibilityState={{ expanded }}\n editable={editable}\n onChangeText={proposal => {\n if (editable) {\n onQueryChange(proposal)\n\n setExpanded(true)\n }\n }}\n onFocus={event => {\n if (editable) setExpanded(true)\n\n onFocus?.(event)\n }}\n value={query}\n />\n {expanded && editable ?\n (\n <View style={{ gap: theme.spacing.sm }}>\n {status ? <LumenText accessibilityLiveRegion=\"polite\">{status}</LumenText> : null}\n {!loading && resultsErrorMessage && onRetry ?\n (\n <LumenButton\n intent=\"quiet\"\n accessibilityLabel={labels.retry}\n onPress={() => {\n onRetry()\n }}\n >\n {labels.retry}\n </LumenButton>\n ) :\n null}\n {!status ?\n (\n <ScrollView keyboardShouldPersistTaps=\"handled\" style={{ maxHeight: 240 }}>\n {options.map(option => (\n <LumenButton\n accessibilityHint={option.description}\n accessibilityState={{ selected: option.value === value }}\n disabled={option.disabled}\n intent=\"quiet\"\n key={option.value}\n accessibilityLabel={option.label}\n onPress={() => {\n if (option.disabled) return\n\n onQueryChange(option.label)\n\n onValueChange(option.value)\n\n setExpanded(false)\n }}\n >\n {option.label}\n </LumenButton>\n ))}\n </ScrollView>\n ) :\n null}\n <LumenButton\n intent=\"quiet\"\n accessibilityLabel={labels.dismiss}\n onPress={() => {\n setExpanded(false)\n }}\n >\n {labels.dismiss}\n </LumenButton>\n </View>\n ) :\n null}\n </LumenFieldGroup>\n )\n}\n\nexport const LumenAutocomplete = (props: LumenAutocompleteProps): ReactElement => (\n <LumenAutocompleteControl key={`${props.enabled ?? true}-${props.readOnly ?? false}`} {...props} />\n)\n",
|
|
60167
60254
|
"packages/react-native/src/agenda-components.test.tsx": "import { act, createElement, type ReactElement } from 'react'\n\nimport { createRoot, type Root, type TestInstance } from 'test-renderer'\nimport { afterEach, expect, test, vi } from 'vitest'\n\nimport { LumenAgenda, type LumenAgendaProps } from './agenda-components.js'\n\nObject.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })\nvi.mock('react-native', () => ({\n View: (props: Record<string, unknown>): ReactElement => createElement('View', props),\n ScrollView: (props: Record<string, unknown>): ReactElement => createElement('ScrollView', props),\n Pressable: (props: Record<string, unknown>): ReactElement => createElement('Pressable', props)\n}))\nvi.mock('./primitives.js', () => ({ LumenText: (props: Record<string, unknown>): ReactElement => createElement('Text', props) }))\nvi.mock('./theme-context.js', () => ({ useLumenTheme: () => ({ colors: { line: '#eee' }, spacing: { sm: 12 } }) }))\nconst roots: Root[] = []\nafterEach(() => {\n act(() => {\n roots.forEach(root => {\n root.unmount()\n })\n })\n roots.length = 0\n})\nconst render = (props: LumenAgendaProps): Root => {\n const root = createRoot()\n roots.push(root)\n act(() => {\n root.render(<LumenAgenda {...props} />)\n })\n return root\n}\nconst read = (instance: TestInstance, key: string): unknown => (instance.props as Record<string, unknown>)[key]\nconst press = (instance: TestInstance): void => {\n const callback = read(instance, 'onPress')\n if (typeof callback !== 'function') throw new Error('Missing press')\n act(() => {\n Reflect.apply(callback, undefined, [])\n })\n}\nconst find = (root: Root, label: string): TestInstance => {\n const match = root.container.queryAll(instance => instance.type === 'Pressable' && read(instance, 'accessibilityLabel') === label)[0]\n if (!match) throw new Error(`Missing ${label}`)\n return match\n}\nconst props = (): LumenAgendaProps => ({ label: 'Agenda', selectedDay: { year: 2026, month: 3, day: 8 }, dayCount: 1, onSelectedDayChange: vi.fn(), onEventPress: vi.fn(), events: [{ event: { id: 'review', label: 'Review', startDay: { year: 2026, month: 3, day: 8 } }, startMinute: 600, endMinute: 660 }] })\ntest('controlled navigation and event activation emit host values without changing input', () => {\n const input = props()\n const root = render(input)\n press(find(root, 'Next days'))\n expect(input.onSelectedDayChange).toHaveBeenCalledWith({ year: 2026, month: 3, day: 9 })\n press(find(root, '2026-03-08, 10:00 – 11:00, Review'))\n expect(input.onEventPress).toHaveBeenCalledWith(input.events[0], input.selectedDay)\n expect(input.selectedDay.day).toBe(8)\n})\ntest('disabled and read-only block navigation and event actions including direct handlers', () => {\n for (const state of [{ disabled: true }, { readOnly: true }]) {\n const input = props()\n const root = render({ ...input, ...state })\n press(find(root, 'Next days'))\n press(find(root, '2026-03-08, 10:00 – 11:00, Review'))\n expect(input.onSelectedDayChange).not.toHaveBeenCalled()\n expect(input.onEventPress).not.toHaveBeenCalled()\n }\n})\ntest('loading error invalid data hide stale actions; empty ranges keep navigation', () => {\n for (const state of [{ loading: true }, { error: 'Unavailable' }, { dayCount: 32 }, { events: [...props().events, ...props().events] }]) {\n const input = props()\n const root = render({ ...input, ...state })\n expect(root.container.queryAll(instance => instance.type === 'Pressable')).toHaveLength(0)\n }\n const root = render({ ...props(), events: [] })\n expect(root.container.queryAll(instance => instance.type === 'Pressable')).toHaveLength(2)\n})\n",
|
|
60168
60255
|
"packages/react-native/src/agenda-components.tsx": "import type { ReactElement } from 'react'\nimport { Pressable, View, type ViewProps } from 'react-native'\n\nimport { formatLumenAgendaMinute, type LumenAgendaEvent, lumenAgendaGroups } from './agenda-recipes.js'\nimport { addLumenCalendarDays, type LumenCalendarDay, lumenCalendarDayKey } from './calendar-recipes.js'\nimport { LumenText } from './primitives.js'\nimport { useLumenTheme } from './theme-context.js'\n\nexport interface LumenAgendaProps extends Pick<ViewProps, 'style' | 'testID' | 'onLayout'> {\n label: string\n selectedDay: LumenCalendarDay\n onSelectedDayChange: (day: LumenCalendarDay) => void\n events: readonly LumenAgendaEvent[]\n dayCount?: number\n onEventPress?: (event: LumenAgendaEvent, day: LumenCalendarDay) => void\n disabled?: boolean\n readOnly?: boolean\n loading?: boolean\n error?: string | null\n loadingLabel?: string\n emptyLabel?: string\n invalidLabel?: string\n previousLabel?: string\n nextLabel?: string\n allDayLabel?: string\n formatDay?: (day: LumenCalendarDay) => string\n formatTime?: (minute: number) => string\n}\n\nconst agendaStatus = (props: LumenAgendaProps, invalid: boolean): string | null => {\n if (props.error != null) return props.error\n\n if (props.loading) return props.loadingLabel ?? 'Loading'\n\n if (invalid) return props.invalidLabel ?? 'Invalid agenda'\n\n return null\n}\n\nconst navigationDestination = (day: LumenCalendarDay, amount: number, count: number): LumenCalendarDay | null => {\n const next = addLumenCalendarDays(day, amount)\n\n return next && addLumenCalendarDays(next, count - 1) ? next : null\n}\n\ninterface AgendaRowProps {\n input: LumenAgendaEvent\n day: LumenCalendarDay\n time: string\n dayLabel: string\n locked: boolean\n onEventPress: LumenAgendaProps['onEventPress']\n}\n\nconst AgendaRow = ({ input, day, time, dayLabel, locked, onEventPress }: AgendaRowProps): ReactElement => {\n const theme = useLumenTheme()\n const unavailable = locked || input.event.disabled === true\n const name = [dayLabel, time, input.event.label, input.event.detail].filter(Boolean).join(', ')\n\n const content = (\n <>\n <LumenText>{input.event.label}</LumenText>\n <LumenText>{time}</LumenText>\n {input.event.detail && <LumenText>{input.event.detail}</LumenText>}\n </>\n )\n\n const style = { minHeight: 44, paddingVertical: theme.spacing.sm }\n\n if (!onEventPress) return <View accessible accessibilityLabel={name} style={style}>{content}</View>\n\n return (\n <Pressable\n accessibilityRole=\"button\"\n accessibilityLabel={name}\n accessibilityState={{ disabled: unavailable }}\n disabled={unavailable}\n style={style}\n onPress={() => {\n if (!unavailable) onEventPress(input, day)\n }}\n >\n {content}\n </Pressable>\n )\n}\n\nexport const LumenAgenda = (input: LumenAgendaProps): ReactElement => {\n const props = {\n dayCount: 7,\n formatDay: lumenCalendarDayKey,\n formatTime: formatLumenAgendaMinute,\n allDayLabel: 'All day',\n emptyLabel: 'No events',\n previousLabel: 'Previous days',\n nextLabel: 'Next days',\n ...input\n }\n\n const { label, selectedDay, onSelectedDayChange, events, dayCount, formatDay, formatTime } = props\n const theme = useLumenTheme()\n const groups = lumenAgendaGroups(events, selectedDay, dayCount)\n const status = agendaStatus(props, groups.length === 0)\n const locked = props.disabled === true || props.readOnly === true || status !== null\n\n const actions = [\n { day: navigationDestination(selectedDay, -dayCount, dayCount), label: props.previousLabel, id: 'previous' },\n { day: navigationDestination(selectedDay, dayCount, dayCount), label: props.nextLabel, id: 'next' }\n ]\n\n return (\n <View style={props.style} testID={props.testID} onLayout={props.onLayout} accessibilityLabel={label}>\n <LumenText accessibilityRole=\"header\">{label}</LumenText>\n {status !== null ?\n <LumenText accessibilityLiveRegion=\"polite\">{status}</LumenText> :\n (\n <>\n <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>\n {actions.map(action => (\n <Pressable\n key={action.id}\n accessibilityRole=\"button\"\n accessibilityLabel={action.label}\n accessibilityState={{ disabled: locked || !action.day }}\n disabled={locked || !action.day}\n onPress={() => {\n if (!locked && action.day) onSelectedDayChange(action.day)\n }}\n style={{ minWidth: 44, minHeight: 44, padding: theme.spacing.sm, justifyContent: 'center', flexShrink: 1 }}\n >\n <LumenText>{action.label}</LumenText>\n </Pressable>\n ))}\n </View>\n {groups.map(group => (\n <View\n key={lumenCalendarDayKey(group.day)}\n style={{ padding: theme.spacing.sm, borderBottomWidth: 1, borderColor: theme.colors.line }}\n >\n <LumenText accessibilityRole=\"header\">{formatDay(group.day)}</LumenText>\n {group.segments.length === 0 && <LumenText>{props.emptyLabel}</LumenText>}\n {group.segments.map(segment => (\n <AgendaRow\n key={segment.event.event.id}\n input={segment.event}\n day={group.day}\n dayLabel={formatDay(group.day)}\n locked={locked}\n onEventPress={props.onEventPress}\n time={segment.startMinute === null ?\n props.allDayLabel :\n `${formatTime(segment.startMinute)} – ${formatTime(segment.endMinute ?? 1440)}`}\n />\n ))}\n </View>\n ))}\n </>\n )}\n </View>\n )\n}\n",
|
|
@@ -60194,7 +60281,7 @@
|
|
|
60194
60281
|
"packages/react-native/src/command-recipes.test.ts": "import { expect, test } from 'vitest'\n\nimport { isLumenCommandGroupsValid, type LumenCommandGroup, lumenCommandGroups, moveLumenCommandActive, resolveLumenCommandActive } from './command-recipes.js'\nconst groups: readonly LumenCommandGroup[] = [\n { id: 'navigation',\n label: 'Navigation',\n items: [\n { id: 'docs', label: 'Documentation', detail: 'Read guides', keywords: ['manual'], shortcut: 'Ctrl+D' },\n { id: 'blocked', label: 'Disabled', disabled: true }\n ] },\n { id: 'actions', label: 'Actions', items: [{ id: 'theme', label: 'Toggle theme' }] }\n]\ntest('case-insensitive substring filtering retains groups/order and matches host synonyms', () => {\n for (const query of [' DOCUMENT ', 'guides', 'manual', 'ctrl+d']) {\n expect(lumenCommandGroups(groups, query)?.map(group => group.items.map(item => item.id))).toEqual([['docs']])\n }\n expect(lumenCommandGroups(groups, 'missing')).toEqual([])\n expect(groups[0]?.items).toHaveLength(2)\n})\ntest('navigation wraps enabled commands, boundaries work, stale/disabled IDs cannot activate', () => {\n expect(moveLumenCommandActive(groups, '', null, 'next')).toBe('docs')\n expect(moveLumenCommandActive(groups, '', null, 'previous')).toBe('theme')\n expect(moveLumenCommandActive(groups, '', 'docs', 'next')).toBe('theme')\n expect(moveLumenCommandActive(groups, '', 'theme', 'next')).toBe('docs')\n expect(moveLumenCommandActive(groups, '', 'docs', 'previous')).toBe('theme')\n expect(moveLumenCommandActive(groups, '', 'docs', 'last')).toBe('theme')\n expect(moveLumenCommandActive(groups, '', 'theme', 'first')).toBe('docs')\n expect(resolveLumenCommandActive(groups, 'theme', 'docs')).toBeNull()\n expect(resolveLumenCommandActive(groups, '', 'blocked')).toBeNull()\n expect(resolveLumenCommandActive(groups, '', 'unknown')).toBeNull()\n expect(moveLumenCommandActive(groups, 'Disabled', null, 'next')).toBeNull()\n})\ntest('identity is validated before filtering and handles large literal queries without regex', () => {\n for (const input of [[...groups, groups[0] ?? { id: 'navigation', label: '', items: [] }], [{ id: ' ', label: 'Blank', items: [] }], [{ id: 'a', label: 'A', items: [{ id: 'x', label: 'X' }] }, { id: 'b', label: 'B', items: [{ id: 'x', label: 'Duplicate' }] }]]) {\n expect(isLumenCommandGroupsValid(input)).toBe(false)\n expect(lumenCommandGroups(input, 'missing')).toBeNull()\n }\n expect(lumenCommandGroups(groups, '('.repeat(100000))).toEqual([])\n expect(lumenCommandGroups([], '')).toEqual([])\n})\n\ntest('rejects decoded groups, nested items and searchable fields without throwing', () => {\n const malformed: unknown[] = [null,\n 42,\n [],\n {},\n { id: null, label: '', items: [] },\n { id: 'g', label: null, items: [] },\n { id: 'g', label: '', items: null },\n ...[null,\n 42,\n {},\n { id: null, label: '' },\n { id: 'a', label: 42 },\n { id: 'a', label: '', detail: 42 },\n { id: 'a', label: '', shortcut: {} },\n { id: 'a', label: '', keywords: 'word' },\n { id: 'a', label: '', keywords: [null] },\n { id: 'a', label: '', keywords: new Array<unknown>(1) }]\n .map(item => ({ id: 'g', label: '', items: [item] }))]\n\n for (const input of [...malformed.map(group => [group]), null, {}, 'groups', new Array<unknown>(1)]) {\n expect(Reflect.apply(isLumenCommandGroupsValid, undefined, [input])).toBe(false)\n expect(Reflect.apply(lumenCommandGroups, undefined, [input, 'a'])).toBeNull()\n }\n})\n\ntest('rejects decoded non-string queries throughout filtering and keyboard helpers', () => {\n for (const query of [null, {}, 7, ['docs']]) {\n expect(Reflect.apply(lumenCommandGroups, undefined, [groups, query])).toBeNull()\n expect(Reflect.apply(resolveLumenCommandActive, undefined, [groups, query, 'docs'])).toBeNull()\n expect(Reflect.apply(moveLumenCommandActive, undefined, [groups, query, 'docs', 'next'])).toBeNull()\n }\n})\n",
|
|
60195
60282
|
"packages/react-native/src/command-recipes.ts": "export interface LumenCommandItem {\n id: string\n label: string\n detail?: string\n keywords?: readonly string[]\n shortcut?: string\n disabled?: boolean\n}\nexport interface LumenCommandGroup { id: string, label: string, items: readonly LumenCommandItem[] }\nexport type LumenCommandNavigation = 'next' | 'previous' | 'first' | 'last'\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)\nconst optionalBoolean = (value: unknown): boolean => value === undefined || typeof value === 'boolean'\nconst optionalString = (value: unknown): boolean => value === undefined || typeof value === 'string'\n\nconst validKeywords = (value: unknown): boolean => {\n if (value === undefined) return true\n\n if (!Array.isArray(value)) return false\n\n for (const keyword of value as readonly unknown[]) if (typeof keyword !== 'string') return false\n\n return true\n}\n\nconst validCommand = (value: unknown): value is LumenCommandItem => {\n if (!isRecord(value) || typeof value.id !== 'string' || typeof value.label !== 'string') return false\n\n return optionalString(value.detail) && optionalString(value.shortcut) &&\n optionalBoolean(value.disabled) && validKeywords(value.keywords)\n}\n\nconst validGroup = (value: unknown): value is LumenCommandGroup => {\n if (!isRecord(value) || typeof value.id !== 'string' || typeof value.label !== 'string' || !Array.isArray(value.items)) return false\n\n for (const item of value.items as readonly unknown[]) if (!validCommand(item)) return false\n\n return true\n}\n\nexport const isLumenCommandGroupsValid = (groups: readonly LumenCommandGroup[]): boolean => {\n if (!Array.isArray(groups)) return false\n\n const groupIds = new Set<string>()\n const itemIds = new Set<string>()\n\n for (const group of groups as readonly unknown[]) {\n if (!validGroup(group) || group.id.trim().length === 0 || groupIds.has(group.id)) return false\n\n groupIds.add(group.id)\n\n for (const item of group.items) {\n if (item.id.trim().length === 0 || itemIds.has(item.id)) return false\n\n itemIds.add(item.id)\n }\n }\n\n return true\n}\n\nconst commandMatches = (item: LumenCommandItem, query: string): boolean => [item.label || item.id, item.detail ?? '', item.shortcut ?? '', ...(item.keywords ?? [])]\n .some(text => text.toLowerCase().includes(query))\n\nexport const lumenCommandGroups = (\n groups: readonly LumenCommandGroup[], query: string\n): readonly LumenCommandGroup[] | null => {\n if (typeof query !== 'string' || !isLumenCommandGroupsValid(groups)) return null\n\n const search = query.trim().toLowerCase()\n\n return groups.map(group => ({ ...group, items: group.items.filter(item => commandMatches(item, search)) }))\n .filter(group => group.items.length > 0)\n}\nexport const resolveLumenCommandActive = (\n groups: readonly LumenCommandGroup[], query: string, activeId: string | null\n): LumenCommandItem | null => {\n const filtered = lumenCommandGroups(groups, query)\n\n if (!filtered || activeId === null) return null\n\n return filtered.flatMap(group => group.items).find(item => item.id === activeId && !item.disabled) ?? null\n}\n\nconst enabledCommands = (groups: readonly LumenCommandGroup[], query: string): readonly LumenCommandItem[] => {\n const filtered = lumenCommandGroups(groups, query)\n\n if (!filtered) return []\n\n return filtered.flatMap(group => group.items).filter(item => !item.disabled)\n}\n\nconst boundaryCommand = (items: readonly LumenCommandItem[], direction: LumenCommandNavigation): string | null => {\n const item = direction === 'next' || direction === 'first' ? items[0] : items.at(-1)\n\n return item?.id ?? null\n}\n\nexport const moveLumenCommandActive = (\n groups: readonly LumenCommandGroup[], query: string, activeId: string | null, direction: LumenCommandNavigation\n): string | null => {\n const items = enabledCommands(groups, query)\n\n if (items.length === 0) return null\n\n if (direction === 'first') return boundaryCommand(items, direction)\n\n if (direction === 'last') return boundaryCommand(items, direction)\n\n const index = items.findIndex(item => item.id === activeId)\n\n if (index < 0) return boundaryCommand(items, direction)\n\n const delta = direction === 'next' ? 1 : -1\n\n return items[(index + delta + items.length) % items.length]?.id ?? null\n}\n",
|
|
60196
60283
|
"packages/react-native/src/comparison-components.tsx": "import { type ReactElement, useState } from 'react'\nimport { I18nManager, type ImageSourcePropType, View, type ViewProps, type ViewStyle } from 'react-native'\n\nimport { formatLumenImageComparisonValue, type LumenImageComparisonMode, normalizeLumenImageComparisonMode } from '@santi020k/lumen-core'\n\nimport { LumenText } from './foundation-primitives.js'\nimport { LumenImage, type LumenImageFit } from './media-components.js'\nimport { useLumenTheme } from './theme-context.js'\nimport { LumenSlider } from './value-components.js'\n\nexport interface LumenImageComparisonProps extends Omit<ViewProps, 'children'> {\n after: ImageSourcePropType\n afterLabel?: string\n aspectRatio?: number\n before: ImageSourcePropType\n beforeLabel?: string\n enabled?: boolean\n fit?: LumenImageFit\n label: string\n locale?: string\n mode?: LumenImageComparisonMode\n onValueChange: (value: number) => void\n value: number\n}\n\nconst comparisonPosition = (value: number): number => Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0.5\n\nconst comparisonRatio = (ratio: number): number => {\n if (!Number.isFinite(ratio) || ratio < 0.1 || ratio > 10) return 16 / 9\n\n return ratio\n}\n\nconst comparisonEdge = (rtl: boolean): Pick<ViewStyle, 'left' | 'right'> => rtl ? { right: 0 } : { left: 0 }\n\nconst ComparisonAlternatePreview = ({ mode, ratio, before, after, beforeLabel, afterLabel, label, fit }: {\n mode: LumenImageComparisonMode\n ratio: number\n before: ImageSourcePropType\n after: ImageSourcePropType\n beforeLabel: string\n afterLabel: string\n label: string\n fit: LumenImageFit\n}) => {\n const theme = useLumenTheme()\n\n return (\n <View style={{ gap: theme.spacing.sm }}>\n <LumenText>{label}</LumenText>\n <View style={{ flexDirection: mode === 'side-by-side' ? 'row' : 'column', gap: theme.spacing.sm }}>\n {mode !== 'after' && (\n <View style={{ flex: 1, minWidth: 0, gap: theme.spacing.xs }}>\n <LumenImage decorative fit={fit} radius=\"lg\" source={before} aspectRatio={ratio} />\n <LumenText>{beforeLabel}</LumenText>\n </View>\n )}\n {mode !== 'before' && (\n <View style={{ flex: 1, minWidth: 0, gap: theme.spacing.xs }}>\n <LumenImage decorative fit={fit} radius=\"lg\" source={after} aspectRatio={ratio} />\n <LumenText>{afterLabel}</LumenText>\n </View>\n )}\n </View>\n </View>\n )\n}\n\nexport const LumenImageComparison = ({\n after, afterLabel = 'After', aspectRatio = 16 / 9, before, beforeLabel = 'Before',\n enabled = true, fit = 'cover', label, locale, mode: requestedMode, onValueChange, style, value, ...props\n}: LumenImageComparisonProps): ReactElement => {\n const mode = normalizeLumenImageComparisonMode(requestedMode)\n const theme = useLumenTheme()\n const [width, setWidth] = useState(0)\n const position = comparisonPosition(value)\n const ratio = comparisonRatio(aspectRatio)\n const percentage = formatLumenImageComparisonValue(position * 100, '', locale).trim()\n const rtl = I18nManager.isRTL\n\n return (\n <View {...props} style={[{ gap: theme.spacing.sm }, style]}>\n {mode !== 'reveal' ?\n (\n <ComparisonAlternatePreview\n mode={mode}\n ratio={ratio}\n before={before}\n after={after}\n beforeLabel={beforeLabel}\n afterLabel={afterLabel}\n label={label}\n fit={fit}\n />\n ) :\n (\n <>\n <View\n onLayout={event => {\n setWidth(event.nativeEvent.layout.width)\n }}\n style={{ aspectRatio: ratio, borderRadius: theme.radii.lg, overflow: 'hidden', width: '100%' }}\n >\n <LumenImage decorative fit={fit} radius=\"none\" source={before} style={{ height: '100%', position: 'absolute' }} />\n <View style={{ bottom: 0, ...comparisonEdge(rtl), overflow: 'hidden', position: 'absolute', top: 0, width: `${position * 100}%` }}>\n <LumenImage decorative fit={fit} radius=\"none\" source={after} style={{ height: '100%', ...comparisonEdge(rtl), position: 'absolute', width }} />\n </View>\n <View pointerEvents=\"none\" style={{ backgroundColor: theme.colors.ink, bottom: 0, left: `${(rtl ? 1 - position : position) * 100}%`, position: 'absolute', top: 0, width: theme.spacing.xs }} />\n </View>\n <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>\n <LumenText>{afterLabel}</LumenText>\n <LumenText>{beforeLabel}</LumenText>\n </View>\n <LumenSlider enabled={enabled} label={label} max={1} min={0} onValueChange={onValueChange} step={0.01} value={position} valueLabel={`${afterLabel} ${percentage}`} />\n </>\n )}\n </View>\n )\n}\n",
|
|
60197
|
-
"packages/react-native/src/component-behavior.test.tsx": "// cspell:words Espacios Configuración Cambios guardados Registro actualizado Otro\nimport { act, type ComponentRef, createRef, type ReactElement, type Ref, StrictMode, useImperativeHandle, useState } from 'react'\nimport type { View } from 'react-native'\n\nimport { DateTimePickerAndroid } from '@react-native-community/datetimepicker'\nimport { getLumenPhoneCountry } from '@santi020k/lumen-core'\nimport type { LumenMediaViewportValue } from '@santi020k/lumen-core/media-workspace'\nimport { createRoot, type Root, type TestInstance } from 'test-renderer'\nimport { afterEach, describe, expect, test, vi } from 'vitest'\n\nimport { LumenBrandGithubIconGraphic } from './static-icons/brand-github.generated.js'\nimport { LumenSearchIconGraphic } from './static-icons/search.generated.js'\nimport { LumenButtonGroup, LumenChip, LumenFieldGroup, LumenTextarea, LumenToast } from './additional-components.js'\nimport { LumenAutocomplete, LumenInputOTP, LumenNumberField, LumenPasswordField } from './advanced-form-components.js'\nimport { LumenBreadcrumb } from './breadcrumb-components.js'\nimport { LumenBarChart, LumenComboChart, LumenHeatmap, LumenHistogram, LumenLineChart, LumenRangeChart, LumenScatterChart, LumenWaterfallChart } from './chart-components.js'\nimport { LumenImageComparison } from './comparison-components.js'\nimport { LumenDateField, LumenDateRangeField } from './datetime-components.js'\nimport { LumenSearchField, LumenToggle } from './form-components.js'\nimport { LumenIcon as GraphicIcon, LumenIconButton as GraphicIconButton, type LumenIconGraphicProps } from './graphics.js'\nimport { type LumenToastHookController, type LumenToastHookOptions, useToast } from './hooks.js'\nimport { LumenMediaThumbnail, LumenMediaViewport } from './media-workspace-components.js'\nimport { LumenMultiSelect } from './multi-select-components.js'\nimport { LumenAlertDialog, LumenMenu, LumenSheet } from './overlay-components.js'\nimport { LumenPhoneInput } from './phone-components.js'\nimport { resolveLumenPhoneInputValue } from './phone-recipes.js'\nimport { LumenNavigationBar } from './platform-components.js'\nimport { LumenButton, LumenIcon, LumenIconButton, LumenText, LumenTextField } from './primitives.js'\nimport { LumenProvider } from './provider.js'\nimport { LumenRating } from './rating-components.js'\nimport { resolveLumenRating } from './rating-recipes.js'\nimport { LumenCheckbox, LumenTabs } from './selection-components.js'\nimport { LumenStepper } from './stepper-components.js'\nimport { resolveLumenStepState } from './stepper-recipes.js'\nimport { LumenBanner, LumenErrorState, LumenStatusBar } from './structured-components.js'\nimport { LumenDataTable } from './table-components.js'\nimport type { LumenTableRow } from './table-recipes.js'\nimport { createLumenTheme } from './theme.js'\nimport { LumenTimeField, type LumenTimeFieldProps } from './time-components.js'\nimport { LumenTimeline, LumenTimelineItem } from './timeline-components.js'\nimport { LumenPicker, LumenRangeSlider, LumenSlider } from './value-components.js'\n\nObject.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })\n\nconst nativeDirection = vi.hoisted(() => ({ isRTL: false }))\nconst nativePlatform = vi.hoisted(() => ({ OS: 'ios' }))\nconst nativeMotion = vi.hoisted(() => ({ enabled: false }))\nconst accessibilityFocus = vi.hoisted(() => vi.fn())\nconst accessibilityAnnouncement = vi.hoisted(() => vi.fn<(message: string, options: { queue: boolean }) => void>())\ntype RecommendedTimeout = (duration: number) => Promise<number>\nconst accessibilityTimeout = vi.hoisted(() => vi.fn<RecommendedTimeout>(duration => Promise.resolve(duration)))\nconst nativeWindow = vi.hoisted(() => ({ fontScale: 1, height: 800, scale: 2, width: 400 }))\nconst safeAreaInsets = vi.hoisted(() => ({\n bottom: 34,\n left: 8,\n right: 12,\n top: 20\n}))\n\nvi.mock('react-native', async () => {\n const { createElement, isValidElement, useImperativeHandle } = await import('react')\n const hostComponent = (name: string) => (\n props: Record<string, unknown>\n ): ReactElement => createElement(name, props)\n type FocusablePressableProps = Record<string, unknown> & {\n ref?: Ref<{ focus: () => void }>\n }\n const FocusablePressable = ({ ref, ...props }: FocusablePressableProps): ReactElement => {\n useImperativeHandle(ref, () => ({ focus: vi.fn() }))\n\n return createElement('Pressable', props)\n }\n\n return {\n AccessibilityInfo: {\n addEventListener: () => ({ remove: vi.fn() }),\n announceForAccessibilityWithOptions: accessibilityAnnouncement,\n getRecommendedTimeoutMillis: accessibilityTimeout,\n isReduceMotionEnabled: () => Promise.resolve(nativeMotion.enabled),\n sendAccessibilityEvent: accessibilityFocus\n },\n ActivityIndicator: hostComponent('ActivityIndicator'),\n FlatList: (props: Record<string, unknown>): ReactElement => {\n const renderItem = props.renderItem\n const data: readonly unknown[] = Array.isArray(props.data) ? props.data : []\n const children = data.map((item, index) => {\n if (typeof renderItem !== 'function') return null\n\n const rendered: unknown = Reflect.apply(renderItem, undefined, [{ item, index }])\n\n return isValidElement(rendered) ? rendered : null\n })\n\n return createElement('FlatList', props, children)\n },\n I18nManager: nativeDirection,\n Image: hostComponent('Image'),\n KeyboardAvoidingView: hostComponent('KeyboardAvoidingView'),\n Modal: hostComponent('Modal'),\n Platform: nativePlatform,\n Pressable: FocusablePressable,\n ScrollView: hostComponent('ScrollView'),\n Share: { share: () => Promise.resolve({ action: 'sharedAction' }) },\n Switch: hostComponent('Switch'),\n Text: hostComponent('Text'),\n TextInput: hostComponent('TextInput'),\n useColorScheme: () => 'light',\n useWindowDimensions: () => nativeWindow,\n View: hostComponent('View')\n }\n})\n\nvi.mock('@react-native-community/datetimepicker', async () => {\n const { createElement } = await import('react')\n const NativeDatePicker = (props: Record<string, unknown>): ReactElement => (\n createElement('NativeDatePicker', props)\n )\n\n return {\n DateTimePickerAndroid: { dismiss: vi.fn(), open: vi.fn() },\n default: NativeDatePicker\n }\n})\n\nvi.mock('react-native-svg', async () => {\n const { createElement } = await import('react')\n const hostComponent = (name: string) => (\n props: Record<string, unknown>\n ): ReactElement => createElement(name, props)\n\n return Object.fromEntries(\n ['Circle', 'Defs', 'Ellipse', 'G', 'Line', 'LinearGradient', 'Path', 'Polygon', 'Polyline', 'Rect', 'Stop', 'Svg', 'Text']\n .map(name => [name, hostComponent(name)])\n )\n})\n\nconst mountedRoots: Root[] = []\n\ninterface ToastProbeProps {\n ref: Ref<LumenToastHookController>\n options?: LumenToastHookOptions\n}\n\nconst ToastProbe = ({ ref, options }: ToastProbeProps): ReactElement | null => {\n const controller = useToast(options)\n\n useImperativeHandle(ref, () => controller, [controller])\n\n return null\n}\n\nconst readToastController = (ref: { current: LumenToastHookController | null }): LumenToastHookController => {\n if (!ref.current) throw new Error('Toast controller is not mounted')\n\n return ref.current\n}\n\nconst deferredTimeout = () => {\n let complete: (duration: number) => void = () => {\n throw new Error('Missing timeout resolver')\n }\n const promise = new Promise<number>(resolve => {\n complete = resolve\n })\n\n return { promise, resolve: complete }\n}\n\nconst renderNative = async (element: ReactElement): Promise<Root> => {\n const root = createRoot({\n publicTextComponentTypes: ['Text'],\n textComponentTypes: ['RCTText', 'Text']\n })\n\n mountedRoots.push(root)\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\">{element}</LumenProvider>)\n await Promise.resolve()\n })\n\n return root\n}\n\nconst readProp = (instance: TestInstance, property: string): unknown => {\n const props = instance.props as Record<string, unknown>\n\n return props[property]\n}\n\nconst callAction = (value: unknown, missingActionMessage: string): void => {\n if (typeof value !== 'function') throw new Error(missingActionMessage)\n\n Reflect.apply(value, undefined, [])\n}\n\nconst callKeyboardAction = (value: unknown, key: string): ReturnType<typeof vi.fn> => {\n if (typeof value !== 'function') throw new Error('Tab is missing its keyboard action.')\n\n const preventDefault = vi.fn()\n\n Reflect.apply(value, undefined, [{\n nativeEvent: { code: key, key },\n preventDefault\n }])\n\n return preventDefault\n}\n\nconst findByAccessibilityRole = (root: Root, role: string): TestInstance => {\n const matches = root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === role\n )\n\n expect(matches).toHaveLength(1)\n\n const match = matches[0]\n\n if (!match) throw new Error(`Expected one ${role} accessibility node.`)\n\n return match\n}\n\nconst findByAccessibilityLabel = (root: Root, label: string): TestInstance => {\n const matches = root.container.queryAll(\n instance => readProp(instance, 'accessibilityLabel') === label\n )\n\n expect(matches).toHaveLength(1)\n\n const match = matches[0]\n\n if (!match) throw new Error(`Expected one accessibility node labeled ${label}.`)\n\n return match\n}\n\nconst findHostComponent = (root: Root, type: string): TestInstance => {\n const matches = root.container.queryAll(instance => instance.type === type)\n\n expect(matches).toHaveLength(1)\n\n const match = matches[0]\n\n if (!match) throw new Error(`Expected one ${type} host component.`)\n\n return match\n}\n\nafterEach(async () => {\n nativeDirection.isRTL = false\n const roots = mountedRoots.splice(0)\n\n await act(async () => {\n for (const root of roots) root.unmount()\n await Promise.resolve()\n })\n nativeWindow.fontScale = 1\n nativePlatform.OS = 'ios'\n accessibilityAnnouncement.mockClear()\n accessibilityTimeout.mockReset().mockImplementation(duration => Promise.resolve(duration))\n vi.useRealTimers()\n vi.unstubAllGlobals()\n})\n\ndescribe('Lumen React Native component behavior', () => {\n test.each(['bar', 'line', 'scatter', 'range', 'combo'] as const)(\n '%s chart fits its container and resizes without removing data', async kind => {\n const data = [3, 7, 4, 8, 5].map((y, x) => ({ x, y }))\n const series = [{ id: 'activity', label: 'Changes', data }]\n const props = { label: 'Weekly activity', showData: false, series }\n const charts = {\n bar: <LumenBarChart {...props} />,\n line: <LumenLineChart {...props} />,\n scatter: <LumenScatterChart {...props} />,\n range: <LumenRangeChart label=\"Weekly range\" showData={false} data={data.map(({ x, y }) => ({ x, low: y, high: y + 2 }))} />,\n combo: <LumenComboChart {...props} series={[{ id: 'activity', label: 'Changes', data, mark: 'bar' }]} />\n }\n const root = await renderNative(charts[kind])\n const layouts = root.container.queryAll(instance => instance.type === 'View' && typeof readProp(instance, 'onLayout') === 'function')\n expect(layouts).toHaveLength(1)\n const layout = layouts[0]\n if (!layout) throw new Error('Missing chart container')\n const handler = readProp(layout, 'onLayout')\n if (typeof handler !== 'function') throw new Error('Missing chart layout handler')\n\n for (const width of [268, 720, 300, 0, Number.NaN]) {\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent: { layout: { width } } }])\n await Promise.resolve()\n })\n const expectedWidth = width > 0 && Number.isFinite(width) ? width : 300\n const svg = findHostComponent(root, 'Svg')\n expect(readProp(svg, 'width')).toBe(expectedWidth)\n expect(readProp(svg, 'height')).toBe(Math.min(320, Math.max(220, expectedWidth * 0.75)))\n expect(root.container.queryAll(instance => instance.type === 'ScrollView')).toHaveLength(0)\n const bars = root.container.queryAll(instance => instance.type === 'Rect')\n expect(bars).toHaveLength(['bar', 'combo'].includes(kind) ? 5 : 0)\n for (const bar of bars) {\n const coordinates = /translate\\(([-\\d.]+) ([-\\d.]+)\\)/u.exec(String(readProp(bar, 'transform')))\n if (!coordinates?.[1]) throw new Error('Missing bar coordinates')\n expect(Number(coordinates[1])).toBeGreaterThanOrEqual(0)\n expect(Number(coordinates[1]) + Number(readProp(bar, 'width'))).toBeLessThanOrEqual(expectedWidth)\n }\n }\n }\n )\n\n test.each([\n { rtl: false, values: [20, 50, 120, 20, 120] },\n { rtl: true, values: [120, 100, 20, 120, 20] }\n ])('slider touch follows RTL=$rtl while accessibility values remain numeric', async ({ rtl, values }) => {\n nativeDirection.isRTL = rtl\n const onValueChange = vi.fn<(value: number) => void>()\n const root = await renderNative(\n <LumenSlider label=\"Volume\" min={20} max={120} step={10} value={70} onValueChange={onValueChange} />\n )\n const slider = findByAccessibilityRole(root, 'adjustable')\n expect(readProp(slider, 'accessible')).toBe(true)\n const dispatch = async (property: string, nativeEvent: unknown): Promise<void> => {\n const handler = readProp(slider, property)\n if (typeof handler !== 'function') throw new Error(`Missing slider ${property} callback`)\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent }])\n await Promise.resolve()\n })\n }\n await dispatch('onLayout', { layout: { width: 200 } })\n for (const [index, position] of [0, 50, 200, -50, 250].entries()) {\n for (const event of ['onResponderGrant', 'onResponderMove']) {\n await dispatch(event, { locationX: position })\n expect(onValueChange).toHaveBeenLastCalledWith(values[index])\n }\n }\n await dispatch('onAccessibilityAction', { actionName: 'increment' })\n expect(onValueChange).toHaveBeenLastCalledWith(80)\n await dispatch('onAccessibilityAction', { actionName: 'decrement' })\n expect(onValueChange).toHaveBeenLastCalledWith(60)\n expect(readProp(slider, 'accessibilityValue')).toMatchObject({ min: 20, max: 120, now: 70 })\n })\n\n test('range endpoints stay controlled, independently named and cannot cross', async () => {\n const onValueChange = vi.fn<(value: readonly [number, number]) => void>()\n const root = await renderNative(\n <LumenRangeSlider\n label=\"Capacity\"\n startLabel=\"Lower\"\n endLabel=\"Upper\"\n value={[20, 60]}\n min={0}\n max={100}\n step={10}\n onValueChange={onValueChange}\n formatValue={value => `${value}%`}\n />\n )\n const sliders = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'adjustable')\n expect(sliders).toHaveLength(2)\n const [start, end] = sliders\n if (!start || !end) throw new Error('Missing interval endpoints')\n expect(readProp(start, 'accessibilityLabel')).toBe('Capacity · Lower')\n expect(readProp(end, 'accessibilityLabel')).toBe('Capacity · Upper')\n expect(readProp(start, 'accessibilityValue')).toMatchObject({ now: 20, text: '20%' })\n expect(readProp(start, 'aria-valuenow')).toBe(20)\n expect(readProp(start, 'aria-valuetext')).toBe('20%')\n const move = async (slider: TestInstance, direction: string): Promise<void> => {\n const handler = readProp(slider, 'onAccessibilityAction')\n if (typeof handler !== 'function') throw new Error('Missing range adjustment')\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent: { actionName: direction } }])\n await Promise.resolve()\n })\n }\n await move(start, 'increment')\n expect(onValueChange).toHaveBeenLastCalledWith([30, 60])\n await move(end, 'decrement')\n expect(onValueChange).toHaveBeenLastCalledWith([20, 50])\n // The application has not accepted either update; subsequent intent uses its controlled value.\n await move(start, 'increment')\n expect(onValueChange).toHaveBeenLastCalledWith([30, 60])\n await act(async () => {\n root.render(\n <LumenProvider>\n <LumenRangeSlider\n label=\"Capacity\"\n value={[60, 60]}\n min={0}\n max={100}\n step={10}\n onValueChange={onValueChange}\n />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n const next = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'adjustable')\n if (!next[0] || !next[1]) throw new Error('Missing updated interval endpoints')\n await move(next[0], 'increment')\n expect(onValueChange).toHaveBeenLastCalledWith([60, 60])\n await move(next[1], 'decrement')\n expect(onValueChange).toHaveBeenLastCalledWith([60, 60])\n })\n\n test.each([{ enabled: false, readOnly: false }, { enabled: true, readOnly: true }])(\n 'range endpoints reject touch and accessibility when enabled=$enabled readOnly=$readOnly',\n async ({ enabled, readOnly }) => {\n const onValueChange = vi.fn<(value: readonly [number, number]) => void>()\n const root = await renderNative(\n <LumenRangeSlider\n label=\"Capacity\"\n value={[20, 80]}\n enabled={enabled}\n readOnly={readOnly}\n onValueChange={onValueChange}\n />\n )\n const sliders = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'adjustable')\n for (const slider of sliders) {\n expect(readProp(slider, 'accessibilityState')).toEqual({ disabled: true })\n for (const [event, nativeEvent] of [\n ['onAccessibilityAction', { actionName: 'increment' }],\n ['onResponderGrant', { locationX: 100 }],\n ['onResponderMove', { locationX: 100 }]\n ] as const) {\n const handler = readProp(slider, event)\n if (typeof handler !== 'function') throw new Error(`Missing ${event}`)\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent }])\n await Promise.resolve()\n })\n }\n }\n expect(onValueChange).not.toHaveBeenCalled()\n }\n )\n\n test('uses the Android accessibility timeout before dismissing a toast', async () => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n accessibilityTimeout.mockResolvedValue(20_000)\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n readToastController(ref).create({ title: 'Saved' })\n await Promise.resolve()\n })\n\n expect(accessibilityTimeout).toHaveBeenCalledExactlyOnceWith(5000)\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(19_999)\n })\n expect(readToastController(ref).toasts).toHaveLength(1)\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(1)\n })\n expect(readToastController(ref).toasts).toHaveLength(0)\n })\n\n test.each(['shorter', 'invalid', 'oversized', 'rejected'])('retains the requested duration when Android returns a %s recommendation', async result => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n\n if (result === 'rejected') accessibilityTimeout.mockRejectedValue(new Error('Native timeout unavailable'))\n else if (result === 'shorter') accessibilityTimeout.mockResolvedValue(1)\n else if (result === 'oversized') accessibilityTimeout.mockResolvedValue(Number.MAX_SAFE_INTEGER)\n else accessibilityTimeout.mockResolvedValue(Number.NaN)\n\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n readToastController(ref).create({ duration: 1000, title: 'Saved' })\n await Promise.resolve()\n })\n expect(accessibilityTimeout).toHaveBeenCalledExactlyOnceWith(1000)\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(999)\n })\n expect(readToastController(ref).toasts).toHaveLength(1)\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(1)\n })\n expect(readToastController(ref).toasts).toHaveLength(0)\n })\n\n test.each(['dismiss', 'clear', 'unmount', 'evict'])('ignores late Android timeout results after %s', async operation => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n const pending = deferredTimeout()\n\n accessibilityTimeout.mockReturnValueOnce(pending.promise)\n\n const ref = createRef<LumenToastHookController>()\n const root = await renderNative(<ToastProbe options={{ maxCount: 1 }} ref={ref} />)\n let id = ''\n\n await act(async () => {\n id = readToastController(ref).create({ title: 'Saved' })\n await Promise.resolve()\n })\n expect(accessibilityTimeout).toHaveBeenCalledExactlyOnceWith(5000)\n\n await act(async () => {\n if (operation === 'dismiss') readToastController(ref).dismiss(id)\n if (operation === 'clear') readToastController(ref).clear()\n if (operation === 'unmount') root.unmount()\n if (operation === 'evict') readToastController(ref).create({ duration: 0, title: 'Persistent replacement' })\n await Promise.resolve()\n })\n await act(async () => {\n pending.resolve(20_000)\n await Promise.resolve()\n })\n\n expect(vi.getTimerCount()).toBe(0)\n })\n\n test('ignores stale Android recommendations when a toast duration is updated', async () => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n const pending = deferredTimeout()\n\n accessibilityTimeout.mockReturnValueOnce(pending.promise).mockResolvedValueOnce(10_000)\n\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n const id = readToastController(ref).create({ title: 'Saved' })\n\n readToastController(ref).update(id, { duration: 1000 })\n await Promise.resolve()\n })\n expect(accessibilityTimeout).toHaveBeenNthCalledWith(1, 5000)\n expect(accessibilityTimeout).toHaveBeenNthCalledWith(2, 1000)\n expect(accessibilityTimeout).toHaveBeenCalledTimes(2)\n\n await act(async () => {\n pending.resolve(100_000)\n await Promise.resolve()\n await vi.advanceTimersByTimeAsync(10_000)\n })\n\n expect(readToastController(ref).toasts).toHaveLength(0)\n expect(vi.getTimerCount()).toBe(0)\n })\n\n test('keeps explicitly persistent Android toasts without requesting a timeout', async () => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n for (const duration of [0, -1, Number.POSITIVE_INFINITY]) readToastController(ref).create({ duration, title: 'Saved' })\n await vi.advanceTimersByTimeAsync(100_000)\n })\n\n expect(readToastController(ref).toasts).toHaveLength(3)\n expect(accessibilityTimeout).not.toHaveBeenCalled()\n expect(vi.getTimerCount()).toBe(0)\n })\n\n test.each(['ios', 'web'])('preserves the requested %s toast duration without Android timeout calls', async platform => {\n nativePlatform.OS = platform\n vi.useFakeTimers()\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n readToastController(ref).create({ duration: 1000, title: 'Saved' })\n await vi.advanceTimersByTimeAsync(1000)\n })\n\n expect(readToastController(ref).toasts).toHaveLength(0)\n expect(accessibilityTimeout).not.toHaveBeenCalled()\n })\n\n test('keeps enlarged navigation labels readable and preserves destination semantics', async () => {\n const select = vi.fn()\n const reselect = vi.fn()\n const items = [\n { label: 'Espacios de trabajo', value: 'workspaces' },\n { label: 'Configuración', value: 'settings' },\n { disabled: true, label: 'Archivo', value: 'archive' }\n ]\n const navigation = <LumenNavigationBar items={items} onReselect={reselect} onValueChange={select} value=\"workspaces\" />\n const root = await renderNative(navigation)\n const labels = () => root.container.queryAll(instance => instance.type === 'Text')\n expect(labels().map(label => readProp(label, 'numberOfLines'))).toEqual([1, 1, 1])\n\n nativeWindow.fontScale = 3\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\"><LumenNavigationBar items={items} onReselect={reselect} onValueChange={select} value=\"workspaces\" /></LumenProvider>)\n await Promise.resolve()\n })\n expect(labels().every(label => readProp(label, 'numberOfLines') === undefined)).toBe(true)\n expect(labels().every(label => {\n const style = readProp(label, 'style')\n return typeof style === 'object' && style !== null && 'maxWidth' in style && style.maxWidth === '100%'\n })).toBe(true)\n const selected = findByAccessibilityLabel(root, 'Espacios de trabajo')\n expect(readProp(selected, 'accessibilityState')).toMatchObject({ selected: true })\n callAction(readProp(selected, 'onPress'), 'Missing reselect action')\n expect(reselect).toHaveBeenCalledWith('workspaces')\n const settings = findByAccessibilityLabel(root, 'Configuración')\n callAction(readProp(settings, 'onPress'), 'Missing destination action')\n expect(select).toHaveBeenCalledWith('settings')\n expect(readProp(findByAccessibilityLabel(root, 'Archivo'), 'disabled')).toBe(true)\n })\n\n test('keeps alert actions inside safe areas and makes oversized content scrollable', async () => {\n const root = await renderNative(\n <LumenAlertDialog\n confirmLabel=\"Delete\"\n onConfirm={() => {}}\n onDismiss={() => {}}\n safeAreaInsets={safeAreaInsets}\n title=\"Delete project?\"\n visible\n />\n )\n const modalContainer = root.container.queryAll(\n instance => readProp(instance, 'accessibilityViewIsModal') === true\n )[0]\n\n if (!modalContainer) throw new Error('Expected the alert modal container.')\n\n expect(readProp(modalContainer, 'style')).toMatchObject({\n paddingBottom: 58,\n paddingLeft: 32,\n paddingRight: 36,\n paddingTop: 44\n })\n expect(readProp(findByAccessibilityRole(root, 'alert'), 'style')).toMatchObject({\n maxHeight: '100%'\n })\n })\n\n test('keeps sheet actions above the bottom inset and scrolls application content', async () => {\n const root = await renderNative(\n <LumenSheet\n actions={<LumenButton>Save</LumenButton>}\n onDismiss={() => {}}\n safeAreaInsets={safeAreaInsets}\n scrollable\n title=\"Settings\"\n visible\n >\n <LumenText>Sheet content</LumenText>\n </LumenSheet>\n )\n const keyboardSurface = root.container.queryAll(\n instance => instance.type === 'KeyboardAvoidingView'\n )[0]\n const sheetPanel = root.container.queryAll(instance => {\n const style = readProp(instance, 'style')\n\n return typeof style === 'object' &&\n style !== null &&\n 'paddingBottom' in style &&\n style.paddingBottom === 34\n })[0]\n\n if (!keyboardSurface || !sheetPanel) throw new Error('Expected the sheet surfaces.')\n\n expect(readProp(keyboardSurface, 'style')).toMatchObject({\n paddingBottom: 0,\n paddingLeft: 8,\n paddingRight: 12,\n paddingTop: 20\n })\n expect(readProp(sheetPanel, 'style')).toMatchObject({ paddingBottom: 34 })\n expect(root.container.queryAll(instance => instance.type === 'ScrollView')).toHaveLength(1)\n })\n\n test('lets virtualized sheet content own scrolling', async () => {\n const root = await renderNative(\n <LumenSheet onDismiss={() => {}} scrollable={false} visible>\n <LumenText>Virtualized content</LumenText>\n </LumenSheet>\n )\n\n expect(root.container.queryAll(instance => instance.type === 'ScrollView')).toHaveLength(0)\n })\n\n test('button exposes loading as busy and disabled native state', async () => {\n const root = await renderNative(<LumenButton loading>Save changes</LumenButton>)\n const button = findByAccessibilityRole(root, 'button')\n\n expect(readProp(button, 'disabled')).toBe(true)\n expect(readProp(button, 'accessibilityState')).toEqual({\n busy: true,\n disabled: true\n })\n })\n\n test('button preserves its native role when a consumer supplies another role', async () => {\n const root = await renderNative(\n <LumenButton accessibilityRole=\"link\">Open workspace</LumenButton>\n )\n\n expect(findByAccessibilityRole(root, 'button')).toBeDefined()\n expect(root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'link'\n )).toHaveLength(0)\n })\n\n test('text inputs expose disabled native state without dropping caller state', async () => {\n const textFieldRoot = await renderNative(\n <LumenTextField\n accessibilityLabel=\"Project name\"\n accessibilityState={{ busy: true }}\n editable={false}\n />\n )\n const textareaRoot = await renderNative(\n <LumenTextarea\n accessibilityState={{ busy: true }}\n editable={false}\n label=\"Project notes\"\n onChangeText={() => {}}\n value=\"\"\n />\n )\n const searchRoot = await renderNative(\n <LumenSearchField\n accessibilityState={{ busy: true }}\n editable={false}\n onChangeText={() => {}}\n prompt=\"Search projects\"\n value=\"\"\n />\n )\n\n for (const [root, label] of [\n [textFieldRoot, 'Project name'],\n [textareaRoot, 'Project notes'],\n [searchRoot, 'Search projects']\n ] as const) {\n expect(readProp(findByAccessibilityLabel(root, label), 'accessibilityState')).toEqual({\n busy: true,\n disabled: true\n })\n }\n })\n\n test('validation is exposed on the native controls without dropping caller context', async () => {\n const textFieldRoot = await renderNative(\n <LumenTextField accessibilityLabel=\"Project name\" error />\n )\n const textareaRoot = await renderNative(\n <LumenTextarea\n accessibilityHint=\"Consumer guidance\"\n errorMessage=\"Project notes are required\"\n label=\"Project notes\"\n onChangeText={() => {}}\n value=\"\"\n />\n )\n const dateRoot = await renderNative(\n <LumenDateField\n errorMessage=\"Choose a valid birthday\"\n label=\"Birthday\"\n onValueChange={() => {}}\n value={null}\n />\n )\n\n const textField = findByAccessibilityLabel(textFieldRoot, 'Project name')\n const textarea = findByAccessibilityLabel(textareaRoot, 'Project notes')\n const dateButton = findByAccessibilityLabel(dateRoot, 'Birthday, Select a date')\n\n expect(readProp(textField, 'aria-invalid')).toBe(true)\n expect(readProp(textarea, 'aria-invalid')).toBe(true)\n expect(readProp(textarea, 'accessibilityHint')).toBe('Project notes are required')\n expect(readProp(dateButton, 'aria-invalid')).toBe(true)\n expect(readProp(dateButton, 'accessibilityHint')).toBe('Choose a valid birthday')\n })\n\n test('grouped fields expose required and range validation context', async () => {\n const fieldGroupRoot = await renderNative(\n <LumenFieldGroup label=\"Project details\" required>\n <LumenText>Details</LumenText>\n </LumenFieldGroup>\n )\n const dateRangeRoot = await renderNative(\n <LumenDateRangeField\n accessibilityHint=\"Consumer guidance\"\n errorMessage=\"Choose a valid schedule\"\n label=\"Schedule\"\n onValueChange={() => {}}\n value={{ end: null, start: null }}\n />\n )\n\n expect(findByAccessibilityLabel(fieldGroupRoot, 'Project details, required')).toBeDefined()\n\n const dateRange = findByAccessibilityLabel(dateRangeRoot, 'Schedule')\n\n expect(readProp(dateRange, 'aria-invalid')).toBe(true)\n expect(readProp(dateRange, 'accessibilityHint')).toBe('Choose a valid schedule')\n })\n\n test('localizes required-field descriptions without exposing them on optional fields', async () => {\n const root = await renderNative(\n <>\n <LumenFieldGroup label=\"Nombre\" required requiredLabel=\"obligatorio\"><LumenTextField /></LumenFieldGroup>\n <LumenFieldGroup label=\"Notas\" requiredLabel=\"obligatorio\"><LumenTextField /></LumenFieldGroup>\n </>\n )\n\n expect(findByAccessibilityLabel(root, 'Nombre, obligatorio')).toBeDefined()\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Notas, obligatorio')).toHaveLength(0)\n expect(findByAccessibilityLabel(root, 'Notas')).toBeDefined()\n })\n\n test('field groups provide inherited native and web relationships without overriding inputs', async () => {\n const root = await renderNative(\n <LumenFieldGroup\n description=\"Shown on the public profile\"\n errorMessage=\"Choose a unique name\"\n label=\"Project name\"\n required\n >\n <LumenText>Nested row content</LumenText>\n <LumenTextField />\n <LumenTextField accessibilityLabel=\"Short project name\" />\n </LumenFieldGroup>\n )\n const inputs = root.container.queryAll(instance => instance.type === 'TextInput')\n const inherited = inputs[0]\n const explicit = inputs[1]\n\n if (!inherited || !explicit) throw new Error('Expected two grouped text fields.')\n\n expect(readProp(inherited, 'accessibilityLabel')).toBe('Project name')\n expect(readProp(inherited, 'aria-labelledby')).toMatch(/-label$/u)\n expect(readProp(inherited, 'aria-describedby')).toMatch(/-description .*?-error$/u)\n expect(readProp(inherited, 'aria-required')).toBe(true)\n expect(readProp(inherited, 'aria-invalid')).toBe(true)\n expect(readProp(explicit, 'accessibilityLabel')).toBe('Short project name')\n expect(readProp(explicit, 'aria-labelledby')).toBeUndefined()\n\n const validationMessages = root.container.queryAll(\n instance => readProp(instance, 'children') === 'Choose a unique name'\n )\n\n expect(validationMessages).toHaveLength(1)\n\n const validationMessage = validationMessages[0]\n\n if (!validationMessage) throw new Error('Expected the grouped validation message.')\n\n expect(readProp(validationMessage, 'accessibilityLiveRegion')).toBe('polite')\n expect(readProp(validationMessage, 'accessibilityRole')).toBe('alert')\n })\n\n test('read-only phone input locks country selection and number editing', async () => {\n const country = getLumenPhoneCountry('CO')\n if (!country) throw new Error('Missing phone fixture')\n const root = await renderNative(<LumenPhoneInput label=\"Phone\" onValueChange={() => {}} readOnly value={resolveLumenPhoneInputValue([country], country, '', {})} />)\n expect(readProp(findByAccessibilityLabel(root, 'Country code, Colombia, +57'), 'disabled')).toBe(true)\n expect(readProp(findByAccessibilityLabel(root, 'Phone number'), 'editable')).toBe(false)\n })\n\n test('phone input exposes disabled state on both native controls', async () => {\n const country = getLumenPhoneCountry('US')\n\n expect(country).toBeDefined()\n\n if (!country) return\n\n const value = resolveLumenPhoneInputValue([country], country, '', {})\n const root = await renderNative(\n <LumenPhoneInput\n countries={[country]}\n enabled={false}\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n )\n const countrySelector = findByAccessibilityLabel(root, 'Country code, United States, +1')\n const numberInput = findByAccessibilityLabel(root, 'Phone number')\n\n expect(readProp(countrySelector, 'accessibilityState')).toEqual({\n disabled: true,\n expanded: false\n })\n expect(readProp(numberInput, 'accessibilityState')).toEqual({ disabled: true })\n })\n\n test('phone input exposes validation on the number editor', async () => {\n const country = getLumenPhoneCountry('US')\n\n expect(country).toBeDefined()\n\n if (!country) return\n\n const value = resolveLumenPhoneInputValue([country], country, '415', {})\n const root = await renderNative(\n <LumenPhoneInput\n countries={[country]}\n errorMessage=\"Enter a complete phone number\"\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n )\n const numberInput = findByAccessibilityLabel(root, 'Phone number')\n\n expect(readProp(numberInput, 'aria-invalid')).toBe(true)\n expect(readProp(numberInput, 'accessibilityHint')).toBe('Enter a complete phone number')\n })\n\n test('phone input exposes an empty country allow-list as a disabled selector', async () => {\n const country = getLumenPhoneCountry('US')\n\n expect(country).toBeDefined()\n\n if (!country) return\n\n const value = resolveLumenPhoneInputValue([country], country, '', {})\n const root = await renderNative(\n <LumenPhoneInput\n countries={[]}\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n )\n const countrySelector = findByAccessibilityLabel(root, 'Country code, United States, +1')\n\n expect(readProp(countrySelector, 'disabled')).toBe(true)\n expect(readProp(countrySelector, 'accessibilityState')).toEqual({\n disabled: true,\n expanded: false\n })\n })\n\n test('generic picker disables empty options and does not reopen after disabling', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const root = await renderNative(\n <LumenPicker\n label=\"Workspace\"\n onValueChange={onValueChange}\n options={[]}\n value=\"none\"\n />\n )\n let trigger = findByAccessibilityLabel(root, 'Workspace')\n\n expect(readProp(trigger, 'disabled')).toBe(true)\n expect(readProp(trigger, 'accessibilityState')).toEqual({\n disabled: true,\n expanded: false\n })\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenPicker\n label=\"Workspace\"\n onValueChange={onValueChange}\n options={[{ label: 'Design', value: 'design' }]}\n value=\"design\"\n />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n trigger = findByAccessibilityLabel(root, 'Workspace')\n\n await act(async () => {\n callAction(readProp(trigger, 'onPress'), 'Picker is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(findByAccessibilityRole(root, 'menu')).toBeDefined()\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenPicker\n enabled={false}\n label=\"Workspace\"\n onValueChange={onValueChange}\n options={[{ label: 'Design', value: 'design' }]}\n value=\"design\"\n />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'menu'\n )).toHaveLength(0)\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenPicker\n label=\"Workspace\"\n onValueChange={onValueChange}\n options={[{ label: 'Design', value: 'design' }]}\n value=\"design\"\n />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'menu'\n )).toHaveLength(0)\n })\n\n test('date picker does not reopen after the field is disabled and re-enabled', async () => {\n const root = await renderNative(\n <LumenDateField label=\"Birthday\" onValueChange={() => {}} value={null} />\n )\n const trigger = findByAccessibilityLabel(root, 'Birthday, Select a date')\n\n await act(async () => {\n callAction(readProp(trigger, 'onPress'), 'Date field is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(findHostComponent(root, 'NativeDatePicker')).toBeDefined()\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenDateField enabled={false} label=\"Birthday\" onValueChange={() => {}} value={null} />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(root.container.queryAll(instance => instance.type === 'NativeDatePicker')).toHaveLength(0)\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenDateField label=\"Birthday\" onValueChange={() => {}} value={null} />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(root.container.queryAll(instance => instance.type === 'NativeDatePicker')).toHaveLength(0)\n })\n\n test('disabling an Android date field dismisses its imperative native dialog', async () => {\n nativePlatform.OS = 'android'\n\n try {\n const root = await renderNative(\n <LumenDateField label=\"Birthday\" onValueChange={() => {}} value={null} />\n )\n const trigger = findByAccessibilityLabel(root, 'Birthday, Select a date')\n\n await act(async () => {\n callAction(readProp(trigger, 'onPress'), 'Date field is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(DateTimePickerAndroid.open).toHaveBeenCalledOnce()\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenDateField enabled={false} label=\"Birthday\" onValueChange={() => {}} value={null} />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(DateTimePickerAndroid.dismiss).toHaveBeenCalledWith('date')\n } finally {\n nativePlatform.OS = 'ios'\n }\n })\n\n test('phone picker closes and clears its query when the input becomes disabled', async () => {\n const country = getLumenPhoneCountry('US')\n\n expect(country).toBeDefined()\n\n if (!country) return\n\n const value = resolveLumenPhoneInputValue([country], country, '', {})\n const renderPhone = (enabled: boolean): ReactElement => (\n <LumenProvider scheme=\"light\">\n <LumenPhoneInput\n countries={[country]}\n enabled={enabled}\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n </LumenProvider>\n )\n const root = await renderNative(\n <LumenPhoneInput\n countries={[country]}\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n )\n const selector = findByAccessibilityLabel(root, 'Country code, United States, +1')\n\n await act(async () => {\n callAction(readProp(selector, 'onPress'), 'Country selector is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(readProp(findHostComponent(root, 'Modal'), 'visible')).toBe(true)\n expect(readProp(findByAccessibilityLabel(root, 'Search countries'), 'editable')).toBe(true)\n\n await act(async () => {\n const onChangeText = readProp(findByAccessibilityLabel(root, 'Search countries'), 'onChangeText')\n\n if (typeof onChangeText !== 'function') throw new Error('Country search is missing its change action.')\n\n Reflect.apply(onChangeText, undefined, ['united'])\n await Promise.resolve()\n })\n\n await act(async () => {\n root.render(renderPhone(false))\n await Promise.resolve()\n })\n\n expect(readProp(findHostComponent(root, 'Modal'), 'visible')).toBe(false)\n expect(readProp(findByAccessibilityLabel(root, 'Country code, United States, +1'), 'accessibilityState'))\n .toEqual({ disabled: true, expanded: false })\n\n await act(async () => {\n root.render(renderPhone(true))\n await Promise.resolve()\n })\n\n expect(readProp(findByAccessibilityLabel(root, 'Country code, United States, +1'), 'accessibilityState'))\n .toEqual({ disabled: false, expanded: false })\n\n const reopenedSelector = findByAccessibilityLabel(root, 'Country code, United States, +1')\n\n await act(async () => {\n callAction(\n readProp(reopenedSelector, 'onPress'),\n 'Re-enabled country selector is missing its native press action.'\n )\n await Promise.resolve()\n })\n\n expect(readProp(findByAccessibilityLabel(root, 'Search countries'), 'value')).toBe('')\n })\n\n test('chip removal exposes the independent action disabled state', async () => {\n const root = await renderNative(\n <LumenChip disabled label=\"Design\" onRemove={() => {}} />\n )\n const removeButton = findByAccessibilityLabel(root, 'Remove Design')\n\n expect(readProp(removeButton, 'accessibilityRole')).toBe('button')\n expect(readProp(removeButton, 'disabled')).toBe(true)\n expect(readProp(removeButton, 'accessibilityState')).toEqual({ disabled: true })\n expect(readProp(removeButton, 'style')).toMatchObject({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 24,\n minWidth: 24\n })\n })\n\n test('toast announces politely and keeps dismissal independently operable', async () => {\n const onDismiss = vi.fn<() => void>()\n const root = await renderNative(\n <LumenToast dismissLabel=\"Close update\" onDismiss={onDismiss} title=\"Changes saved\" />\n )\n const alert = findByAccessibilityRole(root, 'alert')\n const dismissButton = findByAccessibilityLabel(root, 'Close update')\n const style = readProp(dismissButton, 'style')\n\n expect(readProp(alert, 'accessibilityLiveRegion')).toBe('polite')\n expect(readProp(dismissButton, 'accessibilityRole')).toBe('button')\n expect(style).toBeTypeOf('function')\n\n if (typeof style !== 'function') throw new Error('Toast dismissal is missing its pressable style.')\n\n const restingStyle: unknown = Reflect.apply(style, undefined, [{ pressed: false }])\n\n expect(restingStyle).toMatchObject({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 44,\n minWidth: 44\n })\n\n await act(async () => {\n callAction(readProp(dismissButton, 'onPress'), 'Toast dismissal is missing its press action.')\n await Promise.resolve()\n })\n\n expect(onDismiss).toHaveBeenCalledOnce()\n })\n\n test('queues localized iOS toast copy once and announces changed content', async () => {\n const toast = (description: string) => (\n <StrictMode><LumenToast description={description} title=\"Cambios guardados\" /></StrictMode>\n )\n const root = await renderNative(toast('Registro actualizado'))\n\n expect(accessibilityAnnouncement).toHaveBeenCalledExactlyOnceWith('Cambios guardados. Registro actualizado', { queue: true })\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\">{toast('Registro actualizado')}</LumenProvider>)\n await Promise.resolve()\n })\n\n expect(accessibilityAnnouncement).toHaveBeenCalledOnce()\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\">{toast('Otro registro actualizado')}</LumenProvider>)\n await Promise.resolve()\n })\n\n expect(accessibilityAnnouncement).toHaveBeenLastCalledWith('Cambios guardados. Otro registro actualizado', { queue: true })\n expect(accessibilityAnnouncement).toHaveBeenCalledTimes(2)\n })\n\n test('honors iOS error announcement urgency and off without speaking diagnostic references', async () => {\n const root = await renderNative(<LumenErrorState announcement=\"assertive\" description=\"Try again\" reference=\"request-123\" title=\"Unable to save\" />)\n\n expect(accessibilityAnnouncement).toHaveBeenCalledExactlyOnceWith('Unable to save. Try again', { queue: false })\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\"><LumenErrorState announcement=\"off\" title=\"Unable to save\" /></LumenProvider>)\n await Promise.resolve()\n })\n\n expect(accessibilityAnnouncement).toHaveBeenCalledOnce()\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\"><LumenErrorState announcement=\"polite\" title=\"Unable to save\" /></LumenProvider>)\n await Promise.resolve()\n })\n\n expect(accessibilityAnnouncement).toHaveBeenLastCalledWith('Unable to save', { queue: true })\n expect(accessibilityAnnouncement).toHaveBeenCalledTimes(2)\n })\n\n test('does not announce initially disabled errors or empty toast copy on iOS', async () => {\n await renderNative(\n <>\n <LumenErrorState announcement=\"off\" title=\"Unable to save\" />\n <LumenToast title=\" \" />\n </>\n )\n\n expect(accessibilityAnnouncement).not.toHaveBeenCalled()\n })\n\n test.each(['android', 'web'])('keeps %s live regions without duplicate imperative announcements', async platform => {\n nativePlatform.OS = platform\n const root = await renderNative(\n <>\n <LumenToast title=\"Saved\" />\n <LumenErrorState announcement=\"assertive\" title=\"Unable to save\" />\n </>\n )\n\n expect(readProp(findByAccessibilityRole(root, 'alert'), 'accessibilityLiveRegion')).toBe('polite')\n expect(readProp(findByAccessibilityRole(root, 'summary'), 'accessibilityLiveRegion')).toBe('assertive')\n expect(accessibilityAnnouncement).not.toHaveBeenCalled()\n })\n\n test('banner dismissal has a full independent touch target', async () => {\n const root = await renderNative(\n <LumenBanner dismissLabel=\"Close notice\" onDismiss={() => {}} title=\"Maintenance soon\" />\n )\n const dismissButton = findByAccessibilityLabel(root, 'Close notice')\n const style = readProp(dismissButton, 'style')\n\n expect(readProp(dismissButton, 'accessibilityRole')).toBe('button')\n expect(style).toBeTypeOf('function')\n\n if (typeof style !== 'function') throw new Error('Banner dismissal is missing its pressable style.')\n\n const restingStyle: unknown = Reflect.apply(style, undefined, [{ pressed: false }])\n\n expect(restingStyle).toMatchObject({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 44,\n minWidth: 44\n })\n })\n\n test('status bars allow long messages to wrap without shrinking trailing content', async () => {\n const message = 'El análisis permanece en el dispositivo para proteger tu privacidad.'\n const root = await renderNative(\n <LumenProvider>\n <LumenStatusBar\n message={message}\n trailing={<LumenText>Analyze</LumenText>}\n />\n </LumenProvider>\n )\n const messageNodes = root.container.queryAll(\n instance => instance.type === 'Text' && readProp(instance, 'children') === message\n )\n\n expect(messageNodes).toHaveLength(1)\n\n const messageNode = messageNodes[0]\n\n if (!messageNode) throw new Error('Expected the status message fixture.')\n\n expect(readProp(messageNode, 'numberOfLines')).toBeUndefined()\n expect(readProp(messageNode, 'style')).toMatchObject({ flex: 1, flexShrink: 1 })\n\n const fixedTrailingContainers = root.container.queryAll(instance => {\n const style = readProp(instance, 'style')\n\n return instance.type === 'View' && typeof style === 'object' && style !== null &&\n Reflect.get(style, 'flexShrink') === 0\n })\n\n expect(fixedTrailingContainers).toHaveLength(1)\n })\n\n test('search clear action preserves its label and full touch target', async () => {\n const onChangeText = vi.fn<(value: string) => void>()\n const root = await renderNative(\n <LumenSearchField\n clearLabel=\"Clear query\"\n onChangeText={onChangeText}\n value=\"lumen\"\n />\n )\n const clearButton = findByAccessibilityLabel(root, 'Clear query')\n const style = readProp(clearButton, 'style')\n\n expect(readProp(clearButton, 'accessibilityRole')).toBe('button')\n expect(style).toBeTypeOf('function')\n\n if (typeof style !== 'function') throw new Error('Clear action is missing its pressable style.')\n\n const restingStyle: unknown = Reflect.apply(style, undefined, [{ pressed: false }])\n\n expect(restingStyle).toMatchObject({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 44,\n minWidth: 44\n })\n\n await act(async () => {\n callAction(readProp(clearButton, 'onPress'), 'Clear action is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(onChangeText).toHaveBeenCalledExactlyOnceWith('')\n })\n\n test('checkbox announces state and emits the next controlled value', async () => {\n const onCheckedChange = vi.fn<(checked: boolean) => void>()\n const root = await renderNative(\n <LumenCheckbox\n checked={false}\n label=\"Include diagnostics\"\n onCheckedChange={onCheckedChange}\n />\n )\n const checkbox = findByAccessibilityRole(root, 'checkbox')\n const onPress = readProp(checkbox, 'onPress')\n\n expect(readProp(checkbox, 'accessibilityLabel')).toBe('Include diagnostics')\n expect(readProp(checkbox, 'aria-checked')).toBe(false)\n expect(readProp(checkbox, 'aria-disabled')).toBe(false)\n\n expect(readProp(checkbox, 'accessibilityState')).toEqual({\n checked: false,\n disabled: false\n })\n expect(onPress).toBeTypeOf('function')\n\n await act(async () => {\n callAction(onPress, 'Checkbox is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true)\n })\n\n test('checkbox preserves a host-provided accessible name', async () => {\n const root = await renderNative(\n <LumenCheckbox checked={false} label=\"Diagnostics\" accessibilityLabel=\"Include diagnostic report\" onCheckedChange={() => undefined} />\n )\n\n expect(readProp(findByAccessibilityRole(root, 'checkbox'), 'accessibilityLabel')).toBe('Include diagnostic report')\n })\n\n test('toggle makes the full labeled row the single interactive switch', async () => {\n const onValueChange = vi.fn<(value: boolean) => void>()\n const root = await renderNative(\n <LumenToggle\n description=\"Receive product and security updates\"\n label=\"Automatic updates\"\n onValueChange={onValueChange}\n value={false}\n />\n )\n const toggle = findByAccessibilityRole(root, 'switch')\n const onPress = readProp(toggle, 'onPress')\n const nativeSwitches = root.container.queryAll(instance => instance.type === 'Switch')\n\n expect(readProp(toggle, 'accessibilityLabel')).toBe('Automatic updates')\n expect(readProp(toggle, 'accessibilityHint')).toBe('Receive product and security updates')\n expect(readProp(toggle, 'aria-checked')).toBe(false)\n expect(readProp(toggle, 'accessibilityState')).toEqual({\n checked: false,\n disabled: false\n })\n expect(nativeSwitches).toHaveLength(1)\n\n const nativeSwitch = nativeSwitches[0]\n\n if (!nativeSwitch) throw new Error('Expected the decorative native switch.')\n\n expect(readProp(nativeSwitch, 'accessibilityElementsHidden')).toBe(true)\n expect(readProp(nativeSwitch, 'importantForAccessibility')).toBe('no-hide-descendants')\n expect(readProp(nativeSwitch, 'pointerEvents')).toBe('none')\n expect(readProp(nativeSwitch, 'onValueChange')).toBeUndefined()\n\n await act(async () => {\n callAction(onPress, 'Toggle row is missing its press action.')\n await Promise.resolve()\n })\n\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith(true)\n })\n\n test('web toggle keeps the decorative native input out of the tab order', async () => {\n nativePlatform.OS = 'web'\n const onValueChange = vi.fn()\n const root = await renderNative(<LumenToggle label=\"Updates\" onValueChange={onValueChange} value={false} />)\n const control = root.container.queryAll(instance => instance.type === 'Switch')[0]\n if (!control) throw new Error('Missing decorative switch')\n expect(readProp(control, 'disabled')).toBe(true)\n expect(readProp(control, 'aria-hidden')).toBe(true)\n const row = findByAccessibilityRole(root, 'switch')\n expect(readProp(row, 'disabled')).toBe(false)\n await act(async () => {\n callAction(readProp(row, 'onPress'), 'Missing row action')\n await Promise.resolve()\n })\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith(true)\n nativePlatform.OS = 'ios'\n })\n\n test('toggle preserves a consumer accessibility hint over supporting copy', async () => {\n const root = await renderNative(\n <LumenToggle\n accessibilityHint=\"Changes how updates are installed\"\n description=\"Receive product and security updates\"\n label=\"Automatic updates\"\n onValueChange={() => {}}\n value={false}\n />\n )\n\n expect(readProp(findByAccessibilityRole(root, 'switch'), 'accessibilityHint'))\n .toBe('Changes how updates are installed')\n })\n\n test('disabled toggle row cannot emit a value change', async () => {\n const onValueChange = vi.fn<(value: boolean) => void>()\n const root = await renderNative(\n <LumenToggle\n disabled\n label=\"Automatic updates\"\n onValueChange={onValueChange}\n value\n />\n )\n const toggle = findByAccessibilityRole(root, 'switch')\n\n expect(readProp(toggle, 'accessibilityState')).toEqual({\n checked: true,\n disabled: true\n })\n expect(readProp(toggle, 'disabled')).toBe(true)\n\n await act(async () => {\n callAction(readProp(toggle, 'onPress'), 'Disabled toggle row is missing its guarded press action.')\n await Promise.resolve()\n })\n\n expect(onValueChange).not.toHaveBeenCalled()\n })\n\n test('tabs preserve selected and disabled semantics while emitting enabled selection', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const root = await renderNative(\n <LumenTabs\n label=\"Workspace views\"\n onValueChange={onValueChange}\n options={[\n { label: 'Overview', value: 'overview' },\n { disabled: true, label: 'Activity', value: 'activity' }\n ]}\n value=\"overview\"\n >\n <LumenText>Current workspace health</LumenText>\n </LumenTabs>\n )\n const tabList = findByAccessibilityRole(root, 'tablist')\n const tabs = root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'tab'\n )\n\n expect(readProp(tabList, 'accessibilityLabel')).toBe('Workspace views')\n expect(tabs).toHaveLength(2)\n const [overviewTab, activityTab] = tabs\n\n if (!overviewTab || !activityTab) throw new Error('Expected the two configured tabs.')\n\n expect(readProp(overviewTab, 'accessibilityState')).toEqual({\n disabled: false,\n selected: true\n })\n expect(readProp(activityTab, 'accessibilityState')).toEqual({\n disabled: true,\n selected: false\n })\n\n const enabledTabPress = readProp(overviewTab, 'onPress')\n\n await act(async () => {\n callAction(enabledTabPress, 'Enabled tab is missing its press action.')\n await Promise.resolve()\n })\n\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith('overview')\n })\n\n test('uses the translated tab label by default and accepts an explicit translated panel name', async () => {\n const fixture = (panelAccessibilityLabel?: string): ReactElement => (\n <LumenTabs label=\"Vistas\" onValueChange={vi.fn()} options={[{ label: 'Resumen', value: 'overview' }]} {...(panelAccessibilityLabel ? { panelAccessibilityLabel } : {})} value=\"overview\">\n <LumenText>Contenido</LumenText>\n </LumenTabs>\n )\n const root = await renderNative(fixture())\n const panel = root.container.queryAll(instance => readProp(instance, 'role') === 'tabpanel')[0]\n if (!panel) throw new Error('Missing tab panel')\n expect(readProp(panel, 'accessibilityLabel')).toBe('Resumen')\n await act(async () => {\n root.render(<LumenProvider>{fixture('Contenido del resumen')}</LumenProvider>)\n await Promise.resolve()\n })\n const translatedPanel = root.container.queryAll(instance => readProp(instance, 'role') === 'tabpanel')[0]\n if (!translatedPanel) throw new Error('Missing translated tab panel')\n expect(readProp(translatedPanel, 'accessibilityLabel')).toBe('Contenido del resumen')\n })\n\n test('tabs support directional keyboard navigation without selecting disabled tabs', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const KeyboardTabsFixture = (): ReactElement => {\n const [value, setValue] = useState('overview')\n\n return (\n <LumenTabs\n label=\"Workspace views\"\n onValueChange={nextValue => {\n onValueChange(nextValue)\n setValue(nextValue)\n }}\n options={[\n { label: 'Overview', value: 'overview' },\n { label: 'Activity', value: 'activity' },\n { disabled: true, label: 'Billing', value: 'billing' }\n ]}\n value={value}\n >\n <LumenText>Current workspace health</LumenText>\n </LumenTabs>\n )\n }\n const root = await renderNative(<KeyboardTabsFixture />)\n const pressTabKey = async (optionIndex: number, key: string): Promise<void> => {\n const tab = root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'tab'\n )[optionIndex]\n\n if (!tab) throw new Error(`Expected tab fixture at index ${optionIndex}.`)\n\n let prevented = false\n\n await act(async () => {\n const preventDefault = callKeyboardAction(readProp(tab, 'onKeyDown'), key)\n\n prevented = preventDefault.mock.calls.length > 0\n\n await Promise.resolve()\n })\n\n expect(prevented).toBe(true)\n }\n\n await pressTabKey(0, 'ArrowRight')\n expect(onValueChange).toHaveBeenLastCalledWith('activity')\n\n await pressTabKey(1, 'ArrowRight')\n expect(onValueChange).toHaveBeenLastCalledWith('overview')\n expect(onValueChange).not.toHaveBeenCalledWith('billing')\n })\n\n test('tabs do not reselect the current tab when every alternative is disabled', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const root = await renderNative(\n <LumenTabs\n label=\"Workspace views\"\n onValueChange={onValueChange}\n options={[\n { label: 'Overview', value: 'overview' },\n { disabled: true, label: 'Activity', value: 'activity' }\n ]}\n value=\"overview\"\n >\n <LumenText>Current workspace health</LumenText>\n </LumenTabs>\n )\n const overviewTab = root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'tab'\n )[0]\n\n if (!overviewTab) throw new Error('Expected the selected tab fixture.')\n\n expect(callKeyboardAction(readProp(overviewTab, 'onKeyDown'), 'ArrowRight'))\n .toHaveBeenCalledOnce()\n expect(onValueChange).not.toHaveBeenCalled()\n })\n})\n\ndescribe('LumenSheet consumer layouts', () => {\n afterEach(() => {\n nativeMotion.enabled = false\n nativeWindow.width = 400\n nativeWindow.height = 800\n nativeWindow.fontScale = 1\n nativePlatform.OS = 'ios'\n accessibilityFocus.mockClear()\n })\n\n test.each([\n { fontScale: 2, height: 800, alignment: 'center' },\n { fontScale: 1, height: 320, alignment: 'flex-end' }\n ])('keeps headings and actions scrollable when space is limited: %j', async ({ alignment, ...window }) => {\n Object.assign(nativeWindow, window, { width: 1024 })\n const root = await renderNative(\n <LumenSheet actions={<LumenButton>Save changes</LumenButton>} onDismiss={vi.fn()} presentation=\"adaptive\" title=\"A long translated heading\" visible>\n <LumenTextField accessibilityLabel=\"Details\" />\n </LumenSheet>\n )\n const scroll = findHostComponent(root, 'ScrollView')\n\n expect(scroll.queryAll(instance => readProp(instance, 'accessibilityRole') === 'header')).toHaveLength(1)\n expect(scroll.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')).toHaveLength(1)\n expect(readProp(scroll, 'keyboardShouldPersistTaps')).toBe('handled')\n expect(readProp(findHostComponent(root, 'KeyboardAvoidingView'), 'style')).toMatchObject({ justifyContent: alignment })\n })\n\n test('preserves application-owned virtualization at accessibility text sizes', async () => {\n nativeWindow.fontScale = 3\n const root = await renderNative(\n <LumenSheet onDismiss={vi.fn()} scrollable={false} visible>\n <LumenText>Virtualized collection</LumenText>\n </LumenSheet>\n )\n\n expect(root.container.queryAll(instance => instance.type === 'ScrollView')).toHaveLength(0)\n })\n\n test('focuses an explicit initial control after presentation and restores the trigger after closing', async () => {\n const initialFocusRef = createRef<ComponentRef<typeof View>>()\n const returnFocusRef = createRef<ComponentRef<typeof View>>()\n let restoreFrame: (() => void) | undefined\n vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {\n restoreFrame = callback\n\n return 1\n })\n vi.stubGlobal('cancelAnimationFrame', vi.fn())\n const fixture = (visible: boolean): ReactElement => (\n <>\n <LumenButton ref={returnFocusRef}>Edit</LumenButton>\n <LumenSheet\n initialFocusRef={initialFocusRef}\n onDismiss={vi.fn()}\n returnFocusRef={returnFocusRef}\n visible={visible}\n >\n <LumenButton ref={initialFocusRef}>Save</LumenButton>\n </LumenSheet>\n </>\n )\n const root = await renderNative(fixture(true))\n callAction(readProp(findHostComponent(root, 'Modal'), 'onShow'), 'Missing modal presentation event')\n expect(accessibilityFocus).toHaveBeenCalledExactlyOnceWith(initialFocusRef.current, 'focus')\n await act(async () => {\n root.render(<LumenProvider>{fixture(false)}</LumenProvider>)\n await Promise.resolve()\n })\n expect(accessibilityFocus).toHaveBeenCalledTimes(1)\n if (!restoreFrame) throw new Error('Expected a deferred focus restoration')\n restoreFrame()\n expect(accessibilityFocus).toHaveBeenLastCalledWith(returnFocusRef.current, 'focus')\n })\n\n test('wraps related actions at ordinary text sizes and stacks them at accessibility sizes', async () => {\n const fixture = (): ReactElement => (\n <LumenButtonGroup accessibilityLabel=\"Form actions\">\n <LumenButton>Cancel</LumenButton>\n <LumenButton>Save</LumenButton>\n </LumenButtonGroup>\n )\n const root = await renderNative(fixture())\n const group = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Form actions')[0]\n if (!group) throw new Error('Missing action group')\n expect(readProp(group, 'style')).toEqual(expect.arrayContaining([expect.objectContaining({ flexDirection: 'row', flexWrap: 'wrap' })]))\n nativeWindow.fontScale = 2\n await act(async () => {\n root.render(<LumenProvider>{fixture()}</LumenProvider>)\n await Promise.resolve()\n })\n const updated = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Form actions')[0]\n if (!updated) throw new Error('Missing updated action group')\n expect(readProp(updated, 'style')).toEqual(expect.arrayContaining([expect.objectContaining({ flexDirection: 'column', flexWrap: 'nowrap' })]))\n })\n\n test('keeps actions outside the scrolling body and opts into keyboard avoidance', async () => {\n const root = await renderNative(\n <LumenSheet\n actions={<LumenButton>Save</LumenButton>}\n avoidKeyboard\n keyboardVerticalOffset={24}\n onDismiss={vi.fn()}\n safeAreaInsets={{ bottom: 34 }}\n scrollable\n title=\"Edit record\"\n visible\n >\n <LumenTextField accessibilityLabel=\"Name\" />\n </LumenSheet>\n )\n const scroll = root.container.queryAll(instance => instance.type === 'ScrollView')[0]\n const keyboard = root.container.queryAll(instance => instance.type === 'KeyboardAvoidingView')[0]\n if (!scroll || !keyboard) throw new Error('Missing keyboard or scrolling surface')\n expect(readProp(keyboard, 'enabled')).toBe(true)\n expect(readProp(keyboard, 'behavior')).toBe('padding')\n expect(readProp(keyboard, 'keyboardVerticalOffset')).toBe(24)\n expect(readProp(scroll, 'keyboardShouldPersistTaps')).toBe('handled')\n expect(scroll.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')).toHaveLength(0)\n expect(findByAccessibilityRole(root, 'header')).toBeDefined()\n expect(root.container.queryAll(instance => {\n const style = readProp(instance, 'style')\n return typeof style === 'object' && style !== null && 'paddingBottom' in style && style.paddingBottom === 34\n })).toHaveLength(1)\n })\n\n test('blocks platform and backdrop dismissal until the application unlocks it', async () => {\n const dismiss = vi.fn()\n const root = await renderNative(\n <LumenSheet dismissible={false} onDismiss={dismiss} visible><LumenText>Saving</LumenText></LumenSheet>\n )\n const modal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n const backdrop = root.container.queryAll(instance => instance.type === 'Pressable')[0]\n if (!modal || !backdrop) throw new Error('Missing modal or backdrop')\n callAction(readProp(modal, 'onRequestClose'), 'Missing platform dismiss')\n callAction(readProp(backdrop, 'onPress'), 'Missing backdrop dismiss')\n expect(dismiss).not.toHaveBeenCalled()\n expect(readProp(backdrop, 'disabled')).toBe(true)\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\"><LumenSheet onDismiss={dismiss} visible><LumenText>Saved</LumenText></LumenSheet></LumenProvider>)\n await Promise.resolve()\n })\n const updatedModal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n if (!updatedModal) throw new Error('Missing updated modal')\n callAction(readProp(updatedModal, 'onRequestClose'), 'Missing platform dismiss')\n expect(dismiss).toHaveBeenCalledOnce()\n })\n\n test('names sheets and hides backdrop targets from keyboard and accessibility traversal', async () => {\n const root = await renderNative(<LumenSheet onDismiss={vi.fn()} title=\"Edit record\" visible><LumenText>Details</LumenText></LumenSheet>)\n const modal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n const backdrop = root.container.queryAll(instance => instance.type === 'Pressable')[0]\n if (!modal || !backdrop) throw new Error('Missing modal or backdrop')\n expect(readProp(modal, 'accessibilityLabel')).toBe('Edit record')\n expect(readProp(backdrop, 'tabIndex')).toBe(-1)\n expect(readProp(backdrop, 'aria-hidden')).toBe(true)\n })\n\n test('honors the native reduced-motion setting', async () => {\n nativeMotion.enabled = true\n const root = await renderNative(<LumenSheet onDismiss={vi.fn()} visible><LumenText>Details</LumenText></LumenSheet>)\n const modal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n if (!modal) throw new Error('Missing modal')\n expect(readProp(modal, 'animationType')).toBe('none')\n })\n\n test('honors reduced motion in confirmation dialogs and menus', async () => {\n nativeMotion.enabled = true\n const root = await renderNative(\n <>\n <LumenAlertDialog confirmLabel=\"Save\" onConfirm={vi.fn()} onDismiss={vi.fn()} title=\"Save changes\" visible />\n <LumenMenu accessibilityLabel=\"Actions\" items={[]} trigger={<LumenText>Open menu</LumenText>} />\n </>\n )\n const modals = root.container.queryAll(instance => instance.type === 'Modal')\n\n expect(modals).toHaveLength(2)\n expect(modals.map(modal => readProp(modal, 'animationType'))).toEqual(['none', 'none'])\n })\n\n test('uses a centered adaptive dialog on wide windows and Android keyboard behavior', async () => {\n nativeWindow.width = 1024\n nativePlatform.OS = 'android'\n const root = await renderNative(\n <LumenSheet avoidKeyboard onDismiss={vi.fn()} presentation=\"adaptive\" visible><LumenText>Details</LumenText></LumenSheet>\n )\n const modal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n const keyboard = root.container.queryAll(instance => instance.type === 'KeyboardAvoidingView')[0]\n if (!modal || !keyboard) throw new Error('Missing modal or keyboard surface')\n expect(readProp(modal, 'animationType')).toBe('fade')\n expect(readProp(keyboard, 'behavior')).toBe('height')\n expect(readProp(keyboard, 'style')).toMatchObject({ alignItems: 'center', justifyContent: 'center' })\n })\n})\n\ndescribe('Static graphic icon accessibility', () => {\n const Graphic = ({ size, strokeWidth }: LumenIconGraphicProps): ReactElement => (\n <LumenText>{`Graphic ${String(size)} ${String(strokeWidth)}`}</LumenText>\n )\n\n test('names informative graphics and hides decorative graphics', async () => {\n const root = await renderNative(\n <>\n <GraphicIcon icon={Graphic} label=\"Search records\" size=\"lg\" strokeWidth={3} />\n <GraphicIcon icon={Graphic} decorative label=\"Decorative search\" />\n </>\n )\n const informative = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'image' && readProp(instance, 'accessible') === true)\n expect(informative).toHaveLength(1)\n const named = informative[0]\n if (!named) throw new Error('Missing informative graphic')\n expect(readProp(named, 'accessibilityLabel')).toBe('Search records')\n expect(readProp(named, 'importantForAccessibility')).toBe('yes')\n expect(root.container.queryAll(instance => readProp(instance, 'children') === 'Graphic 24 3').length).toBeGreaterThan(0)\n const hidden = root.container.queryAll(instance => readProp(instance, 'importantForAccessibility') === 'no' && readProp(instance, 'accessible') === false)\n expect(hidden).toHaveLength(1)\n const decorative = hidden[0]\n if (!decorative) throw new Error('Missing decorative graphic')\n expect(readProp(decorative, 'accessibilityLabel')).toBeUndefined()\n expect(readProp(decorative, 'accessibilityElementsHidden')).toBe(true)\n })\n\n test('preserves named and custom icons through the root entrypoint', async () => {\n const root = await renderNative(\n <>\n <LumenIcon name=\"search\" label=\"Named search\" />\n <LumenIcon icon={Graphic} label=\"Custom search\" />\n <LumenIconButton name=\"search\" label=\"Named search action\" disabled />\n <LumenIconButton icon={Graphic} label=\"Custom search action\" disabled />\n </>\n )\n const icons = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'image')\n expect(icons.map(icon => readProp(icon, 'accessibilityLabel'))).toEqual(['Named search', 'Custom search'])\n const buttons = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')\n expect(buttons.map(button => readProp(button, 'accessibilityLabel'))).toEqual(['Named search action', 'Custom search action'])\n expect(buttons.map(button => readProp(button, 'accessibilityState'))).toEqual([{ disabled: true }, { disabled: true }])\n })\n\n test('canonical per-icon imports match named artwork and preserve accessible labels', async () => {\n const root = await renderNative(\n <>\n <LumenIcon name=\"search\" label=\"Named search\" size=\"lg\" strokeWidth={3} />\n <GraphicIcon icon={LumenSearchIconGraphic} label=\"Static search\" size=\"lg\" strokeWidth={3} />\n <LumenIcon name=\"brand:github\" decorative />\n <GraphicIcon icon={LumenBrandGithubIconGraphic} decorative />\n </>\n )\n const graphics = root.container.queryAll(instance => instance.type === 'Svg')\n expect(graphics).toHaveLength(4)\n const geometry = (graphic: TestInstance | undefined): unknown => {\n if (!graphic) throw new Error('Missing canonical SVG')\n return {\n props: Object.fromEntries(Object.entries(graphic.props).filter(([name]) => name !== 'children')),\n children: graphic.children.filter(child => typeof child !== 'string').map(child => ({ type: child.type, props: child.props }))\n }\n }\n expect(geometry(graphics[0])).toEqual(geometry(graphics[1]))\n expect(geometry(graphics[2])).toEqual(geometry(graphics[3]))\n const icons = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'image')\n expect(icons.map(icon => readProp(icon, 'accessibilityLabel'))).toEqual(['Named search', 'Static search'])\n })\n\n test('keeps graphic buttons named and announces disabled state', async () => {\n const root = await renderNative(<GraphicIconButton icon={Graphic} label=\"Search records\" disabled />)\n const button = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')[0]\n if (!button) throw new Error('Missing graphic button')\n expect(readProp(button, 'accessibilityLabel')).toBe('Search records')\n expect(readProp(button, 'accessibilityState')).toEqual({ disabled: true })\n expect(readProp(button, 'disabled')).toBe(true)\n })\n})\n\nconst callInputAction = (value: unknown, proposal: string): void => {\n if (typeof value !== 'function') throw new Error('Missing native input callback')\n Reflect.apply(value, undefined, [proposal])\n}\n\nconst runNativeAction = async (action: () => void): Promise<void> => {\n await act(async () => {\n action()\n\n await Promise.resolve()\n })\n}\n\ndescribe('advanced native inputs', () => {\n test('secure entry hides on blur and after disabling and re-enabling', async () => {\n const props = { label: 'Password', onValueChange: vi.fn(), value: 'synthetic-fixture' }\n const root = await renderNative(<LumenPasswordField {...props} />)\n expect(readProp(findByAccessibilityLabel(root, 'Password'), 'secureTextEntry')).toBe(true)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Show password'), 'onPress'), 'Missing reveal')\n })\n expect(readProp(findByAccessibilityLabel(root, 'Password'), 'secureTextEntry')).toBe(false)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Password'), 'onBlur'), 'Missing blur')\n })\n expect(readProp(findByAccessibilityLabel(root, 'Password'), 'secureTextEntry')).toBe(true)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Show password'), 'onPress'), 'Missing reveal')\n })\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenPasswordField {...props} enabled={false} /></LumenProvider>)\n })\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenPasswordField {...props} /></LumenProvider>)\n })\n expect(readProp(findByAccessibilityLabel(root, 'Password'), 'secureTextEntry')).toBe(true)\n })\n\n test('OTP preserves native autofill, normalizes paste and emits completion only for changed full codes', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const onComplete = vi.fn<(value: string) => void>()\n const root = await renderNative(<LumenInputOTP label=\"Code\" value=\"123\" onValueChange={onValueChange} onComplete={onComplete} />)\n const input = findByAccessibilityLabel(root, 'Code')\n expect(readProp(input, 'textContentType')).toBe('oneTimeCode')\n await runNativeAction(() => {\n callInputAction(readProp(input, 'onChangeText'), '١٢٣-456')\n })\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith('123456')\n expect(onComplete).toHaveBeenCalledExactlyOnceWith('123456')\n await runNativeAction(() => {\n callInputAction(readProp(input, 'onChangeText'), 'not a code')\n })\n expect(onValueChange).toHaveBeenCalledTimes(1)\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenInputOTP label=\"Code\" value=\"123456\" onValueChange={onValueChange} onComplete={onComplete} readOnly /></LumenProvider>)\n })\n await runNativeAction(() => {\n callInputAction(readProp(findByAccessibilityLabel(root, 'Code'), 'onChangeText'), '654321')\n })\n expect(onValueChange).toHaveBeenCalledTimes(1)\n })\n\n test('number entry retains raw drafts and steps exact localized values', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const root = await renderNative(<LumenNumberField label=\"Amount\" value=\"0,1\" locale=\"es-CO\" step=\"0.2\" onValueChange={onValueChange} />)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Increase value'), 'onPress'), 'Missing increase')\n })\n expect(onValueChange).toHaveBeenLastCalledWith('0,3')\n await runNativeAction(() => {\n callInputAction(readProp(findByAccessibilityLabel(root, 'Amount'), 'onChangeText'), '12,')\n })\n expect(onValueChange).toHaveBeenLastCalledWith('12,')\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenNumberField label=\"Amount\" value=\"12,\" locale=\"es-CO\" onValueChange={onValueChange} /></LumenProvider>)\n })\n expect(readProp(findByAccessibilityLabel(root, 'Increase value'), 'disabled')).toBe(true)\n expect(readProp(findByAccessibilityLabel(root, 'Amount'), 'aria-invalid')).toBe(true)\n })\n\n test('autocomplete publishes query before selection and closes its results', async () => {\n const calls: string[] = []\n const root = await renderNative(\n <LumenAutocomplete\n label=\"City\"\n query=\"Bo\"\n options={[{ label: 'Bogotá', value: 'bogota' }]}\n onQueryChange={query => {\n calls.push(query)\n }}\n onValueChange={value => {\n calls.push(value)\n }}\n />\n )\n await runNativeAction(() => {\n callInputAction(readProp(findByAccessibilityLabel(root, 'City'), 'onChangeText'), 'Bog')\n })\n calls.length = 0\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Bogotá'), 'onPress'), 'Missing result')\n })\n expect(calls).toEqual(['Bogotá', 'bogota'])\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Close results')).toHaveLength(0)\n })\n\n test('autocomplete loading hides results and disabled state dismisses selection', async () => {\n const props = { label: 'City', onQueryChange: vi.fn(), onValueChange: vi.fn(), options: [{ label: 'Bogotá', value: 'bogota' }], query: '' }\n const root = await renderNative(<LumenAutocomplete {...props} loading />)\n await runNativeAction(() => {\n callInputAction(readProp(findByAccessibilityLabel(root, 'City'), 'onChangeText'), 'Bo')\n })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Bogotá')).toHaveLength(0)\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenAutocomplete {...props} enabled={false} /></LumenProvider>)\n })\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenAutocomplete {...props} /></LumenProvider>)\n })\n expect(readProp(findByAccessibilityLabel(root, 'City'), 'accessibilityState')).toMatchObject({ expanded: false })\n })\n\n test.each(['ios', 'android', 'web'])('time formatting tolerates malformed locales on %s', async platform => {\n nativePlatform.OS = platform\n for (const locale of ['not_a_locale', 'en--US', '💥']) {\n const props = { label: 'Time', value: { hour: 9, minute: 30 }, locale, onValueChange: vi.fn() }\n const root = await renderNative(<LumenTimeField {...props} is24Hour />)\n\n expect(findByAccessibilityLabel(root, 'Time, 09:30')).toBeDefined()\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenTimeField {...props} is24Hour={false} /></LumenProvider>)\n })\n expect(findByAccessibilityLabel(root, 'Time, 9:30 AM')).toBeDefined()\n }\n })\n\n test('time selection requires confirmation and cancellation preserves the value', async () => {\n nativePlatform.OS = 'ios'\n const onValueChange = vi.fn()\n const root = await renderNative(\n <LumenTimeField\n label=\"Time\"\n value={{ hour: 9, minute: 30 }}\n locale=\"en-US\"\n is24Hour\n minTime={{ hour: 9, minute: 0 }}\n maxTime={{ hour: 17, minute: 0 }}\n onValueChange={onValueChange}\n />\n )\n const open = async (): Promise<void> => {\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, 09:30'), 'onPress'), 'Missing time trigger')\n })\n }\n await open()\n await runNativeAction(() => {\n const picker = findHostComponent(root, 'NativeDatePicker')\n const change = readProp(picker, 'onChange')\n if (typeof change !== 'function') throw new Error('Missing time change')\n Reflect.apply(change, undefined, [{ type: 'set' }, new Date(2000, 0, 1, 10, 45)])\n })\n expect(onValueChange).not.toHaveBeenCalled()\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Cancel'), 'onPress'), 'Missing cancel')\n })\n expect(onValueChange).not.toHaveBeenCalled()\n await open()\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Confirm'), 'onPress'), 'Missing confirm')\n })\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith({ hour: 9, minute: 30 })\n })\n\n test.each(['ios', 'web'])('time sheet resets controlled changes without discarding equivalent inputs on %s', async platform => {\n nativePlatform.OS = platform\n const updates = [\n { value: { hour: 11, minute: 15 }, expected: { hour: 11, minute: 15 } },\n { minTime: { hour: 10, minute: 0 }, expected: { hour: 10, minute: 0 } },\n { maxTime: { hour: 9, minute: 15 }, expected: { hour: 9, minute: 0 } }\n ]\n for (const { expected, ...update } of updates) {\n const onValueChange = vi.fn()\n const props: LumenTimeFieldProps = { label: 'Time',\n value: { hour: 9, minute: 30 },\n locale: 'en-US',\n is24Hour: true,\n minTime: { hour: 9, minute: 0 },\n maxTime: { hour: 17, minute: 0 },\n onValueChange }\n const root = await renderNative(<LumenTimeField {...props} />)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, 09:30'), 'onPress'), 'Missing time trigger')\n })\n await runNativeAction(() => {\n const picker = findHostComponent(root, platform === 'web' ? 'input' : 'NativeDatePicker')\n const change = readProp(picker, 'onChange')\n if (typeof change !== 'function') throw new Error('Missing time change')\n Reflect.apply(change, undefined, platform === 'web' ? [{ currentTarget: { value: '10:45' } }] : [{ type: 'set' }, new Date(2000, 0, 1, 10, 45)])\n })\n await runNativeAction(() => {\n root.render(\n <LumenProvider>\n <LumenTimeField\n {...props}\n value={{ hour: 9, minute: 30 }}\n minTime={{ hour: 9, minute: 0 }}\n maxTime={{ hour: 17, minute: 0 }}\n />\n </LumenProvider>\n )\n })\n expect(readProp(findHostComponent(root, 'Modal'), 'visible')).toBe(true)\n const draft = readProp(findHostComponent(root, platform === 'web' ? 'input' : 'NativeDatePicker'), 'value')\n expect(draft).toEqual(platform === 'web' ? '10:45' : new Date(2000, 0, 1, 10, 45))\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenTimeField {...props} {...update} /></LumenProvider>)\n })\n expect(readProp(findHostComponent(root, 'Modal'), 'visible')).toBe(false)\n expect(onValueChange).not.toHaveBeenCalled()\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, `Time, ${update.value ? '11:15' : '09:30'}`), 'onPress'), 'Missing updated time trigger')\n })\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Confirm'), 'onPress'), 'Missing time confirm')\n })\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith(expected)\n }\n })\n\n test('Android time rejects out-of-range selection and stale callbacks after disabling', async () => {\n nativePlatform.OS = 'android'\n const onValueChange = vi.fn()\n const props = { label: 'Time', locale: 'en-US', onValueChange, value: null }\n const root = await renderNative(<LumenTimeField {...props} minTime={{ hour: 9, minute: 0 }} />)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, Choose a time'), 'onPress'), 'Missing time trigger')\n })\n const options = vi.mocked(DateTimePickerAndroid.open).mock.calls.at(-1)?.[0]\n if (!options?.onValueChange) throw new Error('Missing Android time handler')\n const change = options.onValueChange\n await runNativeAction(() => {\n change({ nativeEvent: { timestamp: 0, utcOffset: 0 } }, new Date(2000, 0, 1, 8, 0))\n })\n expect(onValueChange).not.toHaveBeenCalled()\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, Choose a time'), 'onPress'), 'Missing time trigger')\n })\n const current = vi.mocked(DateTimePickerAndroid.open).mock.calls.at(-1)?.[0]\n if (!current?.onValueChange) throw new Error('Missing Android time handler')\n const stale = current.onValueChange\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenTimeField {...props} enabled={false} /></LumenProvider>)\n })\n expect(DateTimePickerAndroid.dismiss).toHaveBeenCalledWith('time')\n await runNativeAction(() => {\n stale({ nativeEvent: { timestamp: 0, utcOffset: 0 } }, new Date(2000, 0, 1, 10, 0))\n })\n expect(onValueChange).not.toHaveBeenCalled()\n })\n\n test('Android time validates current bounds and uses the current callback while the dialog is open', async () => {\n nativePlatform.OS = 'android'\n const openingCallback = vi.fn()\n const currentCallback = vi.fn()\n const props = { label: 'Time', locale: 'en-US', value: null }\n const root = await renderNative(\n <LumenTimeField {...props} minTime={{ hour: 9, minute: 0 }} onValueChange={openingCallback} />\n )\n const chooseAfterUpdate = async (hour: number): Promise<void> => {\n await runNativeAction(() => {\n root.render(\n <LumenProvider>\n <LumenTimeField {...props} minTime={{ hour: 9, minute: 0 }} onValueChange={openingCallback} />\n </LumenProvider>\n )\n })\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, Choose a time'), 'onPress'), 'Missing time trigger')\n })\n const options = vi.mocked(DateTimePickerAndroid.open).mock.calls.at(-1)?.[0]\n if (!options?.onValueChange) throw new Error('Missing Android time handler')\n const change = options.onValueChange\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenTimeField {...props} minTime={{ hour: 12, minute: 0 }} maxTime={{ hour: 17, minute: 0 }} onValueChange={currentCallback} rangeErrorLabel=\"Choose an afternoon time\" /></LumenProvider>)\n })\n await runNativeAction(() => {\n change({ nativeEvent: { timestamp: 0, utcOffset: 0 } }, new Date(2000, 0, 1, hour, 30))\n })\n }\n await chooseAfterUpdate(10)\n expect(openingCallback).not.toHaveBeenCalled()\n expect(currentCallback).not.toHaveBeenCalled()\n expect(root.container.queryAll(instance => readProp(instance, 'children') === 'Choose an afternoon time').length).toBeGreaterThan(0)\n await chooseAfterUpdate(18)\n expect(currentCallback).not.toHaveBeenCalled()\n await chooseAfterUpdate(13)\n expect(openingCallback).not.toHaveBeenCalled()\n expect(currentCallback).toHaveBeenCalledExactlyOnceWith({ hour: 13, minute: 30 })\n })\n\n test.each(['en--US', 'invalid_tag', '💥'])('image comparison falls back for malformed locale %s', async locale => {\n const root = await renderNative(<LumenImageComparison label=\"Comparison\" before={{ uri: 'fixture:before' }} after={{ uri: 'fixture:after' }} value={0.25} locale={locale} onValueChange={() => {}} />)\n const control = findByAccessibilityRole(root, 'adjustable')\n expect(readProp(control, 'accessibilityValue')).toMatchObject({ text: 'After 25%' })\n })\n\n test.each(['side-by-side', 'before', 'after'] as const)('image comparison %s mode presents labels without an adjustable control', async mode => {\n const root = await renderNative(<LumenImageComparison label=\"Comparison\" before={{ uri: 'fixture:before' }} after={{ uri: 'fixture:after' }} value={0.25} mode={mode} onValueChange={() => {}} />)\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'adjustable')).toHaveLength(0)\n expect(root.container.queryAll(instance => readProp(instance, 'children') === 'Before').length > 0).toBe(mode !== 'after')\n expect(root.container.queryAll(instance => readProp(instance, 'children') === 'After').length > 0).toBe(mode !== 'before')\n })\n\n test('image comparison exposes one adjustable control and localized after percentage', async () => {\n const root = await renderNative(<LumenImageComparison label=\"Comparison\" before={{ uri: 'fixture:before' }} after={{ uri: 'fixture:after' }} value={0.25} locale=\"es-CO\" onValueChange={() => {}} />)\n const control = findByAccessibilityRole(root, 'adjustable')\n expect(readProp(control, 'accessibilityLabel')).toBe('Comparison')\n expect(readProp(control, 'accessibilityValue')).toMatchObject({ text: 'After 25%' })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'image')).toHaveLength(0)\n })\n})\n\ndescribe('native chart data inspection', () => {\n test('renders zero as neutral and missing measurements with a separate marker and exact fallback', async () => {\n const root = await renderNative(\n <LumenHeatmap\n label=\"Activity\"\n colorScale=\"diverging\"\n data={[\n { x: 'Mon', y: 'AM', value: -2 },\n { x: 'Tue', y: 'AM', value: 0 },\n { x: 'Wed', y: 'AM', value: null },\n { x: 'Mon', y: 'AM', value: 40 }\n ]}\n />\n )\n const neutral = root.container.queryAll(instance => instance.type === 'Rect' && readProp(instance, 'fillOpacity') === 0)\n expect(neutral).toHaveLength(1)\n const missing = root.container.queryAll(instance => instance.type === 'Text' && readProp(instance, 'children') === '× Not available')\n expect(missing).toHaveLength(1)\n await act(async () => {\n callAction(readProp(findByAccessibilityRole(root, 'button'), 'onPress'), 'Missing chart disclosure')\n await Promise.resolve()\n })\n const rows = root.container.queryAll(instance => instance.type === 'Text').map(instance => readProp(instance, 'children'))\n expect(rows).toContain('Wed, AM: Not available')\n expect(rows).toContain('Tue, AM: 0')\n expect(rows).toContain('Mon, AM: -2')\n expect(rows).not.toContain('Mon, AM: 40')\n })\n\n test('opens and closes exact source values using a labeled disclosure', async () => {\n const root = await renderNative(\n <LumenWaterfallChart\n label=\"Revenue\"\n data={[\n { id: 'opening', label: 'Opening', kind: 'total', value: 100 },\n { id: 'cost', label: 'Costs', value: -25 }\n ]}\n />\n )\n const button = findByAccessibilityRole(root, 'button')\n expect(readProp(button, 'accessibilityState')).toMatchObject({ expanded: false })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'list')).toHaveLength(0)\n await act(async () => {\n callAction(readProp(button, 'onPress'), 'Missing chart disclosure')\n await Promise.resolve()\n })\n expect(readProp(findByAccessibilityRole(root, 'button'), 'accessibilityState')).toMatchObject({ expanded: true })\n const rows = root.container.queryAll(instance => instance.type === 'Text').map(instance => readProp(instance, 'children'))\n expect(rows).toContain('Costs, Start: 100, End: 75, Value: -25')\n await act(async () => {\n callAction(readProp(findByAccessibilityRole(root, 'button'), 'onPress'), 'Missing chart disclosure')\n await Promise.resolve()\n })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'list')).toHaveLength(0)\n })\n\n test('shows a localized invalid-data state rather than a partial histogram', async () => {\n const root = await renderNative(\n <LumenHistogram\n label=\"Distribution\"\n labels={{ invalidData: 'Check the bins' }}\n data={[\n { start: 0, end: 10, count: 5 }, { start: 5, end: 15, count: 3 }\n ]}\n />\n )\n expect(readProp(findByAccessibilityRole(root, 'alert'), 'children')).toBe('Check the bins')\n expect(root.container.queryAll(instance => instance.type === 'Svg')).toHaveLength(0)\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')).toHaveLength(0)\n })\n})\n\ndescribe('controlled multiple selection', () => {\n test('removal retains unavailable values and never mutates the controlled set', async () => {\n const values = new Set(['one', 'missing'])\n const onValuesChange = vi.fn<(values: ReadonlySet<string>) => void>()\n const root = await renderNative(<LumenMultiSelect label=\"Teams\" values={values} onValuesChange={onValuesChange} query=\"\" onQueryChange={() => undefined} options={[{ label: 'One', value: 'one' }]} />)\n const remove = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Remove One').find(instance => typeof readProp(instance, 'onPress') === 'function')\n if (!remove) throw new Error('Missing selected removal')\n const handler = readProp(remove, 'onPress')\n if (typeof handler !== 'function') throw new Error('Missing removal handler')\n await act(async () => {\n Reflect.apply(handler, undefined, [])\n await Promise.resolve()\n })\n expect(onValuesChange).toHaveBeenLastCalledWith(new Set(['missing']))\n expect(values).toEqual(new Set(['one', 'missing']))\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Remove missing').length).toBeGreaterThan(0)\n })\n\n test.each([{ readOnly: true }, { enabled: false }])('blocked controls reject removal: %o', async blocked => {\n const onValuesChange = vi.fn<(values: ReadonlySet<string>) => void>()\n const root = await renderNative(<LumenMultiSelect {...blocked} label=\"Teams\" values={new Set(['one'])} onValuesChange={onValuesChange} query=\"\" onQueryChange={() => undefined} options={[{ label: 'One', value: 'one' }]} />)\n const remove = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Remove One').find(instance => typeof readProp(instance, 'onPress') === 'function')\n if (!remove) throw new Error('Missing removal')\n const handler = readProp(remove, 'onPress')\n if (typeof handler !== 'function') throw new Error('Missing removal handler')\n await act(async () => {\n Reflect.apply(handler, undefined, [])\n await Promise.resolve()\n })\n expect(onValuesChange).not.toHaveBeenCalled()\n expect(readProp(remove, 'disabled')).toBe(true)\n })\n\n test('disabled selected results cannot be removed', async () => {\n const root = await renderNative(\n <LumenMultiSelect\n label=\"Teams\"\n values={new Set(['one'])}\n onValuesChange={() => {\n throw new Error('Disabled callback')\n }}\n query=\"\"\n onQueryChange={() => undefined}\n options={[{ label: 'One', value: 'one', disabled: true }]}\n />\n )\n const remove = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Remove One').find(instance => typeof readProp(instance, 'onPress') === 'function')\n if (!remove) throw new Error('Missing removal')\n expect(readProp(remove, 'disabled')).toBe(true)\n })\n})\n\ndescribe('multiple selection results', () => {\n test('checkbox intent retains values outside the result list', async () => {\n const onValuesChange = vi.fn<(values: ReadonlySet<string>) => void>()\n const values = new Set(['missing'])\n const root = await renderNative(<LumenMultiSelect label=\"Teams\" values={values} onValuesChange={onValuesChange} query=\"\" onQueryChange={() => undefined} options={[{ label: 'One', value: 'one' }]} />)\n const option = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'checkbox').find(instance => typeof readProp(instance, 'onPress') === 'function')\n if (!option) throw new Error('Missing option')\n const handler = readProp(option, 'onPress')\n if (typeof handler !== 'function') throw new Error('Missing checkbox handler')\n await act(async () => {\n Reflect.apply(handler, undefined, [])\n await Promise.resolve()\n })\n expect(onValuesChange).toHaveBeenLastCalledWith(new Set(['missing', 'one']))\n expect(values).toEqual(new Set(['missing']))\n })\n\n test.each([{ loading: true }, { resultsErrorMessage: 'Search failed' }])('stale results are unavailable: %o', async state => {\n const root = await renderNative(<LumenMultiSelect {...state} label=\"Teams\" values={new Set<string>()} onValuesChange={() => undefined} query=\"\" onQueryChange={() => undefined} options={[{ label: 'One', value: 'one' }]} />)\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'checkbox')).toHaveLength(0)\n })\n})\n\ndescribe('multiple selection option identity', () => {\n test.each([\n { options: [{ label: 'One', value: '' }] },\n { options: [{ label: ' ', value: 'one' }] },\n { options: [{ label: 'One', value: 'one' }, { label: 'Duplicate', value: 'one' }] }\n ])('rejects invalid option identities: %o', async ({ options }) => {\n await expect(renderNative(<LumenMultiSelect label=\"Teams\" values={new Set<string>()} onValuesChange={() => undefined} query=\"\" onQueryChange={() => undefined} options={options} />)).rejects.toThrow('MultiSelect options require')\n })\n})\n\ntest('multiple selection forwards application safe-area insets to its modal', async () => {\n const root = await renderNative(<LumenMultiSelect label=\"Teams\" values={new Set<string>()} onValuesChange={() => undefined} query=\"\" onQueryChange={() => undefined} options={[]} safeAreaInsets={{ bottom: 96 }} />)\n const panels = root.container.queryAll(instance => {\n const style = readProp(instance, 'style')\n\n return typeof style === 'object' && style !== null && 'paddingBottom' in style && style.paddingBottom === 96\n })\n expect(panels.length).toBeGreaterThan(0)\n})\n\ndescribe('rating controlled selection', () => {\n test('clamps nonfinite values and limits the rendered option count', () => {\n expect(resolveLumenRating(Number.NaN, Number.POSITIVE_INFINITY)).toEqual({ max: 5, value: 0 })\n expect(resolveLumenRating(-5, 0)).toEqual({ max: 1, value: 0 })\n expect(resolveLumenRating(999, 999)).toEqual({ max: 100, value: 100 })\n expect(resolveLumenRating(3.9)).toEqual({ max: 5, value: 3 })\n })\n\n test('reports selection without mutating controlled value and blocks read-only edits', async () => {\n const onValueChange = vi.fn<(value: number) => void>()\n const root = await renderNative(\n <LumenRating\n label=\"Score\"\n value={2}\n onValueChange={onValueChange}\n formatOption={(value, max) => `${value} of ${max}`}\n />\n )\n const option = findByAccessibilityLabel(root, '4 of 5')\n act(() => {\n callAction(readProp(option, 'onPress'), 'Missing rating action')\n })\n expect(onValueChange).toHaveBeenCalledWith(4)\n expect(readProp(option, 'accessibilityState')).toEqual({ checked: false, disabled: false, selected: false })\n act(() => {\n root.render(\n <LumenProvider>\n <LumenRating\n label=\"Score\"\n value={2}\n readOnly\n onValueChange={onValueChange}\n />\n </LumenProvider>\n )\n })\n const locked = findByAccessibilityLabel(root, '4 / 5')\n act(() => {\n callAction(readProp(locked, 'onPress'), 'Missing rating action')\n })\n expect(onValueChange).toHaveBeenCalledTimes(1)\n expect(readProp(locked, 'disabled')).toBe(true)\n })\n})\n\ndescribe('progress and history contracts', () => {\n test('normalizes step progress without changing host state', () => {\n expect(resolveLumenStepState(0, -1, 3)).toBe('current')\n expect(resolveLumenStepState(0, Number.NaN, 3)).toBe('current')\n expect(resolveLumenStepState(1, 1.9, 3)).toBe('current')\n expect(resolveLumenStepState(2, 99, 3)).toBe('complete')\n expect(resolveLumenStepState(2, 1, 3)).toBe('upcoming')\n })\n\n test('presents localized controlled progression and accepts an empty workflow', async () => {\n const steps = [{ id: 'prepare', title: 'Prepare' }, { id: 'review', title: 'Review', description: 'Confirm details' }]\n const root = await renderNative(\n <LumenStepper\n label=\"Workflow\"\n steps={steps}\n currentStep={1}\n formatState={state => `Localized ${state}`}\n />\n )\n const review = findByAccessibilityLabel(root, '2 / 2, Review, Confirm details')\n expect(readProp(review, 'accessibilityValue')).toEqual({ text: 'Localized current' })\n expect(readProp(review, 'accessibilityState')).toEqual({ selected: true })\n act(() => {\n root.render(<LumenProvider><LumenStepper label=\"Workflow\" steps={steps} currentStep={2} /></LumenProvider>)\n })\n expect(readProp(findByAccessibilityLabel(root, '2 / 2, Review, Confirm details'), 'accessibilityValue'))\n .toEqual({ text: 'Complete' })\n act(() => {\n root.render(<LumenProvider><LumenStepper label=\"Workflow\" steps={[]} currentStep={0} /></LumenProvider>)\n })\n expect(root.container.queryAll(instance => readProp(instance, 'accessible') === true)).toHaveLength(0)\n })\n\n test('keeps timeline content actions accessible and host owned', async () => {\n const onPress = vi.fn()\n const root = await renderNative(\n <LumenTimeline label=\"History\">\n <LumenTimelineItem>\n <LumenText>Approved</LumenText>\n <LumenButton onPress={onPress}>View approval</LumenButton>\n </LumenTimelineItem>\n </LumenTimeline>\n )\n const button = findByAccessibilityRole(root, 'button')\n act(() => {\n callAction(readProp(button, 'onPress'), 'Missing timeline action')\n })\n expect(onPress).toHaveBeenCalledOnce()\n })\n\n test('breadcrumb reports navigation IDs and cannot navigate current or disabled locations', async () => {\n const onNavigate = vi.fn<(id: string) => void>()\n const items = [{ id: 'home', label: 'Home' },\n { id: 'private', label: 'Private', disabled: true },\n { id: 'current', label: 'Details' }]\n const root = await renderNative(\n <LumenBreadcrumb\n label=\"Location\"\n items={items}\n onNavigate={onNavigate}\n currentLabel=\"Here\"\n />\n )\n const home = findByAccessibilityLabel(root, 'Home')\n act(() => {\n callAction(readProp(home, 'onPress'), 'Missing breadcrumb action')\n })\n expect(onNavigate).toHaveBeenCalledExactlyOnceWith('home')\n const locked = findByAccessibilityLabel(root, 'Private')\n act(() => {\n callAction(readProp(locked, 'onPress'), 'Missing disabled breadcrumb action')\n })\n expect(readProp(findByAccessibilityLabel(root, 'Details, Here'), 'onPress')).toBeUndefined()\n expect(readProp(findByAccessibilityLabel(root, 'Details, Here'), 'aria-current')).toBe('page')\n })\n})\n\ndescribe('data table controlled interactions', () => {\n const columns = [{ key: 'amount', label: 'Amount', sortable: true }]\n const rows: readonly LumenTableRow[] = [\n { id: 'one', label: 'One', cells: { amount: { text: '20', sortValue: 20 } } },\n { id: 'two', label: 'Two', cells: { amount: { text: '2', sortValue: 2 } }, disabled: true }\n ]\n test('emits manual sort requests without reordering and retains hidden selections', async () => {\n const onSortChange = vi.fn()\n const onSelectionChange = vi.fn()\n const root = await renderNative(\n <LumenDataTable\n label=\"Records\"\n columns={columns}\n rows={rows}\n onSortChange={onSortChange}\n onSelectionChange={onSelectionChange}\n selectedIds={new Set(['hidden'])}\n />\n )\n act(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Amount'), 'onPress'), 'Missing sort action')\n })\n expect(onSortChange).toHaveBeenCalledWith({ key: 'amount', direction: 'ascending' })\n act(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Select visible'), 'onPress'), 'Missing bulk selection')\n })\n expect(onSelectionChange).toHaveBeenCalledWith(new Set(['hidden', 'one']))\n expect(root.container.queryAll(instance => readProp(instance, 'accessible') === true)\n .map(instance => readProp(instance, 'accessibilityLabel'))).toEqual(['Amount, 20', 'Amount, 2'])\n })\n test('loading and error states hide stale controls; retry remains host owned', async () => {\n const onRetry = vi.fn()\n const root = await renderNative(\n <LumenDataTable\n label=\"Records\"\n columns={columns}\n rows={rows}\n loading\n loadingLabel=\"Fetching\"\n onSortChange={vi.fn()}\n />\n )\n expect(readProp(findByAccessibilityRole(root, 'progressbar'), 'children')).toBe('Fetching')\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')).toHaveLength(0)\n act(() => {\n root.render(\n <LumenProvider>\n <LumenDataTable\n label=\"Records\"\n columns={columns}\n rows={rows}\n error=\"Try again\"\n onRetry={onRetry}\n retryLabel=\"Reload\"\n />\n </LumenProvider>\n )\n })\n act(() => {\n callAction(readProp(findByAccessibilityRole(root, 'button'), 'onPress'), 'Missing retry')\n })\n expect(onRetry).toHaveBeenCalledOnce()\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'checkbox')).toHaveLength(0)\n })\n test('read-only tables block both sort and selection callbacks', async () => {\n const onSortChange = vi.fn()\n const onSelectionChange = vi.fn()\n const root = await renderNative(\n <LumenDataTable\n label=\"Records\"\n columns={columns}\n rows={rows}\n readOnly\n onSortChange={onSortChange}\n onSelectionChange={onSelectionChange}\n />\n )\n act(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Amount'), 'onPress'), 'Missing guarded sort')\n })\n act(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Select visible'), 'onPress'), 'Missing guarded selection')\n })\n expect(onSortChange).not.toHaveBeenCalled()\n expect(onSelectionChange).not.toHaveBeenCalled()\n })\n})\n\ntest.each(['light', 'dark'] as const)('nested button content uses its %s foreground without overriding explicit tones', async scheme => {\n const theme = createLumenTheme(scheme)\n const root = await renderNative(\n <LumenProvider theme={theme}>\n <LumenButton>\n <LumenText accessibilityLabel=\"Inherited label\">Continue</LumenText>\n <LumenText accessibilityLabel=\"Explicit tone\" tone=\"danger\">Warning</LumenText>\n <LumenIcon name=\"search\" label=\"Inherited icon\" />\n </LumenButton>\n <LumenText accessibilityLabel=\"Outside control\">Outside</LumenText>\n </LumenProvider>\n )\n expect(readProp(findByAccessibilityLabel(root, 'Inherited label'), 'style')).toEqual(expect.arrayContaining([{ color: theme.colors.onBrand }]))\n expect(readProp(findByAccessibilityLabel(root, 'Explicit tone'), 'style')).toEqual(expect.arrayContaining([{ color: theme.colors.danger }]))\n expect(readProp(findByAccessibilityLabel(root, 'Outside control'), 'style')).toEqual(expect.arrayContaining([{ color: theme.colors.ink }]))\n const svg = findByAccessibilityLabel(root, 'Inherited icon').children[0]\n if (typeof svg !== 'object') throw new Error('Missing inherited icon content')\n expect(readProp(svg, 'stroke')).toBe(theme.colors.onBrand)\n})\n\ntest.each([false, true])('web checkbox Space toggles once and respects disabled=%s', async disabled => {\n nativePlatform.OS = 'web'\n const change = vi.fn<(checked: boolean) => void>()\n const hostKey = vi.fn()\n const root = await renderNative(<LumenCheckbox label=\"Choice\" checked disabled={disabled} onCheckedChange={change} onKeyDown={hostKey} />)\n const handler = readProp(findByAccessibilityRole(root, 'checkbox'), 'onKeyDown')\n if (typeof handler !== 'function') throw new Error('Missing checkbox keyboard handler')\n const preventDefault = vi.fn()\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent: { key: ' ', repeat: false }, defaultPrevented: false, preventDefault }])\n await Promise.resolve()\n })\n expect(hostKey).toHaveBeenCalledOnce()\n expect(preventDefault).toHaveBeenCalledOnce()\n expect(change).toHaveBeenCalledTimes(disabled ? 0 : 1)\n expect(change.mock.calls).toEqual(disabled ? [] : [[false]])\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent: { key: ' ', repeat: true }, defaultPrevented: false, preventDefault }])\n Reflect.apply(handler, undefined, [{ nativeEvent: { key: ' ', repeat: false }, defaultPrevented: true, preventDefault }])\n await Promise.resolve()\n })\n expect(change).toHaveBeenCalledTimes(disabled ? 0 : 1)\n})\n\ntest('media thumbnail keeps controlled selection and blocks loading requests', async () => {\n const onSelectionChange = vi.fn()\n const props = { label: 'Landscape', selected: true, onSelectionChange }\n const root = await renderNative(<LumenMediaThumbnail {...props}><LumenText>Photo</LumenText></LumenMediaThumbnail>)\n const button = findByAccessibilityLabel(root, 'Landscape')\n expect(readProp(button, 'accessibilityState')).toMatchObject({ selected: true })\n await runNativeAction(() => {\n callAction(readProp(button, 'onPress'), 'Missing thumbnail action')\n })\n expect(onSelectionChange).toHaveBeenCalledWith(false)\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenMediaThumbnail {...props} state=\"loading\"><LumenText>Photo</LumenText></LumenMediaThumbnail></LumenProvider>)\n })\n const loading = findByAccessibilityLabel(root, 'Landscape')\n expect(readProp(loading, 'accessibilityState')).toMatchObject({ busy: true, disabled: true })\n await runNativeAction(() => {\n callAction(readProp(loading, 'onPress'), 'Missing loading action')\n })\n expect(onSelectionChange).toHaveBeenCalledTimes(1)\n})\n\ntest('native media inspection actions request bounded values and respect disabled state', async () => {\n const onValueChange = vi.fn()\n const props = { label: 'Inspect', value: { zoom: 2, x: 1, y: 0 }, onValueChange }\n const root = await renderNative(<LumenMediaViewport {...props}><LumenText>Photo</LumenText></LumenMediaViewport>)\n const right = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')\n expect(right).toHaveLength(7)\n const fit = right[2]\n if (!fit) throw new Error('Missing fit action')\n await runNativeAction(() => {\n callAction(readProp(fit, 'onPress'), 'Missing fit handler')\n })\n expect(onValueChange).toHaveBeenCalledWith({ zoom: 1, x: 0, y: 0 })\n await runNativeAction(() => {\n root.render(\n <LumenProvider>\n <LumenMediaViewport {...props} disabled><LumenText>Photo</LumenText></LumenMediaViewport>\n </LumenProvider>\n )\n })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button').every(instance => {\n const state = readProp(instance, 'accessibilityState')\n return typeof state === 'object' && state !== null && 'disabled' in state && state.disabled === true\n })).toBe(true)\n})\n\ntest.each([true, false])('media pinch-to-pan preserves the host-accepted zoom (accept=%s)', async accept => {\n const change = vi.fn<(value: LumenMediaViewportValue) => void>()\n const Harness = () => {\n const [value, setValue] = useState<LumenMediaViewportValue>({ zoom: 2, x: 0, y: 0 })\n\n return (\n <LumenMediaViewport\n label=\"Inspect gesture\"\n value={value}\n onValueChange={next => {\n change(next)\n if (accept) setValue(next)\n }}\n >\n <LumenText>Photo</LumenText>\n </LumenMediaViewport>\n )\n }\n const root = await renderNative(<Harness />)\n const dispatch = async (property: string, nativeEvent: unknown): Promise<void> => {\n const handler = readProp(findByAccessibilityLabel(root, 'Inspect gesture'), property)\n if (typeof handler !== 'function') throw new Error(`Missing media ${property} callback`)\n await runNativeAction(() => {\n Reflect.apply(handler, undefined, [{ nativeEvent }])\n })\n }\n const touch = (pageX: number, pageY: number) => ({ pageX, pageY })\n\n await dispatch('onLayout', { layout: { width: 200, height: 100 } })\n await dispatch('onResponderGrant', { pageX: 50, pageY: 50, touches: [touch(50, 50), touch(100, 50)] })\n await dispatch('onResponderMove', { pageX: 50, pageY: 50, touches: [touch(50, 50), touch(150, 50)] })\n expect(change).toHaveBeenLastCalledWith({ zoom: 4, x: 0, y: 0 })\n change.mockClear()\n await dispatch('onResponderMove', { pageX: 150, pageY: 50, touches: [touch(150, 50)] })\n expect(change).not.toHaveBeenCalled()\n await dispatch('onResponderMove', { pageX: 175, pageY: 50, touches: [touch(175, 50)] })\n expect(change).toHaveBeenLastCalledWith({ zoom: accept ? 4 : 2, x: accept ? 1 / 12 : 0.25, y: 0 })\n})\n",
|
|
60284
|
+
"packages/react-native/src/component-behavior.test.tsx": "// cspell:words Espacios Configuración Cambios guardados Registro actualizado Otro\nimport { act, type ComponentRef, createRef, type ReactElement, type Ref, StrictMode, useImperativeHandle, useState } from 'react'\nimport type { View } from 'react-native'\n\nimport { DateTimePickerAndroid } from '@react-native-community/datetimepicker'\nimport { getLumenPhoneCountry } from '@santi020k/lumen-core'\nimport type { LumenMediaViewportValue } from '@santi020k/lumen-core/media-workspace'\nimport { createRoot, type Root, type TestInstance } from 'test-renderer'\nimport { afterEach, describe, expect, test, vi } from 'vitest'\n\nimport { LumenBrandGithubIconGraphic } from './static-icons/brand-github.generated.js'\nimport { LumenSearchIconGraphic } from './static-icons/search.generated.js'\nimport { LumenButtonGroup, LumenChip, LumenFieldGroup, LumenTextarea, LumenToast } from './additional-components.js'\nimport { LumenAutocomplete, LumenInputOTP, LumenNumberField, LumenPasswordField } from './advanced-form-components.js'\nimport { LumenBreadcrumb } from './breadcrumb-components.js'\nimport { LumenBarChart, LumenComboChart, LumenHeatmap, LumenHistogram, LumenLineChart, LumenRangeChart, LumenScatterChart, LumenWaterfallChart } from './chart-components.js'\nimport { LumenImageComparison } from './comparison-components.js'\nimport { LumenDateField, LumenDateRangeField } from './datetime-components.js'\nimport { LumenSearchField, LumenToggle } from './form-components.js'\nimport { LumenIcon as GraphicIcon, LumenIconButton as GraphicIconButton, type LumenIconGraphicProps } from './graphics.js'\nimport { type LumenToastHookController, type LumenToastHookOptions, useToast } from './hooks.js'\nimport { LumenMediaThumbnail, LumenMediaViewport } from './media-workspace-components.js'\nimport { LumenMultiSelect } from './multi-select-components.js'\nimport { LumenAlertDialog, LumenMenu, LumenSheet } from './overlay-components.js'\nimport { LumenPhoneInput } from './phone-components.js'\nimport { resolveLumenPhoneInputValue } from './phone-recipes.js'\nimport { LumenNavigationBar } from './platform-components.js'\nimport { LumenButton, LumenIcon, LumenIconButton, LumenText, LumenTextField } from './primitives.js'\nimport { LumenProvider } from './provider.js'\nimport { LumenRating } from './rating-components.js'\nimport { resolveLumenRating } from './rating-recipes.js'\nimport { LumenCheckbox, LumenTabs } from './selection-components.js'\nimport { LumenStepper } from './stepper-components.js'\nimport { resolveLumenStepState } from './stepper-recipes.js'\nimport { LumenBanner, LumenErrorState, LumenStatusBar } from './structured-components.js'\nimport { LumenDataTable } from './table-components.js'\nimport type { LumenTableRow } from './table-recipes.js'\nimport { createLumenTheme } from './theme.js'\nimport { LumenTimeField, type LumenTimeFieldProps } from './time-components.js'\nimport { LumenTimeline, LumenTimelineItem } from './timeline-components.js'\nimport { LumenPicker, LumenRangeSlider, LumenSlider } from './value-components.js'\n\nObject.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })\n\ntest.each([\n { labelledBy: 'consumer-label' },\n { labelledBy: ['consumer-label', 'consumer-context'] },\n { labelledBy: '' },\n { labelledBy: [] }\n])('grouped text fields retain explicit native labelled-by ownership: $labelledBy', async ({ labelledBy }) => {\n const root = await renderNative(\n <LumenFieldGroup label=\"Inherited label\">\n <LumenTextField accessibilityLabelledBy={labelledBy} />\n </LumenFieldGroup>\n )\n const input = root.container.queryAll(instance => instance.type === 'TextInput')[0]\n if (!input) throw new Error('Expected grouped text field')\n expect(readProp(input, 'accessibilityLabelledBy')).toEqual(labelledBy)\n expect(readProp(input, 'aria-labelledby')).toBeUndefined()\n expect(readProp(input, 'accessibilityLabel')).toBeUndefined()\n})\n\ntest('grouped text fields preserve explicit ARIA and native label references together', async () => {\n const root = await renderNative(\n <LumenFieldGroup label=\"Inherited label\">\n <LumenTextField accessibilityLabelledBy=\"native-label\" aria-labelledby=\"aria-label\" />\n </LumenFieldGroup>\n )\n const input = root.container.queryAll(instance => instance.type === 'TextInput')[0]\n if (!input) throw new Error('Expected grouped text field')\n expect(readProp(input, 'accessibilityLabelledBy')).toBe('native-label')\n expect(readProp(input, 'aria-labelledby')).toBe('aria-label')\n expect(readProp(input, 'accessibilityLabel')).toBeUndefined()\n})\n\nconst nativeDirection = vi.hoisted(() => ({ isRTL: false }))\nconst nativePlatform = vi.hoisted(() => ({ OS: 'ios' }))\nconst nativeMotion = vi.hoisted(() => ({ enabled: false }))\nconst accessibilityFocus = vi.hoisted(() => vi.fn())\nconst accessibilityAnnouncement = vi.hoisted(() => vi.fn<(message: string, options: { queue: boolean }) => void>())\ntype RecommendedTimeout = (duration: number) => Promise<number>\nconst accessibilityTimeout = vi.hoisted(() => vi.fn<RecommendedTimeout>(duration => Promise.resolve(duration)))\nconst nativeWindow = vi.hoisted(() => ({ fontScale: 1, height: 800, scale: 2, width: 400 }))\nconst safeAreaInsets = vi.hoisted(() => ({\n bottom: 34,\n left: 8,\n right: 12,\n top: 20\n}))\n\nvi.mock('react-native', async () => {\n const { createElement, isValidElement, useImperativeHandle } = await import('react')\n const hostComponent = (name: string) => (\n props: Record<string, unknown>\n ): ReactElement => createElement(name, props)\n type FocusablePressableProps = Record<string, unknown> & {\n ref?: Ref<{ focus: () => void }>\n }\n const FocusablePressable = ({ ref, ...props }: FocusablePressableProps): ReactElement => {\n useImperativeHandle(ref, () => ({ focus: vi.fn() }))\n\n return createElement('Pressable', props)\n }\n\n return {\n AccessibilityInfo: {\n addEventListener: () => ({ remove: vi.fn() }),\n announceForAccessibilityWithOptions: accessibilityAnnouncement,\n getRecommendedTimeoutMillis: accessibilityTimeout,\n isReduceMotionEnabled: () => Promise.resolve(nativeMotion.enabled),\n sendAccessibilityEvent: accessibilityFocus\n },\n ActivityIndicator: hostComponent('ActivityIndicator'),\n FlatList: (props: Record<string, unknown>): ReactElement => {\n const renderItem = props.renderItem\n const data: readonly unknown[] = Array.isArray(props.data) ? props.data : []\n const children = data.map((item, index) => {\n if (typeof renderItem !== 'function') return null\n\n const rendered: unknown = Reflect.apply(renderItem, undefined, [{ item, index }])\n\n return isValidElement(rendered) ? rendered : null\n })\n\n return createElement('FlatList', props, children)\n },\n I18nManager: nativeDirection,\n Image: hostComponent('Image'),\n KeyboardAvoidingView: hostComponent('KeyboardAvoidingView'),\n Modal: hostComponent('Modal'),\n Platform: nativePlatform,\n Pressable: FocusablePressable,\n ScrollView: hostComponent('ScrollView'),\n Share: { share: () => Promise.resolve({ action: 'sharedAction' }) },\n Switch: hostComponent('Switch'),\n Text: hostComponent('Text'),\n TextInput: hostComponent('TextInput'),\n useColorScheme: () => 'light',\n useWindowDimensions: () => nativeWindow,\n View: hostComponent('View')\n }\n})\n\nvi.mock('@react-native-community/datetimepicker', async () => {\n const { createElement } = await import('react')\n const NativeDatePicker = (props: Record<string, unknown>): ReactElement => (\n createElement('NativeDatePicker', props)\n )\n\n return {\n DateTimePickerAndroid: { dismiss: vi.fn(), open: vi.fn() },\n default: NativeDatePicker\n }\n})\n\nvi.mock('react-native-svg', async () => {\n const { createElement } = await import('react')\n const hostComponent = (name: string) => (\n props: Record<string, unknown>\n ): ReactElement => createElement(name, props)\n\n return Object.fromEntries(\n ['Circle', 'Defs', 'Ellipse', 'G', 'Line', 'LinearGradient', 'Path', 'Polygon', 'Polyline', 'Rect', 'Stop', 'Svg', 'Text']\n .map(name => [name, hostComponent(name)])\n )\n})\n\nconst mountedRoots: Root[] = []\n\ninterface ToastProbeProps {\n ref: Ref<LumenToastHookController>\n options?: LumenToastHookOptions\n}\n\nconst ToastProbe = ({ ref, options }: ToastProbeProps): ReactElement | null => {\n const controller = useToast(options)\n\n useImperativeHandle(ref, () => controller, [controller])\n\n return null\n}\n\nconst readToastController = (ref: { current: LumenToastHookController | null }): LumenToastHookController => {\n if (!ref.current) throw new Error('Toast controller is not mounted')\n\n return ref.current\n}\n\nconst deferredTimeout = () => {\n let complete: (duration: number) => void = () => {\n throw new Error('Missing timeout resolver')\n }\n const promise = new Promise<number>(resolve => {\n complete = resolve\n })\n\n return { promise, resolve: complete }\n}\n\nconst renderNative = async (element: ReactElement): Promise<Root> => {\n const root = createRoot({\n publicTextComponentTypes: ['Text'],\n textComponentTypes: ['RCTText', 'Text']\n })\n\n mountedRoots.push(root)\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\">{element}</LumenProvider>)\n await Promise.resolve()\n })\n\n return root\n}\n\nconst readProp = (instance: TestInstance, property: string): unknown => {\n const props = instance.props as Record<string, unknown>\n\n return props[property]\n}\n\nconst callAction = (value: unknown, missingActionMessage: string): void => {\n if (typeof value !== 'function') throw new Error(missingActionMessage)\n\n Reflect.apply(value, undefined, [])\n}\n\nconst callKeyboardAction = (value: unknown, key: string): ReturnType<typeof vi.fn> => {\n if (typeof value !== 'function') throw new Error('Tab is missing its keyboard action.')\n\n const preventDefault = vi.fn()\n\n Reflect.apply(value, undefined, [{\n nativeEvent: { code: key, key },\n preventDefault\n }])\n\n return preventDefault\n}\n\nconst findByAccessibilityRole = (root: Root, role: string): TestInstance => {\n const matches = root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === role\n )\n\n expect(matches).toHaveLength(1)\n\n const match = matches[0]\n\n if (!match) throw new Error(`Expected one ${role} accessibility node.`)\n\n return match\n}\n\nconst findByAccessibilityLabel = (root: Root, label: string): TestInstance => {\n const matches = root.container.queryAll(\n instance => readProp(instance, 'accessibilityLabel') === label\n )\n\n expect(matches).toHaveLength(1)\n\n const match = matches[0]\n\n if (!match) throw new Error(`Expected one accessibility node labeled ${label}.`)\n\n return match\n}\n\nconst findHostComponent = (root: Root, type: string): TestInstance => {\n const matches = root.container.queryAll(instance => instance.type === type)\n\n expect(matches).toHaveLength(1)\n\n const match = matches[0]\n\n if (!match) throw new Error(`Expected one ${type} host component.`)\n\n return match\n}\n\nafterEach(async () => {\n nativeDirection.isRTL = false\n const roots = mountedRoots.splice(0)\n\n await act(async () => {\n for (const root of roots) root.unmount()\n await Promise.resolve()\n })\n nativeWindow.fontScale = 1\n nativePlatform.OS = 'ios'\n accessibilityAnnouncement.mockClear()\n accessibilityTimeout.mockReset().mockImplementation(duration => Promise.resolve(duration))\n vi.useRealTimers()\n vi.unstubAllGlobals()\n})\n\ndescribe('Lumen React Native component behavior', () => {\n test.each(['bar', 'line', 'scatter', 'range', 'combo'] as const)(\n '%s chart fits its container and resizes without removing data', async kind => {\n const data = [3, 7, 4, 8, 5].map((y, x) => ({ x, y }))\n const series = [{ id: 'activity', label: 'Changes', data }]\n const props = { label: 'Weekly activity', showData: false, series }\n const charts = {\n bar: <LumenBarChart {...props} />,\n line: <LumenLineChart {...props} />,\n scatter: <LumenScatterChart {...props} />,\n range: <LumenRangeChart label=\"Weekly range\" showData={false} data={data.map(({ x, y }) => ({ x, low: y, high: y + 2 }))} />,\n combo: <LumenComboChart {...props} series={[{ id: 'activity', label: 'Changes', data, mark: 'bar' }]} />\n }\n const root = await renderNative(charts[kind])\n const layouts = root.container.queryAll(instance => instance.type === 'View' && typeof readProp(instance, 'onLayout') === 'function')\n expect(layouts).toHaveLength(1)\n const layout = layouts[0]\n if (!layout) throw new Error('Missing chart container')\n const handler = readProp(layout, 'onLayout')\n if (typeof handler !== 'function') throw new Error('Missing chart layout handler')\n\n for (const width of [268, 720, 300, 0, Number.NaN]) {\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent: { layout: { width } } }])\n await Promise.resolve()\n })\n const expectedWidth = width > 0 && Number.isFinite(width) ? width : 300\n const svg = findHostComponent(root, 'Svg')\n expect(readProp(svg, 'width')).toBe(expectedWidth)\n expect(readProp(svg, 'height')).toBe(Math.min(320, Math.max(220, expectedWidth * 0.75)))\n expect(root.container.queryAll(instance => instance.type === 'ScrollView')).toHaveLength(0)\n const bars = root.container.queryAll(instance => instance.type === 'Rect')\n expect(bars).toHaveLength(['bar', 'combo'].includes(kind) ? 5 : 0)\n for (const bar of bars) {\n const coordinates = /translate\\(([-\\d.]+) ([-\\d.]+)\\)/u.exec(String(readProp(bar, 'transform')))\n if (!coordinates?.[1]) throw new Error('Missing bar coordinates')\n expect(Number(coordinates[1])).toBeGreaterThanOrEqual(0)\n expect(Number(coordinates[1]) + Number(readProp(bar, 'width'))).toBeLessThanOrEqual(expectedWidth)\n }\n }\n }\n )\n\n test.each([\n { rtl: false, values: [20, 50, 120, 20, 120] },\n { rtl: true, values: [120, 100, 20, 120, 20] }\n ])('slider touch follows RTL=$rtl while accessibility values remain numeric', async ({ rtl, values }) => {\n nativeDirection.isRTL = rtl\n const onValueChange = vi.fn<(value: number) => void>()\n const root = await renderNative(\n <LumenSlider label=\"Volume\" min={20} max={120} step={10} value={70} onValueChange={onValueChange} />\n )\n const slider = findByAccessibilityRole(root, 'adjustable')\n expect(readProp(slider, 'accessible')).toBe(true)\n const dispatch = async (property: string, nativeEvent: unknown): Promise<void> => {\n const handler = readProp(slider, property)\n if (typeof handler !== 'function') throw new Error(`Missing slider ${property} callback`)\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent }])\n await Promise.resolve()\n })\n }\n await dispatch('onLayout', { layout: { width: 200 } })\n for (const [index, position] of [0, 50, 200, -50, 250].entries()) {\n for (const event of ['onResponderGrant', 'onResponderMove']) {\n await dispatch(event, { locationX: position })\n expect(onValueChange).toHaveBeenLastCalledWith(values[index])\n }\n }\n await dispatch('onAccessibilityAction', { actionName: 'increment' })\n expect(onValueChange).toHaveBeenLastCalledWith(80)\n await dispatch('onAccessibilityAction', { actionName: 'decrement' })\n expect(onValueChange).toHaveBeenLastCalledWith(60)\n expect(readProp(slider, 'accessibilityValue')).toMatchObject({ min: 20, max: 120, now: 70 })\n })\n\n test('range endpoints stay controlled, independently named and cannot cross', async () => {\n const onValueChange = vi.fn<(value: readonly [number, number]) => void>()\n const root = await renderNative(\n <LumenRangeSlider\n label=\"Capacity\"\n startLabel=\"Lower\"\n endLabel=\"Upper\"\n value={[20, 60]}\n min={0}\n max={100}\n step={10}\n onValueChange={onValueChange}\n formatValue={value => `${value}%`}\n />\n )\n const sliders = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'adjustable')\n expect(sliders).toHaveLength(2)\n const [start, end] = sliders\n if (!start || !end) throw new Error('Missing interval endpoints')\n expect(readProp(start, 'accessibilityLabel')).toBe('Capacity · Lower')\n expect(readProp(end, 'accessibilityLabel')).toBe('Capacity · Upper')\n expect(readProp(start, 'accessibilityValue')).toMatchObject({ now: 20, text: '20%' })\n expect(readProp(start, 'aria-valuenow')).toBe(20)\n expect(readProp(start, 'aria-valuetext')).toBe('20%')\n const move = async (slider: TestInstance, direction: string): Promise<void> => {\n const handler = readProp(slider, 'onAccessibilityAction')\n if (typeof handler !== 'function') throw new Error('Missing range adjustment')\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent: { actionName: direction } }])\n await Promise.resolve()\n })\n }\n await move(start, 'increment')\n expect(onValueChange).toHaveBeenLastCalledWith([30, 60])\n await move(end, 'decrement')\n expect(onValueChange).toHaveBeenLastCalledWith([20, 50])\n // The application has not accepted either update; subsequent intent uses its controlled value.\n await move(start, 'increment')\n expect(onValueChange).toHaveBeenLastCalledWith([30, 60])\n await act(async () => {\n root.render(\n <LumenProvider>\n <LumenRangeSlider\n label=\"Capacity\"\n value={[60, 60]}\n min={0}\n max={100}\n step={10}\n onValueChange={onValueChange}\n />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n const next = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'adjustable')\n if (!next[0] || !next[1]) throw new Error('Missing updated interval endpoints')\n await move(next[0], 'increment')\n expect(onValueChange).toHaveBeenLastCalledWith([60, 60])\n await move(next[1], 'decrement')\n expect(onValueChange).toHaveBeenLastCalledWith([60, 60])\n })\n\n test.each([{ enabled: false, readOnly: false }, { enabled: true, readOnly: true }])(\n 'range endpoints reject touch and accessibility when enabled=$enabled readOnly=$readOnly',\n async ({ enabled, readOnly }) => {\n const onValueChange = vi.fn<(value: readonly [number, number]) => void>()\n const root = await renderNative(\n <LumenRangeSlider\n label=\"Capacity\"\n value={[20, 80]}\n enabled={enabled}\n readOnly={readOnly}\n onValueChange={onValueChange}\n />\n )\n const sliders = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'adjustable')\n for (const slider of sliders) {\n expect(readProp(slider, 'accessibilityState')).toEqual({ disabled: true })\n for (const [event, nativeEvent] of [\n ['onAccessibilityAction', { actionName: 'increment' }],\n ['onResponderGrant', { locationX: 100 }],\n ['onResponderMove', { locationX: 100 }]\n ] as const) {\n const handler = readProp(slider, event)\n if (typeof handler !== 'function') throw new Error(`Missing ${event}`)\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent }])\n await Promise.resolve()\n })\n }\n }\n expect(onValueChange).not.toHaveBeenCalled()\n }\n )\n\n test('uses the Android accessibility timeout before dismissing a toast', async () => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n accessibilityTimeout.mockResolvedValue(20_000)\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n readToastController(ref).create({ title: 'Saved' })\n await Promise.resolve()\n })\n\n expect(accessibilityTimeout).toHaveBeenCalledExactlyOnceWith(5000)\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(19_999)\n })\n expect(readToastController(ref).toasts).toHaveLength(1)\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(1)\n })\n expect(readToastController(ref).toasts).toHaveLength(0)\n })\n\n test.each(['shorter', 'invalid', 'oversized', 'rejected'])('retains the requested duration when Android returns a %s recommendation', async result => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n\n if (result === 'rejected') accessibilityTimeout.mockRejectedValue(new Error('Native timeout unavailable'))\n else if (result === 'shorter') accessibilityTimeout.mockResolvedValue(1)\n else if (result === 'oversized') accessibilityTimeout.mockResolvedValue(Number.MAX_SAFE_INTEGER)\n else accessibilityTimeout.mockResolvedValue(Number.NaN)\n\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n readToastController(ref).create({ duration: 1000, title: 'Saved' })\n await Promise.resolve()\n })\n expect(accessibilityTimeout).toHaveBeenCalledExactlyOnceWith(1000)\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(999)\n })\n expect(readToastController(ref).toasts).toHaveLength(1)\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(1)\n })\n expect(readToastController(ref).toasts).toHaveLength(0)\n })\n\n test.each(['dismiss', 'clear', 'unmount', 'evict'])('ignores late Android timeout results after %s', async operation => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n const pending = deferredTimeout()\n\n accessibilityTimeout.mockReturnValueOnce(pending.promise)\n\n const ref = createRef<LumenToastHookController>()\n const root = await renderNative(<ToastProbe options={{ maxCount: 1 }} ref={ref} />)\n let id = ''\n\n await act(async () => {\n id = readToastController(ref).create({ title: 'Saved' })\n await Promise.resolve()\n })\n expect(accessibilityTimeout).toHaveBeenCalledExactlyOnceWith(5000)\n\n await act(async () => {\n if (operation === 'dismiss') readToastController(ref).dismiss(id)\n if (operation === 'clear') readToastController(ref).clear()\n if (operation === 'unmount') root.unmount()\n if (operation === 'evict') readToastController(ref).create({ duration: 0, title: 'Persistent replacement' })\n await Promise.resolve()\n })\n await act(async () => {\n pending.resolve(20_000)\n await Promise.resolve()\n })\n\n expect(vi.getTimerCount()).toBe(0)\n })\n\n test('ignores stale Android recommendations when a toast duration is updated', async () => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n const pending = deferredTimeout()\n\n accessibilityTimeout.mockReturnValueOnce(pending.promise).mockResolvedValueOnce(10_000)\n\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n const id = readToastController(ref).create({ title: 'Saved' })\n\n readToastController(ref).update(id, { duration: 1000 })\n await Promise.resolve()\n })\n expect(accessibilityTimeout).toHaveBeenNthCalledWith(1, 5000)\n expect(accessibilityTimeout).toHaveBeenNthCalledWith(2, 1000)\n expect(accessibilityTimeout).toHaveBeenCalledTimes(2)\n\n await act(async () => {\n pending.resolve(100_000)\n await Promise.resolve()\n await vi.advanceTimersByTimeAsync(10_000)\n })\n\n expect(readToastController(ref).toasts).toHaveLength(0)\n expect(vi.getTimerCount()).toBe(0)\n })\n\n test('keeps explicitly persistent Android toasts without requesting a timeout', async () => {\n nativePlatform.OS = 'android'\n vi.useFakeTimers()\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n for (const duration of [0, -1, Number.POSITIVE_INFINITY]) readToastController(ref).create({ duration, title: 'Saved' })\n await vi.advanceTimersByTimeAsync(100_000)\n })\n\n expect(readToastController(ref).toasts).toHaveLength(3)\n expect(accessibilityTimeout).not.toHaveBeenCalled()\n expect(vi.getTimerCount()).toBe(0)\n })\n\n test.each(['ios', 'web'])('preserves the requested %s toast duration without Android timeout calls', async platform => {\n nativePlatform.OS = platform\n vi.useFakeTimers()\n const ref = createRef<LumenToastHookController>()\n\n await renderNative(<ToastProbe ref={ref} />)\n await act(async () => {\n readToastController(ref).create({ duration: 1000, title: 'Saved' })\n await vi.advanceTimersByTimeAsync(1000)\n })\n\n expect(readToastController(ref).toasts).toHaveLength(0)\n expect(accessibilityTimeout).not.toHaveBeenCalled()\n })\n\n test('keeps enlarged navigation labels readable and preserves destination semantics', async () => {\n const select = vi.fn()\n const reselect = vi.fn()\n const items = [\n { label: 'Espacios de trabajo', value: 'workspaces' },\n { label: 'Configuración', value: 'settings' },\n { disabled: true, label: 'Archivo', value: 'archive' }\n ]\n const navigation = <LumenNavigationBar items={items} onReselect={reselect} onValueChange={select} value=\"workspaces\" />\n const root = await renderNative(navigation)\n const labels = () => root.container.queryAll(instance => instance.type === 'Text')\n expect(labels().map(label => readProp(label, 'numberOfLines'))).toEqual([1, 1, 1])\n\n nativeWindow.fontScale = 3\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\"><LumenNavigationBar items={items} onReselect={reselect} onValueChange={select} value=\"workspaces\" /></LumenProvider>)\n await Promise.resolve()\n })\n expect(labels().every(label => readProp(label, 'numberOfLines') === undefined)).toBe(true)\n expect(labels().every(label => {\n const style = readProp(label, 'style')\n return typeof style === 'object' && style !== null && 'maxWidth' in style && style.maxWidth === '100%'\n })).toBe(true)\n const selected = findByAccessibilityLabel(root, 'Espacios de trabajo')\n expect(readProp(selected, 'accessibilityState')).toMatchObject({ selected: true })\n callAction(readProp(selected, 'onPress'), 'Missing reselect action')\n expect(reselect).toHaveBeenCalledWith('workspaces')\n const settings = findByAccessibilityLabel(root, 'Configuración')\n callAction(readProp(settings, 'onPress'), 'Missing destination action')\n expect(select).toHaveBeenCalledWith('settings')\n expect(readProp(findByAccessibilityLabel(root, 'Archivo'), 'disabled')).toBe(true)\n })\n\n test('keeps alert actions inside safe areas and makes oversized content scrollable', async () => {\n const root = await renderNative(\n <LumenAlertDialog\n confirmLabel=\"Delete\"\n onConfirm={() => {}}\n onDismiss={() => {}}\n safeAreaInsets={safeAreaInsets}\n title=\"Delete project?\"\n visible\n />\n )\n const modalContainer = root.container.queryAll(\n instance => readProp(instance, 'accessibilityViewIsModal') === true\n )[0]\n\n if (!modalContainer) throw new Error('Expected the alert modal container.')\n\n expect(readProp(modalContainer, 'style')).toMatchObject({\n paddingBottom: 58,\n paddingLeft: 32,\n paddingRight: 36,\n paddingTop: 44\n })\n expect(readProp(findByAccessibilityRole(root, 'alert'), 'style')).toMatchObject({\n maxHeight: '100%'\n })\n })\n\n test('keeps sheet actions above the bottom inset and scrolls application content', async () => {\n const root = await renderNative(\n <LumenSheet\n actions={<LumenButton>Save</LumenButton>}\n onDismiss={() => {}}\n safeAreaInsets={safeAreaInsets}\n scrollable\n title=\"Settings\"\n visible\n >\n <LumenText>Sheet content</LumenText>\n </LumenSheet>\n )\n const keyboardSurface = root.container.queryAll(\n instance => instance.type === 'KeyboardAvoidingView'\n )[0]\n const sheetPanel = root.container.queryAll(instance => {\n const style = readProp(instance, 'style')\n\n return typeof style === 'object' &&\n style !== null &&\n 'paddingBottom' in style &&\n style.paddingBottom === 34\n })[0]\n\n if (!keyboardSurface || !sheetPanel) throw new Error('Expected the sheet surfaces.')\n\n expect(readProp(keyboardSurface, 'style')).toMatchObject({\n paddingBottom: 0,\n paddingLeft: 8,\n paddingRight: 12,\n paddingTop: 20\n })\n expect(readProp(sheetPanel, 'style')).toMatchObject({ paddingBottom: 34 })\n expect(root.container.queryAll(instance => instance.type === 'ScrollView')).toHaveLength(1)\n })\n\n test('lets virtualized sheet content own scrolling', async () => {\n const root = await renderNative(\n <LumenSheet onDismiss={() => {}} scrollable={false} visible>\n <LumenText>Virtualized content</LumenText>\n </LumenSheet>\n )\n\n expect(root.container.queryAll(instance => instance.type === 'ScrollView')).toHaveLength(0)\n })\n\n test('button exposes loading as busy and disabled native state', async () => {\n const root = await renderNative(<LumenButton loading>Save changes</LumenButton>)\n const button = findByAccessibilityRole(root, 'button')\n\n expect(readProp(button, 'disabled')).toBe(true)\n expect(readProp(button, 'accessibilityState')).toEqual({\n busy: true,\n disabled: true\n })\n })\n\n test('button preserves its native role when a consumer supplies another role', async () => {\n const root = await renderNative(\n <LumenButton accessibilityRole=\"link\">Open workspace</LumenButton>\n )\n\n expect(findByAccessibilityRole(root, 'button')).toBeDefined()\n expect(root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'link'\n )).toHaveLength(0)\n })\n\n test('text inputs expose disabled native state without dropping caller state', async () => {\n const textFieldRoot = await renderNative(\n <LumenTextField\n accessibilityLabel=\"Project name\"\n accessibilityState={{ busy: true }}\n editable={false}\n />\n )\n const textareaRoot = await renderNative(\n <LumenTextarea\n accessibilityState={{ busy: true }}\n editable={false}\n label=\"Project notes\"\n onChangeText={() => {}}\n value=\"\"\n />\n )\n const searchRoot = await renderNative(\n <LumenSearchField\n accessibilityState={{ busy: true }}\n editable={false}\n onChangeText={() => {}}\n prompt=\"Search projects\"\n value=\"\"\n />\n )\n\n for (const [root, label] of [\n [textFieldRoot, 'Project name'],\n [textareaRoot, 'Project notes'],\n [searchRoot, 'Search projects']\n ] as const) {\n expect(readProp(findByAccessibilityLabel(root, label), 'accessibilityState')).toEqual({\n busy: true,\n disabled: true\n })\n }\n })\n\n test('validation is exposed on the native controls without dropping caller context', async () => {\n const textFieldRoot = await renderNative(\n <LumenTextField accessibilityLabel=\"Project name\" error />\n )\n const textareaRoot = await renderNative(\n <LumenTextarea\n accessibilityHint=\"Consumer guidance\"\n errorMessage=\"Project notes are required\"\n label=\"Project notes\"\n onChangeText={() => {}}\n value=\"\"\n />\n )\n const dateRoot = await renderNative(\n <LumenDateField\n errorMessage=\"Choose a valid birthday\"\n label=\"Birthday\"\n onValueChange={() => {}}\n value={null}\n />\n )\n\n const textField = findByAccessibilityLabel(textFieldRoot, 'Project name')\n const textarea = findByAccessibilityLabel(textareaRoot, 'Project notes')\n const dateButton = findByAccessibilityLabel(dateRoot, 'Birthday, Select a date')\n\n expect(readProp(textField, 'aria-invalid')).toBe(true)\n expect(readProp(textarea, 'aria-invalid')).toBe(true)\n expect(readProp(textarea, 'accessibilityHint')).toBe('Project notes are required')\n expect(readProp(dateButton, 'aria-invalid')).toBe(true)\n expect(readProp(dateButton, 'accessibilityHint')).toBe('Choose a valid birthday')\n })\n\n test('grouped fields expose required and range validation context', async () => {\n const fieldGroupRoot = await renderNative(\n <LumenFieldGroup label=\"Project details\" required>\n <LumenText>Details</LumenText>\n </LumenFieldGroup>\n )\n const dateRangeRoot = await renderNative(\n <LumenDateRangeField\n accessibilityHint=\"Consumer guidance\"\n errorMessage=\"Choose a valid schedule\"\n label=\"Schedule\"\n onValueChange={() => {}}\n value={{ end: null, start: null }}\n />\n )\n\n expect(findByAccessibilityLabel(fieldGroupRoot, 'Project details, required')).toBeDefined()\n\n const dateRange = findByAccessibilityLabel(dateRangeRoot, 'Schedule')\n\n expect(readProp(dateRange, 'aria-invalid')).toBe(true)\n expect(readProp(dateRange, 'accessibilityHint')).toBe('Choose a valid schedule')\n })\n\n test('pickers inside field groups announce the group error and description', async () => {\n const root = await renderNative(\n <>\n <LumenFieldGroup description=\"Used for billing\" errorMessage=\"Choose a plan\" label=\"Plan\" required>\n <LumenPicker label=\"Plan\" onValueChange={() => {}} options={[{ label: 'Free', value: 'free' }]} value=\"free\" />\n </LumenFieldGroup>\n <LumenFieldGroup description=\"Shown on invoices\" label=\"Currency\">\n <LumenPicker label=\"Currency\" onValueChange={() => {}} options={[{ label: 'USD', value: 'usd' }]} value=\"usd\" />\n </LumenFieldGroup>\n <LumenPicker label=\"Region\" onValueChange={() => {}} options={[{ label: 'EU', value: 'eu' }]} value=\"eu\" />\n </>\n )\n const [invalidPicker, describedPicker, standalonePicker] = root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'combobox'\n )\n\n expect(invalidPicker && readProp(invalidPicker, 'accessibilityHint')).toBe('Choose a plan')\n expect(invalidPicker && readProp(invalidPicker, 'aria-invalid')).toBe(true)\n expect(describedPicker && readProp(describedPicker, 'accessibilityHint')).toBe('Shown on invoices')\n expect(describedPicker && readProp(describedPicker, 'aria-invalid')).toBeUndefined()\n expect(standalonePicker && readProp(standalonePicker, 'accessibilityHint')).toBeUndefined()\n expect(standalonePicker && readProp(standalonePicker, 'aria-invalid')).toBeUndefined()\n })\n\n test('localizes required-field descriptions without exposing them on optional fields', async () => {\n const root = await renderNative(\n <>\n <LumenFieldGroup label=\"Nombre\" required requiredLabel=\"obligatorio\"><LumenTextField /></LumenFieldGroup>\n <LumenFieldGroup label=\"Notas\" requiredLabel=\"obligatorio\"><LumenTextField /></LumenFieldGroup>\n </>\n )\n\n expect(findByAccessibilityLabel(root, 'Nombre, obligatorio')).toBeDefined()\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Notas, obligatorio')).toHaveLength(0)\n expect(findByAccessibilityLabel(root, 'Notas')).toBeDefined()\n })\n\n test('field groups provide inherited native and web relationships without overriding inputs', async () => {\n const root = await renderNative(\n <LumenFieldGroup\n description=\"Shown on the public profile\"\n errorMessage=\"Choose a unique name\"\n label=\"Project name\"\n required\n >\n <LumenText>Nested row content</LumenText>\n <LumenTextField />\n <LumenTextField accessibilityLabel=\"Short project name\" />\n </LumenFieldGroup>\n )\n const inputs = root.container.queryAll(instance => instance.type === 'TextInput')\n const inherited = inputs[0]\n const explicit = inputs[1]\n\n if (!inherited || !explicit) throw new Error('Expected two grouped text fields.')\n\n expect(readProp(inherited, 'accessibilityLabel')).toBe('Project name')\n expect(readProp(inherited, 'aria-labelledby')).toMatch(/-label$/u)\n expect(readProp(inherited, 'aria-describedby')).toMatch(/-description .*?-error$/u)\n expect(readProp(inherited, 'aria-required')).toBe(true)\n expect(readProp(inherited, 'aria-invalid')).toBe(true)\n expect(readProp(explicit, 'accessibilityLabel')).toBe('Short project name')\n expect(readProp(explicit, 'aria-labelledby')).toBeUndefined()\n\n const validationMessages = root.container.queryAll(\n instance => readProp(instance, 'children') === 'Choose a unique name'\n )\n\n expect(validationMessages).toHaveLength(1)\n\n const validationMessage = validationMessages[0]\n\n if (!validationMessage) throw new Error('Expected the grouped validation message.')\n\n expect(readProp(validationMessage, 'accessibilityLiveRegion')).toBe('polite')\n expect(readProp(validationMessage, 'accessibilityRole')).toBe('alert')\n })\n\n test('read-only phone input locks country selection and number editing', async () => {\n const country = getLumenPhoneCountry('CO')\n if (!country) throw new Error('Missing phone fixture')\n const root = await renderNative(<LumenPhoneInput label=\"Phone\" onValueChange={() => {}} readOnly value={resolveLumenPhoneInputValue([country], country, '', {})} />)\n expect(readProp(findByAccessibilityLabel(root, 'Country code, Colombia, +57'), 'disabled')).toBe(true)\n expect(readProp(findByAccessibilityLabel(root, 'Phone number'), 'editable')).toBe(false)\n })\n\n test('phone input exposes disabled state on both native controls', async () => {\n const country = getLumenPhoneCountry('US')\n\n expect(country).toBeDefined()\n\n if (!country) return\n\n const value = resolveLumenPhoneInputValue([country], country, '', {})\n const root = await renderNative(\n <LumenPhoneInput\n countries={[country]}\n enabled={false}\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n )\n const countrySelector = findByAccessibilityLabel(root, 'Country code, United States, +1')\n const numberInput = findByAccessibilityLabel(root, 'Phone number')\n\n expect(readProp(countrySelector, 'accessibilityState')).toEqual({\n disabled: true,\n expanded: false\n })\n expect(readProp(numberInput, 'accessibilityState')).toEqual({ disabled: true })\n })\n\n test('phone input exposes validation on the number editor', async () => {\n const country = getLumenPhoneCountry('US')\n\n expect(country).toBeDefined()\n\n if (!country) return\n\n const value = resolveLumenPhoneInputValue([country], country, '415', {})\n const root = await renderNative(\n <LumenPhoneInput\n countries={[country]}\n errorMessage=\"Enter a complete phone number\"\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n )\n const numberInput = findByAccessibilityLabel(root, 'Phone number')\n\n expect(readProp(numberInput, 'aria-invalid')).toBe(true)\n expect(readProp(numberInput, 'accessibilityHint')).toBe('Enter a complete phone number')\n })\n\n test('phone input exposes an empty country allow-list as a disabled selector', async () => {\n const country = getLumenPhoneCountry('US')\n\n expect(country).toBeDefined()\n\n if (!country) return\n\n const value = resolveLumenPhoneInputValue([country], country, '', {})\n const root = await renderNative(\n <LumenPhoneInput\n countries={[]}\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n )\n const countrySelector = findByAccessibilityLabel(root, 'Country code, United States, +1')\n\n expect(readProp(countrySelector, 'disabled')).toBe(true)\n expect(readProp(countrySelector, 'accessibilityState')).toEqual({\n disabled: true,\n expanded: false\n })\n })\n\n test('generic picker disables empty options and does not reopen after disabling', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const root = await renderNative(\n <LumenPicker\n label=\"Workspace\"\n onValueChange={onValueChange}\n options={[]}\n value=\"none\"\n />\n )\n let trigger = findByAccessibilityLabel(root, 'Workspace')\n\n expect(readProp(trigger, 'disabled')).toBe(true)\n expect(readProp(trigger, 'accessibilityState')).toEqual({\n disabled: true,\n expanded: false\n })\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenPicker\n label=\"Workspace\"\n onValueChange={onValueChange}\n options={[{ label: 'Design', value: 'design' }]}\n value=\"design\"\n />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n trigger = findByAccessibilityLabel(root, 'Workspace')\n\n await act(async () => {\n callAction(readProp(trigger, 'onPress'), 'Picker is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(findByAccessibilityRole(root, 'menu')).toBeDefined()\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenPicker\n enabled={false}\n label=\"Workspace\"\n onValueChange={onValueChange}\n options={[{ label: 'Design', value: 'design' }]}\n value=\"design\"\n />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'menu'\n )).toHaveLength(0)\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenPicker\n label=\"Workspace\"\n onValueChange={onValueChange}\n options={[{ label: 'Design', value: 'design' }]}\n value=\"design\"\n />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'menu'\n )).toHaveLength(0)\n })\n\n test('date picker does not reopen after the field is disabled and re-enabled', async () => {\n const root = await renderNative(\n <LumenDateField label=\"Birthday\" onValueChange={() => {}} value={null} />\n )\n const trigger = findByAccessibilityLabel(root, 'Birthday, Select a date')\n\n await act(async () => {\n callAction(readProp(trigger, 'onPress'), 'Date field is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(findHostComponent(root, 'NativeDatePicker')).toBeDefined()\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenDateField enabled={false} label=\"Birthday\" onValueChange={() => {}} value={null} />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(root.container.queryAll(instance => instance.type === 'NativeDatePicker')).toHaveLength(0)\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenDateField label=\"Birthday\" onValueChange={() => {}} value={null} />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(root.container.queryAll(instance => instance.type === 'NativeDatePicker')).toHaveLength(0)\n })\n\n test('disabling an Android date field dismisses its imperative native dialog', async () => {\n nativePlatform.OS = 'android'\n\n try {\n const root = await renderNative(\n <LumenDateField label=\"Birthday\" onValueChange={() => {}} value={null} />\n )\n const trigger = findByAccessibilityLabel(root, 'Birthday, Select a date')\n\n await act(async () => {\n callAction(readProp(trigger, 'onPress'), 'Date field is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(DateTimePickerAndroid.open).toHaveBeenCalledOnce()\n\n await act(async () => {\n root.render(\n <LumenProvider scheme=\"light\">\n <LumenDateField enabled={false} label=\"Birthday\" onValueChange={() => {}} value={null} />\n </LumenProvider>\n )\n await Promise.resolve()\n })\n\n expect(DateTimePickerAndroid.dismiss).toHaveBeenCalledWith('date')\n } finally {\n nativePlatform.OS = 'ios'\n }\n })\n\n test('phone picker closes and clears its query when the input becomes disabled', async () => {\n const country = getLumenPhoneCountry('US')\n\n expect(country).toBeDefined()\n\n if (!country) return\n\n const value = resolveLumenPhoneInputValue([country], country, '', {})\n const renderPhone = (enabled: boolean): ReactElement => (\n <LumenProvider scheme=\"light\">\n <LumenPhoneInput\n countries={[country]}\n enabled={enabled}\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n </LumenProvider>\n )\n const root = await renderNative(\n <LumenPhoneInput\n countries={[country]}\n label=\"Phone\"\n onValueChange={() => {}}\n value={value}\n />\n )\n const selector = findByAccessibilityLabel(root, 'Country code, United States, +1')\n\n await act(async () => {\n callAction(readProp(selector, 'onPress'), 'Country selector is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(readProp(findHostComponent(root, 'Modal'), 'visible')).toBe(true)\n expect(readProp(findByAccessibilityLabel(root, 'Search countries'), 'editable')).toBe(true)\n\n await act(async () => {\n const onChangeText = readProp(findByAccessibilityLabel(root, 'Search countries'), 'onChangeText')\n\n if (typeof onChangeText !== 'function') throw new Error('Country search is missing its change action.')\n\n Reflect.apply(onChangeText, undefined, ['united'])\n await Promise.resolve()\n })\n\n await act(async () => {\n root.render(renderPhone(false))\n await Promise.resolve()\n })\n\n expect(readProp(findHostComponent(root, 'Modal'), 'visible')).toBe(false)\n expect(readProp(findByAccessibilityLabel(root, 'Country code, United States, +1'), 'accessibilityState'))\n .toEqual({ disabled: true, expanded: false })\n\n await act(async () => {\n root.render(renderPhone(true))\n await Promise.resolve()\n })\n\n expect(readProp(findByAccessibilityLabel(root, 'Country code, United States, +1'), 'accessibilityState'))\n .toEqual({ disabled: false, expanded: false })\n\n const reopenedSelector = findByAccessibilityLabel(root, 'Country code, United States, +1')\n\n await act(async () => {\n callAction(\n readProp(reopenedSelector, 'onPress'),\n 'Re-enabled country selector is missing its native press action.'\n )\n await Promise.resolve()\n })\n\n expect(readProp(findByAccessibilityLabel(root, 'Search countries'), 'value')).toBe('')\n })\n\n test('chip removal exposes the independent action disabled state', async () => {\n const root = await renderNative(\n <LumenChip disabled label=\"Design\" onRemove={() => {}} />\n )\n const removeButton = findByAccessibilityLabel(root, 'Remove Design')\n\n expect(readProp(removeButton, 'accessibilityRole')).toBe('button')\n expect(readProp(removeButton, 'disabled')).toBe(true)\n expect(readProp(removeButton, 'accessibilityState')).toEqual({ disabled: true })\n expect(readProp(removeButton, 'style')).toMatchObject({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 24,\n minWidth: 24\n })\n })\n\n test('toast announces politely and keeps dismissal independently operable', async () => {\n const onDismiss = vi.fn<() => void>()\n const root = await renderNative(\n <LumenToast dismissLabel=\"Close update\" onDismiss={onDismiss} title=\"Changes saved\" />\n )\n const alert = findByAccessibilityRole(root, 'alert')\n const dismissButton = findByAccessibilityLabel(root, 'Close update')\n const style = readProp(dismissButton, 'style')\n\n expect(readProp(alert, 'accessibilityLiveRegion')).toBe('polite')\n expect(readProp(dismissButton, 'accessibilityRole')).toBe('button')\n expect(style).toBeTypeOf('function')\n\n if (typeof style !== 'function') throw new Error('Toast dismissal is missing its pressable style.')\n\n const restingStyle: unknown = Reflect.apply(style, undefined, [{ pressed: false }])\n\n expect(restingStyle).toMatchObject({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 44,\n minWidth: 44\n })\n\n await act(async () => {\n callAction(readProp(dismissButton, 'onPress'), 'Toast dismissal is missing its press action.')\n await Promise.resolve()\n })\n\n expect(onDismiss).toHaveBeenCalledOnce()\n })\n\n test('queues localized iOS toast copy once and announces changed content', async () => {\n const toast = (description: string) => (\n <StrictMode><LumenToast description={description} title=\"Cambios guardados\" /></StrictMode>\n )\n const root = await renderNative(toast('Registro actualizado'))\n\n expect(accessibilityAnnouncement).toHaveBeenCalledExactlyOnceWith('Cambios guardados. Registro actualizado', { queue: true })\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\">{toast('Registro actualizado')}</LumenProvider>)\n await Promise.resolve()\n })\n\n expect(accessibilityAnnouncement).toHaveBeenCalledOnce()\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\">{toast('Otro registro actualizado')}</LumenProvider>)\n await Promise.resolve()\n })\n\n expect(accessibilityAnnouncement).toHaveBeenLastCalledWith('Cambios guardados. Otro registro actualizado', { queue: true })\n expect(accessibilityAnnouncement).toHaveBeenCalledTimes(2)\n })\n\n test('honors iOS error announcement urgency and off without speaking diagnostic references', async () => {\n const root = await renderNative(<LumenErrorState announcement=\"assertive\" description=\"Try again\" reference=\"request-123\" title=\"Unable to save\" />)\n\n expect(accessibilityAnnouncement).toHaveBeenCalledExactlyOnceWith('Unable to save. Try again', { queue: false })\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\"><LumenErrorState announcement=\"off\" title=\"Unable to save\" /></LumenProvider>)\n await Promise.resolve()\n })\n\n expect(accessibilityAnnouncement).toHaveBeenCalledOnce()\n\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\"><LumenErrorState announcement=\"polite\" title=\"Unable to save\" /></LumenProvider>)\n await Promise.resolve()\n })\n\n expect(accessibilityAnnouncement).toHaveBeenLastCalledWith('Unable to save', { queue: true })\n expect(accessibilityAnnouncement).toHaveBeenCalledTimes(2)\n })\n\n test('does not announce initially disabled errors or empty toast copy on iOS', async () => {\n await renderNative(\n <>\n <LumenErrorState announcement=\"off\" title=\"Unable to save\" />\n <LumenToast title=\" \" />\n </>\n )\n\n expect(accessibilityAnnouncement).not.toHaveBeenCalled()\n })\n\n test.each(['android', 'web'])('keeps %s live regions without duplicate imperative announcements', async platform => {\n nativePlatform.OS = platform\n const root = await renderNative(\n <>\n <LumenToast title=\"Saved\" />\n <LumenErrorState announcement=\"assertive\" title=\"Unable to save\" />\n </>\n )\n\n expect(readProp(findByAccessibilityRole(root, 'alert'), 'accessibilityLiveRegion')).toBe('polite')\n expect(readProp(findByAccessibilityRole(root, 'summary'), 'accessibilityLiveRegion')).toBe('assertive')\n expect(accessibilityAnnouncement).not.toHaveBeenCalled()\n })\n\n test('banner dismissal has a full independent touch target', async () => {\n const root = await renderNative(\n <LumenBanner dismissLabel=\"Close notice\" onDismiss={() => {}} title=\"Maintenance soon\" />\n )\n const dismissButton = findByAccessibilityLabel(root, 'Close notice')\n const style = readProp(dismissButton, 'style')\n\n expect(readProp(dismissButton, 'accessibilityRole')).toBe('button')\n expect(style).toBeTypeOf('function')\n\n if (typeof style !== 'function') throw new Error('Banner dismissal is missing its pressable style.')\n\n const restingStyle: unknown = Reflect.apply(style, undefined, [{ pressed: false }])\n\n expect(restingStyle).toMatchObject({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 44,\n minWidth: 44\n })\n })\n\n test('status bars allow long messages to wrap without shrinking trailing content', async () => {\n const message = 'El análisis permanece en el dispositivo para proteger tu privacidad.'\n const root = await renderNative(\n <LumenProvider>\n <LumenStatusBar\n message={message}\n trailing={<LumenText>Analyze</LumenText>}\n />\n </LumenProvider>\n )\n const messageNodes = root.container.queryAll(\n instance => instance.type === 'Text' && readProp(instance, 'children') === message\n )\n\n expect(messageNodes).toHaveLength(1)\n\n const messageNode = messageNodes[0]\n\n if (!messageNode) throw new Error('Expected the status message fixture.')\n\n expect(readProp(messageNode, 'numberOfLines')).toBeUndefined()\n expect(readProp(messageNode, 'style')).toMatchObject({ flex: 1, flexShrink: 1 })\n\n const fixedTrailingContainers = root.container.queryAll(instance => {\n const style = readProp(instance, 'style')\n\n return instance.type === 'View' && typeof style === 'object' && style !== null &&\n Reflect.get(style, 'flexShrink') === 0\n })\n\n expect(fixedTrailingContainers).toHaveLength(1)\n })\n\n test('search clear action preserves its label and full touch target', async () => {\n const onChangeText = vi.fn<(value: string) => void>()\n const root = await renderNative(\n <LumenSearchField\n clearLabel=\"Clear query\"\n onChangeText={onChangeText}\n value=\"lumen\"\n />\n )\n const clearButton = findByAccessibilityLabel(root, 'Clear query')\n const style = readProp(clearButton, 'style')\n\n expect(readProp(clearButton, 'accessibilityRole')).toBe('button')\n expect(style).toBeTypeOf('function')\n\n if (typeof style !== 'function') throw new Error('Clear action is missing its pressable style.')\n\n const restingStyle: unknown = Reflect.apply(style, undefined, [{ pressed: false }])\n\n expect(restingStyle).toMatchObject({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 44,\n minWidth: 44\n })\n\n await act(async () => {\n callAction(readProp(clearButton, 'onPress'), 'Clear action is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(onChangeText).toHaveBeenCalledExactlyOnceWith('')\n })\n\n test('checkbox announces state and emits the next controlled value', async () => {\n const onCheckedChange = vi.fn<(checked: boolean) => void>()\n const root = await renderNative(\n <LumenCheckbox\n checked={false}\n label=\"Include diagnostics\"\n onCheckedChange={onCheckedChange}\n />\n )\n const checkbox = findByAccessibilityRole(root, 'checkbox')\n const onPress = readProp(checkbox, 'onPress')\n\n expect(readProp(checkbox, 'accessibilityLabel')).toBe('Include diagnostics')\n expect(readProp(checkbox, 'aria-checked')).toBe(false)\n expect(readProp(checkbox, 'aria-disabled')).toBe(false)\n\n expect(readProp(checkbox, 'accessibilityState')).toEqual({\n checked: false,\n disabled: false\n })\n expect(onPress).toBeTypeOf('function')\n\n await act(async () => {\n callAction(onPress, 'Checkbox is missing its native press action.')\n await Promise.resolve()\n })\n\n expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true)\n })\n\n test('checkbox preserves a host-provided accessible name', async () => {\n const root = await renderNative(\n <LumenCheckbox checked={false} label=\"Diagnostics\" accessibilityLabel=\"Include diagnostic report\" onCheckedChange={() => undefined} />\n )\n\n expect(readProp(findByAccessibilityRole(root, 'checkbox'), 'accessibilityLabel')).toBe('Include diagnostic report')\n })\n\n test('checkbox announces its visible description as a hint like toggle', async () => {\n const root = await renderNative(\n <>\n <LumenCheckbox\n checked={false}\n description=\"Attach logs to the support ticket\"\n label=\"Include diagnostics\"\n onCheckedChange={() => undefined}\n />\n <LumenCheckbox\n accessibilityHint=\"Consumer guidance\"\n checked={false}\n description=\"Visible guidance\"\n label=\"Share usage\"\n onCheckedChange={() => undefined}\n />\n <LumenCheckbox\n accessibilityHint=\"\"\n checked={false}\n description=\"Visible guidance\"\n label=\"Quiet hints\"\n onCheckedChange={() => undefined}\n />\n </>\n )\n\n expect(readProp(findByAccessibilityLabel(root, 'Include diagnostics'), 'accessibilityHint')).toBe('Attach logs to the support ticket')\n expect(readProp(findByAccessibilityLabel(root, 'Share usage'), 'accessibilityHint')).toBe('Consumer guidance')\n expect(readProp(findByAccessibilityLabel(root, 'Quiet hints'), 'accessibilityHint')).toBe('')\n })\n\n test('toggle makes the full labeled row the single interactive switch', async () => {\n const onValueChange = vi.fn<(value: boolean) => void>()\n const root = await renderNative(\n <LumenToggle\n description=\"Receive product and security updates\"\n label=\"Automatic updates\"\n onValueChange={onValueChange}\n value={false}\n />\n )\n const toggle = findByAccessibilityRole(root, 'switch')\n const onPress = readProp(toggle, 'onPress')\n const nativeSwitches = root.container.queryAll(instance => instance.type === 'Switch')\n\n expect(readProp(toggle, 'accessibilityLabel')).toBe('Automatic updates')\n expect(readProp(toggle, 'accessibilityHint')).toBe('Receive product and security updates')\n expect(readProp(toggle, 'aria-checked')).toBe(false)\n expect(readProp(toggle, 'accessibilityState')).toEqual({\n checked: false,\n disabled: false\n })\n expect(nativeSwitches).toHaveLength(1)\n\n const nativeSwitch = nativeSwitches[0]\n\n if (!nativeSwitch) throw new Error('Expected the decorative native switch.')\n\n expect(readProp(nativeSwitch, 'accessibilityElementsHidden')).toBe(true)\n expect(readProp(nativeSwitch, 'importantForAccessibility')).toBe('no-hide-descendants')\n expect(readProp(nativeSwitch, 'pointerEvents')).toBe('none')\n expect(readProp(nativeSwitch, 'onValueChange')).toBeUndefined()\n\n await act(async () => {\n callAction(onPress, 'Toggle row is missing its press action.')\n await Promise.resolve()\n })\n\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith(true)\n })\n\n test('web toggle keeps the decorative native input out of the tab order', async () => {\n nativePlatform.OS = 'web'\n const onValueChange = vi.fn()\n const root = await renderNative(<LumenToggle label=\"Updates\" onValueChange={onValueChange} value={false} />)\n const control = root.container.queryAll(instance => instance.type === 'Switch')[0]\n if (!control) throw new Error('Missing decorative switch')\n expect(readProp(control, 'disabled')).toBe(true)\n expect(readProp(control, 'aria-hidden')).toBe(true)\n const row = findByAccessibilityRole(root, 'switch')\n expect(readProp(row, 'disabled')).toBe(false)\n await act(async () => {\n callAction(readProp(row, 'onPress'), 'Missing row action')\n await Promise.resolve()\n })\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith(true)\n nativePlatform.OS = 'ios'\n })\n\n test('toggle preserves a consumer accessibility hint over supporting copy', async () => {\n const root = await renderNative(\n <LumenToggle\n accessibilityHint=\"Changes how updates are installed\"\n description=\"Receive product and security updates\"\n label=\"Automatic updates\"\n onValueChange={() => {}}\n value={false}\n />\n )\n\n expect(readProp(findByAccessibilityRole(root, 'switch'), 'accessibilityHint'))\n .toBe('Changes how updates are installed')\n })\n\n test('disabled toggle row cannot emit a value change', async () => {\n const onValueChange = vi.fn<(value: boolean) => void>()\n const root = await renderNative(\n <LumenToggle\n disabled\n label=\"Automatic updates\"\n onValueChange={onValueChange}\n value\n />\n )\n const toggle = findByAccessibilityRole(root, 'switch')\n\n expect(readProp(toggle, 'accessibilityState')).toEqual({\n checked: true,\n disabled: true\n })\n expect(readProp(toggle, 'disabled')).toBe(true)\n\n await act(async () => {\n callAction(readProp(toggle, 'onPress'), 'Disabled toggle row is missing its guarded press action.')\n await Promise.resolve()\n })\n\n expect(onValueChange).not.toHaveBeenCalled()\n })\n\n test('tabs preserve selected and disabled semantics while emitting enabled selection', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const root = await renderNative(\n <LumenTabs\n label=\"Workspace views\"\n onValueChange={onValueChange}\n options={[\n { label: 'Overview', value: 'overview' },\n { disabled: true, label: 'Activity', value: 'activity' }\n ]}\n value=\"overview\"\n >\n <LumenText>Current workspace health</LumenText>\n </LumenTabs>\n )\n const tabList = findByAccessibilityRole(root, 'tablist')\n const tabs = root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'tab'\n )\n\n expect(readProp(tabList, 'accessibilityLabel')).toBe('Workspace views')\n expect(tabs).toHaveLength(2)\n const [overviewTab, activityTab] = tabs\n\n if (!overviewTab || !activityTab) throw new Error('Expected the two configured tabs.')\n\n expect(readProp(overviewTab, 'accessibilityState')).toEqual({\n disabled: false,\n selected: true\n })\n expect(readProp(activityTab, 'accessibilityState')).toEqual({\n disabled: true,\n selected: false\n })\n\n const enabledTabPress = readProp(overviewTab, 'onPress')\n\n await act(async () => {\n callAction(enabledTabPress, 'Enabled tab is missing its press action.')\n await Promise.resolve()\n })\n\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith('overview')\n })\n\n test('uses the translated tab label by default and accepts an explicit translated panel name', async () => {\n const fixture = (panelAccessibilityLabel?: string): ReactElement => (\n <LumenTabs label=\"Vistas\" onValueChange={vi.fn()} options={[{ label: 'Resumen', value: 'overview' }]} {...(panelAccessibilityLabel ? { panelAccessibilityLabel } : {})} value=\"overview\">\n <LumenText>Contenido</LumenText>\n </LumenTabs>\n )\n const root = await renderNative(fixture())\n const panel = root.container.queryAll(instance => readProp(instance, 'role') === 'tabpanel')[0]\n if (!panel) throw new Error('Missing tab panel')\n expect(readProp(panel, 'accessibilityLabel')).toBe('Resumen')\n await act(async () => {\n root.render(<LumenProvider>{fixture('Contenido del resumen')}</LumenProvider>)\n await Promise.resolve()\n })\n const translatedPanel = root.container.queryAll(instance => readProp(instance, 'role') === 'tabpanel')[0]\n if (!translatedPanel) throw new Error('Missing translated tab panel')\n expect(readProp(translatedPanel, 'accessibilityLabel')).toBe('Contenido del resumen')\n })\n\n test('tabs support directional keyboard navigation without selecting disabled tabs', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const KeyboardTabsFixture = (): ReactElement => {\n const [value, setValue] = useState('overview')\n\n return (\n <LumenTabs\n label=\"Workspace views\"\n onValueChange={nextValue => {\n onValueChange(nextValue)\n setValue(nextValue)\n }}\n options={[\n { label: 'Overview', value: 'overview' },\n { label: 'Activity', value: 'activity' },\n { disabled: true, label: 'Billing', value: 'billing' }\n ]}\n value={value}\n >\n <LumenText>Current workspace health</LumenText>\n </LumenTabs>\n )\n }\n const root = await renderNative(<KeyboardTabsFixture />)\n const pressTabKey = async (optionIndex: number, key: string): Promise<void> => {\n const tab = root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'tab'\n )[optionIndex]\n\n if (!tab) throw new Error(`Expected tab fixture at index ${optionIndex}.`)\n\n let prevented = false\n\n await act(async () => {\n const preventDefault = callKeyboardAction(readProp(tab, 'onKeyDown'), key)\n\n prevented = preventDefault.mock.calls.length > 0\n\n await Promise.resolve()\n })\n\n expect(prevented).toBe(true)\n }\n\n await pressTabKey(0, 'ArrowRight')\n expect(onValueChange).toHaveBeenLastCalledWith('activity')\n\n await pressTabKey(1, 'ArrowRight')\n expect(onValueChange).toHaveBeenLastCalledWith('overview')\n expect(onValueChange).not.toHaveBeenCalledWith('billing')\n })\n\n test('tabs do not reselect the current tab when every alternative is disabled', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const root = await renderNative(\n <LumenTabs\n label=\"Workspace views\"\n onValueChange={onValueChange}\n options={[\n { label: 'Overview', value: 'overview' },\n { disabled: true, label: 'Activity', value: 'activity' }\n ]}\n value=\"overview\"\n >\n <LumenText>Current workspace health</LumenText>\n </LumenTabs>\n )\n const overviewTab = root.container.queryAll(\n instance => readProp(instance, 'accessibilityRole') === 'tab'\n )[0]\n\n if (!overviewTab) throw new Error('Expected the selected tab fixture.')\n\n expect(callKeyboardAction(readProp(overviewTab, 'onKeyDown'), 'ArrowRight'))\n .toHaveBeenCalledOnce()\n expect(onValueChange).not.toHaveBeenCalled()\n })\n})\n\ndescribe('LumenSheet consumer layouts', () => {\n afterEach(() => {\n nativeMotion.enabled = false\n nativeWindow.width = 400\n nativeWindow.height = 800\n nativeWindow.fontScale = 1\n nativePlatform.OS = 'ios'\n accessibilityFocus.mockClear()\n })\n\n test.each([\n { fontScale: 2, height: 800, alignment: 'center' },\n { fontScale: 1, height: 320, alignment: 'flex-end' }\n ])('keeps headings and actions scrollable when space is limited: %j', async ({ alignment, ...window }) => {\n Object.assign(nativeWindow, window, { width: 1024 })\n const root = await renderNative(\n <LumenSheet actions={<LumenButton>Save changes</LumenButton>} onDismiss={vi.fn()} presentation=\"adaptive\" title=\"A long translated heading\" visible>\n <LumenTextField accessibilityLabel=\"Details\" />\n </LumenSheet>\n )\n const scroll = findHostComponent(root, 'ScrollView')\n\n expect(scroll.queryAll(instance => readProp(instance, 'accessibilityRole') === 'header')).toHaveLength(1)\n expect(scroll.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')).toHaveLength(1)\n expect(readProp(scroll, 'keyboardShouldPersistTaps')).toBe('handled')\n expect(readProp(findHostComponent(root, 'KeyboardAvoidingView'), 'style')).toMatchObject({ justifyContent: alignment })\n })\n\n test('preserves application-owned virtualization at accessibility text sizes', async () => {\n nativeWindow.fontScale = 3\n const root = await renderNative(\n <LumenSheet onDismiss={vi.fn()} scrollable={false} visible>\n <LumenText>Virtualized collection</LumenText>\n </LumenSheet>\n )\n\n expect(root.container.queryAll(instance => instance.type === 'ScrollView')).toHaveLength(0)\n })\n\n test('focuses an explicit initial control after presentation and restores the trigger after closing', async () => {\n const initialFocusRef = createRef<ComponentRef<typeof View>>()\n const returnFocusRef = createRef<ComponentRef<typeof View>>()\n let restoreFrame: (() => void) | undefined\n vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {\n restoreFrame = callback\n\n return 1\n })\n vi.stubGlobal('cancelAnimationFrame', vi.fn())\n const fixture = (visible: boolean): ReactElement => (\n <>\n <LumenButton ref={returnFocusRef}>Edit</LumenButton>\n <LumenSheet\n initialFocusRef={initialFocusRef}\n onDismiss={vi.fn()}\n returnFocusRef={returnFocusRef}\n visible={visible}\n >\n <LumenButton ref={initialFocusRef}>Save</LumenButton>\n </LumenSheet>\n </>\n )\n const root = await renderNative(fixture(true))\n callAction(readProp(findHostComponent(root, 'Modal'), 'onShow'), 'Missing modal presentation event')\n expect(accessibilityFocus).toHaveBeenCalledExactlyOnceWith(initialFocusRef.current, 'focus')\n await act(async () => {\n root.render(<LumenProvider>{fixture(false)}</LumenProvider>)\n await Promise.resolve()\n })\n expect(accessibilityFocus).toHaveBeenCalledTimes(1)\n if (!restoreFrame) throw new Error('Expected a deferred focus restoration')\n restoreFrame()\n expect(accessibilityFocus).toHaveBeenLastCalledWith(returnFocusRef.current, 'focus')\n })\n\n test('wraps related actions at ordinary text sizes and stacks them at accessibility sizes', async () => {\n const fixture = (): ReactElement => (\n <LumenButtonGroup accessibilityLabel=\"Form actions\">\n <LumenButton>Cancel</LumenButton>\n <LumenButton>Save</LumenButton>\n </LumenButtonGroup>\n )\n const root = await renderNative(fixture())\n const group = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Form actions')[0]\n if (!group) throw new Error('Missing action group')\n expect(readProp(group, 'style')).toEqual(expect.arrayContaining([expect.objectContaining({ flexDirection: 'row', flexWrap: 'wrap' })]))\n nativeWindow.fontScale = 2\n await act(async () => {\n root.render(<LumenProvider>{fixture()}</LumenProvider>)\n await Promise.resolve()\n })\n const updated = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Form actions')[0]\n if (!updated) throw new Error('Missing updated action group')\n expect(readProp(updated, 'style')).toEqual(expect.arrayContaining([expect.objectContaining({ flexDirection: 'column', flexWrap: 'nowrap' })]))\n })\n\n test('keeps actions outside the scrolling body and opts into keyboard avoidance', async () => {\n const root = await renderNative(\n <LumenSheet\n actions={<LumenButton>Save</LumenButton>}\n avoidKeyboard\n keyboardVerticalOffset={24}\n onDismiss={vi.fn()}\n safeAreaInsets={{ bottom: 34 }}\n scrollable\n title=\"Edit record\"\n visible\n >\n <LumenTextField accessibilityLabel=\"Name\" />\n </LumenSheet>\n )\n const scroll = root.container.queryAll(instance => instance.type === 'ScrollView')[0]\n const keyboard = root.container.queryAll(instance => instance.type === 'KeyboardAvoidingView')[0]\n if (!scroll || !keyboard) throw new Error('Missing keyboard or scrolling surface')\n expect(readProp(keyboard, 'enabled')).toBe(true)\n expect(readProp(keyboard, 'behavior')).toBe('padding')\n expect(readProp(keyboard, 'keyboardVerticalOffset')).toBe(24)\n expect(readProp(scroll, 'keyboardShouldPersistTaps')).toBe('handled')\n expect(scroll.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')).toHaveLength(0)\n expect(findByAccessibilityRole(root, 'header')).toBeDefined()\n expect(root.container.queryAll(instance => {\n const style = readProp(instance, 'style')\n return typeof style === 'object' && style !== null && 'paddingBottom' in style && style.paddingBottom === 34\n })).toHaveLength(1)\n })\n\n test('blocks platform and backdrop dismissal until the application unlocks it', async () => {\n const dismiss = vi.fn()\n const root = await renderNative(\n <LumenSheet dismissible={false} onDismiss={dismiss} visible><LumenText>Saving</LumenText></LumenSheet>\n )\n const modal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n const backdrop = root.container.queryAll(instance => instance.type === 'Pressable')[0]\n if (!modal || !backdrop) throw new Error('Missing modal or backdrop')\n callAction(readProp(modal, 'onRequestClose'), 'Missing platform dismiss')\n callAction(readProp(backdrop, 'onPress'), 'Missing backdrop dismiss')\n expect(dismiss).not.toHaveBeenCalled()\n expect(readProp(backdrop, 'disabled')).toBe(true)\n await act(async () => {\n root.render(<LumenProvider scheme=\"light\"><LumenSheet onDismiss={dismiss} visible><LumenText>Saved</LumenText></LumenSheet></LumenProvider>)\n await Promise.resolve()\n })\n const updatedModal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n if (!updatedModal) throw new Error('Missing updated modal')\n callAction(readProp(updatedModal, 'onRequestClose'), 'Missing platform dismiss')\n expect(dismiss).toHaveBeenCalledOnce()\n })\n\n test('names sheets and hides backdrop targets from keyboard and accessibility traversal', async () => {\n const root = await renderNative(<LumenSheet onDismiss={vi.fn()} title=\"Edit record\" visible><LumenText>Details</LumenText></LumenSheet>)\n const modal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n const backdrop = root.container.queryAll(instance => instance.type === 'Pressable')[0]\n if (!modal || !backdrop) throw new Error('Missing modal or backdrop')\n expect(readProp(modal, 'accessibilityLabel')).toBe('Edit record')\n expect(readProp(backdrop, 'tabIndex')).toBe(-1)\n expect(readProp(backdrop, 'aria-hidden')).toBe(true)\n })\n\n test('honors the native reduced-motion setting', async () => {\n nativeMotion.enabled = true\n const root = await renderNative(<LumenSheet onDismiss={vi.fn()} visible><LumenText>Details</LumenText></LumenSheet>)\n const modal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n if (!modal) throw new Error('Missing modal')\n expect(readProp(modal, 'animationType')).toBe('none')\n })\n\n test('honors reduced motion in confirmation dialogs and menus', async () => {\n nativeMotion.enabled = true\n const root = await renderNative(\n <>\n <LumenAlertDialog confirmLabel=\"Save\" onConfirm={vi.fn()} onDismiss={vi.fn()} title=\"Save changes\" visible />\n <LumenMenu accessibilityLabel=\"Actions\" items={[]} trigger={<LumenText>Open menu</LumenText>} />\n </>\n )\n const modals = root.container.queryAll(instance => instance.type === 'Modal')\n\n expect(modals).toHaveLength(2)\n expect(modals.map(modal => readProp(modal, 'animationType'))).toEqual(['none', 'none'])\n })\n\n test('uses a centered adaptive dialog on wide windows and Android keyboard behavior', async () => {\n nativeWindow.width = 1024\n nativePlatform.OS = 'android'\n const root = await renderNative(\n <LumenSheet avoidKeyboard onDismiss={vi.fn()} presentation=\"adaptive\" visible><LumenText>Details</LumenText></LumenSheet>\n )\n const modal = root.container.queryAll(instance => instance.type === 'Modal')[0]\n const keyboard = root.container.queryAll(instance => instance.type === 'KeyboardAvoidingView')[0]\n if (!modal || !keyboard) throw new Error('Missing modal or keyboard surface')\n expect(readProp(modal, 'animationType')).toBe('fade')\n expect(readProp(keyboard, 'behavior')).toBe('height')\n expect(readProp(keyboard, 'style')).toMatchObject({ alignItems: 'center', justifyContent: 'center' })\n })\n})\n\ndescribe('Static graphic icon accessibility', () => {\n const Graphic = ({ size, strokeWidth }: LumenIconGraphicProps): ReactElement => (\n <LumenText>{`Graphic ${String(size)} ${String(strokeWidth)}`}</LumenText>\n )\n\n test('names informative graphics and hides decorative graphics', async () => {\n const root = await renderNative(\n <>\n <GraphicIcon icon={Graphic} label=\"Search records\" size=\"lg\" strokeWidth={3} />\n <GraphicIcon icon={Graphic} decorative label=\"Decorative search\" />\n </>\n )\n const informative = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'image' && readProp(instance, 'accessible') === true)\n expect(informative).toHaveLength(1)\n const named = informative[0]\n if (!named) throw new Error('Missing informative graphic')\n expect(readProp(named, 'accessibilityLabel')).toBe('Search records')\n expect(readProp(named, 'importantForAccessibility')).toBe('yes')\n expect(root.container.queryAll(instance => readProp(instance, 'children') === 'Graphic 24 3').length).toBeGreaterThan(0)\n const hidden = root.container.queryAll(instance => readProp(instance, 'importantForAccessibility') === 'no' && readProp(instance, 'accessible') === false)\n expect(hidden).toHaveLength(1)\n const decorative = hidden[0]\n if (!decorative) throw new Error('Missing decorative graphic')\n expect(readProp(decorative, 'accessibilityLabel')).toBeUndefined()\n expect(readProp(decorative, 'accessibilityElementsHidden')).toBe(true)\n })\n\n test('preserves named and custom icons through the root entrypoint', async () => {\n const root = await renderNative(\n <>\n <LumenIcon name=\"search\" label=\"Named search\" />\n <LumenIcon icon={Graphic} label=\"Custom search\" />\n <LumenIconButton name=\"search\" label=\"Named search action\" disabled />\n <LumenIconButton icon={Graphic} label=\"Custom search action\" disabled />\n </>\n )\n const icons = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'image')\n expect(icons.map(icon => readProp(icon, 'accessibilityLabel'))).toEqual(['Named search', 'Custom search'])\n const buttons = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')\n expect(buttons.map(button => readProp(button, 'accessibilityLabel'))).toEqual(['Named search action', 'Custom search action'])\n expect(buttons.map(button => readProp(button, 'accessibilityState'))).toEqual([{ disabled: true }, { disabled: true }])\n })\n\n test('canonical per-icon imports match named artwork and preserve accessible labels', async () => {\n const root = await renderNative(\n <>\n <LumenIcon name=\"search\" label=\"Named search\" size=\"lg\" strokeWidth={3} />\n <GraphicIcon icon={LumenSearchIconGraphic} label=\"Static search\" size=\"lg\" strokeWidth={3} />\n <LumenIcon name=\"brand:github\" decorative />\n <GraphicIcon icon={LumenBrandGithubIconGraphic} decorative />\n </>\n )\n const graphics = root.container.queryAll(instance => instance.type === 'Svg')\n expect(graphics).toHaveLength(4)\n const geometry = (graphic: TestInstance | undefined): unknown => {\n if (!graphic) throw new Error('Missing canonical SVG')\n return {\n props: Object.fromEntries(Object.entries(graphic.props).filter(([name]) => name !== 'children')),\n children: graphic.children.filter(child => typeof child !== 'string').map(child => ({ type: child.type, props: child.props }))\n }\n }\n expect(geometry(graphics[0])).toEqual(geometry(graphics[1]))\n expect(geometry(graphics[2])).toEqual(geometry(graphics[3]))\n const icons = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'image')\n expect(icons.map(icon => readProp(icon, 'accessibilityLabel'))).toEqual(['Named search', 'Static search'])\n })\n\n test('keeps graphic buttons named and announces disabled state', async () => {\n const root = await renderNative(<GraphicIconButton icon={Graphic} label=\"Search records\" disabled />)\n const button = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')[0]\n if (!button) throw new Error('Missing graphic button')\n expect(readProp(button, 'accessibilityLabel')).toBe('Search records')\n expect(readProp(button, 'accessibilityState')).toEqual({ disabled: true })\n expect(readProp(button, 'disabled')).toBe(true)\n })\n})\n\nconst callInputAction = (value: unknown, proposal: string): void => {\n if (typeof value !== 'function') throw new Error('Missing native input callback')\n Reflect.apply(value, undefined, [proposal])\n}\n\nconst runNativeAction = async (action: () => void): Promise<void> => {\n await act(async () => {\n action()\n\n await Promise.resolve()\n })\n}\n\ndescribe('advanced native inputs', () => {\n test('secure entry hides on blur and after disabling and re-enabling', async () => {\n const props = { label: 'Password', onValueChange: vi.fn(), value: 'synthetic-fixture' }\n const root = await renderNative(<LumenPasswordField {...props} />)\n expect(readProp(findByAccessibilityLabel(root, 'Password'), 'secureTextEntry')).toBe(true)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Show password'), 'onPress'), 'Missing reveal')\n })\n expect(readProp(findByAccessibilityLabel(root, 'Password'), 'secureTextEntry')).toBe(false)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Password'), 'onBlur'), 'Missing blur')\n })\n expect(readProp(findByAccessibilityLabel(root, 'Password'), 'secureTextEntry')).toBe(true)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Show password'), 'onPress'), 'Missing reveal')\n })\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenPasswordField {...props} enabled={false} /></LumenProvider>)\n })\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenPasswordField {...props} /></LumenProvider>)\n })\n expect(readProp(findByAccessibilityLabel(root, 'Password'), 'secureTextEntry')).toBe(true)\n })\n\n test('OTP preserves native autofill, normalizes paste and emits completion only for changed full codes', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const onComplete = vi.fn<(value: string) => void>()\n const root = await renderNative(<LumenInputOTP label=\"Code\" value=\"123\" onValueChange={onValueChange} onComplete={onComplete} />)\n const input = findByAccessibilityLabel(root, 'Code')\n expect(readProp(input, 'textContentType')).toBe('oneTimeCode')\n await runNativeAction(() => {\n callInputAction(readProp(input, 'onChangeText'), '١٢٣-456')\n })\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith('123456')\n expect(onComplete).toHaveBeenCalledExactlyOnceWith('123456')\n await runNativeAction(() => {\n callInputAction(readProp(input, 'onChangeText'), 'not a code')\n })\n expect(onValueChange).toHaveBeenCalledTimes(1)\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenInputOTP label=\"Code\" value=\"123456\" onValueChange={onValueChange} onComplete={onComplete} readOnly /></LumenProvider>)\n })\n await runNativeAction(() => {\n callInputAction(readProp(findByAccessibilityLabel(root, 'Code'), 'onChangeText'), '654321')\n })\n expect(onValueChange).toHaveBeenCalledTimes(1)\n })\n\n test('number entry retains raw drafts and steps exact localized values', async () => {\n const onValueChange = vi.fn<(value: string) => void>()\n const root = await renderNative(<LumenNumberField label=\"Amount\" value=\"0,1\" locale=\"es-CO\" step=\"0.2\" onValueChange={onValueChange} />)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Increase value'), 'onPress'), 'Missing increase')\n })\n expect(onValueChange).toHaveBeenLastCalledWith('0,3')\n await runNativeAction(() => {\n callInputAction(readProp(findByAccessibilityLabel(root, 'Amount'), 'onChangeText'), '12,')\n })\n expect(onValueChange).toHaveBeenLastCalledWith('12,')\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenNumberField label=\"Amount\" value=\"12,\" locale=\"es-CO\" onValueChange={onValueChange} /></LumenProvider>)\n })\n expect(readProp(findByAccessibilityLabel(root, 'Increase value'), 'disabled')).toBe(true)\n expect(readProp(findByAccessibilityLabel(root, 'Amount'), 'aria-invalid')).toBe(true)\n })\n\n test('autocomplete publishes query before selection and closes its results', async () => {\n const calls: string[] = []\n const root = await renderNative(\n <LumenAutocomplete\n label=\"City\"\n query=\"Bo\"\n options={[{ label: 'Bogotá', value: 'bogota' }]}\n onQueryChange={query => {\n calls.push(query)\n }}\n onValueChange={value => {\n calls.push(value)\n }}\n />\n )\n await runNativeAction(() => {\n callInputAction(readProp(findByAccessibilityLabel(root, 'City'), 'onChangeText'), 'Bog')\n })\n calls.length = 0\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Bogotá'), 'onPress'), 'Missing result')\n })\n expect(calls).toEqual(['Bogotá', 'bogota'])\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Close results')).toHaveLength(0)\n })\n\n test('autocomplete loading hides results and disabled state dismisses selection', async () => {\n const props = { label: 'City', onQueryChange: vi.fn(), onValueChange: vi.fn(), options: [{ label: 'Bogotá', value: 'bogota' }], query: '' }\n const root = await renderNative(<LumenAutocomplete {...props} loading />)\n await runNativeAction(() => {\n callInputAction(readProp(findByAccessibilityLabel(root, 'City'), 'onChangeText'), 'Bo')\n })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Bogotá')).toHaveLength(0)\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenAutocomplete {...props} enabled={false} /></LumenProvider>)\n })\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenAutocomplete {...props} /></LumenProvider>)\n })\n expect(readProp(findByAccessibilityLabel(root, 'City'), 'accessibilityState')).toMatchObject({ expanded: false })\n })\n\n test.each(['ios', 'android', 'web'])('time formatting tolerates malformed locales on %s', async platform => {\n nativePlatform.OS = platform\n for (const locale of ['not_a_locale', 'en--US', '💥']) {\n const props = { label: 'Time', value: { hour: 9, minute: 30 }, locale, onValueChange: vi.fn() }\n const root = await renderNative(<LumenTimeField {...props} is24Hour />)\n\n expect(findByAccessibilityLabel(root, 'Time, 09:30')).toBeDefined()\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenTimeField {...props} is24Hour={false} /></LumenProvider>)\n })\n expect(findByAccessibilityLabel(root, 'Time, 9:30 AM')).toBeDefined()\n }\n })\n\n test('time selection requires confirmation and cancellation preserves the value', async () => {\n nativePlatform.OS = 'ios'\n const onValueChange = vi.fn()\n const root = await renderNative(\n <LumenTimeField\n label=\"Time\"\n value={{ hour: 9, minute: 30 }}\n locale=\"en-US\"\n is24Hour\n minTime={{ hour: 9, minute: 0 }}\n maxTime={{ hour: 17, minute: 0 }}\n onValueChange={onValueChange}\n />\n )\n const open = async (): Promise<void> => {\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, 09:30'), 'onPress'), 'Missing time trigger')\n })\n }\n await open()\n await runNativeAction(() => {\n const picker = findHostComponent(root, 'NativeDatePicker')\n const change = readProp(picker, 'onChange')\n if (typeof change !== 'function') throw new Error('Missing time change')\n Reflect.apply(change, undefined, [{ type: 'set' }, new Date(2000, 0, 1, 10, 45)])\n })\n expect(onValueChange).not.toHaveBeenCalled()\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Cancel'), 'onPress'), 'Missing cancel')\n })\n expect(onValueChange).not.toHaveBeenCalled()\n await open()\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Confirm'), 'onPress'), 'Missing confirm')\n })\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith({ hour: 9, minute: 30 })\n })\n\n test.each(['ios', 'web'])('time sheet resets controlled changes without discarding equivalent inputs on %s', async platform => {\n nativePlatform.OS = platform\n const updates = [\n { value: { hour: 11, minute: 15 }, expected: { hour: 11, minute: 15 } },\n { minTime: { hour: 10, minute: 0 }, expected: { hour: 10, minute: 0 } },\n { maxTime: { hour: 9, minute: 15 }, expected: { hour: 9, minute: 0 } }\n ]\n for (const { expected, ...update } of updates) {\n const onValueChange = vi.fn()\n const props: LumenTimeFieldProps = { label: 'Time',\n value: { hour: 9, minute: 30 },\n locale: 'en-US',\n is24Hour: true,\n minTime: { hour: 9, minute: 0 },\n maxTime: { hour: 17, minute: 0 },\n onValueChange }\n const root = await renderNative(<LumenTimeField {...props} />)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, 09:30'), 'onPress'), 'Missing time trigger')\n })\n await runNativeAction(() => {\n const picker = findHostComponent(root, platform === 'web' ? 'input' : 'NativeDatePicker')\n const change = readProp(picker, 'onChange')\n if (typeof change !== 'function') throw new Error('Missing time change')\n Reflect.apply(change, undefined, platform === 'web' ? [{ currentTarget: { value: '10:45' } }] : [{ type: 'set' }, new Date(2000, 0, 1, 10, 45)])\n })\n await runNativeAction(() => {\n root.render(\n <LumenProvider>\n <LumenTimeField\n {...props}\n value={{ hour: 9, minute: 30 }}\n minTime={{ hour: 9, minute: 0 }}\n maxTime={{ hour: 17, minute: 0 }}\n />\n </LumenProvider>\n )\n })\n expect(readProp(findHostComponent(root, 'Modal'), 'visible')).toBe(true)\n const draft = readProp(findHostComponent(root, platform === 'web' ? 'input' : 'NativeDatePicker'), 'value')\n expect(draft).toEqual(platform === 'web' ? '10:45' : new Date(2000, 0, 1, 10, 45))\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenTimeField {...props} {...update} /></LumenProvider>)\n })\n expect(readProp(findHostComponent(root, 'Modal'), 'visible')).toBe(false)\n expect(onValueChange).not.toHaveBeenCalled()\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, `Time, ${update.value ? '11:15' : '09:30'}`), 'onPress'), 'Missing updated time trigger')\n })\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Confirm'), 'onPress'), 'Missing time confirm')\n })\n expect(onValueChange).toHaveBeenCalledExactlyOnceWith(expected)\n }\n })\n\n test('Android time rejects out-of-range selection and stale callbacks after disabling', async () => {\n nativePlatform.OS = 'android'\n const onValueChange = vi.fn()\n const props = { label: 'Time', locale: 'en-US', onValueChange, value: null }\n const root = await renderNative(<LumenTimeField {...props} minTime={{ hour: 9, minute: 0 }} />)\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, Choose a time'), 'onPress'), 'Missing time trigger')\n })\n const options = vi.mocked(DateTimePickerAndroid.open).mock.calls.at(-1)?.[0]\n if (!options?.onValueChange) throw new Error('Missing Android time handler')\n const change = options.onValueChange\n await runNativeAction(() => {\n change({ nativeEvent: { timestamp: 0, utcOffset: 0 } }, new Date(2000, 0, 1, 8, 0))\n })\n expect(onValueChange).not.toHaveBeenCalled()\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, Choose a time'), 'onPress'), 'Missing time trigger')\n })\n const current = vi.mocked(DateTimePickerAndroid.open).mock.calls.at(-1)?.[0]\n if (!current?.onValueChange) throw new Error('Missing Android time handler')\n const stale = current.onValueChange\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenTimeField {...props} enabled={false} /></LumenProvider>)\n })\n expect(DateTimePickerAndroid.dismiss).toHaveBeenCalledWith('time')\n await runNativeAction(() => {\n stale({ nativeEvent: { timestamp: 0, utcOffset: 0 } }, new Date(2000, 0, 1, 10, 0))\n })\n expect(onValueChange).not.toHaveBeenCalled()\n })\n\n test('Android time validates current bounds and uses the current callback while the dialog is open', async () => {\n nativePlatform.OS = 'android'\n const openingCallback = vi.fn()\n const currentCallback = vi.fn()\n const props = { label: 'Time', locale: 'en-US', value: null }\n const root = await renderNative(\n <LumenTimeField {...props} minTime={{ hour: 9, minute: 0 }} onValueChange={openingCallback} />\n )\n const chooseAfterUpdate = async (hour: number): Promise<void> => {\n await runNativeAction(() => {\n root.render(\n <LumenProvider>\n <LumenTimeField {...props} minTime={{ hour: 9, minute: 0 }} onValueChange={openingCallback} />\n </LumenProvider>\n )\n })\n await runNativeAction(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Time, Choose a time'), 'onPress'), 'Missing time trigger')\n })\n const options = vi.mocked(DateTimePickerAndroid.open).mock.calls.at(-1)?.[0]\n if (!options?.onValueChange) throw new Error('Missing Android time handler')\n const change = options.onValueChange\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenTimeField {...props} minTime={{ hour: 12, minute: 0 }} maxTime={{ hour: 17, minute: 0 }} onValueChange={currentCallback} rangeErrorLabel=\"Choose an afternoon time\" /></LumenProvider>)\n })\n await runNativeAction(() => {\n change({ nativeEvent: { timestamp: 0, utcOffset: 0 } }, new Date(2000, 0, 1, hour, 30))\n })\n }\n await chooseAfterUpdate(10)\n expect(openingCallback).not.toHaveBeenCalled()\n expect(currentCallback).not.toHaveBeenCalled()\n expect(root.container.queryAll(instance => readProp(instance, 'children') === 'Choose an afternoon time').length).toBeGreaterThan(0)\n await chooseAfterUpdate(18)\n expect(currentCallback).not.toHaveBeenCalled()\n await chooseAfterUpdate(13)\n expect(openingCallback).not.toHaveBeenCalled()\n expect(currentCallback).toHaveBeenCalledExactlyOnceWith({ hour: 13, minute: 30 })\n })\n\n test.each(['en--US', 'invalid_tag', '💥'])('image comparison falls back for malformed locale %s', async locale => {\n const root = await renderNative(<LumenImageComparison label=\"Comparison\" before={{ uri: 'fixture:before' }} after={{ uri: 'fixture:after' }} value={0.25} locale={locale} onValueChange={() => {}} />)\n const control = findByAccessibilityRole(root, 'adjustable')\n expect(readProp(control, 'accessibilityValue')).toMatchObject({ text: 'After 25%' })\n })\n\n test.each(['side-by-side', 'before', 'after'] as const)('image comparison %s mode presents labels without an adjustable control', async mode => {\n const root = await renderNative(<LumenImageComparison label=\"Comparison\" before={{ uri: 'fixture:before' }} after={{ uri: 'fixture:after' }} value={0.25} mode={mode} onValueChange={() => {}} />)\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'adjustable')).toHaveLength(0)\n expect(root.container.queryAll(instance => readProp(instance, 'children') === 'Before').length > 0).toBe(mode !== 'after')\n expect(root.container.queryAll(instance => readProp(instance, 'children') === 'After').length > 0).toBe(mode !== 'before')\n })\n\n test('image comparison exposes one adjustable control and localized after percentage', async () => {\n const root = await renderNative(<LumenImageComparison label=\"Comparison\" before={{ uri: 'fixture:before' }} after={{ uri: 'fixture:after' }} value={0.25} locale=\"es-CO\" onValueChange={() => {}} />)\n const control = findByAccessibilityRole(root, 'adjustable')\n expect(readProp(control, 'accessibilityLabel')).toBe('Comparison')\n expect(readProp(control, 'accessibilityValue')).toMatchObject({ text: 'After 25%' })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'image')).toHaveLength(0)\n })\n})\n\ndescribe('native chart data inspection', () => {\n test('renders zero as neutral and missing measurements with a separate marker and exact fallback', async () => {\n const root = await renderNative(\n <LumenHeatmap\n label=\"Activity\"\n colorScale=\"diverging\"\n data={[\n { x: 'Mon', y: 'AM', value: -2 },\n { x: 'Tue', y: 'AM', value: 0 },\n { x: 'Wed', y: 'AM', value: null },\n { x: 'Mon', y: 'AM', value: 40 }\n ]}\n />\n )\n const neutral = root.container.queryAll(instance => instance.type === 'Rect' && readProp(instance, 'fillOpacity') === 0)\n expect(neutral).toHaveLength(1)\n const missing = root.container.queryAll(instance => instance.type === 'Text' && readProp(instance, 'children') === '× Not available')\n expect(missing).toHaveLength(1)\n await act(async () => {\n callAction(readProp(findByAccessibilityRole(root, 'button'), 'onPress'), 'Missing chart disclosure')\n await Promise.resolve()\n })\n const rows = root.container.queryAll(instance => instance.type === 'Text').map(instance => readProp(instance, 'children'))\n expect(rows).toContain('Wed, AM: Not available')\n expect(rows).toContain('Tue, AM: 0')\n expect(rows).toContain('Mon, AM: -2')\n expect(rows).not.toContain('Mon, AM: 40')\n })\n\n test('opens and closes exact source values using a labeled disclosure', async () => {\n const root = await renderNative(\n <LumenWaterfallChart\n label=\"Revenue\"\n data={[\n { id: 'opening', label: 'Opening', kind: 'total', value: 100 },\n { id: 'cost', label: 'Costs', value: -25 }\n ]}\n />\n )\n const button = findByAccessibilityRole(root, 'button')\n expect(readProp(button, 'accessibilityState')).toMatchObject({ expanded: false })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'list')).toHaveLength(0)\n await act(async () => {\n callAction(readProp(button, 'onPress'), 'Missing chart disclosure')\n await Promise.resolve()\n })\n expect(readProp(findByAccessibilityRole(root, 'button'), 'accessibilityState')).toMatchObject({ expanded: true })\n const rows = root.container.queryAll(instance => instance.type === 'Text').map(instance => readProp(instance, 'children'))\n expect(rows).toContain('Costs, Start: 100, End: 75, Value: -25')\n await act(async () => {\n callAction(readProp(findByAccessibilityRole(root, 'button'), 'onPress'), 'Missing chart disclosure')\n await Promise.resolve()\n })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'list')).toHaveLength(0)\n })\n\n test('shows a localized invalid-data state rather than a partial histogram', async () => {\n const root = await renderNative(\n <LumenHistogram\n label=\"Distribution\"\n labels={{ invalidData: 'Check the bins' }}\n data={[\n { start: 0, end: 10, count: 5 }, { start: 5, end: 15, count: 3 }\n ]}\n />\n )\n expect(readProp(findByAccessibilityRole(root, 'alert'), 'children')).toBe('Check the bins')\n expect(root.container.queryAll(instance => instance.type === 'Svg')).toHaveLength(0)\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')).toHaveLength(0)\n })\n})\n\ndescribe('controlled multiple selection', () => {\n test('removal retains unavailable values and never mutates the controlled set', async () => {\n const values = new Set(['one', 'missing'])\n const onValuesChange = vi.fn<(values: ReadonlySet<string>) => void>()\n const root = await renderNative(<LumenMultiSelect label=\"Teams\" values={values} onValuesChange={onValuesChange} query=\"\" onQueryChange={() => undefined} options={[{ label: 'One', value: 'one' }]} />)\n const remove = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Remove One').find(instance => typeof readProp(instance, 'onPress') === 'function')\n if (!remove) throw new Error('Missing selected removal')\n const handler = readProp(remove, 'onPress')\n if (typeof handler !== 'function') throw new Error('Missing removal handler')\n await act(async () => {\n Reflect.apply(handler, undefined, [])\n await Promise.resolve()\n })\n expect(onValuesChange).toHaveBeenLastCalledWith(new Set(['missing']))\n expect(values).toEqual(new Set(['one', 'missing']))\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Remove missing').length).toBeGreaterThan(0)\n })\n\n test.each([{ readOnly: true }, { enabled: false }])('blocked controls reject removal: %o', async blocked => {\n const onValuesChange = vi.fn<(values: ReadonlySet<string>) => void>()\n const root = await renderNative(<LumenMultiSelect {...blocked} label=\"Teams\" values={new Set(['one'])} onValuesChange={onValuesChange} query=\"\" onQueryChange={() => undefined} options={[{ label: 'One', value: 'one' }]} />)\n const remove = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Remove One').find(instance => typeof readProp(instance, 'onPress') === 'function')\n if (!remove) throw new Error('Missing removal')\n const handler = readProp(remove, 'onPress')\n if (typeof handler !== 'function') throw new Error('Missing removal handler')\n await act(async () => {\n Reflect.apply(handler, undefined, [])\n await Promise.resolve()\n })\n expect(onValuesChange).not.toHaveBeenCalled()\n expect(readProp(remove, 'disabled')).toBe(true)\n })\n\n test('disabled selected results cannot be removed', async () => {\n const root = await renderNative(\n <LumenMultiSelect\n label=\"Teams\"\n values={new Set(['one'])}\n onValuesChange={() => {\n throw new Error('Disabled callback')\n }}\n query=\"\"\n onQueryChange={() => undefined}\n options={[{ label: 'One', value: 'one', disabled: true }]}\n />\n )\n const remove = root.container.queryAll(instance => readProp(instance, 'accessibilityLabel') === 'Remove One').find(instance => typeof readProp(instance, 'onPress') === 'function')\n if (!remove) throw new Error('Missing removal')\n expect(readProp(remove, 'disabled')).toBe(true)\n })\n})\n\ndescribe('multiple selection results', () => {\n test('checkbox intent retains values outside the result list', async () => {\n const onValuesChange = vi.fn<(values: ReadonlySet<string>) => void>()\n const values = new Set(['missing'])\n const root = await renderNative(<LumenMultiSelect label=\"Teams\" values={values} onValuesChange={onValuesChange} query=\"\" onQueryChange={() => undefined} options={[{ label: 'One', value: 'one' }]} />)\n const option = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'checkbox').find(instance => typeof readProp(instance, 'onPress') === 'function')\n if (!option) throw new Error('Missing option')\n const handler = readProp(option, 'onPress')\n if (typeof handler !== 'function') throw new Error('Missing checkbox handler')\n await act(async () => {\n Reflect.apply(handler, undefined, [])\n await Promise.resolve()\n })\n expect(onValuesChange).toHaveBeenLastCalledWith(new Set(['missing', 'one']))\n expect(values).toEqual(new Set(['missing']))\n })\n\n test.each([{ loading: true }, { resultsErrorMessage: 'Search failed' }])('stale results are unavailable: %o', async state => {\n const root = await renderNative(<LumenMultiSelect {...state} label=\"Teams\" values={new Set<string>()} onValuesChange={() => undefined} query=\"\" onQueryChange={() => undefined} options={[{ label: 'One', value: 'one' }]} />)\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'checkbox')).toHaveLength(0)\n })\n})\n\ndescribe('multiple selection option identity', () => {\n test.each([\n { options: [{ label: 'One', value: '' }] },\n { options: [{ label: ' ', value: 'one' }] },\n { options: [{ label: 'One', value: 'one' }, { label: 'Duplicate', value: 'one' }] }\n ])('rejects invalid option identities: %o', async ({ options }) => {\n await expect(renderNative(<LumenMultiSelect label=\"Teams\" values={new Set<string>()} onValuesChange={() => undefined} query=\"\" onQueryChange={() => undefined} options={options} />)).rejects.toThrow('MultiSelect options require')\n })\n})\n\ntest('multiple selection forwards application safe-area insets to its modal', async () => {\n const root = await renderNative(<LumenMultiSelect label=\"Teams\" values={new Set<string>()} onValuesChange={() => undefined} query=\"\" onQueryChange={() => undefined} options={[]} safeAreaInsets={{ bottom: 96 }} />)\n const panels = root.container.queryAll(instance => {\n const style = readProp(instance, 'style')\n\n return typeof style === 'object' && style !== null && 'paddingBottom' in style && style.paddingBottom === 96\n })\n expect(panels.length).toBeGreaterThan(0)\n})\n\ndescribe('rating controlled selection', () => {\n test('clamps nonfinite values and limits the rendered option count', () => {\n expect(resolveLumenRating(Number.NaN, Number.POSITIVE_INFINITY)).toEqual({ max: 5, value: 0 })\n expect(resolveLumenRating(-5, 0)).toEqual({ max: 1, value: 0 })\n expect(resolveLumenRating(999, 999)).toEqual({ max: 100, value: 100 })\n expect(resolveLumenRating(3.9)).toEqual({ max: 5, value: 3 })\n })\n\n test('reports selection without mutating controlled value and blocks read-only edits', async () => {\n const onValueChange = vi.fn<(value: number) => void>()\n const root = await renderNative(\n <LumenRating\n label=\"Score\"\n value={2}\n onValueChange={onValueChange}\n formatOption={(value, max) => `${value} of ${max}`}\n />\n )\n const option = findByAccessibilityLabel(root, '4 of 5')\n act(() => {\n callAction(readProp(option, 'onPress'), 'Missing rating action')\n })\n expect(onValueChange).toHaveBeenCalledWith(4)\n expect(readProp(option, 'accessibilityState')).toEqual({ checked: false, disabled: false, selected: false })\n act(() => {\n root.render(\n <LumenProvider>\n <LumenRating\n label=\"Score\"\n value={2}\n readOnly\n onValueChange={onValueChange}\n />\n </LumenProvider>\n )\n })\n const locked = findByAccessibilityLabel(root, '4 / 5')\n act(() => {\n callAction(readProp(locked, 'onPress'), 'Missing rating action')\n })\n expect(onValueChange).toHaveBeenCalledTimes(1)\n expect(readProp(locked, 'disabled')).toBe(true)\n })\n})\n\ndescribe('progress and history contracts', () => {\n test('normalizes step progress without changing host state', () => {\n expect(resolveLumenStepState(0, -1, 3)).toBe('current')\n expect(resolveLumenStepState(0, Number.NaN, 3)).toBe('current')\n expect(resolveLumenStepState(1, 1.9, 3)).toBe('current')\n expect(resolveLumenStepState(2, 99, 3)).toBe('complete')\n expect(resolveLumenStepState(2, 1, 3)).toBe('upcoming')\n })\n\n test('presents localized controlled progression and accepts an empty workflow', async () => {\n const steps = [{ id: 'prepare', title: 'Prepare' }, { id: 'review', title: 'Review', description: 'Confirm details' }]\n const root = await renderNative(\n <LumenStepper\n label=\"Workflow\"\n steps={steps}\n currentStep={1}\n formatState={state => `Localized ${state}`}\n />\n )\n const review = findByAccessibilityLabel(root, '2 / 2, Review, Confirm details')\n expect(readProp(review, 'accessibilityValue')).toEqual({ text: 'Localized current' })\n expect(readProp(review, 'accessibilityState')).toEqual({ selected: true })\n act(() => {\n root.render(<LumenProvider><LumenStepper label=\"Workflow\" steps={steps} currentStep={2} /></LumenProvider>)\n })\n expect(readProp(findByAccessibilityLabel(root, '2 / 2, Review, Confirm details'), 'accessibilityValue'))\n .toEqual({ text: 'Complete' })\n act(() => {\n root.render(<LumenProvider><LumenStepper label=\"Workflow\" steps={[]} currentStep={0} /></LumenProvider>)\n })\n expect(root.container.queryAll(instance => readProp(instance, 'accessible') === true)).toHaveLength(0)\n })\n\n test('keeps timeline content actions accessible and host owned', async () => {\n const onPress = vi.fn()\n const root = await renderNative(\n <LumenTimeline label=\"History\">\n <LumenTimelineItem>\n <LumenText>Approved</LumenText>\n <LumenButton onPress={onPress}>View approval</LumenButton>\n </LumenTimelineItem>\n </LumenTimeline>\n )\n const button = findByAccessibilityRole(root, 'button')\n act(() => {\n callAction(readProp(button, 'onPress'), 'Missing timeline action')\n })\n expect(onPress).toHaveBeenCalledOnce()\n })\n\n test('breadcrumb reports navigation IDs and cannot navigate current or disabled locations', async () => {\n const onNavigate = vi.fn<(id: string) => void>()\n const items = [{ id: 'home', label: 'Home' },\n { id: 'private', label: 'Private', disabled: true },\n { id: 'current', label: 'Details' }]\n const root = await renderNative(\n <LumenBreadcrumb\n label=\"Location\"\n items={items}\n onNavigate={onNavigate}\n currentLabel=\"Here\"\n />\n )\n const home = findByAccessibilityLabel(root, 'Home')\n act(() => {\n callAction(readProp(home, 'onPress'), 'Missing breadcrumb action')\n })\n expect(onNavigate).toHaveBeenCalledExactlyOnceWith('home')\n const locked = findByAccessibilityLabel(root, 'Private')\n act(() => {\n callAction(readProp(locked, 'onPress'), 'Missing disabled breadcrumb action')\n })\n expect(readProp(findByAccessibilityLabel(root, 'Details, Here'), 'onPress')).toBeUndefined()\n expect(readProp(findByAccessibilityLabel(root, 'Details, Here'), 'aria-current')).toBe('page')\n })\n})\n\ndescribe('data table controlled interactions', () => {\n const columns = [{ key: 'amount', label: 'Amount', sortable: true }]\n const rows: readonly LumenTableRow[] = [\n { id: 'one', label: 'One', cells: { amount: { text: '20', sortValue: 20 } } },\n { id: 'two', label: 'Two', cells: { amount: { text: '2', sortValue: 2 } }, disabled: true }\n ]\n test('emits manual sort requests without reordering and retains hidden selections', async () => {\n const onSortChange = vi.fn()\n const onSelectionChange = vi.fn()\n const root = await renderNative(\n <LumenDataTable\n label=\"Records\"\n columns={columns}\n rows={rows}\n onSortChange={onSortChange}\n onSelectionChange={onSelectionChange}\n selectedIds={new Set(['hidden'])}\n />\n )\n act(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Amount'), 'onPress'), 'Missing sort action')\n })\n expect(onSortChange).toHaveBeenCalledWith({ key: 'amount', direction: 'ascending' })\n act(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Select visible'), 'onPress'), 'Missing bulk selection')\n })\n expect(onSelectionChange).toHaveBeenCalledWith(new Set(['hidden', 'one']))\n expect(root.container.queryAll(instance => readProp(instance, 'accessible') === true)\n .map(instance => readProp(instance, 'accessibilityLabel'))).toEqual(['Amount, 20', 'Amount, 2'])\n })\n test('loading and error states hide stale controls; retry remains host owned', async () => {\n const onRetry = vi.fn()\n const root = await renderNative(\n <LumenDataTable\n label=\"Records\"\n columns={columns}\n rows={rows}\n loading\n loadingLabel=\"Fetching\"\n onSortChange={vi.fn()}\n />\n )\n expect(readProp(findByAccessibilityRole(root, 'progressbar'), 'children')).toBe('Fetching')\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')).toHaveLength(0)\n act(() => {\n root.render(\n <LumenProvider>\n <LumenDataTable\n label=\"Records\"\n columns={columns}\n rows={rows}\n error=\"Try again\"\n onRetry={onRetry}\n retryLabel=\"Reload\"\n />\n </LumenProvider>\n )\n })\n act(() => {\n callAction(readProp(findByAccessibilityRole(root, 'button'), 'onPress'), 'Missing retry')\n })\n expect(onRetry).toHaveBeenCalledOnce()\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'checkbox')).toHaveLength(0)\n })\n test('read-only tables block both sort and selection callbacks', async () => {\n const onSortChange = vi.fn()\n const onSelectionChange = vi.fn()\n const root = await renderNative(\n <LumenDataTable\n label=\"Records\"\n columns={columns}\n rows={rows}\n readOnly\n onSortChange={onSortChange}\n onSelectionChange={onSelectionChange}\n />\n )\n act(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Amount'), 'onPress'), 'Missing guarded sort')\n })\n act(() => {\n callAction(readProp(findByAccessibilityLabel(root, 'Select visible'), 'onPress'), 'Missing guarded selection')\n })\n expect(onSortChange).not.toHaveBeenCalled()\n expect(onSelectionChange).not.toHaveBeenCalled()\n })\n})\n\ntest.each(['light', 'dark'] as const)('nested button content uses its %s foreground without overriding explicit tones', async scheme => {\n const theme = createLumenTheme(scheme)\n const root = await renderNative(\n <LumenProvider theme={theme}>\n <LumenButton>\n <LumenText accessibilityLabel=\"Inherited label\">Continue</LumenText>\n <LumenText accessibilityLabel=\"Explicit tone\" tone=\"danger\">Warning</LumenText>\n <LumenIcon name=\"search\" label=\"Inherited icon\" />\n </LumenButton>\n <LumenText accessibilityLabel=\"Outside control\">Outside</LumenText>\n </LumenProvider>\n )\n expect(readProp(findByAccessibilityLabel(root, 'Inherited label'), 'style')).toEqual(expect.arrayContaining([{ color: theme.colors.onBrand }]))\n expect(readProp(findByAccessibilityLabel(root, 'Explicit tone'), 'style')).toEqual(expect.arrayContaining([{ color: theme.colors.danger }]))\n expect(readProp(findByAccessibilityLabel(root, 'Outside control'), 'style')).toEqual(expect.arrayContaining([{ color: theme.colors.ink }]))\n const svg = findByAccessibilityLabel(root, 'Inherited icon').children[0]\n if (typeof svg !== 'object') throw new Error('Missing inherited icon content')\n expect(readProp(svg, 'stroke')).toBe(theme.colors.onBrand)\n})\n\ntest.each([false, true])('web checkbox Space toggles once and respects disabled=%s', async disabled => {\n nativePlatform.OS = 'web'\n const change = vi.fn<(checked: boolean) => void>()\n const hostKey = vi.fn()\n const root = await renderNative(<LumenCheckbox label=\"Choice\" checked disabled={disabled} onCheckedChange={change} onKeyDown={hostKey} />)\n const handler = readProp(findByAccessibilityRole(root, 'checkbox'), 'onKeyDown')\n if (typeof handler !== 'function') throw new Error('Missing checkbox keyboard handler')\n const preventDefault = vi.fn()\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent: { key: ' ', repeat: false }, defaultPrevented: false, preventDefault }])\n await Promise.resolve()\n })\n expect(hostKey).toHaveBeenCalledOnce()\n expect(preventDefault).toHaveBeenCalledOnce()\n expect(change).toHaveBeenCalledTimes(disabled ? 0 : 1)\n expect(change.mock.calls).toEqual(disabled ? [] : [[false]])\n await act(async () => {\n Reflect.apply(handler, undefined, [{ nativeEvent: { key: ' ', repeat: true }, defaultPrevented: false, preventDefault }])\n Reflect.apply(handler, undefined, [{ nativeEvent: { key: ' ', repeat: false }, defaultPrevented: true, preventDefault }])\n await Promise.resolve()\n })\n expect(change).toHaveBeenCalledTimes(disabled ? 0 : 1)\n})\n\ntest('media thumbnail keeps controlled selection and blocks loading requests', async () => {\n const onSelectionChange = vi.fn()\n const props = { label: 'Landscape', selected: true, onSelectionChange }\n const root = await renderNative(<LumenMediaThumbnail {...props}><LumenText>Photo</LumenText></LumenMediaThumbnail>)\n const button = findByAccessibilityLabel(root, 'Landscape')\n expect(readProp(button, 'accessibilityState')).toMatchObject({ selected: true })\n await runNativeAction(() => {\n callAction(readProp(button, 'onPress'), 'Missing thumbnail action')\n })\n expect(onSelectionChange).toHaveBeenCalledWith(false)\n await runNativeAction(() => {\n root.render(<LumenProvider><LumenMediaThumbnail {...props} state=\"loading\"><LumenText>Photo</LumenText></LumenMediaThumbnail></LumenProvider>)\n })\n const loading = findByAccessibilityLabel(root, 'Landscape')\n expect(readProp(loading, 'accessibilityState')).toMatchObject({ busy: true, disabled: true })\n await runNativeAction(() => {\n callAction(readProp(loading, 'onPress'), 'Missing loading action')\n })\n expect(onSelectionChange).toHaveBeenCalledTimes(1)\n})\n\ntest('native media inspection actions request bounded values and respect disabled state', async () => {\n const onValueChange = vi.fn()\n const props = { label: 'Inspect', value: { zoom: 2, x: 1, y: 0 }, onValueChange }\n const root = await renderNative(<LumenMediaViewport {...props}><LumenText>Photo</LumenText></LumenMediaViewport>)\n const right = root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button')\n expect(right).toHaveLength(7)\n const fit = right[2]\n if (!fit) throw new Error('Missing fit action')\n await runNativeAction(() => {\n callAction(readProp(fit, 'onPress'), 'Missing fit handler')\n })\n expect(onValueChange).toHaveBeenCalledWith({ zoom: 1, x: 0, y: 0 })\n await runNativeAction(() => {\n root.render(\n <LumenProvider>\n <LumenMediaViewport {...props} disabled><LumenText>Photo</LumenText></LumenMediaViewport>\n </LumenProvider>\n )\n })\n expect(root.container.queryAll(instance => readProp(instance, 'accessibilityRole') === 'button').every(instance => {\n const state = readProp(instance, 'accessibilityState')\n return typeof state === 'object' && state !== null && 'disabled' in state && state.disabled === true\n })).toBe(true)\n})\n\ntest.each([true, false])('media pinch-to-pan preserves the host-accepted zoom (accept=%s)', async accept => {\n const change = vi.fn<(value: LumenMediaViewportValue) => void>()\n const Harness = () => {\n const [value, setValue] = useState<LumenMediaViewportValue>({ zoom: 2, x: 0, y: 0 })\n\n return (\n <LumenMediaViewport\n label=\"Inspect gesture\"\n value={value}\n onValueChange={next => {\n change(next)\n if (accept) setValue(next)\n }}\n >\n <LumenText>Photo</LumenText>\n </LumenMediaViewport>\n )\n }\n const root = await renderNative(<Harness />)\n const dispatch = async (property: string, nativeEvent: unknown): Promise<void> => {\n const handler = readProp(findByAccessibilityLabel(root, 'Inspect gesture'), property)\n if (typeof handler !== 'function') throw new Error(`Missing media ${property} callback`)\n await runNativeAction(() => {\n Reflect.apply(handler, undefined, [{ nativeEvent }])\n })\n }\n const touch = (pageX: number, pageY: number) => ({ pageX, pageY })\n\n await dispatch('onLayout', { layout: { width: 200, height: 100 } })\n await dispatch('onResponderGrant', { pageX: 50, pageY: 50, touches: [touch(50, 50), touch(100, 50)] })\n await dispatch('onResponderMove', { pageX: 50, pageY: 50, touches: [touch(50, 50), touch(150, 50)] })\n expect(change).toHaveBeenLastCalledWith({ zoom: 4, x: 0, y: 0 })\n change.mockClear()\n await dispatch('onResponderMove', { pageX: 150, pageY: 50, touches: [touch(150, 50)] })\n expect(change).not.toHaveBeenCalled()\n await dispatch('onResponderMove', { pageX: 175, pageY: 50, touches: [touch(175, 50)] })\n expect(change).toHaveBeenLastCalledWith({ zoom: accept ? 4 : 2, x: accept ? 1 / 12 : 0.25, y: 0 })\n})\n",
|
|
60198
60285
|
"packages/react-native/src/content-components.tsx": "import { type ReactElement, type ReactNode } from 'react'\nimport {\n type DimensionValue,\n Pressable,\n Text,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\nimport { Circle, Line, Polygon, Polyline, Rect, Svg } from 'react-native-svg'\n\nimport {\n type LumenBackdropIntensity,\n type LumenGraphicSize,\n type LumenIllustrationSize,\n resolveLumenBackdropOpacity,\n resolveLumenDisclosureState,\n resolveLumenGraphicSize,\n resolveLumenIllustrationSize,\n resolveLumenSkeletonHeight } from './content-recipes.js'\nimport {\n type LumenIllustrationElement,\n lumenIllustrations\n} from './illustrations.generated.js'\nimport type { LumenViewRef } from './native-ref-types.js'\nimport { resolveLumenButtonOpacity } from './recipes.js'\nimport { useLumenTheme } from './theme-context.js'\nimport { lumenGraphicOpacities, lumenGraphicStrokeWidths } from './tokens.generated.js'\n\nexport type LumenSkeletonShape = 'circle' | 'rectangle' | 'text'\n\nexport interface LumenSkeletonProps extends Omit<ViewProps, 'children'> {\n height?: number\n label?: string\n ref?: LumenViewRef\n shape?: LumenSkeletonShape\n width?: DimensionValue\n}\n\nexport const LumenSkeleton = ({\n height = 16,\n label,\n ref,\n shape = 'text',\n style,\n width,\n ...props\n}: LumenSkeletonProps): ReactElement => {\n const theme = useLumenTheme()\n const safeHeight = resolveLumenSkeletonHeight(height)\n const resolvedWidth = width ?? (shape === 'circle' ? safeHeight : '100%')\n let borderRadius: number = theme.radii.md\n\n if (shape === 'circle') borderRadius = safeHeight / 2\n\n if (shape === 'text') borderRadius = theme.radii.sm\n\n return (\n <View\n ref={ref}\n {...props}\n accessibilityElementsHidden={!label}\n accessibilityLabel={label}\n accessibilityRole={label ? 'progressbar' : undefined}\n aria-busy={label ? true : undefined}\n importantForAccessibility={label ? 'yes' : 'no-hide-descendants'}\n style={[\n {\n backgroundColor: theme.colors.surfaceStrong,\n borderRadius,\n height: safeHeight,\n opacity: 0.72,\n width: resolvedWidth\n },\n style\n ]}\n />\n )\n}\n\nexport type LumenGraphicTone = 'accent' | 'brand' | 'neutral'\nexport type LumenGraphicVariant = 'glow' | 'grid' | 'orbit'\n\nexport interface LumenGraphicProps extends ViewProps {\n children?: ReactNode\n label?: string\n ref?: LumenViewRef\n size?: LumenGraphicSize\n tone?: LumenGraphicTone\n variant?: LumenGraphicVariant\n}\n\nconst graphicLines = Array.from({ length: 7 }, (_, index) => index)\nconst backdropDots = Array.from({ length: 48 }, (_, index) => index)\n\nexport const LumenGraphic = ({\n children,\n label,\n ref,\n size = 'md',\n style,\n tone = 'brand',\n variant = 'orbit',\n ...props\n}: LumenGraphicProps): ReactElement => {\n const theme = useLumenTheme()\n const dimension = resolveLumenGraphicSize(size)\n\n const color = {\n accent: theme.colors.accent,\n brand: theme.colors.brand,\n neutral: theme.colors.inkMuted\n }[tone]\n\n return (\n <View\n ref={ref}\n {...props}\n accessible={Boolean(label)}\n accessibilityElementsHidden={!label}\n accessibilityLabel={label}\n accessibilityRole={label ? 'image' : undefined}\n importantForAccessibility={label ? 'yes' : 'no-hide-descendants'}\n style={[\n {\n alignItems: 'center',\n height: dimension,\n justifyContent: 'center',\n overflow: 'hidden',\n position: 'relative',\n width: dimension\n },\n style\n ]}\n >\n {variant === 'glow' ?\n <View style={{ backgroundColor: color, borderRadius: dimension / 2, height: '72%', opacity: 0.18, width: '72%' }} /> :\n null}\n {variant === 'grid' ?\n (\n <View style={{ height: '76%', opacity: 0.18, position: 'absolute', width: '76%' }}>\n {graphicLines.map(index => (\n <View\n key={`horizontal-${index}`}\n style={{ backgroundColor: color, height: 1, left: 0, position: 'absolute', right: 0, top: `${index * 16.66}%` }}\n />\n ))}\n {graphicLines.map(index => (\n <View\n key={`vertical-${index}`}\n style={{ backgroundColor: color, bottom: 0, left: `${index * 16.66}%`, position: 'absolute', top: 0, width: 1 }}\n />\n ))}\n </View>\n ) :\n null}\n {variant === 'orbit' ?\n (\n <>\n {[0.9, 0.62, 0.34].map(ratio => (\n <View\n key={ratio}\n style={{\n borderColor: color,\n borderRadius: dimension,\n borderWidth: 1,\n height: dimension * ratio,\n opacity: 0.24,\n position: 'absolute',\n width: dimension * ratio\n }}\n />\n ))}\n <View style={{ backgroundColor: color, height: 1, opacity: 0.18, position: 'absolute', width: '72%' }} />\n <View style={{ backgroundColor: color, height: '72%', opacity: 0.18, position: 'absolute', width: 1 }} />\n </>\n ) :\n null}\n <View style={{ alignItems: 'center', justifyContent: 'center', position: 'absolute' }}>\n {children}\n </View>\n </View>\n )\n}\n\nexport type LumenBackdropTone = 'accent' | 'brand' | 'neutral'\nexport type LumenBackdropVariant = 'aurora' | 'dots' | 'grid' | 'rays'\n\nexport interface LumenBackdropProps extends ViewProps {\n children?: ReactNode\n intensity?: LumenBackdropIntensity\n ref?: LumenViewRef\n tone?: LumenBackdropTone\n variant?: LumenBackdropVariant\n}\n\nexport const LumenBackdrop = ({\n children,\n intensity = 'medium',\n ref,\n style,\n tone = 'brand',\n variant = 'aurora',\n ...props\n}: LumenBackdropProps): ReactElement => {\n const theme = useLumenTheme()\n const color = { accent: theme.colors.accent, brand: theme.colors.brand, neutral: theme.colors.inkMuted }[tone]\n const secondaryColor = tone === 'neutral' ? theme.colors.surfaceStrong : theme.colors.accent\n const opacity = resolveLumenBackdropOpacity(intensity)\n\n return (\n <View ref={ref} {...props} style={[{ minHeight: 160, overflow: 'hidden', position: 'relative' }, style]}>\n <View\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={{ bottom: 0, left: 0, opacity, pointerEvents: 'none', position: 'absolute', right: 0, top: 0 }}\n >\n {variant === 'aurora' ?\n (\n <>\n <View style={{ backgroundColor: color, borderRadius: 140, height: 280, left: -70, opacity: 0.22, position: 'absolute', top: -100, width: 280 }} />\n <View style={{ backgroundColor: secondaryColor, borderRadius: 120, bottom: -110, height: 240, opacity: 0.18, position: 'absolute', right: -50, width: 240 }} />\n </>\n ) :\n null}\n {variant === 'grid' ?\n (\n <>\n {graphicLines.map(index => (\n <View key={`backdrop-horizontal-${index}`} style={{ backgroundColor: color, height: 1, left: 0, opacity: 0.18, position: 'absolute', right: 0, top: `${index * 16.66}%` }} />\n ))}\n {graphicLines.map(index => (\n <View key={`backdrop-vertical-${index}`} style={{ backgroundColor: color, bottom: 0, left: `${index * 16.66}%`, opacity: 0.18, position: 'absolute', top: 0, width: 1 }} />\n ))}\n </>\n ) :\n null}\n {variant === 'dots' ?\n backdropDots.map(index => (\n <View\n key={`backdrop-dot-${index}`}\n style={{\n backgroundColor: color,\n borderRadius: 2,\n height: 3,\n left: `${(index % 8) * 14 + 1}%`,\n opacity: 0.28,\n position: 'absolute',\n top: `${Math.floor(index / 8) * 19 + 2}%`,\n width: 3\n }}\n />\n )) :\n null}\n {variant === 'rays' ?\n graphicLines.map(index => (\n <View\n key={`backdrop-ray-${index}`}\n style={{\n backgroundColor: color,\n height: 1,\n left: '50%',\n opacity: 0.2,\n position: 'absolute',\n top: '50%',\n transform: [{ rotate: `${index * 24 - 72}deg` }, { translateX: -240 }],\n width: 480\n }}\n />\n )) :\n null}\n </View>\n <View style={{ zIndex: 1 }}>{children}</View>\n </View>\n )\n}\n\nexport type LumenIllustrationTone = 'accent' | 'auto' | 'brand' | 'neutral'\nexport type LumenIllustrationVariant = 'empty' | 'error' | 'offline' | 'success'\n\nexport interface LumenIllustrationProps extends ViewProps {\n label?: string\n ref?: LumenViewRef\n size?: LumenIllustrationSize\n tone?: LumenIllustrationTone\n variant?: LumenIllustrationVariant\n}\n\nconst illustrationPoints = (points: readonly number[]): string => {\n const pairs: string[] = []\n\n for (let index = 0; index < points.length; index += 2) {\n pairs.push(`${String(points[index])},${String(points[index + 1])}`)\n }\n\n return pairs.join(' ')\n}\n\nconst renderIllustrationElement = (\n element: LumenIllustrationElement,\n color: string,\n index: number,\n strokeWidth: number\n): ReactElement => {\n const shared = {\n fill: 'none',\n stroke: color,\n strokeLinecap: 'round' as const,\n strokeLinejoin: 'round' as const,\n strokeWidth\n }\n\n if (element.kind === 'circle') {\n return <Circle key={index} {...shared} cx={element.cx} cy={element.cy} r={element.r} />\n }\n\n if (element.kind === 'rounded-rect') {\n return (\n <Rect\n key={index}\n {...shared}\n height={element.height}\n rx={element.radius}\n transform={`translate(${element.x} ${element.y})`}\n width={element.width}\n />\n )\n }\n\n const points = illustrationPoints(element.points)\n\n if (element.kind === 'line') {\n return (\n <Line\n key={index}\n {...shared}\n x1={element.points[0]}\n x2={element.points[2]}\n y1={element.points[1]}\n y2={element.points[3]}\n />\n )\n }\n\n return element.kind === 'polygon' ?\n <Polygon key={index} {...shared} points={points} /> :\n <Polyline key={index} {...shared} points={points} />\n}\n\nexport const LumenIllustration = ({\n label,\n ref,\n size = 'md',\n style,\n tone = 'auto',\n variant = 'empty',\n ...props\n}: LumenIllustrationProps): ReactElement => {\n const theme = useLumenTheme()\n const dimension = resolveLumenIllustrationSize(size)\n\n const semanticColor = {\n empty: theme.colors.brand,\n error: theme.colors.danger,\n offline: theme.colors.inkMuted,\n success: theme.colors.success\n }[variant]\n\n const color = tone === 'auto' ?\n semanticColor :\n {\n accent: theme.colors.accent,\n brand: theme.colors.brand,\n neutral: theme.colors.inkMuted\n }[tone]\n\n const strokeWidth = lumenGraphicStrokeWidths.standard\n const artwork = lumenIllustrations[variant]\n\n return (\n <View\n ref={ref}\n {...props}\n accessible={Boolean(label)}\n accessibilityElementsHidden={!label}\n accessibilityLabel={label}\n accessibilityRole={label ? 'image' : undefined}\n importantForAccessibility={label ? 'yes' : 'no-hide-descendants'}\n style={[{ alignItems: 'center', height: dimension, justifyContent: 'center', position: 'relative', width: dimension }, style]}\n >\n <Svg height={dimension} viewBox=\"0 0 120 120\" width={dimension}>\n <Circle\n cx=\"60\"\n cy=\"60\"\n fill={color}\n fillOpacity={lumenGraphicOpacities.wash}\n r=\"48\"\n />\n {artwork.elements.map((element, index) => renderIllustrationElement(element, color, index, strokeWidth))}\n </Svg>\n </View>\n )\n}\n\nexport interface LumenDisclosureProps extends Omit<ViewProps, 'children'> {\n children: ReactNode\n description?: string\n disabled?: boolean\n expanded: boolean\n graphic?: ReactNode\n onExpandedChange: (expanded: boolean) => void\n ref?: LumenViewRef\n title: string\n}\n\nexport const LumenDisclosure = ({\n children,\n description,\n disabled = false,\n expanded,\n graphic,\n onExpandedChange,\n ref,\n style,\n title,\n ...props\n}: LumenDisclosureProps): ReactElement => {\n const theme = useLumenTheme()\n const state = resolveLumenDisclosureState(expanded, disabled)\n\n return (\n <View\n ref={ref}\n {...props}\n style={[\n {\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.line,\n borderRadius: theme.radii.md,\n borderWidth: 1,\n overflow: 'hidden'\n },\n style\n ]}\n >\n <Pressable\n accessibilityRole=\"button\"\n accessibilityState={{ disabled, expanded }}\n aria-disabled={disabled}\n aria-expanded={expanded}\n disabled={disabled}\n onPress={() => {\n onExpandedChange(!expanded)\n }}\n style={({ pressed }) => ({\n alignItems: 'center',\n flexDirection: 'row',\n gap: theme.spacing.md,\n minHeight: 48,\n opacity: state.opacity * resolveLumenButtonOpacity(disabled, pressed),\n paddingHorizontal: theme.spacing.lg,\n paddingVertical: theme.spacing.md\n })}\n >\n {graphic ? <View>{graphic}</View> : null}\n <View style={{ flex: 1, gap: theme.spacing.xs }}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {title}\n </Text>\n {description ?\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>{description}</Text> :\n null}\n </View>\n <Text\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={{ color: theme.colors.inkSoft, fontSize: theme.fontSizes.lg }}\n >\n {expanded ? '−' : '+'}\n </Text>\n </Pressable>\n {state.contentVisible ?\n (\n <View\n style={{\n borderTopColor: theme.colors.line,\n borderTopWidth: 1,\n padding: theme.spacing.lg\n }}\n >\n {children}\n </View>\n ) :\n null}\n </View>\n )\n}\n",
|
|
60199
60286
|
"packages/react-native/src/content-recipes.test.ts": "import { describe, expect, test } from 'vitest'\n\nimport {\n resolveLumenBackdropOpacity,\n resolveLumenDisclosureState,\n resolveLumenGraphicSize,\n resolveLumenIllustrationSize,\n resolveLumenSkeletonHeight\n} from './content-recipes.js'\n\ndescribe('content state recipes', () => {\n test('preserves controlled disclosure state', () => {\n expect(resolveLumenDisclosureState(true, false)).toEqual({\n contentVisible: true,\n disabled: false,\n opacity: 1\n })\n expect(resolveLumenDisclosureState(false, true)).toEqual({\n contentVisible: false,\n disabled: true,\n opacity: 0.52\n })\n })\n\n test('normalizes invalid skeleton heights', () => {\n expect(resolveLumenSkeletonHeight(24)).toBe(24)\n expect(resolveLumenSkeletonHeight(0)).toBe(16)\n expect(resolveLumenSkeletonHeight(Number.NaN)).toBe(16)\n })\n\n test('keeps graphic sizes aligned with the shared contract', () => {\n expect(resolveLumenGraphicSize('sm')).toBe(160)\n expect(resolveLumenGraphicSize('md')).toBe(240)\n expect(resolveLumenGraphicSize('lg')).toBe(320)\n })\n\n test('normalizes backdrop intensity and illustration sizes', () => {\n expect(resolveLumenBackdropOpacity('subtle')).toBe(0.4)\n expect(resolveLumenBackdropOpacity('medium')).toBe(0.68)\n expect(resolveLumenBackdropOpacity('strong')).toBe(1)\n expect(resolveLumenIllustrationSize('sm')).toBe(96)\n expect(resolveLumenIllustrationSize('md')).toBe(128)\n expect(resolveLumenIllustrationSize('lg')).toBe(176)\n })\n})\n",
|
|
60200
60287
|
"packages/react-native/src/content-recipes.ts": "import { lumenGraphicFrameSizes, lumenIllustrationSizes } from './tokens.generated.js'\n\nexport interface LumenDisclosureState {\n contentVisible: boolean\n disabled: boolean\n opacity: number\n}\n\nexport const resolveLumenDisclosureState = (\n expanded: boolean,\n disabled: boolean\n): LumenDisclosureState => ({\n contentVisible: expanded,\n disabled,\n opacity: disabled ? 0.52 : 1\n})\n\nexport const resolveLumenSkeletonHeight = (height: number): number => (\n Number.isFinite(height) && height > 0 ? height : 16\n)\n\nexport type LumenGraphicSize = 'lg' | 'md' | 'sm'\n\nexport const resolveLumenGraphicSize = (size: LumenGraphicSize): number => ({\n lg: lumenGraphicFrameSizes.lg,\n md: lumenGraphicFrameSizes.md,\n sm: lumenGraphicFrameSizes.sm\n})[size]\n\nexport type LumenBackdropIntensity = 'medium' | 'strong' | 'subtle'\n\nexport const resolveLumenBackdropOpacity = (\n intensity: LumenBackdropIntensity\n): number => ({ medium: 0.68, strong: 1, subtle: 0.4 })[intensity]\n\nexport type LumenIllustrationSize = 'lg' | 'md' | 'sm'\n\nexport const resolveLumenIllustrationSize = (\n size: LumenIllustrationSize\n): number => ({\n lg: lumenIllustrationSizes.lg,\n md: lumenIllustrationSizes.md,\n sm: lumenIllustrationSizes.sm\n})[size]\n",
|
|
@@ -60202,11 +60289,11 @@
|
|
|
60202
60289
|
"packages/react-native/src/data-table-components.test.tsx": "import { act, createElement, type ReactElement } from 'react'\n\nimport { createRoot, type Root, type TestInstance } from 'test-renderer'\nimport { afterEach, expect, test, vi } from 'vitest'\n\nimport { LumenDataTable, type LumenDataTableProps } from './table-components.js'\n\nObject.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })\nvi.mock('react-native', () => ({\n View: (props: Record<string, unknown>): ReactElement => createElement('View', props),\n ScrollView: (props: Record<string, unknown>): ReactElement => createElement('ScrollView', props)\n}))\nvi.mock('./primitives.js', () => ({\n LumenButton: (props: Record<string, unknown>): ReactElement => createElement('Button', props),\n LumenText: (props: Record<string, unknown>): ReactElement => createElement('Text', props)\n}))\nvi.mock('./selection-components.js', () => ({\n LumenCheckbox: (props: Record<string, unknown>): ReactElement => createElement('Checkbox', props)\n}))\nvi.mock('./theme-context.js', () => ({ useLumenTheme: () => ({ spacing: { xs: 4, sm: 8, md: 16 },\n radii: { md: 8 },\n colors: { line: '#888' } }) }))\nconst roots: Root[] = []\nconst columns = [{ key: 'amount', label: 'Amount', sortable: true }]\nconst rows = [\n { id: 'large', label: 'Large', cells: { amount: { text: '20 items', sortValue: 20 } } },\n { id: 'small', label: 'Small', cells: { amount: { text: '2 items', sortValue: 2 } } },\n { id: 'locked', label: 'Locked', disabled: true, cells: { amount: { text: 'Missing', sortValue: null } } }\n]\nafterEach(() => {\n act(() => {\n for (const root of roots) root.unmount()\n })\n roots.length = 0\n})\nconst read = (instance: TestInstance, key: string): unknown => (instance.props as Record<string, unknown>)[key]\nconst render = (props: Partial<LumenDataTableProps> = {}): {\n root: Root\n selection: ReturnType<typeof vi.fn>\n sort: ReturnType<typeof vi.fn>\n} => {\n const root = createRoot()\n const selection = vi.fn()\n const sort = vi.fn()\n\n roots.push(root)\n act(() => {\n root.render(\n <LumenDataTable\n label=\"Synthetic records\"\n rows={rows}\n columns={columns}\n selectedIds={new Set(['hidden', 'locked'])}\n onSelectionChange={selection}\n onSortChange={sort}\n {...props}\n />\n )\n })\n\n return { root, selection, sort }\n}\nconst find = (root: Root, type: string, label: string): TestInstance => {\n const item = root.container.queryAll(instance => instance.type === type && read(instance, 'accessibilityLabel') === label)[0]\n\n if (!item) throw new Error(`Missing ${label}`)\n\n return item\n}\nconst invoke = (instance: TestInstance, key: string, args: readonly unknown[] = []): void => {\n const handler = read(instance, key)\n\n if (typeof handler !== 'function') throw new Error(`Missing ${key}`)\n act(() => {\n Reflect.apply(handler, undefined, args)\n })\n}\nconst order = (root: Root): readonly unknown[] => root.container.queryAll(item => item.type === 'Checkbox').map(item => read(item, 'label'))\n\ntest.each([null, [], ['small'], {}, new Set([42])])('malformed controlled selection renders invalid feedback (%#)', selectedIds => {\n const props: Partial<LumenDataTableProps> = { onSelectionChange: undefined, invalidLabel: 'Invalid selection' }\n Reflect.set(props, 'selectedIds', selectedIds)\n const { root } = render(props)\n expect(root.container.queryAll(item => item.type === 'Text').some(item => item.children.includes('Invalid selection'))).toBe(true)\n expect(root.container.queryAll(item => item.type === 'Checkbox')).toHaveLength(0)\n})\n\ntest('client sorting changes displayed stable rows, localizes status and proposes controlled next sort', () => {\n const { root, sort } = render({ sortMode: 'client',\n sort: { key: 'amount', direction: 'ascending' },\n formatSort: value => value?.direction === 'ascending' ? 'Increasing' : 'Decreasing' })\n\n expect(order(root)).toEqual(['Small', 'Large', 'Locked'])\n expect(find(root, 'Button', 'Amount, Increasing')).toBeDefined()\n invoke(find(root, 'Button', 'Amount, Increasing'), 'onPress')\n expect(sort).toHaveBeenCalledWith({ key: 'amount', direction: 'descending' })\n expect(order(root)).toEqual(['Small', 'Large', 'Locked'])\n expect(rows.map(row => row.id)).toEqual(['large', 'small', 'locked'])\n})\n\ntest('manual sorting retains host order while emitting a sort request', () => {\n const { root, sort } = render({ sortMode: 'manual', sort: { key: 'amount', direction: 'ascending' } })\n\n expect(order(root)).toEqual(['Large', 'Small', 'Locked'])\n invoke(find(root, 'Button', 'Amount, ascending'), 'onPress')\n expect(sort).toHaveBeenCalledWith({ key: 'amount', direction: 'descending' })\n expect(order(root)).toEqual(['Large', 'Small', 'Locked'])\n})\n\ntest('bulk and individual selection preserve hidden and disabled IDs without mutating controlled input', () => {\n const ids = new Set(['hidden', 'locked'])\n const { root, selection } = render({ selectedIds: ids })\n\n invoke(find(root, 'Button', 'Select visible'), 'onPress')\n expect(selection).toHaveBeenLastCalledWith(new Set(['hidden', 'locked', 'large', 'small']))\n invoke(find(root, 'Checkbox', 'Small'), 'onCheckedChange', [true])\n expect(selection).toHaveBeenLastCalledWith(new Set(['hidden', 'locked', 'small']))\n invoke(find(root, 'Checkbox', 'Locked'), 'onCheckedChange', [false])\n expect(selection).toHaveBeenCalledTimes(2)\n expect(ids).toEqual(new Set(['hidden', 'locked']))\n const selected = render({ selectedIds: new Set(['hidden', 'locked', 'large', 'small']), deselectAllLabel: 'Clear visible' })\n\n invoke(find(selected.root, 'Button', 'Clear visible'), 'onPress')\n expect(selected.selection).toHaveBeenCalledWith(ids)\n})\n\ntest('disabled and readOnly block sort and selection proposals even for retained selected rows', () => {\n for (const props of [{ disabled: true }, { readOnly: true }]) {\n const { root, selection, sort } = render(props)\n\n invoke(find(root, 'Button', 'Amount'), 'onPress')\n invoke(find(root, 'Button', 'Select visible'), 'onPress')\n invoke(find(root, 'Checkbox', 'Small'), 'onCheckedChange', [true])\n expect(selection).not.toHaveBeenCalled()\n expect(sort).not.toHaveBeenCalled()\n expect(read(find(root, 'Checkbox', 'Small'), 'disabled')).toBe(true)\n }\n})\n\ntest('loading, even an empty error, invalid IDs and empty data replace stale controls', () => {\n for (const props of [{ loading: true }, { error: '' }, { rows: [rows[0], rows[0]].filter(row => row !== undefined) }, { rows: [] }]) {\n const { root, selection, sort } = render(props)\n\n expect(root.container.queryAll(item => item.type === 'Button' || item.type === 'Checkbox')).toHaveLength(0)\n expect(selection).not.toHaveBeenCalled()\n expect(sort).not.toHaveBeenCalled()\n }\n})\n\ntest('localized error recovery remains host-owned and disabled retry stays disabled', () => {\n const retry = vi.fn()\n const { root } = render({ error: 'Unavailable data', retryLabel: 'Try records again', onRetry: retry, disabled: true })\n\n expect(read(find(root, 'Button', 'Try records again'), 'disabled')).toBe(true)\n expect(retry).not.toHaveBeenCalled()\n const enabled = render({ error: 'Unavailable data', retryLabel: 'Try records again', onRetry: retry })\n\n invoke(find(enabled.root, 'Button', 'Try records again'), 'onPress')\n expect(retry).toHaveBeenCalledOnce()\n})\n\ntest('horizontal layout retains sort controls and inherited prototype cells use the missing label', () => {\n const { root } = render({ layout: 'scroll',\n columns: [{ key: 'toString', label: 'Prototype name', sortable: true }],\n rows: [{ id: 'one', label: 'One', cells: {} }],\n missingLabel: 'No cell value' })\n\n expect(root.container.queryAll(item => item.type === 'ScrollView' && read(item, 'horizontal') === true)).toHaveLength(1)\n expect(find(root, 'Button', 'Prototype name')).toBeDefined()\n expect(root.container.queryAll(item => item.type === 'Text' && read(item, 'children') === 'No cell value')).toHaveLength(1)\n})\n",
|
|
60203
60290
|
"packages/react-native/src/datetime-components.tsx": "import {\n type ChangeEvent,\n createElement,\n type ReactElement,\n useEffect,\n useState\n} from 'react'\nimport {\n Platform,\n Pressable,\n Text,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\n\nimport {\n DateTimePickerAndroid,\n type DateTimePickerChangeEvent\n} from '@react-native-community/datetimepicker'\n\nimport {\n clampLumenDate,\n formatLumenDateDisplayValue,\n formatLumenDateInputValue,\n isValidLumenDate,\n type LumenDateRangeValue,\n parseLumenDateInputValue,\n resolveLumenDateBounds,\n resolveLumenDatePickerValue,\n resolveLumenDateRangeEndChange,\n resolveLumenDateRangeEndMinimum,\n resolveLumenDateRangeStartChange\n} from './form-recipes.js'\nimport { NativeDatePicker, type NativeDatePickerProps } from './native-date-picker.js'\nimport {\n resolveLumenAriaInvalid,\n resolveLumenButtonOpacity,\n resolveLumenValidationHint\n} from './recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nconst defaultDateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' })\n\ninterface DateSupportingTextProps {\n description?: string | undefined\n errorMessage?: string | undefined\n}\n\nconst resolveDateLabelColor = (\n danger: string,\n ink: string,\n errorMessage?: string\n): string => errorMessage ? danger : ink\n\nconst resolveDatePickerExpanded = (enabled: boolean, expanded: boolean): boolean => (\n enabled && expanded\n)\n\nconst dismissAndroidDatePicker = (): void => {\n if (Platform.OS === 'android') void DateTimePickerAndroid.dismiss('date')\n}\n\nconst DateSupportingText = ({ description, errorMessage }: DateSupportingTextProps): ReactElement | null => {\n const theme = useLumenTheme()\n const supportingText = errorMessage ?? description\n\n if (!supportingText) return null\n\n return (\n <Text\n accessibilityLiveRegion={errorMessage ? 'polite' : 'none'}\n style={{\n color: errorMessage ? theme.colors.danger : theme.colors.inkMuted,\n fontSize: theme.fontSizes.xs\n }}\n >\n {supportingText}\n </Text>\n )\n}\n\ninterface NativeDatePickerDisclosureProps extends NativeDatePickerProps {\n expanded: boolean\n label: string\n onDateChange: (date: Date) => void\n webValue: Date | null\n}\n\nconst NativeDatePickerDisclosure = ({\n expanded,\n label,\n onDateChange,\n webValue,\n ...props\n}: NativeDatePickerDisclosureProps): ReactElement | null => {\n if (!expanded || Platform.OS === 'android') return null\n\n if (Platform.OS === 'web') {\n return createElement('input', {\n 'aria-label': label,\n disabled: props.disabled,\n max: props.maximumDate ? formatLumenDateInputValue(props.maximumDate) : undefined,\n min: props.minimumDate ? formatLumenDateInputValue(props.minimumDate) : undefined,\n onChange: (event: ChangeEvent<HTMLInputElement>) => {\n const date = parseLumenDateInputValue(event.currentTarget.value)\n\n if (date) onDateChange(date)\n },\n style: {\n accentColor: props.accentColor,\n border: '1px solid currentColor',\n borderRadius: 6,\n colorScheme: props.themeVariant,\n font: 'inherit',\n minHeight: 44,\n padding: '0 12px'\n },\n type: 'date',\n value: webValue ? formatLumenDateInputValue(webValue) : ''\n })\n }\n\n return <NativeDatePicker {...props} />\n}\n\nexport interface LumenDateFieldProps extends Omit<ViewProps, 'children'> {\n description?: string\n enabled?: boolean\n errorMessage?: string\n formatValue?: (date: Date) => string\n label: string\n maximumDate?: Date\n minimumDate?: Date\n onValueChange: (value: Date) => void\n placeholder?: string\n value: Date | null\n}\n\nconst LumenDateFieldControl = ({\n description,\n enabled = true,\n errorMessage,\n formatValue,\n label,\n maximumDate,\n minimumDate,\n onValueChange,\n placeholder = 'Select a date',\n style,\n value,\n ...props\n}: LumenDateFieldProps): ReactElement => {\n const theme = useLumenTheme()\n const [expanded, setExpanded] = useState(false)\n const pickerExpanded = resolveDatePickerExpanded(enabled, expanded)\n const validValue = isValidLumenDate(value) ? value : null\n const pickerValue = resolveLumenDatePickerValue(value, minimumDate, maximumDate)\n\n const displayValue = formatLumenDateDisplayValue(\n value,\n placeholder,\n formatValue ?? defaultDateFormatter.format.bind(defaultDateFormatter)\n )\n\n const pickerBounds = resolveLumenDateBounds(minimumDate, maximumDate)\n\n useEffect(() => dismissAndroidDatePicker, [])\n\n const commitValue = (_event: DateTimePickerChangeEvent, selectedDate?: Date): void => {\n if (!selectedDate) return\n\n onValueChange(clampLumenDate(selectedDate, minimumDate, maximumDate))\n }\n\n const openPicker = (): void => {\n if (!enabled) return\n\n if (Platform.OS === 'android') {\n DateTimePickerAndroid.open({\n ...pickerBounds,\n mode: 'date',\n onChange: commitValue,\n value: pickerValue\n })\n\n return\n }\n\n setExpanded(previous => !previous)\n }\n\n return (\n <View {...props} style={[{ gap: theme.spacing.xs }, style]}>\n <Text\n style={{\n color: errorMessage ? theme.colors.danger : theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n <Pressable\n accessibilityHint={resolveLumenValidationHint(errorMessage, description)}\n accessibilityLabel={`${label}, ${displayValue}`}\n accessibilityRole=\"button\"\n accessibilityState={{ disabled: !enabled, expanded: pickerExpanded }}\n aria-invalid={resolveLumenAriaInvalid(errorMessage)}\n disabled={!enabled}\n onPress={openPicker}\n style={({ pressed }) => ({\n alignItems: 'center',\n backgroundColor: theme.colors.surface,\n borderColor: errorMessage ? theme.colors.danger : theme.colors.line,\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n flexDirection: 'row',\n minHeight: 44,\n opacity: enabled ? resolveLumenButtonOpacity(false, pressed) : 0.52,\n paddingHorizontal: theme.spacing.md\n })}\n >\n <Text\n style={{\n color: validValue ? theme.colors.ink : theme.colors.inkMuted,\n flex: 1,\n fontSize: theme.fontSizes.sm\n }}\n >\n {displayValue}\n </Text>\n <Text\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={{ color: theme.colors.inkMuted }}\n >\n ▾\n </Text>\n </Pressable>\n <NativeDatePickerDisclosure\n {...pickerBounds}\n accentColor={theme.colors.brandSolid}\n disabled={!enabled}\n display={Platform.OS === 'ios' ? 'inline' : 'default'}\n expanded={pickerExpanded}\n label={label}\n mode=\"date\"\n onDateChange={selectedDate => {\n onValueChange(clampLumenDate(selectedDate, minimumDate, maximumDate))\n }}\n onChange={commitValue}\n themeVariant={theme.scheme}\n value={pickerValue}\n webValue={validValue}\n />\n <DateSupportingText\n {...(description ? { description } : {})}\n {...(errorMessage ? { errorMessage } : {})}\n />\n </View>\n )\n}\n\nexport const LumenDateField = (props: LumenDateFieldProps): ReactElement => (\n <LumenDateFieldControl key={String(props.enabled ?? true)} {...props} />\n)\n\nexport interface LumenDateRangeFieldProps extends Omit<ViewProps, 'children'> {\n description?: string\n enabled?: boolean\n endLabel?: string\n errorMessage?: string\n formatValue?: (date: Date) => string\n label: string\n maximumDate?: Date\n minimumDate?: Date\n onValueChange: (value: LumenDateRangeValue) => void\n placeholder?: string\n startLabel?: string\n value: LumenDateRangeValue\n}\n\nconst resolveSharedDateFieldProps = (\n enabled: boolean,\n formatValue?: (date: Date) => string,\n maximumDate?: Date,\n placeholder?: string\n): Pick<LumenDateFieldProps, 'enabled' | 'formatValue' | 'maximumDate' | 'placeholder'> => ({\n enabled,\n ...(formatValue ? { formatValue } : {}),\n ...(maximumDate ? { maximumDate } : {}),\n ...(placeholder ? { placeholder } : {})\n})\n\nexport const LumenDateRangeField = ({\n description,\n enabled = true,\n endLabel = 'End date',\n errorMessage,\n formatValue,\n label,\n maximumDate,\n minimumDate,\n onValueChange,\n placeholder,\n startLabel = 'Start date',\n style,\n value,\n ...props\n}: LumenDateRangeFieldProps): ReactElement => {\n const theme = useLumenTheme()\n const endMinimum = resolveLumenDateRangeEndMinimum(value.start, minimumDate)\n const labelColor = resolveDateLabelColor(theme.colors.danger, theme.colors.ink, errorMessage)\n const sharedFieldProps = resolveSharedDateFieldProps(enabled, formatValue, maximumDate, placeholder)\n\n return (\n <View\n {...props}\n accessibilityHint={resolveLumenValidationHint(errorMessage, description, props.accessibilityHint)}\n accessibilityLabel={props.accessibilityLabel ?? label}\n aria-invalid={resolveLumenAriaInvalid(errorMessage)}\n style={[{ gap: theme.spacing.sm }, style]}\n >\n <Text\n style={{\n color: labelColor,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n <LumenDateField\n {...sharedFieldProps}\n label={startLabel}\n {...(minimumDate ? { minimumDate } : {})}\n onValueChange={start => {\n onValueChange(resolveLumenDateRangeStartChange(value, start, minimumDate, maximumDate))\n }}\n value={value.start}\n />\n <LumenDateField\n {...sharedFieldProps}\n label={endLabel}\n {...(endMinimum ? { minimumDate: endMinimum } : {})}\n onValueChange={end => {\n onValueChange(resolveLumenDateRangeEndChange(value, end, minimumDate, maximumDate))\n }}\n value={value.end}\n />\n <DateSupportingText\n {...(description ? { description } : {})}\n {...(errorMessage ? { errorMessage } : {})}\n />\n </View>\n )\n}\n",
|
|
60204
60291
|
"packages/react-native/src/datetime.ts": "export {\n LumenDateField,\n type LumenDateFieldProps,\n LumenDateRangeField,\n type LumenDateRangeFieldProps\n} from './datetime-components.js'\nexport {\n type LumenDateRangeValue\n} from './form-recipes.js'\nexport { LumenTimeField, type LumenTimeFieldProps } from './time-components.js'\nexport { type LumenTimeSelection } from '@santi020k/lumen-core'\n",
|
|
60205
|
-
"packages/react-native/src/field-context.ts": "import { createContext, use } from 'react'\n\nexport interface LumenFieldContextValue {\n descriptionId?: string\n error: boolean\n errorId?: string\n label: string\n labelId: string\n required: boolean\n}\n\nexport const LumenFieldContext = createContext<LumenFieldContextValue | null>(null)\n\nexport const useLumenFieldContext = (): LumenFieldContextValue | null => use(LumenFieldContext)\n",
|
|
60292
|
+
"packages/react-native/src/field-context.ts": "import { createContext, use } from 'react'\n\nexport interface LumenFieldContextValue {\n /** Spoken guidance for controls that announce field context through a hint instead of relationships. */\n description?: string\n descriptionId?: string\n error: boolean\n errorId?: string\n errorMessage?: string\n label: string\n labelId: string\n required: boolean\n}\n\nexport const LumenFieldContext = createContext<LumenFieldContextValue | null>(null)\n\nexport const useLumenFieldContext = (): LumenFieldContextValue | null => use(LumenFieldContext)\n",
|
|
60206
60293
|
"packages/react-native/src/form-components.tsx": "import {\n type ReactElement,\n type ReactNode\n} from 'react'\nimport {\n Platform,\n Pressable,\n Switch,\n type SwitchProps,\n Text,\n TextInput,\n type TextInputProps,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\n\nimport { resolveLumenSearchFieldState } from './form-recipes.js'\nimport type { LumenSwitchRef, LumenTextInputRef, LumenViewRef } from './native-ref-types.js'\nimport { resolveLumenButtonOpacity } from './recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nconst resolveSearchValue = <Value,>(value: Value | undefined, fallback: Value): Value => value ?? fallback\n\nexport interface LumenToggleProps extends Omit<SwitchProps, 'onValueChange' | 'value'> {\n description?: string\n label: string\n onValueChange: (value: boolean) => void\n ref?: LumenSwitchRef\n showLabel?: boolean\n value: boolean\n}\n\nexport const LumenToggle = ({\n accessibilityHint,\n description,\n disabled = false,\n label,\n onValueChange,\n ref,\n showLabel = true,\n style,\n value,\n ...props\n}: LumenToggleProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <Pressable\n accessibilityHint={accessibilityHint ?? description}\n accessibilityLabel={label}\n accessibilityRole=\"switch\"\n accessibilityState={{ checked: value, disabled }}\n aria-checked={value}\n disabled={disabled}\n onPress={() => {\n if (!disabled) onValueChange(!value)\n }}\n style={{\n alignItems: 'center',\n flexDirection: 'row',\n gap: theme.spacing.lg,\n minHeight: 44\n }}\n >\n {showLabel ?\n (\n <View style={{ flex: 1, gap: theme.spacing.xs }}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n {description ?\n <Text style={{ color: theme.colors.inkSoft, fontSize: theme.fontSizes.xs }}>{description}</Text> :\n null}\n </View>\n ) :\n null}\n <Switch\n ref={ref}\n {...props}\n accessibilityElementsHidden\n aria-hidden\n disabled={disabled || Platform.OS === 'web'}\n importantForAccessibility=\"no-hide-descendants\"\n ios_backgroundColor={theme.colors.surfaceStrong}\n pointerEvents=\"none\"\n style={[{ opacity: disabled ? 0.52 : 1 }, style]}\n thumbColor={value ? theme.colors.onBrand : theme.colors.surface}\n trackColor={{ false: theme.colors.surfaceStrong, true: theme.colors.brandSolid }}\n value={value}\n />\n </Pressable>\n )\n}\n\nexport interface LumenSettingsRowProps extends ViewProps {\n control: ReactNode\n description?: string\n graphic?: ReactNode\n ref?: LumenViewRef\n title: string\n}\n\nexport const LumenSettingsRow = ({\n control,\n description,\n graphic,\n ref,\n style,\n title,\n ...props\n}: LumenSettingsRowProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <View\n ref={ref}\n {...props}\n style={[\n {\n alignItems: 'center',\n flexDirection: 'row',\n gap: theme.spacing.lg,\n minHeight: 56,\n paddingVertical: theme.spacing.sm\n },\n style\n ]}\n >\n {graphic ? <View>{graphic}</View> : null}\n <View style={{ flex: 1, gap: theme.spacing.xs }}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {title}\n </Text>\n {description ?\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>{description}</Text> :\n null}\n </View>\n <View>{control}</View>\n </View>\n )\n}\n\nexport interface LumenSearchFieldProps extends Omit<TextInputProps, 'onChangeText' | 'value'> {\n clearLabel?: string\n graphic?: ReactNode\n onChangeText: (value: string) => void\n prompt?: string\n ref?: LumenTextInputRef\n value: string\n}\n\nexport const LumenSearchField = ({\n accessibilityState,\n clearLabel = 'Clear search',\n editable = true,\n graphic,\n onChangeText,\n placeholder,\n placeholderTextColor,\n prompt = 'Search',\n ref,\n returnKeyType = 'search',\n style,\n value,\n ...props\n}: LumenSearchFieldProps): ReactElement => {\n const theme = useLumenTheme()\n const searchState = resolveLumenSearchFieldState(value, editable)\n\n return (\n <View\n style={{\n alignItems: 'center',\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.line,\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n flexDirection: 'row',\n gap: theme.spacing.sm,\n minHeight: 44,\n opacity: searchState.opacity,\n paddingLeft: theme.spacing.md,\n paddingRight: theme.spacing.xs\n }}\n >\n {graphic}\n <TextInput\n ref={ref}\n {...props}\n accessibilityLabel={resolveSearchValue(props.accessibilityLabel, prompt)}\n accessibilityState={{ ...accessibilityState, disabled: !editable }}\n editable={editable}\n onChangeText={onChangeText}\n placeholder={resolveSearchValue(placeholder, prompt)}\n placeholderTextColor={resolveSearchValue(placeholderTextColor, theme.colors.inkMuted)}\n returnKeyType={returnKeyType}\n style={[\n {\n color: theme.colors.ink,\n flex: 1,\n fontSize: theme.fontSizes.sm,\n minHeight: 42,\n paddingVertical: theme.spacing.sm\n },\n style\n ]}\n value={value}\n />\n {searchState.showClearAction ?\n (\n <Pressable\n accessibilityLabel={clearLabel}\n accessibilityRole=\"button\"\n hitSlop={4}\n onPress={() => {\n onChangeText('')\n }}\n style={({ pressed }) => ({\n alignItems: 'center',\n justifyContent: 'center',\n minHeight: 44,\n minWidth: 44,\n opacity: resolveLumenButtonOpacity(false, pressed),\n paddingHorizontal: theme.spacing.sm\n })}\n >\n <Text\n style={{\n color: theme.colors.inkSoft,\n fontSize: theme.fontSizes.xs,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {clearLabel}\n </Text>\n </Pressable>\n ) :\n null}\n </View>\n )\n}\n",
|
|
60207
60294
|
"packages/react-native/src/form-recipes.test.ts": "import { describe, expect, test } from 'vitest'\n\nimport {\n clampLumenDate,\n formatLumenDateDisplayValue,\n formatLumenDateInputValue,\n parseLumenDateInputValue,\n resolveLumenDateBounds,\n resolveLumenDatePickerValue,\n resolveLumenDateRangeEndChange,\n resolveLumenDateRangeStartChange,\n resolveLumenSearchFieldState\n} from './form-recipes.js'\n\ndescribe('Lumen React Native form component recipes', () => {\n test('shows the clear action only for editable non-empty queries', () => {\n expect(resolveLumenSearchFieldState('lumen', true)).toEqual({\n opacity: 1,\n showClearAction: true\n })\n expect(resolveLumenSearchFieldState('', true).showClearAction).toBe(false)\n expect(resolveLumenSearchFieldState('lumen', false)).toEqual({\n opacity: 0.52,\n showClearAction: false\n })\n })\n\n test('normalizes and clamps picker dates to inclusive bounds', () => {\n const minimum = new Date(2026, 7, 10)\n const maximum = new Date(2026, 7, 20)\n\n expect(clampLumenDate(new Date(2026, 7, 5, 18), minimum, maximum)).toEqual(minimum)\n expect(clampLumenDate(new Date(2026, 7, 15, 18), minimum, maximum)).toEqual(new Date(2026, 7, 15))\n expect(resolveLumenDatePickerValue(null, minimum, maximum, new Date(2026, 7, 25))).toEqual(maximum)\n expect(resolveLumenDateBounds(maximum, minimum)).toEqual({\n maximumDate: maximum,\n minimumDate: maximum\n })\n expect(resolveLumenDateBounds(new Date(Number.NaN), maximum)).toEqual({ maximumDate: maximum })\n expect(resolveLumenDateBounds(minimum, new Date(Number.NaN))).toEqual({ minimumDate: minimum })\n expect(resolveLumenDatePickerValue(new Date(Number.NaN), minimum, maximum, minimum)).toEqual(minimum)\n })\n\n test('uses the placeholder without formatting an invalid controlled date', () => {\n const formatter = (date: Date): string => date.toISOString()\n\n expect(formatLumenDateDisplayValue(new Date(Number.NaN), 'Select a date', formatter))\n .toBe('Select a date')\n })\n\n test('keeps a controlled date range ordered as either boundary changes', () => {\n const initial = {\n end: new Date(2026, 7, 12),\n start: new Date(2026, 7, 10)\n }\n const movedStart = resolveLumenDateRangeStartChange(initial, new Date(2026, 7, 15))\n\n expect(movedStart).toEqual({\n end: new Date(2026, 7, 15),\n start: new Date(2026, 7, 15)\n })\n expect(resolveLumenDateRangeEndChange(movedStart, new Date(2026, 7, 8))).toEqual(movedStart)\n })\n\n test('round-trips valid browser date input without timezone conversion', () => {\n expect(formatLumenDateInputValue(new Date(2026, 7, 26, 23, 45))).toBe('2026-08-26')\n expect(parseLumenDateInputValue('2026-08-26')).toEqual(new Date(2026, 7, 26))\n expect(parseLumenDateInputValue('2026-02-30')).toBeNull()\n expect(parseLumenDateInputValue('08/26/2026')).toBeNull()\n })\n})\n",
|
|
60208
60295
|
"packages/react-native/src/form-recipes.ts": "export interface LumenSearchFieldState {\n opacity: number\n showClearAction: boolean\n}\n\nexport const resolveLumenSearchFieldState = (\n value: string,\n editable: boolean\n): LumenSearchFieldState => ({\n opacity: editable ? 1 : 0.52,\n showClearAction: editable && value.length > 0\n})\n\nexport interface LumenDateRangeValue {\n end: Date | null\n start: Date | null\n}\n\nexport const isValidLumenDate = (value: Date | null | undefined): value is Date => (\n value !== null && value !== undefined && Number.isFinite(value.getTime())\n)\n\nconst normalizeLumenDate = (value: Date): Date => (\n isValidLumenDate(value) ? new Date(value.getFullYear(), value.getMonth(), value.getDate()) : new Date(0)\n)\n\nexport const formatLumenDateDisplayValue = (\n value: Date | null,\n placeholder: string,\n formatter: (date: Date) => string\n): string => isValidLumenDate(value) ? formatter(value) : placeholder\n\nconst padLumenDatePart = (value: number): string => String(value).padStart(2, '0')\n\nexport const formatLumenDateInputValue = (value: Date): string => {\n const normalized = normalizeLumenDate(value)\n\n return `${normalized.getFullYear()}-${padLumenDatePart(normalized.getMonth() + 1)}-${padLumenDatePart(normalized.getDate())}`\n}\n\nexport const parseLumenDateInputValue = (value: string): Date | null => {\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(value)\n\n if (!match) return null\n\n const year = Number(match[1])\n const month = Number(match[2]) - 1\n const day = Number(match[3])\n const date = new Date(year, month, day)\n\n if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null\n\n return date\n}\n\nexport interface LumenDateBounds {\n maximumDate?: Date\n minimumDate?: Date\n}\n\nexport const resolveLumenDateBounds = (\n minimumDate?: Date,\n maximumDate?: Date\n): LumenDateBounds => {\n const bounds: LumenDateBounds = {}\n\n if (isValidLumenDate(minimumDate)) bounds.minimumDate = normalizeLumenDate(minimumDate)\n\n if (isValidLumenDate(maximumDate)) bounds.maximumDate = normalizeLumenDate(maximumDate)\n\n if (bounds.minimumDate && bounds.maximumDate && bounds.maximumDate < bounds.minimumDate) {\n bounds.maximumDate = bounds.minimumDate\n }\n\n return bounds\n}\n\nexport const clampLumenDate = (\n value: Date,\n minimumDate?: Date,\n maximumDate?: Date\n): Date => {\n const normalizedValue = normalizeLumenDate(value)\n\n const { maximumDate: normalizedMaximum, minimumDate: normalizedMinimum } = resolveLumenDateBounds(\n minimumDate,\n maximumDate\n )\n\n if (normalizedMinimum && normalizedValue < normalizedMinimum) return normalizedMinimum\n\n if (normalizedMaximum && normalizedValue > normalizedMaximum) return normalizedMaximum\n\n return normalizedValue\n}\n\nexport const resolveLumenDatePickerValue = (\n value: Date | null,\n minimumDate?: Date,\n maximumDate?: Date,\n fallback = new Date()\n): Date => clampLumenDate(isValidLumenDate(value) ? value : fallback, minimumDate, maximumDate)\n\nexport const resolveLumenDateRangeEndMinimum = (\n start: Date | null,\n minimumDate?: Date\n): Date | undefined => {\n if (!start) return minimumDate\n\n if (!minimumDate || start > minimumDate) return start\n\n return minimumDate\n}\n\nexport const resolveLumenDateRangeStartChange = (\n value: LumenDateRangeValue,\n start: Date,\n minimumDate?: Date,\n maximumDate?: Date\n): LumenDateRangeValue => {\n const nextStart = clampLumenDate(start, minimumDate, maximumDate)\n const nextEnd = value.end && normalizeLumenDate(value.end) < nextStart ? nextStart : value.end\n\n return { end: nextEnd, start: nextStart }\n}\n\nexport const resolveLumenDateRangeEndChange = (\n value: LumenDateRangeValue,\n end: Date,\n minimumDate?: Date,\n maximumDate?: Date\n): LumenDateRangeValue => {\n const effectiveMinimum = resolveLumenDateRangeEndMinimum(value.start, minimumDate)\n\n return {\n end: clampLumenDate(end, effectiveMinimum, maximumDate),\n start: value.start\n }\n}\n",
|
|
60209
|
-
"packages/react-native/src/foundation-primitives.tsx": "import {\n type ReactElement,\n type ReactNode\n} from 'react'\nimport {\n ActivityIndicator,\n type ActivityIndicatorProps,\n type ColorValue,\n Pressable,\n type PressableProps,\n Text,\n TextInput,\n type TextInputProps,\n type TextProps,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\n\nimport { LumenControlContentContext, useLumenControlContentColor } from './control-content-context.js'\nimport {\n type LumenFieldContextValue,\n useLumenFieldContext\n} from './field-context.js'\nimport type {\n LumenActivityIndicatorRef,\n LumenTextInputRef,\n LumenTextRef,\n LumenViewRef\n} from './native-ref-types.js'\nimport {\n resolveLumenButtonColors,\n resolveLumenButtonOpacity,\n resolveLumenButtonSize,\n resolveLumenControlMinHeight,\n resolveLumenPressableStyle,\n resolveLumenSurfaceColor,\n resolveLumenTextColor,\n resolveLumenTextStyle\n} from './recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nexport type LumenTextTone =\n | 'danger' |\n 'default' |\n 'muted' |\n 'soft' |\n 'success' |\n 'warning'\nexport type LumenTextVariant = 'body' | 'caption' | 'label' | 'title'\n\nexport interface LumenTextProps extends TextProps {\n ref?: LumenTextRef\n tone?: LumenTextTone\n variant?: LumenTextVariant\n}\n\nexport const LumenText = ({\n ref,\n style,\n tone = 'default',\n variant = 'body',\n ...props\n}: LumenTextProps): ReactElement => {\n const theme = useLumenTheme()\n const contentColor = useLumenControlContentColor()\n\n return (\n <Text\n ref={ref}\n {...props}\n style={[\n { color: tone === 'default' ? contentColor ?? theme.colors.ink : resolveLumenTextColor(theme.colors, tone) },\n resolveLumenTextStyle(theme, variant),\n style\n ]}\n />\n )\n}\n\nexport type LumenSurfacePadding = 'lg' | 'md' | 'none' | 'sm' | 'xl'\nexport type LumenSurfaceRadius = '2xl' | '3xl' | 'lg' | 'md' | 'none' | 'sm' | 'xl'\nexport type LumenSurfaceTone = 'canvas' | 'muted' | 'strong' | 'surface'\n\nexport interface LumenSurfaceProps extends ViewProps {\n padding?: LumenSurfacePadding\n radius?: LumenSurfaceRadius\n ref?: LumenViewRef\n tone?: LumenSurfaceTone\n}\n\nexport const LumenSurface = ({\n padding = 'md',\n radius = 'md',\n ref,\n style,\n tone = 'surface',\n ...props\n}: LumenSurfaceProps): ReactElement => {\n const theme = useLumenTheme()\n const backgroundColor = resolveLumenSurfaceColor(theme, tone)\n const paddingValue = padding === 'none' ? 0 : theme.spacing[padding]\n const radiusValue = radius === 'none' ? 0 : theme.radii[radius]\n\n return (\n <View\n ref={ref}\n {...props}\n style={[{ backgroundColor, borderRadius: radiusValue, padding: paddingValue }, style]}\n />\n )\n}\n\nexport type LumenButtonIntent = 'danger' | 'primary' | 'quiet' | 'secondary' | 'success'\nexport type LumenControlSize = 'lg' | 'md' | 'sm'\nexport type LumenIconSize = 'lg' | 'md' | 'sm'\n\nexport interface LumenButtonProps extends Omit<PressableProps, 'children'> {\n children: ReactNode\n intent?: LumenButtonIntent\n loading?: boolean\n ref?: LumenViewRef\n size?: LumenControlSize\n}\n\nexport const LumenButton = ({\n accessibilityState,\n children,\n disabled = false,\n intent = 'primary',\n loading = false,\n ref,\n size = 'md',\n style,\n ...props\n}: LumenButtonProps): ReactElement => {\n const theme = useLumenTheme()\n const colors = resolveLumenButtonColors(theme.colors, intent)\n const dimensions = resolveLumenButtonSize(theme, size)\n const isDisabled = disabled === true || loading\n\n return (\n <Pressable\n ref={ref}\n {...props}\n accessibilityRole=\"button\"\n accessibilityState={{\n ...accessibilityState,\n busy: loading,\n disabled: isDisabled\n }}\n disabled={isDisabled}\n style={state => [\n {\n alignItems: 'center',\n backgroundColor: colors.backgroundColor,\n borderColor: colors.borderColor,\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n flexDirection: 'row',\n gap: theme.spacing.sm,\n justifyContent: 'center',\n opacity: resolveLumenButtonOpacity(isDisabled, state.pressed)\n },\n dimensions.container,\n resolveLumenPressableStyle(style, state)\n ]}\n >\n {loading ? <ActivityIndicator color={colors.color} size=\"small\" /> : null}\n <Text\n style={[\n {\n color: colors.color,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n },\n dimensions.label\n ]}\n >\n <LumenControlContentContext value={colors.color}>{children}</LumenControlContentContext>\n </Text>\n </Pressable>\n )\n}\n\nexport interface LumenTextFieldProps extends TextInputProps {\n 'aria-describedby'?: string\n 'aria-required'?: boolean\n error?: boolean\n ref?: LumenTextInputRef\n size?: LumenControlSize\n}\n\nconst hasExplicitLumenTextFieldLabel = (\n props: Pick<LumenTextFieldProps, 'accessibilityLabel' | 'aria-label' | 'aria-labelledby'>\n): boolean => [\n props.accessibilityLabel,\n props['aria-label'],\n props['aria-labelledby']\n].some(value => value !== undefined)\n\nconst resolveLumenTextFieldDescribedBy = (\n ariaDescribedBy: string | undefined,\n field: LumenFieldContextValue | null\n): string | undefined => {\n const inheritedDescription = [field?.descriptionId, field?.errorId]\n .filter((value): value is string => Boolean(value))\n .join(' ')\n\n return ariaDescribedBy ?? (inheritedDescription || undefined)\n}\n\nconst resolveLumenTextFieldFontSize = (\n size: LumenControlSize,\n mediumFontSize: number,\n smallFontSize: number\n): number => size === 'lg' ? mediumFontSize : smallFontSize\n\nconst resolveLumenTextFieldBorderColor = (\n isInvalid: boolean,\n dangerColor: ColorValue,\n lineColor: ColorValue\n): ColorValue => isInvalid ? dangerColor : lineColor\n\nconst resolveLumenTextFieldOpacity = (editable: boolean): number => editable ? 1 : 0.52\n\ninterface LumenTextFieldAccessibility {\n label: string | undefined\n labelledBy: string | undefined\n required: boolean | undefined\n}\n\nconst resolveLumenTextFieldAccessibility = (\n props: LumenTextFieldProps,\n field: LumenFieldContextValue | null,\n ariaRequired: boolean | undefined\n): LumenTextFieldAccessibility => {\n const hasExplicitLabel = hasExplicitLumenTextFieldLabel(props)\n\n return {\n label: hasExplicitLabel ? props.accessibilityLabel : field?.label,\n labelledBy: hasExplicitLabel ? props['aria-labelledby'] : field?.labelId,\n required: ariaRequired ?? field?.required\n }\n}\n\nexport const LumenTextField = ({\n 'aria-describedby': ariaDescribedBy,\n 'aria-required': ariaRequired,\n accessibilityState,\n editable = true,\n error = false,\n placeholderTextColor,\n ref,\n size = 'md',\n style,\n ...props\n}: LumenTextFieldProps): ReactElement => {\n const theme = useLumenTheme()\n const field = useLumenFieldContext()\n const minHeight = resolveLumenControlMinHeight(size)\n const fontSize = resolveLumenTextFieldFontSize(size, theme.fontSizes.md, theme.fontSizes.sm)\n const accessibility = resolveLumenTextFieldAccessibility(props, field, ariaRequired)\n const describedBy = resolveLumenTextFieldDescribedBy(ariaDescribedBy, field)\n const isInvalid = error || (field?.error ?? false)\n\n return (\n <TextInput\n ref={ref}\n {...props}\n accessibilityLabel={accessibility.label}\n accessibilityState={{\n ...accessibilityState,\n disabled: !editable\n }}\n aria-describedby={describedBy}\n aria-invalid={isInvalid || undefined}\n aria-labelledby={accessibility.labelledBy}\n aria-required={accessibility.required}\n editable={editable}\n placeholderTextColor={placeholderTextColor ?? theme.colors.inkMuted}\n style={[\n {\n backgroundColor: theme.colors.surface,\n borderColor: resolveLumenTextFieldBorderColor(\n isInvalid,\n theme.colors.danger,\n theme.colors.line\n ),\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n color: theme.colors.ink,\n fontSize,\n minHeight,\n opacity: resolveLumenTextFieldOpacity(editable),\n paddingHorizontal: theme.spacing.md,\n paddingVertical: theme.spacing.sm\n },\n style\n ]}\n />\n )\n}\n\nexport interface LumenBadgeProps extends ViewProps {\n children: ReactNode\n ref?: LumenViewRef\n tone?: 'accent' | 'danger' | 'neutral' | 'success' | 'warning'\n}\n\nexport const LumenBadge = ({\n children,\n ref,\n style,\n tone = 'neutral',\n ...props\n}: LumenBadgeProps): ReactElement => {\n const theme = useLumenTheme()\n const color = tone === 'neutral' ? theme.colors.inkSoft : theme.colors[tone]\n const backgroundColor = tone === 'neutral' ? theme.colors.surfaceMuted : `${color}1F`\n\n return (\n <View\n ref={ref}\n {...props}\n style={[\n {\n alignSelf: 'flex-start',\n backgroundColor,\n borderRadius: theme.radii.full,\n paddingHorizontal: theme.spacing.sm,\n paddingVertical: theme.spacing.xs\n },\n style\n ]}\n >\n <Text style={{ color, fontSize: theme.fontSizes.xs, fontWeight: '600' }}>\n {children}\n </Text>\n </View>\n )\n}\n\nexport interface LumenDividerProps extends ViewProps {\n ref?: LumenViewRef\n}\n\nexport const LumenDivider = ({\n ref,\n style,\n ...props\n}: LumenDividerProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <View\n ref={ref}\n accessibilityElementsHidden\n importantForAccessibility=\"no\"\n {...props}\n style={[{ backgroundColor: theme.colors.line, height: 1 }, style]}\n />\n )\n}\n\nexport interface LumenSpinnerProps extends ActivityIndicatorProps {\n ref?: LumenActivityIndicatorRef\n}\n\nexport const LumenSpinner = ({\n color,\n ref,\n ...props\n}: LumenSpinnerProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <ActivityIndicator\n ref={ref}\n accessibilityRole=\"progressbar\"\n color={color ?? theme.colors.brand}\n {...props}\n />\n )\n}\n",
|
|
60296
|
+
"packages/react-native/src/foundation-primitives.tsx": "import {\n type ReactElement,\n type ReactNode\n} from 'react'\nimport {\n ActivityIndicator,\n type ActivityIndicatorProps,\n type ColorValue,\n Pressable,\n type PressableProps,\n Text,\n TextInput,\n type TextInputProps,\n type TextProps,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\n\nimport { LumenControlContentContext, useLumenControlContentColor } from './control-content-context.js'\nimport {\n type LumenFieldContextValue,\n useLumenFieldContext\n} from './field-context.js'\nimport type {\n LumenActivityIndicatorRef,\n LumenTextInputRef,\n LumenTextRef,\n LumenViewRef\n} from './native-ref-types.js'\nimport {\n resolveLumenButtonColors,\n resolveLumenButtonOpacity,\n resolveLumenButtonSize,\n resolveLumenControlMinHeight,\n resolveLumenPressableStyle,\n resolveLumenSurfaceColor,\n resolveLumenTextColor,\n resolveLumenTextStyle\n} from './recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nexport type LumenTextTone =\n | 'danger' |\n 'default' |\n 'muted' |\n 'soft' |\n 'success' |\n 'warning'\nexport type LumenTextVariant = 'body' | 'caption' | 'label' | 'title'\n\nexport interface LumenTextProps extends TextProps {\n ref?: LumenTextRef\n tone?: LumenTextTone\n variant?: LumenTextVariant\n}\n\nexport const LumenText = ({\n ref,\n style,\n tone = 'default',\n variant = 'body',\n ...props\n}: LumenTextProps): ReactElement => {\n const theme = useLumenTheme()\n const contentColor = useLumenControlContentColor()\n\n return (\n <Text\n ref={ref}\n {...props}\n style={[\n { color: tone === 'default' ? contentColor ?? theme.colors.ink : resolveLumenTextColor(theme.colors, tone) },\n resolveLumenTextStyle(theme, variant),\n style\n ]}\n />\n )\n}\n\nexport type LumenSurfacePadding = 'lg' | 'md' | 'none' | 'sm' | 'xl'\nexport type LumenSurfaceRadius = '2xl' | '3xl' | 'lg' | 'md' | 'none' | 'sm' | 'xl'\nexport type LumenSurfaceTone = 'canvas' | 'muted' | 'strong' | 'surface'\n\nexport interface LumenSurfaceProps extends ViewProps {\n padding?: LumenSurfacePadding\n radius?: LumenSurfaceRadius\n ref?: LumenViewRef\n tone?: LumenSurfaceTone\n}\n\nexport const LumenSurface = ({\n padding = 'md',\n radius = 'md',\n ref,\n style,\n tone = 'surface',\n ...props\n}: LumenSurfaceProps): ReactElement => {\n const theme = useLumenTheme()\n const backgroundColor = resolveLumenSurfaceColor(theme, tone)\n const paddingValue = padding === 'none' ? 0 : theme.spacing[padding]\n const radiusValue = radius === 'none' ? 0 : theme.radii[radius]\n\n return (\n <View\n ref={ref}\n {...props}\n style={[{ backgroundColor, borderRadius: radiusValue, padding: paddingValue }, style]}\n />\n )\n}\n\nexport type LumenButtonIntent = 'danger' | 'primary' | 'quiet' | 'secondary' | 'success'\nexport type LumenControlSize = 'lg' | 'md' | 'sm'\nexport type LumenIconSize = 'lg' | 'md' | 'sm'\n\nexport interface LumenButtonProps extends Omit<PressableProps, 'children'> {\n children: ReactNode\n intent?: LumenButtonIntent\n loading?: boolean\n ref?: LumenViewRef\n size?: LumenControlSize\n}\n\nexport const LumenButton = ({\n accessibilityState,\n children,\n disabled = false,\n intent = 'primary',\n loading = false,\n ref,\n size = 'md',\n style,\n ...props\n}: LumenButtonProps): ReactElement => {\n const theme = useLumenTheme()\n const colors = resolveLumenButtonColors(theme.colors, intent)\n const dimensions = resolveLumenButtonSize(theme, size)\n const isDisabled = disabled === true || loading\n\n return (\n <Pressable\n ref={ref}\n {...props}\n accessibilityRole=\"button\"\n accessibilityState={{\n ...accessibilityState,\n busy: loading,\n disabled: isDisabled\n }}\n disabled={isDisabled}\n style={state => [\n {\n alignItems: 'center',\n backgroundColor: colors.backgroundColor,\n borderColor: colors.borderColor,\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n flexDirection: 'row',\n gap: theme.spacing.sm,\n justifyContent: 'center',\n opacity: resolveLumenButtonOpacity(isDisabled, state.pressed)\n },\n dimensions.container,\n resolveLumenPressableStyle(style, state)\n ]}\n >\n {loading ? <ActivityIndicator color={colors.color} size=\"small\" /> : null}\n <Text\n style={[\n {\n color: colors.color,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n },\n dimensions.label\n ]}\n >\n <LumenControlContentContext value={colors.color}>{children}</LumenControlContentContext>\n </Text>\n </Pressable>\n )\n}\n\nexport interface LumenTextFieldProps extends TextInputProps {\n 'aria-describedby'?: string\n 'aria-required'?: boolean\n error?: boolean\n ref?: LumenTextInputRef\n size?: LumenControlSize\n}\n\nconst hasExplicitLumenTextFieldLabel = (\n props: Pick<LumenTextFieldProps, 'accessibilityLabel' | 'accessibilityLabelledBy' | 'aria-label' | 'aria-labelledby'>\n): boolean => [\n props.accessibilityLabel,\n props.accessibilityLabelledBy,\n props['aria-label'],\n props['aria-labelledby']\n].some(value => value !== undefined)\n\nconst resolveLumenTextFieldDescribedBy = (\n ariaDescribedBy: string | undefined,\n field: LumenFieldContextValue | null\n): string | undefined => {\n const inheritedDescription = [field?.descriptionId, field?.errorId]\n .filter((value): value is string => Boolean(value))\n .join(' ')\n\n return ariaDescribedBy ?? (inheritedDescription || undefined)\n}\n\nconst resolveLumenTextFieldFontSize = (\n size: LumenControlSize,\n mediumFontSize: number,\n smallFontSize: number\n): number => size === 'lg' ? mediumFontSize : smallFontSize\n\nconst resolveLumenTextFieldBorderColor = (\n isInvalid: boolean,\n dangerColor: ColorValue,\n lineColor: ColorValue\n): ColorValue => isInvalid ? dangerColor : lineColor\n\nconst resolveLumenTextFieldOpacity = (editable: boolean): number => editable ? 1 : 0.52\n\ninterface LumenTextFieldAccessibility {\n label: string | undefined\n labelledBy: string | undefined\n required: boolean | undefined\n}\n\nconst resolveLumenTextFieldAccessibility = (\n props: LumenTextFieldProps,\n field: LumenFieldContextValue | null,\n ariaRequired: boolean | undefined\n): LumenTextFieldAccessibility => {\n const hasExplicitLabel = hasExplicitLumenTextFieldLabel(props)\n\n return {\n label: hasExplicitLabel ? props.accessibilityLabel : field?.label,\n labelledBy: hasExplicitLabel ? props['aria-labelledby'] : field?.labelId,\n required: ariaRequired ?? field?.required\n }\n}\n\nexport const LumenTextField = ({\n 'aria-describedby': ariaDescribedBy,\n 'aria-required': ariaRequired,\n accessibilityState,\n editable = true,\n error = false,\n placeholderTextColor,\n ref,\n size = 'md',\n style,\n ...props\n}: LumenTextFieldProps): ReactElement => {\n const theme = useLumenTheme()\n const field = useLumenFieldContext()\n const minHeight = resolveLumenControlMinHeight(size)\n const fontSize = resolveLumenTextFieldFontSize(size, theme.fontSizes.md, theme.fontSizes.sm)\n const accessibility = resolveLumenTextFieldAccessibility(props, field, ariaRequired)\n const describedBy = resolveLumenTextFieldDescribedBy(ariaDescribedBy, field)\n const isInvalid = error || (field?.error ?? false)\n\n return (\n <TextInput\n ref={ref}\n {...props}\n accessibilityLabel={accessibility.label}\n accessibilityState={{\n ...accessibilityState,\n disabled: !editable\n }}\n aria-describedby={describedBy}\n aria-invalid={isInvalid || undefined}\n aria-labelledby={accessibility.labelledBy}\n aria-required={accessibility.required}\n editable={editable}\n placeholderTextColor={placeholderTextColor ?? theme.colors.inkMuted}\n style={[\n {\n backgroundColor: theme.colors.surface,\n borderColor: resolveLumenTextFieldBorderColor(\n isInvalid,\n theme.colors.danger,\n theme.colors.line\n ),\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n color: theme.colors.ink,\n fontSize,\n minHeight,\n opacity: resolveLumenTextFieldOpacity(editable),\n paddingHorizontal: theme.spacing.md,\n paddingVertical: theme.spacing.sm\n },\n style\n ]}\n />\n )\n}\n\nexport interface LumenBadgeProps extends ViewProps {\n children: ReactNode\n ref?: LumenViewRef\n tone?: 'accent' | 'danger' | 'neutral' | 'success' | 'warning'\n}\n\nexport const LumenBadge = ({\n children,\n ref,\n style,\n tone = 'neutral',\n ...props\n}: LumenBadgeProps): ReactElement => {\n const theme = useLumenTheme()\n const color = tone === 'neutral' ? theme.colors.inkSoft : theme.colors[tone]\n const backgroundColor = tone === 'neutral' ? theme.colors.surfaceMuted : `${color}1F`\n\n return (\n <View\n ref={ref}\n {...props}\n style={[\n {\n alignSelf: 'flex-start',\n backgroundColor,\n borderRadius: theme.radii.full,\n paddingHorizontal: theme.spacing.sm,\n paddingVertical: theme.spacing.xs\n },\n style\n ]}\n >\n <Text style={{ color, fontSize: theme.fontSizes.xs, fontWeight: '600' }}>\n {children}\n </Text>\n </View>\n )\n}\n\nexport interface LumenDividerProps extends ViewProps {\n ref?: LumenViewRef\n}\n\nexport const LumenDivider = ({\n ref,\n style,\n ...props\n}: LumenDividerProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <View\n ref={ref}\n accessibilityElementsHidden\n importantForAccessibility=\"no\"\n {...props}\n style={[{ backgroundColor: theme.colors.line, height: 1 }, style]}\n />\n )\n}\n\nexport interface LumenSpinnerProps extends ActivityIndicatorProps {\n ref?: LumenActivityIndicatorRef\n}\n\nexport const LumenSpinner = ({\n color,\n ref,\n ...props\n}: LumenSpinnerProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <ActivityIndicator\n ref={ref}\n accessibilityRole=\"progressbar\"\n color={color ?? theme.colors.brand}\n {...props}\n />\n )\n}\n",
|
|
60210
60297
|
"packages/react-native/src/foundations.ts": "export {\n LumenBadge,\n type LumenBadgeProps,\n LumenButton,\n type LumenButtonIntent,\n type LumenButtonProps,\n type LumenControlSize,\n LumenDivider,\n type LumenDividerProps,\n type LumenIconSize,\n LumenSpinner,\n type LumenSpinnerProps,\n LumenSurface,\n type LumenSurfacePadding,\n type LumenSurfaceProps,\n type LumenSurfaceRadius,\n type LumenSurfaceTone,\n LumenText,\n LumenTextField,\n type LumenTextFieldProps,\n type LumenTextProps,\n type LumenTextTone,\n type LumenTextVariant } from './foundation-primitives.js'\nexport { LumenProvider, type LumenProviderProps } from './provider.js'\nexport { createLumenTheme, lumenDarkTheme, lumenLightTheme, type LumenTheme } from './theme.js'\nexport { type LumenAppearance, type LumenSurfaceMaterial, type LumenThemeOptions, type LumenThemePreset } from './theme.js'\nexport { useLumenTheme } from './theme-context.js'\n",
|
|
60211
60298
|
"packages/react-native/src/graphics.ts": "export {\n LumenStaticIcon as LumenIcon,\n LumenStaticIconButton as LumenIconButton,\n type LumenStaticIconButtonProps as LumenIconButtonProps,\n type LumenIconGraphic,\n type LumenIconGraphicProps,\n type LumenStaticIconProps as LumenIconProps\n} from './icon-primitives.js'\nexport { LumenProvider, type LumenProviderProps } from './provider.js'\nexport { useLumenTheme } from './theme-context.js'\n",
|
|
60212
60299
|
"packages/react-native/src/hook-recipes.test.ts": "import { describe, expect, test } from 'vitest'\n\nimport {\n createLumenHookToast,\n getNextLumenThemeScheme,\n normalizeLumenHookSelectionOptions,\n updateLumenHookToast\n} from './hook-recipes.js'\n\ndescribe('React Native hook recipes', () => {\n test('normalizes primitive and structured native selection options', () => {\n expect(normalizeLumenHookSelectionOptions([\n 'overview',\n { disabled: true, label: 'Activity', value: 'activity' }\n ])).toEqual([\n { label: 'overview', value: 'overview' },\n { disabled: true, label: 'Activity', value: 'activity' }\n ])\n })\n\n test('toggles between the supported explicit native schemes', () => {\n expect(getNextLumenThemeScheme('light')).toBe('dark')\n expect(getNextLumenThemeScheme('dark')).toBe('light')\n })\n\n test('creates and updates stable toast records', () => {\n const toast = createLumenHookToast('toast-1', { title: 'Saved' })\n\n expect(toast).toEqual({ id: 'toast-1', title: 'Saved', variant: 'default' })\n expect(updateLumenHookToast(toast, {\n description: 'Available offline',\n variant: 'success'\n })).toEqual({\n description: 'Available offline',\n id: 'toast-1',\n title: 'Saved',\n variant: 'success'\n })\n })\n})\n",
|
|
@@ -60263,7 +60350,7 @@
|
|
|
60263
60350
|
"packages/react-native/src/schedule-components.tsx": "import { type ReactElement, useState } from 'react'\nimport { Pressable, ScrollView, View, type ViewProps } from 'react-native'\n\nimport { formatLumenAgendaMinute, type LumenAgendaEvent, type LumenAgendaSegment } from './agenda-recipes.js'\nimport { addLumenCalendarDays, type LumenCalendarDay, lumenCalendarDayKey } from './calendar-recipes.js'\nimport { LumenText } from './primitives.js'\nimport { type LumenScheduleDay, lumenScheduleLayout } from './schedule-recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nexport interface LumenScheduleProps extends Pick<ViewProps, 'style' | 'testID'> {\n label: string\n selectedDay: LumenCalendarDay\n onSelectedDayChange: (day: LumenCalendarDay) => void\n events: readonly LumenAgendaEvent[]\n dayCount?: number\n startHour?: number\n endHour?: number\n onEventPress?: (event: LumenAgendaEvent, day: LumenCalendarDay) => void\n onEventMove?: (event: LumenAgendaEvent, targetDay: LumenCalendarDay) => void\n disabled?: boolean\n readOnly?: boolean\n loading?: boolean\n error?: string | null\n loadingLabel?: string\n emptyLabel?: string\n invalidLabel?: string\n previousLabel?: string\n nextLabel?: string\n previousDayLabel?: string\n nextDayLabel?: string\n allDayLabel?: string\n formatDay?: (day: LumenCalendarDay) => string\n formatTime?: (minute: number) => string\n}\n\nconst scheduleStatus = (input: LumenScheduleProps, invalid: boolean): string | null => {\n if (input.error != null) return input.error\n\n if (input.loading) return input.loadingLabel ?? 'Loading'\n\n if (invalid) return input.invalidLabel ?? 'Invalid schedule'\n\n return null\n}\n\ninterface EventButtonProps {\n segment: LumenAgendaSegment\n day: LumenCalendarDay\n name: string\n disabled: boolean\n onPress: (event: LumenAgendaEvent, day: LumenCalendarDay) => void\n}\n\nconst ScheduleEvent = ({ segment, day, name, disabled, onPress }: EventButtonProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <Pressable\n accessibilityRole=\"button\"\n accessibilityLabel={name}\n disabled={disabled}\n accessibilityState={{ disabled }}\n onPress={() => {\n if (!disabled) onPress(segment.event, day)\n }}\n style={{\n minWidth: 44,\n minHeight: 44,\n flex: 1,\n padding: theme.spacing.xs,\n backgroundColor: theme.colors.brandSoft,\n overflow: 'hidden'\n }}\n >\n <LumenText numberOfLines={2}>{segment.event.event.label}</LumenText>\n </Pressable>\n )\n}\n\nconst columnWidth = (day: LumenScheduleDay): number => {\n const widths = day.placements.map(item => item.laneCount * 100)\n\n return widths.reduce((width, value) => Math.max(width, value), 200)\n}\n\nconst rangeDestination = (day: LumenCalendarDay, amount: number, count: number): LumenCalendarDay | null => {\n const next = addLumenCalendarDays(day, amount)\n\n return next && addLumenCalendarDays(next, count - 1) ? next : null\n}\n\ninterface FocusedEvent { id: string, day: LumenCalendarDay }\n\nconst focusedEvent = (\n days: readonly LumenScheduleDay[], focused: FocusedEvent | null\n): LumenAgendaEvent | undefined => {\n if (!focused) return undefined\n\n const day = days.find(group => lumenCalendarDayKey(group.day) === lumenCalendarDayKey(focused.day))\n\n if (!day) return undefined\n\n return [...day.allDay, ...day.placements.map(item => item.segment)]\n .find(segment => segment.event.event.id === focused.id)?.event\n}\n\nexport const LumenSchedule = (input: LumenScheduleProps): ReactElement => {\n const props = {\n dayCount: 7,\n startHour: 8,\n endHour: 18,\n formatDay: lumenCalendarDayKey,\n formatTime: formatLumenAgendaMinute,\n emptyLabel: 'No events in this time range',\n allDayLabel: 'All day',\n previousLabel: 'Previous range',\n nextLabel: 'Next range',\n previousDayLabel: 'Move to previous day',\n nextDayLabel: 'Move to next day',\n ...input\n }\n\n const theme = useLumenTheme()\n const [focused, setFocused] = useState<FocusedEvent | null>(null)\n const days = lumenScheduleLayout(props.events, props.selectedDay, props.dayCount, props.startHour, props.endHour)\n const status = scheduleStatus(props, days.length === 0)\n const locked = props.disabled === true || props.readOnly === true || status !== null\n const move = props.onEventMove\n const noActions = !props.onEventPress && !move\n const active = focusedEvent(days, focused)\n\n const focus = (event: LumenAgendaEvent, day: LumenCalendarDay): void => {\n if (locked || event.event.disabled) return\n\n setFocused({ id: event.event.id, day })\n\n props.onEventPress?.(event, day)\n }\n\n const name = (segment: LumenAgendaSegment, day: LumenCalendarDay): string => {\n let time = props.allDayLabel\n\n if (segment.startMinute !== null) {\n time = `${props.formatTime(segment.startMinute)} – ${props.formatTime(segment.endMinute ?? 1440)}`\n }\n\n return [props.formatDay(day), time, segment.event.event.label, segment.event.event.detail].filter(Boolean).join(', ')\n }\n\n const allDayHeight = days.reduce((count, day) => Math.max(count, day.allDay.length), 1) * 48\n\n const hours = status === null ?\n Array.from({ length: props.endHour - props.startHour + 1 }, (_, index) => props.startHour + index) :\n []\n\n const navigation = [\n { id: 'previous', label: props.previousLabel, day: rangeDestination(props.selectedDay, -props.dayCount, props.dayCount) },\n { id: 'next', label: props.nextLabel, day: rangeDestination(props.selectedDay, props.dayCount, props.dayCount) }\n ]\n\n return (\n <View style={props.style} testID={props.testID} accessibilityLabel={props.label}>\n <LumenText accessibilityRole=\"header\">{props.label}</LumenText>\n {status !== null ?\n <LumenText accessibilityLiveRegion=\"polite\">{status}</LumenText> :\n (\n <>\n <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>\n {navigation.map(action => (\n <Pressable\n key={action.id}\n accessibilityRole=\"button\"\n accessibilityLabel={action.label}\n disabled={locked || !action.day}\n accessibilityState={{ disabled: locked || !action.day }}\n style={{ minWidth: 44, minHeight: 44, flexShrink: 1, justifyContent: 'center' }}\n onPress={() => {\n if (!locked && action.day) props.onSelectedDayChange(action.day)\n }}\n >\n <LumenText>{action.label}</LumenText>\n </Pressable>\n ))}\n </View>\n {days.every(day => day.placements.length === 0 && day.allDay.length === 0) && (\n <LumenText>{props.emptyLabel}</LumenText>\n )}\n <ScrollView horizontal>\n <ScrollView style={{ maxHeight: 480 }} nestedScrollEnabled>\n <View style={{ flexDirection: 'row' }}>\n <View style={{ width: 64 }}>\n <View style={{ height: 44 + allDayHeight }}><LumenText>{props.allDayLabel}</LumenText></View>\n <View style={{ height: (props.endHour - props.startHour) * 60 }}>\n {hours.map(hour => <View key={hour} style={{ position: 'absolute', top: Math.min((hour - props.startHour) * 60, (props.endHour - props.startHour) * 60 - 20) }}><LumenText>{props.formatTime(hour * 60)}</LumenText></View>)}\n </View>\n </View>\n {days.map(day => {\n const width = columnWidth(day)\n\n return (\n <View\n key={lumenCalendarDayKey(day.day)}\n style={{ width, borderLeftWidth: 1, borderColor: theme.colors.line }}\n >\n <View style={{ height: 44, justifyContent: 'center', padding: theme.spacing.xs }}><LumenText accessibilityRole=\"header\">{props.formatDay(day.day)}</LumenText></View>\n <View style={{ height: allDayHeight }}>\n {day.allDay.map(segment => (\n <View key={segment.event.event.id} style={{ height: 48 }}>\n <ScheduleEvent\n segment={segment}\n day={day.day}\n name={name(segment, day.day)}\n disabled={locked || segment.event.event.disabled === true || noActions}\n onPress={focus}\n />\n </View>\n ))}\n </View>\n <View style={{ height: (props.endHour - props.startHour) * 60 }}>\n {hours.slice(0, -1).map(hour => <View key={hour} style={{ position: 'absolute', top: (hour - props.startHour) * 60, left: 0, right: 0, borderTopWidth: 1, borderColor: theme.colors.line }} />)}\n {day.placements.map(item => <View key={item.segment.event.event.id} style={{ position: 'absolute', top: item.top, height: item.height, left: item.lane * width / item.laneCount, width: width / item.laneCount, paddingHorizontal: 2 }}><ScheduleEvent segment={item.segment} day={day.day} name={name(item.segment, day.day)} disabled={locked || item.segment.event.event.disabled === true || noActions} onPress={focus} /></View>)}\n </View>\n </View>\n )\n })}\n </View>\n </ScrollView>\n </ScrollView>\n {move && active && focused && (\n <View>\n <LumenText>{active.event.label}</LumenText>\n {[-1, 1].map(direction => {\n const target = addLumenCalendarDays(focused.day, direction)\n const unavailable = locked || active.event.disabled === true || !target\n\n return (\n <Pressable\n key={direction}\n accessibilityRole=\"button\"\n accessibilityLabel={direction < 0 ? props.previousDayLabel : props.nextDayLabel}\n disabled={unavailable}\n accessibilityState={{ disabled: unavailable }}\n style={{ minHeight: 44, minWidth: 44 }}\n onPress={() => {\n if (!unavailable) move(active, target)\n }}\n >\n <LumenText>{direction < 0 ? props.previousDayLabel : props.nextDayLabel}</LumenText>\n </Pressable>\n )\n })}\n </View>\n )}\n </>\n )}\n </View>\n )\n}\n",
|
|
60264
60351
|
"packages/react-native/src/schedule-recipes.test.ts": "import { expect, test } from 'vitest'\n\nimport type { LumenAgendaEvent } from './agenda-recipes.js'\nimport { lumenScheduleLayout } from './schedule-recipes.js'\nconst day = { year: 2026, month: 3, day: 8 }\nconst timed = (id: string, startMinute: number, endMinute: number): LumenAgendaEvent => ({\n event: { id, label: id, startDay: day }, startMinute, endMinute\n})\ntest('overlapping chains share lane count; separated intervals reclaim full width', () => {\n const layout = lumenScheduleLayout([timed('a', 540, 660), timed('b', 570, 630), timed('c', 630, 690), timed('d', 720, 780)], day, 1)\n expect(layout[0]?.placements.map(item => [item.segment.event.event.id, item.lane, item.laneCount])).toEqual([\n ['a', 0, 2], ['b', 1, 2], ['c', 1, 2], ['d', 0, 1]\n ])\n})\ntest('short events keep accessible height without hit collisions, clipping to hour window', () => {\n const layout = lumenScheduleLayout([timed('a', 480, 485), timed('b', 486, 490), timed('last', 1079, 1080), timed('off', 1080, 1100)], day, 1)\n const items = layout[0]?.placements ?? []\n expect(items).toHaveLength(3)\n expect(items[0]?.height).toBe(48)\n expect(items[1]?.laneCount).toBe(2)\n expect(items[2]?.top).toBe(552)\n for (const item of items) expect(item.top + item.height).toBeLessThanOrEqual(600)\n})\ntest('all-day band and overnight clipping reuse Agenda; invalid IDs/window/range fail closed', () => {\n const overnight = { ...timed('night', 1380, 60), event: { id: 'night', label: 'Night', startDay: day, endDay: { ...day, day: 9 } } }\n const allDay = { event: { id: 'holiday', label: 'Holiday', startDay: day } }\n const layout = lumenScheduleLayout([overnight, allDay], day, 2, 0, 24)\n expect(layout[0]?.allDay).toHaveLength(1)\n expect(layout[1]?.placements[0]?.segment.startMinute).toBe(0)\n expect(lumenScheduleLayout([allDay, allDay], day)).toEqual([])\n for (const [count, start, end] of [[0, 8, 18], [8, 8, 18], [1, -1, 18], [1, 18, 8], [1, 8, 25], [1, 8.5, 18]]) {\n expect(lumenScheduleLayout([], day, count, start, end)).toEqual([])\n }\n expect(lumenScheduleLayout([], { year: 9999, month: 12, day: 31 }, 2)).toEqual([])\n})\n",
|
|
60265
60352
|
"packages/react-native/src/schedule-recipes.ts": "import { type LumenAgendaEvent, lumenAgendaGroups, type LumenAgendaSegment } from './agenda-recipes.js'\nimport type { LumenCalendarDay } from './calendar-recipes.js'\n\nexport interface LumenSchedulePlacement {\n segment: LumenAgendaSegment\n top: number\n height: number\n lane: number\n laneCount: number\n}\nexport interface LumenScheduleDay {\n day: LumenCalendarDay\n allDay: readonly LumenAgendaSegment[]\n placements: readonly LumenSchedulePlacement[]\n}\n\nconst assignLanes = (placements: LumenSchedulePlacement[]): LumenSchedulePlacement[] => {\n let group: LumenSchedulePlacement[] = []\n let laneEnds: number[] = []\n let groupEnd = -1\n\n const finish = (): void => {\n for (const item of group) item.laneCount = laneEnds.length\n }\n\n for (const item of placements) {\n if (item.top >= groupEnd) {\n finish()\n\n group = []\n\n laneEnds = []\n }\n\n let lane = laneEnds.findIndex(value => value <= item.top)\n\n if (lane < 0) lane = laneEnds.length\n\n laneEnds[lane] = item.top + item.height\n\n item.lane = lane\n\n groupEnd = Math.max(groupEnd, item.top + item.height)\n\n group.push(item)\n }\n\n finish()\n\n return placements\n}\n\nconst placeSegments = (\n segments: readonly LumenAgendaSegment[], start: number, end: number\n): LumenSchedulePlacement[] => {\n const placements: LumenSchedulePlacement[] = []\n\n for (const segment of segments) {\n if (segment.startMinute === null || segment.endMinute === null) continue\n\n if (segment.startMinute >= end || segment.endMinute <= start) continue\n\n const top = Math.min(Math.max(segment.startMinute, start), end - 48) - start\n const height = Math.max(48, Math.min(segment.endMinute, end) - start - top)\n\n placements.push({ segment, top, height, lane: 0, laneCount: 1 })\n }\n\n placements.sort((a, b) => a.top - b.top)\n\n return assignLanes(placements)\n}\n\nconst validHours = (start: number, end: number): boolean => {\n const integers = Number.isInteger(start) && Number.isInteger(end)\n\n return integers && start >= 0 && end <= 24 && start < end\n}\n\nexport const lumenScheduleLayout = (\n events: readonly LumenAgendaEvent[], selectedDay: LumenCalendarDay, dayCount = 7, startHour = 8, endHour = 18\n): readonly LumenScheduleDay[] => {\n if (!Number.isInteger(dayCount) || dayCount < 1 || dayCount > 7) return []\n\n if (!validHours(startHour, endHour)) return []\n\n return lumenAgendaGroups(events, selectedDay, dayCount).map(group => ({\n day: group.day,\n allDay: group.segments.filter(segment => segment.startMinute === null),\n placements: placeSegments(group.segments, startHour * 60, endHour * 60)\n }))\n}\n",
|
|
60266
|
-
"packages/react-native/src/selection-components.tsx": "import {\n type ComponentRef,\n type ReactElement,\n type ReactNode,\n useEffect,\n useId,\n useRef\n} from 'react'\nimport {\n type HostInstance,\n Platform,\n Pressable,\n type PressableProps,\n ScrollView,\n Text,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\n\nimport type { LumenViewRef } from './native-ref-types.js'\nimport { resolveLumenButtonOpacity } from './recipes.js'\nimport { resolveLumenSelectionState } from './selection-recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nexport interface LumenSelectionOption {\n description?: string\n disabled?: boolean\n label: string\n value: string\n}\n\nexport interface LumenCheckboxProps extends Omit<PressableProps, 'children' | 'disabled' | 'onPress'> {\n checked: boolean\n description?: string\n disabled?: boolean\n label: string\n onCheckedChange: (checked: boolean) => void\n ref?: LumenViewRef\n}\n\nexport const LumenCheckbox = ({\n checked,\n description,\n disabled = false,\n label,\n onCheckedChange,\n onKeyDown,\n ref,\n style,\n ...props\n}: LumenCheckboxProps): ReactElement => {\n const theme = useLumenTheme()\n const state = resolveLumenSelectionState(checked, disabled)\n\n return (\n <Pressable\n ref={ref}\n {...props}\n accessibilityRole=\"checkbox\"\n accessibilityLabel={props.accessibilityLabel ?? label}\n aria-checked={checked}\n aria-disabled={disabled}\n accessibilityState={{ checked, disabled }}\n disabled={disabled}\n onKeyDown={event => {\n onKeyDown?.(event)\n\n const key = event.nativeEvent.key || event.nativeEvent.code\n\n if (Platform.OS !== 'web' || event.defaultPrevented || (key !== ' ' && key !== 'Spacebar')) return\n\n event.preventDefault()\n\n const repeated = 'repeat' in event.nativeEvent && event.nativeEvent.repeat === true\n\n if (!disabled && !repeated) onCheckedChange(!checked)\n }}\n onPress={() => {\n if (!disabled) onCheckedChange(!checked)\n }}\n style={pressState => [\n {\n alignItems: 'flex-start',\n flexDirection: 'row',\n gap: theme.spacing.md,\n minHeight: 44,\n opacity: state.opacity,\n paddingVertical: theme.spacing.sm\n },\n typeof style === 'function' ? style(pressState) : style\n ]}\n >\n {({ pressed }) => (\n <>\n <View\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={{\n alignItems: 'center',\n backgroundColor: checked ? theme.colors.brandSolid : theme.colors.surface,\n borderColor: checked ? theme.colors.brandSolid : theme.colors.line,\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n height: 22,\n justifyContent: 'center',\n marginTop: 1,\n opacity: resolveLumenButtonOpacity(disabled, pressed),\n width: 22\n }}\n >\n {checked ?\n <Text style={{ color: theme.colors.onBrand, fontSize: 14, fontWeight: '700' }}>✓</Text> :\n null}\n </View>\n <View style={{ flex: 1, gap: theme.spacing.xs }}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n {description ?\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>{description}</Text> :\n null}\n </View>\n </>\n )}\n </Pressable>\n )\n}\n\nexport interface LumenRadioGroupProps extends Omit<ViewProps, 'children'> {\n label: string\n onValueChange: (value: string) => void\n options: readonly LumenSelectionOption[]\n ref?: LumenViewRef\n value: string\n}\n\nexport const LumenRadioGroup = ({\n label,\n onValueChange,\n options,\n ref,\n style,\n value,\n ...props\n}: LumenRadioGroupProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <View\n ref={ref}\n {...props}\n accessibilityLabel={props.accessibilityLabel ?? label}\n accessibilityRole=\"radiogroup\"\n style={[{ gap: theme.spacing.sm }, style]}\n >\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n {options.map(option => {\n const selected = value === option.value\n const state = resolveLumenSelectionState(selected, option.disabled ?? false)\n\n return (\n <Pressable\n aria-checked={selected}\n aria-disabled={state.disabled}\n key={option.value}\n accessibilityRole=\"radio\"\n accessibilityState={{ checked: selected, disabled: state.disabled }}\n disabled={state.disabled}\n onPress={() => {\n onValueChange(option.value)\n }}\n style={({ pressed }) => ({\n alignItems: 'flex-start',\n flexDirection: 'row',\n gap: theme.spacing.md,\n minHeight: 44,\n opacity: state.opacity * resolveLumenButtonOpacity(state.disabled, pressed),\n paddingVertical: theme.spacing.sm\n })}\n >\n <View\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={{\n alignItems: 'center',\n borderColor: selected ? theme.colors.brandSolid : theme.colors.line,\n borderRadius: 11,\n borderWidth: 2,\n height: 22,\n justifyContent: 'center',\n marginTop: 1,\n width: 22\n }}\n >\n {selected ?\n <View style={{ backgroundColor: theme.colors.brandSolid, borderRadius: 5, height: 10, width: 10 }} /> :\n null}\n </View>\n <View style={{ flex: 1, gap: theme.spacing.xs }}>\n <Text style={{ color: theme.colors.ink, fontSize: theme.fontSizes.sm }}>{option.label}</Text>\n {option.description ?\n (\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>\n {option.description}\n </Text>\n ) :\n null}\n </View>\n </Pressable>\n )\n })}\n </View>\n )\n}\n\nexport interface LumenSegmentedControlProps extends Omit<ViewProps, 'children'> {\n label: string\n onValueChange: (value: string) => void\n options: readonly LumenSelectionOption[]\n ref?: LumenViewRef\n showLabel?: boolean\n value: string\n}\n\nexport const LumenSegmentedControl = ({\n label,\n onValueChange,\n options,\n ref,\n showLabel = true,\n style,\n value,\n ...props\n}: LumenSegmentedControlProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.sm }, style]}>\n {showLabel ?\n (\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n ) :\n null}\n <View\n accessibilityLabel={label}\n accessibilityRole=\"radiogroup\"\n style={{\n backgroundColor: theme.colors.surfaceMuted,\n borderColor: theme.colors.line,\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n flexDirection: 'row',\n padding: 2\n }}\n >\n {options.map(option => {\n const selected = value === option.value\n const state = resolveLumenSelectionState(selected, option.disabled ?? false)\n\n return (\n <Pressable\n aria-checked={selected}\n aria-disabled={state.disabled}\n key={option.value}\n accessibilityLabel={option.label}\n accessibilityRole=\"radio\"\n accessibilityState={{ checked: selected, disabled: state.disabled }}\n disabled={state.disabled}\n onPress={() => {\n onValueChange(option.value)\n }}\n style={({ pressed }) => ({\n alignItems: 'center',\n backgroundColor: selected ? theme.colors.surface : 'transparent',\n borderRadius: theme.radii.sm,\n flex: 1,\n justifyContent: 'center',\n minHeight: 40,\n opacity: state.opacity * resolveLumenButtonOpacity(state.disabled, pressed),\n paddingHorizontal: theme.spacing.sm,\n paddingVertical: theme.spacing.sm\n })}\n >\n <Text\n numberOfLines={1}\n style={{\n color: selected ? theme.colors.brand : theme.colors.inkSoft,\n fontSize: theme.fontSizes.xs,\n fontWeight: String(\n selected ? theme.fontWeights.bold : theme.fontWeights.semibold\n ) as TextStyle['fontWeight']\n }}\n >\n {option.label}\n </Text>\n </Pressable>\n )\n })}\n </View>\n </View>\n )\n}\n\nexport interface LumenTabsProps extends Omit<ViewProps, 'children'> {\n children: ReactNode\n label: string\n onValueChange: (value: string) => void\n options: readonly LumenSelectionOption[]\n /** Localized panel name; defaults to the selected tab's visible label. */\n panelAccessibilityLabel?: string\n ref?: LumenViewRef\n value: string\n}\n\nexport const LumenTabs = ({\n children,\n label,\n onValueChange,\n options,\n panelAccessibilityLabel,\n ref,\n style,\n value,\n ...props\n}: LumenTabsProps): ReactElement => {\n const theme = useLumenTheme()\n const selectedOption = options.find(option => option.value === value)\n const selectedIndex = options.findIndex(option => option.value === value)\n const tabsId = useId().replaceAll(':', '')\n const pendingFocusValueRef = useRef<string | undefined>(undefined)\n const tabInstancesRef = useRef<(HostInstance | null)[]>([])\n const tabLayoutsRef = useRef(new Map<number, { width: number, x: number }>())\n const tabListRef = useRef<ComponentRef<typeof ScrollView>>(null)\n const viewportWidthRef = useRef(0)\n\n const scrollTabIntoView = (index: number, animated: boolean): void => {\n const layout = tabLayoutsRef.current.get(index)\n\n if (!layout) return\n\n const centeredOffset = layout.x - Math.max(0, (viewportWidthRef.current - layout.width) / 2)\n\n tabListRef.current?.scrollTo({ animated, x: Math.max(0, centeredOffset) })\n }\n\n useEffect(() => {\n if (selectedIndex < 0) return\n\n scrollTabIntoView(selectedIndex, true)\n\n if (pendingFocusValueRef.current === value) {\n tabInstancesRef.current[selectedIndex]?.focus()\n\n pendingFocusValueRef.current = undefined\n }\n }, [selectedIndex, value])\n\n const selectTab = (optionIndex: number): boolean => {\n const option = options[optionIndex]\n\n if (!option || option.disabled) return false\n\n if (option.value === value) {\n tabInstancesRef.current[optionIndex]?.focus()\n\n return true\n }\n\n pendingFocusValueRef.current = option.value\n\n onValueChange(option.value)\n\n return true\n }\n\n const moveSelection = (currentIndex: number, direction: -1 | 1): void => {\n for (let offset = 1; offset < options.length; offset += 1) {\n const nextIndex = (currentIndex + direction * offset + options.length) % options.length\n\n if (selectTab(nextIndex)) return\n }\n }\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.md }, style]}>\n <ScrollView\n accessibilityLabel={label}\n accessibilityRole=\"tablist\"\n contentContainerStyle={{ gap: theme.spacing.xs }}\n horizontal\n onLayout={event => {\n viewportWidthRef.current = event.nativeEvent.layout.width\n\n if (selectedIndex >= 0) scrollTabIntoView(selectedIndex, false)\n }}\n ref={tabListRef}\n showsHorizontalScrollIndicator={false}\n style={{ borderBottomColor: theme.colors.line, borderBottomWidth: 1 }}\n >\n {options.map((option, optionIndex) => {\n const selected = option.value === value\n const state = resolveLumenSelectionState(selected, option.disabled ?? false)\n const tabId = `${tabsId}-tab-${optionIndex}`\n\n return (\n <Pressable\n aria-disabled={state.disabled}\n aria-selected={selected}\n accessibilityRole=\"tab\"\n accessibilityState={{ disabled: state.disabled, selected }}\n disabled={state.disabled}\n key={option.value}\n nativeID={tabId}\n onKeyDown={event => {\n const key = event.nativeEvent.key || event.nativeEvent.code\n\n if (key === 'ArrowLeft') {\n event.preventDefault()\n\n moveSelection(optionIndex, -1)\n } else if (key === 'ArrowRight') {\n event.preventDefault()\n\n moveSelection(optionIndex, 1)\n }\n }}\n onLayout={event => {\n tabLayoutsRef.current.set(optionIndex, event.nativeEvent.layout)\n\n if (selected) scrollTabIntoView(optionIndex, false)\n }}\n onPress={() => {\n onValueChange(option.value)\n }}\n ref={instance => {\n tabInstancesRef.current[optionIndex] = instance\n }}\n style={({ pressed }) => ({\n borderBottomColor: selected ? theme.colors.brandSolid : 'transparent',\n borderBottomWidth: 2,\n minHeight: 44,\n opacity: state.opacity * resolveLumenButtonOpacity(state.disabled, pressed),\n paddingHorizontal: theme.spacing.md,\n paddingVertical: theme.spacing.sm\n })}\n >\n <Text\n style={{\n color: selected ? theme.colors.brand : theme.colors.inkSoft,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(\n selected ? theme.fontWeights.bold : theme.fontWeights.semibold\n ) as TextStyle['fontWeight']\n }}\n >\n {option.label}\n </Text>\n </Pressable>\n )\n })}\n </ScrollView>\n <View\n aria-labelledby={selectedIndex >= 0 ? `${tabsId}-tab-${selectedIndex}` : undefined}\n accessibilityLabel={panelAccessibilityLabel ?? selectedOption?.label ?? value}\n accessibilityLiveRegion=\"polite\"\n role=\"tabpanel\"\n >\n {children}\n </View>\n </View>\n )\n}\n",
|
|
60353
|
+
"packages/react-native/src/selection-components.tsx": "import {\n type ComponentRef,\n type ReactElement,\n type ReactNode,\n useEffect,\n useId,\n useRef\n} from 'react'\nimport {\n type HostInstance,\n Platform,\n Pressable,\n type PressableProps,\n ScrollView,\n Text,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\n\nimport type { LumenViewRef } from './native-ref-types.js'\nimport { resolveLumenButtonOpacity } from './recipes.js'\nimport { resolveLumenSelectionState } from './selection-recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nexport interface LumenSelectionOption {\n description?: string\n disabled?: boolean\n label: string\n value: string\n}\n\nexport interface LumenCheckboxProps extends Omit<PressableProps, 'children' | 'disabled' | 'onPress'> {\n checked: boolean\n description?: string\n disabled?: boolean\n label: string\n onCheckedChange: (checked: boolean) => void\n ref?: LumenViewRef\n}\n\nexport const LumenCheckbox = ({\n checked,\n description,\n disabled = false,\n label,\n onCheckedChange,\n onKeyDown,\n ref,\n style,\n ...props\n}: LumenCheckboxProps): ReactElement => {\n const theme = useLumenTheme()\n const state = resolveLumenSelectionState(checked, disabled)\n\n return (\n <Pressable\n ref={ref}\n {...props}\n accessibilityRole=\"checkbox\"\n // An explicit native label hides the visible description, so it is announced as the hint, like LumenToggle.\n accessibilityHint={props.accessibilityHint ?? description}\n accessibilityLabel={props.accessibilityLabel ?? label}\n aria-checked={checked}\n aria-disabled={disabled}\n accessibilityState={{ checked, disabled }}\n disabled={disabled}\n onKeyDown={event => {\n onKeyDown?.(event)\n\n const key = event.nativeEvent.key || event.nativeEvent.code\n\n if (Platform.OS !== 'web' || event.defaultPrevented || (key !== ' ' && key !== 'Spacebar')) return\n\n event.preventDefault()\n\n const repeated = 'repeat' in event.nativeEvent && event.nativeEvent.repeat === true\n\n if (!disabled && !repeated) onCheckedChange(!checked)\n }}\n onPress={() => {\n if (!disabled) onCheckedChange(!checked)\n }}\n style={pressState => [\n {\n alignItems: 'flex-start',\n flexDirection: 'row',\n gap: theme.spacing.md,\n minHeight: 44,\n opacity: state.opacity,\n paddingVertical: theme.spacing.sm\n },\n typeof style === 'function' ? style(pressState) : style\n ]}\n >\n {({ pressed }) => (\n <>\n <View\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={{\n alignItems: 'center',\n backgroundColor: checked ? theme.colors.brandSolid : theme.colors.surface,\n borderColor: checked ? theme.colors.brandSolid : theme.colors.line,\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n height: 22,\n justifyContent: 'center',\n marginTop: 1,\n opacity: resolveLumenButtonOpacity(disabled, pressed),\n width: 22\n }}\n >\n {checked ?\n <Text style={{ color: theme.colors.onBrand, fontSize: 14, fontWeight: '700' }}>✓</Text> :\n null}\n </View>\n <View style={{ flex: 1, gap: theme.spacing.xs }}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n {description ?\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>{description}</Text> :\n null}\n </View>\n </>\n )}\n </Pressable>\n )\n}\n\nexport interface LumenRadioGroupProps extends Omit<ViewProps, 'children'> {\n label: string\n onValueChange: (value: string) => void\n options: readonly LumenSelectionOption[]\n ref?: LumenViewRef\n value: string\n}\n\nexport const LumenRadioGroup = ({\n label,\n onValueChange,\n options,\n ref,\n style,\n value,\n ...props\n}: LumenRadioGroupProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <View\n ref={ref}\n {...props}\n accessibilityLabel={props.accessibilityLabel ?? label}\n accessibilityRole=\"radiogroup\"\n style={[{ gap: theme.spacing.sm }, style]}\n >\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n {options.map(option => {\n const selected = value === option.value\n const state = resolveLumenSelectionState(selected, option.disabled ?? false)\n\n return (\n <Pressable\n aria-checked={selected}\n aria-disabled={state.disabled}\n key={option.value}\n accessibilityRole=\"radio\"\n accessibilityState={{ checked: selected, disabled: state.disabled }}\n disabled={state.disabled}\n onPress={() => {\n onValueChange(option.value)\n }}\n style={({ pressed }) => ({\n alignItems: 'flex-start',\n flexDirection: 'row',\n gap: theme.spacing.md,\n minHeight: 44,\n opacity: state.opacity * resolveLumenButtonOpacity(state.disabled, pressed),\n paddingVertical: theme.spacing.sm\n })}\n >\n <View\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={{\n alignItems: 'center',\n borderColor: selected ? theme.colors.brandSolid : theme.colors.line,\n borderRadius: 11,\n borderWidth: 2,\n height: 22,\n justifyContent: 'center',\n marginTop: 1,\n width: 22\n }}\n >\n {selected ?\n <View style={{ backgroundColor: theme.colors.brandSolid, borderRadius: 5, height: 10, width: 10 }} /> :\n null}\n </View>\n <View style={{ flex: 1, gap: theme.spacing.xs }}>\n <Text style={{ color: theme.colors.ink, fontSize: theme.fontSizes.sm }}>{option.label}</Text>\n {option.description ?\n (\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>\n {option.description}\n </Text>\n ) :\n null}\n </View>\n </Pressable>\n )\n })}\n </View>\n )\n}\n\nexport interface LumenSegmentedControlProps extends Omit<ViewProps, 'children'> {\n label: string\n onValueChange: (value: string) => void\n options: readonly LumenSelectionOption[]\n ref?: LumenViewRef\n showLabel?: boolean\n value: string\n}\n\nexport const LumenSegmentedControl = ({\n label,\n onValueChange,\n options,\n ref,\n showLabel = true,\n style,\n value,\n ...props\n}: LumenSegmentedControlProps): ReactElement => {\n const theme = useLumenTheme()\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.sm }, style]}>\n {showLabel ?\n (\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n ) :\n null}\n <View\n accessibilityLabel={label}\n accessibilityRole=\"radiogroup\"\n style={{\n backgroundColor: theme.colors.surfaceMuted,\n borderColor: theme.colors.line,\n borderRadius: theme.radii.sm,\n borderWidth: 1,\n flexDirection: 'row',\n padding: 2\n }}\n >\n {options.map(option => {\n const selected = value === option.value\n const state = resolveLumenSelectionState(selected, option.disabled ?? false)\n\n return (\n <Pressable\n aria-checked={selected}\n aria-disabled={state.disabled}\n key={option.value}\n accessibilityLabel={option.label}\n accessibilityRole=\"radio\"\n accessibilityState={{ checked: selected, disabled: state.disabled }}\n disabled={state.disabled}\n onPress={() => {\n onValueChange(option.value)\n }}\n style={({ pressed }) => ({\n alignItems: 'center',\n backgroundColor: selected ? theme.colors.surface : 'transparent',\n borderRadius: theme.radii.sm,\n flex: 1,\n justifyContent: 'center',\n minHeight: 40,\n opacity: state.opacity * resolveLumenButtonOpacity(state.disabled, pressed),\n paddingHorizontal: theme.spacing.sm,\n paddingVertical: theme.spacing.sm\n })}\n >\n <Text\n numberOfLines={1}\n style={{\n color: selected ? theme.colors.brand : theme.colors.inkSoft,\n fontSize: theme.fontSizes.xs,\n fontWeight: String(\n selected ? theme.fontWeights.bold : theme.fontWeights.semibold\n ) as TextStyle['fontWeight']\n }}\n >\n {option.label}\n </Text>\n </Pressable>\n )\n })}\n </View>\n </View>\n )\n}\n\nexport interface LumenTabsProps extends Omit<ViewProps, 'children'> {\n children: ReactNode\n label: string\n onValueChange: (value: string) => void\n options: readonly LumenSelectionOption[]\n /** Localized panel name; defaults to the selected tab's visible label. */\n panelAccessibilityLabel?: string\n ref?: LumenViewRef\n value: string\n}\n\nexport const LumenTabs = ({\n children,\n label,\n onValueChange,\n options,\n panelAccessibilityLabel,\n ref,\n style,\n value,\n ...props\n}: LumenTabsProps): ReactElement => {\n const theme = useLumenTheme()\n const selectedOption = options.find(option => option.value === value)\n const selectedIndex = options.findIndex(option => option.value === value)\n const tabsId = useId().replaceAll(':', '')\n const pendingFocusValueRef = useRef<string | undefined>(undefined)\n const tabInstancesRef = useRef<(HostInstance | null)[]>([])\n const tabLayoutsRef = useRef(new Map<number, { width: number, x: number }>())\n const tabListRef = useRef<ComponentRef<typeof ScrollView>>(null)\n const viewportWidthRef = useRef(0)\n\n const scrollTabIntoView = (index: number, animated: boolean): void => {\n const layout = tabLayoutsRef.current.get(index)\n\n if (!layout) return\n\n const centeredOffset = layout.x - Math.max(0, (viewportWidthRef.current - layout.width) / 2)\n\n tabListRef.current?.scrollTo({ animated, x: Math.max(0, centeredOffset) })\n }\n\n useEffect(() => {\n if (selectedIndex < 0) return\n\n scrollTabIntoView(selectedIndex, true)\n\n if (pendingFocusValueRef.current === value) {\n tabInstancesRef.current[selectedIndex]?.focus()\n\n pendingFocusValueRef.current = undefined\n }\n }, [selectedIndex, value])\n\n const selectTab = (optionIndex: number): boolean => {\n const option = options[optionIndex]\n\n if (!option || option.disabled) return false\n\n if (option.value === value) {\n tabInstancesRef.current[optionIndex]?.focus()\n\n return true\n }\n\n pendingFocusValueRef.current = option.value\n\n onValueChange(option.value)\n\n return true\n }\n\n const moveSelection = (currentIndex: number, direction: -1 | 1): void => {\n for (let offset = 1; offset < options.length; offset += 1) {\n const nextIndex = (currentIndex + direction * offset + options.length) % options.length\n\n if (selectTab(nextIndex)) return\n }\n }\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.md }, style]}>\n <ScrollView\n accessibilityLabel={label}\n accessibilityRole=\"tablist\"\n contentContainerStyle={{ gap: theme.spacing.xs }}\n horizontal\n onLayout={event => {\n viewportWidthRef.current = event.nativeEvent.layout.width\n\n if (selectedIndex >= 0) scrollTabIntoView(selectedIndex, false)\n }}\n ref={tabListRef}\n showsHorizontalScrollIndicator={false}\n style={{ borderBottomColor: theme.colors.line, borderBottomWidth: 1 }}\n >\n {options.map((option, optionIndex) => {\n const selected = option.value === value\n const state = resolveLumenSelectionState(selected, option.disabled ?? false)\n const tabId = `${tabsId}-tab-${optionIndex}`\n\n return (\n <Pressable\n aria-disabled={state.disabled}\n aria-selected={selected}\n accessibilityRole=\"tab\"\n accessibilityState={{ disabled: state.disabled, selected }}\n disabled={state.disabled}\n key={option.value}\n nativeID={tabId}\n onKeyDown={event => {\n const key = event.nativeEvent.key || event.nativeEvent.code\n\n if (key === 'ArrowLeft') {\n event.preventDefault()\n\n moveSelection(optionIndex, -1)\n } else if (key === 'ArrowRight') {\n event.preventDefault()\n\n moveSelection(optionIndex, 1)\n }\n }}\n onLayout={event => {\n tabLayoutsRef.current.set(optionIndex, event.nativeEvent.layout)\n\n if (selected) scrollTabIntoView(optionIndex, false)\n }}\n onPress={() => {\n onValueChange(option.value)\n }}\n ref={instance => {\n tabInstancesRef.current[optionIndex] = instance\n }}\n style={({ pressed }) => ({\n borderBottomColor: selected ? theme.colors.brandSolid : 'transparent',\n borderBottomWidth: 2,\n minHeight: 44,\n opacity: state.opacity * resolveLumenButtonOpacity(state.disabled, pressed),\n paddingHorizontal: theme.spacing.md,\n paddingVertical: theme.spacing.sm\n })}\n >\n <Text\n style={{\n color: selected ? theme.colors.brand : theme.colors.inkSoft,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(\n selected ? theme.fontWeights.bold : theme.fontWeights.semibold\n ) as TextStyle['fontWeight']\n }}\n >\n {option.label}\n </Text>\n </Pressable>\n )\n })}\n </ScrollView>\n <View\n aria-labelledby={selectedIndex >= 0 ? `${tabsId}-tab-${selectedIndex}` : undefined}\n accessibilityLabel={panelAccessibilityLabel ?? selectedOption?.label ?? value}\n accessibilityLiveRegion=\"polite\"\n role=\"tabpanel\"\n >\n {children}\n </View>\n </View>\n )\n}\n",
|
|
60267
60354
|
"packages/react-native/src/selection-recipes.test.ts": "import { describe, expect, test } from 'vitest'\n\nimport { resolveLumenSelectionState } from './selection-recipes.js'\n\ndescribe('resolveLumenSelectionState', () => {\n test('preserves selection and enabled state', () => {\n expect(resolveLumenSelectionState(true, false)).toEqual({\n disabled: false,\n opacity: 1,\n selected: true\n })\n })\n\n test('dims disabled choices without changing selection', () => {\n expect(resolveLumenSelectionState(false, true)).toEqual({\n disabled: true,\n opacity: 0.52,\n selected: false\n })\n })\n})\n",
|
|
60268
60355
|
"packages/react-native/src/selection-recipes.ts": "export interface LumenSelectionState {\n disabled: boolean\n opacity: number\n selected: boolean\n}\n\nexport const resolveLumenSelectionState = (\n selected: boolean,\n disabled: boolean\n): LumenSelectionState => ({\n disabled,\n opacity: disabled ? 0.52 : 1,\n selected\n})\n",
|
|
60269
60356
|
"packages/react-native/src/shared-components.tsx": "import {\n createContext,\n type ReactElement,\n type ReactNode,\n use\n} from 'react'\nimport {\n type ColorValue,\n Image,\n type ImageSourcePropType,\n Pressable,\n type PressableProps,\n Text,\n type TextProps,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\n\nimport type { LumenTextRef, LumenViewRef } from './native-ref-types.js'\nimport {\n type LumenSurfacePadding,\n type LumenSurfaceRadius\n} from './primitives.js'\nimport {\n resolveLumenButtonOpacity,\n resolveLumenPressableStyle\n} from './recipes.js'\nimport {\n type LumenAlertVariant,\n type LumenAvatarSize,\n type LumenCardVariant,\n resolveLumenAlertColors,\n resolveLumenAvatarSize,\n resolveLumenCardColors,\n resolveLumenProgressValue,\n resolveLumenSurfacePadding,\n resolveLumenSurfaceRadius\n} from './shared-recipes.js'\nimport { useLumenTheme } from './theme-context.js'\n\nconst resolveLumenBorderWidth = (theme: ReturnType<typeof useLumenTheme>): number => theme.appearance?.borderWidth ?? 1\n\nexport interface LumenCardProps extends Omit<PressableProps, 'children'> {\n children: ReactNode\n padding?: LumenSurfacePadding\n radius?: LumenSurfaceRadius\n ref?: LumenViewRef\n variant?: LumenCardVariant\n}\n\nexport const LumenCard = ({\n accessibilityRole,\n accessibilityState,\n children,\n disabled = false,\n onLongPress,\n onPress,\n padding = 'xl',\n radius = 'lg',\n ref,\n style,\n variant = 'default',\n ...props\n}: LumenCardProps): ReactElement => {\n const theme = useLumenTheme()\n const isDisabled = disabled === true\n const interactive = Boolean(onPress ?? onLongPress)\n const colors = resolveLumenCardColors(theme.colors, variant)\n\n const cardStyle = {\n backgroundColor: colors.backgroundColor,\n borderColor: colors.borderColor,\n borderRadius: resolveLumenSurfaceRadius(theme, radius),\n borderWidth: resolveLumenBorderWidth(theme),\n gap: theme.spacing.lg,\n padding: resolveLumenSurfacePadding(theme, padding)\n }\n\n if (!interactive) {\n return (\n <View\n ref={ref}\n {...props}\n accessibilityRole={accessibilityRole}\n accessibilityState={accessibilityState}\n style={[\n cardStyle,\n resolveLumenPressableStyle(style, { pressed: false })\n ]}\n >\n {children}\n </View>\n )\n }\n\n return (\n <Pressable\n ref={ref}\n {...props}\n accessibilityRole={accessibilityRole ?? 'button'}\n accessibilityState={{\n ...accessibilityState,\n disabled: isDisabled || undefined\n }}\n disabled={isDisabled}\n onLongPress={onLongPress}\n onPress={onPress}\n style={state => [\n {\n ...cardStyle,\n borderColor: state.pressed ? theme.colors.brand : colors.borderColor,\n opacity: resolveLumenButtonOpacity(isDisabled, state.pressed)\n },\n resolveLumenPressableStyle(style, state)\n ]}\n >\n {children}\n </Pressable>\n )\n}\n\nexport interface LumenAlertProps extends ViewProps {\n children: ReactNode\n ref?: LumenViewRef\n variant?: LumenAlertVariant\n}\n\ninterface LumenAlertContentColors {\n description: ColorValue\n foreground: ColorValue\n}\n\nconst LumenAlertColorContext = createContext<LumenAlertContentColors | null>(\n null\n)\n\nexport const LumenAlert = ({\n children,\n ref,\n style,\n variant = 'default',\n ...props\n}: LumenAlertProps): ReactElement => {\n const theme = useLumenTheme()\n const colors = resolveLumenAlertColors(theme.colors, variant)\n\n return (\n <View\n ref={ref}\n {...props}\n style={[\n {\n backgroundColor: colors.backgroundColor,\n borderColor: colors.borderColor,\n borderRadius: theme.radii.md,\n borderWidth: 1,\n gap: theme.spacing.xs,\n paddingHorizontal: theme.spacing.lg,\n paddingVertical: theme.spacing.md\n },\n style\n ]}\n >\n <LumenAlertColorContext\n value={{\n description:\n variant === 'default' ? theme.colors.inkSoft : colors.color,\n foreground: colors.color\n }}\n >\n {children}\n </LumenAlertColorContext>\n </View>\n )\n}\n\nexport interface LumenAlertTitleProps extends TextProps {\n ref?: LumenTextRef\n}\n\nexport const LumenAlertTitle = ({\n ref,\n style,\n ...props\n}: LumenAlertTitleProps): ReactElement => {\n const theme = useLumenTheme()\n const colors = use(LumenAlertColorContext)\n\n return (\n <Text\n ref={ref}\n {...props}\n style={[\n {\n color: colors?.foreground ?? theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(\n theme.fontWeights.semibold\n ) as TextStyle['fontWeight']\n },\n style\n ]}\n />\n )\n}\n\nexport interface LumenAlertDescriptionProps extends TextProps {\n ref?: LumenTextRef\n}\n\nexport const LumenAlertDescription = ({\n ref,\n style,\n ...props\n}: LumenAlertDescriptionProps): ReactElement => {\n const theme = useLumenTheme()\n const colors = use(LumenAlertColorContext)\n\n return (\n <Text\n ref={ref}\n {...props}\n style={[\n {\n color: colors?.description ?? theme.colors.inkSoft,\n fontSize: theme.fontSizes.sm,\n lineHeight: 20\n },\n style\n ]}\n />\n )\n}\n\nexport interface LumenProgressProps extends ViewProps {\n color?: ColorValue\n label?: string\n max?: number\n ref?: LumenViewRef\n value?: number\n}\n\nexport const LumenProgress = ({\n color,\n label,\n max = 100,\n ref,\n style,\n value = 0,\n ...props\n}: LumenProgressProps): ReactElement => {\n const theme = useLumenTheme()\n const progress = resolveLumenProgressValue(value, max)\n\n return (\n <View\n ref={ref}\n {...props}\n accessibilityLabel={label}\n accessibilityRole=\"progressbar\"\n accessibilityValue={{ max: progress.max, min: 0, now: progress.value }}\n style={[\n {\n backgroundColor: theme.colors.surfaceStrong,\n borderRadius: theme.radii.full,\n height: theme.spacing.sm,\n overflow: 'hidden',\n width: '100%'\n },\n style\n ]}\n >\n <View\n importantForAccessibility=\"no\"\n style={{\n backgroundColor: color ?? theme.colors.brandSolid,\n borderRadius: theme.radii.full,\n height: '100%',\n width: `${progress.percentage}%`\n }}\n />\n </View>\n )\n}\n\nexport interface LumenAvatarProps extends ViewProps {\n fallback?: string\n label?: string\n ref?: LumenViewRef\n size?: LumenAvatarSize\n source?: ImageSourcePropType\n}\n\nexport const LumenAvatar = ({\n fallback = '?',\n label,\n ref,\n size = 'md',\n source,\n style,\n ...props\n}: LumenAvatarProps): ReactElement => {\n const theme = useLumenTheme()\n const dimension = resolveLumenAvatarSize(size)\n const fontSize = size === 'lg' ? theme.fontSizes.lg : theme.fontSizes.sm\n\n return (\n <View\n ref={ref}\n {...props}\n accessible={Boolean(label)}\n accessibilityLabel={label}\n accessibilityRole={label ? 'image' : undefined}\n style={[\n {\n alignItems: 'center',\n backgroundColor: theme.colors.surfaceMuted,\n borderColor: theme.colors.line,\n borderRadius: theme.radii.full,\n borderWidth: 1,\n height: dimension,\n justifyContent: 'center',\n overflow: 'hidden',\n width: dimension\n },\n style\n ]}\n >\n {source ?\n (\n <Image\n accessibilityElementsHidden\n importantForAccessibility=\"no\"\n resizeMode=\"cover\"\n source={source}\n style={{ height: dimension, width: dimension }}\n />\n ) :\n (\n <Text\n accessibilityElementsHidden\n importantForAccessibility=\"no\"\n style={{\n color: theme.colors.ink,\n fontSize,\n fontWeight: String(\n theme.fontWeights.bold\n ) as TextStyle['fontWeight']\n }}\n >\n {fallback}\n </Text>\n )}\n </View>\n )\n}\n",
|
|
@@ -60310,7 +60397,7 @@
|
|
|
60310
60397
|
"packages/react-native/src/tree-select-components.tsx": "import { type ReactElement, useState } from 'react'\nimport { FlatList, View, type ViewProps } from 'react-native'\n\nimport { LumenButton, LumenText } from './primitives.js'\nimport { useLumenTheme } from './theme-context.js'\nimport type { LumenTreeNode, LumenTreeRow } from './tree-recipes.js'\nimport { LumenTreeSelectModel } from './tree-select-recipes.js'\n\nexport interface LumenTreeSelectProps extends Pick<ViewProps, 'style' | 'testID' | 'nativeID' | 'accessibilityHint'> {\n label: string\n nodes: readonly LumenTreeNode[]\n value: string | null\n onValueChange: (value: string) => void\n disabled?: boolean | undefined\n readOnly?: boolean | undefined\n loading?: boolean | undefined\n error?: string | null | undefined\n placeholder?: string | undefined\n unknownSelectionLabel?: string | undefined\n loadingLabel?: string | undefined\n emptyLabel?: string | undefined\n invalidLabel?: string | undefined\n expandedLabel?: string | undefined\n collapsedLabel?: string | undefined\n formatOption?: ((label: string, path: readonly string[], level: number) => string) | undefined\n}\n\nconst defaultOption = (_label: string, path: readonly string[], level: number): string => `${path.join(' / ')}, level ${level}`\n\nconst statusLabel = (props: LumenTreeSelectProps, model: LumenTreeSelectModel): string | null => {\n if (props.loading) return props.loadingLabel ?? 'Loading'\n\n if (props.error !== undefined && props.error !== null) return props.error\n\n if (!model.valid) return props.invalidLabel ?? 'Invalid options'\n\n return null\n}\n\nconst TreeSelectOption = ({ props, model, row, close }: {\n props: LumenTreeSelectProps\n model: LumenTreeSelectModel\n row: LumenTreeRow\n close: () => void\n}): ReactElement => {\n const theme = useLumenTheme()\n const blocked = (props.disabled ?? false) || (props.readOnly ?? false) || !model.canSelect(row.node.id)\n const path = model.tree.path(row.node.id).map(node => node.label)\n const name = (props.formatOption ?? defaultOption)(row.node.label, path, row.depth + 1)\n\n return (\n <View style={{ marginStart: Math.min(row.depth, 8) * theme.spacing.sm }}>\n <LumenButton\n disabled={blocked}\n accessibilityLabel={name}\n accessibilityState={{ selected: props.value === row.node.id, disabled: blocked }}\n onPress={() => {\n if (blocked) return\n\n const next = model.selecting(row.node.id, props.value)\n\n if (next !== null && next !== props.value) props.onValueChange(next)\n\n close()\n }}\n >\n {row.node.label}\n </LumenButton>\n </View>\n )\n}\n\nexport const LumenTreeSelect = (props: LumenTreeSelectProps): ReactElement => {\n const theme = useLumenTheme()\n const [open, setOpen] = useState(false)\n const model = new LumenTreeSelectModel(props.nodes)\n const status = statusLabel(props, model)\n const selection = model.selectionLabel(props.value, props.placeholder ?? 'Select…', props.unknownSelectionLabel ?? 'Unavailable selection')\n\n return (\n <View\n testID={props.testID}\n nativeID={props.nativeID}\n accessibilityLabel={props.label}\n accessibilityHint={props.accessibilityHint}\n style={[{ gap: theme.spacing.sm }, props.style]}\n >\n <LumenText>{props.label}</LumenText>\n {status !== null ? <LumenText accessibilityLiveRegion=\"polite\">{selection}</LumenText> : null}\n {status !== null ?\n <LumenText accessibilityLiveRegion=\"polite\">{status}</LumenText> :\n (\n <>\n <LumenButton\n disabled={props.disabled}\n accessibilityLabel={`${props.label}: ${selection}`}\n accessibilityState={{ expanded: open }}\n accessibilityValue={{ text: open ? props.expandedLabel ?? 'Expanded' : props.collapsedLabel ?? 'Collapsed' }}\n onPress={() => {\n if (!props.disabled) setOpen(current => !current)\n }}\n >\n {selection}\n </LumenButton>\n {open ?\n (\n <FlatList\n nestedScrollEnabled\n style={{ maxHeight: 320 }}\n accessibilityLabel={props.label}\n data={model.rows}\n keyExtractor={row => row.node.id}\n ListEmptyComponent={<LumenText>{props.emptyLabel ?? 'No options'}</LumenText>}\n renderItem={({ item }) => (\n <TreeSelectOption\n props={props}\n model={model}\n row={item}\n close={() => {\n setOpen(false)\n }}\n />\n )}\n />\n ) :\n null}\n </>\n )}\n </View>\n )\n}\n",
|
|
60311
60398
|
"packages/react-native/src/tree-select-recipes.test.ts": "import { expect, test } from 'vitest'\n\nimport { LumenTreeSelectModel } from './tree-select-recipes.js'\n\ntest('all hierarchical rows are shown and both parents and leaves can be selected', () => {\n const model = new LumenTreeSelectModel([\n { id: 'root', label: 'Root' }, { id: 'leaf', label: 'Leaf', parentId: 'root' }\n ])\n\n expect(model.rows.map(row => [row.node.id, row.depth])).toEqual([['root', 0], ['leaf', 1]])\n expect(model.selecting('root', 'missing')).toBe('root')\n expect(model.selecting('leaf', null)).toBe('leaf')\n expect(model.selectionLabel('missing', 'Choose', 'Unknown')).toBe('Unknown')\n expect(model.selectionLabel(null, 'Choose', 'Unknown')).toBe('Choose')\n})\n\ntest('disabled ancestry, excluded nodes and unknown IDs retain the host value', () => {\n const model = new LumenTreeSelectModel([\n { id: 'root', label: 'Root', disabled: true },\n { id: 'leaf', label: 'Leaf', parentId: 'root' },\n { id: 'group', label: 'Group', selectable: false }\n ])\n\n for (const id of ['root', 'leaf', 'group', 'missing']) expect(model.selecting(id, 'host-only')).toBe('host-only')\n expect(model.selectionLabel('leaf', 'Choose', 'Unknown')).toBe('Leaf')\n})\n\ntest('invalid graphs hide options and cannot rewrite an unknown controlled selection', () => {\n for (const nodes of [\n [{ id: ' ', label: '' }],\n [{ id: 'a', label: '' }, { id: 'a', label: '' }],\n [{ id: 'a', label: '', parentId: 'missing' }],\n [{ id: 'a', label: '', parentId: 'b' }, { id: 'b', label: '', parentId: 'a' }]\n ]) {\n const model = new LumenTreeSelectModel(nodes)\n\n expect(model.valid).toBe(false)\n expect(model.rows).toEqual([])\n expect(model.selecting('a', 'unknown')).toBe('unknown')\n }\n})\n\ntest('deep hierarchy traversal stays iterative', () => {\n const nodes = Array.from({ length: 10000 }, (_, index) => ({\n id: String(index), label: String(index), parentId: index === 0 ? null : String(index - 1)\n }))\n const model = new LumenTreeSelectModel(nodes)\n\n expect(model.rows).toHaveLength(10000)\n expect(model.rows.at(-1)?.depth).toBe(9999)\n})\n",
|
|
60312
60399
|
"packages/react-native/src/tree-select-recipes.ts": "import { LumenTreeModel, type LumenTreeNode, type LumenTreeRow } from './tree-recipes.js'\n\n/** Single selection over the validated hierarchy; parent nodes are selectable. */\nexport class LumenTreeSelectModel {\n readonly tree: LumenTreeModel\n readonly rows: readonly LumenTreeRow[]\n readonly valid: boolean\n\n constructor(nodes: readonly LumenTreeNode[]) {\n this.tree = new LumenTreeModel(nodes)\n\n this.valid = this.tree.valid && nodes.every(node => node.id.trim().length > 0)\n\n this.rows = this.valid ? this.tree.visibleRows(new Set(nodes.map(node => node.id))) : []\n }\n\n canSelect(id: string): boolean {\n const node = this.tree.node(id)\n\n return this.valid && node !== undefined && !this.tree.isDisabled(id) && node.selectable !== false\n }\n\n selecting(id: string, current: string | null): string | null {\n return this.canSelect(id) ? id : current\n }\n\n selectionLabel(value: string | null, placeholder: string, unknownLabel: string): string {\n if (value === null) return placeholder\n\n if (!this.valid) return unknownLabel\n\n return this.tree.node(value)?.label ?? unknownLabel\n }\n}\n",
|
|
60313
|
-
"packages/react-native/src/value-components.tsx": "import {\n type ReactElement,\n type ReactNode,\n useState\n} from 'react'\nimport {\n type AccessibilityActionEvent,\n I18nManager,\n type LayoutChangeEvent,\n type NativeSyntheticEvent,\n type NativeTouchEvent,\n Pressable,\n Text,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\nimport { Circle, Svg } from 'react-native-svg'\n\nimport type { LumenViewRef } from './native-ref-types.js'\nimport { LumenIcon } from './primitives.js'\nimport { resolveLumenProgressValue } from './shared-recipes.js'\nimport {\n type LumenMetricTone,\n resolveLumenMetricColor\n} from './structured-recipes.js'\nimport { useLumenTheme } from './theme-context.js'\nimport {\n resolveLumenRangeValue,\n resolveLumenSliderPosition,\n resolveLumenSliderValue,\n stepLumenSliderValue\n} from './value-recipes.js'\n\nexport interface LumenPickerOption<Value extends number | string> {\n disabled?: boolean\n label: string\n value: Value\n}\n\nexport interface LumenPickerProps<Value extends number | string> extends Omit<ViewProps, 'children'> {\n enabled?: boolean\n label: string\n onValueChange: (value: Value) => void\n options: readonly LumenPickerOption<Value>[]\n ref?: LumenViewRef\n value: Value\n}\n\nconst resolveControlOpacity = (enabled: boolean, pressed: boolean): number => {\n if (!enabled) return 0.5\n\n return pressed ? 0.82 : 1\n}\n\nconst LumenPickerControl = <Value extends number | string,>({\n enabled = true,\n label,\n onValueChange,\n options,\n ref,\n style,\n value,\n ...props\n}: LumenPickerProps<Value>): ReactElement => {\n const theme = useLumenTheme()\n const [expanded, setExpanded] = useState(false)\n const controlEnabled = enabled && options.length > 0\n const selected = options.find(option => option.value === value)\n const selectedLabel = selected?.label ?? String(value)\n const menuVisible = controlEnabled && expanded\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.xs }, style]}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n <Pressable\n accessibilityLabel={label}\n accessibilityRole=\"combobox\"\n accessibilityState={{ disabled: !controlEnabled, expanded: menuVisible }}\n accessibilityValue={{ text: selectedLabel }}\n disabled={!controlEnabled}\n onPress={() => {\n setExpanded(current => !current)\n }}\n style={({ pressed }) => ({\n alignItems: 'center',\n backgroundColor: theme.colors.surface,\n borderColor: menuVisible ? theme.colors.brand : theme.colors.line,\n borderRadius: theme.radii.md,\n borderWidth: 1,\n flexDirection: 'row',\n justifyContent: 'space-between',\n minHeight: 44,\n opacity: resolveControlOpacity(controlEnabled, pressed),\n paddingHorizontal: theme.spacing.md,\n paddingVertical: theme.spacing.sm\n })}\n >\n <Text style={{ color: theme.colors.ink, fontSize: theme.fontSizes.sm }}>{selectedLabel}</Text>\n <LumenIcon\n color={theme.colors.inkMuted}\n decorative\n name={menuVisible ? 'chevron-up' : 'chevron-down'}\n size=\"sm\"\n />\n </Pressable>\n {menuVisible ?\n (\n <View\n accessibilityLabel={`${label} options`}\n accessibilityRole=\"menu\"\n style={{\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.line,\n borderRadius: theme.radii.md,\n borderWidth: 1,\n overflow: 'hidden'\n }}\n >\n {options.map(option => {\n const optionDisabled = option.disabled ?? false\n const optionSelected = option.value === value\n\n return (\n <Pressable\n accessibilityRole=\"menuitem\"\n accessibilityState={{ disabled: optionDisabled, selected: optionSelected }}\n disabled={optionDisabled}\n key={`${typeof option.value}:${String(option.value)}`}\n onPress={() => {\n onValueChange(option.value)\n\n setExpanded(false)\n }}\n style={({ pressed }) => ({\n backgroundColor: optionSelected ? theme.colors.brandSoft : theme.colors.surface,\n justifyContent: 'center',\n minHeight: 44,\n opacity: resolveControlOpacity(!optionDisabled, pressed),\n paddingHorizontal: theme.spacing.md,\n paddingVertical: theme.spacing.sm\n })}\n >\n <Text style={{ color: theme.colors.ink, fontSize: theme.fontSizes.sm }}>\n {option.label}\n </Text>\n </Pressable>\n )\n })}\n </View>\n ) :\n null}\n </View>\n )\n}\n\nexport const LumenPicker = <Value extends number | string,>(\n props: LumenPickerProps<Value>\n): ReactElement => (\n <LumenPickerControl\n key={String((props.enabled ?? true) && props.options.length > 0)}\n {...props}\n />\n)\n\nexport interface LumenSliderProps extends Omit<ViewProps, 'children'> {\n enabled?: boolean\n label: string\n max?: number\n min?: number\n onValueChange: (value: number) => void\n ref?: LumenViewRef\n step?: number\n value: number\n valueLabel?: string\n}\n\nexport const LumenSlider = ({\n enabled = true,\n label,\n max = 100,\n min = 0,\n onValueChange,\n ref,\n step,\n style,\n value,\n valueLabel,\n ...props\n}: LumenSliderProps): ReactElement => {\n const theme = useLumenTheme()\n const [trackWidth, setTrackWidth] = useState(0)\n const resolved = resolveLumenSliderValue(value, min, max, step)\n const formattedValue = valueLabel ?? String(resolved.value)\n\n const updateFromTouch = (event: NativeSyntheticEvent<NativeTouchEvent>): void => {\n if (!enabled) return\n\n const location = I18nManager.isRTL ? trackWidth - event.nativeEvent.locationX : event.nativeEvent.locationX\n\n onValueChange(resolveLumenSliderPosition(location, trackWidth, resolved))\n }\n\n const handleAccessibilityAction = (event: AccessibilityActionEvent): void => {\n if (!enabled) return\n\n const action = event.nativeEvent.actionName\n\n if (action === 'increment' || action === 'decrement') {\n onValueChange(stepLumenSliderValue(resolved, action))\n }\n }\n\n const handleLayout = (event: LayoutChangeEvent): void => {\n setTrackWidth(event.nativeEvent.layout.width)\n }\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.sm }, style]}>\n <View style={{ alignItems: 'baseline', flexDirection: 'row', justifyContent: 'space-between' }}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>\n {formattedValue}\n </Text>\n </View>\n <View\n accessible\n accessibilityActions={[{ name: 'decrement' }, { name: 'increment' }]}\n accessibilityLabel={label}\n accessibilityRole=\"adjustable\"\n aria-disabled={!enabled}\n aria-valuemax={resolved.max}\n aria-valuemin={resolved.min}\n aria-valuenow={resolved.value}\n aria-valuetext={formattedValue}\n accessibilityState={{ disabled: !enabled }}\n accessibilityValue={{\n max: resolved.max,\n min: resolved.min,\n now: resolved.value,\n text: formattedValue\n }}\n onAccessibilityAction={handleAccessibilityAction}\n onLayout={handleLayout}\n onMoveShouldSetResponder={() => enabled}\n onResponderGrant={updateFromTouch}\n onResponderMove={updateFromTouch}\n onStartShouldSetResponder={() => enabled}\n style={{\n height: 44,\n justifyContent: 'center',\n opacity: enabled ? 1 : 0.5\n }}\n >\n <View\n importantForAccessibility=\"no-hide-descendants\"\n style={{\n backgroundColor: theme.colors.surfaceStrong,\n borderRadius: theme.radii.full,\n height: 6,\n overflow: 'visible'\n }}\n >\n <View\n style={{\n backgroundColor: theme.colors.brandSolid,\n borderRadius: theme.radii.full,\n height: 6,\n width: `${resolved.percentage}%`\n }}\n />\n <View\n style={{\n backgroundColor: theme.colors.brandSolid,\n borderColor: theme.colors.surface,\n borderRadius: 10,\n borderWidth: 2,\n height: 20,\n marginStart: -10,\n start: `${resolved.percentage}%`,\n position: 'absolute',\n top: -7,\n width: 20\n }}\n />\n </View>\n </View>\n </View>\n )\n}\n\nexport interface LumenGaugeProps extends Omit<ViewProps, 'children'> {\n center?: ReactNode\n label: string\n max?: number\n ref?: LumenViewRef\n tone?: LumenMetricTone\n value: number\n valueLabel: string\n}\n\nexport const LumenGauge = ({\n center,\n label,\n max = 100,\n ref,\n style,\n tone = 'brand',\n value,\n valueLabel,\n ...props\n}: LumenGaugeProps): ReactElement => {\n const theme = useLumenTheme()\n const progress = resolveLumenProgressValue(value, max)\n const circumference = 2 * Math.PI * 44\n const strokeDashoffset = circumference * (1 - progress.percentage / 100)\n\n return (\n <View\n ref={ref}\n {...props}\n accessibilityLabel={label}\n accessibilityRole=\"progressbar\"\n accessibilityValue={{ max: progress.max, min: 0, now: progress.value, text: valueLabel }}\n style={[{ alignItems: 'center', gap: theme.spacing.sm }, style]}\n >\n <View\n importantForAccessibility=\"no-hide-descendants\"\n style={{ alignItems: 'center', height: 108, justifyContent: 'center', width: 108 }}\n >\n <Svg height={108} style={{ position: 'absolute' }} viewBox=\"0 0 108 108\" width={108}>\n <Circle\n cx={54}\n cy={54}\n fill=\"none\"\n r={44}\n stroke={theme.colors.surfaceStrong}\n strokeWidth={10}\n />\n <Circle\n cx={54}\n cy={54}\n fill=\"none\"\n r={44}\n stroke={resolveLumenMetricColor(theme.colors, tone)}\n strokeDasharray={`${circumference} ${circumference}`}\n strokeDashoffset={strokeDashoffset}\n strokeLinecap=\"round\"\n strokeWidth={10}\n transform=\"rotate(-90 54 54)\"\n />\n </Svg>\n {center ?? (\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {valueLabel}\n </Text>\n )}\n </View>\n <Text\n style={{\n color: theme.colors.inkSoft,\n fontSize: theme.fontSizes.xs,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n </View>\n )\n}\n\n/** Controlled interval with independent, named adjustable endpoints. */\nexport interface LumenRangeSliderProps extends Omit<ViewProps, 'children'> {\n enabled?: boolean\n endLabel?: string\n formatValue?: (value: number) => string\n label: string\n max?: number\n min?: number\n onValueChange: (value: readonly [number, number]) => void\n readOnly?: boolean\n ref?: LumenViewRef\n startLabel?: string\n step?: number\n value: readonly [number, number]\n}\n\nexport const LumenRangeSlider = ({\n enabled = true,\n endLabel = 'Maximum',\n formatValue = String,\n label,\n max = 100,\n min = 0,\n onValueChange,\n readOnly = false,\n ref,\n startLabel = 'Minimum',\n step,\n style,\n value,\n ...props\n}: LumenRangeSliderProps): ReactElement => {\n const theme = useLumenTheme()\n const [start, end] = resolveLumenRangeValue(value, min, max, step)\n const editable = enabled && !readOnly\n const sliderStep = step === undefined ? {} : { step }\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.sm }, style]}>\n <LumenSlider\n enabled={editable}\n label={`${label} · ${startLabel}`}\n max={max}\n min={min}\n onValueChange={next => {\n if (editable) onValueChange([Math.min(next, end), end])\n }}\n {...sliderStep}\n value={start}\n valueLabel={formatValue(start)}\n />\n <LumenSlider\n enabled={editable}\n label={`${label} · ${endLabel}`}\n max={max}\n min={min}\n onValueChange={next => {\n if (editable) onValueChange([start, Math.max(start, next)])\n }}\n {...sliderStep}\n value={end}\n valueLabel={formatValue(end)}\n />\n </View>\n )\n}\n",
|
|
60400
|
+
"packages/react-native/src/value-components.tsx": "import {\n type ReactElement,\n type ReactNode,\n useState\n} from 'react'\nimport {\n type AccessibilityActionEvent,\n type ColorValue,\n I18nManager,\n type LayoutChangeEvent,\n type NativeSyntheticEvent,\n type NativeTouchEvent,\n Pressable,\n Text,\n type TextStyle,\n View,\n type ViewProps\n} from 'react-native'\nimport { Circle, Svg } from 'react-native-svg'\n\nimport { type LumenFieldContextValue, useLumenFieldContext } from './field-context.js'\nimport type { LumenViewRef } from './native-ref-types.js'\nimport { LumenIcon } from './primitives.js'\nimport { resolveLumenAriaInvalid, resolveLumenValidationHint } from './recipes.js'\nimport { resolveLumenProgressValue } from './shared-recipes.js'\nimport {\n type LumenMetricTone,\n resolveLumenMetricColor\n} from './structured-recipes.js'\nimport { useLumenTheme } from './theme-context.js'\nimport {\n resolveLumenRangeValue,\n resolveLumenSliderPosition,\n resolveLumenSliderValue,\n stepLumenSliderValue\n} from './value-recipes.js'\n\nexport interface LumenPickerOption<Value extends number | string> {\n disabled?: boolean\n label: string\n value: Value\n}\n\nexport interface LumenPickerProps<Value extends number | string> extends Omit<ViewProps, 'children'> {\n enabled?: boolean\n label: string\n onValueChange: (value: Value) => void\n options: readonly LumenPickerOption<Value>[]\n ref?: LumenViewRef\n value: Value\n}\n\nconst resolveControlOpacity = (enabled: boolean, pressed: boolean): number => {\n if (!enabled) return 0.5\n\n return pressed ? 0.82 : 1\n}\n\nconst resolvePickerFieldValidation = (\n field: LumenFieldContextValue | null\n): { hint: string | undefined, invalid: true | undefined } => ({\n hint: resolveLumenValidationHint(field?.errorMessage, field?.description),\n invalid: resolveLumenAriaInvalid(field?.errorMessage)\n})\n\nconst resolvePickerBorderColor = (\n open: boolean,\n invalid: boolean,\n colors: { brand: ColorValue, danger: ColorValue, line: ColorValue }\n): ColorValue => {\n if (open) return colors.brand\n\n return invalid ? colors.danger : colors.line\n}\n\nconst LumenPickerControl = <Value extends number | string,>({\n enabled = true,\n label,\n onValueChange,\n options,\n ref,\n style,\n value,\n ...props\n}: LumenPickerProps<Value>): ReactElement => {\n const theme = useLumenTheme()\n // Inside a field group the trigger announces the group's error and description like other fields.\n const validation = resolvePickerFieldValidation(useLumenFieldContext())\n const [expanded, setExpanded] = useState(false)\n const controlEnabled = enabled && options.length > 0\n const selected = options.find(option => option.value === value)\n const selectedLabel = selected?.label ?? String(value)\n const menuVisible = controlEnabled && expanded\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.xs }, style]}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n <Pressable\n accessibilityHint={validation.hint}\n accessibilityLabel={label}\n accessibilityRole=\"combobox\"\n accessibilityState={{ disabled: !controlEnabled, expanded: menuVisible }}\n accessibilityValue={{ text: selectedLabel }}\n aria-invalid={validation.invalid}\n disabled={!controlEnabled}\n onPress={() => {\n setExpanded(current => !current)\n }}\n style={({ pressed }) => ({\n alignItems: 'center',\n backgroundColor: theme.colors.surface,\n borderColor: resolvePickerBorderColor(menuVisible, validation.invalid === true, theme.colors),\n borderRadius: theme.radii.md,\n borderWidth: 1,\n flexDirection: 'row',\n justifyContent: 'space-between',\n minHeight: 44,\n opacity: resolveControlOpacity(controlEnabled, pressed),\n paddingHorizontal: theme.spacing.md,\n paddingVertical: theme.spacing.sm\n })}\n >\n <Text style={{ color: theme.colors.ink, fontSize: theme.fontSizes.sm }}>{selectedLabel}</Text>\n <LumenIcon\n color={theme.colors.inkMuted}\n decorative\n name={menuVisible ? 'chevron-up' : 'chevron-down'}\n size=\"sm\"\n />\n </Pressable>\n {menuVisible ?\n (\n <View\n accessibilityLabel={`${label} options`}\n accessibilityRole=\"menu\"\n style={{\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.line,\n borderRadius: theme.radii.md,\n borderWidth: 1,\n overflow: 'hidden'\n }}\n >\n {options.map(option => {\n const optionDisabled = option.disabled ?? false\n const optionSelected = option.value === value\n\n return (\n <Pressable\n accessibilityRole=\"menuitem\"\n accessibilityState={{ disabled: optionDisabled, selected: optionSelected }}\n disabled={optionDisabled}\n key={`${typeof option.value}:${String(option.value)}`}\n onPress={() => {\n onValueChange(option.value)\n\n setExpanded(false)\n }}\n style={({ pressed }) => ({\n backgroundColor: optionSelected ? theme.colors.brandSoft : theme.colors.surface,\n justifyContent: 'center',\n minHeight: 44,\n opacity: resolveControlOpacity(!optionDisabled, pressed),\n paddingHorizontal: theme.spacing.md,\n paddingVertical: theme.spacing.sm\n })}\n >\n <Text style={{ color: theme.colors.ink, fontSize: theme.fontSizes.sm }}>\n {option.label}\n </Text>\n </Pressable>\n )\n })}\n </View>\n ) :\n null}\n </View>\n )\n}\n\nexport const LumenPicker = <Value extends number | string,>(\n props: LumenPickerProps<Value>\n): ReactElement => (\n <LumenPickerControl\n key={String((props.enabled ?? true) && props.options.length > 0)}\n {...props}\n />\n)\n\nexport interface LumenSliderProps extends Omit<ViewProps, 'children'> {\n enabled?: boolean\n label: string\n max?: number\n min?: number\n onValueChange: (value: number) => void\n ref?: LumenViewRef\n step?: number\n value: number\n valueLabel?: string\n}\n\nexport const LumenSlider = ({\n enabled = true,\n label,\n max = 100,\n min = 0,\n onValueChange,\n ref,\n step,\n style,\n value,\n valueLabel,\n ...props\n}: LumenSliderProps): ReactElement => {\n const theme = useLumenTheme()\n const [trackWidth, setTrackWidth] = useState(0)\n const resolved = resolveLumenSliderValue(value, min, max, step)\n const formattedValue = valueLabel ?? String(resolved.value)\n\n const updateFromTouch = (event: NativeSyntheticEvent<NativeTouchEvent>): void => {\n if (!enabled) return\n\n const location = I18nManager.isRTL ? trackWidth - event.nativeEvent.locationX : event.nativeEvent.locationX\n\n onValueChange(resolveLumenSliderPosition(location, trackWidth, resolved))\n }\n\n const handleAccessibilityAction = (event: AccessibilityActionEvent): void => {\n if (!enabled) return\n\n const action = event.nativeEvent.actionName\n\n if (action === 'increment' || action === 'decrement') {\n onValueChange(stepLumenSliderValue(resolved, action))\n }\n }\n\n const handleLayout = (event: LayoutChangeEvent): void => {\n setTrackWidth(event.nativeEvent.layout.width)\n }\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.sm }, style]}>\n <View style={{ alignItems: 'baseline', flexDirection: 'row', justifyContent: 'space-between' }}>\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n <Text style={{ color: theme.colors.inkMuted, fontSize: theme.fontSizes.xs }}>\n {formattedValue}\n </Text>\n </View>\n <View\n accessible\n accessibilityActions={[{ name: 'decrement' }, { name: 'increment' }]}\n accessibilityLabel={label}\n accessibilityRole=\"adjustable\"\n aria-disabled={!enabled}\n aria-valuemax={resolved.max}\n aria-valuemin={resolved.min}\n aria-valuenow={resolved.value}\n aria-valuetext={formattedValue}\n accessibilityState={{ disabled: !enabled }}\n accessibilityValue={{\n max: resolved.max,\n min: resolved.min,\n now: resolved.value,\n text: formattedValue\n }}\n onAccessibilityAction={handleAccessibilityAction}\n onLayout={handleLayout}\n onMoveShouldSetResponder={() => enabled}\n onResponderGrant={updateFromTouch}\n onResponderMove={updateFromTouch}\n onStartShouldSetResponder={() => enabled}\n style={{\n height: 44,\n justifyContent: 'center',\n opacity: enabled ? 1 : 0.5\n }}\n >\n <View\n importantForAccessibility=\"no-hide-descendants\"\n style={{\n backgroundColor: theme.colors.surfaceStrong,\n borderRadius: theme.radii.full,\n height: 6,\n overflow: 'visible'\n }}\n >\n <View\n style={{\n backgroundColor: theme.colors.brandSolid,\n borderRadius: theme.radii.full,\n height: 6,\n width: `${resolved.percentage}%`\n }}\n />\n <View\n style={{\n backgroundColor: theme.colors.brandSolid,\n borderColor: theme.colors.surface,\n borderRadius: 10,\n borderWidth: 2,\n height: 20,\n marginStart: -10,\n start: `${resolved.percentage}%`,\n position: 'absolute',\n top: -7,\n width: 20\n }}\n />\n </View>\n </View>\n </View>\n )\n}\n\nexport interface LumenGaugeProps extends Omit<ViewProps, 'children'> {\n center?: ReactNode\n label: string\n max?: number\n ref?: LumenViewRef\n tone?: LumenMetricTone\n value: number\n valueLabel: string\n}\n\nexport const LumenGauge = ({\n center,\n label,\n max = 100,\n ref,\n style,\n tone = 'brand',\n value,\n valueLabel,\n ...props\n}: LumenGaugeProps): ReactElement => {\n const theme = useLumenTheme()\n const progress = resolveLumenProgressValue(value, max)\n const circumference = 2 * Math.PI * 44\n const strokeDashoffset = circumference * (1 - progress.percentage / 100)\n\n return (\n <View\n ref={ref}\n {...props}\n accessibilityLabel={label}\n accessibilityRole=\"progressbar\"\n accessibilityValue={{ max: progress.max, min: 0, now: progress.value, text: valueLabel }}\n style={[{ alignItems: 'center', gap: theme.spacing.sm }, style]}\n >\n <View\n importantForAccessibility=\"no-hide-descendants\"\n style={{ alignItems: 'center', height: 108, justifyContent: 'center', width: 108 }}\n >\n <Svg height={108} style={{ position: 'absolute' }} viewBox=\"0 0 108 108\" width={108}>\n <Circle\n cx={54}\n cy={54}\n fill=\"none\"\n r={44}\n stroke={theme.colors.surfaceStrong}\n strokeWidth={10}\n />\n <Circle\n cx={54}\n cy={54}\n fill=\"none\"\n r={44}\n stroke={resolveLumenMetricColor(theme.colors, tone)}\n strokeDasharray={`${circumference} ${circumference}`}\n strokeDashoffset={strokeDashoffset}\n strokeLinecap=\"round\"\n strokeWidth={10}\n transform=\"rotate(-90 54 54)\"\n />\n </Svg>\n {center ?? (\n <Text\n style={{\n color: theme.colors.ink,\n fontSize: theme.fontSizes.sm,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {valueLabel}\n </Text>\n )}\n </View>\n <Text\n style={{\n color: theme.colors.inkSoft,\n fontSize: theme.fontSizes.xs,\n fontWeight: String(theme.fontWeights.semibold) as TextStyle['fontWeight']\n }}\n >\n {label}\n </Text>\n </View>\n )\n}\n\n/** Controlled interval with independent, named adjustable endpoints. */\nexport interface LumenRangeSliderProps extends Omit<ViewProps, 'children'> {\n enabled?: boolean\n endLabel?: string\n formatValue?: (value: number) => string\n label: string\n max?: number\n min?: number\n onValueChange: (value: readonly [number, number]) => void\n readOnly?: boolean\n ref?: LumenViewRef\n startLabel?: string\n step?: number\n value: readonly [number, number]\n}\n\nexport const LumenRangeSlider = ({\n enabled = true,\n endLabel = 'Maximum',\n formatValue = String,\n label,\n max = 100,\n min = 0,\n onValueChange,\n readOnly = false,\n ref,\n startLabel = 'Minimum',\n step,\n style,\n value,\n ...props\n}: LumenRangeSliderProps): ReactElement => {\n const theme = useLumenTheme()\n const [start, end] = resolveLumenRangeValue(value, min, max, step)\n const editable = enabled && !readOnly\n const sliderStep = step === undefined ? {} : { step }\n\n return (\n <View ref={ref} {...props} style={[{ gap: theme.spacing.sm }, style]}>\n <LumenSlider\n enabled={editable}\n label={`${label} · ${startLabel}`}\n max={max}\n min={min}\n onValueChange={next => {\n if (editable) onValueChange([Math.min(next, end), end])\n }}\n {...sliderStep}\n value={start}\n valueLabel={formatValue(start)}\n />\n <LumenSlider\n enabled={editable}\n label={`${label} · ${endLabel}`}\n max={max}\n min={min}\n onValueChange={next => {\n if (editable) onValueChange([start, Math.max(start, next)])\n }}\n {...sliderStep}\n value={end}\n valueLabel={formatValue(end)}\n />\n </View>\n )\n}\n",
|
|
60314
60401
|
"packages/react-native/src/value-recipes.test.ts": "import { describe, expect, test } from 'vitest'\n\nimport {\n resolveLumenRangeValue,\n resolveLumenSliderPosition,\n resolveLumenSliderValue,\n stepLumenSliderValue\n} from './value-recipes.js'\n\ndescribe('Lumen React Native value recipes', () => {\n test('clamps and snaps slider values to a finite range', () => {\n expect(resolveLumenSliderValue(48, 0, 100, 10)).toEqual({\n max: 100,\n min: 0,\n percentage: 50,\n step: 10,\n value: 50\n })\n expect(resolveLumenSliderValue(Number.POSITIVE_INFINITY, 20, 10)).toMatchObject({\n max: 120,\n min: 20,\n value: 20\n })\n })\n\n test('maps pointer positions through the same step contract', () => {\n const value = resolveLumenSliderValue(0, 0, 100, 25)\n\n expect(resolveLumenSliderPosition(74, 100, value)).toBe(75)\n expect(resolveLumenSliderPosition(-20, 100, value)).toBe(0)\n expect(resolveLumenSliderPosition(140, 100, value)).toBe(100)\n })\n\n test('supports bounded assistive-technology increments', () => {\n const value = resolveLumenSliderValue(90, 0, 100, 10)\n\n expect(stepLumenSliderValue(value, 'increment')).toBe(100)\n expect(stepLumenSliderValue(value, 'decrement')).toBe(80)\n expect(stepLumenSliderValue(resolveLumenSliderValue(100, 0, 100, 10), 'increment')).toBe(100)\n })\n})\n\ndescribe('native numeric intervals', () => {\n test('normalizes unordered, out-of-range and nonfinite endpoints without mutating input', () => {\n const input = [80, 20] as const\n expect(resolveLumenRangeValue(input, 0, 100, 10)).toEqual([20, 80])\n expect(input).toEqual([80, 20])\n expect(resolveLumenRangeValue([-20, 120], 0, 100)).toEqual([0, 100])\n expect(resolveLumenRangeValue([Number.NaN, Number.POSITIVE_INFINITY], 0, 100)).toEqual([0, 100])\n expect(resolveLumenRangeValue([30, 30], 0, 100, 10)).toEqual([30, 30])\n })\n\n test('retains the domain step grid and nonzero origin', () => {\n expect(resolveLumenRangeValue([24, 76], 0, 100, 10)).toEqual([20, 80])\n expect(resolveLumenRangeValue([25, 96], 5, 96, 10)).toEqual([25, 95])\n expect(resolveLumenRangeValue([0.2, 0.8], 0, 1, 10)).toEqual([0, 1])\n })\n\n test('rejects unusable domains and steps before rendering', () => {\n const domains = [\n [1, 1],\n [2, 1],\n [Number.NaN, 1],\n [0, Number.POSITIVE_INFINITY],\n [-Number.MAX_VALUE, Number.MAX_VALUE],\n [0, Number.MIN_VALUE]\n ]\n\n for (const [min, max] of domains) {\n if (min === undefined || max === undefined) throw new Error('Missing bounds fixture')\n expect(() => resolveLumenRangeValue([0, 1], min, max)).toThrow(RangeError)\n }\n for (const step of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.MIN_VALUE]) {\n expect(() => resolveLumenRangeValue([0, 100], 0, 100, step)).toThrow(RangeError)\n }\n })\n})\n",
|
|
60315
60402
|
"packages/react-native/src/value-recipes.ts": "export interface LumenSliderValue {\n max: number\n min: number\n percentage: number\n step: number\n value: number\n}\n\nconst finiteOr = (value: number, fallback: number): number => (\n Number.isFinite(value) ? value : fallback\n)\n\nexport const resolveLumenSliderValue = (\n value: number,\n min: number,\n max: number,\n step?: number\n): LumenSliderValue => {\n const safeMin = finiteOr(min, 0)\n const candidateMax = finiteOr(max, safeMin + 100)\n const safeMax = candidateMax > safeMin ? candidateMax : safeMin + 100\n const range = safeMax - safeMin\n const safeStep = Number.isFinite(step) && (step ?? 0) > 0 ? Math.min(step ?? 1, range) : range / 100\n const finiteValue = finiteOr(value, safeMin)\n const clampedValue = Math.min(safeMax, Math.max(safeMin, finiteValue))\n const stepIndex = Math.round((clampedValue - safeMin) / safeStep)\n const steppedValue = Math.min(safeMax, Math.max(safeMin, safeMin + stepIndex * safeStep))\n\n return {\n max: safeMax,\n min: safeMin,\n percentage: ((steppedValue - safeMin) / range) * 100,\n step: safeStep,\n value: steppedValue\n }\n}\n\nexport const resolveLumenSliderPosition = (\n position: number,\n width: number,\n value: LumenSliderValue\n): number => {\n if (!Number.isFinite(width) || width <= 0) return value.value\n\n const safePosition = Math.min(width, Math.max(0, finiteOr(position, 0)))\n const rawValue = value.min + (safePosition / width) * (value.max - value.min)\n\n return resolveLumenSliderValue(rawValue, value.min, value.max, value.step).value\n}\n\nexport const stepLumenSliderValue = (\n value: LumenSliderValue,\n direction: 'decrement' | 'increment'\n): number => resolveLumenSliderValue(\n value.value + (direction === 'increment' ? value.step : -value.step),\n value.min,\n value.max,\n value.step\n).value\n\n/** Reject unusable bounds before constructing native controls. Never mutate caller state. */\nexport const resolveLumenRangeValue = (\n value: readonly [number, number],\n min: number,\n max: number,\n step?: number\n): readonly [number, number] => {\n if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max || !Number.isFinite(max - min)) {\n throw new RangeError('Range bounds must be finite, increasing, and have a finite span.')\n }\n\n if (step !== undefined && (!Number.isFinite(step) || step <= 0 || !Number.isFinite((max - min) / step))) {\n throw new RangeError('Range step must be finite, positive, and representable within the bounds.')\n }\n\n if ((max - min) / 100 === 0) throw new RangeError('Range span must support adjustment.')\n\n const start = resolveLumenSliderValue(finiteOr(value[0], min), min, max, step).value\n const end = resolveLumenSliderValue(finiteOr(value[1], max), min, max, step).value\n\n return [Math.min(start, end), Math.max(start, end)]\n}\n"
|
|
60316
60403
|
},
|
|
@@ -61369,17 +61456,17 @@
|
|
|
61369
61456
|
"peerDependencies": {
|
|
61370
61457
|
"astro": ">=5"
|
|
61371
61458
|
},
|
|
61372
|
-
"version": "4.
|
|
61459
|
+
"version": "4.1.0"
|
|
61373
61460
|
},
|
|
61374
61461
|
"@santi020k/lumen-core": {
|
|
61375
61462
|
"peerDependencies": {
|
|
61376
61463
|
"@rive-app/canvas": "^2.44.0"
|
|
61377
61464
|
},
|
|
61378
|
-
"version": "4.
|
|
61465
|
+
"version": "4.1.0"
|
|
61379
61466
|
},
|
|
61380
61467
|
"@santi020k/lumen-elements": {
|
|
61381
61468
|
"peerDependencies": {},
|
|
61382
|
-
"version": "4.
|
|
61469
|
+
"version": "4.1.0"
|
|
61383
61470
|
},
|
|
61384
61471
|
"@santi020k/lumen-icons-brand": {
|
|
61385
61472
|
"peerDependencies": {},
|
|
@@ -61389,18 +61476,18 @@
|
|
|
61389
61476
|
"peerDependencies": {
|
|
61390
61477
|
"react": ">=19"
|
|
61391
61478
|
},
|
|
61392
|
-
"version": "4.
|
|
61479
|
+
"version": "4.1.0"
|
|
61393
61480
|
},
|
|
61394
61481
|
"@santi020k/lumen-mcp": {
|
|
61395
61482
|
"peerDependencies": {},
|
|
61396
|
-
"version": "4.
|
|
61483
|
+
"version": "4.1.0"
|
|
61397
61484
|
},
|
|
61398
61485
|
"@santi020k/lumen-react": {
|
|
61399
61486
|
"peerDependencies": {
|
|
61400
61487
|
"react": ">=19",
|
|
61401
61488
|
"motion": "^14.0.0"
|
|
61402
61489
|
},
|
|
61403
|
-
"version": "4.
|
|
61490
|
+
"version": "4.1.0"
|
|
61404
61491
|
},
|
|
61405
61492
|
"@santi020k/lumen-react-hook-form": {
|
|
61406
61493
|
"peerDependencies": {
|
|
@@ -61408,7 +61495,7 @@
|
|
|
61408
61495
|
"react": ">=19",
|
|
61409
61496
|
"react-hook-form": ">=7.76.0 <8"
|
|
61410
61497
|
},
|
|
61411
|
-
"version": "4.0.
|
|
61498
|
+
"version": "4.0.1"
|
|
61412
61499
|
},
|
|
61413
61500
|
"@santi020k/lumen-react-native": {
|
|
61414
61501
|
"peerDependencies": {
|
|
@@ -61417,7 +61504,7 @@
|
|
|
61417
61504
|
"react-native": ">=0.86.2",
|
|
61418
61505
|
"react-native-svg": ">=12 <16"
|
|
61419
61506
|
},
|
|
61420
|
-
"version": "4.0.
|
|
61507
|
+
"version": "4.0.1"
|
|
61421
61508
|
},
|
|
61422
61509
|
"@santi020k/lumen-tokens": {
|
|
61423
61510
|
"peerDependencies": {},
|
|
@@ -61428,9 +61515,9 @@
|
|
|
61428
61515
|
"swift": {
|
|
61429
61516
|
"package": "https://github.com/santi020k/lumen.git",
|
|
61430
61517
|
"revision": null,
|
|
61431
|
-
"tag": "v4.
|
|
61518
|
+
"tag": "v4.1.0"
|
|
61432
61519
|
},
|
|
61433
|
-
"version": "4.
|
|
61520
|
+
"version": "4.1.0"
|
|
61434
61521
|
},
|
|
61435
61522
|
"migration": {
|
|
61436
61523
|
"codemod": null,
|