@lo-tek/interfaces 0.0.0 → 0.38.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/CHANGELOG.md +3389 -0
- package/LICENSE +21 -0
- package/README.md +129 -1
- package/agent/astro.md +400 -0
- package/agent/bundles.md +429 -0
- package/agent/datastar.md +409 -0
- package/agent/html.md +388 -0
- package/agent/react.md +440 -0
- package/agent/templates.md +472 -0
- package/core/lotek-core.wasm +0 -0
- package/core/lotek.wasm +0 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +14 -0
- package/dist/cli.js.map +1 -0
- package/dist/components/css/accordion/accordion.d.ts +37 -0
- package/dist/components/css/accordion/accordion.d.ts.map +1 -0
- package/dist/components/css/accordion/accordion.js +31 -0
- package/dist/components/css/accordion/accordion.js.map +1 -0
- package/dist/components/css/alert/alert.d.ts +35 -0
- package/dist/components/css/alert/alert.d.ts.map +1 -0
- package/dist/components/css/alert/alert.js +29 -0
- package/dist/components/css/alert/alert.js.map +1 -0
- package/dist/components/css/avatar/avatar.d.ts +40 -0
- package/dist/components/css/avatar/avatar.d.ts.map +1 -0
- package/dist/components/css/avatar/avatar.js +30 -0
- package/dist/components/css/avatar/avatar.js.map +1 -0
- package/dist/components/css/badge/badge.d.ts +24 -0
- package/dist/components/css/badge/badge.d.ts.map +1 -0
- package/dist/components/css/badge/badge.js +24 -0
- package/dist/components/css/badge/badge.js.map +1 -0
- package/dist/components/css/breadcrumbs/breadcrumbs.d.ts +26 -0
- package/dist/components/css/breadcrumbs/breadcrumbs.d.ts.map +1 -0
- package/dist/components/css/breadcrumbs/breadcrumbs.js +24 -0
- package/dist/components/css/breadcrumbs/breadcrumbs.js.map +1 -0
- package/dist/components/css/button/button.d.ts +26 -0
- package/dist/components/css/button/button.d.ts.map +1 -0
- package/dist/components/css/button/button.js +25 -0
- package/dist/components/css/button/button.js.map +1 -0
- package/dist/components/css/card/card.d.ts +36 -0
- package/dist/components/css/card/card.d.ts.map +1 -0
- package/dist/components/css/card/card.js +28 -0
- package/dist/components/css/card/card.js.map +1 -0
- package/dist/components/css/checkbox/checkbox.d.ts +32 -0
- package/dist/components/css/checkbox/checkbox.d.ts.map +1 -0
- package/dist/components/css/checkbox/checkbox.js +27 -0
- package/dist/components/css/checkbox/checkbox.js.map +1 -0
- package/dist/components/css/divider/divider.d.ts +29 -0
- package/dist/components/css/divider/divider.d.ts.map +1 -0
- package/dist/components/css/divider/divider.js +23 -0
- package/dist/components/css/divider/divider.js.map +1 -0
- package/dist/components/css/error-text/error-text.d.ts +24 -0
- package/dist/components/css/error-text/error-text.d.ts.map +1 -0
- package/dist/components/css/error-text/error-text.js +18 -0
- package/dist/components/css/error-text/error-text.js.map +1 -0
- package/dist/components/css/fieldset/fieldset.d.ts +55 -0
- package/dist/components/css/fieldset/fieldset.d.ts.map +1 -0
- package/dist/components/css/fieldset/fieldset.js +39 -0
- package/dist/components/css/fieldset/fieldset.js.map +1 -0
- package/dist/components/css/file-upload/file-upload.d.ts +41 -0
- package/dist/components/css/file-upload/file-upload.d.ts.map +1 -0
- package/dist/components/css/file-upload/file-upload.js +32 -0
- package/dist/components/css/file-upload/file-upload.js.map +1 -0
- package/dist/components/css/heading/heading.d.ts +29 -0
- package/dist/components/css/heading/heading.d.ts.map +1 -0
- package/dist/components/css/heading/heading.js +23 -0
- package/dist/components/css/heading/heading.js.map +1 -0
- package/dist/components/css/help-text/help-text.d.ts +24 -0
- package/dist/components/css/help-text/help-text.d.ts.map +1 -0
- package/dist/components/css/help-text/help-text.js +23 -0
- package/dist/components/css/help-text/help-text.js.map +1 -0
- package/dist/components/css/icon/icon.d.ts +34 -0
- package/dist/components/css/icon/icon.d.ts.map +1 -0
- package/dist/components/css/icon/icon.js +25 -0
- package/dist/components/css/icon/icon.js.map +1 -0
- package/dist/components/css/input/input.d.ts +40 -0
- package/dist/components/css/input/input.d.ts.map +1 -0
- package/dist/components/css/input/input.js +45 -0
- package/dist/components/css/input/input.js.map +1 -0
- package/dist/components/css/label/label.d.ts +33 -0
- package/dist/components/css/label/label.d.ts.map +1 -0
- package/dist/components/css/label/label.js +23 -0
- package/dist/components/css/label/label.js.map +1 -0
- package/dist/components/css/link/link.d.ts +28 -0
- package/dist/components/css/link/link.d.ts.map +1 -0
- package/dist/components/css/link/link.js +20 -0
- package/dist/components/css/link/link.js.map +1 -0
- package/dist/components/css/list/list.d.ts +31 -0
- package/dist/components/css/list/list.d.ts.map +1 -0
- package/dist/components/css/list/list.js +25 -0
- package/dist/components/css/list/list.js.map +1 -0
- package/dist/components/css/pagination/pagination.d.ts +27 -0
- package/dist/components/css/pagination/pagination.d.ts.map +1 -0
- package/dist/components/css/pagination/pagination.js +25 -0
- package/dist/components/css/pagination/pagination.js.map +1 -0
- package/dist/components/css/paragraph/paragraph.d.ts +36 -0
- package/dist/components/css/paragraph/paragraph.d.ts.map +1 -0
- package/dist/components/css/paragraph/paragraph.js +26 -0
- package/dist/components/css/paragraph/paragraph.js.map +1 -0
- package/dist/components/css/progress/progress.d.ts +53 -0
- package/dist/components/css/progress/progress.d.ts.map +1 -0
- package/dist/components/css/progress/progress.js +38 -0
- package/dist/components/css/progress/progress.js.map +1 -0
- package/dist/components/css/radio/radio.d.ts +32 -0
- package/dist/components/css/radio/radio.d.ts.map +1 -0
- package/dist/components/css/radio/radio.js +27 -0
- package/dist/components/css/radio/radio.js.map +1 -0
- package/dist/components/css/search/search.d.ts +34 -0
- package/dist/components/css/search/search.d.ts.map +1 -0
- package/dist/components/css/search/search.js +32 -0
- package/dist/components/css/search/search.js.map +1 -0
- package/dist/components/css/select/select.d.ts +40 -0
- package/dist/components/css/select/select.d.ts.map +1 -0
- package/dist/components/css/select/select.js +31 -0
- package/dist/components/css/select/select.js.map +1 -0
- package/dist/components/css/shared.d.ts +90 -0
- package/dist/components/css/shared.d.ts.map +1 -0
- package/dist/components/css/shared.js +167 -0
- package/dist/components/css/shared.js.map +1 -0
- package/dist/components/css/skeleton/skeleton.d.ts +29 -0
- package/dist/components/css/skeleton/skeleton.d.ts.map +1 -0
- package/dist/components/css/skeleton/skeleton.js +23 -0
- package/dist/components/css/skeleton/skeleton.js.map +1 -0
- package/dist/components/css/skip-link/skip-link.d.ts +18 -0
- package/dist/components/css/skip-link/skip-link.d.ts.map +1 -0
- package/dist/components/css/skip-link/skip-link.js +16 -0
- package/dist/components/css/skip-link/skip-link.js.map +1 -0
- package/dist/components/css/spinner/spinner.d.ts +33 -0
- package/dist/components/css/spinner/spinner.d.ts.map +1 -0
- package/dist/components/css/spinner/spinner.js +25 -0
- package/dist/components/css/spinner/spinner.js.map +1 -0
- package/dist/components/css/sr-only/sr-only.d.ts +17 -0
- package/dist/components/css/sr-only/sr-only.d.ts.map +1 -0
- package/dist/components/css/sr-only/sr-only.js +15 -0
- package/dist/components/css/sr-only/sr-only.js.map +1 -0
- package/dist/components/css/switch/switch.d.ts +37 -0
- package/dist/components/css/switch/switch.d.ts.map +1 -0
- package/dist/components/css/switch/switch.js +29 -0
- package/dist/components/css/switch/switch.js.map +1 -0
- package/dist/components/css/table/table.d.ts +76 -0
- package/dist/components/css/table/table.d.ts.map +1 -0
- package/dist/components/css/table/table.js +59 -0
- package/dist/components/css/table/table.js.map +1 -0
- package/dist/components/css/tag/tag.d.ts +35 -0
- package/dist/components/css/tag/tag.d.ts.map +1 -0
- package/dist/components/css/tag/tag.js +27 -0
- package/dist/components/css/tag/tag.js.map +1 -0
- package/dist/components/css/textarea/textarea.d.ts +23 -0
- package/dist/components/css/textarea/textarea.d.ts.map +1 -0
- package/dist/components/css/textarea/textarea.js +18 -0
- package/dist/components/css/textarea/textarea.js.map +1 -0
- package/dist/components/css/toggle-group/toggle-group.d.ts +31 -0
- package/dist/components/css/toggle-group/toggle-group.d.ts.map +1 -0
- package/dist/components/css/toggle-group/toggle-group.js +29 -0
- package/dist/components/css/toggle-group/toggle-group.js.map +1 -0
- package/dist/components/css/tooltip/tooltip.d.ts +31 -0
- package/dist/components/css/tooltip/tooltip.d.ts.map +1 -0
- package/dist/components/css/tooltip/tooltip.js +29 -0
- package/dist/components/css/tooltip/tooltip.js.map +1 -0
- package/dist/components/default-texts.d.ts +41 -0
- package/dist/components/default-texts.d.ts.map +1 -0
- package/dist/components/default-texts.js +41 -0
- package/dist/components/default-texts.js.map +1 -0
- package/dist/components/events.d.ts +77 -0
- package/dist/components/events.d.ts.map +1 -0
- package/dist/components/events.js +3 -0
- package/dist/components/events.js.map +1 -0
- package/dist/components/frame/dialog/dialog.d.ts +122 -0
- package/dist/components/frame/dialog/dialog.d.ts.map +1 -0
- package/dist/components/frame/dialog/dialog.js +112 -0
- package/dist/components/frame/dialog/dialog.js.map +1 -0
- package/dist/components/frame/dialog/lt-dialog.d.ts +104 -0
- package/dist/components/frame/dialog/lt-dialog.d.ts.map +1 -0
- package/dist/components/frame/dialog/lt-dialog.js +434 -0
- package/dist/components/frame/dialog/lt-dialog.js.map +1 -0
- package/dist/components/frame/error-summary/error-summary.d.ts +53 -0
- package/dist/components/frame/error-summary/error-summary.d.ts.map +1 -0
- package/dist/components/frame/error-summary/error-summary.js +54 -0
- package/dist/components/frame/error-summary/error-summary.js.map +1 -0
- package/dist/components/frame/error-summary/lt-error-summary.d.ts +93 -0
- package/dist/components/frame/error-summary/lt-error-summary.d.ts.map +1 -0
- package/dist/components/frame/error-summary/lt-error-summary.js +408 -0
- package/dist/components/frame/error-summary/lt-error-summary.js.map +1 -0
- package/dist/components/frame/field/field-core.d.ts +92 -0
- package/dist/components/frame/field/field-core.d.ts.map +1 -0
- package/dist/components/frame/field/field-core.js +63 -0
- package/dist/components/frame/field/field-core.js.map +1 -0
- package/dist/components/frame/field/lt-field.d.ts +173 -0
- package/dist/components/frame/field/lt-field.d.ts.map +1 -0
- package/dist/components/frame/field/lt-field.js +600 -0
- package/dist/components/frame/field/lt-field.js.map +1 -0
- package/dist/components/frame/popover/lt-popover.d.ts +136 -0
- package/dist/components/frame/popover/lt-popover.d.ts.map +1 -0
- package/dist/components/frame/popover/lt-popover.js +520 -0
- package/dist/components/frame/popover/lt-popover.js.map +1 -0
- package/dist/components/frame/popover/popover.d.ts +49 -0
- package/dist/components/frame/popover/popover.d.ts.map +1 -0
- package/dist/components/frame/popover/popover.js +64 -0
- package/dist/components/frame/popover/popover.js.map +1 -0
- package/dist/components/frame/session-timeout/lt-session-timeout.d.ts +198 -0
- package/dist/components/frame/session-timeout/lt-session-timeout.d.ts.map +1 -0
- package/dist/components/frame/session-timeout/lt-session-timeout.js +610 -0
- package/dist/components/frame/session-timeout/lt-session-timeout.js.map +1 -0
- package/dist/components/frame/session-timeout/session-timeout.d.ts +113 -0
- package/dist/components/frame/session-timeout/session-timeout.d.ts.map +1 -0
- package/dist/components/frame/session-timeout/session-timeout.js +101 -0
- package/dist/components/frame/session-timeout/session-timeout.js.map +1 -0
- package/dist/components/frame/suggestion/lt-suggestion.d.ts +367 -0
- package/dist/components/frame/suggestion/lt-suggestion.d.ts.map +1 -0
- package/dist/components/frame/suggestion/lt-suggestion.js +1171 -0
- package/dist/components/frame/suggestion/lt-suggestion.js.map +1 -0
- package/dist/components/frame/suggestion/suggestion.d.ts +143 -0
- package/dist/components/frame/suggestion/suggestion.d.ts.map +1 -0
- package/dist/components/frame/suggestion/suggestion.js +127 -0
- package/dist/components/frame/suggestion/suggestion.js.map +1 -0
- package/dist/components/frame/tabs/lt-tabs.d.ts +426 -0
- package/dist/components/frame/tabs/lt-tabs.d.ts.map +1 -0
- package/dist/components/frame/tabs/lt-tabs.js +1265 -0
- package/dist/components/frame/tabs/lt-tabs.js.map +1 -0
- package/dist/components/frame/tabs/tabs.d.ts +112 -0
- package/dist/components/frame/tabs/tabs.d.ts.map +1 -0
- package/dist/components/frame/tabs/tabs.js +107 -0
- package/dist/components/frame/tabs/tabs.js.map +1 -0
- package/dist/components/host-element.d.ts +184 -0
- package/dist/components/host-element.d.ts.map +1 -0
- package/dist/components/host-element.js +440 -0
- package/dist/components/host-element.js.map +1 -0
- package/dist/components/standalone/connection-status/connection-status.d.ts +32 -0
- package/dist/components/standalone/connection-status/connection-status.d.ts.map +1 -0
- package/dist/components/standalone/connection-status/connection-status.js +26 -0
- package/dist/components/standalone/connection-status/connection-status.js.map +1 -0
- package/dist/components/standalone/connection-status/lt-connection-status.d.ts +86 -0
- package/dist/components/standalone/connection-status/lt-connection-status.d.ts.map +1 -0
- package/dist/components/standalone/connection-status/lt-connection-status.js +240 -0
- package/dist/components/standalone/connection-status/lt-connection-status.js.map +1 -0
- package/dist/components/standalone/toast/lt-toast.d.ts +109 -0
- package/dist/components/standalone/toast/lt-toast.d.ts.map +1 -0
- package/dist/components/standalone/toast/lt-toast.js +256 -0
- package/dist/components/standalone/toast/lt-toast.js.map +1 -0
- package/dist/components/standalone/toast/toast.d.ts +52 -0
- package/dist/components/standalone/toast/toast.d.ts.map +1 -0
- package/dist/components/standalone/toast/toast.js +42 -0
- package/dist/components/standalone/toast/toast.js.map +1 -0
- package/dist/diagnostics/core.d.ts +171 -0
- package/dist/diagnostics/core.d.ts.map +1 -0
- package/dist/diagnostics/core.js +84 -0
- package/dist/diagnostics/core.js.map +1 -0
- package/dist/diagnostics/default-core.d.ts +10 -0
- package/dist/diagnostics/default-core.d.ts.map +1 -0
- package/dist/diagnostics/default-core.js +72 -0
- package/dist/diagnostics/default-core.js.map +1 -0
- package/dist/diagnostics/index.d.ts +84 -0
- package/dist/diagnostics/index.d.ts.map +1 -0
- package/dist/diagnostics/index.js +106 -0
- package/dist/diagnostics/index.js.map +1 -0
- package/dist/dom.d.ts +55 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +162 -0
- package/dist/dom.js.map +1 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -0
- package/dist/jsx/react.d.ts +180 -0
- package/dist/jsx/react.d.ts.map +1 -0
- package/dist/jsx/react.js +2 -0
- package/dist/jsx/react.js.map +1 -0
- package/dist/lotek.css +3617 -0
- package/dist/lt.d.ts +325 -0
- package/dist/lt.d.ts.map +1 -0
- package/dist/lt.js +183 -0
- package/dist/lt.js.map +1 -0
- package/dist/react.d.ts +494 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +296 -0
- package/dist/react.js.map +1 -0
- package/dist/register.d.ts +17 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +35 -0
- package/dist/register.js.map +1 -0
- package/dist/tokens/color.d.ts +43 -0
- package/dist/tokens/color.d.ts.map +1 -0
- package/dist/tokens/color.js +123 -0
- package/dist/tokens/color.js.map +1 -0
- package/dist/tokens/contract.d.ts +18 -0
- package/dist/tokens/contract.d.ts.map +1 -0
- package/dist/tokens/contract.js +18 -0
- package/dist/tokens/contract.js.map +1 -0
- package/dist/tokens/matrix-cells.d.ts +30 -0
- package/dist/tokens/matrix-cells.d.ts.map +1 -0
- package/dist/tokens/matrix-cells.js +273 -0
- package/dist/tokens/matrix-cells.js.map +1 -0
- package/dist/tokens/matrix.d.ts +18 -0
- package/dist/tokens/matrix.d.ts.map +1 -0
- package/dist/tokens/matrix.js +21 -0
- package/dist/tokens/matrix.js.map +1 -0
- package/dist/tokens/theme-check.d.ts +42 -0
- package/dist/tokens/theme-check.d.ts.map +1 -0
- package/dist/tokens/theme-check.js +10 -0
- package/dist/tokens/theme-check.js.map +1 -0
- package/dist/tokens/theme.d.ts +94 -0
- package/dist/tokens/theme.d.ts.map +1 -0
- package/dist/tokens/theme.js +14 -0
- package/dist/tokens/theme.js.map +1 -0
- package/dist/tokens/tokens.d.ts +122 -0
- package/dist/tokens/tokens.d.ts.map +1 -0
- package/dist/tokens/tokens.js +158 -0
- package/dist/tokens/tokens.js.map +1 -0
- package/dist/vocabulary/classes.d.ts +3 -0
- package/dist/vocabulary/classes.d.ts.map +1 -0
- package/dist/vocabulary/classes.js +1354 -0
- package/dist/vocabulary/classes.js.map +1 -0
- package/dist/vocabulary/elements.d.ts +3 -0
- package/dist/vocabulary/elements.d.ts.map +1 -0
- package/dist/vocabulary/elements.js +490 -0
- package/dist/vocabulary/elements.js.map +1 -0
- package/dist/vocabulary/types.d.ts +229 -0
- package/dist/vocabulary/types.d.ts.map +1 -0
- package/dist/vocabulary/types.js +11 -0
- package/dist/vocabulary/types.js.map +1 -0
- package/dist/wasi-host.d.ts +24 -0
- package/dist/wasi-host.d.ts.map +1 -0
- package/dist/wasi-host.js +140 -0
- package/dist/wasi-host.js.map +1 -0
- package/live-templates.xml +63 -0
- package/manifest/files.json +373 -0
- package/manifest/manifest.json +5090 -0
- package/manifest/manifest.schema.json +586 -0
- package/package.json +399 -5
- package/src/cli.ts +21 -0
- package/src/components/css/accordion/accordion.css +102 -0
- package/src/components/css/accordion/accordion.ts +50 -0
- package/src/components/css/alert/alert.css +53 -0
- package/src/components/css/alert/alert.ts +48 -0
- package/src/components/css/avatar/avatar.css +96 -0
- package/src/components/css/avatar/avatar.ts +57 -0
- package/src/components/css/badge/badge.css +42 -0
- package/src/components/css/badge/badge.ts +43 -0
- package/src/components/css/breadcrumbs/breadcrumbs.css +62 -0
- package/src/components/css/breadcrumbs/breadcrumbs.ts +30 -0
- package/src/components/css/button/button.css +128 -0
- package/src/components/css/button/button.ts +44 -0
- package/src/components/css/card/card.css +65 -0
- package/src/components/css/card/card.ts +50 -0
- package/src/components/css/checkbox/checkbox.css +114 -0
- package/src/components/css/checkbox/checkbox.ts +50 -0
- package/src/components/css/divider/divider.css +38 -0
- package/src/components/css/divider/divider.ts +42 -0
- package/src/components/css/error-text/error-text.css +21 -0
- package/src/components/css/error-text/error-text.ts +37 -0
- package/src/components/css/fieldset/fieldset.css +72 -0
- package/src/components/css/fieldset/fieldset.ts +87 -0
- package/src/components/css/file-upload/file-upload.css +102 -0
- package/src/components/css/file-upload/file-upload.ts +65 -0
- package/src/components/css/heading/heading.css +33 -0
- package/src/components/css/heading/heading.ts +42 -0
- package/src/components/css/help-text/help-text.css +24 -0
- package/src/components/css/help-text/help-text.ts +42 -0
- package/src/components/css/icon/icon.css +20 -0
- package/src/components/css/icon/icon.ts +44 -0
- package/src/components/css/input/input.css +145 -0
- package/src/components/css/input/input.ts +77 -0
- package/src/components/css/label/label.css +42 -0
- package/src/components/css/label/label.ts +51 -0
- package/src/components/css/link/link.css +38 -0
- package/src/components/css/link/link.ts +38 -0
- package/src/components/css/list/list.css +53 -0
- package/src/components/css/list/list.ts +44 -0
- package/src/components/css/pagination/pagination.css +95 -0
- package/src/components/css/pagination/pagination.ts +33 -0
- package/src/components/css/paragraph/paragraph.css +31 -0
- package/src/components/css/paragraph/paragraph.ts +53 -0
- package/src/components/css/progress/progress.css +161 -0
- package/src/components/css/progress/progress.ts +78 -0
- package/src/components/css/radio/radio.css +94 -0
- package/src/components/css/radio/radio.ts +50 -0
- package/src/components/css/search/search.css +50 -0
- package/src/components/css/search/search.ts +56 -0
- package/src/components/css/select/select.css +321 -0
- package/src/components/css/select/select.ts +63 -0
- package/src/components/css/shared.ts +199 -0
- package/src/components/css/skeleton/skeleton.css +62 -0
- package/src/components/css/skeleton/skeleton.ts +43 -0
- package/src/components/css/skip-link/skip-link.css +41 -0
- package/src/components/css/skip-link/skip-link.ts +22 -0
- package/src/components/css/spinner/spinner.css +48 -0
- package/src/components/css/spinner/spinner.ts +48 -0
- package/src/components/css/sr-only/sr-only.css +16 -0
- package/src/components/css/sr-only/sr-only.ts +21 -0
- package/src/components/css/switch/switch.css +105 -0
- package/src/components/css/switch/switch.ts +50 -0
- package/src/components/css/table/table.css +159 -0
- package/src/components/css/table/table.ts +107 -0
- package/src/components/css/tag/tag.css +58 -0
- package/src/components/css/tag/tag.ts +49 -0
- package/src/components/css/textarea/textarea.css +83 -0
- package/src/components/css/textarea/textarea.ts +40 -0
- package/src/components/css/toggle-group/toggle-group.css +92 -0
- package/src/components/css/toggle-group/toggle-group.ts +37 -0
- package/src/components/css/tooltip/tooltip.css +89 -0
- package/src/components/css/tooltip/tooltip.ts +37 -0
- package/src/components/default-texts.ts +41 -0
- package/src/components/events.ts +68 -0
- package/src/components/frame/dialog/dialog.css +306 -0
- package/src/components/frame/dialog/dialog.ts +166 -0
- package/src/components/frame/dialog/lt-dialog.ts +513 -0
- package/src/components/frame/error-summary/error-summary.css +47 -0
- package/src/components/frame/error-summary/error-summary.ts +90 -0
- package/src/components/frame/error-summary/lt-error-summary.ts +458 -0
- package/src/components/frame/field/field-core.ts +152 -0
- package/src/components/frame/field/field.css +17 -0
- package/src/components/frame/field/lt-field.ts +790 -0
- package/src/components/frame/popover/lt-popover.ts +586 -0
- package/src/components/frame/popover/popover.css +50 -0
- package/src/components/frame/popover/popover.ts +102 -0
- package/src/components/frame/session-timeout/lt-session-timeout.ts +715 -0
- package/src/components/frame/session-timeout/session-timeout.css +50 -0
- package/src/components/frame/session-timeout/session-timeout.ts +156 -0
- package/src/components/frame/suggestion/lt-suggestion.ts +1379 -0
- package/src/components/frame/suggestion/suggestion.css +132 -0
- package/src/components/frame/suggestion/suggestion.ts +172 -0
- package/src/components/frame/tabs/lt-tabs.ts +1395 -0
- package/src/components/frame/tabs/tabs.css +128 -0
- package/src/components/frame/tabs/tabs.ts +188 -0
- package/src/components/host-element.ts +493 -0
- package/src/components/standalone/connection-status/connection-status.css +41 -0
- package/src/components/standalone/connection-status/connection-status.ts +47 -0
- package/src/components/standalone/connection-status/lt-connection-status.ts +268 -0
- package/src/components/standalone/toast/lt-toast.ts +300 -0
- package/src/components/standalone/toast/toast.css +78 -0
- package/src/components/standalone/toast/toast.ts +70 -0
- package/src/diagnostics/core.ts +227 -0
- package/src/diagnostics/default-core.ts +81 -0
- package/src/diagnostics/index.ts +150 -0
- package/src/dom.ts +185 -0
- package/src/index.ts +218 -0
- package/src/jsx/react.ts +212 -0
- package/src/lt.ts +192 -0
- package/src/react.ts +411 -0
- package/src/register.ts +43 -0
- package/src/tailwind/tailwind.css +169 -0
- package/src/tokens/boundary.css +104 -0
- package/src/tokens/color-contract.json +231 -0
- package/src/tokens/color.ts +150 -0
- package/src/tokens/contract.ts +24 -0
- package/src/tokens/matrix-cells.ts +375 -0
- package/src/tokens/matrix.ts +32 -0
- package/src/tokens/theme-check.ts +51 -0
- package/src/tokens/theme.ts +104 -0
- package/src/tokens/tokens.css +569 -0
- package/src/tokens/tokens.ts +223 -0
- package/src/vocabulary/classes.ts +1356 -0
- package/src/vocabulary/elements.ts +492 -0
- package/src/vocabulary/types.ts +228 -0
- package/src/wasi-host.ts +155 -0
- package/web-types.json +800 -0
|
@@ -0,0 +1,1379 @@
|
|
|
1
|
+
import { LABEL_CLASS } from "../../css/label/label.js"
|
|
2
|
+
import { DEFAULT_TEXTS } from "../../default-texts.js"
|
|
3
|
+
import {
|
|
4
|
+
addClass,
|
|
5
|
+
defineElement,
|
|
6
|
+
derivedParts,
|
|
7
|
+
HostElement,
|
|
8
|
+
isServerControlled,
|
|
9
|
+
languageOf,
|
|
10
|
+
rememberedId,
|
|
11
|
+
SERVER_CONTROLLED,
|
|
12
|
+
setAttr,
|
|
13
|
+
setFlag,
|
|
14
|
+
setText,
|
|
15
|
+
warnAboutMarkup,
|
|
16
|
+
} from "../../host-element.js"
|
|
17
|
+
import {
|
|
18
|
+
SUGGESTION_EMPTY_CLASS,
|
|
19
|
+
SUGGESTION_LIST_CLASS,
|
|
20
|
+
SUGGESTION_OPTION_CLASS,
|
|
21
|
+
SUGGESTION_VALUE_CLASS,
|
|
22
|
+
} from "./suggestion.js"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The texts for the number of matches, one attribute per form. `count-none`
|
|
26
|
+
* is zero matches, and the rest are the forms `Intl.PluralRules` knows.
|
|
27
|
+
*/
|
|
28
|
+
const COUNT_ATTRIBUTES = [
|
|
29
|
+
"count-none",
|
|
30
|
+
"count-zero",
|
|
31
|
+
"count-one",
|
|
32
|
+
"count-two",
|
|
33
|
+
"count-few",
|
|
34
|
+
"count-many",
|
|
35
|
+
"count-other",
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
export const LT_SUGGESTION_TAG = "lt-suggestion" as const
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The parts the suggestion field finds, as selectors relative to the element.
|
|
42
|
+
* The web component finds its parts with these, and the manifest describes
|
|
43
|
+
* them for whoever writes the markup by hand, so the two cannot disagree.
|
|
44
|
+
*/
|
|
45
|
+
export const LT_SUGGESTION_PARTS = {
|
|
46
|
+
/** The field: the role when present, otherwise the first input. */
|
|
47
|
+
control: "[role='combobox'], input:not([type='hidden'])",
|
|
48
|
+
/** The list: the role when present, otherwise the class. */
|
|
49
|
+
list: `[role='listbox'], .${SUGGESTION_LIST_CLASS}`,
|
|
50
|
+
/** An option: the role when present, otherwise an `<li>` without a role in the list. */
|
|
51
|
+
option: `[role='option'], .${SUGGESTION_LIST_CLASS} > li:not([role])`,
|
|
52
|
+
empty: `.${SUGGESTION_EMPTY_CLASS}`,
|
|
53
|
+
status: "[role='status']",
|
|
54
|
+
/** The hidden field that receives the code of the option the user chose. */
|
|
55
|
+
value: `input.${SUGGESTION_VALUE_CLASS}`,
|
|
56
|
+
/** The label, which gets `for` from the field's id. */
|
|
57
|
+
label: "label",
|
|
58
|
+
} as const
|
|
59
|
+
|
|
60
|
+
const CONTROL_SELECTOR = LT_SUGGESTION_PARTS.control
|
|
61
|
+
const LIST_SELECTOR = LT_SUGGESTION_PARTS.list
|
|
62
|
+
const OPTION_SELECTOR = LT_SUGGESTION_PARTS.option
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The keyboard and the filtering in a field with a suggestion list.
|
|
66
|
+
*
|
|
67
|
+
* The server writes the field and the list. When the markup is made with
|
|
68
|
+
* JavaScript, `lt.suggestion()` writes the roles, the ids and the wiring.
|
|
69
|
+
* When it comes from a template without JavaScript, the template writes
|
|
70
|
+
* `for` on the label and `id` on the field, or wraps the field in the label,
|
|
71
|
+
* which HTML requires for the field to have a name without JavaScript. The
|
|
72
|
+
* rest is enough: a `.lt-suggestion__list` with `<li>` and a
|
|
73
|
+
* `[role="status"]`. The web component sets the roles, gives the list and the
|
|
74
|
+
* options ids, connects the list to the field, and closes the list until the
|
|
75
|
+
* user touches the field. Only what is missing, so what the server wrote
|
|
76
|
+
* stays.
|
|
77
|
+
*
|
|
78
|
+
* The web component never writes an id on the field. Datastar and idiomorph
|
|
79
|
+
* recognise a node by its id, and a field with an id the server did not send
|
|
80
|
+
* is replaced on the next update, along with focus and what the user has
|
|
81
|
+
* typed.
|
|
82
|
+
*
|
|
83
|
+
* The field always shows the option's text. If the server has written a
|
|
84
|
+
* hidden `.lt-suggestion__value`, it receives the code from `data-value`, or
|
|
85
|
+
* the text when the option has none. Without JavaScript the text is
|
|
86
|
+
* submitted, and the server looks it up. The web component renders nothing.
|
|
87
|
+
* It used to create the whole field itself, and then there was neither a
|
|
88
|
+
* label nor an input before the script had run, and nothing was included in
|
|
89
|
+
* the submission.
|
|
90
|
+
*
|
|
91
|
+
* What the web component does is what the browser does not: it filters the
|
|
92
|
+
* options while the user types, moves the highlight with the arrow keys, and
|
|
93
|
+
* keeps `aria-activedescendant` in sync so the screen reader announces the
|
|
94
|
+
* option without focus leaving the field.
|
|
95
|
+
*
|
|
96
|
+
* If something else has already filtered, a server or a React component that
|
|
97
|
+
* renders only the matches, `prefiltered` says so, and the web component
|
|
98
|
+
* leaves the options alone.
|
|
99
|
+
*
|
|
100
|
+
* ```html
|
|
101
|
+
* <lt-suggestion>
|
|
102
|
+
* <label class="lt-label" for="city">City</label>
|
|
103
|
+
* <input class="lt-input" id="city" role="combobox" aria-controls="city-list"
|
|
104
|
+
* aria-expanded="false" aria-autocomplete="list" autocomplete="off"
|
|
105
|
+
* aria-describedby="city-status">
|
|
106
|
+
* <ul class="lt-suggestion__list" id="city-list" role="listbox" hidden>
|
|
107
|
+
* <li class="lt-suggestion__option" id="city-option-0" role="option">Bergen</li>
|
|
108
|
+
* </ul>
|
|
109
|
+
* <p class="lt-suggestion__empty">No matches</p>
|
|
110
|
+
* <span class="lt-sr-only" id="city-status" role="status"></span>
|
|
111
|
+
* </lt-suggestion>
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export class LtSuggestion extends HostElement {
|
|
115
|
+
static observedAttributes = [
|
|
116
|
+
"prefiltered",
|
|
117
|
+
SERVER_CONTROLLED,
|
|
118
|
+
...COUNT_ATTRIBUTES,
|
|
119
|
+
] as const
|
|
120
|
+
|
|
121
|
+
private observer?: MutationObserver
|
|
122
|
+
private control?: HTMLInputElement
|
|
123
|
+
private list?: HTMLElement
|
|
124
|
+
private empty?: HTMLElement
|
|
125
|
+
/** The options that already have their listener. */
|
|
126
|
+
private readonly bound = new WeakSet<HTMLElement>()
|
|
127
|
+
/** The ids the web component created, per node, so a patch gets the same ones back. */
|
|
128
|
+
private readonly ids = new WeakMap<Element, string>()
|
|
129
|
+
/** The roles the web component set. See `derivedParts`. */
|
|
130
|
+
private readonly roledByMe = new WeakSet<Element>()
|
|
131
|
+
/** True while the web component dispatches events, so it does not answer itself. */
|
|
132
|
+
private choosing = false
|
|
133
|
+
/**
|
|
134
|
+
* The text and code of the choice in the field, when the value field is in
|
|
135
|
+
* use.
|
|
136
|
+
*
|
|
137
|
+
* The pair is what the code in the value field applies to. If the user
|
|
138
|
+
* changes the text, the code no longer applies, and the value field is
|
|
139
|
+
* emptied; otherwise the form would submit the code for Bergen with the
|
|
140
|
+
* text "Bodø" in the field. If the text is still there after an update, the
|
|
141
|
+
* code is set back: the value field's `value` is an attribute, and a morph
|
|
142
|
+
* writes the server's over what the web component set.
|
|
143
|
+
*/
|
|
144
|
+
private chosen?: { label: string; value: string }
|
|
145
|
+
/**
|
|
146
|
+
* What the server sent as text and code, the last time something other
|
|
147
|
+
* than the web component wrote the code. When the form is reset, the text
|
|
148
|
+
* goes back to the server's, and the code must follow: a hidden field has
|
|
149
|
+
* no default value of its own to go back to, since the web component
|
|
150
|
+
* writes the code as an attribute.
|
|
151
|
+
*/
|
|
152
|
+
private serverChoice?: { label: string; value: string }
|
|
153
|
+
/** The code the web component wrote last. If something else is there, the server wrote it. */
|
|
154
|
+
private writtenCode?: string
|
|
155
|
+
/** The form listened to for `reset`. Follows the field, which a patch can move. */
|
|
156
|
+
private boundForm?: HTMLFormElement | null
|
|
157
|
+
/** The `for` the web component wrote on the label. */
|
|
158
|
+
private readonly forByMe = new WeakMap<Element, string>()
|
|
159
|
+
/**
|
|
160
|
+
* The name the web component gave the list, per list: `aria-labelledby`
|
|
161
|
+
* pointing to the label, or `aria-label` with its text. If something else
|
|
162
|
+
* is there, the server wrote it, and it stays.
|
|
163
|
+
*/
|
|
164
|
+
private readonly listNameByMe = new WeakMap<
|
|
165
|
+
Element,
|
|
166
|
+
{ name: "aria-labelledby" | "aria-label"; value: string }
|
|
167
|
+
>()
|
|
168
|
+
/**
|
|
169
|
+
* Labels where the server wrote a `for` pointing to another id. Reported
|
|
170
|
+
* once; the memory is not reset, since the rewritten `for` cannot be told
|
|
171
|
+
* apart from a server that corrected itself.
|
|
172
|
+
*/
|
|
173
|
+
private readonly wrongFor = new WeakSet<Element>()
|
|
174
|
+
/**
|
|
175
|
+
* Whether the list is open, and which option is highlighted.
|
|
176
|
+
*
|
|
177
|
+
* Both come from the user: she typed something, and she moves with the
|
|
178
|
+
* arrow keys. None of it is in the HTML the server sent, so a morph strips
|
|
179
|
+
* it. The template used to list `aria-expanded`, `aria-activedescendant`
|
|
180
|
+
* and `hidden` in `data-preserve-attr`. Now the web component sets it back,
|
|
181
|
+
* as long as the morph keeps the field. It only does so when the id
|
|
182
|
+
* matches, see `warnAboutReplacedControl`.
|
|
183
|
+
*/
|
|
184
|
+
private wantOpen?: boolean
|
|
185
|
+
/**
|
|
186
|
+
* The highlighted option, remembered by both id and text.
|
|
187
|
+
*
|
|
188
|
+
* The ids from `lt.suggestion()` are positional, so a new list from the
|
|
189
|
+
* server reuses them. With the id alone the web component put the
|
|
190
|
+
* highlight back on option number two in an entirely different list, and
|
|
191
|
+
* the screen reader announced a place the user never navigated to. The
|
|
192
|
+
* text is the identity; the id is only where `aria-activedescendant`
|
|
193
|
+
* points.
|
|
194
|
+
*/
|
|
195
|
+
private active?: { id: string; label: string; value: string | null }
|
|
196
|
+
/**
|
|
197
|
+
* The count last announced, when something else filters.
|
|
198
|
+
*
|
|
199
|
+
* Only changes are announced, and the first count is the starting point,
|
|
200
|
+
* not news. See `announceFiltered`.
|
|
201
|
+
*/
|
|
202
|
+
private announcedCount?: number
|
|
203
|
+
/**
|
|
204
|
+
* The text last written in the status element.
|
|
205
|
+
*
|
|
206
|
+
* The text is not in the server's version, so an update that sends the
|
|
207
|
+
* region again empties the element. `repair()` writes it back.
|
|
208
|
+
*/
|
|
209
|
+
private announcedText?: string
|
|
210
|
+
|
|
211
|
+
connectedCallback(): void {
|
|
212
|
+
/*
|
|
213
|
+
* The attributes are included, not just the children. Every write below
|
|
214
|
+
* compares first, or the observer would trigger itself forever.
|
|
215
|
+
*/
|
|
216
|
+
this.observer = new MutationObserver(() => this.sync())
|
|
217
|
+
this.observer.observe(this, {
|
|
218
|
+
childList: true,
|
|
219
|
+
subtree: true,
|
|
220
|
+
attributes: true,
|
|
221
|
+
attributeFilter: [
|
|
222
|
+
"hidden",
|
|
223
|
+
"aria-expanded",
|
|
224
|
+
"aria-selected",
|
|
225
|
+
"aria-activedescendant",
|
|
226
|
+
// The wiring the web component fills in when the markup arrived without it.
|
|
227
|
+
"role",
|
|
228
|
+
"id",
|
|
229
|
+
"for",
|
|
230
|
+
"aria-controls",
|
|
231
|
+
"autocomplete",
|
|
232
|
+
"aria-autocomplete",
|
|
233
|
+
"class",
|
|
234
|
+
// The value field's `value` is an attribute, and a morph can write
|
|
235
|
+
// the server's empty value over the code the user chose.
|
|
236
|
+
"value",
|
|
237
|
+
// `form` on the field picks which form the reset belongs to.
|
|
238
|
+
"form",
|
|
239
|
+
// A field that becomes `readonly` or `disabled` must close the list.
|
|
240
|
+
"readonly",
|
|
241
|
+
"disabled",
|
|
242
|
+
"aria-disabled",
|
|
243
|
+
// The list sits in the top layer. A morph that removes `popover`
|
|
244
|
+
// closes the popover, and the web component sets it back.
|
|
245
|
+
"popover",
|
|
246
|
+
// The list's name, from the label.
|
|
247
|
+
"aria-labelledby",
|
|
248
|
+
"aria-label",
|
|
249
|
+
],
|
|
250
|
+
})
|
|
251
|
+
this.sync()
|
|
252
|
+
window.addEventListener("resize", this.reposition)
|
|
253
|
+
window.addEventListener("scroll", this.reposition, true)
|
|
254
|
+
// The list closes when focus leaves the web component, with Tab as with
|
|
255
|
+
// anything else. On the host and not on the field: if focus goes through
|
|
256
|
+
// a button inside the web component and then out, the list must close
|
|
257
|
+
// then too. Without this it stayed over the next field, with
|
|
258
|
+
// `aria-expanded` true on a field without focus.
|
|
259
|
+
this.addEventListener("focusout", this.handleFocusOut)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
attributeChangedCallback(name: string): void {
|
|
263
|
+
if (name === SERVER_CONTROLLED && isServerControlled(this)) {
|
|
264
|
+
this.wantOpen = undefined
|
|
265
|
+
this.active = undefined
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* The attribute can come and go while the page is alive, and then the
|
|
270
|
+
* list must be corrected at once rather than on the next keystroke.
|
|
271
|
+
* `this.observer` signals that the web component is in the document: the
|
|
272
|
+
* callback also fires during the upgrade, before the children exist.
|
|
273
|
+
*/
|
|
274
|
+
if (name === "prefiltered" && this.observer) {
|
|
275
|
+
/*
|
|
276
|
+
* After the whole update, as the other repairs: a morph sets the
|
|
277
|
+
* attribute before it has replaced the options, and the count was
|
|
278
|
+
* the old list's. And through `repair()`, which filters only a list
|
|
279
|
+
* that stays open: the filter announces, and a closed list said "One
|
|
280
|
+
* match" to nobody's search.
|
|
281
|
+
*/
|
|
282
|
+
queueMicrotask(() => {
|
|
283
|
+
if (!this.observer) return
|
|
284
|
+
// The count present when the attribute is set is the starting point,
|
|
285
|
+
// and nothing to announce. Without this the first change afterwards
|
|
286
|
+
// was taken as the starting point, and was lost.
|
|
287
|
+
this.announcedCount = this.prefiltered ? this.visible.length : undefined
|
|
288
|
+
// The whole repair, not the filter alone: the same update can lock
|
|
289
|
+
// the field, and `repair()` closes the list before it filters.
|
|
290
|
+
this.sync()
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private sync(): void {
|
|
296
|
+
this.wire()
|
|
297
|
+
this.bind()
|
|
298
|
+
this.repair()
|
|
299
|
+
this.announceFiltered()
|
|
300
|
+
this.mirrorPopover()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Opens and closes the popover the list sits in, following `hidden`.
|
|
305
|
+
*
|
|
306
|
+
* `hidden` still says whether the list is open, from the server as from
|
|
307
|
+
* the web component, and the popover follows. The list sits in the top
|
|
308
|
+
* layer, so a parent with `overflow`, such as the body of a dialog, does
|
|
309
|
+
* not clip it.
|
|
310
|
+
*/
|
|
311
|
+
private mirrorPopover(): void {
|
|
312
|
+
const list = this.listElement
|
|
313
|
+
if (!list?.isConnected || !list.hasAttribute("popover")) return
|
|
314
|
+
const shown = list.matches(":popover-open")
|
|
315
|
+
try {
|
|
316
|
+
if (!list.hidden && !shown) list.showPopover()
|
|
317
|
+
else if (list.hidden && shown) list.hidePopover()
|
|
318
|
+
} catch {
|
|
319
|
+
// An element on its way out of the tree cannot be shown. The next
|
|
320
|
+
// round sees it again.
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if (!list.hidden) this.reposition()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Places the list right below the field, as wide as it. If there is no
|
|
328
|
+
* room below, it goes above, and it is squeezed into the viewport either
|
|
329
|
+
* way, like the panel in `<lt-popover>`. The position is physical,
|
|
330
|
+
* computed from the field's box.
|
|
331
|
+
*/
|
|
332
|
+
private reposition = (): void => {
|
|
333
|
+
const list = this.listElement
|
|
334
|
+
const control = this.querySelector<HTMLElement>(CONTROL_SELECTOR)
|
|
335
|
+
if (!list || !control || !list.matches(":popover-open")) return
|
|
336
|
+
|
|
337
|
+
const field = control.getBoundingClientRect()
|
|
338
|
+
const height = list.getBoundingClientRect().height
|
|
339
|
+
const space = 4
|
|
340
|
+
|
|
341
|
+
let top = field.bottom + space
|
|
342
|
+
if (top + height > window.innerHeight && field.top - height - space >= 0) {
|
|
343
|
+
top = field.top - height - space
|
|
344
|
+
}
|
|
345
|
+
top = Math.max(space, Math.min(top, window.innerHeight - height - space))
|
|
346
|
+
|
|
347
|
+
list.style.setProperty("--lt-suggestion-top", `${Math.round(top)}px`)
|
|
348
|
+
list.style.setProperty(
|
|
349
|
+
"--lt-suggestion-left",
|
|
350
|
+
`${Math.round(field.left)}px`,
|
|
351
|
+
)
|
|
352
|
+
list.style.setProperty(
|
|
353
|
+
"--lt-suggestion-width",
|
|
354
|
+
`${Math.round(field.width)}px`,
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Names the list from the label, when the markup arrived without a name.
|
|
360
|
+
*
|
|
361
|
+
* With `aria-labelledby` when the label is next to the field; the web
|
|
362
|
+
* component gives it an id when it has none, since it holds nothing the
|
|
363
|
+
* user can lose. If the label wraps the field, it gets no id: a morph would
|
|
364
|
+
* then replace the label, and the field with it. In that case the list
|
|
365
|
+
* gets the label's text as `aria-label`, without the field's value.
|
|
366
|
+
*/
|
|
367
|
+
private nameList(list: HTMLElement, control: HTMLInputElement): void {
|
|
368
|
+
const mine = this.listNameByMe.get(list)
|
|
369
|
+
const labelledby = list.getAttribute("aria-labelledby")
|
|
370
|
+
const label = list.getAttribute("aria-label")
|
|
371
|
+
const fromServer =
|
|
372
|
+
(labelledby !== null &&
|
|
373
|
+
!(mine?.name === "aria-labelledby" && mine.value === labelledby)) ||
|
|
374
|
+
(label !== null && !(mine?.name === "aria-label" && mine.value === label))
|
|
375
|
+
if (fromServer) return
|
|
376
|
+
|
|
377
|
+
const labelElement =
|
|
378
|
+
control.labels?.[0] ?? this.querySelector<HTMLLabelElement>("label")
|
|
379
|
+
if (!labelElement) return
|
|
380
|
+
|
|
381
|
+
if (labelElement.contains(control)) {
|
|
382
|
+
let text = ""
|
|
383
|
+
for (const node of labelElement.childNodes)
|
|
384
|
+
if (
|
|
385
|
+
node !== control &&
|
|
386
|
+
!(node instanceof Element && node.contains(control))
|
|
387
|
+
)
|
|
388
|
+
text += node.textContent ?? ""
|
|
389
|
+
text = text.replace(/\s+/g, " ").trim()
|
|
390
|
+
if (labelledby !== null) setAttr(list, "aria-labelledby", null)
|
|
391
|
+
if (text === "") {
|
|
392
|
+
if (label !== null) setAttr(list, "aria-label", null)
|
|
393
|
+
this.listNameByMe.delete(list)
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
setAttr(list, "aria-label", text)
|
|
397
|
+
this.listNameByMe.set(list, { name: "aria-label", value: text })
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!labelElement.id) {
|
|
402
|
+
setAttr(
|
|
403
|
+
labelElement,
|
|
404
|
+
"id",
|
|
405
|
+
rememberedId(this.ids, labelElement, "lt-suggestion-label"),
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
if (label !== null) setAttr(list, "aria-label", null)
|
|
409
|
+
setAttr(list, "aria-labelledby", labelElement.id)
|
|
410
|
+
this.listNameByMe.set(list, {
|
|
411
|
+
name: "aria-labelledby",
|
|
412
|
+
value: labelElement.id,
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Fills in the wiring where the markup arrived without it. Only what is
|
|
418
|
+
* missing.
|
|
419
|
+
*
|
|
420
|
+
* The list is closed when the markup says nothing about it, that is when
|
|
421
|
+
* neither `aria-expanded` on the field nor `hidden` on the list is there.
|
|
422
|
+
* If the markup states one of them, that is the server's word on both, as
|
|
423
|
+
* with `lt.suggestion({ open: true })`.
|
|
424
|
+
*/
|
|
425
|
+
private wire(): void {
|
|
426
|
+
const control = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
|
|
427
|
+
if (!control) return
|
|
428
|
+
|
|
429
|
+
if (!control.hasAttribute("role")) setAttr(control, "role", "combobox")
|
|
430
|
+
if (!control.hasAttribute("autocomplete")) {
|
|
431
|
+
setAttr(control, "autocomplete", "off")
|
|
432
|
+
}
|
|
433
|
+
if (!control.hasAttribute("aria-autocomplete")) {
|
|
434
|
+
setAttr(control, "aria-autocomplete", "list")
|
|
435
|
+
}
|
|
436
|
+
const label = this.querySelector(LT_SUGGESTION_PARTS.label)
|
|
437
|
+
const root = this.getRootNode() as Document | ShadowRoot
|
|
438
|
+
|
|
439
|
+
if (label) {
|
|
440
|
+
addClass(label, LABEL_CLASS)
|
|
441
|
+
// `for` and `id` are the same information, and are written from the
|
|
442
|
+
// field's id, never the other way round: the web component does not
|
|
443
|
+
// write an id on the field. If the label wraps the field, the wiring
|
|
444
|
+
// is implicit. Same rule as in `<lt-field>`.
|
|
445
|
+
if (control.id && !label.contains(control)) {
|
|
446
|
+
const current = label.getAttribute("for")
|
|
447
|
+
if (
|
|
448
|
+
current !== null &&
|
|
449
|
+
current !== control.id &&
|
|
450
|
+
current !== this.forByMe.get(label)
|
|
451
|
+
) {
|
|
452
|
+
this.wrongFor.add(label)
|
|
453
|
+
}
|
|
454
|
+
setAttr(label, "for", control.id)
|
|
455
|
+
this.forByMe.set(label, control.id)
|
|
456
|
+
}
|
|
457
|
+
warnAboutMarkup(
|
|
458
|
+
this,
|
|
459
|
+
"for on the label pointed to a different id than the field's. " +
|
|
460
|
+
"The component rewrote it, but without JavaScript the field has " +
|
|
461
|
+
"no label and a screen reader announces it without a name, so " +
|
|
462
|
+
"write the field's id in for in the template.",
|
|
463
|
+
() => {
|
|
464
|
+
const now = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
|
|
465
|
+
const labelNow = this.querySelector("label")
|
|
466
|
+
return (
|
|
467
|
+
now !== null && labelNow !== null && this.wrongFor.has(labelNow)
|
|
468
|
+
)
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
warnAboutMarkup(
|
|
472
|
+
this,
|
|
473
|
+
"<label> is not connected to the field: the field has no id, " +
|
|
474
|
+
"and the label does not wrap it, so without JavaScript a screen " +
|
|
475
|
+
"reader announces the field without a name. Write an id on the " +
|
|
476
|
+
"field and the same value in for on the label, or wrap the " +
|
|
477
|
+
"field in the <label>; the component does not write the id " +
|
|
478
|
+
"itself, because an id the server did not send makes a server " +
|
|
479
|
+
"update replace the field.",
|
|
480
|
+
() => {
|
|
481
|
+
const now = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
|
|
482
|
+
const labelNow = this.querySelector("label")
|
|
483
|
+
return (
|
|
484
|
+
now !== null &&
|
|
485
|
+
labelNow !== null &&
|
|
486
|
+
!now.id &&
|
|
487
|
+
!labelNow.contains(now) &&
|
|
488
|
+
!now.hasAttribute("aria-label") &&
|
|
489
|
+
!now.hasAttribute("aria-labelledby")
|
|
490
|
+
)
|
|
491
|
+
},
|
|
492
|
+
)
|
|
493
|
+
// `for` wins over the label wrapping the field. If it points to another
|
|
494
|
+
// id, the field has no label, with JavaScript as well.
|
|
495
|
+
warnAboutMarkup(
|
|
496
|
+
this,
|
|
497
|
+
"<label> wraps the field, but its for points to a different id " +
|
|
498
|
+
"than the field's, and for wins, so a screen reader announces " +
|
|
499
|
+
"the field without a name. Remove for, or give the field the " +
|
|
500
|
+
"same id.",
|
|
501
|
+
() => {
|
|
502
|
+
const now = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
|
|
503
|
+
const labelNow = this.querySelector("label")
|
|
504
|
+
if (!now || !labelNow?.contains(now)) return false
|
|
505
|
+
const target = labelNow.getAttribute("for")
|
|
506
|
+
return target !== null && target !== now.id
|
|
507
|
+
},
|
|
508
|
+
)
|
|
509
|
+
} else {
|
|
510
|
+
// With no label inside, it can be outside, with `for` set to the field's
|
|
511
|
+
// id. It gets the class, as in `<lt-field>`; a patch of this region does
|
|
512
|
+
// not touch it, so it needs no repair.
|
|
513
|
+
if (control.id) {
|
|
514
|
+
const outside = (
|
|
515
|
+
this.getRootNode() as Document | ShadowRoot
|
|
516
|
+
).querySelector?.(`label[for="${CSS.escape(control.id)}"]`)
|
|
517
|
+
if (outside) addClass(outside, LABEL_CLASS)
|
|
518
|
+
}
|
|
519
|
+
warnAboutMarkup(
|
|
520
|
+
this,
|
|
521
|
+
"found no <label>, so the field has no label and a screen " +
|
|
522
|
+
"reader announces it without a name. If the label is outside " +
|
|
523
|
+
"<lt-suggestion>, its for must point to the field's id.",
|
|
524
|
+
() => {
|
|
525
|
+
const now = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
|
|
526
|
+
if (!now || this.querySelector("label")) return false
|
|
527
|
+
// A <label> around the whole <lt-suggestion> also names the field.
|
|
528
|
+
if ((now.labels?.length ?? 0) > 0) return false
|
|
529
|
+
if (
|
|
530
|
+
now.hasAttribute("aria-label") ||
|
|
531
|
+
now.hasAttribute("aria-labelledby")
|
|
532
|
+
)
|
|
533
|
+
return false
|
|
534
|
+
const rootNow = this.getRootNode() as Document | ShadowRoot
|
|
535
|
+
return !(
|
|
536
|
+
now.id &&
|
|
537
|
+
rootNow.querySelector?.(`label[for="${CSS.escape(now.id)}"]`)
|
|
538
|
+
)
|
|
539
|
+
},
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const list = this.listElement
|
|
544
|
+
if (list) {
|
|
545
|
+
addClass(list, SUGGESTION_LIST_CLASS)
|
|
546
|
+
if (!list.hasAttribute("role")) setAttr(list, "role", "listbox")
|
|
547
|
+
if (!list.id) {
|
|
548
|
+
// Same rule as for the field: if `aria-controls` points to nothing,
|
|
549
|
+
// that is the id the new list should have.
|
|
550
|
+
const wanted = control.getAttribute("aria-controls")
|
|
551
|
+
const free = wanted && !root.getElementById?.(wanted)
|
|
552
|
+
if (free) this.ids.set(list, wanted)
|
|
553
|
+
setAttr(list, "id", rememberedId(this.ids, list, "lt-suggestion-list"))
|
|
554
|
+
}
|
|
555
|
+
if (!control.hasAttribute("aria-controls")) {
|
|
556
|
+
setAttr(control, "aria-controls", list.id)
|
|
557
|
+
}
|
|
558
|
+
if (!list.hasAttribute("popover")) setAttr(list, "popover", "manual")
|
|
559
|
+
this.nameList(list, control)
|
|
560
|
+
if (!control.hasAttribute("aria-expanded")) {
|
|
561
|
+
if (!list.hasAttribute("hidden")) setFlag(list, "hidden", true)
|
|
562
|
+
setAttr(control, "aria-expanded", "false")
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
for (const option of this.options) {
|
|
567
|
+
addClass(option, SUGGESTION_OPTION_CLASS)
|
|
568
|
+
if (!option.hasAttribute("role")) {
|
|
569
|
+
setAttr(option, "role", "option")
|
|
570
|
+
this.roledByMe.add(option)
|
|
571
|
+
}
|
|
572
|
+
if (!option.id) {
|
|
573
|
+
setAttr(
|
|
574
|
+
option,
|
|
575
|
+
"id",
|
|
576
|
+
rememberedId(this.ids, option, "lt-suggestion-option"),
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
// From the start, not only when the list closes: otherwise the options
|
|
580
|
+
// the server sent were without it until then, while the rest of the list
|
|
581
|
+
// had it.
|
|
582
|
+
if (!option.hasAttribute("aria-selected")) {
|
|
583
|
+
setAttr(option, "aria-selected", "false")
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Sets the user's state back after a patch.
|
|
590
|
+
*
|
|
591
|
+
* The filtering is recomputed from what is in the field, so it needs no
|
|
592
|
+
* memory. What the list shows and what is highlighted do.
|
|
593
|
+
*/
|
|
594
|
+
private repair(): void {
|
|
595
|
+
if (!this.control) return
|
|
596
|
+
|
|
597
|
+
// The code is set back under `server-controlled` too. The attribute is
|
|
598
|
+
// about whether the list is open and what is highlighted, not what the user
|
|
599
|
+
// chose.
|
|
600
|
+
const valueField = this.valueField
|
|
601
|
+
if (
|
|
602
|
+
valueField &&
|
|
603
|
+
this.chosen &&
|
|
604
|
+
this.control.value === this.chosen.label &&
|
|
605
|
+
valueField.value === ""
|
|
606
|
+
) {
|
|
607
|
+
this.writeCode(valueField, this.chosen.value)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// A field that became `readonly` or `disabled` while the list was open
|
|
611
|
+
// closes it. Otherwise the repair kept it open, and Escape does nothing in
|
|
612
|
+
// a locked field. First, so nothing below writes for a list that closes.
|
|
613
|
+
if (!isServerControlled(this) && this.locked && this.wantOpen)
|
|
614
|
+
this.setOpen(false)
|
|
615
|
+
|
|
616
|
+
// An emptied status element gets its text back while the list is open,
|
|
617
|
+
// where the count is what the user is working on. Not otherwise: every
|
|
618
|
+
// patch of the region would announce the old count again. And not when
|
|
619
|
+
// something else filters: then a patch is the answer to a keystroke, and
|
|
620
|
+
// `announceFiltered` announces the new count right after.
|
|
621
|
+
const status = this.querySelector<HTMLElement>(LT_SUGGESTION_PARTS.status)
|
|
622
|
+
if (
|
|
623
|
+
status &&
|
|
624
|
+
this.wantOpen === true &&
|
|
625
|
+
!this.prefiltered &&
|
|
626
|
+
this.announcedText !== undefined &&
|
|
627
|
+
(status.textContent ?? "").trim() === ""
|
|
628
|
+
) {
|
|
629
|
+
setText(status, this.announcedText)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (isServerControlled(this)) {
|
|
633
|
+
/*
|
|
634
|
+
* The server decides whether the list is open and what is
|
|
635
|
+
* highlighted, not what matches: an update that changes an open list
|
|
636
|
+
* is filtered here too. Before, the list showed every option the
|
|
637
|
+
* server sent, whatever the user had typed, and an emptied list kept
|
|
638
|
+
* the empty message hidden.
|
|
639
|
+
*/
|
|
640
|
+
if (this.listElement?.hidden === false) this.filter()
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (this.wantOpen !== undefined) {
|
|
645
|
+
/*
|
|
646
|
+
* Each half is checked separately. A patch that touched only the field left
|
|
647
|
+
* `aria-expanded="false"` while the options were visible, and the screen
|
|
648
|
+
* reader said the list was closed while it was on screen.
|
|
649
|
+
*/
|
|
650
|
+
const list = this.listElement
|
|
651
|
+
const expanded = this.control.getAttribute("aria-expanded")
|
|
652
|
+
if (
|
|
653
|
+
(list && list.hidden === this.wantOpen) ||
|
|
654
|
+
expanded !== String(this.wantOpen)
|
|
655
|
+
) {
|
|
656
|
+
this.applyOpen(this.wantOpen)
|
|
657
|
+
}
|
|
658
|
+
if (this.wantOpen) this.filter()
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (this.active) {
|
|
662
|
+
/*
|
|
663
|
+
* The text is what counts, and the option must be visible. Otherwise the
|
|
664
|
+
* web component highlighted something the server had just filtered out, and
|
|
665
|
+
* `aria-activedescendant` pointed to a hidden element. The id is a
|
|
666
|
+
* fallback: if the server sends the list again, the options are replaced
|
|
667
|
+
* and get new ids, and the highlight is found again by its text. The code
|
|
668
|
+
* counts too, since two places can have the same name.
|
|
669
|
+
*/
|
|
670
|
+
const sameText = (o: HTMLElement) =>
|
|
671
|
+
(o.textContent ?? "").trim() === this.active?.label &&
|
|
672
|
+
o.getAttribute("data-value") === this.active?.value
|
|
673
|
+
const option =
|
|
674
|
+
this.visible.find((o) => o.id === this.active?.id && sameText(o)) ??
|
|
675
|
+
this.visible.find(sameText)
|
|
676
|
+
if (!option) {
|
|
677
|
+
// The pointer must go together with the highlight. Without this
|
|
678
|
+
// `aria-activedescendant` pointed to an option that is now called
|
|
679
|
+
// something else, and the screen reader announced a place the user never
|
|
680
|
+
// navigated to.
|
|
681
|
+
this.active = undefined
|
|
682
|
+
this.control.removeAttribute("aria-activedescendant")
|
|
683
|
+
} else if (
|
|
684
|
+
option.getAttribute("aria-selected") !== "true" ||
|
|
685
|
+
this.control.getAttribute("aria-activedescendant") !== option.id
|
|
686
|
+
) {
|
|
687
|
+
// Both sides of the wiring are checked. If the patch stripped only
|
|
688
|
+
// `aria-activedescendant` while the highlight remained, the screen reader
|
|
689
|
+
// lost its reading position without anything else looking wrong.
|
|
690
|
+
this.markOption(option)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
disconnectedCallback(): void {
|
|
696
|
+
window.removeEventListener("resize", this.reposition)
|
|
697
|
+
window.removeEventListener("scroll", this.reposition, true)
|
|
698
|
+
this.removeEventListener("focusout", this.handleFocusOut)
|
|
699
|
+
this.observer?.disconnect()
|
|
700
|
+
this.observer = undefined
|
|
701
|
+
this.unbind()
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Something else has already filtered, so the web component leaves the
|
|
706
|
+
* options alone.
|
|
707
|
+
*
|
|
708
|
+
* The name used to be `server-filtered`, and that was misleading: it is not
|
|
709
|
+
* about servers. A React app that renders only the matches has filtered all
|
|
710
|
+
* the same, without any server involved.
|
|
711
|
+
*
|
|
712
|
+
* The web component hides an option when its text does not contain what is
|
|
713
|
+
* in the field. If you filter on something else, without diacritics, on a
|
|
714
|
+
* code that is not shown, or fuzzily, the two disagree, and then your filter
|
|
715
|
+
* should win. The attribute turns off both the hiding and the empty
|
|
716
|
+
* message, that is everything about what the list shows. The announcement
|
|
717
|
+
* of the number of matches remains, since it is not markup.
|
|
718
|
+
*/
|
|
719
|
+
get prefiltered(): boolean {
|
|
720
|
+
return this.hasAttribute("prefiltered")
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
set prefiltered(on: boolean) {
|
|
724
|
+
/*
|
|
725
|
+
* The setter is not decoration. React 19 writes the property rather than
|
|
726
|
+
* the attribute when a web component has one with that name, and a getter
|
|
727
|
+
* alone throws "Cannot set property prefiltered". The attribute never
|
|
728
|
+
* landed, and the web component hid what React had just rendered. The
|
|
729
|
+
* hyphen in the old name hid the problem: `server-filtered` cannot be a
|
|
730
|
+
* property name, so React sent it as an attribute.
|
|
731
|
+
*/
|
|
732
|
+
setFlag(this, "prefiltered", on)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private get listElement(): HTMLElement | null {
|
|
736
|
+
return this.querySelector<HTMLElement>(LIST_SELECTOR)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private get emptyElement(): HTMLElement | null {
|
|
740
|
+
return this.querySelector<HTMLElement>(LT_SUGGESTION_PARTS.empty)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private get valueField(): HTMLInputElement | null {
|
|
744
|
+
return this.querySelector<HTMLInputElement>(LT_SUGGESTION_PARTS.value)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** The field accepts no input: `readonly` or `disabled`. */
|
|
748
|
+
private get locked(): boolean {
|
|
749
|
+
return Boolean(this.control?.readOnly || this.control?.disabled)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The options, recognised by the role and not by the class.
|
|
754
|
+
*
|
|
755
|
+
* The role must be there anyway, for the screen reader. A template that
|
|
756
|
+
* wrote `<li role="option">` with its own styling got neither filtering nor
|
|
757
|
+
* arrow keys when the web component looked for the class, and nothing said
|
|
758
|
+
* so.
|
|
759
|
+
*/
|
|
760
|
+
private get options(): HTMLElement[] {
|
|
761
|
+
return derivedParts<HTMLElement>(
|
|
762
|
+
this,
|
|
763
|
+
OPTION_SELECTOR,
|
|
764
|
+
"option",
|
|
765
|
+
this.roledByMe,
|
|
766
|
+
)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Which visible option is highlighted now.
|
|
771
|
+
*
|
|
772
|
+
* It is read from `aria-selected` in the markup, not from a counter inside
|
|
773
|
+
* the web component. The server can set the highlight itself with
|
|
774
|
+
* `lt.suggestion({ activeIndex })`, and a parallel counter would have jumped
|
|
775
|
+
* to the top of the list on the first arrow key instead of to the next
|
|
776
|
+
* option. The filtering also moves the options around, and then the lookup
|
|
777
|
+
* gives the right answer by itself.
|
|
778
|
+
*/
|
|
779
|
+
private get activeIndex(): number {
|
|
780
|
+
return this.selectable.findIndex(
|
|
781
|
+
(option) => option.getAttribute("aria-selected") === "true",
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** The options that are visible now. */
|
|
786
|
+
private get visible(): HTMLElement[] {
|
|
787
|
+
return this.options.filter((option) => !option.hidden)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The visible options that can be chosen. An option with
|
|
792
|
+
* `aria-disabled="true"` is shown, but the arrow keys skip it and it cannot
|
|
793
|
+
* be chosen, with neither keyboard nor mouse.
|
|
794
|
+
*/
|
|
795
|
+
private get selectable(): HTMLElement[] {
|
|
796
|
+
return this.visible.filter(
|
|
797
|
+
(option) => option.getAttribute("aria-disabled") !== "true",
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private unbind(): void {
|
|
802
|
+
const control = this.control
|
|
803
|
+
if (control) {
|
|
804
|
+
control.removeEventListener("input", this.handleInput)
|
|
805
|
+
control.removeEventListener("keydown", this.handleKeydown)
|
|
806
|
+
control.removeEventListener("focus", this.handleFocus)
|
|
807
|
+
this.control = undefined
|
|
808
|
+
}
|
|
809
|
+
this.boundForm?.removeEventListener("reset", this.handleReset)
|
|
810
|
+
this.boundForm = undefined
|
|
811
|
+
this.list?.removeEventListener("mousedown", this.handleListMouseDown)
|
|
812
|
+
this.list = undefined
|
|
813
|
+
this.empty?.removeEventListener("mousedown", this.handleListMouseDown)
|
|
814
|
+
this.empty = undefined
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Reports when an update replaced the field because the id from the server
|
|
819
|
+
* changed.
|
|
820
|
+
*
|
|
821
|
+
* The message comes only when the user had something to lose: the old field
|
|
822
|
+
* is out of the tree, both had an id from the server, the ids differ, `name`
|
|
823
|
+
* is the same, and the old field held something other than the new one. A
|
|
824
|
+
* server that sends the text back has not lost it.
|
|
825
|
+
*/
|
|
826
|
+
private warnAboutReplacedControl(
|
|
827
|
+
lost: HTMLInputElement,
|
|
828
|
+
control: HTMLInputElement,
|
|
829
|
+
): void {
|
|
830
|
+
if (lost.isConnected || !lost.id || !control.id || lost.id === control.id)
|
|
831
|
+
return
|
|
832
|
+
if (lost.getAttribute("name") !== control.getAttribute("name")) return
|
|
833
|
+
if (lost.value === control.value) return
|
|
834
|
+
|
|
835
|
+
warnAboutMarkup(
|
|
836
|
+
this,
|
|
837
|
+
"a server update replaced the field and lost what the user had " +
|
|
838
|
+
"typed, along with focus and the list, because the field's id " +
|
|
839
|
+
"was different from last time and the update did not recognise " +
|
|
840
|
+
"it. Build the id from something that is the same every time, " +
|
|
841
|
+
"such as the field name or a key in the data, not from a random " +
|
|
842
|
+
"number or a counter.",
|
|
843
|
+
// The replacement has happened and cannot undo itself, unless another
|
|
844
|
+
// update has already replaced the field again.
|
|
845
|
+
() =>
|
|
846
|
+
!lost.isConnected &&
|
|
847
|
+
this.querySelector<HTMLInputElement>(CONTROL_SELECTOR) === control,
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private bind(): void {
|
|
852
|
+
const control = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
|
|
853
|
+
if (!control) {
|
|
854
|
+
warnAboutMarkup(
|
|
855
|
+
this,
|
|
856
|
+
'found no field: neither an <input> nor a [role="combobox"]. ' +
|
|
857
|
+
"Without one the component does not know which field to listen " +
|
|
858
|
+
"to, and neither filtering nor the arrow keys work.",
|
|
859
|
+
// An empty element is a region the server has not filled yet, and not
|
|
860
|
+
// an error in the markup.
|
|
861
|
+
() =>
|
|
862
|
+
this.childElementCount > 0 &&
|
|
863
|
+
this.querySelector(CONTROL_SELECTOR) === null,
|
|
864
|
+
)
|
|
865
|
+
// The field is gone. Firefox sends no `focusout` when the focused node is
|
|
866
|
+
// removed, so the list would otherwise stay open without a field.
|
|
867
|
+
this.wantOpen = undefined
|
|
868
|
+
this.active = undefined
|
|
869
|
+
const orphan = this.listElement
|
|
870
|
+
if (orphan) setFlag(orphan, "hidden", true)
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
warnAboutMarkup(
|
|
875
|
+
this,
|
|
876
|
+
'found no list: neither a [role="listbox"] nor a ' +
|
|
877
|
+
".lt-suggestion__list. The options cannot be shown, filtered or " +
|
|
878
|
+
"chosen with the keyboard.",
|
|
879
|
+
() => this.querySelector(CONTROL_SELECTOR) !== null && !this.listElement,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
// The number of matches is announced in a status element the server
|
|
883
|
+
// sends. Without it, people who cannot see the screen get no word that the
|
|
884
|
+
// list narrowed, and the web component used to stay silent then.
|
|
885
|
+
warnAboutMarkup(
|
|
886
|
+
this,
|
|
887
|
+
'found no [role="status"], so the number of matches is not ' +
|
|
888
|
+
"announced to people who cannot see the screen. Write an empty " +
|
|
889
|
+
"element with that role, as `lt.suggestion()` does.",
|
|
890
|
+
() =>
|
|
891
|
+
this.querySelector(CONTROL_SELECTOR) !== null &&
|
|
892
|
+
this.listElement !== null &&
|
|
893
|
+
this.querySelector(LT_SUGGESTION_PARTS.status) === null,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
if (control !== this.control) {
|
|
897
|
+
if (this.control) this.warnAboutReplacedControl(this.control, control)
|
|
898
|
+
// The new field has no focus. The open list and the highlight belonged to
|
|
899
|
+
// the old one.
|
|
900
|
+
this.wantOpen = undefined
|
|
901
|
+
this.active = undefined
|
|
902
|
+
// A value field the server filled applies to the text in the field now.
|
|
903
|
+
const valueField = this.valueField
|
|
904
|
+
this.serverChoice =
|
|
905
|
+
valueField && valueField.value !== ""
|
|
906
|
+
? { label: control.value, value: valueField.value }
|
|
907
|
+
: undefined
|
|
908
|
+
this.chosen = this.serverChoice
|
|
909
|
+
this.control?.removeEventListener("input", this.handleInput)
|
|
910
|
+
this.control?.removeEventListener("keydown", this.handleKeydown)
|
|
911
|
+
this.control?.removeEventListener("focus", this.handleFocus)
|
|
912
|
+
control.addEventListener("input", this.handleInput)
|
|
913
|
+
control.addEventListener("keydown", this.handleKeydown)
|
|
914
|
+
control.addEventListener("focus", this.handleFocus)
|
|
915
|
+
this.control = control
|
|
916
|
+
}
|
|
917
|
+
// The form is read every time: `form` on the field can point to another
|
|
918
|
+
// one without anything moving.
|
|
919
|
+
const form = control.form
|
|
920
|
+
if (form !== this.boundForm) {
|
|
921
|
+
this.boundForm?.removeEventListener("reset", this.handleReset)
|
|
922
|
+
form?.addEventListener("reset", this.handleReset)
|
|
923
|
+
this.boundForm = form
|
|
924
|
+
}
|
|
925
|
+
const code = this.valueField?.value ?? ""
|
|
926
|
+
if (
|
|
927
|
+
code !== "" &&
|
|
928
|
+
code !== this.writtenCode &&
|
|
929
|
+
code !== this.chosen?.value
|
|
930
|
+
) {
|
|
931
|
+
// Something other than the web component wrote the code: the server's word.
|
|
932
|
+
this.serverChoice = { label: control.value, value: code }
|
|
933
|
+
this.chosen = this.serverChoice
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/*
|
|
937
|
+
* A press in the list or on the empty message must not take focus from the
|
|
938
|
+
* field, not on the scrollbar either. Otherwise `focusout` closed the list
|
|
939
|
+
* before the choice could happen. Both can be replaced by a patch, so the
|
|
940
|
+
* listeners follow the elements.
|
|
941
|
+
*/
|
|
942
|
+
const list = this.listElement
|
|
943
|
+
if (list !== this.list) {
|
|
944
|
+
this.list?.removeEventListener("mousedown", this.handleListMouseDown)
|
|
945
|
+
list?.addEventListener("mousedown", this.handleListMouseDown)
|
|
946
|
+
this.list = list ?? undefined
|
|
947
|
+
}
|
|
948
|
+
const empty = this.emptyElement
|
|
949
|
+
if (empty !== this.empty) {
|
|
950
|
+
this.empty?.removeEventListener("mousedown", this.handleListMouseDown)
|
|
951
|
+
empty?.addEventListener("mousedown", this.handleListMouseDown)
|
|
952
|
+
this.empty = empty ?? undefined
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// The options are replaced independently of the field: in a Datastar app
|
|
956
|
+
// the server sends a new list while the user types. Had this been behind
|
|
957
|
+
// the check above, the new options would never have got their listener,
|
|
958
|
+
// and choosing with the mouse would have stopped working after the first
|
|
959
|
+
// update.
|
|
960
|
+
for (const option of this.options) {
|
|
961
|
+
if (this.bound.has(option)) continue
|
|
962
|
+
option.addEventListener("mousedown", this.handleOptionMouseDown)
|
|
963
|
+
option.addEventListener("click", this.handleOptionClick)
|
|
964
|
+
this.bound.add(option)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private setOpen(open: boolean): void {
|
|
969
|
+
/*
|
|
970
|
+
* A list without options below an empty field has nothing to show, and is
|
|
971
|
+
* not opened. Without this, focus opened an empty list, and a field waiting
|
|
972
|
+
* for its first search showed "No matches" before the user had typed a
|
|
973
|
+
* character: the empty message follows the list, and the list was open.
|
|
974
|
+
*/
|
|
975
|
+
const show = open && this.hasSomethingToShow()
|
|
976
|
+
this.wantOpen = show
|
|
977
|
+
this.applyOpen(show)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private hasSomethingToShow(): boolean {
|
|
981
|
+
return this.options.length > 0 || (this.control?.value ?? "").trim() !== ""
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** Writes the state into the markup, without changing what the user wanted. */
|
|
985
|
+
private applyOpen(open: boolean): void {
|
|
986
|
+
const list = this.listElement
|
|
987
|
+
if (!list || !this.control) return
|
|
988
|
+
|
|
989
|
+
setFlag(list, "hidden", !open)
|
|
990
|
+
setAttr(this.control, "aria-expanded", String(open))
|
|
991
|
+
this.syncEmpty()
|
|
992
|
+
|
|
993
|
+
if (!open) {
|
|
994
|
+
this.active = undefined
|
|
995
|
+
this.control.removeAttribute("aria-activedescendant")
|
|
996
|
+
for (const option of this.options) {
|
|
997
|
+
setAttr(option, "aria-selected", "false")
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* The empty message follows the list: visible only when the list is open and
|
|
1004
|
+
* empty.
|
|
1005
|
+
*
|
|
1006
|
+
* It is a sibling of the list and not a child, so `hidden` on the list did
|
|
1007
|
+
* not hide it, and "No matches" stayed below a closed field after Escape.
|
|
1008
|
+
* If something else has filtered, it also controls the empty message.
|
|
1009
|
+
*/
|
|
1010
|
+
private syncEmpty(): void {
|
|
1011
|
+
if (this.prefiltered) return
|
|
1012
|
+
const empty = this.emptyElement
|
|
1013
|
+
const list = this.listElement
|
|
1014
|
+
if (!empty || !list) return
|
|
1015
|
+
setFlag(empty, "hidden", Boolean(list.hidden) || this.visible.length > 0)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Hides what does not match, and announces how many are left.
|
|
1020
|
+
*
|
|
1021
|
+
* Runs when the user types or focuses the field, and again after a patch
|
|
1022
|
+
* while the list is open, since the patch may have replaced the options.
|
|
1023
|
+
*/
|
|
1024
|
+
private filter(): void {
|
|
1025
|
+
/*
|
|
1026
|
+
* If something else has filtered, it decides what the list shows, and the
|
|
1027
|
+
* empty message is part of that. The web component therefore touches
|
|
1028
|
+
* nothing here, and the announcement comes from `announceFiltered`
|
|
1029
|
+
* instead.
|
|
1030
|
+
*
|
|
1031
|
+
* Two versions before this one were wrong. The first returned at once, and
|
|
1032
|
+
* then a Datastar app had no announcement of the number of matches at all.
|
|
1033
|
+
* The second counted here: then an empty field announced "No matches"
|
|
1034
|
+
* already on focus, before the user had typed a character, and an
|
|
1035
|
+
* asynchronous search announced it again on every keystroke while the
|
|
1036
|
+
* answer was still on its way. The web component also made the empty
|
|
1037
|
+
* message visible again after the app had hidden it, and there was no way
|
|
1038
|
+
* around it.
|
|
1039
|
+
*/
|
|
1040
|
+
if (this.prefiltered) return
|
|
1041
|
+
|
|
1042
|
+
const query = (this.control?.value ?? "").trim().toLowerCase()
|
|
1043
|
+
for (const option of this.options) {
|
|
1044
|
+
const label = (option.textContent ?? "").trim().toLowerCase()
|
|
1045
|
+
setFlag(option, "hidden", query !== "" && !label.includes(query))
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
this.dropHiddenMark()
|
|
1049
|
+
this.syncEmpty()
|
|
1050
|
+
this.announce(this.visible.length)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Removes the highlight from an option the filtering just hid.
|
|
1055
|
+
*
|
|
1056
|
+
* The cleanup belongs here, where the hiding happens, and not only in
|
|
1057
|
+
* `repair()`: that returns at once under `server-controlled`, and then
|
|
1058
|
+
* `aria-activedescendant` pointed to a hidden option, which the screen
|
|
1059
|
+
* reader announced anyway.
|
|
1060
|
+
*/
|
|
1061
|
+
private dropHiddenMark(): void {
|
|
1062
|
+
const control = this.control
|
|
1063
|
+
if (!control) return
|
|
1064
|
+
|
|
1065
|
+
const marked = this.options.find(
|
|
1066
|
+
(option) => option.getAttribute("aria-selected") === "true",
|
|
1067
|
+
)
|
|
1068
|
+
if (marked?.hidden) {
|
|
1069
|
+
setAttr(marked, "aria-selected", "false")
|
|
1070
|
+
if (this.active?.id === marked.id) this.active = undefined
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const pointed = control.getAttribute("aria-activedescendant")
|
|
1074
|
+
if (pointed) {
|
|
1075
|
+
const target = this.options.find((option) => option.id === pointed)
|
|
1076
|
+
if (!target || target.hidden) {
|
|
1077
|
+
control.removeAttribute("aria-activedescendant")
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Announces the count when something else has filtered.
|
|
1084
|
+
*
|
|
1085
|
+
* The list then changes not from a keystroke but from the app rendering
|
|
1086
|
+
* again, and that is exactly the change the observer sees. The message
|
|
1087
|
+
* belongs here and not in `filter()`, because half a second over the
|
|
1088
|
+
* network can pass between the keystroke and the new answer.
|
|
1089
|
+
*
|
|
1090
|
+
* Only changes are announced. The first count is the starting point and not
|
|
1091
|
+
* news, and it is often 0 because the app has not rendered the list yet.
|
|
1092
|
+
*/
|
|
1093
|
+
private announceFiltered(): void {
|
|
1094
|
+
if (!this.prefiltered) return
|
|
1095
|
+
|
|
1096
|
+
const hits = this.visible.length
|
|
1097
|
+
if (hits === this.announcedCount) return
|
|
1098
|
+
|
|
1099
|
+
const first = this.announcedCount === undefined
|
|
1100
|
+
this.announcedCount = hits
|
|
1101
|
+
if (!first) this.announce(hits)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Announces the number of matches.
|
|
1106
|
+
*
|
|
1107
|
+
* Without this, people who cannot see the screen get no word that the list
|
|
1108
|
+
* narrowed while they typed. The text is generated on the client, and
|
|
1109
|
+
* `repair()` writes it back when an update empties the element. If the
|
|
1110
|
+
* element is missing, `bind()` has already reported it.
|
|
1111
|
+
*/
|
|
1112
|
+
private announce(hits: number): void {
|
|
1113
|
+
const status = this.querySelector<HTMLElement>(LT_SUGGESTION_PARTS.status)
|
|
1114
|
+
if (!status) return
|
|
1115
|
+
|
|
1116
|
+
this.announcedText = this.countText(hits)
|
|
1117
|
+
setText(status, this.announcedText)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The text for a count, in the page's language.
|
|
1122
|
+
*
|
|
1123
|
+
* `count-none` applies to zero matches, since "No matches" is a sentence of
|
|
1124
|
+
* its own and not an inflection. Otherwise `Intl.PluralRules` picks the form
|
|
1125
|
+
* the language requires, and `count-<form>` gives the text, with `{n}` for
|
|
1126
|
+
* the number. If the form is missing, `count-other` applies. If that is
|
|
1127
|
+
* missing too, the default text from `DEFAULT_TEXTS` applies.
|
|
1128
|
+
*/
|
|
1129
|
+
private countText(hits: number): string {
|
|
1130
|
+
const lang = languageOf(this)
|
|
1131
|
+
const written = (name: string) => this.getAttribute(name)
|
|
1132
|
+
const form = new Intl.PluralRules(lang).select(hits)
|
|
1133
|
+
const template =
|
|
1134
|
+
(hits === 0 ? written("count-none") : null) ??
|
|
1135
|
+
written(`count-${form}`) ??
|
|
1136
|
+
written("count-other")
|
|
1137
|
+
const number = new Intl.NumberFormat(lang).format(hits)
|
|
1138
|
+
if (template !== null) return template.replaceAll("{n}", number)
|
|
1139
|
+
const fallback =
|
|
1140
|
+
hits === 0
|
|
1141
|
+
? DEFAULT_TEXTS.suggestionNone
|
|
1142
|
+
: hits === 1
|
|
1143
|
+
? DEFAULT_TEXTS.suggestionOne
|
|
1144
|
+
: DEFAULT_TEXTS.suggestionOther
|
|
1145
|
+
return fallback.replaceAll("{n}", number)
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
private markActive(index: number): void {
|
|
1149
|
+
const shown = this.selectable
|
|
1150
|
+
if (shown.length === 0 || !this.control) return
|
|
1151
|
+
|
|
1152
|
+
const next = (index + shown.length) % shown.length
|
|
1153
|
+
|
|
1154
|
+
this.markOption(shown[next])
|
|
1155
|
+
shown[next].scrollIntoView({ block: "nearest" })
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Highlights one option, and remembers which. */
|
|
1159
|
+
private markOption(option: HTMLElement): void {
|
|
1160
|
+
if (!this.control) return
|
|
1161
|
+
|
|
1162
|
+
for (const other of this.options) {
|
|
1163
|
+
setAttr(other, "aria-selected", other === option ? "true" : "false")
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
this.active = {
|
|
1167
|
+
id: option.id,
|
|
1168
|
+
label: (option.textContent ?? "").trim(),
|
|
1169
|
+
value: option.getAttribute("data-value"),
|
|
1170
|
+
}
|
|
1171
|
+
// aria-activedescendant moves the screen reader's reading position without
|
|
1172
|
+
// focus leaving the field. Without it the option is never announced.
|
|
1173
|
+
setAttr(this.control, "aria-activedescendant", option.id)
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
private choose(option: HTMLElement): void {
|
|
1177
|
+
if (!this.control || this.locked) return
|
|
1178
|
+
if (option.getAttribute("aria-disabled") === "true") return
|
|
1179
|
+
|
|
1180
|
+
const label = (option.textContent ?? "").trim()
|
|
1181
|
+
const value = option.getAttribute("data-value") ?? label
|
|
1182
|
+
/*
|
|
1183
|
+
* The setter from the prototype chain, past a `value` React has put on the
|
|
1184
|
+
* node itself to track it. Set directly on the node, React saw no change,
|
|
1185
|
+
* `onChange` never came, and on the next render React wrote the old text
|
|
1186
|
+
* back into the field. The chain and not just the nearest prototype, so
|
|
1187
|
+
* `<textarea>` and a subclass of `<input>` work too.
|
|
1188
|
+
*/
|
|
1189
|
+
let setter: ((v: string) => void) | undefined
|
|
1190
|
+
for (
|
|
1191
|
+
let proto = Object.getPrototypeOf(this.control);
|
|
1192
|
+
proto && !setter;
|
|
1193
|
+
proto = Object.getPrototypeOf(proto)
|
|
1194
|
+
) {
|
|
1195
|
+
setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
|
|
1196
|
+
}
|
|
1197
|
+
// The field shows the text. The code goes to the value field, when the
|
|
1198
|
+
// server has written one, and in the event. `data-value` used to end up in
|
|
1199
|
+
// the field, and the user who chose Bergen saw "4601".
|
|
1200
|
+
if (setter) setter.call(this.control, label)
|
|
1201
|
+
else this.control.value = label
|
|
1202
|
+
|
|
1203
|
+
const valueField = this.valueField
|
|
1204
|
+
if (valueField) {
|
|
1205
|
+
this.writeCode(valueField, value)
|
|
1206
|
+
this.chosen = { label, value }
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// The flag must be set before the events are dispatched. `input` is the
|
|
1210
|
+
// same event the web component listens to, so without this the list opens
|
|
1211
|
+
// again the moment the user has chosen something.
|
|
1212
|
+
this.choosing = true
|
|
1213
|
+
this.control.dispatchEvent(new Event("input", { bubbles: true }))
|
|
1214
|
+
this.control.dispatchEvent(new Event("change", { bubbles: true }))
|
|
1215
|
+
this.choosing = false
|
|
1216
|
+
|
|
1217
|
+
this.setOpen(false)
|
|
1218
|
+
this.dispatchEvent(
|
|
1219
|
+
new CustomEvent("suggestion-select", {
|
|
1220
|
+
detail: { value, label },
|
|
1221
|
+
bubbles: true,
|
|
1222
|
+
composed: true,
|
|
1223
|
+
}),
|
|
1224
|
+
)
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Writes the code in the value field and dispatches `input` and `change` on
|
|
1229
|
+
* it, so `data-bind` in Datastar sees the code. The events do not bubble: a
|
|
1230
|
+
* `data-on:input` on the host, which the recipe uses to fetch suggestions,
|
|
1231
|
+
* must not trigger a search for the code every time the user chooses.
|
|
1232
|
+
*/
|
|
1233
|
+
private writeCode(valueField: HTMLInputElement, code: string): void {
|
|
1234
|
+
if (valueField.value === code) return
|
|
1235
|
+
this.writtenCode = code
|
|
1236
|
+
setAttr(valueField, "value", code)
|
|
1237
|
+
// A hidden field reads the attribute. If the field has another type and a
|
|
1238
|
+
// morph has already set the property, the attribute no longer counts.
|
|
1239
|
+
if (valueField.value !== code) valueField.value = code
|
|
1240
|
+
valueField.dispatchEvent(new Event("input"))
|
|
1241
|
+
valueField.dispatchEvent(new Event("change"))
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* The form is reset. The browser sets the text back to the server's after
|
|
1246
|
+
* the event has been dispatched, so the code is set in the next turn, after
|
|
1247
|
+
* the reset is done. The text there then decides: if it is the server's,
|
|
1248
|
+
* the server's code applies; if it is the chosen one, the code stays;
|
|
1249
|
+
* otherwise the code of the option with that text, otherwise none. The
|
|
1250
|
+
* server's comes first because two options can have the same name: if the
|
|
1251
|
+
* user chose the second Våler, the reset goes back to the server's. The
|
|
1252
|
+
* server may also have sent the chosen one back, and then it is as much the
|
|
1253
|
+
* server's word as the user's.
|
|
1254
|
+
*/
|
|
1255
|
+
private handleReset = (): void => {
|
|
1256
|
+
setTimeout(() => {
|
|
1257
|
+
const valueField = this.valueField
|
|
1258
|
+
if (!valueField || !this.control) return
|
|
1259
|
+
const text = this.control.value
|
|
1260
|
+
const option = this.options.find(
|
|
1261
|
+
(o) => (o.textContent ?? "").trim() === text,
|
|
1262
|
+
)
|
|
1263
|
+
const code =
|
|
1264
|
+
this.serverChoice && text === this.serverChoice.label
|
|
1265
|
+
? this.serverChoice.value
|
|
1266
|
+
: this.chosen && text === this.chosen.label
|
|
1267
|
+
? this.chosen.value
|
|
1268
|
+
: option
|
|
1269
|
+
? (option.getAttribute("data-value") ?? text)
|
|
1270
|
+
: ""
|
|
1271
|
+
this.writeCode(valueField, code)
|
|
1272
|
+
this.chosen = code !== "" ? { label: text, value: code } : undefined
|
|
1273
|
+
}, 0)
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
private handleInput = (): void => {
|
|
1277
|
+
if (this.choosing) return
|
|
1278
|
+
// The user changed the text after a choice. The code no longer applies.
|
|
1279
|
+
// If the user types the chosen text again, after an update emptied both,
|
|
1280
|
+
// the code comes back at once.
|
|
1281
|
+
const valueField = this.valueField
|
|
1282
|
+
if (valueField && this.chosen) {
|
|
1283
|
+
if (this.control?.value !== this.chosen.label) {
|
|
1284
|
+
this.writeCode(valueField, "")
|
|
1285
|
+
this.chosen = undefined
|
|
1286
|
+
} else if (valueField.value === "") {
|
|
1287
|
+
this.writeCode(valueField, this.chosen.value)
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (this.locked) return
|
|
1291
|
+
this.filter()
|
|
1292
|
+
this.setOpen(true)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private handleFocus = (): void => {
|
|
1296
|
+
if (this.locked) return
|
|
1297
|
+
this.openFiltered()
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Opens the list, and filters and announces only when it opens. Filtering
|
|
1302
|
+
* first announced "No matches" for a list that `setOpen` then kept closed,
|
|
1303
|
+
* since there was nothing to show.
|
|
1304
|
+
*/
|
|
1305
|
+
private openFiltered(): void {
|
|
1306
|
+
this.setOpen(true)
|
|
1307
|
+
if (this.wantOpen) this.filter()
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
private handleFocusOut = (event: FocusEvent): void => {
|
|
1311
|
+
// If focus goes to something inside the web component, such as a button
|
|
1312
|
+
// next to the field, the list stays.
|
|
1313
|
+
const next = event.relatedTarget
|
|
1314
|
+
if (next instanceof Node && this.contains(next)) return
|
|
1315
|
+
this.setOpen(false)
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
private handleKeydown = (event: KeyboardEvent): void => {
|
|
1319
|
+
// During IME composition the keys belong to the composition. Enter
|
|
1320
|
+
// confirms the characters, and must not choose the highlighted option.
|
|
1321
|
+
if (event.isComposing || event.keyCode === 229) return
|
|
1322
|
+
if (this.locked) return
|
|
1323
|
+
const open = this.listElement?.hidden === false
|
|
1324
|
+
|
|
1325
|
+
if (event.key === "ArrowDown") {
|
|
1326
|
+
event.preventDefault()
|
|
1327
|
+
if (!open) this.openFiltered()
|
|
1328
|
+
this.markActive(this.activeIndex + 1)
|
|
1329
|
+
} else if (event.key === "ArrowUp") {
|
|
1330
|
+
event.preventDefault()
|
|
1331
|
+
// The list must open first, as with arrow down. Without that
|
|
1332
|
+
// `aria-activedescendant` pointed to an option in a list the field at the
|
|
1333
|
+
// same time reported as closed, and the screen reader announced something
|
|
1334
|
+
// that was not on screen.
|
|
1335
|
+
if (!open) this.openFiltered()
|
|
1336
|
+
// If nothing is highlighted, arrow up goes to the last one.
|
|
1337
|
+
// `activeIndex - 1` would have given the second to last, since nothing
|
|
1338
|
+
// highlighted is -1.
|
|
1339
|
+
this.markActive(this.activeIndex < 0 ? -1 : this.activeIndex - 1)
|
|
1340
|
+
} else if (
|
|
1341
|
+
(event.key === "PageUp" || event.key === "PageDown") &&
|
|
1342
|
+
open &&
|
|
1343
|
+
this.selectable.length > 0
|
|
1344
|
+
) {
|
|
1345
|
+
// Home and End move the caret in the text, and are left alone. PgUp and
|
|
1346
|
+
// PgDn go to the first and last option.
|
|
1347
|
+
event.preventDefault()
|
|
1348
|
+
this.markActive(event.key === "PageUp" ? 0 : this.selectable.length - 1)
|
|
1349
|
+
} else if (event.key === "Enter" && open && this.activeIndex >= 0) {
|
|
1350
|
+
event.preventDefault()
|
|
1351
|
+
const chosen = this.selectable[this.activeIndex]
|
|
1352
|
+
if (chosen) this.choose(chosen)
|
|
1353
|
+
} else if (event.key === "Escape" && open) {
|
|
1354
|
+
event.preventDefault()
|
|
1355
|
+
this.setOpen(false)
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
private handleListMouseDown = (event: Event): void => {
|
|
1360
|
+
event.preventDefault()
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
private handleOptionMouseDown = (event: Event): void => {
|
|
1364
|
+
// Keeps focus in the field. Without this the field lost focus before
|
|
1365
|
+
// `click`, and `focusout` closed the list before the choice could happen.
|
|
1366
|
+
event.preventDefault()
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
private handleOptionClick = (event: Event): void => {
|
|
1370
|
+
// The choice happens on `click`, which assistive technology also sends
|
|
1371
|
+
// when it activates an element. It used to happen on `mousedown`, and a
|
|
1372
|
+
// `click` alone chose nothing.
|
|
1373
|
+
this.choose(event.currentTarget as HTMLElement)
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
export function defineLtSuggestion(tagName: string = LT_SUGGESTION_TAG): void {
|
|
1378
|
+
defineElement(tagName, LtSuggestion)
|
|
1379
|
+
}
|